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    TaskPanel,
11}
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14struct BottomPanelSpec {
15    kind: BottomPanelKind,
16    height: u16,
17}
18
19impl Session {
20    pub fn render(&mut self, frame: &mut Frame<'_>) {
21        let viewport = frame.area();
22        if viewport.height == 0 || viewport.width == 0 {
23            return;
24        }
25
26        // Clear entire frame if modal was just closed to remove artifacts
27        if self.needs_full_clear {
28            frame.render_widget(Clear, viewport);
29            self.needs_full_clear = false;
30        }
31
32        // Calculate layout constraints
33        let header_lines = self.header_lines();
34        let header_height = self.header_height_from_lines(viewport.width, &header_lines);
35        if header_height != self.header_rows {
36            self.header_rows = header_height;
37            self.recalculate_transcript_rows();
38        }
39
40        let inner_width = viewport.width.saturating_sub(2);
41        let desired_lines = self.desired_input_lines(inner_width);
42        let block_height = Self::input_block_height_for_lines(desired_lines);
43        let status_height = ui::INLINE_INPUT_STATUS_HEIGHT;
44        let input_core_height = block_height.saturating_add(status_height);
45        let panel = resolve_bottom_panel_spec(self, viewport, header_height, input_core_height);
46        let input_height = input_core_height.saturating_add(panel.height);
47        self.apply_input_height(input_height);
48
49        let mut constraints = vec![Constraint::Length(header_height), Constraint::Min(1)];
50        constraints.push(Constraint::Length(input_height));
51
52        let segments = Layout::vertical(constraints).split(viewport);
53
54        let header_area = segments[0];
55        let main_area = segments[1];
56        let input_index = segments.len().saturating_sub(1);
57        let input_area = segments[input_index];
58
59        let _available_width = main_area.width;
60        let _horizontal_minimum = ui::INLINE_CONTENT_MIN_WIDTH + ui::INLINE_NAVIGATION_MIN_WIDTH;
61
62        let modal_in_bottom = matches!(panel.kind, BottomPanelKind::InlineModal);
63        let (transcript_area, modal_area) = if modal_in_bottom {
64            (main_area, None)
65        } else {
66            render::split_inline_modal_area(self, main_area)
67        };
68        self.set_modal_list_area(None);
69        let (input_area, bottom_panel_area) =
70            split_input_and_bottom_panel_area(input_area, panel.height);
71        self.set_bottom_panel_area(bottom_panel_area);
72        let navigation_area = Rect::new(main_area.x, main_area.y, 0, 0); // No navigation area since timeline pane is removed
73
74        // Use SessionWidget for buffer-based rendering (header, transcript, overlays)
75        SessionWidget::new(self)
76            .header_lines(header_lines.clone())
77            .header_area(header_area)
78            .transcript_area(transcript_area)
79            .navigation_area(navigation_area) // Pass empty navigation area
80            .render(viewport, frame.buffer_mut());
81
82        // Handle frame-based rendering for components that need it
83        // Note: header, transcript, and overlays are handled by SessionWidget
84        // Timeline pane has been removed, so no navigation rendering
85        self.render_input(frame, input_area);
86        let mut rendered_bottom_modal = false;
87        if !modal_in_bottom {
88            if let Some(modal_area) = modal_area {
89                render::render_modal(self, frame, modal_area);
90            } else {
91                render::render_modal(self, frame, viewport);
92            }
93        }
94        if let Some(panel_area) = bottom_panel_area {
95            match panel.kind {
96                BottomPanelKind::InlineModal => {
97                    frame.render_widget(Clear, panel_area);
98                    render::render_modal(self, frame, panel_area);
99                    rendered_bottom_modal = true;
100                }
101                BottomPanelKind::FilePalette => {
102                    render::render_file_palette(self, frame, panel_area);
103                }
104                BottomPanelKind::HistoryPicker => {
105                    render::render_history_picker(self, frame, panel_area);
106                }
107                BottomPanelKind::SlashPalette => {
108                    slash::render_slash_palette(self, frame, panel_area);
109                }
110                BottomPanelKind::TaskPanel => {
111                    render_task_panel(self, frame, panel_area);
112                }
113                BottomPanelKind::None => {
114                    frame.render_widget(Clear, panel_area);
115                }
116            }
117        }
118        if modal_in_bottom && !rendered_bottom_modal {
119            render::render_modal(self, frame, viewport);
120        }
121
122        // Render diff preview modal if active
123        if self.diff_preview_state().is_some() {
124            diff_preview::render_diff_preview(self, frame, viewport);
125        }
126
127        // Apply mouse text selection highlight
128        if self.mouse_selection.has_selection || self.mouse_selection.is_selecting {
129            self.mouse_selection
130                .apply_highlight(frame.buffer_mut(), viewport);
131
132            // Copy to clipboard once when selection is finalized
133            if self.mouse_selection.needs_copy() {
134                let text = self
135                    .mouse_selection
136                    .extract_text(frame.buffer_mut(), viewport);
137                if !text.is_empty() {
138                    MouseSelectionState::copy_to_clipboard(&text);
139                }
140                self.mouse_selection.mark_copied();
141            }
142        }
143    }
144
145    #[allow(dead_code)]
146    pub(crate) fn render_message_spans(&self, index: usize) -> Vec<Span<'static>> {
147        let Some(line) = self.lines.get(index) else {
148            return vec![Span::raw(String::new())];
149        };
150        message_renderer::render_message_spans(
151            line,
152            &self.theme,
153            &self.labels,
154            |kind| self.prefix_text(kind),
155            |line| self.prefix_style(line),
156            |kind| self.text_fallback(kind),
157        )
158    }
159}
160
161fn resolve_bottom_panel_spec(
162    session: &mut Session,
163    viewport: Rect,
164    header_height: u16,
165    input_reserved_height: u16,
166) -> BottomPanelSpec {
167    let max_panel_height = viewport
168        .height
169        .saturating_sub(header_height)
170        .saturating_sub(input_reserved_height)
171        .saturating_sub(1);
172    if max_panel_height == 0 || viewport.width == 0 {
173        return BottomPanelSpec {
174            kind: BottomPanelKind::None,
175            height: 0,
176        };
177    }
178
179    if session.inline_lists_visible() {
180        let split_context = SplitContext {
181            width: viewport.width,
182            max_panel_height,
183        };
184        if modal_eligible_for_inline_bottom(session) {
185            if let Some(panel) = panel_from_split(
186                session,
187                split_context,
188                BottomPanelKind::InlineModal,
189                split_inline_modal_area_probe,
190            ) {
191                return panel;
192            }
193        } else if session.file_palette_active {
194            if let Some(panel) = panel_from_split(
195                session,
196                split_context,
197                BottomPanelKind::FilePalette,
198                render::split_inline_file_palette_area,
199            ) {
200                return panel;
201            }
202        } else if session.history_picker_state.active {
203            if let Some(panel) = panel_from_split(
204                session,
205                split_context,
206                BottomPanelKind::HistoryPicker,
207                render::split_inline_history_picker_area,
208            ) {
209                return panel;
210            }
211        } else if !session.slash_palette.is_empty()
212            && let Some(panel) = panel_from_split(
213                session,
214                split_context,
215                BottomPanelKind::SlashPalette,
216                slash::split_inline_slash_area,
217            )
218        {
219            return panel;
220        }
221    }
222
223    if session.show_task_panel
224        && let Some(panel) = panel_from_split(
225            session,
226            SplitContext {
227                width: viewport.width,
228                max_panel_height,
229            },
230            BottomPanelKind::TaskPanel,
231            split_inline_task_panel_area,
232        )
233    {
234        return panel;
235    }
236
237    BottomPanelSpec {
238        kind: BottomPanelKind::None,
239        height: 0,
240    }
241}
242
243#[derive(Clone, Copy, Debug, PartialEq, Eq)]
244struct SplitContext {
245    width: u16,
246    max_panel_height: u16,
247}
248
249fn panel_from_split(
250    session: &mut Session,
251    ctx: SplitContext,
252    kind: BottomPanelKind,
253    split_fn: fn(&mut Session, Rect) -> (Rect, Option<Rect>),
254) -> Option<BottomPanelSpec> {
255    let height = probe_panel_height(session, ctx, split_fn);
256    if height == 0 {
257        None
258    } else {
259        Some(BottomPanelSpec {
260            kind,
261            height: normalize_panel_height(height, ctx.max_panel_height),
262        })
263    }
264}
265
266fn normalize_panel_height(raw_height: u16, max_panel_height: u16) -> u16 {
267    if raw_height == 0 || max_panel_height == 0 {
268        return 0;
269    }
270
271    let min_floor = ui::INLINE_LIST_PANEL_MIN_HEIGHT
272        .min(max_panel_height)
273        .max(1);
274    raw_height.max(min_floor).min(max_panel_height)
275}
276
277fn modal_eligible_for_inline_bottom(session: &Session) -> bool {
278    session.wizard_overlay().is_some()
279        || session
280            .modal_state()
281            .is_some_and(|modal| modal.list.is_some())
282}
283
284fn split_inline_modal_area_probe(session: &mut Session, area: Rect) -> (Rect, Option<Rect>) {
285    render::split_inline_modal_area(session, area)
286}
287
288fn split_inline_task_panel_area(session: &mut Session, area: Rect) -> (Rect, Option<Rect>) {
289    let visible_lines = session.task_panel_lines.len().max(1);
290    let desired_list_rows =
291        list_panel::rows_to_u16(visible_lines.min(ui::INLINE_LIST_MAX_ROWS_MULTILINE));
292    let fixed_rows = list_panel::fixed_section_rows(1, 1, false);
293    list_panel::split_bottom_list_panel(area, fixed_rows, desired_list_rows)
294}
295
296fn render_task_panel(session: &mut Session, frame: &mut Frame<'_>, area: Rect) {
297    if area.width == 0 || area.height == 0 {
298        return;
299    }
300
301    let rows = if session.task_panel_lines.is_empty() {
302        vec![(
303            inline_list::InlineListRow::single(
304                ui::PLAN_STATUS_EMPTY.to_string().into(),
305                session.header_secondary_style(),
306            ),
307            1,
308        )]
309    } else {
310        session
311            .task_panel_lines
312            .iter()
313            .map(|line| {
314                (
315                    inline_list::InlineListRow::single(
316                        line.clone().into(),
317                        session.header_secondary_style(),
318                    ),
319                    1,
320                )
321            })
322            .collect()
323    };
324    let item_count = session.task_panel_lines.len();
325    let sections = list_panel::SharedListPanelSections {
326        header: vec![Line::from(vec![Span::styled(
327            ui::PLAN_BLOCK_TITLE.to_string(),
328            session.section_title_style(),
329        )])],
330        info: vec![Line::from(format!(
331            "{} item{}",
332            item_count,
333            if item_count == 1 { "" } else { "s" }
334        ))],
335        search: None,
336    };
337    let styles = list_panel::SharedListPanelStyles {
338        base_style: session.styles.default_style(),
339        selected_style: Some(session.styles.modal_list_highlight_style()),
340        text_style: session.header_secondary_style(),
341    };
342    let mut model = list_panel::StaticRowsListPanelModel {
343        rows,
344        selected: None,
345        offset: 0,
346        visible_rows: area.height as usize,
347    };
348    list_panel::render_shared_list_panel(frame, area, sections, styles, &mut model);
349}
350
351fn probe_panel_height(
352    session: &mut Session,
353    ctx: SplitContext,
354    split_fn: fn(&mut Session, Rect) -> (Rect, Option<Rect>),
355) -> u16 {
356    if ctx.width == 0 || ctx.max_panel_height == 0 {
357        return 0;
358    }
359
360    let probe_area = Rect::new(0, 0, ctx.width, ctx.max_panel_height.saturating_add(1));
361    let (_, panel_area) = split_fn(session, probe_area);
362    panel_area.map(|area| area.height).unwrap_or(0)
363}
364
365fn split_input_and_bottom_panel_area(area: Rect, panel_height: u16) -> (Rect, Option<Rect>) {
366    if area.height == 0 || panel_height == 0 || area.height <= 1 {
367        return (area, None);
368    }
369
370    let resolved_panel = panel_height.min(area.height.saturating_sub(1));
371    if resolved_panel == 0 {
372        return (area, None);
373    }
374
375    let input_height = area.height.saturating_sub(resolved_panel);
376    let chunks = Layout::vertical([
377        Constraint::Length(input_height.max(1)),
378        Constraint::Length(resolved_panel),
379    ])
380    .split(area);
381    (chunks[0], Some(chunks[1]))
382}