intelli_shell/widgets/
command.rs

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