Skip to main content

kimun_notes/components/
button_row.rs

1//! A row of clickable `[ Label ]` buttons. Rects are recorded at render time
2//! and hit-tested against later mouse events (same pattern as
3//! `search_list`): no render, no hits.
4
5use ratatui::Frame;
6use ratatui::layout::{Position, Rect};
7use ratatui::style::{Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::Paragraph;
10use unicode_width::UnicodeWidthStr;
11
12use crate::settings::themes::Theme;
13
14pub struct ButtonRow {
15    labels: Vec<String>,
16    enabled: Vec<bool>,
17    focused: Option<usize>,
18    rects: Vec<Rect>,
19}
20
21impl ButtonRow {
22    pub fn new(labels: &[&str]) -> Self {
23        Self {
24            labels: labels.iter().map(|l| l.to_string()).collect(),
25            enabled: vec![true; labels.len()],
26            focused: None,
27            rects: Vec::new(),
28        }
29    }
30
31    pub fn set_enabled(&mut self, idx: usize, enabled: bool) {
32        if let Some(e) = self.enabled.get_mut(idx) {
33            *e = enabled;
34        }
35        if !enabled && self.focused == Some(idx) {
36            self.focused = None;
37        }
38    }
39
40    pub fn is_enabled(&self, idx: usize) -> bool {
41        self.enabled.get(idx).copied().unwrap_or(false)
42    }
43
44    pub fn set_focused(&mut self, idx: Option<usize>) {
45        self.focused = idx.filter(|&i| self.is_enabled(i));
46    }
47
48    pub fn focused(&self) -> Option<usize> {
49        self.focused
50    }
51
52    /// Moves focus to the next enabled button; returns false at the end.
53    pub fn focus_next(&mut self) -> bool {
54        let start = self.focused.map_or(0, |i| i + 1);
55        match (start..self.labels.len()).find(|&i| self.is_enabled(i)) {
56            Some(i) => {
57                self.focused = Some(i);
58                true
59            }
60            None => false,
61        }
62    }
63
64    /// Moves focus to the previous enabled button; returns false at the start.
65    pub fn focus_prev(&mut self) -> bool {
66        let end = self.focused.unwrap_or(self.labels.len());
67        match (0..end).rev().find(|&i| self.is_enabled(i)) {
68            Some(i) => {
69                self.focused = Some(i);
70                true
71            }
72            None => false,
73        }
74    }
75
76    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme) {
77        self.rects.clear();
78        let normal = Style::default()
79            .fg(theme.fg.to_ratatui())
80            .bg(theme.bg_panel.to_ratatui());
81        let dim = Style::default()
82            .fg(theme.gray.to_ratatui())
83            .bg(theme.bg_panel.to_ratatui())
84            .add_modifier(Modifier::DIM);
85        let focus = Style::default()
86            .fg(theme.selection_fg.to_ratatui())
87            .bg(theme.selection_bg.to_ratatui())
88            .add_modifier(Modifier::BOLD);
89        let mut spans = vec![Span::styled(" ", normal)];
90        let mut x = rect.x + 1;
91        for (i, label) in self.labels.iter().enumerate() {
92            if i > 0 {
93                spans.push(Span::styled("  ", normal));
94                x += 2;
95            }
96            let text = format!("[ {label} ]");
97            let w = text.width() as u16;
98            let style = if !self.enabled[i] {
99                dim
100            } else if self.focused == Some(i) {
101                focus
102            } else {
103                normal.patch(theme.action())
104            };
105            // Clip to the row: a button past the right edge is not clickable.
106            let visible = rect.right().saturating_sub(x).min(w);
107            let r = Rect {
108                x,
109                y: rect.y,
110                width: visible,
111                height: 1,
112            };
113            self.rects.push(r);
114            if self.enabled[i] {
115                crate::components::clickable::register(r);
116            }
117            spans.push(Span::styled(text, style));
118            x += w;
119        }
120        f.render_widget(Paragraph::new(Line::from(spans)).style(normal), rect);
121    }
122
123    /// Index of the enabled button under a left press, from the last
124    /// render. Other buttons never press a button — a right-click on
125    /// `Discard` must not discard.
126    pub fn hit(&self, m: &ratatui::crossterm::event::MouseEvent) -> Option<usize> {
127        if !crate::components::clickable::is_left_press(m) {
128            return None;
129        }
130        self.hit_at(m.column, m.row)
131    }
132
133    /// Index of the enabled button at (col,row) from the last render — for
134    /// callers that have already decided the event is a left press.
135    pub fn hit_at(&self, col: u16, row: u16) -> Option<usize> {
136        let pos = Position { x: col, y: row };
137        self.rects
138            .iter()
139            .position(|r| r.contains(pos))
140            .filter(|&i| self.is_enabled(i))
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use ratatui::{Terminal, backend::TestBackend};
148
149    fn drawn(row: &mut ButtonRow) {
150        let theme = Theme::gruvbox_dark();
151        let mut t = Terminal::new(TestBackend::new(40, 1)).unwrap();
152        t.draw(|f| row.render(f, f.area(), &theme)).unwrap();
153    }
154
155    #[test]
156    fn hit_finds_each_button_after_render() {
157        let mut row = ButtonRow::new(&["Save", "Cancel"]);
158        drawn(&mut row);
159        // Layout: " [ Save ]  [ Cancel ]" → Save at cols 1..=8, Cancel at 11..=20.
160        assert_eq!(row.hit_at(1, 0), Some(0));
161        assert_eq!(row.hit_at(8, 0), Some(0));
162        assert_eq!(row.hit_at(11, 0), Some(1));
163        assert_eq!(row.hit_at(20, 0), Some(1));
164    }
165
166    #[test]
167    fn gap_between_buttons_hits_nothing() {
168        let mut row = ButtonRow::new(&["Save", "Cancel"]);
169        drawn(&mut row);
170        assert_eq!(row.hit_at(9, 0), None);
171        assert_eq!(row.hit_at(0, 0), None);
172        assert_eq!(row.hit_at(30, 0), None);
173    }
174
175    #[test]
176    fn disabled_buttons_are_never_hit_or_focused() {
177        let mut row = ButtonRow::new(&["Add", "Edit", "Delete"]);
178        row.set_enabled(1, false);
179        drawn(&mut row);
180        assert_eq!(row.hit_at(10, 0), None, "Edit is disabled");
181        row.set_focused(Some(0));
182        assert!(row.focus_next());
183        assert_eq!(row.focused(), Some(2), "focus skips the disabled button");
184        assert!(!row.focus_next());
185    }
186
187    #[test]
188    fn focus_prev_skips_disabled_and_stops_at_start() {
189        let mut row = ButtonRow::new(&["Add", "Edit", "Delete"]);
190        row.set_enabled(1, false);
191        row.set_focused(Some(2));
192        assert!(row.focus_prev());
193        assert_eq!(row.focused(), Some(0));
194        assert!(!row.focus_prev());
195    }
196
197    #[test]
198    fn disabling_focused_button_clears_focus() {
199        let mut row = ButtonRow::new(&["Add", "Edit"]);
200        row.set_focused(Some(1));
201        row.set_enabled(1, false);
202        assert_eq!(row.focused(), None);
203        row.set_focused(Some(1));
204        assert_eq!(row.focused(), None);
205    }
206
207    #[test]
208    fn no_hit_before_first_render() {
209        let row = ButtonRow::new(&["Save"]);
210        assert_eq!(row.hit_at(1, 0), None);
211    }
212}