vtcode_tui/core_tui/widgets/
palette.rs1use 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
14pub 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 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 #[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 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 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 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}