Skip to main content

rusty_bubbles/
help.rs

1//! Cleanroom Rust port of upstream Go source file: `help/help.go`
2//! Upstream Target Tag / Version: `v2.1.0`
3//!
4//! <public-docs>
5//! # Help
6//!
7//! A simple help view for Bubble Tea applications.
8//! </public-docs>
9
10use crate::key::Binding;
11use rusty_bubbletea::model::{Cmd, Msg};
12use rusty_lipgloss::{color::Color, style::Style, TOP};
13
14/// KeyMap is a map of keybindings used to generate help. Since it's an
15/// interface it can be any type, though a struct or a map of bindings are
16/// likely candidates.
17///
18/// Note that if a key is disabled (via `key.Binding::set_enabled`) it will
19/// not be rendered in the help view, so in theory generated help should
20/// self-manage.
21pub trait KeyMap {
22    /// ShortHelp returns a slice of bindings to be displayed in the short
23    /// version of the help. The help bubble will render help in the order in
24    /// which the help items are returned here.
25    fn short_help(&self) -> Vec<Binding>;
26
27    /// FullHelp returns an extended group of help items, grouped by columns.
28    /// The help bubble will render the help in the order in which the help
29    /// items are returned here.
30    fn full_help(&self) -> Vec<Vec<Binding>>;
31}
32
33/// Styles is a set of available style definitions for the Help bubble.
34#[derive(Debug, Clone)]
35pub struct Styles {
36    /// Styling for the ellipsis indicator.
37    pub ellipsis: Style,
38
39    /// Styling for the short help
40    pub short_key: Style,
41    /// Styling for the short help
42    pub short_desc: Style,
43    /// Styling for the short help
44    pub short_separator: Style,
45
46    /// Styling for the full help
47    pub full_key: Style,
48    /// Styling for the full help
49    pub full_desc: Style,
50    /// Styling for the full help
51    pub full_separator: Style,
52}
53
54/// DefaultStyles returns a set of default styles for the help bubble. Light
55/// or dark styles can be selected by passing `is_dark`.
56pub fn default_styles(is_dark: bool) -> Styles {
57    let light_dark = rusty_lipgloss::color::light_dark(is_dark);
58
59    let key_style =
60        Style::new().foreground_color(light_dark(Color::parse("#909090"), Color::parse("#626262")));
61    let desc_style =
62        Style::new().foreground_color(light_dark(Color::parse("#B2B2B2"), Color::parse("#4A4A4A")));
63    let sep_style =
64        Style::new().foreground_color(light_dark(Color::parse("#DADADA"), Color::parse("#3C3C3C")));
65
66    Styles {
67        short_key: key_style.clone(),
68        short_desc: desc_style.clone(),
69        short_separator: sep_style.clone(),
70        ellipsis: sep_style.clone(),
71        full_key: key_style,
72        full_desc: desc_style,
73        full_separator: sep_style,
74    }
75}
76
77/// DefaultDarkStyles returns a set of default styles for dark backgrounds.
78pub fn default_dark_styles() -> Styles {
79    default_styles(true)
80}
81
82/// DefaultLightStyles returns a set of default styles for light backgrounds.
83pub fn default_light_styles() -> Styles {
84    default_styles(false)
85}
86
87/// Model contains the state of the help view.
88#[derive(Debug, Clone)]
89pub struct Model {
90    /// if true, render the "full" help menu
91    pub show_all: bool,
92
93    /// The separator used in the short help.
94    pub short_separator: String,
95    /// The separator used in the full help.
96    pub full_separator: String,
97
98    /// The symbol we use in the short help when help items have been
99    /// truncated due to width. Periods of ellipsis by default.
100    pub ellipsis: String,
101
102    /// The styles used by the help view.
103    pub styles: Styles,
104
105    width: usize,
106}
107
108/// New creates a new help view with some useful defaults.
109pub fn new() -> Model {
110    Model {
111        show_all: false,
112        short_separator: " • ".to_string(),
113        full_separator: "    ".to_string(),
114        ellipsis: "…".to_string(),
115        styles: default_dark_styles(),
116        width: 0,
117    }
118}
119
120impl Model {
121    /// Update helps satisfy the Bubble Tea Model interface. It's a no-op.
122    pub fn update(&mut self, _msg: &dyn Msg) -> Cmd {
123        None
124    }
125
126    /// View renders the help view's current state.
127    pub fn view(&self, k: &dyn KeyMap) -> String {
128        if self.show_all {
129            return self.full_help_view(&k.full_help());
130        }
131        self.short_help_view(&k.short_help())
132    }
133
134    /// SetWidth sets the maximum width for the help view.
135    pub fn set_width(&mut self, w: usize) {
136        self.width = w;
137    }
138
139    /// Width returns the maximum width for the help view.
140    pub fn width(&self) -> usize {
141        self.width
142    }
143
144    /// ShortHelpView renders a single line help view from a slice of
145    /// keybindings. If the line is longer than the maximum width it will be
146    /// gracefully truncated, showing only as many help items as possible.
147    pub fn short_help_view(&self, bindings: &[Binding]) -> String {
148        if bindings.is_empty() {
149            return String::new();
150        }
151
152        let mut b = String::new();
153        let mut total_width = 0;
154        let separator = self
155            .styles
156            .short_separator
157            .clone()
158            .inline(true)
159            .render(&self.short_separator);
160
161        for (i, kb) in bindings.iter().enumerate() {
162            if !kb.enabled() {
163                continue;
164            }
165
166            // Sep
167            let sep = if total_width > 0 && i < bindings.len() {
168                separator.clone()
169            } else {
170                String::new()
171            };
172
173            // Item
174            let str = sep
175                + &self
176                    .styles
177                    .short_key
178                    .clone()
179                    .inline(true)
180                    .render(&kb.help().key)
181                + " "
182                + &self
183                    .styles
184                    .short_desc
185                    .clone()
186                    .inline(true)
187                    .render(&kb.help().desc);
188            let w = rusty_lipgloss::size::width(&str);
189
190            // Tail
191            if let (tail, false) = self.should_add_item(total_width, w) {
192                if !tail.is_empty() {
193                    b.push_str(&tail);
194                }
195                break;
196            }
197
198            total_width += w;
199            b.push_str(&str);
200        }
201
202        b
203    }
204
205    /// FullHelpView renders help columns from a slice of key binding slices.
206    /// Each top level slice entry renders into a column.
207    pub fn full_help_view(&self, groups: &[Vec<Binding>]) -> String {
208        if groups.is_empty() {
209            return String::new();
210        }
211
212        let mut out: Vec<String> = Vec::new();
213
214        let mut total_width = 0;
215        let separator = self
216            .styles
217            .full_separator
218            .clone()
219            .inline(true)
220            .render(&self.full_separator);
221
222        // Iterate over groups to build columns
223        for (i, group) in groups.iter().enumerate() {
224            if group.is_empty() || !should_render_column(group) {
225                continue;
226            }
227            let mut keys: Vec<String> = Vec::new();
228            let mut descriptions: Vec<String> = Vec::new();
229
230            // Sep
231            let sep = if total_width > 0 && i < groups.len() {
232                separator.clone()
233            } else {
234                String::new()
235            };
236
237            // Separate keys and descriptions into different slices
238            for kb in group {
239                if !kb.enabled() {
240                    continue;
241                }
242                keys.push(kb.help().key);
243                descriptions.push(kb.help().desc);
244            }
245
246            // Column
247            let key_col = self.styles.full_key.clone().render(&keys.join("\n"));
248            let desc_col = self
249                .styles
250                .full_desc
251                .clone()
252                .render(&descriptions.join("\n"));
253            let col = rusty_lipgloss::join::join_horizontal(TOP, &[&sep, &key_col, " ", &desc_col]);
254            let w = rusty_lipgloss::size::width(&col);
255
256            // Tail
257            if let (tail, false) = self.should_add_item(total_width, w) {
258                if !tail.is_empty() {
259                    out.push(tail);
260                }
261                break;
262            }
263
264            total_width += w;
265            out.push(col);
266        }
267
268        let refs: Vec<&str> = out.iter().map(|s| s.as_str()).collect();
269        rusty_lipgloss::join::join_horizontal(TOP, &refs)
270    }
271
272    fn should_add_item(&self, total_width: usize, width: usize) -> (String, bool) {
273        // If there's room for an ellipsis, print that.
274        if self.width > 0 && total_width + width > self.width {
275            let tail = String::from(" ")
276                + &self
277                    .styles
278                    .ellipsis
279                    .clone()
280                    .inline(true)
281                    .render(&self.ellipsis);
282
283            if total_width + rusty_lipgloss::size::width(&tail) < self.width {
284                return (tail, false);
285            }
286        }
287        (String::new(), true)
288    }
289}
290
291fn should_render_column(b: &[Binding]) -> bool {
292    b.iter().any(|v| v.enabled())
293}