Skip to main content

vtcode_tui/core_tui/session/
impl_render.rs

1use super::*;
2
3#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4enum BottomPanelKind {
5    None,
6    InlineModal,
7    FilePalette,
8    HistoryPicker,
9    SlashPalette,
10}
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13struct BottomPanelSpec {
14    kind: BottomPanelKind,
15    height: u16,
16}
17
18impl Session {
19    pub fn render(&mut self, frame: &mut Frame<'_>) {
20        let viewport = frame.area();
21        if viewport.height == 0 || viewport.width == 0 {
22            return;
23        }
24
25        // Clear entire frame if modal was just closed to remove artifacts
26        if self.needs_full_clear {
27            frame.render_widget(Clear, viewport);
28            self.needs_full_clear = false;
29        }
30
31        // Calculate layout constraints
32        let header_lines = self.header_lines();
33        let header_height = self.header_height_from_lines(viewport.width, &header_lines);
34        if header_height != self.header_rows {
35            self.header_rows = header_height;
36            self.recalculate_transcript_rows();
37        }
38
39        let inner_width = viewport.width.saturating_sub(2);
40        let desired_lines = self.desired_input_lines(inner_width);
41        let block_height = Self::input_block_height_for_lines(desired_lines);
42        let status_height = ui::INLINE_INPUT_STATUS_HEIGHT;
43        let input_core_height = block_height.saturating_add(status_height);
44        let panel = resolve_bottom_panel_spec(self, viewport, header_height, input_core_height);
45        let input_height = input_core_height.saturating_add(panel.height);
46        self.apply_input_height(input_height);
47
48        let mut constraints = vec![Constraint::Length(header_height), Constraint::Min(1)];
49        constraints.push(Constraint::Length(input_height));
50
51        let segments = Layout::vertical(constraints).split(viewport);
52
53        let header_area = segments[0];
54        let main_area = segments[1];
55        let input_index = segments.len().saturating_sub(1);
56        let input_area = segments[input_index];
57
58        let _available_width = main_area.width;
59        let _horizontal_minimum = ui::INLINE_CONTENT_MIN_WIDTH + ui::INLINE_NAVIGATION_MIN_WIDTH;
60
61        let modal_in_bottom = matches!(panel.kind, BottomPanelKind::InlineModal);
62        let (transcript_area, modal_area) = if modal_in_bottom {
63            (main_area, None)
64        } else {
65            render::split_inline_modal_area(self, main_area)
66        };
67        let (input_area, bottom_panel_area) =
68            split_input_and_bottom_panel_area(input_area, panel.height);
69        let navigation_area = Rect::new(main_area.x, main_area.y, 0, 0); // No navigation area since timeline pane is removed
70
71        // Use SessionWidget for buffer-based rendering (header, transcript, overlays)
72        SessionWidget::new(self)
73            .header_lines(header_lines.clone())
74            .header_area(header_area)
75            .transcript_area(transcript_area)
76            .navigation_area(navigation_area) // Pass empty navigation area
77            .render(viewport, frame.buffer_mut());
78
79        // Handle frame-based rendering for components that need it
80        // Note: header, transcript, and overlays are handled by SessionWidget
81        // Timeline pane has been removed, so no navigation rendering
82        self.render_input(frame, input_area);
83        let mut rendered_bottom_modal = false;
84        if !modal_in_bottom {
85            if let Some(modal_area) = modal_area {
86                render::render_modal(self, frame, modal_area);
87            } else {
88                render::render_modal(self, frame, viewport);
89            }
90        }
91        if let Some(panel_area) = bottom_panel_area {
92            match panel.kind {
93                BottomPanelKind::InlineModal => {
94                    frame.render_widget(Clear, panel_area);
95                    render::render_modal(self, frame, panel_area);
96                    rendered_bottom_modal = true;
97                }
98                BottomPanelKind::FilePalette => {
99                    render::render_file_palette(self, frame, panel_area);
100                }
101                BottomPanelKind::HistoryPicker => {
102                    render::render_history_picker(self, frame, panel_area);
103                }
104                BottomPanelKind::SlashPalette => {
105                    slash::render_slash_palette(self, frame, panel_area);
106                }
107                BottomPanelKind::None => {
108                    frame.render_widget(Clear, panel_area);
109                }
110            }
111        }
112        if modal_in_bottom && !rendered_bottom_modal {
113            render::render_modal(self, frame, viewport);
114        }
115
116        // Render diff preview modal if active
117        if self.diff_preview.is_some() {
118            diff_preview::render_diff_preview(self, frame, viewport);
119        }
120
121        // Apply mouse text selection highlight
122        if self.mouse_selection.has_selection || self.mouse_selection.is_selecting {
123            self.mouse_selection
124                .apply_highlight(frame.buffer_mut(), viewport);
125
126            // Copy to clipboard once when selection is finalized
127            if self.mouse_selection.needs_copy() {
128                let text = self
129                    .mouse_selection
130                    .extract_text(frame.buffer_mut(), viewport);
131                if !text.is_empty() {
132                    MouseSelectionState::copy_to_clipboard(&text);
133                }
134                self.mouse_selection.mark_copied();
135            }
136        }
137    }
138
139    #[allow(dead_code)]
140    pub(crate) fn render_message_spans(&self, index: usize) -> Vec<Span<'static>> {
141        let Some(line) = self.lines.get(index) else {
142            return vec![Span::raw(String::new())];
143        };
144        message_renderer::render_message_spans(
145            line,
146            &self.theme,
147            &self.labels,
148            |kind| self.prefix_text(kind),
149            |line| self.prefix_style(line),
150            |kind| self.text_fallback(kind),
151        )
152    }
153}
154
155fn resolve_bottom_panel_spec(
156    session: &mut Session,
157    viewport: Rect,
158    header_height: u16,
159    input_reserved_height: u16,
160) -> BottomPanelSpec {
161    let max_panel_height = viewport
162        .height
163        .saturating_sub(header_height)
164        .saturating_sub(input_reserved_height)
165        .saturating_sub(1);
166    if max_panel_height == 0 || viewport.width == 0 {
167        return BottomPanelSpec {
168            kind: BottomPanelKind::None,
169            height: 0,
170        };
171    }
172
173    if session.inline_lists_visible() {
174        let split_context = SplitContext {
175            width: viewport.width,
176            max_panel_height,
177        };
178        if modal_eligible_for_inline_bottom(session) {
179            if let Some(panel) = panel_from_split(
180                session,
181                split_context,
182                BottomPanelKind::InlineModal,
183                split_inline_modal_area_probe,
184            ) {
185                return panel;
186            }
187        } else if session.file_palette_active {
188            if let Some(panel) = panel_from_split(
189                session,
190                split_context,
191                BottomPanelKind::FilePalette,
192                render::split_inline_file_palette_area,
193            ) {
194                return panel;
195            }
196        } else if session.history_picker_state.active {
197            if let Some(panel) = panel_from_split(
198                session,
199                split_context,
200                BottomPanelKind::HistoryPicker,
201                render::split_inline_history_picker_area,
202            ) {
203                return panel;
204            }
205        } else if !session.slash_palette.is_empty()
206            && let Some(panel) = panel_from_split(
207                session,
208                split_context,
209                BottomPanelKind::SlashPalette,
210                slash::split_inline_slash_area,
211            )
212        {
213            return panel;
214        }
215    }
216
217    BottomPanelSpec {
218        kind: BottomPanelKind::None,
219        height: 0,
220    }
221}
222
223#[derive(Clone, Copy, Debug, PartialEq, Eq)]
224struct SplitContext {
225    width: u16,
226    max_panel_height: u16,
227}
228
229fn panel_from_split(
230    session: &mut Session,
231    ctx: SplitContext,
232    kind: BottomPanelKind,
233    split_fn: fn(&mut Session, Rect) -> (Rect, Option<Rect>),
234) -> Option<BottomPanelSpec> {
235    let height = probe_panel_height(session, ctx, split_fn);
236    if height == 0 {
237        None
238    } else {
239        Some(BottomPanelSpec {
240            kind,
241            height: normalize_panel_height(height, ctx.max_panel_height),
242        })
243    }
244}
245
246fn normalize_panel_height(raw_height: u16, max_panel_height: u16) -> u16 {
247    if raw_height == 0 || max_panel_height == 0 {
248        return 0;
249    }
250
251    let min_floor = ui::INLINE_LIST_PANEL_MIN_HEIGHT
252        .min(max_panel_height)
253        .max(1);
254    raw_height.max(min_floor).min(max_panel_height)
255}
256
257fn modal_eligible_for_inline_bottom(session: &Session) -> bool {
258    session.wizard_modal.is_some()
259        || session
260            .modal
261            .as_ref()
262            .is_some_and(|modal| modal.list.is_some())
263}
264
265fn split_inline_modal_area_probe(session: &mut Session, area: Rect) -> (Rect, Option<Rect>) {
266    render::split_inline_modal_area(session, area)
267}
268
269fn probe_panel_height(
270    session: &mut Session,
271    ctx: SplitContext,
272    split_fn: fn(&mut Session, Rect) -> (Rect, Option<Rect>),
273) -> u16 {
274    if ctx.width == 0 || ctx.max_panel_height == 0 {
275        return 0;
276    }
277
278    let probe_area = Rect::new(0, 0, ctx.width, ctx.max_panel_height.saturating_add(1));
279    let (_, panel_area) = split_fn(session, probe_area);
280    panel_area.map(|area| area.height).unwrap_or(0)
281}
282
283fn split_input_and_bottom_panel_area(area: Rect, panel_height: u16) -> (Rect, Option<Rect>) {
284    if area.height == 0 || panel_height == 0 || area.height <= 1 {
285        return (area, None);
286    }
287
288    let resolved_panel = panel_height.min(area.height.saturating_sub(1));
289    if resolved_panel == 0 {
290        return (area, None);
291    }
292
293    let input_height = area.height.saturating_sub(resolved_panel);
294    let chunks = Layout::vertical([
295        Constraint::Length(input_height.max(1)),
296        Constraint::Length(resolved_panel),
297    ])
298    .split(area);
299    (chunks[0], Some(chunks[1]))
300}