Skip to main content

oxicode_vtui/design/layout/
shortcuts_bar.rs

1//! Shortcuts bar — bottom keyboard hint strip.
2//!
3//! Ported from grok-build's `views/shortcuts_bar.rs`.
4//!
5//! Styling is decoupled via the [`ShortcutBarStyling`] trait, mirroring the
6//! existing `PanelStyleProvider` pattern.
7
8use ratatui::buffer::Buffer;
9use ratatui::layout::Rect;
10use ratatui::style::Style;
11use ratatui::text::{Line, Span};
12use ratatui::widgets::Widget;
13
14// ───────────────────────────────────────────────────────────────────────────
15// ShortcutBarStyling trait
16// ───────────────────────────────────────────────────────────────────────────
17
18/// Trait for providing shortcuts bar styles.  Decouples the widget from any
19/// specific theme type.
20pub trait ShortcutBarStyling {
21    /// Style for key labels (e.g., "Enter", "Esc").
22    fn key_style(&self) -> Style;
23    /// Style for human-readable labels (e.g., "send", "cancel").
24    fn label_style(&self) -> Style;
25    /// Style for separators between hints.
26    fn separator_style(&self) -> Style;
27    /// Background fill style.
28    fn background_style(&self) -> Style;
29    /// Style for the pending-action key.
30    fn pending_key_style(&self) -> Style;
31}
32
33// ───────────────────────────────────────────────────────────────────────────
34// HintItem
35// ───────────────────────────────────────────────────────────────────────────
36
37/// A single keyboard hint for the shortcuts bar.
38#[derive(Debug, Clone)]
39pub struct HintItem {
40    /// Key display string (e.g., `"Enter"`, `"Ctrl+C"`).
41    pub key: String,
42    /// Optional secondary key for paired hints.
43    pub alt_key: Option<String>,
44    /// Human-readable label.
45    pub label: String,
46    /// Whether this hint is pinned (always visible in compact mode).
47    pub pinned: bool,
48}
49
50impl HintItem {
51    /// Create a new hint.
52    #[must_use]
53    pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
54        Self {
55            key: key.into(),
56            alt_key: None,
57            label: label.into(),
58            pinned: false,
59        }
60    }
61
62    /// Create a paired hint (two keys, one label).
63    #[must_use]
64    pub fn paired(
65        key: impl Into<String>,
66        alt: impl Into<String>,
67        label: impl Into<String>,
68    ) -> Self {
69        Self {
70            key: key.into(),
71            alt_key: Some(alt.into()),
72            label: label.into(),
73            pinned: false,
74        }
75    }
76
77    /// Mark this hint as pinned.
78    #[must_use]
79    pub fn pinned(mut self) -> Self {
80        self.pinned = true;
81        self
82    }
83
84    /// Display width.
85    #[must_use]
86    pub fn display_width(&self) -> usize {
87        let key_w = if let Some(alt) = &self.alt_key {
88            self.key.chars().count() + 1 + alt.chars().count()
89        } else {
90            self.key.chars().count()
91        };
92        key_w + 2 + self.label.chars().count()
93    }
94
95    /// Build the styled spans for this hint.
96    fn spans<S: ShortcutBarStyling>(&self, styles: &S) -> Vec<Span<'static>> {
97        let mut spans = Vec::with_capacity(5);
98        if let Some(alt) = &self.alt_key {
99            spans.push(Span::styled(self.key.clone(), styles.key_style()));
100            spans.push(Span::styled("/", styles.separator_style()));
101            spans.push(Span::styled(alt.clone(), styles.key_style()));
102        } else {
103            spans.push(Span::styled(self.key.clone(), styles.key_style()));
104        }
105        spans.push(Span::styled(":", styles.separator_style()));
106        spans.push(Span::styled(self.label.clone(), styles.label_style()));
107        spans
108    }
109}
110
111// ───────────────────────────────────────────────────────────────────────────
112// CompactConfig + PendingHint
113// ───────────────────────────────────────────────────────────────────────────
114
115/// Compact-mode configuration for the shortcuts bar.
116#[derive(Debug, Clone, Copy)]
117pub struct CompactConfig {
118    /// Maximum hints to display (pinned always included).
119    pub max_visible: usize,
120    /// Key for the trailing help hint.
121    pub help_key: &'static str,
122    /// Label for the trailing help hint.
123    pub help_label: &'static str,
124}
125
126impl Default for CompactConfig {
127    fn default() -> Self {
128        Self {
129            max_visible: 8,
130            help_key: "?",
131            help_label: "help",
132        }
133    }
134}
135
136/// Info for the "press again to confirm" pending-action hint.
137#[derive(Clone, Copy)]
138pub struct PendingHint {
139    /// Key to press again.
140    pub key: &'static str,
141    /// What action will be confirmed.
142    pub label: &'static str,
143}
144
145// ───────────────────────────────────────────────────────────────────────────
146// ShortcutsBar widget
147// ───────────────────────────────────────────────────────────────────────────
148
149/// Shortcuts bar widget.  Renders [`HintItem`]s in a single row.
150pub struct ShortcutsBar<'a, S: ShortcutBarStyling> {
151    hints: &'a [HintItem],
152    styles: &'a S,
153    compact: Option<&'a CompactConfig>,
154    pending: Option<PendingHint>,
155    right: Option<Line<'a>>,
156}
157
158impl<'a, S: ShortcutBarStyling> ShortcutsBar<'a, S> {
159    /// Create a shortcuts bar with the given hints and styles.
160    #[must_use]
161    pub fn new(hints: &'a [HintItem], styles: &'a S) -> Self {
162        Self {
163            hints,
164            styles,
165            compact: None,
166            pending: None,
167            right: None,
168        }
169    }
170
171    /// Enable compact mode.
172    #[must_use]
173    pub fn compact(mut self, cfg: &'a CompactConfig) -> Self {
174        self.compact = Some(cfg);
175        self
176    }
177
178    /// Set the pending-action confirmation hint.
179    #[must_use]
180    pub fn pending(mut self, hint: PendingHint) -> Self {
181        self.pending = Some(hint);
182        self
183    }
184
185    /// Set a right-aligned status line (e.g. scroll position).
186    ///
187    /// Skipped when it would overlap the left hints or while a pending
188    /// confirmation hint owns the row.
189    #[must_use]
190    pub fn right(mut self, line: Line<'a>) -> Self {
191        self.right = Some(line);
192        self
193    }
194}
195
196impl<S: ShortcutBarStyling> Widget for ShortcutsBar<'_, S> {
197    fn render(self, area: Rect, buf: &mut Buffer) {
198        if area.height == 0 || area.width == 0 {
199            return;
200        }
201        buf.set_style(area, self.styles.background_style());
202
203        if let Some(pending) = self.pending {
204            let line = Line::from(vec![
205                Span::raw("press "),
206                Span::styled(pending.key, self.styles.pending_key_style()),
207                Span::raw(" again to "),
208                Span::styled(pending.label, self.styles.label_style()),
209            ]);
210            buf.set_line(area.x, area.y, &line, area.width);
211            return;
212        }
213
214        let effective = compute_effective_hints(self.hints, self.compact);
215        let mut x = area.x;
216        let separator = Span::styled("  ", self.styles.separator_style());
217
218        for (i, hint) in effective.iter().enumerate() {
219            if i > 0 {
220                let sep_w = separator.width() as u16;
221                if x + sep_w > area.x + area.width {
222                    break;
223                }
224                buf.set_span(x, area.y, &separator, sep_w);
225                x += sep_w;
226            }
227            let spans = hint.spans(self.styles);
228            let line = Line::from(spans);
229            let w = hint.display_width() as u16;
230            if x + w > area.x + area.width {
231                break;
232            }
233            buf.set_line(x, area.y, &line, w);
234            x += w;
235        }
236
237        if let Some(right) = self.right {
238            let w = right.width() as u16;
239            let right_x = area.x + area.width.saturating_sub(w);
240            if right_x > x {
241                buf.set_line(right_x, area.y, &right, w);
242            }
243        }
244    }
245}
246
247// ───────────────────────────────────────────────────────────────────────────
248// Hint computation
249// ───────────────────────────────────────────────────────────────────────────
250
251/// Compute the hint list the bar will actually render.
252#[must_use]
253pub fn compute_effective_hints<'a>(
254    hints: &'a [HintItem],
255    compact: Option<&'a CompactConfig>,
256) -> Vec<&'a HintItem> {
257    let Some(cfg) = compact else {
258        return hints.iter().collect();
259    };
260
261    let mut result: Vec<&HintItem> = Vec::new();
262    let pinned_count = hints.iter().filter(|h| h.pinned).count();
263
264    for h in hints.iter().filter(|h| h.pinned) {
265        result.push(h);
266    }
267    let remaining = cfg.max_visible.saturating_sub(pinned_count);
268    for h in hints.iter().filter(|h| !h.pinned).take(remaining) {
269        result.push(h);
270    }
271    result
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    struct TestStyles;
279    impl ShortcutBarStyling for TestStyles {
280        fn key_style(&self) -> Style {
281            Style::default()
282        }
283        fn label_style(&self) -> Style {
284            Style::default()
285        }
286        fn separator_style(&self) -> Style {
287            Style::default()
288        }
289        fn background_style(&self) -> Style {
290            Style::default()
291        }
292        fn pending_key_style(&self) -> Style {
293            Style::default()
294        }
295    }
296
297    #[test]
298    fn hint_item_display_width() {
299        assert_eq!(HintItem::new("Enter", "send").display_width(), 11); // "Enter: send" = 11 cols
300    }
301
302    #[test]
303    fn compute_effective_compact_preserves_pinned() {
304        let hints = vec![
305            HintItem::new("a", "x").pinned(),
306            HintItem::new("b", "y"),
307            HintItem::new("c", "z"),
308        ];
309        let cfg = CompactConfig {
310            max_visible: 2,
311            ..Default::default()
312        };
313        let eff = compute_effective_hints(&hints, Some(&cfg));
314        assert_eq!(eff.len(), 2);
315        assert_eq!(eff[0].key, "a");
316        assert_eq!(eff[1].key, "b");
317    }
318
319    #[test]
320    fn shortcuts_bar_renders_without_panic() {
321        let styles = TestStyles;
322        let hints = vec![
323            HintItem::new("Enter", "send"),
324            HintItem::new("Esc", "cancel"),
325        ];
326        let mut buf = Buffer::empty(Rect::new(0, 0, 80, 1));
327        ShortcutsBar::new(&hints, &styles).render(Rect::new(0, 0, 80, 1), &mut buf);
328        assert_eq!(buf[(0, 0)].symbol(), "E");
329    }
330
331    #[test]
332    fn shortcuts_bar_pending_mode() {
333        let styles = TestStyles;
334        let mut buf = Buffer::empty(Rect::new(0, 0, 80, 1));
335        ShortcutsBar::new(&[], &styles)
336            .pending(PendingHint {
337                key: "q",
338                label: "quit",
339            })
340            .render(Rect::new(0, 0, 80, 1), &mut buf);
341        assert_eq!(buf[(0, 0)].symbol(), "p");
342    }
343}