Skip to main content

intelli_shell/widgets/
command.rs

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