Skip to main content

vtcode_tui/core_tui/widgets/
slash.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::Rect,
4    style::{Modifier, Style},
5    text::{Line, Span},
6    widgets::{Block, Clear, List, ListItem, Paragraph, Widget, Wrap},
7};
8
9use crate::config::constants::ui;
10use crate::ui::tui::session::{
11    Session,
12    modal::compute_modal_area,
13    slash_palette::{SlashPalette, SlashPaletteSuggestion},
14    terminal_capabilities,
15};
16use crate::ui::tui::style::{ratatui_color_from_ansi, ratatui_style_from_inline};
17use crate::ui::tui::types::InlineTextStyle;
18
19/// Widget for rendering the slash command palette
20///
21/// # Example
22/// ```ignore
23/// SlashWidget::new(session, palette, viewport)
24///     .highlight_style(accent_style)
25///     .render(area, buf);
26/// ```
27pub struct SlashWidget<'a> {
28    session: &'a Session,
29    palette: &'a SlashPalette,
30    viewport: Rect,
31    highlight_style: Option<Style>,
32}
33
34impl<'a> SlashWidget<'a> {
35    /// Create a new SlashWidget with required parameters
36    pub fn new(session: &'a Session, palette: &'a SlashPalette, viewport: Rect) -> Self {
37        Self {
38            session,
39            palette,
40            viewport,
41            highlight_style: None,
42        }
43    }
44
45    /// Set a custom highlight style
46    #[must_use]
47    pub fn highlight_style(mut self, style: Style) -> Self {
48        self.highlight_style = Some(style);
49        self
50    }
51}
52
53impl<'a> Widget for SlashWidget<'a> {
54    fn render(self, _area: Rect, buf: &mut Buffer) {
55        if self.viewport.height == 0 || self.viewport.width == 0 || self.palette.is_empty() {
56            return;
57        }
58
59        let suggestions = self.palette.suggestions();
60        if suggestions.is_empty() {
61            return;
62        }
63
64        let instructions = self.instructions();
65        let modal_height = suggestions.len() + instructions.len() + 2;
66        let area = compute_modal_area(self.viewport, modal_height, 0, 0, true);
67
68        // Clear the background
69        Clear.render(area, buf);
70
71        // Create the bordered block with title
72        let block = Block::bordered()
73            .title("Slash Commands")
74            .border_type(terminal_capabilities::get_border_type())
75            .style(self.session.styles.default_style())
76            .border_style(self.session.styles.border_style());
77        let inner = block.inner(area);
78        block.render(area, buf);
79
80        if inner.height == 0 || inner.width == 0 {
81            return;
82        }
83
84        // Render instructions at the top
85        let inst_height = instructions.len().min(inner.height as usize);
86        if inst_height > 0 {
87            let inst_area = Rect {
88                x: inner.x,
89                y: inner.y,
90                width: inner.width,
91                height: inst_height as u16,
92            };
93            let paragraph = Paragraph::new(instructions).wrap(Wrap { trim: true });
94            paragraph.render(inst_area, buf);
95        }
96
97        // Render the slash command list
98        let list_y = inner.y + inst_height as u16;
99        let list_height = inner.height.saturating_sub(inst_height as u16);
100        if list_height > 0 {
101            let list_area = Rect {
102                x: inner.x,
103                y: list_y,
104                width: inner.width,
105                height: list_height,
106            };
107
108            let list_items = self.create_list_items(suggestions);
109            let list = List::new(list_items)
110                .style(self.session.styles.default_style())
111                .highlight_style(
112                    self.highlight_style
113                        .unwrap_or_else(|| self.slash_highlight_style()),
114                )
115                .highlight_symbol(ui::MODAL_LIST_HIGHLIGHT_FULL)
116                .repeat_highlight_symbol(true);
117
118            // Render the list - since we can't get mutable access to list state,
119            // we'll render without stateful highlighting and let the caller handle selection
120            list.render(list_area, buf);
121        }
122    }
123}
124
125impl<'a> SlashWidget<'a> {
126    /// Create list items from slash palette suggestions
127    fn create_list_items(&self, suggestions: &[SlashPaletteSuggestion]) -> Vec<ListItem<'static>> {
128        suggestions
129            .iter()
130            .map(|suggestion| match suggestion {
131                SlashPaletteSuggestion::Static(command) => ListItem::new(Line::from(vec![
132                    Span::styled(format!("/ {}", command.name), self.slash_name_style()),
133                    Span::raw(" "),
134                    Span::styled(
135                        command.description.to_owned(),
136                        self.slash_description_style(),
137                    ),
138                ])),
139            })
140            .collect()
141    }
142
143    /// Create instructions for the slash palette
144    fn instructions(&self) -> Vec<Line<'static>> {
145        vec![
146            Line::from(Span::styled(
147                ui::SLASH_PALETTE_HINT_PRIMARY.to_owned(),
148                self.session.styles.default_style(),
149            )),
150            Line::from(Span::styled(
151                ui::SLASH_PALETTE_HINT_SECONDARY.to_owned(),
152                self.session
153                    .styles
154                    .default_style()
155                    .add_modifier(Modifier::DIM),
156            )),
157        ]
158    }
159
160    /// Get the highlight style for selected items
161    fn slash_highlight_style(&self) -> Style {
162        let mut style = Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD);
163        if let Some(primary) = self.session.theme.primary.or(self.session.theme.secondary) {
164            style = style.fg(ratatui_color_from_ansi(primary));
165        }
166        style
167    }
168
169    /// Get the style for command names
170    fn slash_name_style(&self) -> Style {
171        let style = InlineTextStyle::default()
172            .bold()
173            .with_color(self.session.theme.primary.or(self.session.theme.foreground));
174        ratatui_style_from_inline(&style, self.session.theme.foreground)
175    }
176
177    /// Get the style for command descriptions
178    fn slash_description_style(&self) -> Style {
179        self.session
180            .styles
181            .default_style()
182            .add_modifier(Modifier::DIM)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::ui::tui::InlineTheme;
190    use crate::ui::tui::types::SlashCommandItem;
191    use insta::assert_snapshot;
192    use ratatui::Terminal;
193    use ratatui::backend::TestBackend;
194
195    fn create_test_session() -> Session {
196        let theme = InlineTheme::default();
197        Session::new(theme, None, 24)
198    }
199
200    fn create_test_palette() -> SlashPalette {
201        let mut palette = SlashPalette::with_commands(vec![
202            SlashCommandItem::new("help", "Show help"),
203            SlashCommandItem::new("hello", "Demo command"),
204        ]);
205        // Add some test suggestions
206        palette.update(Some("he"), 5);
207        palette
208    }
209
210    #[test]
211    fn test_slash_widget_creation() {
212        let session = create_test_session();
213        let palette = create_test_palette();
214        let viewport = Rect::new(0, 0, 80, 24);
215
216        let _widget = SlashWidget::new(&session, &palette, viewport);
217
218        // Widget should be created successfully
219        assert_eq!(viewport.width, 80);
220        assert_eq!(viewport.height, 24);
221    }
222
223    #[test]
224    fn test_slash_widget_render_empty() {
225        let session = create_test_session();
226        let palette = SlashPalette::new(); // Empty palette
227        let viewport = Rect::new(0, 0, 80, 24);
228
229        let backend = TestBackend::new(80, 24);
230        let mut terminal = Terminal::new(backend).unwrap();
231
232        terminal
233            .draw(|frame| {
234                let widget = SlashWidget::new(&session, &palette, viewport);
235                widget.render(viewport, frame.buffer_mut());
236            })
237            .unwrap();
238
239        assert_snapshot!(terminal.backend());
240    }
241
242    #[test]
243    fn test_slash_widget_render_with_suggestions() {
244        let session = create_test_session();
245        let mut palette = SlashPalette::with_commands(vec![
246            SlashCommandItem::new("help", "Show help"),
247            SlashCommandItem::new("hello", "Demo command"),
248        ]);
249        palette.update(Some(""), 5); // This should populate with some default suggestions
250        let viewport = Rect::new(0, 0, 80, 24);
251
252        let backend = TestBackend::new(80, 24);
253        let mut terminal = Terminal::new(backend).unwrap();
254
255        terminal
256            .draw(|frame| {
257                let widget = SlashWidget::new(&session, &palette, viewport);
258                widget.render(viewport, frame.buffer_mut());
259            })
260            .unwrap();
261
262        assert_snapshot!(terminal.backend());
263    }
264}