Files
rdbms-playground/src/ui.rs
T
claude@clouddev1 d0c8f9d5d2 feat: copy the output panel to the system clipboard (#11)
New app-level `copy` / `copy all` / `copy last` command (ADR-0041).
Delivery is OSC 52 *and* a best-effort native write (arboard), always
both — OSC 52 acceptance is undetectable, so a true fallback can't be
built. Payload is the panel's plain text exactly as rendered (tags,
✓/✗, box-drawing), drift-locked to render_output_line. arboard added
--no-default-features (X11-only; OSC 52 covers Wayland).

Amends ADR-0003's command registry; requirements V6.
2026-06-02 14:23:21 +00:00

2111 lines
83 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, OutputKind, OutputLine, OutputStyleClass};
use crate::mode::Mode;
use crate::theme::Theme;
/// Render the entire application frame.
///
/// Takes `&mut App` because the renderer reports the current
/// output-panel row count back to the App for scroll-cap
/// computation — without that feedback, scrolling past the top
/// of the buffer would slide the visible window off and
/// "eat" lines from the bottom on subsequent renders.
pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
let area = frame.area();
paint_background(theme, frame, area);
// Reserve two rows at the bottom for status:
// - top row: "Project: <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);
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(28), Constraint::Min(20)])
.split(outer[0]);
render_items_panel(app, theme, frame, columns[0]);
render_right_column(app, theme, frame, columns[1]);
render_project_label(app, theme, frame, outer[1]);
render_status_bar(app, theme, frame, outer[2]);
// Modal dialogs (rebuild confirm, save-as prompt, load
// picker, …) are drawn last so they overlay the rest of
// the frame.
if let Some(modal) = app.modal.as_ref() {
render_modal(modal, theme, frame, area);
}
}
fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
use crate::app::Modal;
match modal {
Modal::RebuildConfirm(m) => render_rebuild_confirm(&m.summary, theme, frame, area),
Modal::PathEntry(m) => render_path_entry(m, theme, frame, area),
Modal::LoadPicker(m) => render_load_picker(m, theme, frame, area),
Modal::UndoConfirm(m) => render_undo_confirm(m, theme, frame, area),
}
}
fn render_path_entry(
m: &crate::app::PathEntryModal,
theme: &Theme,
frame: &mut Frame<'_>,
area: Rect,
) {
let dialog_w = area.width.clamp(20, 70);
let inner_w = dialog_w.saturating_sub(4) as usize;
let prompt_lines = wrap_lines(&m.prompt, inner_w);
// Title + blank + prompt + blank + input box (1 row + borders) + blank + key hints.
let dialog_h = (prompt_lines.len() as u16).saturating_add(8).min(area.height);
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
let dialog_area = Rect {
x,
y,
width: dialog_w,
height: dialog_h,
};
frame.render_widget(ratatui::widgets::Clear, dialog_area);
let title_style = Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.fg))
.title(Line::from(vec![Span::styled(
format!(" {} ", m.title),
title_style,
)]))
.style(Style::default().bg(theme.bg).fg(theme.fg));
let mut text_lines: Vec<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
}
/// Maximum content rows the Hint panel may grow to before its last
/// visible row is ellipsis-truncated (issue #12). The panel starts
/// at one row and grows only as far as a wrapped hint needs, up to
/// this cap, reclaiming the space when the hint is short.
const MAX_HINT_ROWS: usize = 3;
/// 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) {
// Resolve the hint first so the layout can size the Hint panel to
// the wrapped hint (issue #12): one content row by default,
// growing up to MAX_HINT_ROWS, reclaiming the space when short.
// The hint panel spans the full column width, so `area.width` is
// its width too.
let hint_lines = resolve_hint_lines(app, theme, area.width);
let hint_content =
(hint_lines.len().clamp(1, MAX_HINT_ROWS) as u16).saturating_add(2);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(5), // Output panel
Constraint::Length(3), // Input panel
Constraint::Length(hint_content), // Hint panel (dynamic)
])
.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: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
format!(" {} ", crate::t!("panel.tables_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
if app.tables.is_empty() {
let placeholder = Paragraph::new(Line::from(Span::styled(
crate::t!("panel.tables_empty"),
Style::default()
.fg(theme.muted)
.add_modifier(Modifier::ITALIC),
)))
.block(block);
frame.render_widget(placeholder, area);
return;
}
let highlight = app
.current_table
.as_ref()
.map(|t| t.name.as_str())
.unwrap_or_default();
// Nested tables / per-table indexes (S2, ADR-0025): each
// table line, with its index names indented beneath it.
let mut lines: Vec<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);
frame.render_widget(paragraph, area);
}
fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
format!(" {} ", crate::t!("panel.output_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
let inner = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
// Render every output line into a wrapped Paragraph and let
// ratatui handle the wrapping; we then use the wrapped row
// count to cap scroll correctly. Bottom-anchoring (most
// recent visible by default) is achieved by computing the
// scroll offset relative to the bottom of the wrapped view.
let visible = inner.height as usize;
// Compute the total wrapped row count first, working from
// OutputLines directly (so the borrow ends before the
// mutable `note_output_viewport` call below).
let total_wrapped = approximate_wrapped_rows_from_output(&app.output, inner.width);
app.note_output_viewport(visible, total_wrapped);
let lines: Vec<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),
}
}
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),
])
}
fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let effective = app.effective_mode();
let (border_color, mode_color, label) = match effective {
EffectiveMode::Simple => (
theme.border,
theme.mode_simple,
crate::t!("mode.label_simple"),
),
EffectiveMode::AdvancedPersistent => (
theme.border_advanced,
theme.mode_advanced,
crate::t!("mode.label_advanced"),
),
// Mixed-case label distinguishes the one-shot (`:`-triggered)
// state from a persistent advanced mode at a glance.
EffectiveMode::AdvancedOneShot => (
theme.border_advanced,
theme.mode_advanced,
crate::t!("mode.label_advanced_one_shot"),
),
};
let title = Line::from(vec![
Span::raw(" "),
Span::styled(
label,
Style::default()
.fg(mode_color)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.title(title)
.style(Style::default().bg(theme.bg).fg(theme.fg));
// Cursor block: render the character at the cursor position
// inverted so the cursor is visible without enabling a real
// terminal cursor.
//
// Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both
// modes — `render_input_runs_in_mode` runs the highlight
// walker with the active mode so SQL keywords / operators /
// CASE / function calls colour correctly in Advanced mode.
let cursor = app.input_cursor.min(app.input.len());
let mode_for_render = match effective {
EffectiveMode::Simple => crate::mode::Mode::Simple,
EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => {
crate::mode::Mode::Advanced
}
};
let runs = crate::input_render::render_input_runs_in_mode(
&app.input,
cursor,
theme,
&app.schema_cache,
mode_for_render,
);
let spans = runs_to_spans(&app.input, &runs);
// ADR-0027 §4: the rightmost six columns of the input row
// (a five-column label plus a one-column gap) are reserved
// unconditionally, so the text area is always
// `inner.width - 6` and the typed command never shifts
// sideways when the validity indicator appears or hides.
let inner = block.inner(area);
frame.render_widget(block, area);
let text_area = Rect {
width: inner.width.saturating_sub(6),
..inner
};
frame.render_widget(Paragraph::new(Line::from(spans)), text_area);
if let Some(severity) = app.input_indicator {
let (indicator_label, color) = match severity {
crate::dsl::walker::Severity::Error => ("[ERR]", theme.error),
crate::dsl::walker::Severity::Warning => ("[WRN]", theme.warning),
};
let label_area = Rect {
x: inner.x + inner.width.saturating_sub(5),
width: 5.min(inner.width),
..inner
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
indicator_label,
Style::default().fg(color).add_modifier(Modifier::BOLD),
))),
label_area,
);
}
}
/// Convert `StyledRun`s into ratatui `Span`s borrowed from
/// `input`. The end-of-input cursor sentinel (empty range) is
/// rendered as an inverted space.
fn runs_to_spans<'a>(
input: &'a str,
runs: &[crate::input_render::StyledRun],
) -> Vec<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_HINT_ROWS` with an
/// ellipsis backstop (issue #12). The returned line count is the
/// content-row count `render_right_column` allocates for, so the
/// panel grows for a long hint and reclaims the space for a short
/// one.
///
/// 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) -> 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_HINT_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() {
// Before the fix the Hint panel was a fixed 1 content row,
// so this hint's useful tail was clipped. Now the panel
// grows (to MAX_HINT_ROWS) so the tail is visible.
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 short_hint_keeps_panel_at_one_content_row() {
// Reclaim: a short hint must not inflate the panel.
let mut app = App::new();
app.hint = Some("Type a command".to_string());
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 80, 20);
assert!(
out.lines().any(|l| l.contains("Type a command")),
"short hint visible:\n{out}"
);
assert_eq!(
hint_content_rows(&out),
1,
"short hint should occupy exactly one content row:\n{out}"
);
}
#[test]
fn long_hint_grows_panel_but_caps_at_max_rows() {
let mut app = App::new();
app.hint = Some(LONG_HINT.to_string());
let theme = Theme::dark();
// Narrow width forces more wrapped lines than the cap.
let out = render_to_string(&mut app, &theme, 44, 20);
assert_eq!(
hint_content_rows(&out),
MAX_HINT_ROWS,
"long hint caps at MAX_HINT_ROWS content rows:\n{out}"
);
}
/// 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();
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("populated_with_table_dark", snapshot);
}
#[test]
fn items_panel_nests_indexes_under_their_table() {
// S2 (ADR-0025): the items panel renders each table
// with its index names indented beneath it. 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();
let out = render_to_string(&mut app, &theme, 80, 24);
assert!(out.contains("Customers"), "table listed:\n{out}");
assert!(out.contains("Orders"), "table listed:\n{out}");
assert!(out.contains("idx_email"), "index nested in panel:\n{out}");
assert!(out.contains("uidx_login [unique]"), "unique index marked:\n{out}");
}
}