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}