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 lipgloss_extras::lipgloss;
8use lipgloss_extras::prelude::*;
9
10/// A trait that defines the key bindings to be displayed in the help view.
11///
12/// Any model that uses the help component should implement this trait to provide
13/// the key bindings that the help view will render.
14pub trait KeyMap {
15    /// Returns a slice of key bindings for the short help view.
16    fn short_help(&self) -> Vec<&key::Binding>;
17    /// Returns a nested slice of key bindings for the full help view.
18    /// Each inner slice represents a column in the help view.
19    fn full_help(&self) -> Vec<Vec<&key::Binding>>;
20}
21
22/// A set of styles for the help component.
23///
24/// This structure defines all the visual styling options available for customizing
25/// the appearance of the help view. Each field controls a specific visual element.
26///
27/// # Examples
28///
29/// ```rust
30/// use bubbletea_widgets::help::Styles;
31/// use lipgloss_extras::prelude::*;
32///
33/// let custom_styles = Styles {
34///     short_key: Style::new().foreground(Color::from("#FF6B6B")),
35///     short_desc: Style::new().foreground(Color::from("#4ECDC4")),
36///     ..Default::default()
37/// };
38/// ```
39#[derive(Debug, Clone)]
40pub struct Styles {
41    /// Style for the ellipsis character when content is truncated.
42    pub ellipsis: Style,
43    /// Style for key names in the short help view.
44    pub short_key: Style,
45    /// Style for descriptions in the short help view.
46    pub short_desc: Style,
47    /// Style for the separator between items in the short help view.
48    pub short_separator: Style,
49    /// Style for key names in the full help view.
50    pub full_key: Style,
51    /// Style for descriptions in the full help view.
52    pub full_desc: Style,
53    /// Style for the separator between columns in the full help view.
54    pub full_separator: Style,
55}
56
57impl Default for Styles {
58    /// Creates default styles with a subtle color scheme.
59    ///
60    /// The default styling uses muted colors that work well in most terminal environments:
61    /// - Keys are styled in a medium gray (#909090)
62    /// - Descriptions use a lighter gray (#B2B2B2)  
63    /// - Separators use an even lighter gray (#DDDADA)
64    ///
65    /// # Examples
66    ///
67    /// ```rust
68    /// use bubbletea_widgets::help::Styles;
69    ///
70    /// let styles = Styles::default();
71    /// ```
72    fn default() -> Self {
73        let key_style = Style::new().foreground(Color::from("#909090"));
74        let desc_style = Style::new().foreground(Color::from("#B2B2B2"));
75        let sep_style = Style::new().foreground(Color::from("#DDDADA"));
76
77        Self {
78            ellipsis: sep_style.clone(),
79            short_key: key_style.clone(),
80            short_desc: desc_style.clone(),
81            short_separator: sep_style.clone(),
82            full_key: key_style,
83            full_desc: desc_style,
84            full_separator: sep_style,
85        }
86    }
87}
88
89/// The help model that manages help view state and rendering.
90///
91/// This is the main component for displaying help information in terminal applications.
92/// It can show either a compact single-line view or an expanded multi-column view
93/// based on the `show_all` toggle.
94///
95/// # Examples
96///
97/// Basic usage:
98/// ```rust
99/// use bubbletea_widgets::help::{Model, KeyMap};
100/// use bubbletea_widgets::key;
101///
102/// // Create a new help model
103/// let help = Model::new().with_width(80);
104///
105/// // Implement KeyMap for your application
106/// struct AppKeyMap;
107/// impl KeyMap for AppKeyMap {
108///     fn short_help(&self) -> Vec<&key::Binding> {
109///         vec![] // Your key bindings
110///     }
111///     fn full_help(&self) -> Vec<Vec<&key::Binding>> {
112///         vec![vec![]] // Your grouped key bindings
113///     }
114/// }
115///
116/// let keymap = AppKeyMap;
117/// let help_text = help.view(&keymap);
118/// ```
119#[derive(Debug, Clone)]
120pub struct Model {
121    /// Toggles between short (single-line) and full (multi-column) help view.
122    /// When `false`, shows compact help; when `true`, shows detailed help.
123    pub show_all: bool,
124    /// The maximum width of the help view in characters.
125    /// When set to 0, no width limit is enforced.
126    pub width: usize,
127
128    /// The separator string used between items in the short help view.
129    /// Default is " • " (bullet with spaces).
130    pub short_separator: String,
131    /// The separator string used between columns in the full help view.
132    /// Default is "    " (four spaces).
133    pub full_separator: String,
134    /// The character displayed when help content is truncated due to width constraints.
135    /// Default is "…" (horizontal ellipsis).
136    pub ellipsis: String,
137
138    /// The styling configuration for all visual elements of the help view.
139    pub styles: Styles,
140}
141
142impl Default for Model {
143    /// Creates a new help model with sensible defaults.
144    ///
145    /// Default configuration:
146    /// - `show_all`: false (shows short help)
147    /// - `width`: 0 (no width limit)
148    /// - `short_separator`: " • "
149    /// - `full_separator`: "    " (4 spaces)
150    /// - `ellipsis`: "…"
151    /// - `styles`: Default styles
152    ///
153    /// # Examples
154    ///
155    /// ```rust
156    /// use bubbletea_widgets::help::Model;
157    ///
158    /// let help = Model::default();
159    /// assert_eq!(help.show_all, false);
160    /// assert_eq!(help.width, 0);
161    /// ```
162    fn default() -> Self {
163        Self {
164            show_all: false,
165            width: 0,
166            short_separator: " • ".to_string(),
167            full_separator: "    ".to_string(),
168            ellipsis: "…".to_string(),
169            styles: Styles::default(),
170        }
171    }
172}
173
174impl Model {
175    /// Creates a new help model with default settings.
176    ///
177    /// This is equivalent to calling `Model::default()` but provides a more
178    /// conventional constructor-style API.
179    ///
180    /// # Examples
181    ///
182    /// ```rust
183    /// use bubbletea_widgets::help::Model;
184    ///
185    /// let help = Model::new();
186    /// ```
187    pub fn new() -> Self {
188        Self::default()
189    }
190
191    /// Sets the maximum width of the help view.
192    ///
193    /// When a width is set, the help view will truncate content that exceeds
194    /// this limit, showing an ellipsis to indicate truncation.
195    ///
196    /// # Arguments
197    ///
198    /// * `width` - Maximum width in characters. Use 0 for no limit.
199    ///
200    /// # Examples
201    ///
202    /// ```rust
203    /// use bubbletea_widgets::help::Model;
204    ///
205    /// let help = Model::new().with_width(80);
206    /// assert_eq!(help.width, 80);
207    /// ```
208    pub fn with_width(mut self, width: usize) -> Self {
209        self.width = width;
210        self
211    }
212
213    /// Renders the help view based on the current model state.
214    ///
215    /// This is the main rendering function that switches between short and full
216    /// help views based on the `show_all` flag.
217    ///
218    /// # Arguments
219    ///
220    /// * `keymap` - An object implementing the `KeyMap` trait that provides
221    ///   the key bindings to display.
222    ///
223    /// # Returns
224    ///
225    /// A formatted string ready for display in the terminal.
226    ///
227    /// # Examples
228    ///
229    /// ```rust
230    /// use bubbletea_widgets::help::{Model, KeyMap};
231    /// use bubbletea_widgets::key;
232    ///
233    /// struct MyKeyMap;
234    /// impl KeyMap for MyKeyMap {
235    ///     fn short_help(&self) -> Vec<&key::Binding> { vec![] }
236    ///     fn full_help(&self) -> Vec<Vec<&key::Binding>> { vec![] }
237    /// }
238    ///
239    /// let help = Model::new();
240    /// let keymap = MyKeyMap;
241    /// let rendered = help.view(&keymap);
242    /// ```
243    pub fn view<K: KeyMap>(&self, keymap: &K) -> String {
244        if self.show_all {
245            self.full_help_view(keymap.full_help())
246        } else {
247            self.short_help_view(keymap.short_help())
248        }
249    }
250
251    /// Renders a compact single-line help view.
252    ///
253    /// This view displays key bindings in a horizontal layout, separated by
254    /// the configured separator. If the content exceeds the specified width,
255    /// it will be truncated with an ellipsis.
256    ///
257    /// # Arguments
258    ///
259    /// * `bindings` - A vector of key bindings to display.
260    ///
261    /// # Returns
262    ///
263    /// A single-line string containing the formatted help text.
264    ///
265    /// # Examples
266    ///
267    /// ```rust
268    /// use bubbletea_widgets::help::Model;
269    /// use bubbletea_widgets::key;
270    ///
271    /// let help = Model::new();
272    /// let bindings = vec![]; // Your key bindings
273    /// let short_help = help.short_help_view(bindings);
274    /// ```
275    pub fn short_help_view(&self, bindings: Vec<&key::Binding>) -> String {
276        if bindings.is_empty() {
277            return String::new();
278        }
279
280        let mut builder = String::new();
281        let mut total_width = 0;
282        let separator = self
283            .styles
284            .short_separator
285            .clone()
286            .inline(true)
287            .render(&self.short_separator);
288
289        for (i, kb) in bindings.iter().enumerate() {
290            // Skip disabled bindings
291            if !kb.enabled() {
292                continue;
293            }
294
295            let sep = if total_width > 0 && i < bindings.len() {
296                &separator
297            } else {
298                ""
299            };
300
301            // Format: "key description"
302            let help = kb.help();
303            let key_part = self.styles.short_key.clone().inline(true).render(&help.key);
304            let desc_part = self
305                .styles
306                .short_desc
307                .clone()
308                .inline(true)
309                .render(&help.desc);
310            let item_str = format!("{}{} {}", sep, key_part, desc_part);
311
312            let item_width = lipgloss::width_visible(&item_str);
313
314            if let Some(tail) = self.should_add_item(total_width, item_width) {
315                if !tail.is_empty() {
316                    builder.push_str(&tail);
317                }
318                break;
319            }
320
321            total_width += item_width;
322            builder.push_str(&item_str);
323        }
324        builder
325    }
326
327    /// Renders a detailed multi-column help view.
328    ///
329    /// This view organizes key bindings into columns, with each group of bindings
330    /// forming a separate column. Keys and descriptions are aligned vertically
331    /// within each column.
332    ///
333    /// # Arguments
334    ///
335    /// * `groups` - A vector of key binding groups, where each group becomes
336    ///   a column in the output.
337    ///
338    /// # Returns
339    ///
340    /// A multi-line string containing the formatted help text with proper
341    /// column alignment.
342    ///
343    /// # Examples
344    ///
345    /// ```rust
346    /// use bubbletea_widgets::help::Model;
347    /// use bubbletea_widgets::key;
348    ///
349    /// let help = Model::new();
350    /// let groups = vec![vec![]]; // Your grouped key bindings
351    /// let full_help = help.full_help_view(groups);
352    /// ```
353    pub fn full_help_view(&self, groups: Vec<Vec<&key::Binding>>) -> String {
354        if groups.is_empty() {
355            return String::new();
356        }
357
358        let mut columns = Vec::new();
359        let mut total_width = 0;
360        let separator = self
361            .styles
362            .full_separator
363            .clone()
364            .inline(true)
365            .render(&self.full_separator);
366
367        for (i, group) in groups.iter().enumerate() {
368            if group.is_empty() {
369                continue;
370            }
371
372            let sep = if i > 0 { &separator } else { "" };
373
374            let keys: Vec<String> = group
375                .iter()
376                .filter(|b| b.enabled())
377                .map(|b| b.help().key.clone())
378                .collect();
379            let descs: Vec<String> = group
380                .iter()
381                .filter(|b| b.enabled())
382                .map(|b| b.help().desc.clone())
383                .collect();
384
385            if keys.is_empty() {
386                continue;
387            }
388
389            let key_column = self
390                .styles
391                .full_key
392                .clone()
393                .inline(true)
394                .render(&keys.join("\n"));
395            let desc_column = self
396                .styles
397                .full_desc
398                .clone()
399                .inline(true)
400                .render(&descs.join("\n"));
401
402            let col_str =
403                lipgloss::join_horizontal(lipgloss::TOP, &[sep, &key_column, " ", &desc_column]);
404
405            let col_width = lipgloss::width_visible(&col_str);
406
407            if let Some(tail) = self.should_add_item(total_width, col_width) {
408                if !tail.is_empty() {
409                    columns.push(tail);
410                }
411                break;
412            }
413
414            total_width += col_width;
415            columns.push(col_str);
416        }
417
418        lipgloss::join_horizontal(
419            lipgloss::TOP,
420            &columns.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
421        )
422    }
423
424    /// Determines if an item can be added to the view without exceeding the width limit.
425    ///
426    /// This helper function checks width constraints and returns appropriate truncation
427    /// indicators when content would exceed the configured width.
428    ///
429    /// # Arguments
430    ///
431    /// * `total_width` - Current accumulated width of content
432    /// * `item_width` - Width of the item being considered for addition
433    ///
434    /// # Returns
435    ///
436    /// * `None` - Item can be added without exceeding width
437    /// * `Some(String)` - Item cannot be added; string contains ellipsis if it fits,
438    ///   or empty string if even ellipsis won't fit
439    ///
440    /// # Panics
441    ///
442    /// This function does not panic under normal circumstances.
443    fn should_add_item(&self, total_width: usize, item_width: usize) -> Option<String> {
444        if self.width > 0 && total_width + item_width > self.width {
445            let tail = format!(
446                " {}",
447                self.styles
448                    .ellipsis
449                    .clone()
450                    .inline(true)
451                    .render(&self.ellipsis)
452            );
453            if total_width + lipgloss::width_visible(&tail) < self.width {
454                return Some(tail);
455            }
456            return Some("".to_string());
457        }
458        None // Item can be added
459    }
460}