25800e3eb5
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.
1297 lines
49 KiB
Rust
1297 lines
49 KiB
Rust
//! 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}");
|
||
}
|
||
}
|