oxicode_vtui/design/layout/
shortcuts_bar.rs1use ratatui::buffer::Buffer;
9use ratatui::layout::Rect;
10use ratatui::style::Style;
11use ratatui::text::{Line, Span};
12use ratatui::widgets::Widget;
13
14pub trait ShortcutBarStyling {
21 fn key_style(&self) -> Style;
23 fn label_style(&self) -> Style;
25 fn separator_style(&self) -> Style;
27 fn background_style(&self) -> Style;
29 fn pending_key_style(&self) -> Style;
31}
32
33#[derive(Debug, Clone)]
39pub struct HintItem {
40 pub key: String,
42 pub alt_key: Option<String>,
44 pub label: String,
46 pub pinned: bool,
48}
49
50impl HintItem {
51 #[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 #[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 #[must_use]
79 pub fn pinned(mut self) -> Self {
80 self.pinned = true;
81 self
82 }
83
84 #[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 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#[derive(Debug, Clone, Copy)]
117pub struct CompactConfig {
118 pub max_visible: usize,
120 pub help_key: &'static str,
122 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#[derive(Clone, Copy)]
138pub struct PendingHint {
139 pub key: &'static str,
141 pub label: &'static str,
143}
144
145pub 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 #[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 #[must_use]
173 pub fn compact(mut self, cfg: &'a CompactConfig) -> Self {
174 self.compact = Some(cfg);
175 self
176 }
177
178 #[must_use]
180 pub fn pending(mut self, hint: PendingHint) -> Self {
181 self.pending = Some(hint);
182 self
183 }
184
185 #[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#[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); }
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}