Skip to main content

vtcode_tui/core_tui/session/render/
palettes.rs

1use super::*;
2use crate::ui::tui::session::inline_list::{InlineListRow, render_inline_list};
3
4#[derive(Clone)]
5struct FilePaletteRenderRow {
6    text: String,
7    style: Style,
8    selectable: bool,
9    selected: bool,
10}
11
12pub fn split_inline_file_palette_area(session: &mut Session, area: Rect) -> (Rect, Option<Rect>) {
13    if area.height == 0
14        || area.width == 0
15        || session.modal.is_some()
16        || session.history_picker_state.active
17        || !session.file_palette_active
18    {
19        return (area, None);
20    }
21
22    let Some(palette) = session.file_palette.as_ref() else {
23        return (area, None);
24    };
25
26    let instruction_rows = if palette.has_files() {
27        file_palette_instructions(session, palette).len()
28    } else {
29        1
30    };
31
32    let list_rows = if palette.has_files() {
33        let mut rows = palette.current_page_items().len().max(1);
34        if palette.has_more_items() {
35            rows += 1;
36        }
37        rows.min(ui::INLINE_LIST_MAX_ROWS)
38    } else {
39        1
40    };
41
42    let instruction_rows = instruction_rows.min(u16::MAX as usize) as u16;
43    let desired_height = instruction_rows.saturating_add(list_rows.min(u16::MAX as usize) as u16);
44    let max_panel_height = area.height.saturating_sub(1);
45    if max_panel_height <= instruction_rows {
46        return (area, None);
47    }
48
49    let panel_height = desired_height.min(max_panel_height);
50    let chunks =
51        Layout::vertical([Constraint::Min(1), Constraint::Length(panel_height)]).split(area);
52    (chunks[0], Some(chunks[1]))
53}
54
55pub fn render_file_palette(session: &mut Session, frame: &mut Frame<'_>, area: Rect) {
56    if !session.file_palette_active
57        || area.height == 0
58        || area.width == 0
59        || session.modal.is_some()
60    {
61        return;
62    }
63
64    let Some(palette) = session.file_palette.as_ref() else {
65        return;
66    };
67
68    frame.render_widget(Clear, area);
69
70    if !palette.has_files() {
71        let loading = Paragraph::new(Line::from(Span::styled(
72            "Loading workspace files...".to_owned(),
73            default_style(session).add_modifier(Modifier::DIM),
74        )))
75        .wrap(Wrap { trim: true });
76        frame.render_widget(loading, area);
77        return;
78    }
79
80    let instructions = file_palette_instructions(session, palette);
81    let instruction_rows = instructions.len().min(u16::MAX as usize) as u16;
82    let layout = if instruction_rows == 0 {
83        Layout::vertical([Constraint::Min(1)]).split(area)
84    } else {
85        Layout::vertical([Constraint::Length(instruction_rows), Constraint::Min(1)]).split(area)
86    };
87
88    if layout.is_empty() {
89        return;
90    }
91
92    let list_area = if instruction_rows == 0 {
93        layout[0]
94    } else {
95        frame.render_widget(
96            Paragraph::new(instructions).wrap(Wrap { trim: true }),
97            layout[0],
98        );
99        if layout.len() < 2 {
100            return;
101        }
102        layout[1]
103    };
104
105    if list_area.width == 0 || list_area.height == 0 {
106        return;
107    }
108
109    let rows = build_file_palette_rows(session, palette);
110    let item_count = rows.len();
111    if item_count == 0 {
112        return;
113    }
114
115    let default_style = default_style(session);
116    let highlight_style = modal_list_highlight_style(session);
117    let selected_prefix = ui::MODAL_LIST_HIGHLIGHT_FULL.to_owned();
118    let unselected_prefix = " ".repeat(selected_prefix.chars().count());
119
120    let selected = rows.iter().position(|row| row.selectable && row.selected);
121    let rendered_rows = rows
122        .into_iter()
123        .map(|row| {
124            (
125                InlineListRow::single(
126                    Line::from(vec![
127                        Span::styled(unselected_prefix.clone(), default_style),
128                        Span::styled(row.text, row.style),
129                    ]),
130                    if row.selectable {
131                        default_style
132                    } else {
133                        default_style.add_modifier(Modifier::DIM)
134                    },
135                ),
136                1_u16,
137            )
138        })
139        .collect::<Vec<_>>();
140
141    let _ = render_inline_list(
142        frame,
143        list_area,
144        rendered_rows,
145        selected,
146        Some(highlight_style),
147    );
148}
149
150fn build_file_palette_rows(session: &Session, palette: &FilePalette) -> Vec<FilePaletteRenderRow> {
151    let mut rows = Vec::new();
152    let default = default_style(session);
153
154    for (_global_idx, entry, selected) in palette.current_page_items() {
155        let mut style = default;
156        let prefix = if entry.is_dir {
157            style = style.add_modifier(Modifier::BOLD);
158            "↳  "
159        } else {
160            "  · "
161        };
162
163        rows.push(FilePaletteRenderRow {
164            text: format!("{}{}", prefix, entry.display_name),
165            style,
166            selectable: true,
167            selected,
168        });
169    }
170
171    if rows.is_empty() {
172        rows.push(FilePaletteRenderRow {
173            text: "No matching files".to_owned(),
174            style: default.add_modifier(Modifier::DIM),
175            selectable: false,
176            selected: false,
177        });
178    }
179
180    if palette.has_more_items() {
181        let remaining = palette
182            .total_items()
183            .saturating_sub(palette.current_page_number() * 20);
184        rows.push(FilePaletteRenderRow {
185            text: format!("  ... ({} more items)", remaining),
186            style: default.add_modifier(Modifier::DIM | Modifier::ITALIC),
187            selectable: false,
188            selected: false,
189        });
190    }
191
192    rows
193}
194
195fn file_palette_instructions(session: &Session, palette: &FilePalette) -> Vec<Line<'static>> {
196    let mut lines = vec![];
197
198    if palette.is_empty() {
199        lines.push(Line::from(Span::styled(
200            "No files found matching filter".to_owned(),
201            default_style(session).add_modifier(Modifier::DIM),
202        )));
203    } else {
204        let total = palette.total_items();
205        let count_text = if total == 1 {
206            "1 file".to_owned()
207        } else {
208            format!("{} files", total)
209        };
210
211        let nav_text = "↑↓ Navigate · PgUp/PgDn Page · Tab/Enter Select";
212
213        lines.push(Line::from(vec![Span::styled(
214            format!("{} · Esc Close", nav_text),
215            default_style(session),
216        )]));
217
218        lines.push(Line::from(vec![
219            Span::styled(
220                format!("Showing {}", count_text),
221                default_style(session).add_modifier(Modifier::DIM),
222            ),
223            Span::styled(
224                if !palette.filter_query().is_empty() {
225                    format!(" matching '{}'", palette.filter_query())
226                } else {
227                    String::new()
228                },
229                accent_style(session),
230            ),
231        ]));
232    }
233
234    lines
235}
236
237pub(super) fn has_input_status(session: &Session) -> bool {
238    let left_present = session
239        .input_status_left
240        .as_ref()
241        .is_some_and(|value| !value.trim().is_empty());
242    if left_present {
243        return true;
244    }
245    session
246        .input_status_right
247        .as_ref()
248        .is_some_and(|value| !value.trim().is_empty())
249}