a6fd26d15a
Surprise gap from the post-sweep sanity check — `ui.rs` had a substantial set of TUI-rendered strings that the previous two sweep passes didn't cover. Caught by grepping for capitalised literals in `ui.rs` after running the binary smoke check. ## Migrated - **modal.*** — load picker title / empty state / path prompt; rebuild confirm title / "Continue?" prompt. (modal.path_entry's title comes from `save.*` since it's the save / save-as dialog.) - **save.*** — `save` no-op hint, modal titles for Save / Save as, modal prompt body. - **status.*** — status bar `Project:` label and the `(no project)` placeholder. - **panel.*** — `Tables` panel title, `(none yet)` placeholder for empty tables, `(no active hint)` placeholder for the hint panel. - **shortcut.*** — the bottom-bar keyboard hint labels (submit, confirm, cancel, yes, no, load, select, browse_path, back_to_list, switch, advanced_once, cancel_one_shot, quit). Each is a translatable label paired with a key name (Enter / Esc / Ctrl-C / etc.) at the call site. Keystroke names are deliberately left as literals — translating them would mean retraining users away from what their keyboard says. The `push_shortcut` closure's parameter type changed from `&'static str` to `&str` so it accepts the catalog-returned String. ## Deliberately left - **Echo prefix tags**: `[simple] `, `[advanced] `, `[system] `, `[error] `. Their column widths are hardcoded into the wrap-width calculation in `render_output_panel`; translating them would silently break alignment. Worth a follow-up pass if a future locale needs different prefixes (would need `mode.label()` and the echo-tag widths to live behind a single locale-aware function). - **Mode labels**: `SIMPLE` / `ADVANCED` / `Advanced:` rendered in the input panel border. Same alignment reasoning as the echo tags — also they're keywords (the user types `mode simple` to switch), so translating the display label without translating the command word would be confusing. Left as is. - **Visual decoration**: `[Y]`, `[N]`, `[TEMP] `, `>` cursor markers, `█` cursor block, `↑↓` arrow glyph, `›` selection marker. Universal symbols / labels rather than translatable prose. ## Catalog totals The catalog now has ~170 entries across 16 categories. `tests/engine_vocabulary_audit` passes — no engine vocabulary leaks anywhere user-reachable. ## Tally 610 tests passing (no change — pure refactor with identical-output catalog substitutions). Clippy clean with nursery lints. Release builds at 7.8 MB. ADR-0019 §9 is now genuinely complete.
819 lines
30 KiB
Rust
819 lines
30 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};
|
||
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),
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/// 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();
|
||
let lines: Vec<Line<'_>> = app
|
||
.tables
|
||
.iter()
|
||
.map(|name| {
|
||
let style = if name == highlight {
|
||
Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD)
|
||
} else {
|
||
Style::default().fg(theme.fg)
|
||
};
|
||
Line::from(Span::styled(name.as_str(), style))
|
||
})
|
||
.collect();
|
||
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(
|
||
" Output ",
|
||
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()
|
||
}
|
||
|
||
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 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),
|
||
};
|
||
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(),
|
||
};
|
||
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, "SIMPLE"),
|
||
EffectiveMode::AdvancedPersistent => {
|
||
(theme.border_advanced, theme.mode_advanced, "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, "Advanced:")
|
||
}
|
||
};
|
||
|
||
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. When the cursor is at end-of-input we
|
||
// append an inverted space.
|
||
let cursor = app.input_cursor.min(app.input.len());
|
||
let before = &app.input[..cursor];
|
||
let (under, after) = if cursor < app.input.len() {
|
||
// Find the end of the character under the cursor.
|
||
let mut end = cursor + 1;
|
||
while end < app.input.len() && !app.input.is_char_boundary(end) {
|
||
end += 1;
|
||
}
|
||
(&app.input[cursor..end], &app.input[end..])
|
||
} else {
|
||
(" ", "")
|
||
};
|
||
let spans = vec![
|
||
Span::styled(before, Style::default().fg(theme.fg)),
|
||
Span::styled(
|
||
under,
|
||
Style::default().fg(theme.fg).add_modifier(Modifier::REVERSED),
|
||
),
|
||
Span::styled(after, Style::default().fg(theme.fg)),
|
||
];
|
||
let paragraph = Paragraph::new(Line::from(spans)).block(block);
|
||
frame.render_widget(paragraph, area);
|
||
}
|
||
|
||
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(
|
||
" Hint ",
|
||
Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD),
|
||
))
|
||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||
|
||
let empty_hint = crate::t!("panel.hint_empty");
|
||
let body = app.hint.as_deref().unwrap_or(empty_hint.as_str());
|
||
let paragraph = Paragraph::new(Line::from(Span::styled(
|
||
body.to_string(),
|
||
Style::default().fg(theme.muted),
|
||
)))
|
||
.block(block)
|
||
.wrap(Wrap { trim: false });
|
||
|
||
frame.render_widget(paragraph, area);
|
||
}
|
||
|
||
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;
|
||
use ratatui::Terminal;
|
||
use ratatui::backend::TestBackend;
|
||
|
||
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 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 `:`.
|
||
let mut app = App::new();
|
||
app.input.push_str(": sel");
|
||
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,
|
||
},
|
||
ColumnDescription {
|
||
name: "Name".to_string(),
|
||
user_type: Some(Type::Text),
|
||
sqlite_type: "TEXT".to_string(),
|
||
notnull: false,
|
||
primary_key: false,
|
||
},
|
||
],
|
||
outbound_relationships: Vec::new(),
|
||
inbound_relationships: 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,
|
||
});
|
||
app.output.push_back(OutputLine {
|
||
text: " Customers".to_string(),
|
||
kind: OutputKind::System,
|
||
mode_at_submission: Mode::Simple,
|
||
});
|
||
app.output.push_back(OutputLine {
|
||
text: " id serial [PK]".to_string(),
|
||
kind: OutputKind::System,
|
||
mode_at_submission: Mode::Simple,
|
||
});
|
||
app.output.push_back(OutputLine {
|
||
text: " Name text".to_string(),
|
||
kind: OutputKind::System,
|
||
mode_at_submission: Mode::Simple,
|
||
});
|
||
|
||
let theme = Theme::dark();
|
||
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||
insta::assert_snapshot!("populated_with_table_dark", snapshot);
|
||
}
|
||
}
|