//! Rendering of the application state into a Ratatui frame. //! //! The render function is pure with respect to runtime: given an //! `App` and a `Theme`, the same frame is produced regardless of //! when or where it is called. That property is what makes Tier 2 //! (TestBackend) and Tier 3 (synthetic event) tests in ADR-0008 //! straightforward. use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap}; use crate::app::{App, EffectiveMode, OutputKind, OutputLine, OutputStyleClass}; use crate::mode::Mode; use crate::theme::Theme; /// Render the entire application frame. /// /// Takes `&mut App` because the renderer reports the current /// output-panel row count back to the App for scroll-cap /// computation — without that feedback, scrolling past the top /// of the buffer would slide the visible window off and /// "eat" lines from the bottom on subsequent renders. pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { let area = frame.area(); paint_background(theme, frame, area); // Reserve two rows at the bottom for status: // - top row: "Project: " (P-NAME-3, ADR-0015 §2). // - bottom row: mode-aware keyboard shortcuts. let outer = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(8), Constraint::Length(1), Constraint::Length(1), ]) .split(area); let columns = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Length(28), Constraint::Min(20)]) .split(outer[0]); render_items_panel(app, theme, frame, columns[0]); render_right_column(app, theme, frame, columns[1]); render_project_label(app, theme, frame, outer[1]); render_status_bar(app, theme, frame, outer[2]); // Modal dialogs (rebuild confirm, save-as prompt, load // picker, …) are drawn last so they overlay the rest of // the frame. if let Some(modal) = app.modal.as_ref() { render_modal(modal, theme, frame, area); } } fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { use crate::app::Modal; match modal { Modal::RebuildConfirm(m) => render_rebuild_confirm(&m.summary, theme, frame, area), Modal::PathEntry(m) => render_path_entry(m, theme, frame, area), Modal::LoadPicker(m) => render_load_picker(m, theme, frame, area), Modal::UndoConfirm(m) => render_undo_confirm(m, theme, frame, area), } } fn render_path_entry( m: &crate::app::PathEntryModal, theme: &Theme, frame: &mut Frame<'_>, area: Rect, ) { let dialog_w = area.width.clamp(20, 70); let inner_w = dialog_w.saturating_sub(4) as usize; let prompt_lines = wrap_lines(&m.prompt, inner_w); // Title + blank + prompt + blank + input box (1 row + borders) + blank + key hints. let dialog_h = (prompt_lines.len() as u16).saturating_add(8).min(area.height); let x = area.x + (area.width.saturating_sub(dialog_w)) / 2; let y = area.y + (area.height.saturating_sub(dialog_h)) / 2; let dialog_area = Rect { x, y, width: dialog_w, height: dialog_h, }; frame.render_widget(ratatui::widgets::Clear, dialog_area); let title_style = Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.fg)) .title(Line::from(vec![Span::styled( format!(" {} ", m.title), title_style, )])) .style(Style::default().bg(theme.bg).fg(theme.fg)); let mut text_lines: Vec> = Vec::new(); text_lines.push(Line::from("")); for line in prompt_lines { text_lines.push(Line::from(line)); } text_lines.push(Line::from("")); let cursor_marker = "█"; let display_input = if m.cursor == m.input.len() { format!("{}{cursor_marker}", m.input) } else { format!( "{}{cursor_marker}{}", &m.input[..m.cursor], &m.input[m.cursor..] ) }; text_lines.push(Line::from(format!("> {display_input}"))); text_lines.push(Line::from("")); text_lines.push(Line::from(vec![ Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!(" {} ", crate::t!("shortcut.confirm"))), Span::styled("Esc", Style::default().fg(theme.muted)), Span::styled( format!(" {}", crate::t!("shortcut.cancel")), Style::default().fg(theme.muted), ), ])); let paragraph = Paragraph::new(text_lines) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, dialog_area); } fn render_load_picker( m: &crate::app::LoadPickerModal, theme: &Theme, frame: &mut Frame<'_>, area: Rect, ) { use crate::app::LoadPickerSubMode; let dialog_w = area.width.clamp(20, 70); let dialog_h = area.height.clamp(10, 20); let x = area.x + (area.width.saturating_sub(dialog_w)) / 2; let y = area.y + (area.height.saturating_sub(dialog_h)) / 2; let dialog_area = Rect { x, y, width: dialog_w, height: dialog_h, }; frame.render_widget(ratatui::widgets::Clear, dialog_area); let title_style = Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.fg)) .title(Line::from(vec![Span::styled( format!(" {} ", crate::t!("modal.load_picker_title")), title_style, )])) .style(Style::default().bg(theme.bg).fg(theme.fg)); let mut text_lines: Vec> = Vec::new(); text_lines.push(Line::from("")); match &m.sub_mode { LoadPickerSubMode::List => { if m.entries.is_empty() { text_lines.push(Line::from(crate::t!("modal.load_picker_empty"))); } else { for (i, entry) in m.entries.iter().enumerate() { let marker = if i == m.selected { "›" } else { " " }; let temp_tag = if entry.is_temp { "[TEMP] " } else { "" }; let style = if i == m.selected { Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.fg) }; let line = format!( " {marker} {temp_tag}{name} {modified}", name = entry.display_name, modified = entry.modified, ); text_lines.push(Line::from(Span::styled(line, style))); } } text_lines.push(Line::from("")); text_lines.push(Line::from(vec![ Span::styled("↑↓", Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!(" {} ", crate::t!("shortcut.select"))), Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!(" {} ", crate::t!("shortcut.load"))), Span::styled("b", Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!(" {} ", crate::t!("shortcut.browse_path"))), Span::styled("Esc", Style::default().fg(theme.muted)), Span::styled( format!(" {}", crate::t!("shortcut.cancel")), Style::default().fg(theme.muted), ), ])); } LoadPickerSubMode::PathEntry { input, cursor } => { text_lines.push(Line::from(crate::t!("modal.load_picker_path_prompt"))); text_lines.push(Line::from("")); let cursor_marker = "█"; let display_input = if *cursor == input.len() { format!("{input}{cursor_marker}") } else { format!( "{}{cursor_marker}{}", &input[..*cursor], &input[*cursor..] ) }; text_lines.push(Line::from(format!("> {display_input}"))); text_lines.push(Line::from("")); text_lines.push(Line::from(vec![ Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)), Span::raw(format!(" {} ", crate::t!("shortcut.load"))), Span::styled("Esc", Style::default().fg(theme.muted)), Span::styled( format!(" {}", crate::t!("shortcut.back_to_list")), Style::default().fg(theme.muted), ), ])); } } let paragraph = Paragraph::new(text_lines) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, dialog_area); } /// Centred dialog with a one-paragraph body and a [Y]es/[N]o /// hint at the bottom. Sized at min(60 cols, area.width-4) /// wide and tall enough to fit the wrapped body plus 4 rows /// of chrome. fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let dialog_w = area.width.min(60).saturating_sub(0); let dialog_w = dialog_w.max(20); let inner_w = dialog_w.saturating_sub(4) as usize; let body_lines: Vec = wrap_lines(summary, inner_w); let body_height = body_lines.len() as u16; // Title row + blank + body + blank + prompt + blank + keys + borders (2). let dialog_h = body_height.saturating_add(7).min(area.height); let x = area.x + (area.width.saturating_sub(dialog_w)) / 2; let y = area.y + (area.height.saturating_sub(dialog_h)) / 2; let dialog_area = Rect { x, y, width: dialog_w, height: dialog_h, }; // Solid background panel so we cover whatever was beneath. let bg = ratatui::widgets::Clear; frame.render_widget(bg, dialog_area); let title_style = Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.fg)) .title(Line::from(vec![Span::styled( format!(" {} ", crate::t!("modal.rebuild_confirm_title")), title_style, )])) .style(Style::default().bg(theme.bg).fg(theme.fg)); let mut text_lines: Vec> = Vec::new(); text_lines.push(Line::from("")); for line in body_lines { text_lines.push(Line::from(line)); } text_lines.push(Line::from("")); text_lines.push(Line::from(crate::t!("modal.rebuild_confirm_prompt"))); text_lines.push(Line::from("")); text_lines.push(Line::from(vec![ Span::styled( "[Y]", Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD), ), Span::raw(format!(" {} ", crate::t!("shortcut.yes"))), Span::styled( "[N]", Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD), ), Span::raw(format!(" {} ", crate::t!("shortcut.no"))), Span::styled("Esc", Style::default().fg(theme.muted)), Span::styled( format!(" {}", crate::t!("shortcut.cancel")), Style::default().fg(theme.muted), ), ])); let paragraph = Paragraph::new(text_lines) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, dialog_area); } /// `undo` / `redo` confirmation modal (ADR-0006 Amendment 1). Names /// the command that will be undone / re-applied and when its /// snapshot was taken, then prompts `Y` / `N`. fn render_undo_confirm( m: &crate::app::UndoConfirmModal, theme: &Theme, frame: &mut Frame<'_>, area: Rect, ) { let dialog_w = area.width.clamp(20, 60); let inner_w = dialog_w.saturating_sub(4) as usize; let intro = if m.is_redo { crate::t!("modal.redo_confirm_command") } else { crate::t!("modal.undo_confirm_command") }; let mut body_lines: Vec = wrap_lines(&format!("{intro} {}", m.command), inner_w); body_lines.extend(wrap_lines( &crate::t!("modal.undo_confirm_when", timestamp = m.timestamp), inner_w, )); let body_height = body_lines.len() as u16; // Title row + blank + body + blank + prompt + blank + keys + borders (2). let dialog_h = body_height.saturating_add(7).min(area.height); let x = area.x + (area.width.saturating_sub(dialog_w)) / 2; let y = area.y + (area.height.saturating_sub(dialog_h)) / 2; let dialog_area = Rect { x, y, width: dialog_w, height: dialog_h, }; frame.render_widget(ratatui::widgets::Clear, dialog_area); let title = if m.is_redo { crate::t!("modal.redo_confirm_title") } else { crate::t!("modal.undo_confirm_title") }; let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.fg)) .title(Line::from(vec![Span::styled( format!(" {title} "), title_style, )])) .style(Style::default().bg(theme.bg).fg(theme.fg)); let mut text_lines: Vec> = Vec::new(); text_lines.push(Line::from("")); for line in body_lines { text_lines.push(Line::from(line)); } text_lines.push(Line::from("")); text_lines.push(Line::from(crate::t!("modal.undo_confirm_prompt"))); text_lines.push(Line::from("")); text_lines.push(Line::from(vec![ Span::styled("[Y]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)), Span::raw(format!(" {} ", crate::t!("shortcut.yes"))), Span::styled("[N]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)), Span::raw(format!(" {} ", crate::t!("shortcut.no"))), Span::styled("Esc", Style::default().fg(theme.muted)), Span::styled( format!(" {}", crate::t!("shortcut.cancel")), Style::default().fg(theme.muted), ), ])); let paragraph = Paragraph::new(text_lines) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, dialog_area); } /// Greedy word-wrap to `width` columns. Sufficient for the /// short prose modals carry; we don't try to be Unicode-aware /// (display-width-wise) since the strings we generate are /// ASCII-friendly. fn wrap_lines(s: &str, width: usize) -> Vec { if width == 0 { return vec![s.to_string()]; } let mut lines: Vec = Vec::new(); let mut current = String::new(); for word in s.split_whitespace() { if !current.is_empty() && current.len() + 1 + word.len() <= width { current.push(' '); } else if !current.is_empty() { lines.push(std::mem::take(&mut current)); } current.push_str(word); } if !current.is_empty() { lines.push(current); } if lines.is_empty() { lines.push(String::new()); } lines } fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let label_style = Style::default().fg(theme.muted); let value_style = Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD); let bar_style = Style::default().bg(theme.bg).fg(theme.muted); let no_project = crate::t!("status.no_project"); let display = app.project_name.as_deref().unwrap_or(no_project.as_str()); let mut spans: Vec> = vec![Span::styled( crate::t!("status.project_label"), label_style, )]; if app.project_is_temp { spans.push(Span::styled( "[TEMP] ", Style::default() .fg(theme.muted) .add_modifier(Modifier::BOLD), )); } spans.push(Span::styled(display.to_string(), value_style)); let line = Line::from(spans); let paragraph = Paragraph::new(line).style(bar_style); frame.render_widget(paragraph, area); } fn render_right_column(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(5), // Output panel Constraint::Length(3), // Input panel Constraint::Length(3), // Hint panel ]) .split(area); render_output_panel(app, theme, frame, rows[0]); render_input_panel(app, theme, frame, rows[1]); render_hint_panel(app, theme, frame, rows[2]); } fn paint_background(theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default().style(Style::default().bg(theme.bg).fg(theme.fg)); frame.render_widget(block, area); } fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.border)) .title(Span::styled( format!(" {} ", crate::t!("panel.tables_title")), Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD), )) .style(Style::default().bg(theme.bg).fg(theme.fg)); if app.tables.is_empty() { let placeholder = Paragraph::new(Line::from(Span::styled( crate::t!("panel.tables_empty"), Style::default() .fg(theme.muted) .add_modifier(Modifier::ITALIC), ))) .block(block); frame.render_widget(placeholder, area); return; } let highlight = app .current_table .as_ref() .map(|t| t.name.as_str()) .unwrap_or_default(); // Nested tables / per-table indexes (S2, ADR-0025): each // table line, with its index names indented beneath it. let mut lines: Vec> = Vec::new(); for name in &app.tables { let style = if name == highlight { Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.fg) }; lines.push(Line::from(Span::styled(name.as_str(), style))); if let Some(indexes) = app.schema_cache.table_indexes.get(name) { for index in indexes { lines.push(Line::from(Span::styled( format!(" {index}"), Style::default().fg(theme.muted), ))); } } } let paragraph = Paragraph::new(lines).block(block); frame.render_widget(paragraph, area); } fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.border)) .title(Span::styled( format!(" {} ", crate::t!("panel.output_title")), Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD), )) .style(Style::default().bg(theme.bg).fg(theme.fg)); let inner = area.inner(Margin { horizontal: 1, vertical: 1, }); // Render every output line into a wrapped Paragraph and let // ratatui handle the wrapping; we then use the wrapped row // count to cap scroll correctly. Bottom-anchoring (most // recent visible by default) is achieved by computing the // scroll offset relative to the bottom of the wrapped view. let visible = inner.height as usize; // Compute the total wrapped row count first, working from // OutputLines directly (so the borrow ends before the // mutable `note_output_viewport` call below). let total_wrapped = approximate_wrapped_rows_from_output(&app.output, inner.width); app.note_output_viewport(visible, total_wrapped); let lines: Vec> = app .output .iter() .map(|line| render_output_line(line, theme)) .collect(); let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); let max_scroll = total_wrapped.saturating_sub(visible); let effective_scroll = app.output_scroll.min(max_scroll); // Paragraph::scroll((y, _)) sets the topmost visible row. // We want bottom-anchored: y = max_scroll - effective_scroll // so scroll==0 shows the bottom and PageUp moves y down to // reveal older content. let scroll_y = max_scroll.saturating_sub(effective_scroll); let scroll_y_u16 = u16::try_from(scroll_y).unwrap_or(u16::MAX); frame.render_widget(block, area); frame.render_widget(paragraph.scroll((scroll_y_u16, 0)), inner); } /// Approximate the number of display rows the output buffer /// will occupy after width-wrapping. Computed directly from /// `OutputLine`s (rather than the rendered `Line` objects) so /// the calculation can run before we hand `&mut App` to the /// scroll-cap update. Ratatui's exact `line_count` is gated /// behind an unstable feature; this character-based /// approximation is close enough for scroll capping — off-by-one /// at boundaries is acceptable. fn approximate_wrapped_rows_from_output( output: &std::collections::VecDeque, width: u16, ) -> usize { if width == 0 { return output.len(); } let w = usize::from(width); output .iter() .map(|line| { // Tag width matches `render_output_line` exactly so // the row count is right when the panel is too narrow // for the natural line. let tag_len = match line.kind { OutputKind::Echo => match line.mode_at_submission { Mode::Simple => "[simple] ".len(), Mode::Advanced => "[advanced] ".len(), }, OutputKind::System => "[system] ".len(), OutputKind::Error => "[error] ".len(), }; let total = tag_len + line.text.chars().count(); if total == 0 { 1 } else { total.div_ceil(w) } }) .sum() } /// Resolve a semantic [`OutputStyleClass`] to a concrete style /// against the active theme (ADR-0028 §5/§6). const fn output_span_style(class: OutputStyleClass, theme: &Theme) -> Style { match class { OutputStyleClass::Neutral => Style::new().fg(theme.fg), OutputStyleClass::Efficient => Style::new().fg(theme.plan_efficient), OutputStyleClass::Expensive => Style::new().fg(theme.warning), OutputStyleClass::AutomaticIndex => Style::new() .fg(theme.warning) .add_modifier(Modifier::BOLD), } } fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> { let tag_style = match line.mode_at_submission { Mode::Simple => Style::default().fg(theme.mode_simple), Mode::Advanced => Style::default().fg(theme.mode_advanced), }; let tag = match line.kind { OutputKind::Echo => format!("[{}] ", line.mode_at_submission.label().to_lowercase()), OutputKind::System => "[system] ".to_string(), OutputKind::Error => "[error] ".to_string(), }; // Simple-mode echo lines get token-class highlighting on // their input portion (ADR-0022 §5). Echo body shape is // contracted to ``; the prefix is // pinned to the catalog template by // `dsl::tests::echo_prefix_matches_catalog_template`. if line.kind == OutputKind::Echo && line.mode_at_submission == Mode::Simple && let Some(rest) = line.text.strip_prefix(crate::dsl::ECHO_PREFIX) { let mut spans: Vec> = Vec::with_capacity(2 + rest.len() / 4); spans.push(Span::styled(tag, tag_style)); spans.push(Span::styled( crate::dsl::ECHO_PREFIX, Style::default().fg(theme.fg), )); for run in crate::input_render::lex_to_runs(rest, theme) { spans.push(Span::styled( &rest[run.byte_range.0..run.byte_range.1], run.style, )); } return Line::from(spans); } // Echo body without the expected prefix, or any non-echo // line, falls through to the plain rendering below. // ADR-0028 §5: a line carrying a styled-runs payload is // rendered span-by-span, each run's semantic class resolved // to a colour from the active theme. The tag keeps its // kind styling. (Echo lines never carry runs, so this never // collides with the branch above.) if let Some(runs) = &line.styled_runs { let mut spans: Vec> = Vec::with_capacity(runs.len() + 1); spans.push(Span::styled(tag, tag_style)); for run in runs { let (start, end) = run.byte_range; spans.push(Span::styled( &line.text[start..end], output_span_style(run.class, theme), )); } return Line::from(spans); } let body_style = match line.kind { OutputKind::Echo => Style::default().fg(theme.fg), OutputKind::System => Style::default().fg(theme.system), OutputKind::Error => Style::default().fg(theme.error), }; Line::from(vec![ Span::styled(tag, tag_style), Span::styled(line.text.as_str(), body_style), ]) } fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let effective = app.effective_mode(); let (border_color, mode_color, label) = match effective { EffectiveMode::Simple => ( theme.border, theme.mode_simple, crate::t!("mode.label_simple"), ), EffectiveMode::AdvancedPersistent => ( theme.border_advanced, theme.mode_advanced, crate::t!("mode.label_advanced"), ), // Mixed-case label distinguishes the one-shot (`:`-triggered) // state from a persistent advanced mode at a glance. EffectiveMode::AdvancedOneShot => ( theme.border_advanced, theme.mode_advanced, crate::t!("mode.label_advanced_one_shot"), ), }; let title = Line::from(vec![ Span::raw(" "), Span::styled( label, Style::default() .fg(mode_color) .add_modifier(Modifier::BOLD), ), Span::raw(" "), ]); let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(border_color)) .title(title) .style(Style::default().bg(theme.bg).fg(theme.fg)); // Cursor block: render the character at the cursor position // inverted so the cursor is visible without enabling a real // terminal cursor. // // Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both // modes — `render_input_runs_in_mode` runs the highlight // walker with the active mode so SQL keywords / operators / // CASE / function calls colour correctly in Advanced mode. let cursor = app.input_cursor.min(app.input.len()); let mode_for_render = match effective { EffectiveMode::Simple => crate::mode::Mode::Simple, EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => { crate::mode::Mode::Advanced } }; let runs = crate::input_render::render_input_runs_in_mode( &app.input, cursor, theme, &app.schema_cache, mode_for_render, ); let spans = runs_to_spans(&app.input, &runs); // ADR-0027 §4: the rightmost six columns of the input row // (a five-column label plus a one-column gap) are reserved // unconditionally, so the text area is always // `inner.width - 6` and the typed command never shifts // sideways when the validity indicator appears or hides. let inner = block.inner(area); frame.render_widget(block, area); let text_area = Rect { width: inner.width.saturating_sub(6), ..inner }; frame.render_widget(Paragraph::new(Line::from(spans)), text_area); if let Some(severity) = app.input_indicator { let (indicator_label, color) = match severity { crate::dsl::walker::Severity::Error => ("[ERR]", theme.error), crate::dsl::walker::Severity::Warning => ("[WRN]", theme.warning), }; let label_area = Rect { x: inner.x + inner.width.saturating_sub(5), width: 5.min(inner.width), ..inner }; frame.render_widget( Paragraph::new(Line::from(Span::styled( indicator_label, Style::default().fg(color).add_modifier(Modifier::BOLD), ))), label_area, ); } } /// Convert `StyledRun`s into ratatui `Span`s borrowed from /// `input`. The end-of-input cursor sentinel (empty range) is /// rendered as an inverted space. fn runs_to_spans<'a>( input: &'a str, runs: &[crate::input_render::StyledRun], ) -> Vec> { runs.iter() .map(|r| { if r.byte_range.0 == r.byte_range.1 { Span::styled(" ", r.style) } else { Span::styled(&input[r.byte_range.0..r.byte_range.1], r.style) } }) .collect() } /// Strip a leading one-shot `:` sigil (and the whitespace after /// it) from `input`, returning the advanced command slice and the /// cursor remapped into it. Mirrors `App::submit`'s `:` handling /// so the hint panel hints at the command, not the sigil /// (ADR-0022 Amendment 1). Used only when the effective mode is /// `AdvancedOneShot`, where `input` is guaranteed to start (after /// any leading whitespace) with `:`. fn strip_one_shot_prefix(input: &str, cursor: usize) -> (&str, usize) { let lead_ws = input.len() - input.trim_start().len(); let after_colon = lead_ws + 1; // skip the `:` let ws_after = input[after_colon..].len() - input[after_colon..].trim_start().len(); let prefix_len = (after_colon + ws_after).min(input.len()); let effective = &input[prefix_len..]; let effective_cursor = cursor.saturating_sub(prefix_len).min(effective.len()); (effective, effective_cursor) } fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(theme.border)) .title(Span::styled( format!(" {} ", crate::t!("panel.hint_title")), Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD), )) .style(Style::default().bg(theme.bg).fg(theme.fg)); // Resolution order for the hint panel body: // 1. An explicit app-set hint (e.g. modal contexts) wins. // 2. Otherwise, with non-empty input, the ambient // typing-assistance hint (ADR-0022 §6) computed in the // effective mode. // 3. Otherwise, the existing empty-state placeholder. // ADR-0022 Amendment 1: advanced mode no longer skips ambient // hinting. The original §12 carve-out predated the unified // mode-aware walker (ADR-0030/0031/0032); the walker now // speaks SQL, so `ambient_hint_in_mode` surfaces SQL slot // hints + completion candidates in advanced mode too. let empty_hint = crate::t!("panel.hint_empty"); // In one-shot advanced mode (`:` prefix in simple mode) the // raw input carries the `:` sigil, which is not part of the // grammar. Strip it for the ambient computation so the hint // reflects the advanced command — mirroring `App::submit`. let (hint_input, hint_cursor) = match app.effective_mode() { EffectiveMode::AdvancedOneShot => { strip_one_shot_prefix(&app.input, app.input_cursor) } _ => (app.input.as_str(), app.input_cursor), }; let ambient = crate::input_render::ambient_hint_in_mode( hint_input, hint_cursor, app.last_completion.as_ref(), &app.schema_cache, app.effective_mode().as_mode(), ); let muted = Style::default().fg(theme.muted); let line = match (app.hint.as_deref(), ambient) { (Some(set), _) => Line::from(Span::styled(set.to_string(), muted)), (None, Some(crate::input_render::AmbientHint::Prose(text))) => { Line::from(Span::styled(text, muted)) } (None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => { // ADR-0022 §7 + user's #2: render items with the // selected one highlighted; if not all fit, // scroll horizontally with `<` / `>` markers at // the edges. Inner width = panel area minus // borders (2). let inner = area.width.saturating_sub(2) as usize; render_candidate_line(&items, selected, inner, theme) } (None, None) => Line::from(Span::styled(empty_hint, muted)), }; let paragraph = Paragraph::new(line) .block(block) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); } /// Render the candidate-list line for the hint panel /// (ADR-0022 §7 + the user's #2). Items are space-separated. /// Each candidate is colour-coded by kind — keywords in /// `tok_keyword`, identifiers in `tok_identifier` — so the /// user can tell command grammar apart from schema names at /// a glance (post-stage-8 user feedback). The selected item /// (if any) gets bolded; when the items overflow `width`, /// scroll markers `<` / `>` appear at the edges with the /// window centred on the selection (or item 0 with no /// selection). /// /// Returns `Line<'static>` (each item cloned into its span) /// so the caller doesn't have to manage the items' lifetime. fn render_candidate_line( items: &[crate::completion::Candidate], selected: Option, width: usize, theme: &Theme, ) -> Line<'static> { if items.is_empty() { return Line::default(); } let separator_style = Style::default().fg(theme.muted); let marker_style = Style::default().fg(theme.fg); let style_for = |i: usize| { let base_fg = match items[i].kind { crate::completion::CandidateKind::Keyword => theme.tok_keyword, crate::completion::CandidateKind::Identifier => theme.tok_identifier, crate::completion::CandidateKind::Flag => theme.tok_flag, crate::completion::CandidateKind::Punct => theme.tok_punct, }; let mut s = Style::default().fg(base_fg); if Some(i) == selected { s = s.add_modifier(Modifier::BOLD); } s }; let total_width: usize = items .iter() .map(|c| c.text.len() + 1) .sum::() .saturating_sub(1); if total_width <= width { let mut spans: Vec> = Vec::with_capacity(items.len() * 2); for (i, item) in items.iter().enumerate() { if i > 0 { spans.push(Span::styled(" ".to_string(), separator_style)); } spans.push(Span::styled(item.text.clone(), style_for(i))); } return Line::from(spans); } // Overflow path: window centred on `selected` (or item 0). // Reserve 4 chars for the `< ` / ` >` markers we may end // up using. let center = selected.unwrap_or(0); let mut left = center; let mut right = center; let mut used = items[center].text.len(); let avail = width.saturating_sub(4); while (left > 0 || right + 1 < items.len()) && used < avail { if right + 1 < items.len() { let cost = items[right + 1].text.len() + 1; if used + cost <= avail { right += 1; used += cost; continue; } } if left > 0 { let cost = items[left - 1].text.len() + 1; if used + cost <= avail { left -= 1; used += cost; continue; } } break; } let need_left_marker = left > 0; let need_right_marker = right + 1 < items.len(); let mut spans: Vec> = Vec::with_capacity((right - left + 1) * 2 + 4); if need_left_marker { spans.push(Span::styled("< ".to_string(), marker_style)); } for (offset, item) in items[left..=right].iter().enumerate() { if offset > 0 { spans.push(Span::styled(" ".to_string(), separator_style)); } spans.push(Span::styled(item.text.clone(), style_for(left + offset))); } if need_right_marker { spans.push(Span::styled(" >".to_string(), marker_style)); } Line::from(spans) } fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let key_style = Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD); let sep_style = Style::default().fg(theme.muted); let label_style = Style::default().fg(theme.muted); let bar_style = Style::default().bg(theme.bg).fg(theme.muted); let separator = Span::styled(" · ", sep_style); let mut spans: Vec> = Vec::new(); let push_shortcut = |spans: &mut Vec>, key: &'static str, label: &str| { if !spans.is_empty() { spans.push(separator.clone()); } spans.push(Span::styled(key, key_style)); spans.push(Span::raw(" ")); spans.push(Span::styled(label.to_string(), label_style)); }; let submit = crate::t!("shortcut.submit"); push_shortcut(&mut spans, "Enter", &submit); let switch = crate::t!("shortcut.switch"); let advanced_once = crate::t!("shortcut.advanced_once"); let cancel_one_shot = crate::t!("shortcut.cancel_one_shot"); let quit = crate::t!("shortcut.quit"); match app.effective_mode() { EffectiveMode::Simple => { push_shortcut(&mut spans, ":", &advanced_once); push_shortcut(&mut spans, "mode advanced", &switch); } EffectiveMode::AdvancedPersistent => { push_shortcut(&mut spans, "mode simple", &switch); } EffectiveMode::AdvancedOneShot => { push_shortcut(&mut spans, "Backspace", &cancel_one_shot); } } push_shortcut(&mut spans, "Ctrl-C", &quit); let paragraph = Paragraph::new(Line::from(spans)).style(bar_style); frame.render_widget(paragraph, area); } #[cfg(test)] mod tests { use super::*; use crate::app::{App, OutputSpan}; use ratatui::Terminal; use ratatui::backend::TestBackend; #[test] fn styled_runs_payload_renders_each_span_with_its_class_colour() { // ADR-0028 §5: an OutputLine carrying a styled-runs // payload renders span-by-span, each run's semantic // class resolved to a theme colour. let theme = Theme::dark(); let line = OutputLine::styled( "SCAN Customers".to_string(), OutputKind::System, Mode::Simple, vec![ OutputSpan { byte_range: (0, 4), class: OutputStyleClass::Expensive, }, OutputSpan { byte_range: (4, 14), class: OutputStyleClass::Neutral, }, ], ); let rendered = render_output_line(&line, &theme); // tag span + 2 run spans. assert_eq!(rendered.spans.len(), 3); assert_eq!(rendered.spans[1].content.as_ref(), "SCAN"); assert_eq!(rendered.spans[1].style.fg, Some(theme.warning)); assert_eq!(rendered.spans[2].content.as_ref(), " Customers"); assert_eq!(rendered.spans[2].style.fg, Some(theme.fg)); } #[test] fn a_line_without_styled_runs_keeps_whole_line_kind_styling() { let theme = Theme::dark(); let line = OutputLine { text: "plain system line".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, styled_runs: None, }; let rendered = render_output_line(&line, &theme); // tag span + single whole-line body span. assert_eq!(rendered.spans.len(), 2); assert_eq!(rendered.spans[1].content.as_ref(), "plain system line"); assert_eq!(rendered.spans[1].style.fg, Some(theme.system)); } fn render_to_string(app: &mut App, theme: &Theme, width: u16, height: u16) -> String { // Snapshot tests need realistic state, not the boot // fallback "(no project)" — every real session has a // project. Set a representative name unless the test // already set one. if app.project_name.is_none() { app.project_name = Some("Term Planner".to_string()); } let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).expect("create terminal"); terminal .draw(|f| render(app, theme, f)) .expect("draw frame"); let buffer = terminal.backend().buffer().clone(); let mut out = String::new(); for y in 0..buffer.area.height { for x in 0..buffer.area.width { out.push_str(buffer[(x, y)].symbol()); } out.push('\n'); } out } #[test] fn dark_theme_default_view_snapshot() { let mut app = App::new(); let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("default_simple_dark", snapshot); } #[test] fn light_theme_default_view_snapshot() { let mut app = App::new(); let theme = Theme::light(); let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("default_simple_light", snapshot); } #[test] fn advanced_mode_default_view_snapshot() { let mut app = App::new(); app.mode = Mode::Advanced; let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("default_advanced_dark", snapshot); } #[test] fn advanced_mode_hint_panel_surfaces_sql_candidates() { // Regression reproduction (ADR-0022 Amendment 1): in // advanced mode the hint panel must surface ambient // assistance for SQL — here the FROM-slot table candidate // `Customers` — not the empty placeholder. Before the fix // `render_hint_panel` returned `None` for advanced mode and // the hint resolver/completion ran in simple mode, so a SQL // statement got the "this is SQL" gate and no candidates. let mut app = App::new(); app.mode = Mode::Advanced; app.schema_cache.tables.push("Customers".to_string()); app.input.push_str("select * from "); app.input_cursor = app.input.len(); let theme = Theme::dark(); let rendered = render_to_string(&mut app, &theme, 80, 24); assert!( rendered.contains("Customers"), "advanced-mode hint panel should surface the FROM-slot \ candidate `Customers`; got:\n{rendered}", ); } #[test] fn highlighted_input_all_token_classes_snapshot() { // ADR-0022 stage 2: representative input that exercises // every token class — keyword (insert / into / values // / null), identifier (T), number (1), string ('hi'), // punct (parens, comma), flag (--all-rows), lex error // ($ at the end). The snapshot captures the rendered // text symbols only — `render_to_string` does not record // ratatui style — so this test is a regression net for // text layout, not for colour mappings. Colour mappings // are unit-tested in `input_render::tests`. let mut app = App::new(); app.input .push_str("insert into T values (1, 'hi', null) --all-rows $"); app.input_cursor = app.input.len(); let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("highlighted_input_all_token_classes_dark", snapshot); } #[test] fn one_shot_advanced_prompt_snapshot() { // Typing `:sel` in simple mode should flip the input panel // label to `Advanced:` while the persistent mode stays simple. // The visible input includes the auto-inserted space after `:`. // With the cursor after `sel` (ADR-0022 Amendment 1), the hint // panel now offers the advanced `select` completion — the `:` // sigil is stripped before the ambient walk. let mut app = App::new(); app.input.push_str(": sel"); app.input_cursor = app.input.len(); let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("one_shot_advanced_dark", snapshot); } #[test] fn rebuild_confirm_modal_snapshot() { use crate::app::{Modal, RebuildConfirmModal}; let mut app = App::new(); app.modal = Some(Modal::RebuildConfirm(RebuildConfirmModal { summary: "3 tables and 47 rows will be reconstructed; \ the existing playground.db will be replaced" .to_string(), })); let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("rebuild_confirm_modal_dark", snapshot); } #[test] fn populated_with_table_snapshot() { // Items panel lists tables; output panel shows the // structure of the current table. use crate::app::{OutputKind, OutputLine}; use crate::db::{ColumnDescription, TableDescription}; let mut app = App::new(); app.tables = vec!["Customers".to_string(), "Orders".to_string()]; use crate::dsl::Type; let desc = TableDescription { name: "Customers".to_string(), columns: vec![ ColumnDescription { name: "id".to_string(), user_type: Some(Type::Serial), sqlite_type: "INTEGER".to_string(), notnull: false, primary_key: true, unique: false, default: None, check: None, }, ColumnDescription { name: "Name".to_string(), user_type: Some(Type::Text), sqlite_type: "TEXT".to_string(), notnull: false, primary_key: false, unique: false, default: None, check: None, }, ], outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), indexes: Vec::new(), }; app.current_table = Some(desc); // Mirror what the App writes when a DSL command succeeds. app.output.push_back(OutputLine { text: "[ok] create table Customers".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, styled_runs: None, }); app.output.push_back(OutputLine { text: " Customers".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, styled_runs: None, }); app.output.push_back(OutputLine { text: " id serial [PK]".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, styled_runs: None, }); app.output.push_back(OutputLine { text: " Name text".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, styled_runs: None, }); let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("populated_with_table_dark", snapshot); } #[test] fn items_panel_nests_indexes_under_their_table() { // S2 (ADR-0025): the items panel renders each table // with its index names indented beneath it. let mut app = App::new(); app.tables = vec!["Customers".to_string(), "Orders".to_string()]; app.schema_cache.table_indexes.insert( "Customers".to_string(), vec!["idx_email".to_string()], ); let theme = Theme::dark(); let out = render_to_string(&mut app, &theme, 80, 24); assert!(out.contains("Customers"), "table listed:\n{out}"); assert!(out.contains("Orders"), "table listed:\n{out}"); assert!(out.contains("idx_email"), "index nested in panel:\n{out}"); } }