Files
rdbms-playground/src/ui.rs
T
claude@clouddev1 25800e3eb5 feat: ADR-0006 §8 steps 4-5 — undo/redo commands + confirm-modal flow
Commands & grammar (step 4):
- AppCommand::Undo/Redo, grammar nodes + REGISTRY entries, catalog
  help/usage + keys; parse tests
- replay skips undo/redo (is_app_lifecycle_entry_word) + completion
  entry-keyword lockstep; replay-skip test extended

Wiring (step 5):
- Action::{PrepareUndo,PrepareRedo,Undo,Redo} + AppEvent::{UndoPrepared,
  UndoUnavailable,UndoSucceeded,UndoFailed}
- App: undo_enabled flag, Modal::UndoConfirm, dispatch + event handling
  + confirm-key handler (Y confirms / N/Esc cancels); "turned off" when
  --no-undo; "nothing to undo/redo" when empty
- ui::render_undo_confirm names the command + snapshot time
- runtime: opens with undo enabled (!--no-undo), threads it through the
  project-switch path, spawn_prepare_undo/spawn_undo (peek->modal,
  restore->refresh tables + schema cache)
- 9 Tier-1 app tests + 3 parse tests

1692 passed / 0 failed / 1 ignored; clippy clean.
2026-05-24 20:48:30 +00:00

1297 lines
49 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, 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);
}
/// `undo` / `redo` confirmation modal (ADR-0006 Amendment 1). Names
/// the command that will be undone / re-applied and when its
/// snapshot was taken, then prompts `Y` / `N`.
fn render_undo_confirm(
m: &crate::app::UndoConfirmModal,
theme: &Theme,
frame: &mut Frame<'_>,
area: Rect,
) {
let dialog_w = area.width.clamp(20, 60);
let inner_w = dialog_w.saturating_sub(4) as usize;
let intro = if m.is_redo {
crate::t!("modal.redo_confirm_command")
} else {
crate::t!("modal.undo_confirm_command")
};
let mut body_lines: Vec<String> = wrap_lines(&format!("{intro} {}", m.command), inner_w);
body_lines.extend(wrap_lines(
&crate::t!("modal.undo_confirm_when", timestamp = m.timestamp),
inner_w,
));
let body_height = body_lines.len() as u16;
// Title row + blank + body + blank + prompt + blank + keys + borders (2).
let dialog_h = body_height.saturating_add(7).min(area.height);
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
let dialog_area = Rect {
x,
y,
width: dialog_w,
height: dialog_h,
};
frame.render_widget(ratatui::widgets::Clear, dialog_area);
let title = if m.is_redo {
crate::t!("modal.redo_confirm_title")
} else {
crate::t!("modal.undo_confirm_title")
};
let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.fg))
.title(Line::from(vec![Span::styled(
format!(" {title} "),
title_style,
)]))
.style(Style::default().bg(theme.bg).fg(theme.fg));
let mut text_lines: Vec<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.undo_confirm_prompt")));
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled("[Y]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
Span::raw(format!(" {} ", crate::t!("shortcut.yes"))),
Span::styled("[N]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
Span::raw(format!(" {} ", crate::t!("shortcut.no"))),
Span::styled("Esc", Style::default().fg(theme.muted)),
Span::styled(
format!(" {}", crate::t!("shortcut.cancel")),
Style::default().fg(theme.muted),
),
]));
let paragraph = Paragraph::new(text_lines)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, dialog_area);
}
/// Greedy word-wrap to `width` columns. Sufficient for the
/// short prose modals carry; we don't try to be Unicode-aware
/// (display-width-wise) since the strings we generate are
/// ASCII-friendly.
fn wrap_lines(s: &str, width: usize) -> Vec<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
}
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) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(5), // Output panel
Constraint::Length(3), // Input panel
Constraint::Length(3), // Hint panel
])
.split(area);
render_output_panel(app, theme, frame, rows[0]);
render_input_panel(app, theme, frame, rows[1]);
render_hint_panel(app, theme, frame, rows[2]);
}
fn paint_background(theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default().style(Style::default().bg(theme.bg).fg(theme.fg));
frame.render_widget(block, area);
}
fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
format!(" {} ", crate::t!("panel.tables_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
if app.tables.is_empty() {
let placeholder = Paragraph::new(Line::from(Span::styled(
crate::t!("panel.tables_empty"),
Style::default()
.fg(theme.muted)
.add_modifier(Modifier::ITALIC),
)))
.block(block);
frame.render_widget(placeholder, area);
return;
}
let highlight = app
.current_table
.as_ref()
.map(|t| t.name.as_str())
.unwrap_or_default();
// Nested tables / per-table indexes (S2, ADR-0025): each
// table line, with its index names indented beneath it.
let mut lines: Vec<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 {
lines.push(Line::from(Span::styled(
format!(" {index}"),
Style::default().fg(theme.muted),
)));
}
}
}
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
}
fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
format!(" {} ", crate::t!("panel.output_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
let inner = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
// Render every output line into a wrapped Paragraph and let
// ratatui handle the wrapping; we then use the wrapped row
// count to cap scroll correctly. Bottom-anchoring (most
// recent visible by default) is achieved by computing the
// scroll offset relative to the bottom of the wrapped view.
let visible = inner.height as usize;
// Compute the total wrapped row count first, working from
// OutputLines directly (so the borrow ends before the
// mutable `note_output_viewport` call below).
let total_wrapped = approximate_wrapped_rows_from_output(&app.output, inner.width);
app.note_output_viewport(visible, total_wrapped);
let lines: Vec<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 => "[system] ".len(),
OutputKind::Error => "[error] ".len(),
};
let total = tag_len + line.text.chars().count();
if total == 0 { 1 } else { total.div_ceil(w) }
})
.sum()
}
/// Resolve a semantic [`OutputStyleClass`] to a concrete style
/// against the active theme (ADR-0028 §5/§6).
const fn output_span_style(class: OutputStyleClass, theme: &Theme) -> Style {
match class {
OutputStyleClass::Neutral => Style::new().fg(theme.fg),
OutputStyleClass::Efficient => Style::new().fg(theme.plan_efficient),
OutputStyleClass::Expensive => Style::new().fg(theme.warning),
OutputStyleClass::AutomaticIndex => Style::new()
.fg(theme.warning)
.add_modifier(Modifier::BOLD),
}
}
fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> {
let tag_style = match line.mode_at_submission {
Mode::Simple => Style::default().fg(theme.mode_simple),
Mode::Advanced => Style::default().fg(theme.mode_advanced),
};
let tag = match line.kind {
OutputKind::Echo => format!("[{}] ", line.mode_at_submission.label().to_lowercase()),
OutputKind::System => "[system] ".to_string(),
OutputKind::Error => "[error] ".to_string(),
};
// Simple-mode echo lines get token-class highlighting on
// their input portion (ADR-0022 §5). Echo body shape is
// contracted to `<ECHO_PREFIX><input>`; the prefix is
// pinned to the catalog template by
// `dsl::tests::echo_prefix_matches_catalog_template`.
if line.kind == OutputKind::Echo
&& line.mode_at_submission == Mode::Simple
&& let Some(rest) = line.text.strip_prefix(crate::dsl::ECHO_PREFIX)
{
let mut spans: Vec<Span<'a>> = Vec::with_capacity(2 + rest.len() / 4);
spans.push(Span::styled(tag, tag_style));
spans.push(Span::styled(
crate::dsl::ECHO_PREFIX,
Style::default().fg(theme.fg),
));
for run in crate::input_render::lex_to_runs(rest, theme) {
spans.push(Span::styled(
&rest[run.byte_range.0..run.byte_range.1],
run.style,
));
}
return Line::from(spans);
}
// Echo body without the expected prefix, or any non-echo
// line, falls through to the plain rendering below.
// ADR-0028 §5: a line carrying a styled-runs payload is
// rendered span-by-span, each run's semantic class resolved
// to a colour from the active theme. The tag keeps its
// kind styling. (Echo lines never carry runs, so this never
// collides with the branch above.)
if let Some(runs) = &line.styled_runs {
let mut spans: Vec<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);
}
let body_style = match line.kind {
OutputKind::Echo => Style::default().fg(theme.fg),
OutputKind::System => Style::default().fg(theme.system),
OutputKind::Error => Style::default().fg(theme.error),
};
Line::from(vec![
Span::styled(tag, tag_style),
Span::styled(line.text.as_str(), body_style),
])
}
fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let effective = app.effective_mode();
let (border_color, mode_color, label) = match effective {
EffectiveMode::Simple => (
theme.border,
theme.mode_simple,
crate::t!("mode.label_simple"),
),
EffectiveMode::AdvancedPersistent => (
theme.border_advanced,
theme.mode_advanced,
crate::t!("mode.label_advanced"),
),
// Mixed-case label distinguishes the one-shot (`:`-triggered)
// state from a persistent advanced mode at a glance.
EffectiveMode::AdvancedOneShot => (
theme.border_advanced,
theme.mode_advanced,
crate::t!("mode.label_advanced_one_shot"),
),
};
let title = Line::from(vec![
Span::raw(" "),
Span::styled(
label,
Style::default()
.fg(mode_color)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.title(title)
.style(Style::default().bg(theme.bg).fg(theme.fg));
// Cursor block: render the character at the cursor position
// inverted so the cursor is visible without enabling a real
// terminal cursor.
//
// Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both
// modes — `render_input_runs_in_mode` runs the highlight
// walker with the active mode so SQL keywords / operators /
// CASE / function calls colour correctly in Advanced mode.
let cursor = app.input_cursor.min(app.input.len());
let mode_for_render = match effective {
EffectiveMode::Simple => crate::mode::Mode::Simple,
EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => {
crate::mode::Mode::Advanced
}
};
let runs = crate::input_render::render_input_runs_in_mode(
&app.input,
cursor,
theme,
&app.schema_cache,
mode_for_render,
);
let spans = runs_to_spans(&app.input, &runs);
// ADR-0027 §4: the rightmost six columns of the input row
// (a five-column label plus a one-column gap) are reserved
// unconditionally, so the text area is always
// `inner.width - 6` and the typed command never shifts
// sideways when the validity indicator appears or hides.
let inner = block.inner(area);
frame.render_widget(block, area);
let text_area = Rect {
width: inner.width.saturating_sub(6),
..inner
};
frame.render_widget(Paragraph::new(Line::from(spans)), text_area);
if let Some(severity) = app.input_indicator {
let (indicator_label, color) = match severity {
crate::dsl::walker::Severity::Error => ("[ERR]", theme.error),
crate::dsl::walker::Severity::Warning => ("[WRN]", theme.warning),
};
let label_area = Rect {
x: inner.x + inner.width.saturating_sub(5),
width: 5.min(inner.width),
..inner
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
indicator_label,
Style::default().fg(color).add_modifier(Modifier::BOLD),
))),
label_area,
);
}
}
/// Convert `StyledRun`s into ratatui `Span`s borrowed from
/// `input`. The end-of-input cursor sentinel (empty range) is
/// rendered as an inverted space.
fn runs_to_spans<'a>(
input: &'a str,
runs: &[crate::input_render::StyledRun],
) -> Vec<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)
}
fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
format!(" {} ", crate::t!("panel.hint_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(theme.bg).fg(theme.fg));
// Resolution order for the hint panel body:
// 1. An explicit app-set hint (e.g. modal contexts) wins.
// 2. Otherwise, with non-empty input, the ambient
// typing-assistance hint (ADR-0022 §6) computed in the
// effective mode.
// 3. Otherwise, the existing empty-state placeholder.
// ADR-0022 Amendment 1: advanced mode no longer skips ambient
// hinting. The original §12 carve-out predated the unified
// mode-aware walker (ADR-0030/0031/0032); the walker now
// speaks SQL, so `ambient_hint_in_mode` surfaces SQL slot
// hints + completion candidates in advanced mode too.
let empty_hint = crate::t!("panel.hint_empty");
// In one-shot advanced mode (`:` prefix in simple mode) the
// raw input carries the `:` sigil, which is not part of the
// grammar. Strip it for the ambient computation so the hint
// reflects the advanced command — mirroring `App::submit`.
let (hint_input, hint_cursor) = match app.effective_mode() {
EffectiveMode::AdvancedOneShot => {
strip_one_shot_prefix(&app.input, app.input_cursor)
}
_ => (app.input.as_str(), app.input_cursor),
};
let ambient = crate::input_render::ambient_hint_in_mode(
hint_input,
hint_cursor,
app.last_completion.as_ref(),
&app.schema_cache,
app.effective_mode().as_mode(),
);
let muted = Style::default().fg(theme.muted);
let line = match (app.hint.as_deref(), ambient) {
(Some(set), _) => Line::from(Span::styled(set.to_string(), muted)),
(None, Some(crate::input_render::AmbientHint::Prose(text))) => {
Line::from(Span::styled(text, muted))
}
(None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => {
// ADR-0022 §7 + user's #2: render items with the
// selected one highlighted; if not all fit,
// scroll horizontally with `<` / `>` markers at
// the edges. Inner width = panel area minus
// borders (2).
let inner = area.width.saturating_sub(2) as usize;
render_candidate_line(&items, selected, inner, theme)
}
(None, None) => Line::from(Span::styled(empty_hint, muted)),
};
let paragraph = Paragraph::new(line)
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
/// Render the candidate-list line for the hint panel
/// (ADR-0022 §7 + the user's #2). Items are space-separated.
/// Each candidate is colour-coded by kind — keywords in
/// `tok_keyword`, identifiers in `tok_identifier` — so the
/// user can tell command grammar apart from schema names at
/// a glance (post-stage-8 user feedback). The selected item
/// (if any) gets bolded; when the items overflow `width`,
/// scroll markers `<` / `>` appear at the edges with the
/// window centred on the selection (or item 0 with no
/// selection).
///
/// Returns `Line<'static>` (each item cloned into its span)
/// so the caller doesn't have to manage the items' lifetime.
fn render_candidate_line(
items: &[crate::completion::Candidate],
selected: Option<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);
let style_for = |i: usize| {
let base_fg = match items[i].kind {
crate::completion::CandidateKind::Keyword => theme.tok_keyword,
crate::completion::CandidateKind::Identifier => theme.tok_identifier,
crate::completion::CandidateKind::Flag => theme.tok_flag,
crate::completion::CandidateKind::Punct => theme.tok_punct,
};
let mut s = Style::default().fg(base_fg);
if Some(i) == selected {
s = s.add_modifier(Modifier::BOLD);
}
s
};
let total_width: usize = items
.iter()
.map(|c| c.text.len() + 1)
.sum::<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 a_line_without_styled_runs_keeps_whole_line_kind_styling() {
let theme = Theme::dark();
let line = OutputLine {
text: "plain system line".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
};
let rendered = render_output_line(&line, &theme);
// tag span + single whole-line body span.
assert_eq!(rendered.spans.len(), 2);
assert_eq!(rendered.spans[1].content.as_ref(), "plain system line");
assert_eq!(rendered.spans[1].style.fg, Some(theme.system));
}
fn render_to_string(app: &mut App, theme: &Theme, width: u16, height: u16) -> String {
// Snapshot tests need realistic state, not the boot
// fallback "(no project)" — every real session has a
// project. Set a representative name unless the test
// already set one.
if app.project_name.is_none() {
app.project_name = Some("Term Planner".to_string());
}
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("create terminal");
terminal
.draw(|f| render(app, theme, f))
.expect("draw frame");
let buffer = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
out.push_str(buffer[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn dark_theme_default_view_snapshot() {
let mut app = App::new();
let theme = Theme::dark();
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("default_simple_dark", snapshot);
}
#[test]
fn light_theme_default_view_snapshot() {
let mut app = App::new();
let theme = Theme::light();
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("default_simple_light", snapshot);
}
#[test]
fn advanced_mode_default_view_snapshot() {
let mut app = App::new();
app.mode = Mode::Advanced;
let theme = Theme::dark();
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("default_advanced_dark", snapshot);
}
#[test]
fn advanced_mode_hint_panel_surfaces_sql_candidates() {
// Regression reproduction (ADR-0022 Amendment 1): in
// advanced mode the hint panel must surface ambient
// assistance for SQL — here the FROM-slot table candidate
// `Customers` — not the empty placeholder. Before the fix
// `render_hint_panel` returned `None` for advanced mode and
// the hint resolver/completion ran in simple mode, so a SQL
// statement got the "this is SQL" gate and no candidates.
let mut app = App::new();
app.mode = Mode::Advanced;
app.schema_cache.tables.push("Customers".to_string());
app.input.push_str("select * from ");
app.input_cursor = app.input.len();
let theme = Theme::dark();
let rendered = render_to_string(&mut app, &theme, 80, 24);
assert!(
rendered.contains("Customers"),
"advanced-mode hint panel should surface the FROM-slot \
candidate `Customers`; got:\n{rendered}",
);
}
#[test]
fn highlighted_input_all_token_classes_snapshot() {
// ADR-0022 stage 2: representative input that exercises
// every token class — keyword (insert / into / values
// / null), identifier (T), number (1), string ('hi'),
// punct (parens, comma), flag (--all-rows), lex error
// ($ at the end). The snapshot captures the rendered
// text symbols only — `render_to_string` does not record
// ratatui style — so this test is a regression net for
// text layout, not for colour mappings. Colour mappings
// are unit-tested in `input_render::tests`.
let mut app = App::new();
app.input
.push_str("insert into T values (1, 'hi', null) --all-rows $");
app.input_cursor = app.input.len();
let theme = Theme::dark();
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("highlighted_input_all_token_classes_dark", snapshot);
}
#[test]
fn one_shot_advanced_prompt_snapshot() {
// Typing `:sel` in simple mode should flip the input panel
// label to `Advanced:` while the persistent mode stays simple.
// The visible input includes the auto-inserted space after `:`.
// With the cursor after `sel` (ADR-0022 Amendment 1), the hint
// panel now offers the advanced `select` completion — the `:`
// sigil is stripped before the ambient walk.
let mut app = App::new();
app.input.push_str(": sel");
app.input_cursor = app.input.len();
let theme = Theme::dark();
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("one_shot_advanced_dark", snapshot);
}
#[test]
fn rebuild_confirm_modal_snapshot() {
use crate::app::{Modal, RebuildConfirmModal};
let mut app = App::new();
app.modal = Some(Modal::RebuildConfirm(RebuildConfirmModal {
summary: "3 tables and 47 rows will be reconstructed; \
the existing playground.db will be replaced"
.to_string(),
}));
let theme = Theme::dark();
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("rebuild_confirm_modal_dark", snapshot);
}
#[test]
fn populated_with_table_snapshot() {
// Items panel lists tables; output panel shows the
// structure of the current table.
use crate::app::{OutputKind, OutputLine};
use crate::db::{ColumnDescription, TableDescription};
let mut app = App::new();
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
use crate::dsl::Type;
let desc = TableDescription {
name: "Customers".to_string(),
columns: vec![
ColumnDescription {
name: "id".to_string(),
user_type: Some(Type::Serial),
sqlite_type: "INTEGER".to_string(),
notnull: false,
primary_key: true,
unique: false,
default: None,
check: None,
},
ColumnDescription {
name: "Name".to_string(),
user_type: Some(Type::Text),
sqlite_type: "TEXT".to_string(),
notnull: false,
primary_key: false,
unique: false,
default: None,
check: None,
},
],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
};
app.current_table = Some(desc);
// Mirror what the App writes when a DSL command succeeds.
app.output.push_back(OutputLine {
text: "[ok] create table Customers".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
});
app.output.push_back(OutputLine {
text: " Customers".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
});
app.output.push_back(OutputLine {
text: " id serial [PK]".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
});
app.output.push_back(OutputLine {
text: " Name text".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
});
let theme = Theme::dark();
let snapshot = render_to_string(&mut app, &theme, 80, 24);
insta::assert_snapshot!("populated_with_table_dark", snapshot);
}
#[test]
fn items_panel_nests_indexes_under_their_table() {
// S2 (ADR-0025): the items panel renders each table
// with its index names indented beneath it.
let mut app = App::new();
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
app.schema_cache.table_indexes.insert(
"Customers".to_string(),
vec!["idx_email".to_string()],
);
let theme = Theme::dark();
let out = render_to_string(&mut app, &theme, 80, 24);
assert!(out.contains("Customers"), "table listed:\n{out}");
assert!(out.contains("Orders"), "table listed:\n{out}");
assert!(out.contains("idx_email"), "index nested in panel:\n{out}");
}
}