//! 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, EchoStatus, EffectiveMode, NavFocus, 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. /// Minimum terminal width at which the schema sidebar (the left items /// column) is shown by default (ADR-0046 DB1). At or below this the /// sidebar is hidden so the output/input panels get the full width — /// notably the 90-column screencasts. Tunable. const SIDEBAR_MIN_WIDTH: u16 = 90; /// Whether the schema sidebar is visible — a pure function of terminal /// width (ADR-0046 DB1). Phase C will also reveal it while a sidebar /// panel is focused via the Ctrl-O peek. const fn sidebar_visible(total_width: u16) -> bool { total_width > SIDEBAR_MIN_WIDTH } /// Height (including borders) of the Relationships sub-panel within the /// left column (ADR-0046 DB4): floored at 5 rows (so an empty panel /// shows "(none)"), grown with `content_rows` up to half the column, /// and never so tall that the Tables panel above drops below 3 rows. const fn relationships_panel_height(col_h: u16, content_rows: u16) -> u16 { let want = content_rows + 2; // + top/bottom borders let mut h = if want < 5 { 5 } else { want }; let cap = col_h / 2; // never more than half the column if h > cap { h = cap; } let max_h = col_h.saturating_sub(3); // leave Tables at least 3 rows if h > max_h { h = max_h; } h } 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); // ADR-0046 DB1: on a wide terminal the schema sidebar takes a fixed // left column; at or below SIDEBAR_MIN_WIDTH it is hidden and the // right column spans the full width. if sidebar_visible(area.width) { let columns = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Length(28), Constraint::Min(20)]) .split(outer[0]); // ADR-0046 DB4: the sidebar stacks Tables (top) over a // Relationships panel (bottom), the latter content-sized within // [5 rows, half the column]. let rel_content = (app.relationships.len() as u16).saturating_mul(3); let rel_h = relationships_panel_height(columns[0].height, rel_content); let sidebar = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(3), Constraint::Length(rel_h)]) .split(columns[0]); render_items_panel(app, theme, frame, sidebar[0]); render_relationships_panel(app, theme, frame, sidebar[1]); render_right_column(app, theme, frame, columns[1]); } else { render_right_column(app, theme, frame, outer[0]); } render_project_label(app, theme, frame, outer[1]); render_status_bar(app, theme, frame, outer[2]); // ADR-0046 DC2: in navigation mode, draw the focused sidebar as an // expanded overlay over the (unchanged) base render — revealing it // if it was hidden (peek) and widening it for browsing. Drawn below // the modal layer; a modal can't open in navigation mode, but if one // is somehow up it still wins. if app.nav_focus.in_sidebar() { render_nav_sidebar_overlay(app, theme, frame, outer[0]); } // 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); } // ADR-0047 D4: the demo overlays draw last of all — over modals — so // a keystroke badge (and, in Phase C, a step caption) stays visible // while the load picker (the #24 cast) or any modal is up. if app.demo_mode { render_demo_overlays(app, frame); } } /// The fixed high-contrast style for every demo overlay (ADR-0047 D4): /// bold black text on a yellow background. fn demo_overlay_style() -> Style { Style::default() .bg(crate::theme::DEMO_OVERLAY_BG) .fg(crate::theme::DEMO_OVERLAY_FG) .add_modifier(Modifier::BOLD) } /// Draw the demonstration-mode overlays anchored to the output panel's /// inner bottom-right corner (ADR-0047 D4): the step caption (if any) at /// the bottom, the keystroke badge stacked directly above it (or at the /// bottom when there is no caption). Both are inset one cell and skipped /// rather than overflowing when the area is too small. fn render_demo_overlays(app: &App, frame: &mut Frame<'_>) { let area = app.last_output_area; if area.width == 0 || area.height == 0 { return; // not measured yet } // Caption first — it owns the bottom-right corner. The badge then // stacks above whatever the caption actually occupied. let caption_rect = app .demo_caption .as_deref() .and_then(|text| render_caption_box(text, area, frame)); if let Some(label) = app.demo_badge { render_badge_box(label, area, caption_rect, frame); } } /// Paint a flat filled overlay rectangle — a solid yellow block with no /// border glyphs (ADR-0047 D4) — and lay `body` inside a one-cell /// margin. The borderless solid block is deliberately *unlike* the app's /// bordered panels, so the demo overlays read as a distinct callout. fn fill_overlay_rect(rect: Rect, body: String, frame: &mut Frame<'_>) { frame.render_widget(ratatui::widgets::Clear, rect); // `Block` with no borders fills the whole rect with the overlay // background (same mechanism as `paint_background`). frame.render_widget(Block::default().style(demo_overlay_style()), rect); let inner = rect.inner(Margin { horizontal: 1, vertical: 1, }); frame.render_widget(Paragraph::new(body).style(demo_overlay_style()), inner); } /// A small high-contrast keystroke badge (`[TAB]`, `[ENTER]`, …) inset /// one cell from the bottom-right of `area` (ADR-0047 D2/D4) — the label /// on a flat yellow rectangle with a one-cell margin. When a caption box /// is present (`above`), the badge sits directly on top of it, right /// edges aligned; otherwise it takes the bottom-right corner. Skipped /// rather than overflowing if it cannot fit. fn render_badge_box(label: &str, area: Rect, above: Option, frame: &mut Frame<'_>) { let box_w = label.chars().count() as u16 + 2; // one-cell margin each side let box_h = 3; // text row + a margin row above and below if box_w + 1 > area.width { return; } let x = area.x + area.width - box_w - 1; let y = match above { // Directly above the caption, right edges aligned. Some(c) => { if c.y < area.y + box_h { return; // no room above the caption } c.y - box_h } None => { if box_h + 1 > area.height { return; } area.y + area.height - box_h - 1 } }; fill_overlay_rect(Rect { x, y, width: box_w, height: box_h }, label.to_string(), frame); } /// A step-caption box inset one cell from the bottom-right of `area` /// (ADR-0047 D3/D4): the text word-wrapped to at most 3 lines within a /// corner-sized width, bold black on a flat yellow rectangle. Returns /// the rect it drew, or `None` if it was too small to place (so the /// badge can fall back to the bottom-right corner). fn render_caption_box(text: &str, area: Rect, frame: &mut Frame<'_>) -> Option { // Content width capped so the box stays corner-sized; the caption // wraps to ≤ 3 lines and ellipsises beyond (D4). let content_w = 40.min(area.width.saturating_sub(4)) as usize; if content_w < 4 { return None; // output too narrow for a useful caption } let lines = clamp_wrapped(text, content_w, 3); let inner_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0); let box_w = inner_w as u16 + 2; // one-cell margin each side let box_h = lines.len() as u16 + 2; // text rows + a margin row above and below if box_w + 1 > area.width || box_h + 1 > area.height { return None; } let rect = Rect { x: area.x + area.width - box_w - 1, y: area.y + area.height - box_h - 1, width: box_w, height: box_h, }; fill_overlay_rect(rect, lines.join("\n"), frame); Some(rect) } /// Width (columns) of the navigation-mode expanded sidebar overlay /// (ADR-0046 DC2). Wide enough that most relationship endpoints fit on /// one line, turning horizontal truncation into vertical scrolling. const NAV_EXPANDED_WIDTH: u16 = 45; /// Blank columns cleared to the right of the expanded sidebar overlay /// (ADR-0046 DC2), separating it from the base panels left visible /// behind it so the overlay's right edge reads cleanly. const NAV_OVERLAY_GUTTER: u16 = 1; /// Draw the focused sidebar, expanded, as an overlay over the left of /// the main content area (ADR-0046 DC2/DC3). `Clear` + a background /// repaint hide the base render underneath; the two panels keep the /// DB4 split. The focused panel is accent-bordered (DC3). fn render_nav_sidebar_overlay(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { // ADR-0046 DC2: clear the sidebar strip plus a one-column gutter and // paint the expanded sidebar over it. The base output / input / hint // stay visible to the right — unchanged, just partially occluded — // and the gutter keeps them from butting against the sidebar's // border. They are restored fully on the next frame when navigation // mode exits. let width = NAV_EXPANDED_WIDTH.min(area.width); let cleared_w = (width + NAV_OVERLAY_GUTTER).min(area.width); let cleared = Rect { x: area.x, y: area.y, width: cleared_w, height: area.height, }; frame.render_widget(ratatui::widgets::Clear, cleared); paint_background(theme, frame, cleared); let sidebar = Rect { x: area.x, y: area.y, width, height: area.height, }; let rel_content = (app.relationships.len() as u16).saturating_mul(3); let rel_h = relationships_panel_height(sidebar.height, rel_content); let parts = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(3), Constraint::Length(rel_h)]) .split(sidebar); render_items_panel(app, theme, frame, parts[0]); render_relationships_panel(app, theme, frame, parts[1]); } /// Border style for a sidebar panel: an accented, bold border when it /// holds navigation focus (ADR-0046 DC3), the muted border otherwise. fn panel_border_style(theme: &Theme, focused: bool) -> Style { if focused { Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.border) } } 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); } /// Format a stored ISO-8601 UTC timestamp for display in a /// confirmation dialog (issue #13): parse it, convert to the /// machine's local timezone, and render a fixed human-friendly /// form (`24 May 2026, 11:00`). Month names stay English — no /// locale feature. Falls back to the raw input if it can't be /// parsed; this is defensive only, since stored values are always /// `utc_iso8601_now()` output. fn format_snapshot_timestamp(iso: &str) -> String { chrono::DateTime::parse_from_rfc3339(iso) .map(|dt| format_local_datetime(dt.with_timezone(&chrono::Local))) .unwrap_or_else(|_| iso.to_string()) } /// Render a timezone-aware datetime in the fixed display form. /// Split out from [`format_snapshot_timestamp`] so the format can /// be unit-tested deterministically with a fixed offset (the /// `Local` conversion itself is machine-dependent). fn format_local_datetime(dt: chrono::DateTime) -> String where Tz: chrono::TimeZone, Tz::Offset: std::fmt::Display, { dt.format("%-d %b %Y, %H:%M").to_string() } /// Preferred outer width (columns) for the undo/redo confirm /// dialog (issue #13): wide enough to hold the longest content /// line on a single row, clamped to sane bounds and the available /// area so a short insert no longer wraps on roomy terminals. fn undo_dialog_width( content_widths: impl IntoIterator, area_width: u16, ) -> u16 { /// Floor — comfortably fits the button row plus borders. const MIN: u16 = 34; /// Ceiling for outlier (ultra-wide) terminals. const MAX: u16 = 100; let widest = content_widths.into_iter().max().unwrap_or(0); // +4: left/right border (2) + one padding column each side (2). let preferred = u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4); let upper = area_width.min(MAX); let lower = MIN.min(upper); preferred.clamp(lower, upper) } /// `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 intro = if m.is_redo { crate::t!("modal.redo_confirm_command") } else { crate::t!("modal.undo_confirm_command") }; let title = if m.is_redo { crate::t!("modal.redo_confirm_title") } else { crate::t!("modal.undo_confirm_title") }; let intro_line = format!("{intro} {}", m.command); // Local-time, human-formatted snapshot stamp (issue #13). let when_display = format_snapshot_timestamp(&m.timestamp); let when_line = crate::t!("modal.undo_confirm_when", timestamp = when_display); let prompt = crate::t!("modal.undo_confirm_prompt"); // Reconstruct the button row purely to measure its width — the // styled spans are built below. Keep this in sync with them. let buttons_measure = format!( "[Y] {} [N] {} Esc {}", crate::t!("shortcut.yes"), crate::t!("shortcut.no"), crate::t!("shortcut.cancel"), ); // Grow the dialog to fit the longest content line on one row // (issue #13). The title sits in the border, so it needs two // extra columns for the surrounding spaces. let dialog_w = undo_dialog_width( [ title.chars().count() + 2, intro_line.chars().count(), when_line.chars().count(), prompt.chars().count(), buttons_measure.chars().count(), ], area.width, ); let inner_w = dialog_w.saturating_sub(4) as usize; let mut body_lines: Vec = wrap_lines(&intro_line, inner_w); body_lines.extend(wrap_lines(&when_line, 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_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(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 } /// Absolute ceiling on Hint-panel content rows. Per ADR-0046 (DA1/DA2) /// the panel's height is no longer driven by the hint *content* — it is /// a pure function of terminal geometry (`hint_rows`), fixed between /// resizes, so it cannot resize mid-typing and shove the input/output /// panels (#20). This is the most rows `hint_rows` will ever allocate; /// a hint longer than the allocation is ellipsis-truncated (issue #12's /// overflow signalling is retained — only the *sizing* changed). const MAX_HINT_ROWS: usize = 3; /// Terminal heights below this are "compact" (covers the ~25-row /// screencasts); at or above it the terminal is "comfortable" and can /// afford taller panels (ADR-0046 DA2). Tunable. const COMFORTABLE_MIN_HEIGHT: u16 = 40; /// A 3rd hint row is only ever needed when the hint column's inner /// width is narrow enough to wrap the longest catalog hint past two /// lines; at or above this width two rows always suffice (ADR-0046 DA2, /// measured against `src/friendly/strings/en-US.yaml`). const HINT_THIRD_ROW_MAX_INNER: u16 = 54; /// Input- and hint-panel content-row counts as a pure function of the /// right column's geometry (ADR-0046 DA1/DA2/DA4) — NOT of the hint or /// input text. That is the point of #20: heights fixed per terminal /// size cannot jump as the user types. Returns `(input_rows, /// hint_rows)`; add 2 to each for the panel borders. /// /// - Compact height (`< COMFORTABLE_MIN_HEIGHT`): input 1 row, hint 2. /// - Comfortable height: input 2 rows (DA4 two-row display); hint 2, or /// 3 when the column is narrow enough (`inner < /// HINT_THIRD_ROW_MAX_INNER`) that the longest hint needs a third /// line. /// - Degradation: on a terminal too short to honour the output panel's /// `Min(5)` plus both panels, the hint shrinks first, then the input, /// so the output keeps its floor. const fn panel_heights(area: Rect) -> (u16, u16) { let comfortable = area.height >= COMFORTABLE_MIN_HEIGHT; let inner_w = area.width.saturating_sub(2); let mut input_c: u16 = if comfortable { 2 } else { 1 }; let mut hint_c: u16 = if !comfortable { 2 } else if inner_w < HINT_THIRD_ROW_MAX_INNER { MAX_HINT_ROWS as u16 } else { 2 }; // Honour the output panel's Min(5) first on a very short terminal: // 5 (output) + (input_c + 2) + (hint_c + 2) must fit in the column. // Shrink the hint first, then the input. while 5 + (input_c + 2) + (hint_c + 2) > area.height { if hint_c > 1 { hint_c -= 1; } else if input_c > 1 { input_c -= 1; } else { break; } } (input_c, hint_c) } /// Word-wrap `text` to `width`, then clamp to at most `max_rows` /// rows. If wrapping produced more rows than the cap, the last kept /// row is truncated to end with an ellipsis so the overflow is /// signalled rather than silently dropped (issue #12). Every /// returned row fits within `width`. fn clamp_wrapped(text: &str, width: usize, max_rows: usize) -> Vec { let mut lines = wrap_lines(text, width); if lines.len() <= max_rows { return lines; } lines.truncate(max_rows.max(1)); if let Some(last) = lines.last_mut() { // Reserve one column for the ellipsis. let budget = width.saturating_sub(1); let mut chars: Vec = last.chars().collect(); while chars.len() > budget { chars.pop(); } chars.push('…'); *last = chars.into_iter().collect(); } 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) { // ADR-0046 DA1/DA2: the Hint panel's height is a pure function of // the column geometry, fixed between resizes — it no longer tracks // the hint content, so typing cannot make it resize and shove the // input/output panels (#20). The hint is then clamped to that fixed // row budget. The hint panel spans the full column width, so // `area.width` is its width too. let (input_c, hint_c) = panel_heights(area); let hint_lines = resolve_hint_lines(app, theme, area.width, hint_c as usize); let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(5), // Output panel Constraint::Length(input_c + 2), // Input panel (1 row, or 2 when tall) Constraint::Length(hint_c + 2), // Hint panel (geometry-fixed) ]) .split(area); render_output_panel(app, theme, frame, rows[0]); render_input_panel(app, theme, frame, rows[1]); render_hint_panel(theme, frame, rows[2], hint_lines); } 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: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(panel_border_style( theme, app.nav_focus == NavFocus::SidebarTables, )) .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)); // ADR-0046 DC3: clamp + store the scroll before the (borrowing) // lines are built. Visible rows and the content total are computed // by counting (one row per table + one per index), so the `&mut // app` writes finish before the immutable line borrows begin. let visible = area.height.saturating_sub(2) as usize; app.last_tables_visible = visible; 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 total: usize = app .tables .iter() .map(|t| 1 + app.schema_cache.table_indexes.get(t).map_or(0, Vec::len)) .sum(); let offset = app.tables_scroll.min(total.saturating_sub(visible)); app.tables_scroll = offset; 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 { // Mark a UNIQUE index so the panel distinguishes it from // a performance-only index (ADR-0035 §4d). let unique = if index.unique { " [unique]" } else { "" }; lines.push(Line::from(Span::styled( format!(" {}{unique}", index.name), Style::default().fg(theme.muted), ))); } } } let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0)); frame.render_widget(paragraph, area); } /// The Relationships sub-panel below the Tables list (ADR-0046 DB2). In /// the narrow (unfocused) column each relationship is three lines — its /// name, then the endpoints broken at the arrow to fit — every line /// ellipsized past the inner width. Phase C adds focus + scroll for the /// overflow; for now content beyond the panel's height is clipped. fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(panel_border_style( theme, app.nav_focus == NavFocus::SidebarRelationships, )) .title(Span::styled( format!(" {} ", crate::t!("panel.relationships_title")), Style::default() .fg(theme.fg) .add_modifier(Modifier::BOLD), )) .style(Style::default().bg(theme.bg).fg(theme.fg)); // ADR-0046 DC3: clamp + store the scroll before the borrowing lines // (three rows per relationship). let visible = area.height.saturating_sub(2) as usize; app.last_relationships_visible = visible; if app.relationships.is_empty() { let placeholder = Paragraph::new(Line::from(Span::styled( crate::t!("panel.relationships_empty"), Style::default() .fg(theme.muted) .add_modifier(Modifier::ITALIC), ))) .block(block); frame.render_widget(placeholder, area); return; } let total = app.relationships.len() * 3; let offset = app.relationships_scroll.min(total.saturating_sub(visible)); app.relationships_scroll = offset; let inner_w = area.width.saturating_sub(2) as usize; let name_style = Style::default().fg(theme.fg); let detail_style = Style::default().fg(theme.muted); let mut lines: Vec> = Vec::new(); for rel in &app.relationships { lines.push(Line::from(Span::styled( ellipsize(&rel.name, inner_w), name_style, ))); let parent = format!(" {}.{} ->", rel.parent_table, rel.parent_columns.join(", ")); lines.push(Line::from(Span::styled(ellipsize(&parent, inner_w), detail_style))); let child = format!(" {}.{}", rel.child_table, rel.child_columns.join(", ")); lines.push(Line::from(Span::styled(ellipsize(&child, inner_w), detail_style))); } let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0)); frame.render_widget(paragraph, area); } /// Truncate `s` to `width` display columns, appending an ellipsis when /// it overflows (ADR-0046 DB2). Assumes one column per character. fn ellipsize(s: &str, width: usize) -> String { if width == 0 { return String::new(); } if s.chars().count() <= width { return s.to_string(); } let mut out: String = s.chars().take(width.saturating_sub(1)).collect(); out.push('…'); out } 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); // ADR-0044 §3: record the panel width so a later `show relationship` // diagram (rendered App-side) can choose side-by-side vs vertical. app.last_output_width = inner.width; // ADR-0047 D4: record the full inner area so the top-level draw can // anchor the demo overlays to the output panel's bottom-right corner. app.last_output_area = inner; 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 | OutputKind::TeachingEcho => "[system] ".len(), OutputKind::Error => "[error] ".len(), }; // ADR-0040: a completed echo renders ` ✓/✗` — // the `running: ` prefix is dropped and a 2-column marker // (space + glyph) appended; everything else renders its // text verbatim. let content_chars = if matches!( (line.kind, line.status), (OutputKind::Echo, Some(EchoStatus::Ok | EchoStatus::Err)) ) { line.text .strip_prefix(crate::dsl::ECHO_PREFIX) .unwrap_or(line.text.as_str()) .chars() .count() + 2 } else { line.text.chars().count() }; let total = tag_len + content_chars; 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), // ADR-0038 §4 / §6: de-emphasised text — the `Executing SQL:` // prefix and every category-3 prose line (caveat + the // existing `client_side.*` notes). `theme.muted` is the // established dim foreground. OutputStyleClass::Hint => Style::new().fg(theme.muted), // ADR-0044 relationship diagrams. Reuse existing theme colours // (no new Theme fields): the table name stands out via weight, // keys + cardinality take accent colours, connectors are muted. OutputStyleClass::DiagramTableName => { Style::new().fg(theme.fg).add_modifier(Modifier::BOLD) } OutputStyleClass::DiagramKey => Style::new() .fg(theme.plan_efficient) .add_modifier(Modifier::BOLD), OutputStyleClass::DiagramCardinality => Style::new() .fg(theme.tok_number) .add_modifier(Modifier::BOLD), OutputStyleClass::DiagramConnector => Style::new().fg(theme.muted), } } fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> { // ADR-0037 Amendment (issue #10): the output tag is colour-coded by // the message's STATUS (its kind), not the submission mode — so the // leftmost glyph the eye lands on says "ok" vs "error" at a glance, // and a routine `[system]` line never looks identical to an `[error]`. // The echo line is the sole exception: its tag's whole job is to label // the submission mode (ADR-0037's stated purpose), so it keeps the // mode tint (per-command success rides the trailing ✓/✗ — ADR-0040). let tag_style = match line.kind { OutputKind::Echo => match line.mode_at_submission { Mode::Simple => Style::default().fg(theme.mode_simple), Mode::Advanced => Style::default().fg(theme.mode_advanced), }, OutputKind::System | OutputKind::TeachingEcho => Style::default().fg(theme.system), OutputKind::Error => Style::default().fg(theme.error), }; let tag = match line.kind { OutputKind::Echo => format!("[{}] ", line.mode_at_submission.label().to_lowercase()), OutputKind::System | OutputKind::TeachingEcho => "[system] ".to_string(), OutputKind::Error => "[error] ".to_string(), }; // ADR-0040: an echo renders `running: ` while pending // (and for untracked parse/pre-flight rejections), and // ` ✓` / ` ✗` once an executed command completes // — the marker replaces the old `[ok]`/`failed:` summary line. // Simple-mode input keeps its token-class highlighting (ADR-0022 // §5); advanced-mode input renders plain, as before. The body // shape is ``; the prefix is pinned to the // catalog template by // `dsl::tests::echo_prefix_matches_catalog_template`. if line.kind == OutputKind::Echo { let input = line .text .strip_prefix(crate::dsl::ECHO_PREFIX) .unwrap_or(line.text.as_str()); let mut spans: Vec> = Vec::with_capacity(3 + input.len() / 4); spans.push(Span::styled(tag, tag_style)); // Pending / untracked → keep the `running: ` prefix; // completed → drop it (the marker carries the outcome). if !matches!(line.status, Some(EchoStatus::Ok | EchoStatus::Err)) { spans.push(Span::styled( crate::dsl::ECHO_PREFIX, Style::default().fg(theme.fg), )); } if line.mode_at_submission == Mode::Simple { for run in crate::input_render::lex_to_runs(input, theme) { spans.push(Span::styled( &input[run.byte_range.0..run.byte_range.1], run.style, )); } } else { spans.push(Span::styled(input, Style::default().fg(theme.fg))); } match line.status { Some(EchoStatus::Ok) => { spans.push(Span::styled(" ✓", Style::default().fg(theme.system))); } Some(EchoStatus::Err) => { spans.push(Span::styled(" ✗", Style::default().fg(theme.error))); } _ => {} } return Line::from(spans); } // ADR-0038 §4 styled-runs polish — the DSL → SQL teaching echo // gets the same syntactic treatment as the input echo, but with // a dimmed `Executing SQL: ` prefix instead of the mode label, // and the SQL portion lexed in `Mode::Advanced` (the echoed SQL // is always advanced syntax, regardless of submission mode). if line.kind == OutputKind::TeachingEcho && let Some(rest) = line.text.strip_prefix(crate::echo::TEACHING_ECHO_LABEL) { let prefix_len = crate::echo::TEACHING_ECHO_LABEL.len(); let mut spans: Vec> = Vec::with_capacity(2 + rest.len() / 4); spans.push(Span::styled(tag, tag_style)); spans.push(Span::styled( &line.text[..prefix_len], Style::default().fg(theme.muted), )); for run in crate::input_render::lex_to_runs_in_mode(rest, theme, Mode::Advanced) { spans.push(Span::styled( &rest[run.byte_range.0..run.byte_range.1], run.style, )); } return Line::from(spans); } // A TeachingEcho line missing the canonical prefix is a builder // bug; it 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); } // ADR-0037 Amendment (issue #10): bodies are neutral — the status // colour lives on the tag, not flooded across the message text. An // error body keeps weight via BOLD (rustc-style: severity-coloured // label, readable bold message) rather than a hard-to-read wall of // red; a system body is plain `theme.fg`. let body_style = match line.kind { // TeachingEcho without a prefix reaches here only as a builder // bug; it shares the neutral system body so it degrades // gracefully rather than crashing. OutputKind::Echo | OutputKind::System | OutputKind::TeachingEcho => { Style::default().fg(theme.fg) } OutputKind::Error => Style::default().fg(theme.fg).add_modifier(Modifier::BOLD), }; Line::from(vec![ Span::styled(tag, tag_style), Span::styled(line.text.as_str(), body_style), ]) } /// Horizontal scroll offset (in display columns) for a single-line /// input that may overflow its `text_width`-column viewport (ADR-0046 /// DA3). Keeps the cursor visible; when the line overflows, reserves one /// column on each side for the `<` / `>` overflow markers so a marker /// never hides the cursor. `cursor_col` is the column of the cursor /// cell (which can be `line_cols`, one past the last char, when the /// cursor sits at the end). Returns the new offset given the previous /// one, so the view only scrolls when the cursor would leave the window. const fn input_scroll_offset( line_cols: usize, cursor_col: usize, text_width: usize, offset: usize, ) -> usize { // The line (including the cursor-at-end cell) fits: no scroll. if line_cols < text_width || text_width == 0 { return 0; } // Reserve a column each side for the `<` / `>` markers. let eff = if text_width > 2 { text_width - 2 } else { 1 }; let mut off = offset; if cursor_col < off { off = cursor_col; } else if cursor_col >= off + eff { off = cursor_col + 1 - eff; } // Never scroll past the point where the cursor-at-end cell shows. let max_off = (line_cols + 1).saturating_sub(eff); if off > max_off { off = max_off; } off } fn render_input_panel(app: &mut 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)); let cursor = app.input_cursor.min(app.input.len()); // 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 first row's text area is always // `inner.width - 6` and the typed command never shifts sideways // when the validity indicator appears or hides. A two-row input // (DA4) lets the *second* row use the full width. let inner = block.inner(area); let text_area = Rect { width: inner.width.saturating_sub(6), ..inner }; // Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both modes — // the highlight walker runs with the active mode so SQL keywords / // operators / CASE / function calls colour correctly. The cursor // cell is rendered inverted (an empty-range run) so it is visible // without a real terminal cursor. let mode_for_render = match effective { EffectiveMode::Simple => crate::mode::Mode::Simple, EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => { crate::mode::Mode::Advanced } }; frame.render_widget(block, area); // ADR-0046 DA3/DA4: render the single logical line across one row // (compact terminals) or two (comfortable, height ≥ 40), scrolling // horizontally in either case so the cursor stays visible. if inner.height >= 2 { render_input_two_rows(app, theme, frame, inner, text_area, cursor, mode_for_render); } else { render_input_one_row(app, theme, frame, text_area, cursor, mode_for_render); } 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, ); } } /// One-row input rendering (ADR-0046 DA3): the single logical line is /// horizontally scrolled so the cursor stays visible, with `<` / `>` /// markers (muted) at the reserved edge columns signalling hidden /// content. The offset is stored *before* the highlight spans borrow /// `app.input`, so the `&mut app` write does not clash. fn render_input_one_row( app: &mut App, theme: &Theme, frame: &mut Frame<'_>, text_area: Rect, cursor: usize, mode_for_render: crate::mode::Mode, ) { let line_cols = app.input.chars().count(); let cursor_col = app.input[..cursor].chars().count(); let tw = text_area.width as usize; let offset = input_scroll_offset(line_cols, cursor_col, tw, app.input_scroll_offset); app.input_scroll_offset = offset; 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); if line_cols > tw || offset > 0 { // Overflow: reserve one column each side for `<` / `>` markers, // render the windowed text between them, then draw the markers // for whichever side still has hidden content. let eff = if tw > 2 { tw - 2 } else { 1 }; let mid = Rect { x: text_area.x + 1, width: eff as u16, ..text_area }; frame.render_widget( Paragraph::new(Line::from(spans)).scroll((0, offset as u16)), mid, ); let marker = Style::default().fg(theme.muted); if offset > 0 { frame.render_widget( Paragraph::new(Span::styled("<", marker)), Rect { width: 1, ..text_area }, ); } if offset + eff < line_cols { frame.render_widget( Paragraph::new(Span::styled(">", marker)), Rect { x: text_area.x + text_area.width.saturating_sub(1), width: 1, ..text_area }, ); } } else { frame.render_widget(Paragraph::new(Line::from(spans)), text_area); } } /// Two-row input rendering (ADR-0046 DA4): on a comfortable terminal the /// single logical line is soft-wrapped across two visual rows — the /// first row stops 6 columns short (the ADR-0027 indicator reserve), the /// second uses the full width. When the line overflows both rows it /// scrolls horizontally (one column each side reserved for `<` / `>` /// markers) so the cursor stays visible. `text_area` is the first /// (narrower) row; `inner` spans both rows. fn render_input_two_rows( app: &mut App, theme: &Theme, frame: &mut Frame<'_>, inner: Rect, text_area: Rect, cursor: usize, mode_for_render: crate::mode::Mode, ) { let row0_w = text_area.width as usize; // first row reserves the indicator let row1_w = inner.width as usize; // second row uses the full width let capacity = row0_w + row1_w; let line_cols = app.input.chars().count(); let cursor_col = app.input[..cursor].chars().count(); let offset = input_scroll_offset(line_cols, cursor_col, capacity, app.input_scroll_offset); app.input_scroll_offset = offset; let runs = crate::input_render::render_input_runs_in_mode( &app.input, cursor, theme, &app.schema_cache, mode_for_render, ); let cells = expand_runs_to_cells(&app.input, &runs); let len = cells.len(); // Overflowing both rows reserves a marker column on each row's // outer edge; otherwise both rows use their full text width. let overflow = line_cols >= capacity; let row0_text_w = if overflow { row0_w.saturating_sub(1) } else { row0_w }; let row1_text_w = if overflow { row1_w.saturating_sub(1) } else { row1_w }; let eff_cap = row0_text_w + row1_text_w; let start = offset.min(len); let end = (offset + eff_cap).min(len); let window = &cells[start..end]; let split = row0_text_w.min(window.len()); let to_line = |cs: &[(String, Style)]| { Line::from( cs.iter() .map(|(s, st)| Span::styled(s.clone(), *st)) .collect::>(), ) }; let row0_x = if overflow { text_area.x + 1 } else { text_area.x }; frame.render_widget( Paragraph::new(to_line(&window[..split])), Rect { x: row0_x, y: inner.y, width: row0_text_w as u16, height: 1, }, ); frame.render_widget( Paragraph::new(to_line(&window[split..])), Rect { x: inner.x, y: inner.y + 1, width: row1_text_w as u16, height: 1, }, ); let marker = Style::default().fg(theme.muted); if overflow && offset > 0 { frame.render_widget( Paragraph::new(Span::styled("<", marker)), Rect { x: text_area.x, y: inner.y, width: 1, height: 1, }, ); } if overflow && end < len { frame.render_widget( Paragraph::new(Span::styled(">", marker)), Rect { x: inner.x + inner.width.saturating_sub(1), y: inner.y + 1, width: 1, height: 1, }, ); } } /// Expand styled runs into one owned `(grapheme, style)` cell per /// display column, including the inverted cursor cell (ADR-0046 DA4). /// The two-row renderer places cells across two visual rows and so /// needs them individually rather than as byte-range spans. fn expand_runs_to_cells( input: &str, runs: &[crate::input_render::StyledRun], ) -> Vec<(String, Style)> { let mut cells = Vec::new(); for r in runs { if r.byte_range.0 == r.byte_range.1 { // Cursor sentinel (empty range) → inverted space cell. cells.push((" ".to_string(), r.style)); } else { for ch in input[r.byte_range.0..r.byte_range.1].chars() { cells.push((ch.to_string(), r.style)); } } } cells } /// 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) } /// Resolve the Hint panel body into its rendered lines, pre-wrapped /// to the panel's inner width and clamped to `max_rows` with an /// ellipsis backstop (issue #12). `max_rows` is the geometry-fixed row /// budget chosen by `hint_rows` (ADR-0046 DA1/DA2); the panel does not /// resize to the hint, so a short hint simply leaves the spare rows /// blank and a long one is ellipsized at the budget. /// /// Resolution order for the 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) in the effective mode. /// 3. Otherwise, the empty-state placeholder. /// /// Prose hints (1, the ambient `Prose` arm, and the placeholder) /// word-wrap across up to `MAX_HINT_ROWS` rows. The candidate list /// stays a single row and scrolls horizontally with `<` / `>` /// markers (`render_candidate_line`) — it already self-fits, so it /// is not wrapped. /// /// 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. fn resolve_hint_lines( app: &App, theme: &Theme, area_width: u16, max_rows: usize, ) -> Vec> { let inner = area_width.saturating_sub(2) as usize; let muted = Style::default().fg(theme.muted); let prose = |text: &str| { clamp_wrapped(text, inner, max_rows) .into_iter() .map(|l| Line::from(Span::styled(l, muted))) .collect::>>() }; // 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(), ); match (app.hint.as_deref(), ambient) { (Some(set), _) => prose(set), (None, Some(crate::input_render::AmbientHint::Prose(text))) => prose(&text), (None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => { vec![render_candidate_line(&items, selected, inner, theme)] } (None, None) => prose(&crate::t!("panel.hint_empty")), } } fn render_hint_panel( theme: &Theme, frame: &mut Frame<'_>, area: Rect, lines: Vec>, ) { 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)); // Lines are already wrapped to the inner width by // `resolve_hint_lines`, so no Paragraph-level wrapping is needed. let paragraph = Paragraph::new(lines).block(block); 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); // (ADR-0035 §4i e) When a shared entry word merged simple + advanced // continuations, the list mixes mode-classes — colour SQL-only // (`Advanced`) and DSL-only (`Simple`) continuations with the mode // palette so a learner sees which is which; `Both` (and every // single-mode list) keeps the token-kind colour, so the tint appears // only where it is informative. let mixed = { let mut seen = std::collections::HashSet::new(); for c in items { seen.insert(c.mode.block_order()); } seen.len() > 1 }; let style_for = |i: usize| { let kind_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, crate::completion::CandidateKind::Function => theme.tok_function, }; let base_fg = if mixed { match items[i].mode { crate::completion::ModeClass::Both => kind_fg, crate::completion::ModeClass::Advanced => theme.mode_advanced, crate::completion::ModeClass::Simple => theme.mode_simple, } } else { kind_fg }; 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 hint_class_resolves_to_muted_foreground() { // ADR-0038 §4 / §6: the dim style class used for the // `Executing SQL:` prefix, the DontConvert caveat, and every // category-3 prose note. Pins the `theme.muted` resolution // across both palettes. for theme in [Theme::dark(), Theme::light()] { let style = output_span_style(OutputStyleClass::Hint, &theme); assert_eq!( style.fg, Some(theme.muted), "Hint must resolve to theme.muted on {:?} background", theme.background, ); } } #[test] fn teaching_echo_line_renders_dim_prefix_and_lexed_sql() { // ADR-0038 §4 styled-runs polish: a TeachingEcho line is laid // out as [tag][dim prefix][lexed SQL spans]. The tag is the green // status colour (a `[system]` line — ADR-0037 Amendment, issue // #10); the `Executing SQL: ` prefix is `theme.muted`; the SQL // portion is re-lexed in advanced mode so it picks up keyword / // identifier / literal colours. let theme = Theme::dark(); let line = OutputLine { text: format!( "{}{}", crate::echo::TEACHING_ECHO_LABEL, "CREATE TABLE T (id serial PRIMARY KEY)" ), kind: OutputKind::TeachingEcho, mode_at_submission: Mode::Advanced, styled_runs: None, status: None, }; let rendered = render_output_line(&line, &theme); // [system] tag, then the dim prefix, then ≥1 SQL spans. assert!(rendered.spans.len() >= 3, "tag + prefix + sql: {:?}", rendered.spans); assert_eq!(rendered.spans[0].content.as_ref(), "[system] "); assert_eq!(rendered.spans[1].content.as_ref(), crate::echo::TEACHING_ECHO_LABEL); assert_eq!( rendered.spans[1].style.fg, Some(theme.muted), "prefix is dimmed (theme.muted)", ); // At least one SQL span carries a keyword colour — `CREATE` is // the leading keyword and gets `tok_keyword`. Pinning this // also confirms the lexer ran in advanced mode (the bare // `CREATE` keyword is only highlighted past the entry word in // advanced — ADR-0030 §8). let has_keyword_span = rendered .spans .iter() .any(|s| s.style.fg == Some(theme.tok_keyword)); assert!( has_keyword_span, "expected at least one keyword-coloured SQL span: {:?}", rendered.spans ); } #[test] fn plain_text_matches_rendered_line_content() { // ADR-0041 drift-lock: `OutputLine::plain_text()` (the `copy` // payload) must equal the visible content `render_output_line` // produces — the concatenation of its span texts — for every // line shape. If the renderer changes how a line reads, this // fails until `plain_text` is brought back in step, so the // clipboard can never silently diverge from the screen. let theme = Theme::dark(); let label = crate::echo::TEACHING_ECHO_LABEL; let mut pending = OutputLine::echo("create table T", Mode::Simple); pending.status = Some(EchoStatus::Pending); let mut ok = OutputLine::echo("create table T", Mode::Simple); ok.status = Some(EchoStatus::Ok); let mut err = OutputLine::echo("insert into T values (1)", Mode::Advanced); err.status = Some(EchoStatus::Err); let lines = vec![ pending, ok, err, OutputLine { text: " T".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, styled_runs: None, status: None, }, OutputLine { text: "no such table".to_string(), kind: OutputKind::Error, mode_at_submission: Mode::Simple, styled_runs: None, status: None, }, OutputLine { text: format!("{label}CREATE TABLE T (id serial)"), kind: OutputKind::TeachingEcho, mode_at_submission: Mode::Advanced, styled_runs: None, status: None, }, 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, }, ], ), ]; for line in &lines { let rendered: String = render_output_line(line, &theme) .spans .iter() .map(|s| s.content.as_ref()) .collect(); assert_eq!( line.plain_text(), rendered, "plain_text drifted from render for a {:?} line", line.kind, ); } } #[test] fn category_three_prose_line_renders_all_dim() { // ADR-0038 §6: the existing illuminating client_side notes and // the new --dont-convert caveat are de-emphasised prose. A // styled-runs payload with a single Hint span over the whole // text yields one dim body span (plus the [system] tag). let theme = Theme::dark(); let text = "[client-side] 5 row(s) were transformed".to_string(); let line = OutputLine::styled( text.clone(), OutputKind::System, Mode::Advanced, vec![OutputSpan { byte_range: (0, text.len()), class: OutputStyleClass::Hint, }], ); let rendered = render_output_line(&line, &theme); assert_eq!(rendered.spans.len(), 2, "tag + one Hint span"); assert_eq!(rendered.spans[0].content.as_ref(), "[system] "); assert_eq!(rendered.spans[1].content.as_ref(), text.as_str()); assert_eq!( rendered.spans[1].style.fg, Some(theme.muted), "the whole prose line is dim", ); } #[test] fn candidate_line_colours_mixed_mode_continuations() { // ADR-0035 §4i (e): when a shared entry word's completions mix // mode-classes, Advanced (SQL-only) → mode_advanced, Simple // (DSL-only) → mode_simple, Both → token-kind colour. Spans // alternate candidate / separator, so candidate `i` is span `2*i`. use crate::completion::{Candidate, CandidateKind, ModeClass}; let theme = Theme::dark(); let items = vec![ Candidate { text: "table".into(), kind: CandidateKind::Keyword, mode: ModeClass::Both }, Candidate { text: "index".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced }, Candidate { text: "relationship".into(), kind: CandidateKind::Keyword, mode: ModeClass::Simple }, ]; let line = render_candidate_line(&items, None, 100, &theme); assert_eq!(line.spans[0].content.as_ref(), "table"); assert_eq!(line.spans[0].style.fg, Some(theme.tok_keyword), "Both keeps the kind colour"); assert_eq!(line.spans[2].content.as_ref(), "index"); assert_eq!(line.spans[2].style.fg, Some(theme.mode_advanced), "Advanced → advanced colour"); assert_eq!(line.spans[4].content.as_ref(), "relationship"); assert_eq!(line.spans[4].style.fg, Some(theme.mode_simple), "Simple → simple colour"); } #[test] fn candidate_line_single_mode_keeps_kind_colour() { // The mode tint applies ONLY when the list mixes classes. An // all-one-mode list (the common case, e.g. deep inside a SQL // statement) keeps the token-kind colours — no redundant tint. use crate::completion::{Candidate, CandidateKind, ModeClass}; let theme = Theme::dark(); let items = vec![ Candidate { text: "values".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced }, Candidate { text: "select".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced }, ]; let line = render_candidate_line(&items, None, 100, &theme); assert_eq!( line.spans[0].style.fg, Some(theme.tok_keyword), "an all-Advanced list is not tinted (would be redundant noise)" ); } #[test] fn system_line_renders_green_tag_and_neutral_body() { // ADR-0037 Amendment (issue #10): the status-coloured-tag model. // A `[system]` line's TAG carries the green status colour; its // BODY is neutral `theme.fg`, not flooded green. The mode tint // no longer leaks onto system lines (it belongs to the echo line // alone — ADR-0037's stated purpose). `mode_at_submission` is // Advanced here precisely to prove the tag is NOT the mode tint. let theme = Theme::dark(); let line = OutputLine { text: "plain system line".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Advanced, styled_runs: None, status: 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[0].content.as_ref(), "[system] "); assert_eq!( rendered.spans[0].style.fg, Some(theme.system), "the [system] tag is green (status), not the mode tint", ); assert_eq!(rendered.spans[1].content.as_ref(), "plain system line"); assert_eq!( rendered.spans[1].style.fg, Some(theme.fg), "the system body is neutral, not flooded green", ); } #[test] fn error_line_renders_red_tag_and_bold_neutral_body() { // ADR-0037 Amendment (issue #10): the `[error]` TAG carries the // red status colour (the leftmost glyph the eye lands on), while // the BODY renders in neutral `theme.fg` + BOLD (rustc-style: // severity-coloured label, readable bold message). A wall of red // prose is hard to read; the red lives on the tag instead. The // mode tint does not leak onto error lines. let theme = Theme::dark(); let line = OutputLine { text: "no such column: agx".to_string(), kind: OutputKind::Error, mode_at_submission: Mode::Advanced, styled_runs: None, status: None, }; let rendered = render_output_line(&line, &theme); assert_eq!(rendered.spans.len(), 2); assert_eq!(rendered.spans[0].content.as_ref(), "[error] "); assert_eq!( rendered.spans[0].style.fg, Some(theme.error), "the [error] tag is red (status), not the mode tint", ); assert_eq!(rendered.spans[1].content.as_ref(), "no such column: agx"); assert_eq!( rendered.spans[1].style.fg, Some(theme.fg), "the error body is neutral fg, not flooded red", ); assert!( rendered.spans[1].style.add_modifier.contains(Modifier::BOLD), "the error body is bold for weight without the red-wall readability cost", ); } #[test] fn echo_tag_keeps_the_mode_tint_not_a_status_colour() { // The echo line is the sole exception to the status-tag model: // its tag's whole job is to label the submission mode (ADR-0037), // so it keeps the mode tint. Per-command success rides the trailing // ✓/✗ marker (ADR-0040), not the tag. Locked for both modes so a // future refactor of `tag_style` cannot regress the echo. let theme = Theme::dark(); for (mode, want) in [ (Mode::Simple, theme.mode_simple), (Mode::Advanced, theme.mode_advanced), ] { let line = OutputLine { text: format!("{}create table T", crate::dsl::ECHO_PREFIX), kind: OutputKind::Echo, mode_at_submission: mode, styled_runs: None, status: None, }; let rendered = render_output_line(&line, &theme); assert_eq!( rendered.spans[0].style.fg, Some(want), "echo tag must stay the {mode:?} mode tint", ); } } #[test] fn teaching_echo_tag_is_green_like_other_system_lines() { // A TeachingEcho is a `[system]`-tagged line, so under the // status-tag model its tag is green, not the mode tint. The dim // prefix + lexed-SQL body are unchanged (covered separately). let theme = Theme::dark(); let line = OutputLine { text: format!( "{}{}", crate::echo::TEACHING_ECHO_LABEL, "CREATE TABLE T (id serial PRIMARY KEY)" ), kind: OutputKind::TeachingEcho, mode_at_submission: Mode::Advanced, styled_runs: None, status: None, }; let rendered = render_output_line(&line, &theme); assert_eq!(rendered.spans[0].content.as_ref(), "[system] "); assert_eq!( rendered.spans[0].style.fg, Some(theme.system), "the teaching-echo tag is green (a [system] line), not the mode tint", ); } #[test] fn error_and_system_tags_are_distinguishable_in_both_themes() { // Issue #10 regression guard, stated directly: the `[error]` and // `[system]` tags must NOT render in the same colour, and neither // may collapse to the mode tint. Asserted on both palettes — the // render logic is theme-agnostic, but locking both proves the // colours themselves stay distinct end to end. for theme in [Theme::dark(), Theme::light()] { let tag_fg = |kind| { render_output_line( &OutputLine { text: "x".to_string(), kind, mode_at_submission: Mode::Advanced, styled_runs: None, status: None, }, &theme, ) .spans[0] .style .fg }; let error_tag = tag_fg(OutputKind::Error); let system_tag = tag_fg(OutputKind::System); assert_ne!( error_tag, system_tag, "[error] and [system] tags must differ ({:?})", theme.background, ); assert_ne!( error_tag, Some(theme.mode_advanced), "the error tag must not be the mode tint ({:?})", theme.background, ); assert_ne!( system_tag, Some(theme.mode_advanced), "the system tag must not be the mode tint ({:?})", theme.background, ); } } 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 } // ---- Issue #12: long hints no longer clipped to one row ----- const LONG_HINT: &str = "(id, created_at auto-generated — skipped here; list columns explicitly, e.g. `insert into T (...) values (...)`, to set it.)"; #[test] fn long_prose_hint_shows_tail_across_multiple_rows() { // A multi-row hint panel (here 2 rows at a compact 80×20) shows // the hint's useful tail rather than clipping it to one row. // (Pre-#12 the panel was a fixed 1 row; ADR-0046 keeps it // multi-row but now sizes by geometry, not content.) let mut app = App::new(); app.hint = Some(LONG_HINT.to_string()); let theme = Theme::dark(); let out = render_to_string(&mut app, &theme, 80, 20); assert!( out.contains("list columns explicitly"), "the hint tail must be visible, not clipped:\n{out}" ); } #[test] fn hint_panel_height_is_fixed_by_geometry_not_content() { // ADR-0046 DA1/DA2 (#20): the panel no longer shrinks to a // short hint (the issue #12 "reclaim" behaviour is deliberately // reversed). At a compact (height < 40) terminal it is a fixed // 2 content rows whether the hint is short or long, so it never // resizes mid-typing and shoves the input/output panels. let theme = Theme::dark(); let mut short = App::new(); short.hint = Some("Type a command".to_string()); let short_out = render_to_string(&mut short, &theme, 80, 20); assert!( short_out.lines().any(|l| l.contains("Type a command")), "short hint visible:\n{short_out}" ); let mut long = App::new(); long.hint = Some(LONG_HINT.to_string()); let long_out = render_to_string(&mut long, &theme, 80, 20); assert_eq!( hint_content_rows(&short_out), 2, "compact terminal fixes the hint at 2 rows:\n{short_out}" ); assert_eq!( hint_content_rows(&short_out), hint_content_rows(&long_out), "the hint panel height must not differ between a short and a \ long hint at the same terminal size (#20 anti-jump):\n\ short:\n{short_out}\nlong:\n{long_out}" ); } #[test] fn narrow_comfortable_terminal_allows_a_third_hint_row() { // ADR-0046 DA2: a 3rd hint row appears only on a comfortable // (height ≥ 40) terminal whose hint column is narrow enough // (inner < 54) to wrap the longest hint past two lines; the // ellipsis backstop still caps it at MAX_HINT_ROWS. (At a // compact height the same hint is held to 2 rows.) let mut app = App::new(); app.hint = Some(LONG_HINT.to_string()); let theme = Theme::dark(); let out = render_to_string(&mut app, &theme, 44, 50); assert_eq!( hint_content_rows(&out), MAX_HINT_ROWS, "narrow + tall terminal caps the long hint at MAX_HINT_ROWS \ content rows:\n{out}" ); } #[test] fn panel_heights_are_geometry_driven() { // ADR-0046 DA1/DA2/DA4: the pure helper the renderer and these // tests share. Height picks the bucket (input 1→2, hint floor); // width gates the hint's 3rd row; a tiny terminal degrades hint // then input to protect output `Min(5)`. let at = |w: u16, h: u16| panel_heights(Rect::new(0, 0, w, h)); // Compact height → input 1, hint 2, regardless of width. assert_eq!(at(90, 25), (1, 2)); assert_eq!(at(40, 25), (1, 2)); // Comfortable height → input 2; hint 2 when wide (inner ≥ 54). assert_eq!(at(90, 45), (2, 2)); assert_eq!(at(56, 45), (2, 2)); // inner == 54 is "wide enough" // Comfortable + narrow (inner < 54) → hint 3. assert_eq!(at(55, 45), (2, 3)); // inner == 53 assert_eq!(at(50, 45), (2, 3)); // Very short terminal degrades hint first, then input, to keep // the output panel's Min(5). assert_eq!(at(90, 11), (1, 1)); } // ---- ADR-0046 DA3: input horizontal scroll ------------------- #[test] fn input_scroll_offset_keeps_the_cursor_in_view() { // Fits (line shorter than the viewport) → never scrolls. assert_eq!(input_scroll_offset(10, 10, 20, 0), 0); assert_eq!(input_scroll_offset(19, 19, 20, 5), 0); // Overflow, cursor at end → window shows the tail, reserving the // two marker columns (eff = tw - 2 = 18): 50 + 1 - 18 = 33. assert_eq!(input_scroll_offset(50, 50, 20, 0), 33); // Cursor jumped left of the window → scroll left to the cursor. assert_eq!(input_scroll_offset(50, 5, 20, 33), 5); // Cursor still inside the current window → stable, no change. assert_eq!(input_scroll_offset(50, 40, 20, 33), 33); // Never scroll past the cursor-at-end cell, even from a stale // over-large offset. assert_eq!(input_scroll_offset(50, 50, 20, 999), 33); } const LONG_INPUT: &str = "select * from Customers where id = 12345 and name = 'Alice Wonderland'"; #[test] fn long_input_scrolls_to_keep_the_tail_and_cursor_visible() { // #23: a command longer than the input field must not clip the // cursor off the right edge — it scrolls so the tail is visible, // with a `<` marker for the hidden head. let mut app = App::new(); app.input.push_str(LONG_INPUT); app.input_cursor = app.input.len(); let theme = Theme::dark(); // Narrow (sidebar hidden, DB1) so the line overflows the field. let out = render_to_string(&mut app, &theme, 60, 24); assert!( out.contains("'Alice Wonderland'"), "the tail around the cursor must be visible:\n{out}" ); assert!( !out.lines().any(|l| l.contains("select * from Customers where")), "the head must be scrolled off:\n{out}" ); assert!(out.contains('<'), "a left scroll marker signals the hidden head:\n{out}"); } #[test] fn input_at_home_shows_the_head_with_a_right_marker() { // With the cursor at Home, the head is visible and a `>` marker // signals the hidden tail. let mut app = App::new(); app.input.push_str(LONG_INPUT); app.input_cursor = 0; let theme = Theme::dark(); // Narrow (sidebar hidden, DB1) so the line overflows the field. let out = render_to_string(&mut app, &theme, 60, 24); assert!(out.contains("select * from"), "head visible at Home:\n{out}"); assert!(out.contains('>'), "a right scroll marker signals the hidden tail:\n{out}"); assert!(!out.contains("Wonderland"), "the tail must be scrolled off:\n{out}"); } // ---- ADR-0046 DA4: two-row input on tall terminals ----------- #[test] fn comfortable_terminal_wraps_input_across_two_rows() { // On a tall (height ≥ 40) terminal the input shows two rows, so // a medium command wraps instead of scrolling — the whole // command is visible at once, head above tail. let mut app = App::new(); app.input.push_str(LONG_INPUT); app.input_cursor = app.input.len(); let theme = Theme::dark(); // Narrow (sidebar hidden, DB1) so the line wraps across the two // rows rather than fitting on the first. let out = render_to_string(&mut app, &theme, 60, 44); let head = out .lines() .position(|l| l.contains("select * from Customers")); let tail = out.lines().position(|l| l.contains("'Alice Wonderland'")); assert!( head.is_some() && tail.is_some(), "both head and tail are visible across two rows:\n{out}" ); assert!( tail.unwrap() > head.unwrap(), "the tail wraps onto a row below the head:\n{out}" ); } #[test] fn two_row_input_scrolls_when_it_overflows_both_rows() { // A narrow-but-tall terminal: two rows, but the line is longer // than both can hold, so it scrolls to keep the tail/cursor // visible with a `<` marker for the hidden head. let mut app = App::new(); app.input.push_str(LONG_INPUT); app.input_cursor = app.input.len(); let theme = Theme::dark(); // Very narrow + tall: two rows, but the line exceeds both. let out = render_to_string(&mut app, &theme, 38, 44); assert!(out.contains("Wonderland"), "the tail/cursor stays visible:\n{out}"); assert!(out.contains('<'), "a left marker signals the hidden head:\n{out}"); } #[test] fn two_row_input_keeps_the_indicator_on_the_first_row() { // ADR-0046 DA4 / ADR-0027: the [ERR]/[WRN] indicator stays // anchored to the *first* input row (whose 6-column reserve it // occupies); the wrapped tail on the second row is untouched. let mut app = App::new(); app.input.push_str(LONG_INPUT); app.input_cursor = app.input.len(); app.input_indicator = Some(crate::dsl::walker::Severity::Error); let theme = Theme::dark(); // Narrow (sidebar hidden, DB1) so the line wraps across two rows. let out = render_to_string(&mut app, &theme, 60, 44); let err_line = out .lines() .position(|l| l.contains("[ERR]")) .expect("indicator visible"); let head_line = out .lines() .position(|l| l.contains("select * from Customers")) .expect("head visible"); assert_eq!( err_line, head_line, "the indicator shares the first input row with the head:\n{out}" ); assert!( out.contains("'Alice Wonderland'"), "the wrapped tail on the second row is intact:\n{out}" ); } #[test] fn two_row_input_snapshot() { // Locks the DA4 two-row layout: head on the first (indicator- // reserved) row, tail on the full-width second row. let mut app = App::new(); app.input.push_str(LONG_INPUT); app.input_cursor = app.input.len(); let theme = Theme::dark(); // Narrow (sidebar hidden, DB1) so the command wraps across rows. let snapshot = render_to_string(&mut app, &theme, 60, 44); insta::assert_snapshot!("two_row_input_dark", snapshot); } /// Count the content rows inside the Hint panel of a rendered /// screen: the rows between the `╭ Hint …` title border and the /// next `╰…╯` bottom border. fn hint_content_rows(out: &str) -> usize { let lines: Vec<&str> = out.lines().collect(); let top = lines .iter() .position(|l| l.contains("Hint") && l.contains('╭')) .expect("hint title border present"); // Rows strictly between the title border and the next // bottom border == the content-row count. lines[top + 1..] .iter() .position(|l| l.contains('╰')) .expect("hint bottom border present") } #[test] fn clamp_wrapped_truncates_with_ellipsis_past_max() { // ≤ max rows: untouched. let two = clamp_wrapped("alpha beta gamma delta", 11, 3); assert_eq!(two, vec!["alpha beta", "gamma delta"]); // > max rows: clamp to max, last row ends with an ellipsis, // and every row stays within the width. let many = clamp_wrapped( "alpha beta gamma delta epsilon zeta eta theta iota", 11, 3, ); assert_eq!(many.len(), 3); assert!(many[2].ends_with('…'), "last row ellipsized: {many:?}"); for row in &many { assert!(row.chars().count() <= 11, "row within width: {row:?}"); } } #[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); } // ---- ADR-0040: echo completion marker ----------------------- #[test] fn echo_renders_running_then_marker_per_status() { use crate::app::EchoStatus; let mut app = App::new(); // Pending → `running: ` (current look). app.output .push_back(OutputLine::echo("drop table Orders", Mode::Advanced)); // Ok → ` ✓`, no `running:`. let mut ok = OutputLine::echo("create table T with pk", Mode::Simple); ok.status = Some(EchoStatus::Ok); app.output.push_back(ok); // Err → ` ✗`, no `running:`. let mut err = OutputLine::echo("insert into T values (1)", Mode::Advanced); err.status = Some(EchoStatus::Err); app.output.push_back(err); let out = render_to_string(&mut app, &Theme::dark(), 100, 20); assert!(out.contains("running: drop table Orders"), "pending keeps running::\n{out}"); assert!(out.contains("create table T with pk ✓"), "ok shows ✓:\n{out}"); assert!(out.contains("insert into T values (1) ✗"), "err shows ✗:\n{out}"); assert!( !out.contains("running: create table"), "a completed echo drops the running: prefix:\n{out}" ); } // ---- Issue #13: undo confirm dialog ------------------------- #[test] fn format_local_datetime_renders_fixed_human_form() { // Deterministic: a fixed offset (not Local) so the output // does not depend on the test machine's timezone. let dt = chrono::DateTime::parse_from_rfc3339("2026-05-24T11:05:00+02:00") .expect("valid rfc3339"); assert_eq!(format_local_datetime(dt), "24 May 2026, 11:05"); // Single-digit day: no leading zero on the day, but zero- // padded hour. let dt = chrono::DateTime::parse_from_rfc3339("2026-05-04T09:05:00+00:00") .expect("valid rfc3339"); assert_eq!(format_local_datetime(dt), "4 May 2026, 09:05"); } #[test] fn format_snapshot_timestamp_drops_machine_syntax() { // The stored UTC string is reformatted: no 'T'/'Z' machine // syntax survives, and the year is preserved. (Day/month // can shift across the date line depending on local TZ, so // we assert only the stable parts.) let out = format_snapshot_timestamp("2026-07-24T10:00:00Z"); assert!(!out.contains('T'), "no date/time 'T' separator: {out}"); assert!(!out.contains('Z'), "no UTC 'Z' suffix: {out}"); assert!(out.contains("2026"), "year preserved: {out}"); } #[test] fn format_snapshot_timestamp_falls_back_on_garbage() { assert_eq!(format_snapshot_timestamp("not a timestamp"), "not a timestamp"); } #[test] fn undo_dialog_width_grows_to_fit_and_clamps() { // Grows to the widest line + 4 (borders + padding). assert_eq!(undo_dialog_width([50usize], 120), 54); // Floors at MIN (34) for tiny content. assert_eq!(undo_dialog_width([3usize], 120), 34); // Caps at MAX (100) for absurdly long content. assert_eq!(undo_dialog_width([400usize], 120), 100); // Never exceeds the available area, and never panics when // the area is narrower than MIN. assert_eq!(undo_dialog_width([50usize], 40), 40); assert_eq!(undo_dialog_width([50usize], 10), 10); } #[test] fn undo_modal_command_does_not_wrap_on_wide_terminal() { use crate::app::{Modal, UndoConfirmModal}; let mut app = App::new(); app.modal = Some(Modal::UndoConfirm(UndoConfirmModal { command: "insert into Customers values (1, 'Oliver Sturm')".to_string(), timestamp: "2026-05-24T10:00:00Z".to_string(), is_redo: false, })); let theme = Theme::dark(); let out = render_to_string(&mut app, &theme, 120, 30); assert!( out.lines().any(|l| l.contains( "This will undo: insert into Customers values (1, 'Oliver Sturm')" )), "command must sit on one row on a wide terminal:\n{out}" ); } #[test] fn undo_modal_uses_capitalized_labels_and_formatted_time() { use crate::app::{Modal, UndoConfirmModal}; let mut app = App::new(); app.modal = Some(Modal::UndoConfirm(UndoConfirmModal { command: "delete from T where id = 1".to_string(), timestamp: "2026-05-24T10:00:00Z".to_string(), is_redo: false, })); let theme = Theme::dark(); let out = render_to_string(&mut app, &theme, 120, 30); assert!(out.contains("Snapshot taken"), "capitalized Snapshot:\n{out}"); assert!(out.contains("[Y] Yes"), "capitalized Yes:\n{out}"); assert!(out.contains("[N] No"), "capitalized No:\n{out}"); assert!( !out.contains("2026-05-24T10:00:00Z"), "raw ISO timestamp must not appear:\n{out}" ); } #[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(), unique_constraints: Vec::new(), check_constraints: Vec::new(), }; app.current_table = Some(desc); // Mirror what the App writes when a DSL command succeeds // (ADR-0040): the command's echo line resolves to a ✓ marker — // there is no separate `[ok]` summary line. let mut echo = OutputLine::echo("create table Customers", Mode::Simple); echo.status = Some(crate::app::EchoStatus::Ok); app.output.push_back(echo); app.output.push_back(OutputLine { text: " Customers".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, styled_runs: None, status: None, }); app.output.push_back(OutputLine { text: " id serial [PK]".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, styled_runs: None, status: None, }); app.output.push_back(OutputLine { text: " Name text".to_string(), kind: OutputKind::System, mode_at_submission: Mode::Simple, styled_runs: None, status: None, }); let theme = Theme::dark(); // Width > SIDEBAR_MIN_WIDTH so the sidebar (tables list) shows // alongside the output panel (DB1). let snapshot = render_to_string(&mut app, &theme, 110, 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. A UNIQUE index is // marked `[unique]` (ADR-0035 §4d). use crate::completion::IndexEntry; let mut app = App::new(); app.tables = vec!["Customers".to_string(), "Orders".to_string()]; app.schema_cache.table_indexes.insert( "Customers".to_string(), vec![ IndexEntry { name: "idx_email".to_string(), unique: false }, IndexEntry { name: "uidx_login".to_string(), unique: true }, ], ); let theme = Theme::dark(); // Width > SIDEBAR_MIN_WIDTH so the sidebar is shown (DB1). let out = render_to_string(&mut app, &theme, 110, 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}"); assert!(out.contains("uidx_login [unique]"), "unique index marked:\n{out}"); } #[test] fn sidebar_visible_is_width_gated() { // ADR-0046 DB1: shown above SIDEBAR_MIN_WIDTH, hidden at/below. assert!(!sidebar_visible(80)); assert!(!sidebar_visible(90)); // the 90-col screencast: hidden assert!(sidebar_visible(91)); assert!(sidebar_visible(120)); } #[test] fn sidebar_hidden_at_or_below_threshold_width() { // The Tables panel disappears at a narrow width (the output // panel then spans the full width) and returns when wide. let mut app = App::new(); app.tables = vec!["Customers".to_string()]; let theme = Theme::dark(); let narrow = render_to_string(&mut app, &theme, 80, 24); assert!(!narrow.contains("Tables"), "sidebar hidden at 80 wide:\n{narrow}"); let wide = render_to_string(&mut app, &theme, 110, 24); assert!(wide.contains("Tables"), "sidebar shown at 110 wide:\n{wide}"); assert!(wide.contains("Customers"), "tables listed when shown:\n{wide}"); } #[test] fn relationships_panel_height_is_content_sized_within_bounds() { // ADR-0046 DB4: empty floors at 5; grows with content; capped at // half the column; leaves the Tables panel at least 3 rows. assert_eq!(relationships_panel_height(40, 0), 5); // empty floor assert_eq!(relationships_panel_height(40, 6), 8); // 6 content + borders assert_eq!(relationships_panel_height(40, 30), 20); // capped at half assert_eq!(relationships_panel_height(7, 0), 3); // tiny: Tables keeps 3 } fn one_relationship() -> crate::persistence::RelationshipSchema { use crate::dsl::action::ReferentialAction; crate::persistence::RelationshipSchema { name: "Customers_Orders".to_string(), parent_table: "Customers".to_string(), parent_columns: vec!["id".to_string()], child_table: "Orders".to_string(), child_columns: vec!["customer_id".to_string()], on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::Cascade, } } #[test] fn relationships_panel_lists_each_relationship() { // ADR-0046 DB2: name, then endpoints broken at the arrow. let mut app = App::new(); app.tables = vec!["Customers".to_string(), "Orders".to_string()]; app.relationships = vec![one_relationship()]; let theme = Theme::dark(); let out = render_to_string(&mut app, &theme, 110, 24); assert!(out.contains("Relationships"), "panel title present:\n{out}"); assert!(out.contains("Customers_Orders"), "relationship name:\n{out}"); assert!( out.lines().any(|l| l.contains("Customers.id ->")), "parent endpoint, broken at the arrow:\n{out}" ); assert!( out.lines().any(|l| l.contains("Orders.customer_id")), "child endpoint, indented:\n{out}" ); } #[test] fn empty_relationships_panel_shows_none() { let mut app = App::new(); app.tables = vec!["Customers".to_string()]; let theme = Theme::dark(); let out = render_to_string(&mut app, &theme, 110, 24); assert!(out.contains("Relationships"), "panel title present:\n{out}"); assert!(out.contains("(none)"), "empty placeholder:\n{out}"); } #[test] fn relationships_panel_snapshot() { let mut app = App::new(); app.tables = vec!["Customers".to_string(), "Orders".to_string()]; app.relationships = vec![one_relationship()]; let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 110, 24); insta::assert_snapshot!("relationships_panel_dark", snapshot); } #[test] fn navigation_mode_reveals_and_expands_the_sidebar() { // ADR-0046 DC1/DC2: at a narrow width the sidebar is hidden, but // focusing a sidebar panel peeks it open as an expanded overlay. let mut app = App::new(); app.tables = vec!["Customers".to_string()]; app.relationships = vec![one_relationship()]; let theme = Theme::dark(); let normal = render_to_string(&mut app, &theme, 80, 24); assert!( !normal.contains("Tables"), "sidebar hidden at 80 wide when not browsing:\n{normal}" ); app.nav_focus = NavFocus::SidebarTables; let focused = render_to_string(&mut app, &theme, 80, 24); assert!(focused.contains("Tables"), "sidebar revealed in nav mode:\n{focused}"); assert!(focused.contains("Customers"), "tables in the overlay:\n{focused}"); assert!( focused.contains("Relationships"), "relationships panel in the overlay:\n{focused}" ); assert!( focused.contains("Customers_Orders"), "relationship listed in the overlay:\n{focused}" ); } #[test] fn focused_panel_gets_an_accent_border() { // ADR-0046 DC3: the focused sidebar panel is accent-bordered. let theme = Theme::dark(); let focused = panel_border_style(&theme, true); let normal = panel_border_style(&theme, false); assert_eq!(focused.fg, Some(theme.fg)); assert!(focused.add_modifier.contains(Modifier::BOLD)); assert_eq!(normal.fg, Some(theme.border)); assert!(!normal.add_modifier.contains(Modifier::BOLD)); } #[test] fn focused_tables_panel_scrolls_and_clamps() { // ADR-0046 DC3: more tables than fit → a large offset reveals the // lower entries and clamps so it can't scroll past the end. let mut app = App::new(); app.tables = (0..30).map(|i| format!("Table{i:02}")).collect(); app.nav_focus = NavFocus::SidebarTables; app.tables_scroll = 1000; // way past the end let theme = Theme::dark(); let out = render_to_string(&mut app, &theme, 80, 24); assert!( out.contains("Table29"), "the last table is visible after the offset clamps:\n{out}" ); assert!( !out.contains("Table00"), "the top tables are scrolled off:\n{out}" ); assert!( app.tables_scroll < 30, "the stored offset was clamped to the content: {}", app.tables_scroll ); } #[test] fn navigation_overlay_snapshot() { // The expanded overlay over a full-width base (sidebar hidden at // 80), with the Relationships panel focused (accent border). let mut app = App::new(); app.tables = vec!["Customers".to_string(), "Orders".to_string()]; app.relationships = vec![one_relationship()]; app.nav_focus = NavFocus::SidebarRelationships; let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 80, 24); insta::assert_snapshot!("nav_overlay_relationships_focused_dark", snapshot); } // ---- ADR-0047 (issue #22): demo-mode keystroke badge ---- /// Render to a `TestBackend` buffer (for cell-level style checks the /// text-only `render_to_string` cannot make). fn render_to_buffer( app: &mut App, theme: &Theme, width: u16, height: u16, ) -> ratatui::buffer::Buffer { 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"); terminal.backend().buffer().clone() } #[test] fn demo_badge_box_renders_at_output_bottom_right() { // At the 90×26 cast geometry the sidebar is hidden and the badge // box sits inset in the output panel's bottom-right corner. let mut app = App::new(); app.demo_mode = true; app.demo_badge = Some("[TAB]"); let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 90, 26); insta::assert_snapshot!("demo_badge_tab_dark_90x26", snapshot); } #[test] fn demo_badge_box_renders_in_light_theme() { let mut app = App::new(); app.demo_mode = true; app.demo_badge = Some("[ENTER]"); let theme = Theme::light(); let snapshot = render_to_string(&mut app, &theme, 90, 26); insta::assert_snapshot!("demo_badge_enter_light_90x26", snapshot); } #[test] fn demo_badge_box_is_black_on_yellow() { let mut app = App::new(); app.demo_mode = true; app.demo_badge = Some("[TAB]"); let theme = Theme::dark(); let buffer = render_to_buffer(&mut app, &theme, 90, 26); // Collect the badge cells (the only ones painted with the fixed // overlay background) and confirm the high-contrast pairing. let mut badge_cells = 0; let mut row_text: std::collections::BTreeMap = Default::default(); for y in 0..buffer.area.height { for x in 0..buffer.area.width { let cell = &buffer[(x, y)]; if cell.bg == crate::theme::DEMO_OVERLAY_BG { badge_cells += 1; assert_eq!( cell.fg, crate::theme::DEMO_OVERLAY_FG, "badge cell at ({x},{y}) must be black-on-yellow" ); row_text.entry(y).or_default().push_str(cell.symbol()); } } } assert!(badge_cells > 0, "expected a yellow badge box to be drawn"); // The label appears on the box's middle (text) row. assert!( row_text.values().any(|line| line.contains("[TAB]")), "badge text not found among styled rows: {row_text:?}" ); } #[test] fn demo_caption_box_renders_at_output_bottom_right() { let mut app = App::new(); app.demo_mode = true; app.demo_caption = Some("Now press Tab to complete the table name".to_string()); let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 90, 26); insta::assert_snapshot!("demo_caption_dark_90x26", snapshot); } #[test] fn demo_badge_stacks_above_caption() { let mut app = App::new(); app.demo_mode = true; app.demo_badge = Some("[TAB]"); app.demo_caption = Some("Completing the name".to_string()); let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 90, 26); insta::assert_snapshot!("demo_badge_and_caption_stacked_90x26", snapshot); } #[test] fn demo_caption_wraps_to_three_lines_and_ellipsises() { let mut app = App::new(); app.demo_mode = true; app.demo_caption = Some( "This is a deliberately long step caption that must wrap onto \ several lines and then be clipped to three with an ellipsis \ so the corner box never grows without bound." .to_string(), ); let theme = Theme::dark(); let snapshot = render_to_string(&mut app, &theme, 90, 26); insta::assert_snapshot!("demo_caption_wrapped_90x26", snapshot); } #[test] fn demo_badge_box_skipped_when_area_too_small() { // ADR-0047 D4 clamp guard: a box that cannot fit the given area // is not drawn rather than overflowing. let backend = TestBackend::new(40, 10); let mut terminal = Terminal::new(backend).expect("create terminal"); terminal .draw(|f| super::render_badge_box("[SHIFT-TAB]", Rect::new(0, 0, 5, 3), None, f)) .expect("draw frame"); let buffer = terminal.backend().buffer(); let drew_badge = (0..buffer.area.height).any(|y| { (0..buffer.area.width).any(|x| buffer[(x, y)].bg == crate::theme::DEMO_OVERLAY_BG) }); assert!(!drew_badge, "badge must be skipped when it cannot fit"); } }