Skip to main content

datui_lib/widgets/ui/
hintbar.rs

1//! The chip row: keys and their labels, one renderer for the footer and
2//! every Surface footer.
3
4use crate::render::footer::{Hint, registry_hint_as, registry_hint_in};
5use datui_cli::keys::Context;
6use ratatui::buffer::Buffer;
7use ratatui::layout::Rect;
8use ratatui::style::{Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Paragraph, Widget};
11
12/// Blank cells after a chip's label, before the next chip.
13const GAP: u16 = 2;
14
15/// One key chip: the key on the accent, the label beside it, from the key registry.
16#[derive(Debug, Clone)]
17struct Chip {
18    hint: Hint,
19    /// What yields first when the row runs out of room: the lightest chip,
20    /// wherever it sits. None weighs chips by position, leftmost heaviest —
21    /// plain cut-from-the-right. The way out (Esc) should weigh the most.
22    weight: Option<i32>,
23}
24
25/// A row of key chips. Primary action first, Esc last; chips that do not fit
26/// are dropped whole from the right, never clipped mid-word.
27///
28/// Every chip is a key of one screen's registry entries ([`Self::screen`]), so a
29/// dialog's footer says what its help says.
30#[derive(Debug, Clone)]
31pub struct HintBar {
32    hints: Vec<Chip>,
33    screen: Option<(Context, Option<&'static str>)>,
34    key_style: Style,
35    label_style: Style,
36    accent_label_style: Style,
37}
38
39impl HintBar {
40    /// A bar with explicit styles, for the footer's background-filled row.
41    pub fn with_styles(key_style: Style, label_style: Style, accent_label_style: Style) -> Self {
42        Self {
43            hints: Vec::new(),
44            screen: None,
45            key_style,
46            label_style,
47            accent_label_style,
48        }
49    }
50
51    /// A bar styled from the theme snapshot, for Surface footers.
52    pub fn from_ctx(ctx: &crate::render::context::RenderContext) -> Self {
53        Self::with_styles(
54            Style::default()
55                .bg(ctx.keybind_hints)
56                .fg(ctx.text_inverse)
57                .add_modifier(Modifier::BOLD),
58            Style::default().fg(ctx.keybind_labels),
59            Style::default().fg(ctx.keybind_hints),
60        )
61    }
62
63    /// The screen whose registry entries the chips that follow are.
64    pub fn screen(mut self, context: Context) -> Self {
65        self.screen = Some((context, None));
66        self
67    }
68
69    /// The group of the screen's entries the chips that follow are, where the screen
70    /// lists a key twice.
71    pub fn group(mut self, group: &'static str) -> Self {
72        if let Some((_, in_group)) = self.screen.as_mut() {
73            *in_group = Some(group);
74        }
75        self
76    }
77
78    /// A chip for `keys`, with the registry's word for them.
79    pub fn key(self, keys: &str) -> Self {
80        let (context, group) = self.screen.expect("a bar's screen before its keys");
81        self.push(registry_hint_in(context, group, keys))
82    }
83
84    /// A chip for `keys` saying `label`, one of the registry's words for them.
85    pub fn key_as(self, keys: &str, label: &'static str) -> Self {
86        let (context, group) = self.screen.expect("a bar's screen before its keys");
87        self.push(registry_hint_as(context, group, keys, label))
88    }
89
90    /// A chip built elsewhere from the registry.
91    pub fn push(mut self, hint: Hint) -> Self {
92        self.hints.push(Chip { hint, weight: None });
93        self
94    }
95
96    /// The weight of the last chip; see `Chip::weight`.
97    pub fn weight(mut self, weight: i32) -> Self {
98        if let Some(chip) = self.hints.last_mut() {
99            chip.weight = Some(weight);
100        }
101        self
102    }
103
104    /// Accent the label of the chip whose key is `key`.
105    pub fn accent(mut self, key: &str) -> Self {
106        for chip in &mut self.hints {
107            if chip.hint.key == key {
108                chip.hint.accented = true;
109            }
110        }
111        self
112    }
113
114    /// A chip's cost in columns: the key padded one cell each side, a space,
115    /// the label, then [`GAP`] cells before the next chip. Measured in display
116    /// columns — a `[glyphs]` override may be wide.
117    fn chip_width(chip: &Chip) -> u16 {
118        (crate::glyphs::display_width(&chip.hint.key) as u16 + 2)
119            + (crate::glyphs::display_width(&chip.hint.label) as u16 + 3)
120    }
121
122    /// Which chips a row of `width` shows: chips are dropped whole, lightest
123    /// first, until the rest fit. Display order never changes. A bar that ends
124    /// its row (`flush`) needs no gap after its last chip.
125    fn kept(&self, width: u16, flush: bool) -> Vec<bool> {
126        let n = self.hints.len();
127        let mut keep = vec![true; n];
128        let weight = |i: usize| self.hints[i].weight.unwrap_or((n - i) as i32);
129        let budget = if flush {
130            width.saturating_add(GAP)
131        } else {
132            width
133        };
134        let mut used: u16 = self.hints.iter().map(Self::chip_width).sum();
135        while used > budget {
136            // Lightest chip goes; on a tie, the rightmost.
137            let Some(drop) = (0..n)
138                .filter(|&i| keep[i])
139                .min_by_key(|&i| (weight(i), std::cmp::Reverse(i)))
140            else {
141                break;
142            };
143            keep[drop] = false;
144            used -= Self::chip_width(&self.hints[drop]);
145        }
146        keep
147    }
148
149    fn used(&self, keep: &[bool]) -> u16 {
150        keep.iter()
151            .zip(&self.hints)
152            .filter(|(keep, _)| **keep)
153            .map(|(_, hint)| Self::chip_width(hint))
154            .sum()
155    }
156
157    /// The columns the bar will actually use in a row of `width`.
158    #[cfg(test)]
159    pub fn width_in(&self, width: u16) -> u16 {
160        self.used(&self.kept(width, false))
161    }
162
163    /// The columns a bar drawn with [`Self::render_flush`] uses in a row of `width`:
164    /// the last chip's trailing gap is not counted.
165    pub fn flush_width_in(&self, width: u16) -> u16 {
166        self.used(&self.kept(width, true)).saturating_sub(GAP)
167    }
168
169    /// Draw a bar that nothing follows on its row, such as a Surface footer: a
170    /// chip fits when its label does, without the gap a next chip would need.
171    pub fn render_flush(&self, area: Rect, buf: &mut Buffer) {
172        self.draw(area, buf, true);
173    }
174
175    /// Where each chip [`Widget::render`] draws in `area` lands, key and label without
176    /// the gap after it, with its key: what a click on the bar presses.
177    #[cfg(test)]
178    pub fn chips_in(&self, area: Rect) -> Vec<(Rect, &str)> {
179        self.chips(area, false)
180    }
181
182    /// Where each kept chip lands in `area`, drawn flush or not.
183    fn chips(&self, area: Rect, flush: bool) -> Vec<(Rect, &str)> {
184        let mut x = area.x;
185        let mut chips = Vec::new();
186        for (hint, keep) in self.hints.iter().zip(self.kept(area.width, flush)) {
187            if !keep {
188                continue;
189            }
190            let width = Self::chip_width(hint);
191            let shown = (width - GAP).min(area.right().saturating_sub(x));
192            if shown > 0 {
193                chips.push((
194                    Rect::new(x, area.y, shown, area.height.min(1)),
195                    hint.hint.key.as_ref(),
196                ));
197            }
198            x = x.saturating_add(width);
199        }
200        chips
201    }
202
203    fn draw(&self, area: Rect, buf: &mut Buffer, flush: bool) {
204        // Every chip that names one key is a click target that presses it, as the
205        // status footer's are; drawn after what it sits on, it lies on top.
206        for (rect, key) in self.chips(area, flush) {
207            if let Some(key) = crate::app::pointer::chip_key(key) {
208                crate::app::pointer::record(rect, crate::app::pointer::Hit::Chip(key));
209            }
210        }
211        let kept = self.kept(area.width, flush);
212        let mut spans = Vec::new();
213        for (chip, keep) in self.hints.iter().zip(kept) {
214            if !keep {
215                continue;
216            }
217            spans.push(Span::styled(format!(" {} ", chip.hint.key), self.key_style));
218            let style = if chip.hint.accented {
219                self.accent_label_style
220            } else {
221                self.label_style
222            };
223            spans.push(Span::styled(format!(" {}  ", chip.hint.label), style));
224        }
225        Paragraph::new(Line::from(spans)).render(area, buf);
226    }
227}
228
229impl Widget for &HintBar {
230    fn render(self, area: Rect, buf: &mut Buffer) {
231        self.draw(area, buf, false);
232    }
233}
234
235#[cfg(test)]
236mod tests;