kimun_notes/components/autocomplete/
popup.rs1use ratatui::Frame;
2use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3use ratatui::layout::Rect;
4use ratatui::style::{Modifier, Style};
5use ratatui::text::{Line, Span};
6use ratatui::widgets::{Block, Borders, Clear, List, ListItem};
7use unicode_width::UnicodeWidthStr;
8
9use crate::settings::themes::Theme;
10
11use super::state::AutocompleteState;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum PopupOutcome {
16 Consumed(PopupAction),
17 NotHandled,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum PopupAction {
23 None,
25 Accept,
28 Dismiss,
30}
31
32pub fn handle_key(state: &mut AutocompleteState, key: KeyEvent) -> PopupOutcome {
38 const SYSTEM_MODS: KeyModifiers = KeyModifiers::CONTROL
43 .union(KeyModifiers::ALT)
44 .union(KeyModifiers::SUPER)
45 .union(KeyModifiers::META);
46 if key.modifiers.intersects(SYSTEM_MODS) {
47 return PopupOutcome::NotHandled;
48 }
49 match key.code {
50 KeyCode::Down => {
51 state.move_highlight_down();
52 PopupOutcome::Consumed(PopupAction::None)
53 }
54 KeyCode::Up => {
55 state.move_highlight_up();
56 PopupOutcome::Consumed(PopupAction::None)
57 }
58 KeyCode::PageDown => {
59 state.page_down();
60 PopupOutcome::Consumed(PopupAction::None)
61 }
62 KeyCode::PageUp => {
63 state.page_up();
64 PopupOutcome::Consumed(PopupAction::None)
65 }
66 KeyCode::Home => {
67 state.home();
68 PopupOutcome::Consumed(PopupAction::None)
69 }
70 KeyCode::End => {
71 state.end();
72 PopupOutcome::Consumed(PopupAction::None)
73 }
74 KeyCode::Tab | KeyCode::Enter => {
75 if state.selected().is_some() {
76 PopupOutcome::Consumed(PopupAction::Accept)
77 } else {
78 PopupOutcome::NotHandled
80 }
81 }
82 KeyCode::Esc => PopupOutcome::Consumed(PopupAction::Dismiss),
83 _ => PopupOutcome::NotHandled,
84 }
85}
86
87pub fn render(frame: &mut Frame, state: &AutocompleteState, screen: Rect, theme: &Theme) {
95 if state.items.is_empty() {
96 return;
97 }
98
99 const MAX_WIDTH: u16 = 60;
100 const BORDERS: u16 = 2;
101
102 if screen.width < BORDERS + 1 || screen.height < BORDERS + 1 {
106 return;
107 }
108
109 let (start, end) = state.visible_window();
110 let visible_rows = (end - start) as u16;
111 if visible_rows == 0 {
112 return;
113 }
114
115 let content_width = visible_content_width(state, start, end);
116 let desired_width = (content_width as u16)
117 .saturating_add(BORDERS)
118 .min(MAX_WIDTH);
119 let popup_width = desired_width.min(screen.width);
120 let popup_height = visible_rows.saturating_add(BORDERS).min(screen.height);
121
122 let (anchor_col, anchor_row) = state.anchor;
123 let screen_right = screen.x.saturating_add(screen.width);
124 let popup_x = if anchor_col.saturating_add(popup_width) > screen_right {
125 screen_right.saturating_sub(popup_width)
126 } else {
127 anchor_col.max(screen.x)
128 };
129
130 let screen_bottom = screen.y.saturating_add(screen.height);
132 let space_below = screen_bottom.saturating_sub(anchor_row.saturating_add(1));
133 let popup_y = if space_below >= popup_height {
134 anchor_row.saturating_add(1)
135 } else if anchor_row >= popup_height.saturating_add(screen.y) {
136 anchor_row.saturating_sub(popup_height)
137 } else {
138 screen_bottom.saturating_sub(popup_height).max(screen.y)
141 };
142
143 let area = Rect {
144 x: popup_x,
145 y: popup_y,
146 width: popup_width,
147 height: popup_height,
148 };
149
150 frame.render_widget(Clear, area);
151
152 let title = format!(" {} ", visible_title(state));
153 let block = Block::default()
154 .title(title)
155 .borders(Borders::ALL)
156 .border_style(Style::default().fg(theme.focus_border.to_ratatui()))
157 .style(theme.panel_style());
158 let inner = block.inner(area);
159 frame.render_widget(block, area);
160
161 let inner_width = inner.width as usize;
162
163 let items: Vec<ListItem> = (start..end)
164 .map(|idx| build_row(state, idx, inner_width, theme))
165 .collect();
166 let list = List::new(items);
167 frame.render_widget(list, inner);
168
169 if state.has_more_above() {
173 render_overflow_marker(frame, area, '▲', true, theme, state.hidden_above());
174 }
175 if state.has_more_below() {
176 render_overflow_marker(frame, area, '▼', false, theme, state.hidden_below());
177 }
178}
179
180fn visible_title(state: &AutocompleteState) -> String {
181 let sigil = match state.kind {
182 super::TriggerKind::Wikilink => "[[".to_string(),
183 super::TriggerKind::Hashtag => "#".to_string(),
184 super::TriggerKind::LinkFilter => state
187 .opener
188 .map(|c| c.to_string())
189 .unwrap_or_else(|| ">".to_string()),
190 super::TriggerKind::SavedSearch => "?".to_string(),
191 };
192 if state.query.is_empty() {
193 sigil
194 } else {
195 format!("{}{}", sigil, state.query)
196 }
197}
198
199fn visible_content_width(state: &AutocompleteState, start: usize, end: usize) -> usize {
200 let mut widest = visible_title(state).width();
203 for item in &state.items[start..end] {
204 let primary = item.display.width();
205 let secondary = item
206 .secondary
207 .as_deref()
208 .map(|s| s.width() + 2)
209 .unwrap_or(0);
210 widest = widest.max(primary + secondary);
211 }
212 widest
213}
214
215fn build_row<'a>(
216 state: &'a AutocompleteState,
217 idx: usize,
218 inner_width: usize,
219 theme: &Theme,
220) -> ListItem<'a> {
221 let item = &state.items[idx];
222 let is_highlighted = idx == state.highlighted;
223
224 let row_style = if is_highlighted {
225 Style::default()
226 .bg(theme.selection_bg.to_ratatui())
227 .fg(theme.selection_fg.to_ratatui())
228 .add_modifier(Modifier::BOLD)
229 } else {
230 Style::default().fg(theme.fg.to_ratatui())
231 };
232 let secondary_style = Style::default()
233 .fg(theme.gray.to_ratatui())
234 .add_modifier(Modifier::DIM);
235
236 let primary_len = item.display.width();
237 let secondary_text = item.secondary.as_deref().unwrap_or("");
238 let secondary_len = secondary_text.width();
239 let separator = if secondary_text.is_empty() { 0 } else { 1 };
240
241 let total = primary_len + separator + secondary_len;
242 let pad = inner_width.saturating_sub(total);
243
244 let mut spans = vec![Span::styled(item.display.clone(), row_style)];
245 if separator > 0 {
246 spans.push(Span::styled(" ".repeat(pad + separator), row_style));
247 spans.push(Span::styled(secondary_text.to_string(), secondary_style));
248 } else if pad > 0 {
249 spans.push(Span::styled(" ".repeat(pad), row_style));
250 }
251 ListItem::new(Line::from(spans))
252}
253
254fn render_overflow_marker(
255 frame: &mut Frame,
256 area: Rect,
257 glyph: char,
258 on_top: bool,
259 theme: &Theme,
260 hidden_count: usize,
261) {
262 if area.width < 3 {
263 return;
264 }
265 let y = if on_top {
266 area.y
267 } else {
268 area.y + area.height - 1
269 };
270 let label = format!(" {} {} more ", glyph, hidden_count);
271 let label_chars: Vec<char> = label.chars().collect();
272 let label_width = label_chars.len() as u16;
273 let max_label = area.width.saturating_sub(2);
274 let label_width = label_width.min(max_label);
275 let x = area.x + area.width - 1 - label_width;
276 let marker = ratatui::widgets::Paragraph::new(
277 label_chars
278 .into_iter()
279 .take(label_width as usize)
280 .collect::<String>(),
281 )
282 .style(
283 Style::default()
284 .fg(theme.fg_secondary.to_ratatui())
285 .add_modifier(Modifier::DIM),
286 );
287 let marker_area = Rect {
288 x,
289 y,
290 width: label_width,
291 height: 1,
292 };
293 frame.render_widget(marker, marker_area);
294}
295
296#[cfg(test)]
297mod tests {
298 use super::super::TriggerKind;
299 use super::super::state::Suggestion;
300 use super::*;
301 use ratatui::Terminal;
302 use ratatui::backend::TestBackend;
303 use ratatui::crossterm::event::KeyCode;
304
305 fn sample_state(n: usize) -> AutocompleteState {
306 let mut st = AutocompleteState::new(TriggerKind::Hashtag, (0, 0));
307 st.set_items(
308 (0..n)
309 .map(|i| Suggestion {
310 display: format!("tag{i}"),
311 secondary: Some(format!("{i}")),
312 })
313 .collect(),
314 );
315 st
316 }
317
318 fn key(code: KeyCode) -> KeyEvent {
319 KeyEvent::new(code, KeyModifiers::NONE)
320 }
321
322 #[test]
323 fn down_navigates_and_is_consumed() {
324 let mut st = sample_state(5);
325 let out = handle_key(&mut st, key(KeyCode::Down));
326 assert_eq!(out, PopupOutcome::Consumed(PopupAction::None));
327 assert_eq!(st.highlighted, 1);
328 }
329
330 #[test]
331 fn tab_accepts_when_list_nonempty() {
332 let mut st = sample_state(5);
333 let out = handle_key(&mut st, key(KeyCode::Tab));
334 assert_eq!(out, PopupOutcome::Consumed(PopupAction::Accept));
335 }
336
337 #[test]
338 fn enter_accepts_when_list_nonempty() {
339 let mut st = sample_state(5);
340 let out = handle_key(&mut st, key(KeyCode::Enter));
341 assert_eq!(out, PopupOutcome::Consumed(PopupAction::Accept));
342 }
343
344 #[test]
345 fn esc_dismisses() {
346 let mut st = sample_state(5);
347 let out = handle_key(&mut st, key(KeyCode::Esc));
348 assert_eq!(out, PopupOutcome::Consumed(PopupAction::Dismiss));
349 }
350
351 #[test]
352 fn tab_with_empty_list_falls_through() {
353 let mut st = sample_state(0);
354 let out = handle_key(&mut st, key(KeyCode::Tab));
355 assert_eq!(out, PopupOutcome::NotHandled);
356 }
357
358 #[test]
359 fn typing_letter_is_not_handled() {
360 let mut st = sample_state(5);
361 let out = handle_key(&mut st, key(KeyCode::Char('x')));
362 assert_eq!(out, PopupOutcome::NotHandled);
363 }
364
365 #[test]
366 fn ctrl_modifier_falls_through() {
367 let mut st = sample_state(5);
368 let key = KeyEvent::new(KeyCode::Down, KeyModifiers::CONTROL);
369 assert_eq!(handle_key(&mut st, key), PopupOutcome::NotHandled);
370 }
371
372 #[test]
373 fn page_down_jumps() {
374 let mut st = sample_state(30);
375 handle_key(&mut st, key(KeyCode::PageDown));
376 assert_eq!(st.highlighted, 8);
377 }
378
379 #[test]
380 fn end_jumps_to_last() {
381 let mut st = sample_state(30);
382 handle_key(&mut st, key(KeyCode::End));
383 assert_eq!(st.highlighted, 29);
384 }
385
386 fn draw(state: &AutocompleteState, area: Rect) -> Terminal<TestBackend> {
389 let theme = Theme::gruvbox_dark();
390 let backend = TestBackend::new(area.width.max(40), area.height.max(20));
391 let mut terminal = Terminal::new(backend).unwrap();
392 terminal
393 .draw(|f| {
394 render(f, state, f.area(), &theme);
395 })
396 .unwrap();
397 terminal
398 }
399
400 #[test]
401 fn render_does_not_panic_with_few_items() {
402 let mut st = sample_state(3);
403 st.anchor = (5, 5);
404 draw(&st, Rect::new(0, 0, 80, 24));
405 }
406
407 #[test]
408 fn render_caps_height_at_max_visible_rows() {
409 let mut st = sample_state(30);
410 st.anchor = (5, 5);
411 st.max_visible_rows = 8;
412 draw(&st, Rect::new(0, 0, 80, 24));
413 assert_eq!(st.visible_window(), (0, 8));
418 }
419
420 #[test]
421 fn render_flips_above_when_no_room_below() {
422 let mut st = sample_state(5);
423 st.anchor = (0, 9);
425 draw(&st, Rect::new(0, 0, 40, 10));
426 }
429
430 #[test]
433 fn title_uses_link_filter_opener_char() {
434 for opener in ['<', '>', '='] {
437 let mut st = AutocompleteState::new(TriggerKind::LinkFilter, (0, 0));
438 st.opener = Some(opener);
439 st.query = "work".to_string();
440 assert_eq!(visible_title(&st), format!("{opener}work"));
441
442 st.query.clear();
443 assert_eq!(visible_title(&st), opener.to_string());
444 }
445 }
446
447 #[test]
448 fn title_falls_back_when_link_filter_opener_missing() {
449 let mut st = AutocompleteState::new(TriggerKind::LinkFilter, (0, 0));
450 st.opener = None;
451 assert_eq!(visible_title(&st), ">");
452 }
453
454 #[test]
455 fn title_uses_fixed_sigils_for_hashtag_and_wikilink() {
456 let mut st = AutocompleteState::new(TriggerKind::Hashtag, (0, 0));
457 st.query = "tag".to_string();
458 assert_eq!(visible_title(&st), "#tag");
459
460 let mut st = AutocompleteState::new(TriggerKind::Wikilink, (0, 0));
461 st.query = "note".to_string();
462 assert_eq!(visible_title(&st), "[[note");
463 }
464
465 #[test]
466 fn render_empty_state_is_noop() {
467 let st = sample_state(0);
468 let theme = Theme::gruvbox_dark();
469 let backend = TestBackend::new(40, 10);
470 let mut terminal = Terminal::new(backend).unwrap();
471 terminal
473 .draw(|f| {
474 render(f, &st, f.area(), &theme);
475 })
476 .unwrap();
477 }
478}