Files
rdbms-playground/src/ui.rs
T
claude@clouddev1 22bec61d11 feat(ui): scroll the focused sidebar panel + refine the nav overlay (#21, ADR-0046 DC3 + DC2)
DC3 — navigation-mode scroll: the focused Tables / Relationships panel
scrolls (Up/Down by a line, PageUp/PageDown by its visible-row count).
Per-panel offsets are clamped to content at render time, and the
renderer reports each panel's visible rows for paging — mirroring the
output panel's scroll. render_items_panel / render_relationships_panel
take &mut App, count their rows, and store+clamp the offset before
building the borrowing lines.

DC2 refinement: the expand-on-focus overlay now clears only the sidebar
strip plus a one-column gutter, leaving the base output/input/hint
visible (unchanged) to the right rather than blanking the whole area —
truer to "underneath keeps its layout", with the gutter keeping the
cut-off edge clean (chosen after eyeballing both variants). ADR DC2 and
the overlay snapshot updated to match.

Tests: line/page scroll move only the focused panel and clamp; the
render clamps a past-the-end offset so the last row stays visible.
2026-06-10 21:27:13 +00:00

2970 lines
118 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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: <Display Name>" (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);
}
}
/// 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<Line<'_>> = 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<Line<'_>> = 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<String> = 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<Line<'_>> = 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<Tz>(dt: chrono::DateTime<Tz>) -> 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<Item = usize>,
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<String> = 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<Line<'_>> = 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<String> {
if width == 0 {
return vec![s.to_string()];
}
let mut lines: Vec<String> = 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<String> {
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<char> = 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<Span<'_>> = 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<Line<'_>> = 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<Line<'static>> = 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;
let lines: Vec<Line<'_>> = 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<OutputLine>,
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 `<input> ✓/✗` —
// 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: <input>` while pending
// (and for untracked parse/pre-flight rejections), and
// `<input> ✓` / `<input> ✗` 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 `<ECHO_PREFIX><input>`; 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<Span<'a>> = 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<Span<'a>> = 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<Span<'a>> = 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::<Vec<_>>(),
)
};
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<Span<'a>> {
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<Line<'static>> {
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::<Vec<Line<'static>>>()
};
// 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<Line<'static>>,
) {
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<usize>,
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::<usize>()
.saturating_sub(1);
if total_width <= width {
let mut spans: Vec<Span<'static>> = 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<Span<'static>> = 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<Span<'_>> = Vec::new();
let push_shortcut = |spans: &mut Vec<Span<'_>>, 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: <input>` (current look).
app.output
.push_back(OutputLine::echo("drop table Orders", Mode::Advanced));
// Ok → `<input> ✓`, 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 → `<input> ✗`, 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);
}
}