Skip to main content

bubbles/
help.rs

1//! Help view component.
2//!
3//! This module provides a help view for displaying key bindings in TUI
4//! applications.
5//!
6//! # Example
7//!
8//! ```rust
9//! use bubbles::help::Help;
10//! use bubbles::key::Binding;
11//!
12//! let help = Help::new();
13//!
14//! // Create some bindings
15//! let quit = Binding::new().keys(&["q", "ctrl+c"]).help("q", "quit");
16//! let save = Binding::new().keys(&["ctrl+s"]).help("ctrl+s", "save");
17//!
18//! // Render short help
19//! let view = help.short_help_view(&[&quit, &save]);
20//! ```
21
22use crate::key::Binding;
23use bubbletea::{Cmd, Message, Model};
24use lipgloss::Style;
25use unicode_width::UnicodeWidthStr;
26
27/// Styles for the help view.
28#[derive(Debug, Clone)]
29pub struct Styles {
30    /// Style for ellipsis when help is truncated.
31    pub ellipsis: Style,
32    /// Style for keys in short help.
33    pub short_key: Style,
34    /// Style for descriptions in short help.
35    pub short_desc: Style,
36    /// Style for separator in short help.
37    pub short_separator: Style,
38    /// Style for keys in full help.
39    pub full_key: Style,
40    /// Style for descriptions in full help.
41    pub full_desc: Style,
42    /// Style for separator in full help.
43    pub full_separator: Style,
44}
45
46impl Default for Styles {
47    fn default() -> Self {
48        Self {
49            ellipsis: Style::new(),
50            short_key: Style::new(),
51            short_desc: Style::new(),
52            short_separator: Style::new(),
53            full_key: Style::new(),
54            full_desc: Style::new(),
55            full_separator: Style::new(),
56        }
57    }
58}
59
60/// Message to toggle full help display.
61#[derive(Debug, Clone, Copy)]
62pub struct ToggleFullHelpMsg;
63
64/// Message to set the width of the help view.
65#[derive(Debug, Clone, Copy)]
66pub struct SetWidthMsg(pub usize);
67
68/// Message to set key bindings for the help view.
69#[derive(Debug, Clone)]
70pub struct SetBindingsMsg(pub Vec<Binding>);
71
72/// Help view model.
73#[derive(Debug, Clone)]
74pub struct Help {
75    /// Maximum width for the help view.
76    pub width: usize,
77    /// Whether to show full help (vs short help).
78    pub show_all: bool,
79    /// Separator for short help items.
80    pub short_separator: String,
81    /// Separator for full help columns.
82    pub full_separator: String,
83    /// Ellipsis shown when help is truncated.
84    pub ellipsis: String,
85    /// Styles for rendering.
86    pub styles: Styles,
87    /// Key bindings for standalone Model usage.
88    bindings: Vec<Binding>,
89}
90
91impl Default for Help {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97impl Help {
98    /// Creates a new help view with default settings.
99    #[must_use]
100    pub fn new() -> Self {
101        Self {
102            width: 0,
103            show_all: false,
104            short_separator: " • ".to_string(),
105            full_separator: "    ".to_string(),
106            ellipsis: "…".to_string(),
107            styles: Styles::default(),
108            bindings: Vec::new(),
109        }
110    }
111
112    /// Sets the key bindings for standalone Model usage.
113    #[must_use]
114    pub fn with_bindings(mut self, bindings: Vec<Binding>) -> Self {
115        self.bindings = bindings;
116        self
117    }
118
119    /// Sets the key bindings.
120    pub fn set_bindings(&mut self, bindings: Vec<Binding>) {
121        self.bindings = bindings;
122    }
123
124    /// Returns the stored key bindings.
125    #[must_use]
126    pub fn bindings(&self) -> &[Binding] {
127        &self.bindings
128    }
129
130    /// Sets the width of the help view.
131    #[must_use]
132    pub fn width(mut self, width: usize) -> Self {
133        self.width = width;
134        self
135    }
136
137    /// Sets whether to show all help items.
138    #[must_use]
139    pub fn show_all(mut self, show: bool) -> Self {
140        self.show_all = show;
141        self
142    }
143
144    /// Renders the help view for a list of bindings.
145    ///
146    /// Displays short help if `show_all` is false, full help otherwise.
147    #[must_use]
148    pub fn view(&self, bindings: &[&Binding]) -> String {
149        if self.show_all {
150            self.full_help_view(&[bindings.to_vec()])
151        } else {
152            self.short_help_view(bindings)
153        }
154    }
155
156    /// Renders short help from a list of bindings.
157    #[must_use]
158    pub fn short_help_view(&self, bindings: &[&Binding]) -> String {
159        if bindings.is_empty() {
160            return String::new();
161        }
162
163        let mut result = String::new();
164        let mut total_width = 0;
165
166        for binding in bindings {
167            if !binding.enabled() {
168                continue;
169            }
170
171            let help = binding.get_help();
172            if help.key.is_empty() && help.desc.is_empty() {
173                continue;
174            }
175
176            // Separator
177            let sep = if total_width > 0 {
178                self.styles.short_separator.render(&self.short_separator)
179            } else {
180                String::new()
181            };
182
183            // Key + desc
184            let key_str = self.styles.short_key.render(&help.key);
185            let desc_str = self.styles.short_desc.render(&help.desc);
186            let item = format!("{}{} {}", sep, key_str, desc_str);
187            let item_width = sep.width() + help.key.width() + 1 + help.desc.width();
188
189            // Check width limit
190            if self.width > 0 {
191                let ellipsis_width = 1 + self.ellipsis.width();
192                if total_width + item_width > self.width {
193                    if total_width + ellipsis_width < self.width {
194                        result.push(' ');
195                        result.push_str(&self.styles.ellipsis.render(&self.ellipsis));
196                    }
197                    break;
198                }
199            }
200
201            total_width += item_width;
202            result.push_str(&item);
203        }
204
205        result
206    }
207
208    /// Renders full help from groups of bindings.
209    #[must_use]
210    pub fn full_help_view(&self, groups: &[Vec<&Binding>]) -> String {
211        if groups.is_empty() {
212            return String::new();
213        }
214
215        let mut columns: Vec<String> = Vec::new();
216        let mut total_width = 0;
217
218        for group in groups {
219            if !should_render_column(group) {
220                continue;
221            }
222
223            // Collect enabled bindings
224            let mut keys: Vec<&str> = Vec::new();
225            let mut descs: Vec<&str> = Vec::new();
226
227            for binding in group {
228                if binding.enabled() {
229                    let help = binding.get_help();
230                    if !help.key.is_empty() || !help.desc.is_empty() {
231                        keys.push(help.key.as_str());
232                        descs.push(help.desc.as_str());
233                    }
234                }
235            }
236
237            if keys.is_empty() {
238                continue;
239            }
240
241            // Separator
242            let sep = if total_width > 0 {
243                self.styles.full_separator.render(&self.full_separator)
244            } else {
245                String::new()
246            };
247
248            // Build column using join_horizontal to properly align multi-line strings
249            let keys_col = self.styles.full_key.render(&keys.join("\n"));
250            let descs_col = self.styles.full_desc.render(&descs.join("\n"));
251            // Use lipgloss::join_horizontal to properly align columns like Go does
252            let column = lipgloss::join_horizontal(
253                lipgloss::Position::Top,
254                &[&sep, &keys_col, " ", &descs_col],
255            );
256
257            // Approximate width
258            let max_key_width = keys.iter().map(|k| k.width()).max().unwrap_or(0);
259            let max_desc_width = descs.iter().map(|d| d.width()).max().unwrap_or(0);
260            let col_width = self.full_separator.width() + max_key_width + 1 + max_desc_width;
261
262            // Check width limit
263            if self.width > 0 && total_width + col_width > self.width {
264                break;
265            }
266
267            total_width += col_width;
268            columns.push(column);
269        }
270
271        // Join all columns horizontally
272        if columns.len() <= 1 {
273            columns.join("")
274        } else {
275            let refs: Vec<&str> = columns.iter().map(|s| s.as_str()).collect();
276            lipgloss::join_horizontal(lipgloss::Position::Top, &refs)
277        }
278    }
279}
280
281/// Returns whether a column should be rendered (has at least one enabled binding).
282fn should_render_column(bindings: &[&Binding]) -> bool {
283    bindings.iter().any(|b| b.enabled())
284}
285
286/// Trait for types that can provide key bindings for help display.
287pub trait KeyMap {
288    /// Returns bindings for short help display.
289    fn short_help(&self) -> Vec<Binding>;
290
291    /// Returns groups of bindings for full help display.
292    fn full_help(&self) -> Vec<Vec<Binding>>;
293}
294
295/// Implement the Model trait for standalone bubbletea usage.
296impl Model for Help {
297    fn init(&self) -> Option<Cmd> {
298        // Help doesn't need initialization
299        None
300    }
301
302    fn update(&mut self, msg: Message) -> Option<Cmd> {
303        // Handle toggle full help
304        if msg.is::<ToggleFullHelpMsg>() {
305            self.show_all = !self.show_all;
306            return None;
307        }
308
309        // Handle set width
310        if let Some(SetWidthMsg(width)) = msg.downcast_ref::<SetWidthMsg>() {
311            self.width = *width;
312            return None;
313        }
314
315        // Handle set bindings
316        if let Some(set_bindings) = msg.downcast::<SetBindingsMsg>() {
317            self.bindings = set_bindings.0;
318            return None;
319        }
320
321        None
322    }
323
324    fn view(&self) -> String {
325        // Use stored bindings for standalone Model view
326        let binding_refs: Vec<&Binding> = self.bindings.iter().collect();
327        if self.show_all {
328            self.full_help_view(&[binding_refs])
329        } else {
330            self.short_help_view(&binding_refs)
331        }
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_help_new() {
341        let help = Help::new();
342        assert_eq!(help.width, 0);
343        assert!(!help.show_all);
344        assert_eq!(help.short_separator, " • ");
345    }
346
347    #[test]
348    fn test_help_short_view() {
349        let help = Help::new();
350        let quit = Binding::new().keys(&["q"]).help("q", "quit");
351        let save = Binding::new().keys(&["ctrl+s"]).help("^s", "save");
352
353        let view = help.short_help_view(&[&quit, &save]);
354        assert!(view.contains("q"));
355        assert!(view.contains("quit"));
356        assert!(view.contains("^s"));
357        assert!(view.contains("save"));
358    }
359
360    #[test]
361    fn test_help_short_view_with_width() {
362        let help = Help::new().width(20);
363        let quit = Binding::new().keys(&["q"]).help("q", "quit");
364        let save = Binding::new().keys(&["ctrl+s"]).help("^s", "save");
365        let other = Binding::new().keys(&["x"]).help("x", "something very long");
366
367        let view = help.short_help_view(&[&quit, &save, &other]);
368        // Should be truncated
369        assert!(view.len() <= 25); // Account for styling overhead
370    }
371
372    #[test]
373    fn test_help_full_view() {
374        let help = Help::new();
375        let quit = Binding::new().keys(&["q"]).help("q", "quit");
376        let save = Binding::new().keys(&["ctrl+s"]).help("^s", "save");
377
378        let view = help.full_help_view(&[vec![&quit, &save]]);
379        assert!(view.contains("q"));
380        assert!(view.contains("quit"));
381    }
382
383    #[test]
384    fn test_help_empty_bindings() {
385        let help = Help::new();
386        assert_eq!(help.short_help_view(&[]), "");
387        assert_eq!(help.full_help_view(&[]), "");
388    }
389
390    #[test]
391    fn test_help_disabled_bindings() {
392        let help = Help::new();
393        let disabled = Binding::new()
394            .keys(&["q"])
395            .help("q", "quit")
396            .set_enabled(false);
397
398        let view = help.short_help_view(&[&disabled]);
399        assert!(!view.contains("quit"));
400    }
401
402    #[test]
403    fn test_help_builder() {
404        let help = Help::new().width(80).show_all(true);
405        assert_eq!(help.width, 80);
406        assert!(help.show_all);
407    }
408
409    #[test]
410    fn test_should_render_column() {
411        let enabled = Binding::new().keys(&["q"]).help("q", "quit");
412        let disabled = Binding::new()
413            .keys(&["x"])
414            .help("x", "exit")
415            .set_enabled(false);
416
417        assert!(should_render_column(&[&enabled]));
418        assert!(!should_render_column(&[&disabled]));
419        assert!(should_render_column(&[&disabled, &enabled]));
420    }
421
422    // Model trait tests
423
424    #[test]
425    fn test_help_model_init_returns_none() {
426        let help = Help::new();
427        assert!(Model::init(&help).is_none());
428    }
429
430    #[test]
431    fn test_help_model_toggle_full_help() {
432        let mut help = Help::new();
433        assert!(!help.show_all);
434
435        Model::update(&mut help, Message::new(ToggleFullHelpMsg));
436        assert!(help.show_all);
437
438        Model::update(&mut help, Message::new(ToggleFullHelpMsg));
439        assert!(!help.show_all);
440    }
441
442    #[test]
443    fn test_help_model_set_width() {
444        let mut help = Help::new();
445        assert_eq!(help.width, 0);
446
447        Model::update(&mut help, Message::new(SetWidthMsg(80)));
448        assert_eq!(help.width, 80);
449
450        Model::update(&mut help, Message::new(SetWidthMsg(120)));
451        assert_eq!(help.width, 120);
452    }
453
454    #[test]
455    fn test_help_model_set_bindings() {
456        let mut help = Help::new();
457        assert!(help.bindings().is_empty());
458
459        let bindings = vec![
460            Binding::new().keys(&["q"]).help("q", "quit"),
461            Binding::new().keys(&["ctrl+s"]).help("^s", "save"),
462        ];
463
464        Model::update(&mut help, Message::new(SetBindingsMsg(bindings)));
465        assert_eq!(help.bindings().len(), 2);
466    }
467
468    #[test]
469    fn test_help_model_view_short_mode() {
470        let quit = Binding::new().keys(&["q"]).help("q", "quit");
471        let save = Binding::new().keys(&["ctrl+s"]).help("^s", "save");
472
473        let help = Help::new().with_bindings(vec![quit, save]);
474        let view = Model::view(&help);
475
476        assert!(view.contains("q"));
477        assert!(view.contains("quit"));
478        assert!(view.contains("^s"));
479        assert!(view.contains("save"));
480    }
481
482    #[test]
483    fn test_help_model_view_full_mode() {
484        let quit = Binding::new().keys(&["q"]).help("q", "quit");
485        let save = Binding::new().keys(&["ctrl+s"]).help("^s", "save");
486
487        let help = Help::new().with_bindings(vec![quit, save]).show_all(true);
488        let view = Model::view(&help);
489
490        assert!(view.contains("q"));
491        assert!(view.contains("quit"));
492    }
493
494    #[test]
495    fn test_help_model_view_empty_bindings() {
496        let help = Help::new();
497        let view = Model::view(&help);
498        assert!(view.is_empty());
499    }
500
501    #[test]
502    fn test_help_model_view_respects_width() {
503        let quit = Binding::new().keys(&["q"]).help("q", "quit");
504        let save = Binding::new().keys(&["ctrl+s"]).help("^s", "save");
505        let other = Binding::new()
506            .keys(&["x"])
507            .help("x", "something very very long");
508
509        let help = Help::new().width(20).with_bindings(vec![quit, save, other]);
510        let view = Model::view(&help);
511
512        // View should be truncated due to width constraint
513        assert!(view.len() <= 30); // Account for styling overhead
514    }
515
516    #[test]
517    fn test_help_with_bindings_builder() {
518        let bindings = vec![
519            Binding::new().keys(&["q"]).help("q", "quit"),
520            Binding::new().keys(&["ctrl+s"]).help("^s", "save"),
521        ];
522
523        let help = Help::new().with_bindings(bindings);
524        assert_eq!(help.bindings().len(), 2);
525    }
526
527    #[test]
528    fn test_help_set_bindings_method() {
529        let mut help = Help::new();
530        help.set_bindings(vec![Binding::new().keys(&["q"]).help("q", "quit")]);
531        assert_eq!(help.bindings().len(), 1);
532    }
533
534    #[test]
535    fn test_help_model_satisfies_model_bounds() {
536        fn accepts_model<M: Model + Send + 'static>(_model: M) {}
537        let help = Help::new();
538        accepts_model(help);
539    }
540
541    #[test]
542    fn test_help_model_update_returns_none() {
543        let mut help = Help::new();
544        // All message types should return None (no commands)
545        assert!(Model::update(&mut help, Message::new(ToggleFullHelpMsg)).is_none());
546        assert!(Model::update(&mut help, Message::new(SetWidthMsg(80))).is_none());
547        assert!(Model::update(&mut help, Message::new(SetBindingsMsg(vec![]))).is_none());
548    }
549
550    // Parity audit tests (bd-212m.6.6)
551
552    #[test]
553    fn test_help_full_view_multi_group() {
554        // Test full help with multiple groups (columns)
555        let help = Help::new();
556        let nav_up = Binding::new().keys(&["up", "k"]).help("↑/k", "up");
557        let nav_down = Binding::new().keys(&["down", "j"]).help("↓/j", "down");
558        let action_enter = Binding::new().keys(&["enter"]).help("enter", "select");
559        let action_quit = Binding::new().keys(&["q", "ctrl+c"]).help("q", "quit");
560
561        let groups = vec![vec![&nav_up, &nav_down], vec![&action_enter, &action_quit]];
562
563        let view = help.full_help_view(&groups);
564        // Should contain all keys and descriptions
565        assert!(view.contains("↑/k"));
566        assert!(view.contains("↓/j"));
567        assert!(view.contains("enter"));
568        assert!(view.contains("quit"));
569        // Should contain newlines for multi-row layout
570        assert!(view.contains('\n'));
571    }
572
573    #[test]
574    fn test_help_full_view_with_width_truncation() {
575        // Test that full help respects width limits
576        let help = Help::new().width(30);
577        let b1 = Binding::new().keys(&["a"]).help("a", "first action");
578        let b2 = Binding::new().keys(&["b"]).help("b", "second action");
579        let b3 = Binding::new()
580            .keys(&["c"])
581            .help("c", "third action that won't fit");
582
583        let groups = vec![vec![&b1], vec![&b2], vec![&b3]];
584
585        let view = help.full_help_view(&groups);
586        // With width=30, not all groups should fit
587        // Exact behavior depends on column width calculation
588        assert!(!view.is_empty());
589    }
590
591    #[test]
592    fn test_help_mixed_enabled_disabled_in_group() {
593        // Test that disabled bindings are skipped within a group
594        let help = Help::new();
595        let enabled = Binding::new().keys(&["a"]).help("a", "enabled");
596        let disabled = Binding::new()
597            .keys(&["b"])
598            .help("b", "disabled")
599            .set_enabled(false);
600        let enabled2 = Binding::new().keys(&["c"]).help("c", "also enabled");
601
602        let view = help.short_help_view(&[&enabled, &disabled, &enabled2]);
603
604        assert!(view.contains("a"));
605        assert!(!view.contains("b disabled"));
606        assert!(view.contains("c"));
607    }
608
609    #[test]
610    fn test_help_full_view_skips_all_disabled_group() {
611        // Test that groups with all disabled bindings are skipped
612        let help = Help::new();
613        let enabled = Binding::new().keys(&["a"]).help("a", "enabled");
614        let disabled1 = Binding::new()
615            .keys(&["b"])
616            .help("b", "disabled1")
617            .set_enabled(false);
618        let disabled2 = Binding::new()
619            .keys(&["c"])
620            .help("c", "disabled2")
621            .set_enabled(false);
622
623        let groups = vec![vec![&disabled1, &disabled2], vec![&enabled]];
624
625        let view = help.full_help_view(&groups);
626        assert!(view.contains("a"));
627        assert!(view.contains("enabled"));
628        assert!(!view.contains("disabled1"));
629        assert!(!view.contains("disabled2"));
630    }
631
632    #[test]
633    fn test_help_short_view_ellipsis_truncation() {
634        // Test that ellipsis is added when truncating
635        let help = Help::new().width(15);
636        let b1 = Binding::new().keys(&["a"]).help("a", "first");
637        let b2 = Binding::new().keys(&["b"]).help("b", "second");
638        let b3 = Binding::new().keys(&["c"]).help("c", "third");
639
640        let view = help.short_help_view(&[&b1, &b2, &b3]);
641
642        // Should be truncated with ellipsis
643        assert!(view.len() <= 20); // Width + some margin for ellipsis
644    }
645
646    #[test]
647    fn test_help_separator_styles() {
648        // Test that separators use the configured values
649        let mut help = Help::new();
650        help.short_separator = " | ".to_string();
651        help.full_separator = "  ||  ".to_string();
652
653        let b1 = Binding::new().keys(&["a"]).help("a", "first");
654        let b2 = Binding::new().keys(&["b"]).help("b", "second");
655
656        let short_view = help.short_help_view(&[&b1, &b2]);
657        assert!(short_view.contains(" | "), "Short view: {}", short_view);
658
659        let full_view = help.full_help_view(&[vec![&b1], vec![&b2]]);
660        assert!(full_view.contains("||"), "Full view: {}", full_view);
661    }
662
663    #[test]
664    fn test_help_unicode_keys() {
665        // Test handling of Unicode characters in key names
666        let help = Help::new();
667        let arrow_up = Binding::new().keys(&["up"]).help("↑", "move up");
668        let arrow_down = Binding::new().keys(&["down"]).help("↓", "move down");
669
670        let view = help.short_help_view(&[&arrow_up, &arrow_down]);
671        assert!(view.contains("↑"));
672        assert!(view.contains("↓"));
673    }
674
675    #[test]
676    fn test_help_empty_key_or_desc() {
677        // Test bindings with empty key or description are skipped
678        let help = Help::new();
679        let empty_both = Binding::new().keys(&["a"]).help("", "");
680        let empty_key = Binding::new().keys(&["b"]).help("", "desc only");
681        let empty_desc = Binding::new().keys(&["c"]).help("key only", "");
682        let normal = Binding::new().keys(&["d"]).help("d", "normal");
683
684        let view = help.short_help_view(&[&empty_both, &empty_key, &empty_desc, &normal]);
685
686        // empty_both should be skipped (both empty)
687        // empty_key and empty_desc should be included (one is non-empty)
688        // normal should be included
689        assert!(view.contains("desc only") || view.contains("key only"));
690        assert!(view.contains("d normal") || view.contains("d") && view.contains("normal"));
691    }
692
693    #[test]
694    fn test_help_view_method_dispatches_correctly() {
695        // Test that view() method correctly dispatches based on show_all
696        let b1 = Binding::new().keys(&["a"]).help("a", "action");
697        let b2 = Binding::new().keys(&["b"]).help("b", "back");
698
699        let help_short = Help::new();
700        let help_full = Help::new().show_all(true);
701
702        let short_view = help_short.view(&[&b1, &b2]);
703        let full_view = help_full.view(&[&b1, &b2]);
704
705        // Short view should be single line
706        assert!(!short_view.contains('\n') || short_view.lines().count() == 1);
707        // Full view should have bindings stacked
708        assert!(full_view.contains("a"));
709        assert!(full_view.contains("b"));
710    }
711
712    #[test]
713    fn test_help_default_separators() {
714        // Verify default separators match Go implementation
715        let help = Help::new();
716        assert_eq!(help.short_separator, " • ");
717        assert_eq!(help.full_separator, "    ");
718        assert_eq!(help.ellipsis, "…");
719    }
720
721    #[test]
722    fn test_help_zero_width_no_truncation() {
723        // Width of 0 means no truncation
724        let help = Help::new().width(0);
725        let b1 = Binding::new().keys(&["a"]).help(
726            "a",
727            "a very long description that would normally be truncated",
728        );
729        let b2 = Binding::new()
730            .keys(&["b"])
731            .help("b", "another very long description");
732
733        let view = help.short_help_view(&[&b1, &b2]);
734
735        // Should contain full descriptions
736        assert!(view.contains("a very long description"));
737        assert!(view.contains("another very long description"));
738    }
739}