Skip to main content

datui_lib/widgets/ui/
hintbar.rs

1//! The chip row: keys and their labels, one renderer for the control bar and
2//! every Surface footer.
3
4use ratatui::buffer::Buffer;
5use ratatui::layout::Rect;
6use ratatui::style::{Modifier, Style};
7use ratatui::text::{Line, Span};
8use ratatui::widgets::{Paragraph, Widget};
9
10/// Blank cells after a chip's label, before the next chip.
11const GAP: u16 = 2;
12
13/// One key chip: the key on the accent, the label beside it.
14#[derive(Debug, Clone, Copy)]
15pub struct Hint<'a> {
16    pub key: &'a str,
17    pub label: &'a str,
18    /// Give this label the accent — a quiet "look here", never extra text.
19    pub accented: bool,
20    /// What yields first when the row runs out of room: the lightest chip,
21    /// wherever it sits. None weighs chips by position, leftmost heaviest —
22    /// plain cut-from-the-right. The way out (Esc) should weigh the most.
23    pub weight: Option<i32>,
24}
25
26/// A row of key chips. Primary action first, Esc last; chips that do not fit
27/// are dropped whole from the right, never clipped mid-word.
28#[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    /// A bar with explicit styles, for the control bar's background-filled row.
38    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    /// A bar styled from the theme snapshot, for Surface footers.
48    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    /// A chip with an explicit weight; see [`Hint::weight`].
70    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    /// Accent the label of the chip whose key is `key`.
93    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    /// A chip's cost in columns: the key padded one cell each side, a space,
103    /// the label, then [`GAP`] cells before the next chip. Measured in display
104    /// columns — a `[glyphs]` override may be wide.
105    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    /// Which chips a row of `width` shows: chips are dropped whole, lightest
111    /// first, until the rest fit. Display order never changes. A bar that ends
112    /// its row (`flush`) needs no gap after its last chip.
113    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            // Lightest chip goes; on a tie, the rightmost.
125            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    /// The columns the bar will actually use in a row of `width`.
146    pub fn width_in(&self, width: u16) -> u16 {
147        self.used(&self.kept(width, false))
148    }
149
150    /// [`Self::width_in`] for a bar drawn with [`Self::render_flush`]: the last
151    /// chip's trailing gap is not counted.
152    pub fn flush_width_in(&self, width: u16) -> u16 {
153        self.used(&self.kept(width, true)).saturating_sub(GAP)
154    }
155
156    /// Draw a bar that nothing follows on its row, such as a Surface footer: a
157    /// chip fits when its label does, without the gap a next chip would need.
158    pub fn render_flush(&self, area: Rect, buf: &mut Buffer) {
159        self.draw(area, buf, true);
160    }
161
162    /// Where each chip [`Widget::render`] draws in `area` lands, key and label without
163    /// the gap after it, with its key: what a click on the bar presses.
164    pub fn chips_in(&self, area: Rect) -> Vec<(Rect, &'a str)> {
165        self.chips(area, false)
166    }
167
168    /// Where each kept chip lands in `area`, drawn flush or not.
169    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        // Every chip that names one key is a click target that presses it, as the
188        // status footer's are; drawn after what it sits on, it lies on top.
189        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    /// Each chip's place is where its key is drawn, and ends with its label.
224    #[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        // A bar too narrow for the second chip offers only the first.
240        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    /// A chip that does not fit is dropped whole; the ones before it stay whole.
273    #[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                // Either the whole chip is there or none of it.
280                assert_eq!(
281                    out.contains(key),
282                    out.contains(label),
283                    "chip {key}/{label} was clipped at width {width}: {out:?}"
284                );
285            }
286            // Unweighted, the bar cuts from the right: a later chip on screen
287            // means every earlier one is too.
288            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    /// Weighted, the way out yields last: a tight footer keeps Enter and Esc
300    /// and gives up Tab, whatever the order they are drawn in.
301    #[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        // Tighter still, the primary action goes before the way out.
316        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    /// A bar that ends its row keeps a chip whose label reaches the edge: the
324    /// gap after the last chip is for a next one, and there is none.
325    #[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        // One column less and the chip goes whole, as ever.
337        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    /// The key sits on the accent; the label does not.
345    #[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}