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;
8use lipgloss::{style::Style, Color};
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::{style::Style, Color};
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}