kimun_notes/components/
button_row.rs1use 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 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 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 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 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 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 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}