Skip to main content

vtcode_tui/core_tui/widgets/
palette.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, file_palette::FilePalette, modal::compute_modal_area, terminal_capabilities,
12};
13
14/// Widget for rendering the file browser palette
15///
16/// # Example
17/// ```ignore
18/// FilePaletteWidget::new(session, palette, viewport)
19///     .highlight_style(accent_style)
20///     .render(area, buf);
21/// ```
22pub struct FilePaletteWidget<'a> {
23    session: &'a Session,
24    palette: &'a FilePalette,
25    viewport: Rect,
26    highlight_style: Option<Style>,
27}
28
29impl<'a> FilePaletteWidget<'a> {
30    /// Create a new FilePaletteWidget with required parameters
31    pub fn new(session: &'a Session, palette: &'a FilePalette, viewport: Rect) -> Self {
32        Self {
33            session,
34            palette,
35            viewport,
36            highlight_style: None,
37        }
38    }
39
40    /// Set a custom highlight style
41    #[must_use]
42    pub fn highlight_style(mut self, style: Style) -> Self {
43        self.highlight_style = Some(style);
44        self
45    }
46}
47
48impl<'a> Widget for FilePaletteWidget<'a> {
49    fn render(self, _area: Rect, buf: &mut Buffer) {
50        if self.viewport.height == 0 || self.viewport.width == 0 {
51            return;
52        }
53
54        // Show loading state if no files loaded yet
55        if !self.palette.has_files() {
56            self.render_loading(buf);
57            return;
58        }
59
60        let items = self.palette.current_page_items();
61        if items.is_empty() && self.palette.filter_query().is_empty() {
62            return;
63        }
64
65        let instructions = self.instructions();
66        let modal_height = items.len()
67            + instructions.len()
68            + 2
69            + if self.palette.has_more_items() { 1 } else { 0 };
70        let area = compute_modal_area(self.viewport, modal_height, 0, 0, true);
71
72        Clear.render(area, buf);
73        let block = Block::bordered()
74            .title(format!(
75                "File Browser (Page {}/{})",
76                self.palette.current_page_number(),
77                self.palette.total_pages()
78            ))
79            .border_type(terminal_capabilities::get_border_type())
80            .style(self.session.styles.default_style())
81            .border_style(self.session.styles.border_style());
82        let inner = block.inner(area);
83        block.render(area, buf);
84
85        if inner.height == 0 || inner.width == 0 {
86            return;
87        }
88
89        // Render instructions
90        let inst_height = instructions.len().min(inner.height as usize);
91        if inst_height > 0 {
92            let inst_area = Rect {
93                x: inner.x,
94                y: inner.y,
95                width: inner.width,
96                height: inst_height as u16,
97            };
98            let paragraph = Paragraph::new(instructions).wrap(Wrap { trim: true });
99            paragraph.render(inst_area, buf);
100        }
101
102        // Render file list
103        let list_y = inner.y + inst_height as u16;
104        let list_height = inner.height.saturating_sub(inst_height as u16);
105        if list_height > 0 {
106            let list_area = Rect {
107                x: inner.x,
108                y: list_y,
109                width: inner.width,
110                height: list_height,
111            };
112
113            let mut list_items: Vec<ListItem> = items
114                .iter()
115                .map(|(_, entry, is_selected)| {
116                    let base_style = if *is_selected {
117                        self.session.styles.modal_list_highlight_style()
118                    } else {
119                        self.session.styles.default_style()
120                    };
121
122                    let mut style = base_style;
123                    let (prefix, is_dir) = if entry.is_dir {
124                        ("↳  ", true)
125                    } else {
126                        ("  · ", false)
127                    };
128
129                    if is_dir {
130                        style = style.add_modifier(Modifier::BOLD);
131                    }
132
133                    let display = format!(
134                        "{}{}",
135                        prefix,
136                        if entry.is_dir {
137                            format!("{}/", entry.display_name)
138                        } else {
139                            entry.display_name.clone()
140                        }
141                    );
142                    ListItem::new(Line::from(display).style(style))
143                })
144                .collect();
145
146            if self.palette.has_more_items() {
147                let continuation_style = self
148                    .session
149                    .styles
150                    .default_style()
151                    .add_modifier(Modifier::DIM | Modifier::ITALIC);
152                list_items.push(ListItem::new(Line::from(Span::styled(
153                    format!(
154                        "  ... ({} more items)",
155                        self.palette
156                            .total_items()
157                            .saturating_sub(self.palette.current_page_number() * 20)
158                    ),
159                    continuation_style,
160                ))));
161            }
162
163            let list = List::new(list_items)
164                .style(self.session.styles.default_style())
165                .highlight_symbol(ui::MODAL_LIST_HIGHLIGHT_FULL)
166                .repeat_highlight_symbol(true);
167            list.render(list_area, buf);
168        }
169    }
170}
171
172impl<'a> FilePaletteWidget<'a> {
173    fn render_loading(&self, buf: &mut Buffer) {
174        let modal_height = 3;
175        let area = compute_modal_area(self.viewport, modal_height, 0, 0, true);
176
177        Clear.render(area, buf);
178        let block = Block::bordered()
179            .title("File Browser")
180            .border_type(terminal_capabilities::get_border_type())
181            .style(self.session.styles.default_style())
182            .border_style(self.session.styles.border_style());
183        let inner = block.inner(area);
184        block.render(area, buf);
185
186        if inner.height > 0 && inner.width > 0 {
187            let loading_text = vec![Line::from(Span::styled(
188                "Loading workspace files...".to_owned(),
189                self.session
190                    .styles
191                    .default_style()
192                    .add_modifier(Modifier::DIM),
193            ))];
194            let paragraph = Paragraph::new(loading_text).wrap(Wrap { trim: true });
195            paragraph.render(inner, buf);
196        }
197    }
198
199    fn instructions(&self) -> Vec<Line<'static>> {
200        let mut lines = vec![];
201
202        if self.palette.is_empty() {
203            lines.push(Line::from(Span::styled(
204                "No files found matching filter".to_owned(),
205                self.session
206                    .styles
207                    .default_style()
208                    .add_modifier(Modifier::DIM),
209            )));
210        } else {
211            let total = self.palette.total_items();
212            let count_text = if total == 1 {
213                "1 file".to_owned()
214            } else {
215                format!("{} files", total)
216            };
217
218            let nav_text = "↑↓ Navigate · PgUp/PgDn Page · Tab/Enter Select";
219
220            lines.push(Line::from(vec![Span::styled(
221                format!("{} · Esc Close", nav_text),
222                self.session.styles.default_style(),
223            )]));
224
225            lines.push(Line::from(vec![
226                Span::styled(
227                    format!("Showing {}", count_text),
228                    self.session
229                        .styles
230                        .default_style()
231                        .add_modifier(Modifier::DIM),
232                ),
233                Span::styled(
234                    if !self.palette.filter_query().is_empty() {
235                        format!(" matching '{}'", self.palette.filter_query())
236                    } else {
237                        String::new()
238                    },
239                    self.session.styles.accent_style(),
240                ),
241            ]));
242        }
243
244        lines
245    }
246}