Skip to main content

kimun_notes/components/
hint_row.rs

1//! A row of clickable key-hint chips: `[Enter] Rename  [Esc] Cancel`.
2//!
3//! Each chip carries the key it advertises, and a click on it *is* that key:
4//! [`HintRow::hit`] returns the `KeyEvent` and the host feeds it to its own
5//! `handle_key`. One action path for both inputs, so a click can never do
6//! something the key would not. Rects are recorded at render time and
7//! hit-tested against later mouse events (same pattern as `ButtonRow`): no
8//! render, no hits.
9
10use ratatui::Frame;
11use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent};
12use ratatui::layout::{Position, Rect};
13use ratatui::style::{Modifier, Style};
14use ratatui::text::{Line, Span};
15use ratatui::widgets::Paragraph;
16use unicode_width::UnicodeWidthStr;
17
18use crate::components::clickable::is_left_press;
19use crate::settings::themes::Theme;
20
21struct Chip {
22    key: KeyEvent,
23    /// `[key]` — drawn in the action colour when the chip is clickable.
24    key_text: String,
25    /// ` label` — drawn in the row (or override) style.
26    label_text: String,
27    enabled: bool,
28    /// Replaces the row style for this chip when set.
29    style: Option<Style>,
30}
31
32pub struct HintRow {
33    chips: Vec<Chip>,
34    /// Columns before the first chip.
35    indent: u16,
36    /// Columns between chips.
37    gap: u16,
38    /// Plain text drawn after the indent, before the first chip.
39    prefix: String,
40    rects: Vec<Rect>,
41}
42
43impl HintRow {
44    /// Chips as `(key, key label, action label)`, drawn `[label] action`.
45    /// A `KeyCode::Null` chip is informational (`[↑↓] Move`): drawn like the
46    /// others, never clickable.
47    /// Starts with the dialogs' usual two-column indent and gap.
48    pub fn new(chips: &[(KeyCode, &str, &str)]) -> Self {
49        Self {
50            chips: chips
51                .iter()
52                .map(|(code, key, label)| Chip {
53                    key: KeyEvent::new(*code, KeyModifiers::NONE),
54                    key_text: format!("[{key}]"),
55                    label_text: format!(" {label}"),
56                    enabled: true,
57                    style: None,
58                })
59                .collect(),
60            indent: 2,
61            gap: 2,
62            prefix: String::new(),
63            rects: Vec::new(),
64        }
65    }
66
67    /// Override the gap between chips (some rows space them wider).
68    pub fn with_gap(mut self, gap: u16) -> Self {
69        self.gap = gap;
70        self
71    }
72
73    /// Override the columns before the first chip.
74    pub fn with_indent(mut self, indent: u16) -> Self {
75        self.indent = indent;
76        self
77    }
78
79    /// Plain, unclickable text before the first chip (`type a key · `).
80    pub fn with_prefix(mut self, prefix: &str) -> Self {
81        self.prefix = prefix.to_string();
82        self
83    }
84
85    /// Not drawn this frame (e.g. an error line replaced it): forget the
86    /// last render's rects so no invisible chip stays clickable.
87    pub fn hide(&mut self) {
88        self.rects.clear();
89    }
90
91    /// Replace the prefix text (it can change per frame, e.g. a name).
92    pub fn set_prefix(&mut self, prefix: &str) {
93        self.prefix = prefix.to_string();
94    }
95
96    /// Give chip `idx` a modified key (e.g. `Shift+Enter`).
97    pub fn with_modifiers(mut self, idx: usize, modifiers: KeyModifiers) -> Self {
98        if let Some(c) = self.chips.get_mut(idx) {
99            c.key.modifiers = modifiers;
100        }
101        self
102    }
103
104    /// A disabled chip draws dimmed and ignores clicks.
105    pub fn set_enabled(&mut self, idx: usize, enabled: bool) {
106        if let Some(c) = self.chips.get_mut(idx) {
107            c.enabled = enabled;
108        }
109    }
110
111    /// Draw chip `idx` with `style` instead of the row style (`None` resets).
112    pub fn set_style(&mut self, idx: usize, style: Option<Style>) {
113        if let Some(c) = self.chips.get_mut(idx) {
114            c.style = style;
115        }
116    }
117
118    /// Change chip `idx`'s action label, keeping its key.
119    pub fn set_label(&mut self, idx: usize, key: &str, label: &str) {
120        if let Some(c) = self.chips.get_mut(idx) {
121            c.key_text = format!("[{key}]");
122            c.label_text = format!(" {label}");
123        }
124    }
125
126    /// Draw the chips left-aligned in `rect` with `style`; disabled chips
127    /// get `style` dimmed. A clickable chip's `[key]` takes the theme's
128    /// action colour and the chip registers for hover (see
129    /// `components::clickable`); informational chips stay plain.
130    pub fn render(&mut self, f: &mut Frame, rect: Rect, style: Style, theme: &Theme) {
131        self.rects.clear();
132        let mut spans = vec![Span::styled(" ".repeat(self.indent as usize), style)];
133        let mut x = rect.x.saturating_add(self.indent);
134        if !self.prefix.is_empty() {
135            spans.push(Span::styled(self.prefix.clone(), style));
136            x = x.saturating_add(self.prefix.width() as u16);
137        }
138        for (i, chip) in self.chips.iter().enumerate() {
139            if i > 0 {
140                spans.push(Span::styled(" ".repeat(self.gap as usize), style));
141                x = x.saturating_add(self.gap);
142            }
143            let w = (chip.key_text.width() + chip.label_text.width()) as u16;
144            // Clip to the row: a chip past the right edge is not clickable.
145            let r = Rect {
146                x,
147                y: rect.y,
148                width: rect.right().saturating_sub(x).min(w),
149                height: 1,
150            };
151            self.rects.push(r);
152            let clickable = chip.enabled && chip.key.code != KeyCode::Null;
153            if clickable {
154                crate::components::clickable::register(r);
155            }
156            let base = chip.style.unwrap_or(style);
157            let (key_style, label_style) = if !chip.enabled {
158                let dim = base.add_modifier(Modifier::DIM);
159                (dim, dim)
160            } else if clickable {
161                (base.patch(theme.action()), base)
162            } else {
163                (base, base)
164            };
165            spans.push(Span::styled(chip.key_text.clone(), key_style));
166            spans.push(Span::styled(chip.label_text.clone(), label_style));
167            x = x.saturating_add(w);
168        }
169        f.render_widget(Paragraph::new(Line::from(spans)).style(style), rect);
170    }
171
172    /// The key of the enabled chip under a left press, from the last render.
173    pub fn hit(&self, m: &MouseEvent) -> Option<KeyEvent> {
174        if !is_left_press(m) {
175            return None;
176        }
177        let pos = Position::new(m.column, m.row);
178        self.rects
179            .iter()
180            .zip(&self.chips)
181            .find(|(r, c)| c.enabled && c.key.code != KeyCode::Null && r.contains(pos))
182            .map(|(_, c)| c.key)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use ratatui::crossterm::event::{MouseButton, MouseEventKind};
190    use ratatui::{Terminal, backend::TestBackend};
191
192    fn press(col: u16, row: u16) -> MouseEvent {
193        MouseEvent {
194            kind: MouseEventKind::Down(MouseButton::Left),
195            column: col,
196            row,
197            modifiers: KeyModifiers::NONE,
198        }
199    }
200
201    fn drawn(row: &mut HintRow) -> String {
202        let mut t = Terminal::new(TestBackend::new(40, 1)).unwrap();
203        t.draw(|f| row.render(f, f.area(), Style::default(), &Theme::gruvbox_dark()))
204            .unwrap();
205        let buf = t.backend().buffer().clone();
206        (0..40).map(|x| buf[(x, 0)].symbol().to_string()).collect()
207    }
208
209    #[test]
210    fn draws_like_the_old_hint_text() {
211        let mut row = HintRow::new(&[
212            (KeyCode::Enter, "Enter", "Rename"),
213            (KeyCode::Esc, "Esc", "Cancel"),
214        ]);
215        assert!(drawn(&mut row).starts_with("  [Enter] Rename  [Esc] Cancel"));
216    }
217
218    #[test]
219    fn a_click_on_a_chip_is_its_key() {
220        let mut row = HintRow::new(&[
221            (KeyCode::Enter, "Enter", "Rename"),
222            (KeyCode::Esc, "Esc", "Cancel"),
223        ]);
224        drawn(&mut row);
225        assert_eq!(row.hit(&press(2, 0)).map(|k| k.code), Some(KeyCode::Enter));
226        assert_eq!(row.hit(&press(15, 0)).map(|k| k.code), Some(KeyCode::Enter));
227        assert_eq!(row.hit(&press(16, 0)), None, "the gap is not a chip");
228        assert_eq!(row.hit(&press(18, 0)).map(|k| k.code), Some(KeyCode::Esc));
229        assert_eq!(row.hit(&press(0, 0)), None, "the indent is not a chip");
230    }
231
232    #[test]
233    fn a_disabled_chip_ignores_clicks() {
234        let mut row = HintRow::new(&[(KeyCode::Enter, "Enter", "Rename")]);
235        row.set_enabled(0, false);
236        drawn(&mut row);
237        assert_eq!(row.hit(&press(3, 0)), None);
238    }
239
240    #[test]
241    fn modifiers_ride_along() {
242        let mut row = HintRow::new(&[(KeyCode::Enter, "Shift+Enter", "Save & Open")])
243            .with_modifiers(0, KeyModifiers::SHIFT);
244        drawn(&mut row);
245        assert_eq!(
246            row.hit(&press(3, 0)).map(|k| k.modifiers),
247            Some(KeyModifiers::SHIFT)
248        );
249    }
250
251    #[test]
252    fn an_informational_chip_ignores_clicks() {
253        let mut row = HintRow::new(&[(KeyCode::Null, "↑↓", "Move")]);
254        drawn(&mut row);
255        assert_eq!(row.hit(&press(3, 0)), None);
256    }
257
258    #[test]
259    fn a_prefix_shifts_the_chips() {
260        let mut row = HintRow::new(&[(KeyCode::Esc, "Esc", "Close")]).with_prefix("type · ");
261        assert!(drawn(&mut row).starts_with("  type · [Esc] Close"));
262        assert_eq!(row.hit(&press(3, 0)), None, "the prefix is inert");
263        assert_eq!(row.hit(&press(9, 0)).map(|k| k.code), Some(KeyCode::Esc));
264    }
265
266    #[test]
267    fn nothing_hits_before_render() {
268        let row = HintRow::new(&[(KeyCode::Enter, "Enter", "Go")]);
269        assert_eq!(row.hit(&press(3, 0)), None);
270    }
271}