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}
156
157impl<'a, S: ShortcutBarStyling> ShortcutsBar<'a, S> {
158    /// Create a shortcuts bar with the given hints and styles.
159    #[must_use]
160    pub fn new(hints: &'a [HintItem], styles: &'a S) -> Self {
161        Self {
162            hints,
163            styles,
164            compact: None,
165            pending: None,
166        }
167    }
168
169    /// Enable compact mode.
170    #[must_use]
171    pub fn compact(mut self, cfg: &'a CompactConfig) -> Self {
172        self.compact = Some(cfg);
173        self
174    }
175
176    /// Set the pending-action confirmation hint.
177    #[must_use]
178    pub fn pending(mut self, hint: PendingHint) -> Self {
179        self.pending = Some(hint);
180        self
181    }
182}
183
184impl<S: ShortcutBarStyling> Widget for ShortcutsBar<'_, S> {
185    fn render(self, area: Rect, buf: &mut Buffer) {
186        if area.height == 0 || area.width == 0 {
187            return;
188        }
189        buf.set_style(area, self.styles.background_style());
190
191        if let Some(pending) = self.pending {
192            let line = Line::from(vec![
193                Span::raw("press "),
194                Span::styled(pending.key, self.styles.pending_key_style()),
195                Span::raw(" again to "),
196                Span::styled(pending.label, self.styles.label_style()),
197            ]);
198            buf.set_line(area.x, area.y, &line, area.width);
199            return;
200        }
201
202        let effective = compute_effective_hints(self.hints, self.compact);
203        let mut x = area.x;
204        let separator = Span::styled("  ", self.styles.separator_style());
205
206        for (i, hint) in effective.iter().enumerate() {
207            if i > 0 {
208                let sep_w = separator.width() as u16;
209                if x + sep_w > area.x + area.width {
210                    break;
211                }
212                buf.set_span(x, area.y, &separator, sep_w);
213                x += sep_w;
214            }
215            let spans = hint.spans(self.styles);
216            let line = Line::from(spans);
217            let w = hint.display_width() as u16;
218            if x + w > area.x + area.width {
219                break;
220            }
221            buf.set_line(x, area.y, &line, w);
222            x += w;
223        }
224    }
225}
226
227// ───────────────────────────────────────────────────────────────────────────
228// Hint computation
229// ───────────────────────────────────────────────────────────────────────────
230
231/// Compute the hint list the bar will actually render.
232#[must_use]
233pub fn compute_effective_hints<'a>(
234    hints: &'a [HintItem],
235    compact: Option<&'a CompactConfig>,
236) -> Vec<&'a HintItem> {
237    let Some(cfg) = compact else {
238        return hints.iter().collect();
239    };
240
241    let mut result: Vec<&HintItem> = Vec::new();
242    let pinned_count = hints.iter().filter(|h| h.pinned).count();
243
244    for h in hints.iter().filter(|h| h.pinned) {
245        result.push(h);
246    }
247    let remaining = cfg.max_visible.saturating_sub(pinned_count);
248    for h in hints.iter().filter(|h| !h.pinned).take(remaining) {
249        result.push(h);
250    }
251    result
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    struct TestStyles;
259    impl ShortcutBarStyling for TestStyles {
260        fn key_style(&self) -> Style {
261            Style::default()
262        }
263        fn label_style(&self) -> Style {
264            Style::default()
265        }
266        fn separator_style(&self) -> Style {
267            Style::default()
268        }
269        fn background_style(&self) -> Style {
270            Style::default()
271        }
272        fn pending_key_style(&self) -> Style {
273            Style::default()
274        }
275    }
276
277    #[test]
278    fn hint_item_display_width() {
279        assert_eq!(HintItem::new("Enter", "send").display_width(), 11); // "Enter: send" = 11 cols
280    }
281
282    #[test]
283    fn compute_effective_compact_preserves_pinned() {
284        let hints = vec![
285            HintItem::new("a", "x").pinned(),
286            HintItem::new("b", "y"),
287            HintItem::new("c", "z"),
288        ];
289        let cfg = CompactConfig {
290            max_visible: 2,
291            ..Default::default()
292        };
293        let eff = compute_effective_hints(&hints, Some(&cfg));
294        assert_eq!(eff.len(), 2);
295        assert_eq!(eff[0].key, "a");
296        assert_eq!(eff[1].key, "b");
297    }
298
299    #[test]
300    fn shortcuts_bar_renders_without_panic() {
301        let styles = TestStyles;
302        let hints = vec![
303            HintItem::new("Enter", "send"),
304            HintItem::new("Esc", "cancel"),
305        ];
306        let mut buf = Buffer::empty(Rect::new(0, 0, 80, 1));
307        ShortcutsBar::new(&hints, &styles).render(Rect::new(0, 0, 80, 1), &mut buf);
308        assert_eq!(buf[(0, 0)].symbol(), "E");
309    }
310
311    #[test]
312    fn shortcuts_bar_pending_mode() {
313        let styles = TestStyles;
314        let mut buf = Buffer::empty(Rect::new(0, 0, 80, 1));
315        ShortcutsBar::new(&[], &styles)
316            .pending(PendingHint {
317                key: "q",
318                label: "quit",
319            })
320            .render(Rect::new(0, 0, 80, 1), &mut buf);
321        assert_eq!(buf[(0, 0)].symbol(), "p");
322    }
323}