1use 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_text: String,
25 label_text: String,
27 enabled: bool,
28 style: Option<Style>,
30}
31
32pub struct HintRow {
33 chips: Vec<Chip>,
34 indent: u16,
36 gap: u16,
38 prefix: String,
40 rects: Vec<Rect>,
41}
42
43impl HintRow {
44 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 pub fn with_gap(mut self, gap: u16) -> Self {
69 self.gap = gap;
70 self
71 }
72
73 pub fn with_indent(mut self, indent: u16) -> Self {
75 self.indent = indent;
76 self
77 }
78
79 pub fn with_prefix(mut self, prefix: &str) -> Self {
81 self.prefix = prefix.to_string();
82 self
83 }
84
85 pub fn hide(&mut self) {
88 self.rects.clear();
89 }
90
91 pub fn set_prefix(&mut self, prefix: &str) {
93 self.prefix = prefix.to_string();
94 }
95
96 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 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 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 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 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 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 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}