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}
156
157impl<'a, S: ShortcutBarStyling> ShortcutsBar<'a, S> {
158 #[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 #[must_use]
171 pub fn compact(mut self, cfg: &'a CompactConfig) -> Self {
172 self.compact = Some(cfg);
173 self
174 }
175
176 #[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#[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); }
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}