1use ratatui::buffer::Buffer;
5use ratatui::layout::Rect;
6use ratatui::style::{Modifier, Style};
7use ratatui::text::{Line, Span};
8use ratatui::widgets::{Paragraph, Widget};
9
10const GAP: u16 = 2;
12
13#[derive(Debug, Clone, Copy)]
15pub struct Hint<'a> {
16 pub key: &'a str,
17 pub label: &'a str,
18 pub accented: bool,
20 pub weight: Option<i32>,
24}
25
26#[derive(Debug, Clone)]
29pub struct HintBar<'a> {
30 hints: Vec<Hint<'a>>,
31 key_style: Style,
32 label_style: Style,
33 accent_label_style: Style,
34}
35
36impl<'a> HintBar<'a> {
37 pub fn with_styles(key_style: Style, label_style: Style, accent_label_style: Style) -> Self {
39 Self {
40 hints: Vec::new(),
41 key_style,
42 label_style,
43 accent_label_style,
44 }
45 }
46
47 pub fn from_ctx(ctx: &crate::render::context::RenderContext) -> Self {
49 Self::with_styles(
50 Style::default()
51 .bg(ctx.keybind_hints)
52 .fg(ctx.text_inverse)
53 .add_modifier(Modifier::BOLD),
54 Style::default().fg(ctx.keybind_labels),
55 Style::default().fg(ctx.keybind_hints),
56 )
57 }
58
59 pub fn hint(mut self, key: &'a str, label: &'a str) -> Self {
60 self.hints.push(Hint {
61 key,
62 label,
63 accented: false,
64 weight: None,
65 });
66 self
67 }
68
69 pub fn hint_weighted(mut self, key: &'a str, label: &'a str, weight: i32) -> Self {
71 self.hints.push(Hint {
72 key,
73 label,
74 accented: false,
75 weight: Some(weight),
76 });
77 self
78 }
79
80 pub fn hints(mut self, pairs: &[(&'a str, &'a str)]) -> Self {
81 for (key, label) in pairs {
82 self.hints.push(Hint {
83 key,
84 label,
85 accented: false,
86 weight: None,
87 });
88 }
89 self
90 }
91
92 pub fn accent(mut self, key: &str) -> Self {
94 for hint in &mut self.hints {
95 if hint.key == key {
96 hint.accented = true;
97 }
98 }
99 self
100 }
101
102 fn chip_width(hint: &Hint) -> u16 {
106 (crate::glyphs::display_width(hint.key) as u16 + 2)
107 + (crate::glyphs::display_width(hint.label) as u16 + 3)
108 }
109
110 fn kept(&self, width: u16, flush: bool) -> Vec<bool> {
114 let n = self.hints.len();
115 let mut keep = vec![true; n];
116 let weight = |i: usize| self.hints[i].weight.unwrap_or((n - i) as i32);
117 let budget = if flush {
118 width.saturating_add(GAP)
119 } else {
120 width
121 };
122 let mut used: u16 = self.hints.iter().map(Self::chip_width).sum();
123 while used > budget {
124 let Some(drop) = (0..n)
126 .filter(|&i| keep[i])
127 .min_by_key(|&i| (weight(i), std::cmp::Reverse(i)))
128 else {
129 break;
130 };
131 keep[drop] = false;
132 used -= Self::chip_width(&self.hints[drop]);
133 }
134 keep
135 }
136
137 fn used(&self, keep: &[bool]) -> u16 {
138 keep.iter()
139 .zip(&self.hints)
140 .filter(|(keep, _)| **keep)
141 .map(|(_, hint)| Self::chip_width(hint))
142 .sum()
143 }
144
145 pub fn width_in(&self, width: u16) -> u16 {
147 self.used(&self.kept(width, false))
148 }
149
150 pub fn flush_width_in(&self, width: u16) -> u16 {
153 self.used(&self.kept(width, true)).saturating_sub(GAP)
154 }
155
156 pub fn render_flush(&self, area: Rect, buf: &mut Buffer) {
159 self.draw(area, buf, true);
160 }
161
162 pub fn chips_in(&self, area: Rect) -> Vec<(Rect, &'a str)> {
165 self.chips(area, false)
166 }
167
168 fn chips(&self, area: Rect, flush: bool) -> Vec<(Rect, &'a str)> {
170 let mut x = area.x;
171 let mut chips = Vec::new();
172 for (hint, keep) in self.hints.iter().zip(self.kept(area.width, flush)) {
173 if !keep {
174 continue;
175 }
176 let width = Self::chip_width(hint);
177 let shown = (width - GAP).min(area.right().saturating_sub(x));
178 if shown > 0 {
179 chips.push((Rect::new(x, area.y, shown, area.height.min(1)), hint.key));
180 }
181 x = x.saturating_add(width);
182 }
183 chips
184 }
185
186 fn draw(&self, area: Rect, buf: &mut Buffer, flush: bool) {
187 for (rect, key) in self.chips(area, flush) {
190 if let Some(key) = crate::pointer::chip_key(key) {
191 crate::pointer::record(rect, crate::pointer::Hit::Chip(key));
192 }
193 }
194 let kept = self.kept(area.width, flush);
195 let mut spans = Vec::new();
196 for (hint, keep) in self.hints.iter().zip(kept) {
197 if !keep {
198 continue;
199 }
200 spans.push(Span::styled(format!(" {} ", hint.key), self.key_style));
201 let style = if hint.accented {
202 self.accent_label_style
203 } else {
204 self.label_style
205 };
206 spans.push(Span::styled(format!(" {} ", hint.label), style));
207 }
208 Paragraph::new(Line::from(spans)).render(area, buf);
209 }
210}
211
212impl Widget for &HintBar<'_> {
213 fn render(self, area: Rect, buf: &mut Buffer) {
214 self.draw(area, buf, false);
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use crate::render::context::RenderContext;
222
223 #[test]
225 fn chips_are_found_where_they_are_drawn() {
226 let bar = HintBar::from_ctx(&RenderContext::for_test())
227 .hints(&[("Enter", "Inspect"), ("^Q", "Quit")]);
228 let drawn = render_to_string(&bar, 40);
229 let chips = bar.chips_in(Rect::new(0, 0, 40, 1));
230 assert_eq!(chips.len(), 2);
231 for ((rect, key), label) in chips.iter().zip(["Inspect", "Quit"]) {
232 let text: String = drawn
233 .chars()
234 .skip(rect.x as usize)
235 .take(rect.width as usize)
236 .collect();
237 assert_eq!(text, format!(" {key} {label}"));
238 }
239 assert_eq!(bar.chips_in(Rect::new(0, 0, 18, 1)).len(), 1);
241 }
242
243 fn render_to_string(bar: &HintBar, width: u16) -> String {
244 let area = Rect::new(0, 0, width, 1);
245 let mut buf = Buffer::empty(area);
246 bar.render(area, &mut buf);
247 (0..width)
248 .map(|x| buf[(x, 0)].symbol().to_string())
249 .collect()
250 }
251
252 fn bar<'a>() -> HintBar<'a> {
253 HintBar::from_ctx(&RenderContext::for_test())
254 .hint("Enter", "Export")
255 .hint("Tab", "Next")
256 .hint("Esc", "Cancel")
257 }
258
259 #[test]
260 fn chips_read_key_then_label_in_order() {
261 let out = render_to_string(&bar(), 60);
262 let positions: Vec<usize> = ["Enter", "Export", "Tab", "Next", "Esc", "Cancel"]
263 .iter()
264 .map(|word| out.find(word).unwrap_or_else(|| panic!("{word} missing")))
265 .collect();
266 assert!(
267 positions.windows(2).all(|pair| pair[0] < pair[1]),
268 "chips out of order: {out:?}"
269 );
270 }
271
272 #[test]
274 fn a_tight_bar_drops_whole_chips_from_the_right() {
275 let full = bar().width_in(u16::MAX);
276 for width in 1..full {
277 let out = render_to_string(&bar(), width);
278 for (key, label) in [("Enter", "Export"), ("Tab", "Next"), ("Esc", "Cancel")] {
279 assert_eq!(
281 out.contains(key),
282 out.contains(label),
283 "chip {key}/{label} was clipped at width {width}: {out:?}"
284 );
285 }
286 if out.contains("Cancel") {
289 assert!(out.contains("Next") && out.contains("Export"), "{out:?}");
290 }
291 if out.contains("Next") {
292 assert!(out.contains("Export"), "{out:?}");
293 }
294 }
295 let out = render_to_string(&bar(), full);
296 assert!(out.contains("Cancel"), "everything fits at {full}: {out:?}");
297 }
298
299 #[test]
302 fn the_escape_chip_outlives_lighter_chips() {
303 let weighted = || {
304 HintBar::from_ctx(&RenderContext::for_test())
305 .hint_weighted("Enter", "Export", 2)
306 .hint_weighted("Tab", "Next", 1)
307 .hint_weighted("Esc", "Cancel", 3)
308 };
309 let full = weighted().width_in(u16::MAX);
310 let out = render_to_string(&weighted(), full - 1);
311 assert!(
312 out.contains("Export") && out.contains("Cancel") && !out.contains("Next"),
313 "Tab is the chip that yields: {out:?}"
314 );
315 let narrow = render_to_string(&weighted(), 16);
317 assert!(
318 narrow.contains("Cancel") && !narrow.contains("Export"),
319 "Esc goes last: {narrow:?}"
320 );
321 }
322
323 #[test]
326 fn a_flush_bar_keeps_a_chip_that_ends_at_the_edge() {
327 let full = bar().width_in(u16::MAX);
328 let tight = full - 2;
329 assert!(!render_to_string(&bar(), tight).contains("Cancel"));
330 let area = Rect::new(0, 0, tight, 1);
331 let mut buf = Buffer::empty(area);
332 bar().render_flush(area, &mut buf);
333 let out: String = (0..tight).map(|x| buf[(x, 0)].symbol()).collect();
334 assert!(out.ends_with("Cancel"), "{out:?}");
335 assert_eq!(bar().flush_width_in(tight), tight);
336 let area = Rect::new(0, 0, tight - 1, 1);
338 let mut buf = Buffer::empty(area);
339 bar().render_flush(area, &mut buf);
340 let out: String = (0..tight - 1).map(|x| buf[(x, 0)].symbol()).collect();
341 assert!(!out.contains("Esc") && !out.contains("Cancel"), "{out:?}");
342 }
343
344 #[test]
346 fn the_key_carries_the_chip_background_and_the_label_does_not() {
347 let area = Rect::new(0, 0, 40, 1);
348 let mut buf = Buffer::empty(area);
349 let bar = bar();
350 bar.render(area, &mut buf);
351 let out = render_to_string(&bar, 40);
352 let key_x = out.find("Enter").unwrap() as u16;
353 let label_x = out.find("Export").unwrap() as u16;
354 assert_ne!(
355 buf[(key_x, 0)].bg,
356 buf[(label_x, 0)].bg,
357 "key and label share a background, so there is no chip"
358 );
359 }
360
361 #[test]
362 fn accent_marks_one_label_and_changes_no_text() {
363 let plain = render_to_string(&bar(), 60);
364 let accented_bar = bar().accent("Tab");
365 assert_eq!(plain, render_to_string(&accented_bar, 60));
366
367 let area = Rect::new(0, 0, 60, 1);
368 let mut plain_buf = Buffer::empty(area);
369 bar().render(area, &mut plain_buf);
370 let mut accent_buf = Buffer::empty(area);
371 accented_bar.render(area, &mut accent_buf);
372 let changed: Vec<u16> = (0..60)
373 .filter(|&x| plain_buf[(x, 0)].fg != accent_buf[(x, 0)].fg)
374 .collect();
375 assert!(!changed.is_empty(), "the accent did nothing");
376 let label_at = plain.find("Next").unwrap() as u16;
377 let chunk = (label_at - 1)..(label_at + "Next".len() as u16 + 2);
378 for x in &changed {
379 assert!(chunk.contains(x), "column {x} is outside the Next label");
380 }
381 }
382}