kimun_notes/components/
query_highlight.rs1use kimun_core::QueryTokenClass;
11use ratatui::style::{Modifier, Style};
12use ratatui::text::{Line, Span};
13
14use crate::settings::themes::Theme;
15
16fn class_style(class: QueryTokenClass, theme: &Theme) -> Style {
20 match class {
21 QueryTokenClass::Negation => Style::default().fg(theme.red.to_ratatui()),
22 QueryTokenClass::FieldKey => Style::default().fg(theme.yellow.to_ratatui()),
23 QueryTokenClass::LinkValue => Style::default().fg(theme.blue.to_ratatui()),
24 QueryTokenClass::TagValue => Style::default().fg(theme.aqua.to_ratatui()),
25 QueryTokenClass::Quoted => Style::default().fg(theme.green.to_ratatui()),
26 QueryTokenClass::Date | QueryTokenClass::Number => {
27 Style::default().fg(theme.purple.to_ratatui())
28 }
29 QueryTokenClass::Term => Style::default().fg(theme.fg.to_ratatui()),
30 QueryTokenClass::Unterminated => Style::default()
31 .fg(theme.red.to_ratatui())
32 .add_modifier(Modifier::UNDERLINED),
33 }
34}
35
36pub fn highlight_line(query: &str, theme: &Theme, base: Style) -> Line<'static> {
39 if let Some(rest) = query.strip_prefix('?') {
42 let mut spans = vec![Span::styled(
43 "?".to_string(),
44 base.patch(Style::default().fg(theme.gray.to_ratatui())),
45 )];
46 spans.push(Span::styled(
47 rest.to_string(),
48 base.patch(Style::default().fg(theme.aqua.to_ratatui())),
49 ));
50 return Line::from(spans);
51 }
52
53 let token_spans = kimun_core::query_token_spans(query);
54 let mut spans: Vec<Span<'static>> = Vec::with_capacity(token_spans.len() * 2 + 1);
55 let mut pos = 0usize;
56 for ts in token_spans {
57 if ts.range.start > pos {
58 spans.push(Span::styled(query[pos..ts.range.start].to_string(), base));
59 }
60 let text = &query[ts.range.clone()];
61 let style = base.patch(class_style(ts.class, theme));
62 push_with_variables(&mut spans, text, style, base, theme);
65 pos = ts.range.end;
66 }
67 if pos < query.len() {
68 spans.push(Span::styled(query[pos..].to_string(), base));
69 }
70 if spans.is_empty() {
71 spans.push(Span::styled(String::new(), base));
72 }
73 Line::from(spans)
74}
75
76fn push_with_variables(
79 spans: &mut Vec<Span<'static>>,
80 text: &str,
81 style: Style,
82 base: Style,
83 theme: &Theme,
84) {
85 let var_style = base.patch(
86 Style::default()
87 .fg(theme.accent.to_ratatui())
88 .add_modifier(Modifier::ITALIC),
89 );
90 let mut rest = text;
91 while let Some(open) = rest.find('{') {
92 if let Some(close_rel) = rest[open..].find('}') {
93 let close = open + close_rel + 1;
94 if open > 0 {
95 spans.push(Span::styled(rest[..open].to_string(), style));
96 }
97 spans.push(Span::styled(rest[open..close].to_string(), var_style));
98 rest = &rest[close..];
99 } else {
100 break;
101 }
102 }
103 if !rest.is_empty() {
104 spans.push(Span::styled(rest.to_string(), style));
105 }
106}
107
108pub fn emphasis_needles(query: &str) -> Vec<String> {
113 let terms = kimun_core::SearchTerms::from_query_string(query);
114 let mut needles: Vec<String> = terms
115 .terms
116 .iter()
117 .map(|t| t.to_lowercase())
118 .chain(
119 terms
120 .labels
121 .iter()
122 .map(|l| format!("#{}", l.to_lowercase())),
123 )
124 .chain(terms.links.iter().map(|l| l.to_lowercase()))
125 .chain(terms.forward_links.iter().map(|l| l.to_lowercase()))
126 .filter(|t| !t.is_empty() && !t.contains('{'))
127 .collect();
128 needles.sort_unstable();
129 needles.dedup();
130 needles
131}
132
133pub fn error_reason(query: &str) -> Option<&'static str> {
137 kimun_core::query_has_unterminated_quote(query).then_some("unterminated quote")
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 fn texts(line: &Line) -> Vec<String> {
145 line.spans.iter().map(|s| s.content.to_string()).collect()
146 }
147
148 #[test]
149 fn highlights_round_trip_the_text() {
150 let theme = Theme::default();
151 let q = r#"-#wip <{note} "two words" 2026-04-01 plain"#;
152 let line = highlight_line(q, &theme, Style::default());
153 assert_eq!(texts(&line).concat(), q);
154 }
155
156 #[test]
157 fn variable_is_styled_inside_value() {
158 let theme = Theme::default();
159 let line = highlight_line("<{note}", &theme, Style::default());
160 let texts = texts(&line);
161 assert!(texts.contains(&"{note}".to_string()), "got {texts:?}");
162 }
163
164 #[test]
165 fn saved_search_sigil_styles_whole_input() {
166 let theme = Theme::default();
167 let line = highlight_line("?todo", &theme, Style::default());
168 assert_eq!(texts(&line), vec!["?".to_string(), "todo".to_string()]);
169 }
170
171 #[test]
172 fn error_reason_only_for_unterminated() {
173 assert_eq!(error_reason(r#"a "open"#), Some("unterminated quote"));
174 assert_eq!(error_reason(r#"a "closed""#), None);
175 }
176}