bubbletea_widgets/
help.rs

1//! A help component for bubbletea-rs, ported from the Go version.
2//!
3//! This component provides a customizable help view that can automatically
4//! generate its content from a set of key bindings.
5
6use crate::key;
7use bubbletea_rs::{Cmd, Msg};
8use lipgloss_extras::lipgloss;
9use lipgloss_extras::prelude::*;
10
11/// A trait that defines the key bindings to be displayed in the help view.
12///
13/// Any model that uses the help component should implement this trait to provide
14/// the key bindings that the help view will render.
15pub trait KeyMap {
16    /// Returns a slice of key bindings for the short help view.
17    fn short_help(&self) -> Vec<&key::Binding>;
18    /// Returns a nested slice of key bindings for the full help view.
19    /// Each inner slice represents a column in the help view.
20    fn full_help(&self) -> Vec<Vec<&key::Binding>>;
21}
22
23/// A set of styles for the help component.
24///
25/// This structure defines all the visual styling options available for customizing
26/// the appearance of the help view. Each field controls a specific visual element.
27///
28/// # Examples
29///
30/// ```rust
31/// use bubbletea_widgets::help::Styles;
32/// use lipgloss_extras::prelude::*;
33///
34/// let custom_styles = Styles {
35///     short_key: Style::new().foreground(Color::from("#FF6B6B")),
36///     short_desc: Style::new().foreground(Color::from("#4ECDC4")),
37///     ..Default::default()
38/// };
39/// ```
40#[derive(Debug, Clone)]
41pub struct Styles {
42    /// Style for the ellipsis character when content is truncated.
43    pub ellipsis: Style,
44    /// Style for key names in the short help view.
45    pub short_key: Style,
46    /// Style for descriptions in the short help view.
47    pub short_desc: Style,
48    /// Style for the separator between items in the short help view.
49    pub short_separator: Style,
50    /// Style for key names in the full help view.
51    pub full_key: Style,
52    /// Style for descriptions in the full help view.
53    pub full_desc: Style,
54    /// Style for the separator between columns in the full help view.
55    pub full_separator: Style,
56}
57
58impl Default for Styles {
59    /// Creates default styles with a subtle color scheme that adapts to light and dark themes.
60    ///
61    /// The default styling uses adaptive colors that work well in both light and dark terminal environments:
62    /// - Keys are styled in medium gray (light: #909090, dark: #626262)
63    /// - Descriptions use lighter gray (light: #B2B2B2, dark: #4A4A4A)  
64    /// - Separators use even lighter gray (light: #DDDADA, dark: #3C3C3C)
65    ///
66    /// # Examples
67    ///
68    /// ```rust
69    /// use bubbletea_widgets::help::Styles;
70    ///
71    /// let styles = Styles::default();
72    /// ```
73    fn default() -> Self {
74        use lipgloss::AdaptiveColor;
75
76        let key_style = Style::new().foreground(AdaptiveColor {
77            Light: "#909090",
78            Dark: "#626262",
79        });
80        let desc_style = Style::new().foreground(AdaptiveColor {
81            Light: "#B2B2B2",
82            Dark: "#4A4A4A",
83        });
84        let sep_style = Style::new().foreground(AdaptiveColor {
85            Light: "#DDDADA",
86            Dark: "#3C3C3C",
87        });
88
89        Self {
90            ellipsis: sep_style.clone(),
91            short_key: key_style.clone(),
92            short_desc: desc_style.clone(),
93            short_separator: sep_style.clone(),
94            full_key: key_style,
95            full_desc: desc_style,
96            full_separator: sep_style,
97        }
98    }
99}
100
101/// The help model that manages help view state and rendering.
102///
103/// This is the main component for displaying help information in terminal applications.
104/// It can show either a compact single-line view or an expanded multi-column view
105/// based on the `show_all` toggle.
106///
107/// # Examples
108///
109/// Basic usage:
110/// ```rust
111/// use bubbletea_widgets::help::{Model, KeyMap};
112/// use bubbletea_widgets::key;
113///
114/// // Create a new help model
115/// let help = Model::new().with_width(80);
116///
117/// // Implement KeyMap for your application
118/// struct AppKeyMap;
119/// impl KeyMap for AppKeyMap {
120///     fn short_help(&self) -> Vec<&key::Binding> {
121///         vec![] // Your key bindings
122///     }
123///     fn full_help(&self) -> Vec<Vec<&key::Binding>> {
124///         vec![vec![]] // Your grouped key bindings
125///     }
126/// }
127///
128/// let keymap = AppKeyMap;
129/// let help_text = help.view(&keymap);
130/// ```
131#[derive(Debug, Clone)]
132pub struct Model {
133    /// Toggles between short (single-line) and full (multi-column) help view.
134    /// When `false`, shows compact help; when `true`, shows detailed help.
135    pub show_all: bool,
136    /// The maximum width of the help view in characters.
137    /// When set to 0, no width limit is enforced.
138    pub width: usize,
139
140    /// The separator string used between items in the short help view.
141    /// Default is " • " (bullet with spaces).
142    pub short_separator: String,
143    /// The separator string used between columns in the full help view.
144    /// Default is "    " (four spaces).
145    pub full_separator: String,
146    /// The character displayed when help content is truncated due to width constraints.
147    /// Default is "…" (horizontal ellipsis).
148    pub ellipsis: String,
149
150    /// The styling configuration for all visual elements of the help view.
151    pub styles: Styles,
152}
153
154impl Default for Model {
155    /// Creates a new help model with sensible defaults.
156    ///
157    /// Default configuration:
158    /// - `show_all`: false (shows short help)
159    /// - `width`: 0 (no width limit)
160    /// - `short_separator`: " • "
161    /// - `full_separator`: "    " (4 spaces)
162    /// - `ellipsis`: "…"
163    /// - `styles`: Default styles
164    ///
165    /// # Examples
166    ///
167    /// ```rust
168    /// use bubbletea_widgets::help::Model;
169    ///
170    /// let help = Model::default();
171    /// assert_eq!(help.show_all, false);
172    /// assert_eq!(help.width, 0);
173    /// ```
174    fn default() -> Self {
175        Self {
176            show_all: false,
177            width: 0,
178            short_separator: " • ".to_string(),
179            full_separator: "    ".to_string(),
180            ellipsis: "…".to_string(),
181            styles: Styles::default(),
182        }
183    }
184}
185
186impl Model {
187    /// Creates a new help model with default settings.
188    ///
189    /// This is equivalent to calling `Model::default()` but provides a more
190    /// conventional constructor-style API.
191    ///
192    /// # Examples
193    ///
194    /// ```rust
195    /// use bubbletea_widgets::help::Model;
196    ///
197    /// let help = Model::new();
198    /// ```
199    pub fn new() -> Self {
200        Self::default()
201    }
202
203    /// Sets the maximum width of the help view.
204    ///
205    /// When a width is set, the help view will truncate content that exceeds
206    /// this limit, showing an ellipsis to indicate truncation.
207    ///
208    /// # Arguments
209    ///
210    /// * `width` - Maximum width in characters. Use 0 for no limit.
211    ///
212    /// # Examples
213    ///
214    /// ```rust
215    /// use bubbletea_widgets::help::Model;
216    ///
217    /// let help = Model::new().with_width(80);
218    /// assert_eq!(help.width, 80);
219    /// ```
220    pub fn with_width(mut self, width: usize) -> Self {
221        self.width = width;
222        self
223    }
224
225    /// Updates the help model in response to a message.
226    ///
227    /// This method provides compatibility with the bubbletea-rs architecture,
228    /// matching the Go implementation's Update method. Since the help component
229    /// is primarily a view component that doesn't handle user input directly,
230    /// this is a no-op method that simply returns the model unchanged.
231    ///
232    /// # Arguments
233    ///
234    /// * `_msg` - The message to handle (unused for help component)
235    ///
236    /// # Returns
237    ///
238    /// A tuple containing:
239    /// - The unchanged model
240    /// - `None` for the command (no side effects needed)
241    ///
242    /// # Examples
243    ///
244    /// ```rust
245    /// use bubbletea_widgets::help::Model;
246    /// use bubbletea_rs::Msg;
247    ///
248    /// let help = Model::new();
249    /// // Any message can be passed, the help component ignores all messages
250    /// let msg = Box::new(42); // Example message
251    /// let (updated_help, cmd) = help.update(msg);
252    /// assert!(cmd.is_none()); // Help component doesn't generate commands
253    /// ```
254    pub fn update(self, _msg: Msg) -> (Self, Option<Cmd>) {
255        (self, None)
256    }
257
258    /// Renders the help view based on the current model state.
259    ///
260    /// This is the main rendering function that switches between short and full
261    /// help views based on the `show_all` flag.
262    ///
263    /// # Arguments
264    ///
265    /// * `keymap` - An object implementing the `KeyMap` trait that provides
266    ///   the key bindings to display.
267    ///
268    /// # Returns
269    ///
270    /// A formatted string ready for display in the terminal.
271    ///
272    /// # Examples
273    ///
274    /// ```rust
275    /// use bubbletea_widgets::help::{Model, KeyMap};
276    /// use bubbletea_widgets::key;
277    ///
278    /// struct MyKeyMap;
279    /// impl KeyMap for MyKeyMap {
280    ///     fn short_help(&self) -> Vec<&key::Binding> { vec![] }
281    ///     fn full_help(&self) -> Vec<Vec<&key::Binding>> { vec![] }
282    /// }
283    ///
284    /// let help = Model::new();
285    /// let keymap = MyKeyMap;
286    /// let rendered = help.view(&keymap);
287    /// ```
288    pub fn view<K: KeyMap>(&self, keymap: &K) -> String {
289        if self.show_all {
290            self.full_help_view(keymap.full_help())
291        } else {
292            self.short_help_view(keymap.short_help())
293        }
294    }
295
296    /// Renders a compact single-line help view.
297    ///
298    /// This view displays key bindings in a horizontal layout, separated by
299    /// the configured separator. If the content exceeds the specified width,
300    /// it will be truncated with an ellipsis.
301    ///
302    /// # Arguments
303    ///
304    /// * `bindings` - A vector of key bindings to display.
305    ///
306    /// # Returns
307    ///
308    /// A single-line string containing the formatted help text.
309    ///
310    /// # Examples
311    ///
312    /// ```rust
313    /// use bubbletea_widgets::help::Model;
314    /// use bubbletea_widgets::key;
315    ///
316    /// let help = Model::new();
317    /// let bindings = vec![]; // Your key bindings
318    /// let short_help = help.short_help_view(bindings);
319    /// ```
320    pub fn short_help_view(&self, bindings: Vec<&key::Binding>) -> String {
321        if bindings.is_empty() {
322            return String::new();
323        }
324
325        let mut builder = String::new();
326        let mut total_width = 0;
327        let separator = self
328            .styles
329            .short_separator
330            .clone()
331            .inline(true)
332            .render(&self.short_separator);
333
334        for (i, kb) in bindings.iter().enumerate() {
335            // Skip disabled bindings
336            if !kb.enabled() {
337                continue;
338            }
339
340            let sep = if total_width > 0 && i < bindings.len() {
341                &separator
342            } else {
343                ""
344            };
345
346            // Format: "key description"
347            let help = kb.help();
348            let key_part = self.styles.short_key.clone().inline(true).render(&help.key);
349            let desc_part = self
350                .styles
351                .short_desc
352                .clone()
353                .inline(true)
354                .render(&help.desc);
355            let item_str = format!("{}{} {}", sep, key_part, desc_part);
356
357            let item_width = lipgloss::width_visible(&item_str);
358
359            if let Some(tail) = self.should_add_item(total_width, item_width) {
360                if !tail.is_empty() {
361                    builder.push_str(&tail);
362                }
363                break;
364            }
365
366            total_width += item_width;
367            builder.push_str(&item_str);
368        }
369        builder
370    }
371
372    /// Renders a detailed multi-column help view.
373    ///
374    /// This view organizes key bindings into columns, with each group of bindings
375    /// forming a separate column. Keys and descriptions are aligned vertically
376    /// within each column.
377    ///
378    /// # Arguments
379    ///
380    /// * `groups` - A vector of key binding groups, where each group becomes
381    ///   a column in the output.
382    ///
383    /// # Returns
384    ///
385    /// A multi-line string containing the formatted help text with proper
386    /// column alignment.
387    ///
388    /// # Examples
389    ///
390    /// ```rust
391    /// use bubbletea_widgets::help::Model;
392    /// use bubbletea_widgets::key;
393    ///
394    /// let help = Model::new();
395    /// let groups = vec![vec![]]; // Your grouped key bindings
396    /// let full_help = help.full_help_view(groups);
397    /// ```
398    pub fn full_help_view(&self, groups: Vec<Vec<&key::Binding>>) -> String {
399        if groups.is_empty() {
400            return String::new();
401        }
402
403        let mut columns = Vec::new();
404        let mut total_width = 0;
405        let separator = self
406            .styles
407            .full_separator
408            .clone()
409            .inline(true)
410            .render(&self.full_separator);
411
412        for (i, group) in groups.iter().enumerate() {
413            if group.is_empty() || !should_render_column(group) {
414                continue;
415            }
416
417            let sep = if i > 0 { &separator } else { "" };
418
419            let keys: Vec<String> = group
420                .iter()
421                .filter(|b| b.enabled())
422                .map(|b| b.help().key.clone())
423                .collect();
424            let descs: Vec<String> = group
425                .iter()
426                .filter(|b| b.enabled())
427                .map(|b| b.help().desc.clone())
428                .collect();
429
430            let key_column = self
431                .styles
432                .full_key
433                .clone()
434                .inline(true)
435                .render(&keys.join("\n"));
436            let desc_column = self
437                .styles
438                .full_desc
439                .clone()
440                .inline(true)
441                .render(&descs.join("\n"));
442
443            let col_str =
444                lipgloss::join_horizontal(lipgloss::TOP, &[sep, &key_column, " ", &desc_column]);
445
446            let col_width = lipgloss::width_visible(&col_str);
447
448            if let Some(tail) = self.should_add_item(total_width, col_width) {
449                if !tail.is_empty() {
450                    columns.push(tail);
451                }
452                break;
453            }
454
455            total_width += col_width;
456            columns.push(col_str);
457        }
458
459        lipgloss::join_horizontal(
460            lipgloss::TOP,
461            &columns.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
462        )
463    }
464
465    /// Determines if an item can be added to the view without exceeding the width limit.
466    ///
467    /// This helper function checks width constraints and returns appropriate truncation
468    /// indicators when content would exceed the configured width.
469    ///
470    /// # Arguments
471    ///
472    /// * `total_width` - Current accumulated width of content
473    /// * `item_width` - Width of the item being considered for addition
474    ///
475    /// # Returns
476    ///
477    /// * `None` - Item can be added without exceeding width
478    /// * `Some(String)` - Item cannot be added; string contains ellipsis if it fits,
479    ///   or empty string if even ellipsis won't fit
480    ///
481    /// # Panics
482    ///
483    /// This function does not panic under normal circumstances.
484    fn should_add_item(&self, total_width: usize, item_width: usize) -> Option<String> {
485        if self.width > 0 && total_width + item_width > self.width {
486            let tail = format!(
487                " {}",
488                self.styles
489                    .ellipsis
490                    .clone()
491                    .inline(true)
492                    .render(&self.ellipsis)
493            );
494            if total_width + lipgloss::width_visible(&tail) < self.width {
495                return Some(tail);
496            }
497            return Some("".to_string());
498        }
499        None // Item can be added
500    }
501
502    /// Creates a new help model with default settings.
503    ///
504    /// **Deprecated**: Use [`Model::new`] instead.
505    ///
506    /// This function provides backwards compatibility with earlier versions
507    /// of the library and matches the Go implementation's deprecated `NewModel`
508    /// variable.
509    ///
510    /// # Examples
511    ///
512    /// ```rust
513    /// # #[allow(deprecated)]
514    /// use bubbletea_widgets::help::Model;
515    ///
516    /// let help = Model::new_model(); // Deprecated
517    /// let help = Model::new();       // Preferred
518    /// ```
519    #[deprecated(since = "0.1.8", note = "Use Model::new() instead")]
520    pub fn new_model() -> Self {
521        Self::new()
522    }
523}
524
525/// Determines if a column of key bindings should be rendered.
526///
527/// A column should be rendered if it contains at least one enabled binding.
528/// This helper function matches the behavior of the Go implementation's
529/// `shouldRenderColumn` function.
530///
531/// # Arguments
532///
533/// * `bindings` - A slice of key binding references to check
534///
535/// # Returns
536///
537/// `true` if any binding in the group is enabled, `false` otherwise.
538///
539/// # Examples
540///
541/// ```rust
542/// use bubbletea_widgets::help::should_render_column;
543/// use bubbletea_widgets::key::Binding;
544/// use crossterm::event::KeyCode;
545///
546/// let enabled_binding = Binding::new(vec![KeyCode::Enter]);
547/// let disabled_binding = Binding::new(vec![KeyCode::F(1)]).with_disabled();
548///
549/// let column = vec![&enabled_binding, &disabled_binding];
550/// assert!(should_render_column(&column));
551///
552/// let empty_column = vec![&disabled_binding];
553/// assert!(!should_render_column(&empty_column));
554/// ```
555pub fn should_render_column(bindings: &[&key::Binding]) -> bool {
556    for binding in bindings {
557        if binding.enabled() {
558            return true;
559        }
560    }
561    false
562}