intelli_shell/widgets/
command.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::{Rect, Size},
4    style::Style,
5    text::{Line, Span, Text},
6    widgets::Widget,
7};
8
9use crate::{
10    config::Theme,
11    model::Command,
12    utils::{COMMAND_VARIABLE_REGEX, SplitCaptures, SplitItem, truncate_spans_with_ellipsis},
13};
14
15const DEFAULT_STYLE: Style = Style::new();
16
17/// How much width the description is allowed to take if the command doesn't fit
18const DESCRIPTION_WIDTH_PERCENT: f32 = 0.3;
19
20/// Widget to render a [`Command`]
21#[derive(Clone)]
22pub struct CommandWidget<'a>(CommandWidgetInner<'a>, Size);
23impl<'a> CommandWidget<'a> {
24    /// Builds a new [`CommandWidget`]
25    pub fn new(
26        command: &'a Command,
27        theme: &Theme,
28        inline: bool,
29        is_highlighted: bool,
30        is_discarded: bool,
31        plain_style: bool,
32    ) -> Self {
33        let mut line_style = DEFAULT_STYLE;
34        if is_highlighted && let Some(bg_color) = theme.highlight {
35            line_style = line_style.bg(bg_color.into());
36        }
37        // Determine the right styles to use based on highlighted and discarded status
38        let (primary_style, secondary_style, comment_style, accent_style) =
39            match (plain_style, is_discarded, is_highlighted) {
40                // Discarded
41                (_, true, false) => (theme.secondary, theme.secondary, theme.secondary, theme.secondary),
42                // Discarded & highlighted
43                (_, true, true) => (
44                    theme.highlight_secondary,
45                    theme.highlight_secondary,
46                    theme.highlight_secondary,
47                    theme.highlight_secondary,
48                ),
49                // Plain style, regular
50                (true, false, false) => (theme.primary, theme.primary, theme.comment, theme.accent),
51                // Plain style, highlighted
52                (true, false, true) => (
53                    theme.highlight_primary,
54                    theme.highlight_primary,
55                    theme.highlight_comment,
56                    theme.highlight_accent,
57                ),
58                // Regular
59                (false, false, false) => (theme.primary, theme.secondary, theme.comment, theme.accent),
60                // Highlighted
61                (false, false, true) => (
62                    theme.highlight_primary,
63                    theme.highlight_secondary,
64                    theme.highlight_comment,
65                    theme.highlight_accent,
66                ),
67            };
68
69        // Build command spans
70        let cmd_splitter = SplitCaptures::new(&COMMAND_VARIABLE_REGEX, &command.cmd);
71        let cmd_spans = cmd_splitter
72            .map(|e| match e {
73                SplitItem::Unmatched(t) => Span::styled(t, primary_style),
74                SplitItem::Captured(l) => Span::styled(l.get(0).unwrap().as_str(), secondary_style),
75            })
76            .collect::<Vec<_>>();
77
78        if inline {
79            // When inline, display a single line with the command, alias and the first line of the description
80            let mut description_spans = Vec::new();
81            if command.description.is_some() || command.alias.is_some() {
82                description_spans.push(Span::styled(" # ", comment_style));
83                if let Some(ref alias) = command.alias {
84                    description_spans.push(Span::styled("[", accent_style));
85                    description_spans.push(Span::styled(alias, accent_style));
86                    description_spans.push(Span::styled("] ", accent_style));
87                }
88                if let Some(ref description) = command.description
89                    && let Some(line) = description.lines().next()
90                {
91                    description_spans.push(Span::styled(line, comment_style));
92                }
93            }
94
95            // Calculate total size for the list view's layout engine
96            let total_width = cmd_spans.iter().map(|s| s.width() as u16).sum::<u16>()
97                + description_spans.iter().map(|s| s.width() as u16).sum::<u16>();
98
99            let renderer = InlineCommandRenderer {
100                cmd_spans,
101                description_spans,
102                line_style,
103            };
104            Self(CommandWidgetInner::Inline(renderer), Size::new(total_width, 1))
105        } else {
106            // When not inline, display the full description including the alias followed by the command
107            let mut lines = Vec::new();
108            if let Some(ref description) = command.description {
109                let mut alias_included = command.alias.is_none();
110                for line in description.lines() {
111                    if !alias_included && let Some(ref alias) = command.alias {
112                        let parts = vec![
113                            Span::styled("# ", comment_style),
114                            Span::styled("[", accent_style),
115                            Span::styled(alias, accent_style),
116                            Span::styled("] ", accent_style),
117                            Span::styled(line, comment_style),
118                        ];
119                        lines.push(Line::from(parts));
120                        alias_included = true;
121                    } else {
122                        lines.push(Line::from(vec![Span::raw("# "), Span::raw(line)]).style(comment_style));
123                    }
124                }
125            } else if let Some(ref alias) = command.alias {
126                let parts = vec![
127                    Span::styled("# ", comment_style),
128                    Span::styled("[", accent_style),
129                    Span::styled(alias, accent_style),
130                    Span::styled("]", accent_style),
131                ];
132                lines.push(Line::from(parts));
133            }
134            let mut parts = Vec::new();
135            cmd_spans.into_iter().for_each(|s| parts.push(s));
136            lines.push(Line::from(parts));
137
138            let text = Text::from(lines).style(line_style);
139            let width = text.width() as u16;
140            let height = text.height() as u16;
141
142            Self(CommandWidgetInner::Block(text), Size::new(width, height))
143        }
144    }
145
146    /// Retrieves the size of this widget
147    pub fn size(&self) -> Size {
148        self.1
149    }
150}
151
152impl<'a> Widget for CommandWidget<'a> {
153    fn render(self, area: Rect, buf: &mut Buffer)
154    where
155        Self: Sized,
156    {
157        match self.0 {
158            CommandWidgetInner::Inline(w) => w.render(area, buf),
159            CommandWidgetInner::Block(w) => w.render(area, buf),
160        }
161    }
162}
163
164/// An enum to dispatch rendering to the correct widget implementation
165#[derive(Clone)]
166enum CommandWidgetInner<'a> {
167    Inline(InlineCommandRenderer<'a>),
168    Block(Text<'a>),
169}
170
171/// A widget to render a command in a single line, intelligently truncating parts
172#[derive(Clone)]
173struct InlineCommandRenderer<'a> {
174    cmd_spans: Vec<Span<'a>>,
175    description_spans: Vec<Span<'a>>,
176    line_style: Style,
177}
178impl<'a> Widget for InlineCommandRenderer<'a> {
179    fn render(self, area: Rect, buf: &mut Buffer) {
180        if area.width == 0 || area.height == 0 {
181            return;
182        }
183
184        // Apply the base background style across the whole line
185        buf.set_style(area, self.line_style);
186
187        // Calculate the total required width of all spans
188        let cmd_width: u16 = self.cmd_spans.iter().map(|s| s.width() as u16).sum();
189        let desc_width: u16 = self.description_spans.iter().map(|s| s.width() as u16).sum();
190        let total_width = cmd_width.saturating_add(desc_width);
191
192        // If everything fits on the line, render them sequentially
193        if total_width <= area.width {
194            let mut combined_spans = self.cmd_spans;
195            combined_spans.extend(self.description_spans);
196            buf.set_line(area.x, area.y, &Line::from(combined_spans), area.width);
197        } else {
198            // Otherwise, truncate if required
199            let min_description_width = (area.width as f32 * DESCRIPTION_WIDTH_PERCENT).floor() as u16;
200            let desired_desc_width = desc_width.min(min_description_width);
201            let available_space_for_cmd = area.width.saturating_sub(desired_desc_width);
202
203            // If command fits fully, the description fills the remaining space
204            if cmd_width <= available_space_for_cmd {
205                // Render the full command
206                buf.set_line(area.x, area.y, &Line::from(self.cmd_spans), cmd_width);
207
208                // Truncate the description to whatever space is left and render it
209                let remaining_space = area.width.saturating_sub(cmd_width);
210                if remaining_space > 0 {
211                    let (truncated_desc_spans, _) =
212                        truncate_spans_with_ellipsis(&self.description_spans, remaining_space);
213                    buf.set_line(
214                        area.x + cmd_width,
215                        area.y,
216                        &Line::from(truncated_desc_spans),
217                        remaining_space,
218                    );
219                }
220            } else {
221                // Otherwise, the command is too long and must be truncated to accomodate some room for the description
222                let (truncated_desc_spans, truncated_desc_width) =
223                    truncate_spans_with_ellipsis(&self.description_spans, desired_desc_width);
224
225                if truncated_desc_width > 0 {
226                    let desc_start_x = area.x + area.width.saturating_sub(truncated_desc_width);
227                    buf.set_line(
228                        desc_start_x,
229                        area.y,
230                        &Line::from(truncated_desc_spans),
231                        truncated_desc_width,
232                    );
233                }
234
235                let final_cmd_width = area.width.saturating_sub(truncated_desc_width);
236                if final_cmd_width > 0 {
237                    let (truncated_cmd_spans, _) = truncate_spans_with_ellipsis(&self.cmd_spans, final_cmd_width);
238                    buf.set_line(area.x, area.y, &Line::from(truncated_cmd_spans), final_cmd_width);
239                }
240            }
241        }
242    }
243}