Skip to main content

kimun_notes/components/autocomplete/
popup.rs

1use 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/// Result of forwarding a key event to the popup.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum PopupOutcome {
16    Consumed(PopupAction),
17    NotHandled,
18}
19
20/// What the host should do after a `Consumed` outcome.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum PopupAction {
23    /// Internal navigation — no buffer change.
24    None,
25    /// User accepted the highlighted suggestion. Host pulls
26    /// `state.selected()` and writes the replacement.
27    Accept,
28    /// User pressed Esc — close popup without changing the buffer.
29    Dismiss,
30}
31
32/// Routes a key event through the popup's input model. Navigation keys are
33/// consumed (Up/Down, PageUp/PageDown, Home/End). Tab and Enter accept;
34/// Esc dismisses. Everything else returns `NotHandled` so the host can
35/// process it as normal typing — the host then recomputes the trigger
36/// context, which may close or refresh the popup naturally.
37pub fn handle_key(state: &mut AutocompleteState, key: KeyEvent) -> PopupOutcome {
38    // System-modifier combos (Ctrl, Alt, Cmd/SUPER, Win/META) are
39    // never the popup's to consume — let the host route them as
40    // shortcuts. Otherwise macOS Cmd+Tab / Cmd+Esc / Cmd+arrows
41    // would be swallowed as Accept/Dismiss/Navigate.
42    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                // Empty list — let the host process the key as usual.
79                PopupOutcome::NotHandled
80            }
81        }
82        KeyCode::Esc => PopupOutcome::Consumed(PopupAction::Dismiss),
83        _ => PopupOutcome::NotHandled,
84    }
85}
86
87/// Render the popup as a floating layer over `screen`. Picks an anchor
88/// position adjacent to `state.anchor`, flipping above the cursor when
89/// there is no room below. Width grows to fit the longest visible row,
90/// capped at a reasonable maximum and the available screen width. Height
91/// is bounded by `state.max_visible_rows`; the popup never grows past it,
92/// even when the screen has more room available — keeping it visually
93/// subordinate to the editor.
94pub 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    // Below the minimum drawable size the popup can't render a
103    // border + a row of content, so just bail. ratatui can render
104    // smaller, but the result is uninterpretable to the user.
105    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    // Prefer below the trigger; flip above when there is no room.
131    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        // Last resort: clamp inside the screen, accepting that the popup
139        // may cover the trigger line. Better than rendering off-screen.
140        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    // Overflow indicators on the popup's top and bottom border. We render
170    // them as a single-cell overlay on top of the existing border so the
171    // popup stays exactly `max_visible_rows + 2` tall.
172    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        // `LinkFilter` fires for `<`, `>`, and `=`; render the operator the
185        // user actually typed rather than a hardcoded one.
186        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    // Measure display cells (handles CJK, emoji) — `chars().count()`
201    // is wrong for any text wider than ASCII.
202    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    // ---- Rendering smoke tests ----
387
388    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        // The popup occupies max_visible_rows + 2 (borders); not asserting
414        // a specific cell layout here, just that render completes — the
415        // height bound is enforced in the render function and tested
416        // logically through state::visible_window.
417        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        // Anchor near the bottom of a 10-row screen — popup must flip.
424        st.anchor = (0, 9);
425        draw(&st, Rect::new(0, 0, 40, 10));
426        // No assertion on cell content; this just exercises the flip path
427        // without panicking.
428    }
429
430    // ---- visible_title sigil ----
431
432    #[test]
433    fn title_uses_link_filter_opener_char() {
434        // LinkFilter fires for `<`, `>`, and `=`; the title must reflect the
435        // operator the user actually typed, not a hardcoded `>`.
436        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        // Should complete without rendering anything.
472        terminal
473            .draw(|f| {
474                render(f, &st, f.area(), &theme);
475            })
476            .unwrap();
477    }
478}