2d0f4b2958
Render the keystroke badge and step caption as a solid yellow rectangle with no border glyphs and a one-cell text margin, instead of a rounded-border box — deliberately unlike the app's bordered panels so the demo overlays read as a distinct, eye-catching callout. Shared fill_overlay_rect helper (borderless Block fill + inset Paragraph). Snapshots regenerated; ADR-0047 D4 wording updated.
3212 lines
128 KiB
Rust
3212 lines
128 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, EchoStatus, EffectiveMode, NavFocus, OutputKind, OutputLine, OutputStyleClass,
|
||
};
|
||
use crate::mode::Mode;
|
||
use crate::theme::Theme;
|
||
|
||
/// Render the entire application frame.
|
||
///
|
||
/// Takes `&mut App` because the renderer reports the current
|
||
/// output-panel row count back to the App for scroll-cap
|
||
/// computation — without that feedback, scrolling past the top
|
||
/// of the buffer would slide the visible window off and
|
||
/// "eat" lines from the bottom on subsequent renders.
|
||
/// Minimum terminal width at which the schema sidebar (the left items
|
||
/// column) is shown by default (ADR-0046 DB1). At or below this the
|
||
/// sidebar is hidden so the output/input panels get the full width —
|
||
/// notably the 90-column screencasts. Tunable.
|
||
const SIDEBAR_MIN_WIDTH: u16 = 90;
|
||
|
||
/// Whether the schema sidebar is visible — a pure function of terminal
|
||
/// width (ADR-0046 DB1). Phase C will also reveal it while a sidebar
|
||
/// panel is focused via the Ctrl-O peek.
|
||
const fn sidebar_visible(total_width: u16) -> bool {
|
||
total_width > SIDEBAR_MIN_WIDTH
|
||
}
|
||
|
||
/// Height (including borders) of the Relationships sub-panel within the
|
||
/// left column (ADR-0046 DB4): floored at 5 rows (so an empty panel
|
||
/// shows "(none)"), grown with `content_rows` up to half the column,
|
||
/// and never so tall that the Tables panel above drops below 3 rows.
|
||
const fn relationships_panel_height(col_h: u16, content_rows: u16) -> u16 {
|
||
let want = content_rows + 2; // + top/bottom borders
|
||
let mut h = if want < 5 { 5 } else { want };
|
||
let cap = col_h / 2; // never more than half the column
|
||
if h > cap {
|
||
h = cap;
|
||
}
|
||
let max_h = col_h.saturating_sub(3); // leave Tables at least 3 rows
|
||
if h > max_h {
|
||
h = max_h;
|
||
}
|
||
h
|
||
}
|
||
|
||
pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
|
||
let area = frame.area();
|
||
paint_background(theme, frame, area);
|
||
|
||
// Reserve two rows at the bottom for status:
|
||
// - top row: "Project: <Display Name>" (P-NAME-3, ADR-0015 §2).
|
||
// - bottom row: mode-aware keyboard shortcuts.
|
||
let outer = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([
|
||
Constraint::Min(8),
|
||
Constraint::Length(1),
|
||
Constraint::Length(1),
|
||
])
|
||
.split(area);
|
||
|
||
// ADR-0046 DB1: on a wide terminal the schema sidebar takes a fixed
|
||
// left column; at or below SIDEBAR_MIN_WIDTH it is hidden and the
|
||
// right column spans the full width.
|
||
if sidebar_visible(area.width) {
|
||
let columns = Layout::default()
|
||
.direction(Direction::Horizontal)
|
||
.constraints([Constraint::Length(28), Constraint::Min(20)])
|
||
.split(outer[0]);
|
||
// ADR-0046 DB4: the sidebar stacks Tables (top) over a
|
||
// Relationships panel (bottom), the latter content-sized within
|
||
// [5 rows, half the column].
|
||
let rel_content = (app.relationships.len() as u16).saturating_mul(3);
|
||
let rel_h = relationships_panel_height(columns[0].height, rel_content);
|
||
let sidebar = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([Constraint::Min(3), Constraint::Length(rel_h)])
|
||
.split(columns[0]);
|
||
render_items_panel(app, theme, frame, sidebar[0]);
|
||
render_relationships_panel(app, theme, frame, sidebar[1]);
|
||
render_right_column(app, theme, frame, columns[1]);
|
||
} else {
|
||
render_right_column(app, theme, frame, outer[0]);
|
||
}
|
||
render_project_label(app, theme, frame, outer[1]);
|
||
render_status_bar(app, theme, frame, outer[2]);
|
||
|
||
// ADR-0046 DC2: in navigation mode, draw the focused sidebar as an
|
||
// expanded overlay over the (unchanged) base render — revealing it
|
||
// if it was hidden (peek) and widening it for browsing. Drawn below
|
||
// the modal layer; a modal can't open in navigation mode, but if one
|
||
// is somehow up it still wins.
|
||
if app.nav_focus.in_sidebar() {
|
||
render_nav_sidebar_overlay(app, theme, frame, outer[0]);
|
||
}
|
||
|
||
// Modal dialogs (rebuild confirm, save-as prompt, load
|
||
// picker, …) are drawn last so they overlay the rest of
|
||
// the frame.
|
||
if let Some(modal) = app.modal.as_ref() {
|
||
render_modal(modal, theme, frame, area);
|
||
}
|
||
|
||
// ADR-0047 D4: the demo overlays draw last of all — over modals — so
|
||
// a keystroke badge (and, in Phase C, a step caption) stays visible
|
||
// while the load picker (the #24 cast) or any modal is up.
|
||
if app.demo_mode {
|
||
render_demo_overlays(app, frame);
|
||
}
|
||
}
|
||
|
||
/// The fixed high-contrast style for every demo overlay (ADR-0047 D4):
|
||
/// bold black text on a yellow background.
|
||
fn demo_overlay_style() -> Style {
|
||
Style::default()
|
||
.bg(crate::theme::DEMO_OVERLAY_BG)
|
||
.fg(crate::theme::DEMO_OVERLAY_FG)
|
||
.add_modifier(Modifier::BOLD)
|
||
}
|
||
|
||
/// Draw the demonstration-mode overlays anchored to the output panel's
|
||
/// inner bottom-right corner (ADR-0047 D4): the step caption (if any) at
|
||
/// the bottom, the keystroke badge stacked directly above it (or at the
|
||
/// bottom when there is no caption). Both are inset one cell and skipped
|
||
/// rather than overflowing when the area is too small.
|
||
fn render_demo_overlays(app: &App, frame: &mut Frame<'_>) {
|
||
let area = app.last_output_area;
|
||
if area.width == 0 || area.height == 0 {
|
||
return; // not measured yet
|
||
}
|
||
// Caption first — it owns the bottom-right corner. The badge then
|
||
// stacks above whatever the caption actually occupied.
|
||
let caption_rect = app
|
||
.demo_caption
|
||
.as_deref()
|
||
.and_then(|text| render_caption_box(text, area, frame));
|
||
if let Some(label) = app.demo_badge {
|
||
render_badge_box(label, area, caption_rect, frame);
|
||
}
|
||
}
|
||
|
||
/// Paint a flat filled overlay rectangle — a solid yellow block with no
|
||
/// border glyphs (ADR-0047 D4) — and lay `body` inside a one-cell
|
||
/// margin. The borderless solid block is deliberately *unlike* the app's
|
||
/// bordered panels, so the demo overlays read as a distinct callout.
|
||
fn fill_overlay_rect(rect: Rect, body: String, frame: &mut Frame<'_>) {
|
||
frame.render_widget(ratatui::widgets::Clear, rect);
|
||
// `Block` with no borders fills the whole rect with the overlay
|
||
// background (same mechanism as `paint_background`).
|
||
frame.render_widget(Block::default().style(demo_overlay_style()), rect);
|
||
let inner = rect.inner(Margin {
|
||
horizontal: 1,
|
||
vertical: 1,
|
||
});
|
||
frame.render_widget(Paragraph::new(body).style(demo_overlay_style()), inner);
|
||
}
|
||
|
||
/// A small high-contrast keystroke badge (`[TAB]`, `[ENTER]`, …) inset
|
||
/// one cell from the bottom-right of `area` (ADR-0047 D2/D4) — the label
|
||
/// on a flat yellow rectangle with a one-cell margin. When a caption box
|
||
/// is present (`above`), the badge sits directly on top of it, right
|
||
/// edges aligned; otherwise it takes the bottom-right corner. Skipped
|
||
/// rather than overflowing if it cannot fit.
|
||
fn render_badge_box(label: &str, area: Rect, above: Option<Rect>, frame: &mut Frame<'_>) {
|
||
let box_w = label.chars().count() as u16 + 2; // one-cell margin each side
|
||
let box_h = 3; // text row + a margin row above and below
|
||
if box_w + 1 > area.width {
|
||
return;
|
||
}
|
||
let x = area.x + area.width - box_w - 1;
|
||
let y = match above {
|
||
// Directly above the caption, right edges aligned.
|
||
Some(c) => {
|
||
if c.y < area.y + box_h {
|
||
return; // no room above the caption
|
||
}
|
||
c.y - box_h
|
||
}
|
||
None => {
|
||
if box_h + 1 > area.height {
|
||
return;
|
||
}
|
||
area.y + area.height - box_h - 1
|
||
}
|
||
};
|
||
fill_overlay_rect(Rect { x, y, width: box_w, height: box_h }, label.to_string(), frame);
|
||
}
|
||
|
||
/// A step-caption box inset one cell from the bottom-right of `area`
|
||
/// (ADR-0047 D3/D4): the text word-wrapped to at most 3 lines within a
|
||
/// corner-sized width, bold black on a flat yellow rectangle. Returns
|
||
/// the rect it drew, or `None` if it was too small to place (so the
|
||
/// badge can fall back to the bottom-right corner).
|
||
fn render_caption_box(text: &str, area: Rect, frame: &mut Frame<'_>) -> Option<Rect> {
|
||
// Content width capped so the box stays corner-sized; the caption
|
||
// wraps to ≤ 3 lines and ellipsises beyond (D4).
|
||
let content_w = 40.min(area.width.saturating_sub(4)) as usize;
|
||
if content_w < 4 {
|
||
return None; // output too narrow for a useful caption
|
||
}
|
||
let lines = clamp_wrapped(text, content_w, 3);
|
||
let inner_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
|
||
let box_w = inner_w as u16 + 2; // one-cell margin each side
|
||
let box_h = lines.len() as u16 + 2; // text rows + a margin row above and below
|
||
if box_w + 1 > area.width || box_h + 1 > area.height {
|
||
return None;
|
||
}
|
||
let rect = Rect {
|
||
x: area.x + area.width - box_w - 1,
|
||
y: area.y + area.height - box_h - 1,
|
||
width: box_w,
|
||
height: box_h,
|
||
};
|
||
fill_overlay_rect(rect, lines.join("\n"), frame);
|
||
Some(rect)
|
||
}
|
||
|
||
/// Width (columns) of the navigation-mode expanded sidebar overlay
|
||
/// (ADR-0046 DC2). Wide enough that most relationship endpoints fit on
|
||
/// one line, turning horizontal truncation into vertical scrolling.
|
||
const NAV_EXPANDED_WIDTH: u16 = 45;
|
||
|
||
/// Blank columns cleared to the right of the expanded sidebar overlay
|
||
/// (ADR-0046 DC2), separating it from the base panels left visible
|
||
/// behind it so the overlay's right edge reads cleanly.
|
||
const NAV_OVERLAY_GUTTER: u16 = 1;
|
||
|
||
/// Draw the focused sidebar, expanded, as an overlay over the left of
|
||
/// the main content area (ADR-0046 DC2/DC3). `Clear` + a background
|
||
/// repaint hide the base render underneath; the two panels keep the
|
||
/// DB4 split. The focused panel is accent-bordered (DC3).
|
||
fn render_nav_sidebar_overlay(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||
// ADR-0046 DC2: clear the sidebar strip plus a one-column gutter and
|
||
// paint the expanded sidebar over it. The base output / input / hint
|
||
// stay visible to the right — unchanged, just partially occluded —
|
||
// and the gutter keeps them from butting against the sidebar's
|
||
// border. They are restored fully on the next frame when navigation
|
||
// mode exits.
|
||
let width = NAV_EXPANDED_WIDTH.min(area.width);
|
||
let cleared_w = (width + NAV_OVERLAY_GUTTER).min(area.width);
|
||
let cleared = Rect {
|
||
x: area.x,
|
||
y: area.y,
|
||
width: cleared_w,
|
||
height: area.height,
|
||
};
|
||
frame.render_widget(ratatui::widgets::Clear, cleared);
|
||
paint_background(theme, frame, cleared);
|
||
let sidebar = Rect {
|
||
x: area.x,
|
||
y: area.y,
|
||
width,
|
||
height: area.height,
|
||
};
|
||
let rel_content = (app.relationships.len() as u16).saturating_mul(3);
|
||
let rel_h = relationships_panel_height(sidebar.height, rel_content);
|
||
let parts = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([Constraint::Min(3), Constraint::Length(rel_h)])
|
||
.split(sidebar);
|
||
render_items_panel(app, theme, frame, parts[0]);
|
||
render_relationships_panel(app, theme, frame, parts[1]);
|
||
}
|
||
|
||
/// Border style for a sidebar panel: an accented, bold border when it
|
||
/// holds navigation focus (ADR-0046 DC3), the muted border otherwise.
|
||
fn panel_border_style(theme: &Theme, focused: bool) -> Style {
|
||
if focused {
|
||
Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD)
|
||
} else {
|
||
Style::default().fg(theme.border)
|
||
}
|
||
}
|
||
|
||
fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||
use crate::app::Modal;
|
||
match modal {
|
||
Modal::RebuildConfirm(m) => render_rebuild_confirm(&m.summary, theme, frame, area),
|
||
Modal::PathEntry(m) => render_path_entry(m, theme, frame, area),
|
||
Modal::LoadPicker(m) => render_load_picker(m, theme, frame, area),
|
||
Modal::UndoConfirm(m) => render_undo_confirm(m, theme, frame, area),
|
||
}
|
||
}
|
||
|
||
fn render_path_entry(
|
||
m: &crate::app::PathEntryModal,
|
||
theme: &Theme,
|
||
frame: &mut Frame<'_>,
|
||
area: Rect,
|
||
) {
|
||
let dialog_w = area.width.clamp(20, 70);
|
||
let inner_w = dialog_w.saturating_sub(4) as usize;
|
||
let prompt_lines = wrap_lines(&m.prompt, inner_w);
|
||
// Title + blank + prompt + blank + input box (1 row + borders) + blank + key hints.
|
||
let dialog_h = (prompt_lines.len() as u16).saturating_add(8).min(area.height);
|
||
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
|
||
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
|
||
let dialog_area = Rect {
|
||
x,
|
||
y,
|
||
width: dialog_w,
|
||
height: dialog_h,
|
||
};
|
||
frame.render_widget(ratatui::widgets::Clear, dialog_area);
|
||
|
||
let title_style = Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD);
|
||
let block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_type(BorderType::Rounded)
|
||
.border_style(Style::default().fg(theme.fg))
|
||
.title(Line::from(vec![Span::styled(
|
||
format!(" {} ", m.title),
|
||
title_style,
|
||
)]))
|
||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||
|
||
let mut text_lines: Vec<Line<'_>> = Vec::new();
|
||
text_lines.push(Line::from(""));
|
||
for line in prompt_lines {
|
||
text_lines.push(Line::from(line));
|
||
}
|
||
text_lines.push(Line::from(""));
|
||
let cursor_marker = "█";
|
||
let display_input = if m.cursor == m.input.len() {
|
||
format!("{}{cursor_marker}", m.input)
|
||
} else {
|
||
format!(
|
||
"{}{cursor_marker}{}",
|
||
&m.input[..m.cursor],
|
||
&m.input[m.cursor..]
|
||
)
|
||
};
|
||
text_lines.push(Line::from(format!("> {display_input}")));
|
||
text_lines.push(Line::from(""));
|
||
text_lines.push(Line::from(vec![
|
||
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
|
||
Span::raw(format!(" {} ", crate::t!("shortcut.confirm"))),
|
||
Span::styled("Esc", Style::default().fg(theme.muted)),
|
||
Span::styled(
|
||
format!(" {}", crate::t!("shortcut.cancel")),
|
||
Style::default().fg(theme.muted),
|
||
),
|
||
]));
|
||
|
||
let paragraph = Paragraph::new(text_lines)
|
||
.block(block)
|
||
.wrap(Wrap { trim: false });
|
||
frame.render_widget(paragraph, dialog_area);
|
||
}
|
||
|
||
fn render_load_picker(
|
||
m: &crate::app::LoadPickerModal,
|
||
theme: &Theme,
|
||
frame: &mut Frame<'_>,
|
||
area: Rect,
|
||
) {
|
||
use crate::app::LoadPickerSubMode;
|
||
let dialog_w = area.width.clamp(20, 70);
|
||
let dialog_h = area.height.clamp(10, 20);
|
||
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
|
||
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
|
||
let dialog_area = Rect {
|
||
x,
|
||
y,
|
||
width: dialog_w,
|
||
height: dialog_h,
|
||
};
|
||
frame.render_widget(ratatui::widgets::Clear, dialog_area);
|
||
|
||
let title_style = Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD);
|
||
let block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_type(BorderType::Rounded)
|
||
.border_style(Style::default().fg(theme.fg))
|
||
.title(Line::from(vec![Span::styled(
|
||
format!(" {} ", crate::t!("modal.load_picker_title")),
|
||
title_style,
|
||
)]))
|
||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||
|
||
let mut text_lines: Vec<Line<'_>> = Vec::new();
|
||
text_lines.push(Line::from(""));
|
||
|
||
match &m.sub_mode {
|
||
LoadPickerSubMode::List => {
|
||
if m.entries.is_empty() {
|
||
text_lines.push(Line::from(crate::t!("modal.load_picker_empty")));
|
||
} else {
|
||
for (i, entry) in m.entries.iter().enumerate() {
|
||
let marker = if i == m.selected { "›" } else { " " };
|
||
let temp_tag = if entry.is_temp { "[TEMP] " } else { "" };
|
||
let style = if i == m.selected {
|
||
Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD)
|
||
} else {
|
||
Style::default().fg(theme.fg)
|
||
};
|
||
let line = format!(
|
||
" {marker} {temp_tag}{name} {modified}",
|
||
name = entry.display_name,
|
||
modified = entry.modified,
|
||
);
|
||
text_lines.push(Line::from(Span::styled(line, style)));
|
||
}
|
||
}
|
||
text_lines.push(Line::from(""));
|
||
text_lines.push(Line::from(vec![
|
||
Span::styled("↑↓", Style::default().add_modifier(Modifier::BOLD)),
|
||
Span::raw(format!(" {} ", crate::t!("shortcut.select"))),
|
||
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
|
||
Span::raw(format!(" {} ", crate::t!("shortcut.load"))),
|
||
Span::styled("b", Style::default().add_modifier(Modifier::BOLD)),
|
||
Span::raw(format!(" {} ", crate::t!("shortcut.browse_path"))),
|
||
Span::styled("Esc", Style::default().fg(theme.muted)),
|
||
Span::styled(
|
||
format!(" {}", crate::t!("shortcut.cancel")),
|
||
Style::default().fg(theme.muted),
|
||
),
|
||
]));
|
||
}
|
||
LoadPickerSubMode::PathEntry { input, cursor } => {
|
||
text_lines.push(Line::from(crate::t!("modal.load_picker_path_prompt")));
|
||
text_lines.push(Line::from(""));
|
||
let cursor_marker = "█";
|
||
let display_input = if *cursor == input.len() {
|
||
format!("{input}{cursor_marker}")
|
||
} else {
|
||
format!(
|
||
"{}{cursor_marker}{}",
|
||
&input[..*cursor],
|
||
&input[*cursor..]
|
||
)
|
||
};
|
||
text_lines.push(Line::from(format!("> {display_input}")));
|
||
text_lines.push(Line::from(""));
|
||
text_lines.push(Line::from(vec![
|
||
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
|
||
Span::raw(format!(" {} ", crate::t!("shortcut.load"))),
|
||
Span::styled("Esc", Style::default().fg(theme.muted)),
|
||
Span::styled(
|
||
format!(" {}", crate::t!("shortcut.back_to_list")),
|
||
Style::default().fg(theme.muted),
|
||
),
|
||
]));
|
||
}
|
||
}
|
||
|
||
let paragraph = Paragraph::new(text_lines)
|
||
.block(block)
|
||
.wrap(Wrap { trim: false });
|
||
frame.render_widget(paragraph, dialog_area);
|
||
}
|
||
|
||
/// Centred dialog with a one-paragraph body and a [Y]es/[N]o
|
||
/// hint at the bottom. Sized at min(60 cols, area.width-4)
|
||
/// wide and tall enough to fit the wrapped body plus 4 rows
|
||
/// of chrome.
|
||
fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||
let dialog_w = area.width.min(60).saturating_sub(0);
|
||
let dialog_w = dialog_w.max(20);
|
||
let inner_w = dialog_w.saturating_sub(4) as usize;
|
||
|
||
let body_lines: Vec<String> = wrap_lines(summary, inner_w);
|
||
let body_height = body_lines.len() as u16;
|
||
// Title row + blank + body + blank + prompt + blank + keys + borders (2).
|
||
let dialog_h = body_height.saturating_add(7).min(area.height);
|
||
|
||
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
|
||
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
|
||
let dialog_area = Rect {
|
||
x,
|
||
y,
|
||
width: dialog_w,
|
||
height: dialog_h,
|
||
};
|
||
|
||
// Solid background panel so we cover whatever was beneath.
|
||
let bg = ratatui::widgets::Clear;
|
||
frame.render_widget(bg, dialog_area);
|
||
|
||
let title_style = Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD);
|
||
let block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_type(BorderType::Rounded)
|
||
.border_style(Style::default().fg(theme.fg))
|
||
.title(Line::from(vec![Span::styled(
|
||
format!(" {} ", crate::t!("modal.rebuild_confirm_title")),
|
||
title_style,
|
||
)]))
|
||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||
|
||
let mut text_lines: Vec<Line<'_>> = Vec::new();
|
||
text_lines.push(Line::from(""));
|
||
for line in body_lines {
|
||
text_lines.push(Line::from(line));
|
||
}
|
||
text_lines.push(Line::from(""));
|
||
text_lines.push(Line::from(crate::t!("modal.rebuild_confirm_prompt")));
|
||
text_lines.push(Line::from(""));
|
||
text_lines.push(Line::from(vec![
|
||
Span::styled(
|
||
"[Y]",
|
||
Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD),
|
||
),
|
||
Span::raw(format!(" {} ", crate::t!("shortcut.yes"))),
|
||
Span::styled(
|
||
"[N]",
|
||
Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD),
|
||
),
|
||
Span::raw(format!(" {} ", crate::t!("shortcut.no"))),
|
||
Span::styled("Esc", Style::default().fg(theme.muted)),
|
||
Span::styled(
|
||
format!(" {}", crate::t!("shortcut.cancel")),
|
||
Style::default().fg(theme.muted),
|
||
),
|
||
]));
|
||
|
||
let paragraph = Paragraph::new(text_lines)
|
||
.block(block)
|
||
.wrap(Wrap { trim: false });
|
||
frame.render_widget(paragraph, dialog_area);
|
||
}
|
||
|
||
/// Format a stored ISO-8601 UTC timestamp for display in a
|
||
/// confirmation dialog (issue #13): parse it, convert to the
|
||
/// machine's local timezone, and render a fixed human-friendly
|
||
/// form (`24 May 2026, 11:00`). Month names stay English — no
|
||
/// locale feature. Falls back to the raw input if it can't be
|
||
/// parsed; this is defensive only, since stored values are always
|
||
/// `utc_iso8601_now()` output.
|
||
fn format_snapshot_timestamp(iso: &str) -> String {
|
||
chrono::DateTime::parse_from_rfc3339(iso)
|
||
.map(|dt| format_local_datetime(dt.with_timezone(&chrono::Local)))
|
||
.unwrap_or_else(|_| iso.to_string())
|
||
}
|
||
|
||
/// Render a timezone-aware datetime in the fixed display form.
|
||
/// Split out from [`format_snapshot_timestamp`] so the format can
|
||
/// be unit-tested deterministically with a fixed offset (the
|
||
/// `Local` conversion itself is machine-dependent).
|
||
fn format_local_datetime<Tz>(dt: chrono::DateTime<Tz>) -> String
|
||
where
|
||
Tz: chrono::TimeZone,
|
||
Tz::Offset: std::fmt::Display,
|
||
{
|
||
dt.format("%-d %b %Y, %H:%M").to_string()
|
||
}
|
||
|
||
/// Preferred outer width (columns) for the undo/redo confirm
|
||
/// dialog (issue #13): wide enough to hold the longest content
|
||
/// line on a single row, clamped to sane bounds and the available
|
||
/// area so a short insert no longer wraps on roomy terminals.
|
||
fn undo_dialog_width(
|
||
content_widths: impl IntoIterator<Item = usize>,
|
||
area_width: u16,
|
||
) -> u16 {
|
||
/// Floor — comfortably fits the button row plus borders.
|
||
const MIN: u16 = 34;
|
||
/// Ceiling for outlier (ultra-wide) terminals.
|
||
const MAX: u16 = 100;
|
||
let widest = content_widths.into_iter().max().unwrap_or(0);
|
||
// +4: left/right border (2) + one padding column each side (2).
|
||
let preferred =
|
||
u16::try_from(widest).unwrap_or(u16::MAX).saturating_add(4);
|
||
let upper = area_width.min(MAX);
|
||
let lower = MIN.min(upper);
|
||
preferred.clamp(lower, upper)
|
||
}
|
||
|
||
/// `undo` / `redo` confirmation modal (ADR-0006 Amendment 1). Names
|
||
/// the command that will be undone / re-applied and when its
|
||
/// snapshot was taken, then prompts `Y` / `N`.
|
||
fn render_undo_confirm(
|
||
m: &crate::app::UndoConfirmModal,
|
||
theme: &Theme,
|
||
frame: &mut Frame<'_>,
|
||
area: Rect,
|
||
) {
|
||
let intro = if m.is_redo {
|
||
crate::t!("modal.redo_confirm_command")
|
||
} else {
|
||
crate::t!("modal.undo_confirm_command")
|
||
};
|
||
let title = if m.is_redo {
|
||
crate::t!("modal.redo_confirm_title")
|
||
} else {
|
||
crate::t!("modal.undo_confirm_title")
|
||
};
|
||
let intro_line = format!("{intro} {}", m.command);
|
||
// Local-time, human-formatted snapshot stamp (issue #13).
|
||
let when_display = format_snapshot_timestamp(&m.timestamp);
|
||
let when_line =
|
||
crate::t!("modal.undo_confirm_when", timestamp = when_display);
|
||
let prompt = crate::t!("modal.undo_confirm_prompt");
|
||
// Reconstruct the button row purely to measure its width — the
|
||
// styled spans are built below. Keep this in sync with them.
|
||
let buttons_measure = format!(
|
||
"[Y] {} [N] {} Esc {}",
|
||
crate::t!("shortcut.yes"),
|
||
crate::t!("shortcut.no"),
|
||
crate::t!("shortcut.cancel"),
|
||
);
|
||
|
||
// Grow the dialog to fit the longest content line on one row
|
||
// (issue #13). The title sits in the border, so it needs two
|
||
// extra columns for the surrounding spaces.
|
||
let dialog_w = undo_dialog_width(
|
||
[
|
||
title.chars().count() + 2,
|
||
intro_line.chars().count(),
|
||
when_line.chars().count(),
|
||
prompt.chars().count(),
|
||
buttons_measure.chars().count(),
|
||
],
|
||
area.width,
|
||
);
|
||
let inner_w = dialog_w.saturating_sub(4) as usize;
|
||
|
||
let mut body_lines: Vec<String> = wrap_lines(&intro_line, inner_w);
|
||
body_lines.extend(wrap_lines(&when_line, inner_w));
|
||
let body_height = body_lines.len() as u16;
|
||
// Title row + blank + body + blank + prompt + blank + keys + borders (2).
|
||
let dialog_h = body_height.saturating_add(7).min(area.height);
|
||
|
||
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
|
||
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
|
||
let dialog_area = Rect {
|
||
x,
|
||
y,
|
||
width: dialog_w,
|
||
height: dialog_h,
|
||
};
|
||
|
||
frame.render_widget(ratatui::widgets::Clear, dialog_area);
|
||
|
||
let title_style = Style::default().fg(theme.fg).add_modifier(Modifier::BOLD);
|
||
let block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_type(BorderType::Rounded)
|
||
.border_style(Style::default().fg(theme.fg))
|
||
.title(Line::from(vec![Span::styled(
|
||
format!(" {title} "),
|
||
title_style,
|
||
)]))
|
||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||
|
||
let mut text_lines: Vec<Line<'_>> = Vec::new();
|
||
text_lines.push(Line::from(""));
|
||
for line in body_lines {
|
||
text_lines.push(Line::from(line));
|
||
}
|
||
text_lines.push(Line::from(""));
|
||
text_lines.push(Line::from(prompt));
|
||
text_lines.push(Line::from(""));
|
||
text_lines.push(Line::from(vec![
|
||
Span::styled("[Y]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
|
||
Span::raw(format!(" {} ", crate::t!("shortcut.yes"))),
|
||
Span::styled("[N]", Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
|
||
Span::raw(format!(" {} ", crate::t!("shortcut.no"))),
|
||
Span::styled("Esc", Style::default().fg(theme.muted)),
|
||
Span::styled(
|
||
format!(" {}", crate::t!("shortcut.cancel")),
|
||
Style::default().fg(theme.muted),
|
||
),
|
||
]));
|
||
|
||
let paragraph = Paragraph::new(text_lines)
|
||
.block(block)
|
||
.wrap(Wrap { trim: false });
|
||
frame.render_widget(paragraph, dialog_area);
|
||
}
|
||
|
||
/// Greedy word-wrap to `width` columns. Sufficient for the
|
||
/// short prose modals carry; we don't try to be Unicode-aware
|
||
/// (display-width-wise) since the strings we generate are
|
||
/// ASCII-friendly.
|
||
fn wrap_lines(s: &str, width: usize) -> Vec<String> {
|
||
if width == 0 {
|
||
return vec![s.to_string()];
|
||
}
|
||
let mut lines: Vec<String> = Vec::new();
|
||
let mut current = String::new();
|
||
for word in s.split_whitespace() {
|
||
if !current.is_empty() && current.len() + 1 + word.len() <= width {
|
||
current.push(' ');
|
||
} else if !current.is_empty() {
|
||
lines.push(std::mem::take(&mut current));
|
||
}
|
||
current.push_str(word);
|
||
}
|
||
if !current.is_empty() {
|
||
lines.push(current);
|
||
}
|
||
if lines.is_empty() {
|
||
lines.push(String::new());
|
||
}
|
||
lines
|
||
}
|
||
|
||
/// Absolute ceiling on Hint-panel content rows. Per ADR-0046 (DA1/DA2)
|
||
/// the panel's height is no longer driven by the hint *content* — it is
|
||
/// a pure function of terminal geometry (`hint_rows`), fixed between
|
||
/// resizes, so it cannot resize mid-typing and shove the input/output
|
||
/// panels (#20). This is the most rows `hint_rows` will ever allocate;
|
||
/// a hint longer than the allocation is ellipsis-truncated (issue #12's
|
||
/// overflow signalling is retained — only the *sizing* changed).
|
||
const MAX_HINT_ROWS: usize = 3;
|
||
|
||
/// Terminal heights below this are "compact" (covers the ~25-row
|
||
/// screencasts); at or above it the terminal is "comfortable" and can
|
||
/// afford taller panels (ADR-0046 DA2). Tunable.
|
||
const COMFORTABLE_MIN_HEIGHT: u16 = 40;
|
||
|
||
/// A 3rd hint row is only ever needed when the hint column's inner
|
||
/// width is narrow enough to wrap the longest catalog hint past two
|
||
/// lines; at or above this width two rows always suffice (ADR-0046 DA2,
|
||
/// measured against `src/friendly/strings/en-US.yaml`).
|
||
const HINT_THIRD_ROW_MAX_INNER: u16 = 54;
|
||
|
||
/// Input- and hint-panel content-row counts as a pure function of the
|
||
/// right column's geometry (ADR-0046 DA1/DA2/DA4) — NOT of the hint or
|
||
/// input text. That is the point of #20: heights fixed per terminal
|
||
/// size cannot jump as the user types. Returns `(input_rows,
|
||
/// hint_rows)`; add 2 to each for the panel borders.
|
||
///
|
||
/// - Compact height (`< COMFORTABLE_MIN_HEIGHT`): input 1 row, hint 2.
|
||
/// - Comfortable height: input 2 rows (DA4 two-row display); hint 2, or
|
||
/// 3 when the column is narrow enough (`inner <
|
||
/// HINT_THIRD_ROW_MAX_INNER`) that the longest hint needs a third
|
||
/// line.
|
||
/// - Degradation: on a terminal too short to honour the output panel's
|
||
/// `Min(5)` plus both panels, the hint shrinks first, then the input,
|
||
/// so the output keeps its floor.
|
||
const fn panel_heights(area: Rect) -> (u16, u16) {
|
||
let comfortable = area.height >= COMFORTABLE_MIN_HEIGHT;
|
||
let inner_w = area.width.saturating_sub(2);
|
||
let mut input_c: u16 = if comfortable { 2 } else { 1 };
|
||
let mut hint_c: u16 = if !comfortable {
|
||
2
|
||
} else if inner_w < HINT_THIRD_ROW_MAX_INNER {
|
||
MAX_HINT_ROWS as u16
|
||
} else {
|
||
2
|
||
};
|
||
// Honour the output panel's Min(5) first on a very short terminal:
|
||
// 5 (output) + (input_c + 2) + (hint_c + 2) must fit in the column.
|
||
// Shrink the hint first, then the input.
|
||
while 5 + (input_c + 2) + (hint_c + 2) > area.height {
|
||
if hint_c > 1 {
|
||
hint_c -= 1;
|
||
} else if input_c > 1 {
|
||
input_c -= 1;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
(input_c, hint_c)
|
||
}
|
||
|
||
/// Word-wrap `text` to `width`, then clamp to at most `max_rows`
|
||
/// rows. If wrapping produced more rows than the cap, the last kept
|
||
/// row is truncated to end with an ellipsis so the overflow is
|
||
/// signalled rather than silently dropped (issue #12). Every
|
||
/// returned row fits within `width`.
|
||
fn clamp_wrapped(text: &str, width: usize, max_rows: usize) -> Vec<String> {
|
||
let mut lines = wrap_lines(text, width);
|
||
if lines.len() <= max_rows {
|
||
return lines;
|
||
}
|
||
lines.truncate(max_rows.max(1));
|
||
if let Some(last) = lines.last_mut() {
|
||
// Reserve one column for the ellipsis.
|
||
let budget = width.saturating_sub(1);
|
||
let mut chars: Vec<char> = last.chars().collect();
|
||
while chars.len() > budget {
|
||
chars.pop();
|
||
}
|
||
chars.push('…');
|
||
*last = chars.into_iter().collect();
|
||
}
|
||
lines
|
||
}
|
||
|
||
fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||
let label_style = Style::default().fg(theme.muted);
|
||
let value_style = Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD);
|
||
let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
|
||
|
||
let no_project = crate::t!("status.no_project");
|
||
let display = app.project_name.as_deref().unwrap_or(no_project.as_str());
|
||
let mut spans: Vec<Span<'_>> = vec![Span::styled(
|
||
crate::t!("status.project_label"),
|
||
label_style,
|
||
)];
|
||
if app.project_is_temp {
|
||
spans.push(Span::styled(
|
||
"[TEMP] ",
|
||
Style::default()
|
||
.fg(theme.muted)
|
||
.add_modifier(Modifier::BOLD),
|
||
));
|
||
}
|
||
spans.push(Span::styled(display.to_string(), value_style));
|
||
let line = Line::from(spans);
|
||
let paragraph = Paragraph::new(line).style(bar_style);
|
||
frame.render_widget(paragraph, area);
|
||
}
|
||
|
||
fn render_right_column(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||
// ADR-0046 DA1/DA2: the Hint panel's height is a pure function of
|
||
// the column geometry, fixed between resizes — it no longer tracks
|
||
// the hint content, so typing cannot make it resize and shove the
|
||
// input/output panels (#20). The hint is then clamped to that fixed
|
||
// row budget. The hint panel spans the full column width, so
|
||
// `area.width` is its width too.
|
||
let (input_c, hint_c) = panel_heights(area);
|
||
let hint_lines = resolve_hint_lines(app, theme, area.width, hint_c as usize);
|
||
|
||
let rows = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([
|
||
Constraint::Min(5), // Output panel
|
||
Constraint::Length(input_c + 2), // Input panel (1 row, or 2 when tall)
|
||
Constraint::Length(hint_c + 2), // Hint panel (geometry-fixed)
|
||
])
|
||
.split(area);
|
||
|
||
render_output_panel(app, theme, frame, rows[0]);
|
||
render_input_panel(app, theme, frame, rows[1]);
|
||
render_hint_panel(theme, frame, rows[2], hint_lines);
|
||
}
|
||
|
||
fn paint_background(theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||
let block = Block::default().style(Style::default().bg(theme.bg).fg(theme.fg));
|
||
frame.render_widget(block, area);
|
||
}
|
||
|
||
fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||
let block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_type(BorderType::Rounded)
|
||
.border_style(panel_border_style(
|
||
theme,
|
||
app.nav_focus == NavFocus::SidebarTables,
|
||
))
|
||
.title(Span::styled(
|
||
format!(" {} ", crate::t!("panel.tables_title")),
|
||
Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD),
|
||
))
|
||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||
|
||
// ADR-0046 DC3: clamp + store the scroll before the (borrowing)
|
||
// lines are built. Visible rows and the content total are computed
|
||
// by counting (one row per table + one per index), so the `&mut
|
||
// app` writes finish before the immutable line borrows begin.
|
||
let visible = area.height.saturating_sub(2) as usize;
|
||
app.last_tables_visible = visible;
|
||
|
||
if app.tables.is_empty() {
|
||
let placeholder = Paragraph::new(Line::from(Span::styled(
|
||
crate::t!("panel.tables_empty"),
|
||
Style::default()
|
||
.fg(theme.muted)
|
||
.add_modifier(Modifier::ITALIC),
|
||
)))
|
||
.block(block);
|
||
frame.render_widget(placeholder, area);
|
||
return;
|
||
}
|
||
|
||
let total: usize = app
|
||
.tables
|
||
.iter()
|
||
.map(|t| 1 + app.schema_cache.table_indexes.get(t).map_or(0, Vec::len))
|
||
.sum();
|
||
let offset = app.tables_scroll.min(total.saturating_sub(visible));
|
||
app.tables_scroll = offset;
|
||
|
||
let highlight = app
|
||
.current_table
|
||
.as_ref()
|
||
.map(|t| t.name.as_str())
|
||
.unwrap_or_default();
|
||
// Nested tables / per-table indexes (S2, ADR-0025): each
|
||
// table line, with its index names indented beneath it.
|
||
let mut lines: Vec<Line<'_>> = Vec::new();
|
||
for name in &app.tables {
|
||
let style = if name == highlight {
|
||
Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD)
|
||
} else {
|
||
Style::default().fg(theme.fg)
|
||
};
|
||
lines.push(Line::from(Span::styled(name.as_str(), style)));
|
||
if let Some(indexes) = app.schema_cache.table_indexes.get(name) {
|
||
for index in indexes {
|
||
// Mark a UNIQUE index so the panel distinguishes it from
|
||
// a performance-only index (ADR-0035 §4d).
|
||
let unique = if index.unique { " [unique]" } else { "" };
|
||
lines.push(Line::from(Span::styled(
|
||
format!(" {}{unique}", index.name),
|
||
Style::default().fg(theme.muted),
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0));
|
||
frame.render_widget(paragraph, area);
|
||
}
|
||
|
||
/// The Relationships sub-panel below the Tables list (ADR-0046 DB2). In
|
||
/// the narrow (unfocused) column each relationship is three lines — its
|
||
/// name, then the endpoints broken at the arrow to fit — every line
|
||
/// ellipsized past the inner width. Phase C adds focus + scroll for the
|
||
/// overflow; for now content beyond the panel's height is clipped.
|
||
fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||
let block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_type(BorderType::Rounded)
|
||
.border_style(panel_border_style(
|
||
theme,
|
||
app.nav_focus == NavFocus::SidebarRelationships,
|
||
))
|
||
.title(Span::styled(
|
||
format!(" {} ", crate::t!("panel.relationships_title")),
|
||
Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD),
|
||
))
|
||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||
|
||
// ADR-0046 DC3: clamp + store the scroll before the borrowing lines
|
||
// (three rows per relationship).
|
||
let visible = area.height.saturating_sub(2) as usize;
|
||
app.last_relationships_visible = visible;
|
||
|
||
if app.relationships.is_empty() {
|
||
let placeholder = Paragraph::new(Line::from(Span::styled(
|
||
crate::t!("panel.relationships_empty"),
|
||
Style::default()
|
||
.fg(theme.muted)
|
||
.add_modifier(Modifier::ITALIC),
|
||
)))
|
||
.block(block);
|
||
frame.render_widget(placeholder, area);
|
||
return;
|
||
}
|
||
|
||
let total = app.relationships.len() * 3;
|
||
let offset = app.relationships_scroll.min(total.saturating_sub(visible));
|
||
app.relationships_scroll = offset;
|
||
|
||
let inner_w = area.width.saturating_sub(2) as usize;
|
||
let name_style = Style::default().fg(theme.fg);
|
||
let detail_style = Style::default().fg(theme.muted);
|
||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||
for rel in &app.relationships {
|
||
lines.push(Line::from(Span::styled(
|
||
ellipsize(&rel.name, inner_w),
|
||
name_style,
|
||
)));
|
||
let parent = format!(" {}.{} ->", rel.parent_table, rel.parent_columns.join(", "));
|
||
lines.push(Line::from(Span::styled(ellipsize(&parent, inner_w), detail_style)));
|
||
let child = format!(" {}.{}", rel.child_table, rel.child_columns.join(", "));
|
||
lines.push(Line::from(Span::styled(ellipsize(&child, inner_w), detail_style)));
|
||
}
|
||
let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0));
|
||
frame.render_widget(paragraph, area);
|
||
}
|
||
|
||
/// Truncate `s` to `width` display columns, appending an ellipsis when
|
||
/// it overflows (ADR-0046 DB2). Assumes one column per character.
|
||
fn ellipsize(s: &str, width: usize) -> String {
|
||
if width == 0 {
|
||
return String::new();
|
||
}
|
||
if s.chars().count() <= width {
|
||
return s.to_string();
|
||
}
|
||
let mut out: String = s.chars().take(width.saturating_sub(1)).collect();
|
||
out.push('…');
|
||
out
|
||
}
|
||
|
||
fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||
let block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_type(BorderType::Rounded)
|
||
.border_style(Style::default().fg(theme.border))
|
||
.title(Span::styled(
|
||
format!(" {} ", crate::t!("panel.output_title")),
|
||
Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD),
|
||
))
|
||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||
|
||
let inner = area.inner(Margin {
|
||
horizontal: 1,
|
||
vertical: 1,
|
||
});
|
||
|
||
// Render every output line into a wrapped Paragraph and let
|
||
// ratatui handle the wrapping; we then use the wrapped row
|
||
// count to cap scroll correctly. Bottom-anchoring (most
|
||
// recent visible by default) is achieved by computing the
|
||
// scroll offset relative to the bottom of the wrapped view.
|
||
let visible = inner.height as usize;
|
||
|
||
// Compute the total wrapped row count first, working from
|
||
// OutputLines directly (so the borrow ends before the
|
||
// mutable `note_output_viewport` call below).
|
||
let total_wrapped = approximate_wrapped_rows_from_output(&app.output, inner.width);
|
||
app.note_output_viewport(visible, total_wrapped);
|
||
// ADR-0044 §3: record the panel width so a later `show relationship`
|
||
// diagram (rendered App-side) can choose side-by-side vs vertical.
|
||
app.last_output_width = inner.width;
|
||
// ADR-0047 D4: record the full inner area so the top-level draw can
|
||
// anchor the demo overlays to the output panel's bottom-right corner.
|
||
app.last_output_area = inner;
|
||
|
||
let lines: Vec<Line<'_>> = app
|
||
.output
|
||
.iter()
|
||
.map(|line| render_output_line(line, theme))
|
||
.collect();
|
||
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
|
||
|
||
let max_scroll = total_wrapped.saturating_sub(visible);
|
||
let effective_scroll = app.output_scroll.min(max_scroll);
|
||
// Paragraph::scroll((y, _)) sets the topmost visible row.
|
||
// We want bottom-anchored: y = max_scroll - effective_scroll
|
||
// so scroll==0 shows the bottom and PageUp moves y down to
|
||
// reveal older content.
|
||
let scroll_y = max_scroll.saturating_sub(effective_scroll);
|
||
let scroll_y_u16 = u16::try_from(scroll_y).unwrap_or(u16::MAX);
|
||
|
||
frame.render_widget(block, area);
|
||
frame.render_widget(paragraph.scroll((scroll_y_u16, 0)), inner);
|
||
}
|
||
|
||
/// Approximate the number of display rows the output buffer
|
||
/// will occupy after width-wrapping. Computed directly from
|
||
/// `OutputLine`s (rather than the rendered `Line` objects) so
|
||
/// the calculation can run before we hand `&mut App` to the
|
||
/// scroll-cap update. Ratatui's exact `line_count` is gated
|
||
/// behind an unstable feature; this character-based
|
||
/// approximation is close enough for scroll capping — off-by-one
|
||
/// at boundaries is acceptable.
|
||
fn approximate_wrapped_rows_from_output(
|
||
output: &std::collections::VecDeque<OutputLine>,
|
||
width: u16,
|
||
) -> usize {
|
||
if width == 0 {
|
||
return output.len();
|
||
}
|
||
let w = usize::from(width);
|
||
output
|
||
.iter()
|
||
.map(|line| {
|
||
// Tag width matches `render_output_line` exactly so
|
||
// the row count is right when the panel is too narrow
|
||
// for the natural line.
|
||
let tag_len = match line.kind {
|
||
OutputKind::Echo => match line.mode_at_submission {
|
||
Mode::Simple => "[simple] ".len(),
|
||
Mode::Advanced => "[advanced] ".len(),
|
||
},
|
||
OutputKind::System | OutputKind::TeachingEcho => "[system] ".len(),
|
||
OutputKind::Error => "[error] ".len(),
|
||
};
|
||
// ADR-0040: a completed echo renders `<input> ✓/✗` —
|
||
// the `running: ` prefix is dropped and a 2-column marker
|
||
// (space + glyph) appended; everything else renders its
|
||
// text verbatim.
|
||
let content_chars = if matches!(
|
||
(line.kind, line.status),
|
||
(OutputKind::Echo, Some(EchoStatus::Ok | EchoStatus::Err))
|
||
) {
|
||
line.text
|
||
.strip_prefix(crate::dsl::ECHO_PREFIX)
|
||
.unwrap_or(line.text.as_str())
|
||
.chars()
|
||
.count()
|
||
+ 2
|
||
} else {
|
||
line.text.chars().count()
|
||
};
|
||
let total = tag_len + content_chars;
|
||
if total == 0 { 1 } else { total.div_ceil(w) }
|
||
})
|
||
.sum()
|
||
}
|
||
|
||
/// Resolve a semantic [`OutputStyleClass`] to a concrete style
|
||
/// against the active theme (ADR-0028 §5/§6).
|
||
const fn output_span_style(class: OutputStyleClass, theme: &Theme) -> Style {
|
||
match class {
|
||
OutputStyleClass::Neutral => Style::new().fg(theme.fg),
|
||
OutputStyleClass::Efficient => Style::new().fg(theme.plan_efficient),
|
||
OutputStyleClass::Expensive => Style::new().fg(theme.warning),
|
||
OutputStyleClass::AutomaticIndex => Style::new()
|
||
.fg(theme.warning)
|
||
.add_modifier(Modifier::BOLD),
|
||
// ADR-0038 §4 / §6: de-emphasised text — the `Executing SQL:`
|
||
// prefix and every category-3 prose line (caveat + the
|
||
// existing `client_side.*` notes). `theme.muted` is the
|
||
// established dim foreground.
|
||
OutputStyleClass::Hint => Style::new().fg(theme.muted),
|
||
// ADR-0044 relationship diagrams. Reuse existing theme colours
|
||
// (no new Theme fields): the table name stands out via weight,
|
||
// keys + cardinality take accent colours, connectors are muted.
|
||
OutputStyleClass::DiagramTableName => {
|
||
Style::new().fg(theme.fg).add_modifier(Modifier::BOLD)
|
||
}
|
||
OutputStyleClass::DiagramKey => Style::new()
|
||
.fg(theme.plan_efficient)
|
||
.add_modifier(Modifier::BOLD),
|
||
OutputStyleClass::DiagramCardinality => Style::new()
|
||
.fg(theme.tok_number)
|
||
.add_modifier(Modifier::BOLD),
|
||
OutputStyleClass::DiagramConnector => Style::new().fg(theme.muted),
|
||
}
|
||
}
|
||
|
||
fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> {
|
||
// ADR-0037 Amendment (issue #10): the output tag is colour-coded by
|
||
// the message's STATUS (its kind), not the submission mode — so the
|
||
// leftmost glyph the eye lands on says "ok" vs "error" at a glance,
|
||
// and a routine `[system]` line never looks identical to an `[error]`.
|
||
// The echo line is the sole exception: its tag's whole job is to label
|
||
// the submission mode (ADR-0037's stated purpose), so it keeps the
|
||
// mode tint (per-command success rides the trailing ✓/✗ — ADR-0040).
|
||
let tag_style = match line.kind {
|
||
OutputKind::Echo => match line.mode_at_submission {
|
||
Mode::Simple => Style::default().fg(theme.mode_simple),
|
||
Mode::Advanced => Style::default().fg(theme.mode_advanced),
|
||
},
|
||
OutputKind::System | OutputKind::TeachingEcho => Style::default().fg(theme.system),
|
||
OutputKind::Error => Style::default().fg(theme.error),
|
||
};
|
||
let tag = match line.kind {
|
||
OutputKind::Echo => format!("[{}] ", line.mode_at_submission.label().to_lowercase()),
|
||
OutputKind::System | OutputKind::TeachingEcho => "[system] ".to_string(),
|
||
OutputKind::Error => "[error] ".to_string(),
|
||
};
|
||
|
||
// ADR-0040: an echo renders `running: <input>` while pending
|
||
// (and for untracked parse/pre-flight rejections), and
|
||
// `<input> ✓` / `<input> ✗` once an executed command completes
|
||
// — the marker replaces the old `[ok]`/`failed:` summary line.
|
||
// Simple-mode input keeps its token-class highlighting (ADR-0022
|
||
// §5); advanced-mode input renders plain, as before. The body
|
||
// shape is `<ECHO_PREFIX><input>`; the prefix is pinned to the
|
||
// catalog template by
|
||
// `dsl::tests::echo_prefix_matches_catalog_template`.
|
||
if line.kind == OutputKind::Echo {
|
||
let input = line
|
||
.text
|
||
.strip_prefix(crate::dsl::ECHO_PREFIX)
|
||
.unwrap_or(line.text.as_str());
|
||
let mut spans: Vec<Span<'a>> = Vec::with_capacity(3 + input.len() / 4);
|
||
spans.push(Span::styled(tag, tag_style));
|
||
// Pending / untracked → keep the `running: ` prefix;
|
||
// completed → drop it (the marker carries the outcome).
|
||
if !matches!(line.status, Some(EchoStatus::Ok | EchoStatus::Err)) {
|
||
spans.push(Span::styled(
|
||
crate::dsl::ECHO_PREFIX,
|
||
Style::default().fg(theme.fg),
|
||
));
|
||
}
|
||
if line.mode_at_submission == Mode::Simple {
|
||
for run in crate::input_render::lex_to_runs(input, theme) {
|
||
spans.push(Span::styled(
|
||
&input[run.byte_range.0..run.byte_range.1],
|
||
run.style,
|
||
));
|
||
}
|
||
} else {
|
||
spans.push(Span::styled(input, Style::default().fg(theme.fg)));
|
||
}
|
||
match line.status {
|
||
Some(EchoStatus::Ok) => {
|
||
spans.push(Span::styled(" ✓", Style::default().fg(theme.system)));
|
||
}
|
||
Some(EchoStatus::Err) => {
|
||
spans.push(Span::styled(" ✗", Style::default().fg(theme.error)));
|
||
}
|
||
_ => {}
|
||
}
|
||
return Line::from(spans);
|
||
}
|
||
|
||
// ADR-0038 §4 styled-runs polish — the DSL → SQL teaching echo
|
||
// gets the same syntactic treatment as the input echo, but with
|
||
// a dimmed `Executing SQL: ` prefix instead of the mode label,
|
||
// and the SQL portion lexed in `Mode::Advanced` (the echoed SQL
|
||
// is always advanced syntax, regardless of submission mode).
|
||
if line.kind == OutputKind::TeachingEcho
|
||
&& let Some(rest) = line.text.strip_prefix(crate::echo::TEACHING_ECHO_LABEL)
|
||
{
|
||
let prefix_len = crate::echo::TEACHING_ECHO_LABEL.len();
|
||
let mut spans: Vec<Span<'a>> = Vec::with_capacity(2 + rest.len() / 4);
|
||
spans.push(Span::styled(tag, tag_style));
|
||
spans.push(Span::styled(
|
||
&line.text[..prefix_len],
|
||
Style::default().fg(theme.muted),
|
||
));
|
||
for run in
|
||
crate::input_render::lex_to_runs_in_mode(rest, theme, Mode::Advanced)
|
||
{
|
||
spans.push(Span::styled(
|
||
&rest[run.byte_range.0..run.byte_range.1],
|
||
run.style,
|
||
));
|
||
}
|
||
return Line::from(spans);
|
||
}
|
||
// A TeachingEcho line missing the canonical prefix is a builder
|
||
// bug; it falls through to the plain rendering below.
|
||
|
||
// ADR-0028 §5: a line carrying a styled-runs payload is
|
||
// rendered span-by-span, each run's semantic class resolved
|
||
// to a colour from the active theme. The tag keeps its
|
||
// kind styling. (Echo lines never carry runs, so this never
|
||
// collides with the branch above.)
|
||
if let Some(runs) = &line.styled_runs {
|
||
let mut spans: Vec<Span<'a>> = Vec::with_capacity(runs.len() + 1);
|
||
spans.push(Span::styled(tag, tag_style));
|
||
for run in runs {
|
||
let (start, end) = run.byte_range;
|
||
spans.push(Span::styled(
|
||
&line.text[start..end],
|
||
output_span_style(run.class, theme),
|
||
));
|
||
}
|
||
return Line::from(spans);
|
||
}
|
||
|
||
// ADR-0037 Amendment (issue #10): bodies are neutral — the status
|
||
// colour lives on the tag, not flooded across the message text. An
|
||
// error body keeps weight via BOLD (rustc-style: severity-coloured
|
||
// label, readable bold message) rather than a hard-to-read wall of
|
||
// red; a system body is plain `theme.fg`.
|
||
let body_style = match line.kind {
|
||
// TeachingEcho without a prefix reaches here only as a builder
|
||
// bug; it shares the neutral system body so it degrades
|
||
// gracefully rather than crashing.
|
||
OutputKind::Echo | OutputKind::System | OutputKind::TeachingEcho => {
|
||
Style::default().fg(theme.fg)
|
||
}
|
||
OutputKind::Error => Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
|
||
};
|
||
Line::from(vec![
|
||
Span::styled(tag, tag_style),
|
||
Span::styled(line.text.as_str(), body_style),
|
||
])
|
||
}
|
||
|
||
/// Horizontal scroll offset (in display columns) for a single-line
|
||
/// input that may overflow its `text_width`-column viewport (ADR-0046
|
||
/// DA3). Keeps the cursor visible; when the line overflows, reserves one
|
||
/// column on each side for the `<` / `>` overflow markers so a marker
|
||
/// never hides the cursor. `cursor_col` is the column of the cursor
|
||
/// cell (which can be `line_cols`, one past the last char, when the
|
||
/// cursor sits at the end). Returns the new offset given the previous
|
||
/// one, so the view only scrolls when the cursor would leave the window.
|
||
const fn input_scroll_offset(
|
||
line_cols: usize,
|
||
cursor_col: usize,
|
||
text_width: usize,
|
||
offset: usize,
|
||
) -> usize {
|
||
// The line (including the cursor-at-end cell) fits: no scroll.
|
||
if line_cols < text_width || text_width == 0 {
|
||
return 0;
|
||
}
|
||
// Reserve a column each side for the `<` / `>` markers.
|
||
let eff = if text_width > 2 { text_width - 2 } else { 1 };
|
||
let mut off = offset;
|
||
if cursor_col < off {
|
||
off = cursor_col;
|
||
} else if cursor_col >= off + eff {
|
||
off = cursor_col + 1 - eff;
|
||
}
|
||
// Never scroll past the point where the cursor-at-end cell shows.
|
||
let max_off = (line_cols + 1).saturating_sub(eff);
|
||
if off > max_off {
|
||
off = max_off;
|
||
}
|
||
off
|
||
}
|
||
|
||
fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||
let effective = app.effective_mode();
|
||
let (border_color, mode_color, label) = match effective {
|
||
EffectiveMode::Simple => (
|
||
theme.border,
|
||
theme.mode_simple,
|
||
crate::t!("mode.label_simple"),
|
||
),
|
||
EffectiveMode::AdvancedPersistent => (
|
||
theme.border_advanced,
|
||
theme.mode_advanced,
|
||
crate::t!("mode.label_advanced"),
|
||
),
|
||
// Mixed-case label distinguishes the one-shot (`:`-triggered)
|
||
// state from a persistent advanced mode at a glance.
|
||
EffectiveMode::AdvancedOneShot => (
|
||
theme.border_advanced,
|
||
theme.mode_advanced,
|
||
crate::t!("mode.label_advanced_one_shot"),
|
||
),
|
||
};
|
||
|
||
let title = Line::from(vec![
|
||
Span::raw(" "),
|
||
Span::styled(
|
||
label,
|
||
Style::default()
|
||
.fg(mode_color)
|
||
.add_modifier(Modifier::BOLD),
|
||
),
|
||
Span::raw(" "),
|
||
]);
|
||
|
||
let block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_type(BorderType::Rounded)
|
||
.border_style(Style::default().fg(border_color))
|
||
.title(title)
|
||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||
|
||
let cursor = app.input_cursor.min(app.input.len());
|
||
|
||
// ADR-0027 §4: the rightmost six columns of the input row
|
||
// (a five-column label plus a one-column gap) are reserved
|
||
// unconditionally, so the first row's text area is always
|
||
// `inner.width - 6` and the typed command never shifts sideways
|
||
// when the validity indicator appears or hides. A two-row input
|
||
// (DA4) lets the *second* row use the full width.
|
||
let inner = block.inner(area);
|
||
let text_area = Rect {
|
||
width: inner.width.saturating_sub(6),
|
||
..inner
|
||
};
|
||
|
||
// Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both modes —
|
||
// the highlight walker runs with the active mode so SQL keywords /
|
||
// operators / CASE / function calls colour correctly. The cursor
|
||
// cell is rendered inverted (an empty-range run) so it is visible
|
||
// without a real terminal cursor.
|
||
let mode_for_render = match effective {
|
||
EffectiveMode::Simple => crate::mode::Mode::Simple,
|
||
EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => {
|
||
crate::mode::Mode::Advanced
|
||
}
|
||
};
|
||
|
||
frame.render_widget(block, area);
|
||
|
||
// ADR-0046 DA3/DA4: render the single logical line across one row
|
||
// (compact terminals) or two (comfortable, height ≥ 40), scrolling
|
||
// horizontally in either case so the cursor stays visible.
|
||
if inner.height >= 2 {
|
||
render_input_two_rows(app, theme, frame, inner, text_area, cursor, mode_for_render);
|
||
} else {
|
||
render_input_one_row(app, theme, frame, text_area, cursor, mode_for_render);
|
||
}
|
||
|
||
if let Some(severity) = app.input_indicator {
|
||
let (indicator_label, color) = match severity {
|
||
crate::dsl::walker::Severity::Error => ("[ERR]", theme.error),
|
||
crate::dsl::walker::Severity::Warning => ("[WRN]", theme.warning),
|
||
};
|
||
let label_area = Rect {
|
||
x: inner.x + inner.width.saturating_sub(5),
|
||
width: 5.min(inner.width),
|
||
..inner
|
||
};
|
||
frame.render_widget(
|
||
Paragraph::new(Line::from(Span::styled(
|
||
indicator_label,
|
||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||
))),
|
||
label_area,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// One-row input rendering (ADR-0046 DA3): the single logical line is
|
||
/// horizontally scrolled so the cursor stays visible, with `<` / `>`
|
||
/// markers (muted) at the reserved edge columns signalling hidden
|
||
/// content. The offset is stored *before* the highlight spans borrow
|
||
/// `app.input`, so the `&mut app` write does not clash.
|
||
fn render_input_one_row(
|
||
app: &mut App,
|
||
theme: &Theme,
|
||
frame: &mut Frame<'_>,
|
||
text_area: Rect,
|
||
cursor: usize,
|
||
mode_for_render: crate::mode::Mode,
|
||
) {
|
||
let line_cols = app.input.chars().count();
|
||
let cursor_col = app.input[..cursor].chars().count();
|
||
let tw = text_area.width as usize;
|
||
let offset = input_scroll_offset(line_cols, cursor_col, tw, app.input_scroll_offset);
|
||
app.input_scroll_offset = offset;
|
||
|
||
let runs = crate::input_render::render_input_runs_in_mode(
|
||
&app.input,
|
||
cursor,
|
||
theme,
|
||
&app.schema_cache,
|
||
mode_for_render,
|
||
);
|
||
let spans = runs_to_spans(&app.input, &runs);
|
||
|
||
if line_cols > tw || offset > 0 {
|
||
// Overflow: reserve one column each side for `<` / `>` markers,
|
||
// render the windowed text between them, then draw the markers
|
||
// for whichever side still has hidden content.
|
||
let eff = if tw > 2 { tw - 2 } else { 1 };
|
||
let mid = Rect {
|
||
x: text_area.x + 1,
|
||
width: eff as u16,
|
||
..text_area
|
||
};
|
||
frame.render_widget(
|
||
Paragraph::new(Line::from(spans)).scroll((0, offset as u16)),
|
||
mid,
|
||
);
|
||
let marker = Style::default().fg(theme.muted);
|
||
if offset > 0 {
|
||
frame.render_widget(
|
||
Paragraph::new(Span::styled("<", marker)),
|
||
Rect { width: 1, ..text_area },
|
||
);
|
||
}
|
||
if offset + eff < line_cols {
|
||
frame.render_widget(
|
||
Paragraph::new(Span::styled(">", marker)),
|
||
Rect {
|
||
x: text_area.x + text_area.width.saturating_sub(1),
|
||
width: 1,
|
||
..text_area
|
||
},
|
||
);
|
||
}
|
||
} else {
|
||
frame.render_widget(Paragraph::new(Line::from(spans)), text_area);
|
||
}
|
||
}
|
||
|
||
/// Two-row input rendering (ADR-0046 DA4): on a comfortable terminal the
|
||
/// single logical line is soft-wrapped across two visual rows — the
|
||
/// first row stops 6 columns short (the ADR-0027 indicator reserve), the
|
||
/// second uses the full width. When the line overflows both rows it
|
||
/// scrolls horizontally (one column each side reserved for `<` / `>`
|
||
/// markers) so the cursor stays visible. `text_area` is the first
|
||
/// (narrower) row; `inner` spans both rows.
|
||
fn render_input_two_rows(
|
||
app: &mut App,
|
||
theme: &Theme,
|
||
frame: &mut Frame<'_>,
|
||
inner: Rect,
|
||
text_area: Rect,
|
||
cursor: usize,
|
||
mode_for_render: crate::mode::Mode,
|
||
) {
|
||
let row0_w = text_area.width as usize; // first row reserves the indicator
|
||
let row1_w = inner.width as usize; // second row uses the full width
|
||
let capacity = row0_w + row1_w;
|
||
let line_cols = app.input.chars().count();
|
||
let cursor_col = app.input[..cursor].chars().count();
|
||
let offset = input_scroll_offset(line_cols, cursor_col, capacity, app.input_scroll_offset);
|
||
app.input_scroll_offset = offset;
|
||
|
||
let runs = crate::input_render::render_input_runs_in_mode(
|
||
&app.input,
|
||
cursor,
|
||
theme,
|
||
&app.schema_cache,
|
||
mode_for_render,
|
||
);
|
||
let cells = expand_runs_to_cells(&app.input, &runs);
|
||
let len = cells.len();
|
||
|
||
// Overflowing both rows reserves a marker column on each row's
|
||
// outer edge; otherwise both rows use their full text width.
|
||
let overflow = line_cols >= capacity;
|
||
let row0_text_w = if overflow { row0_w.saturating_sub(1) } else { row0_w };
|
||
let row1_text_w = if overflow { row1_w.saturating_sub(1) } else { row1_w };
|
||
let eff_cap = row0_text_w + row1_text_w;
|
||
|
||
let start = offset.min(len);
|
||
let end = (offset + eff_cap).min(len);
|
||
let window = &cells[start..end];
|
||
let split = row0_text_w.min(window.len());
|
||
let to_line = |cs: &[(String, Style)]| {
|
||
Line::from(
|
||
cs.iter()
|
||
.map(|(s, st)| Span::styled(s.clone(), *st))
|
||
.collect::<Vec<_>>(),
|
||
)
|
||
};
|
||
|
||
let row0_x = if overflow { text_area.x + 1 } else { text_area.x };
|
||
frame.render_widget(
|
||
Paragraph::new(to_line(&window[..split])),
|
||
Rect {
|
||
x: row0_x,
|
||
y: inner.y,
|
||
width: row0_text_w as u16,
|
||
height: 1,
|
||
},
|
||
);
|
||
frame.render_widget(
|
||
Paragraph::new(to_line(&window[split..])),
|
||
Rect {
|
||
x: inner.x,
|
||
y: inner.y + 1,
|
||
width: row1_text_w as u16,
|
||
height: 1,
|
||
},
|
||
);
|
||
|
||
let marker = Style::default().fg(theme.muted);
|
||
if overflow && offset > 0 {
|
||
frame.render_widget(
|
||
Paragraph::new(Span::styled("<", marker)),
|
||
Rect {
|
||
x: text_area.x,
|
||
y: inner.y,
|
||
width: 1,
|
||
height: 1,
|
||
},
|
||
);
|
||
}
|
||
if overflow && end < len {
|
||
frame.render_widget(
|
||
Paragraph::new(Span::styled(">", marker)),
|
||
Rect {
|
||
x: inner.x + inner.width.saturating_sub(1),
|
||
y: inner.y + 1,
|
||
width: 1,
|
||
height: 1,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Expand styled runs into one owned `(grapheme, style)` cell per
|
||
/// display column, including the inverted cursor cell (ADR-0046 DA4).
|
||
/// The two-row renderer places cells across two visual rows and so
|
||
/// needs them individually rather than as byte-range spans.
|
||
fn expand_runs_to_cells(
|
||
input: &str,
|
||
runs: &[crate::input_render::StyledRun],
|
||
) -> Vec<(String, Style)> {
|
||
let mut cells = Vec::new();
|
||
for r in runs {
|
||
if r.byte_range.0 == r.byte_range.1 {
|
||
// Cursor sentinel (empty range) → inverted space cell.
|
||
cells.push((" ".to_string(), r.style));
|
||
} else {
|
||
for ch in input[r.byte_range.0..r.byte_range.1].chars() {
|
||
cells.push((ch.to_string(), r.style));
|
||
}
|
||
}
|
||
}
|
||
cells
|
||
}
|
||
|
||
/// Convert `StyledRun`s into ratatui `Span`s borrowed from
|
||
/// `input`. The end-of-input cursor sentinel (empty range) is
|
||
/// rendered as an inverted space.
|
||
fn runs_to_spans<'a>(
|
||
input: &'a str,
|
||
runs: &[crate::input_render::StyledRun],
|
||
) -> Vec<Span<'a>> {
|
||
runs.iter()
|
||
.map(|r| {
|
||
if r.byte_range.0 == r.byte_range.1 {
|
||
Span::styled(" ", r.style)
|
||
} else {
|
||
Span::styled(&input[r.byte_range.0..r.byte_range.1], r.style)
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Strip a leading one-shot `:` sigil (and the whitespace after
|
||
/// it) from `input`, returning the advanced command slice and the
|
||
/// cursor remapped into it. Mirrors `App::submit`'s `:` handling
|
||
/// so the hint panel hints at the command, not the sigil
|
||
/// (ADR-0022 Amendment 1). Used only when the effective mode is
|
||
/// `AdvancedOneShot`, where `input` is guaranteed to start (after
|
||
/// any leading whitespace) with `:`.
|
||
fn strip_one_shot_prefix(input: &str, cursor: usize) -> (&str, usize) {
|
||
let lead_ws = input.len() - input.trim_start().len();
|
||
let after_colon = lead_ws + 1; // skip the `:`
|
||
let ws_after = input[after_colon..].len() - input[after_colon..].trim_start().len();
|
||
let prefix_len = (after_colon + ws_after).min(input.len());
|
||
let effective = &input[prefix_len..];
|
||
let effective_cursor = cursor.saturating_sub(prefix_len).min(effective.len());
|
||
(effective, effective_cursor)
|
||
}
|
||
|
||
/// Resolve the Hint panel body into its rendered lines, pre-wrapped
|
||
/// to the panel's inner width and clamped to `max_rows` with an
|
||
/// ellipsis backstop (issue #12). `max_rows` is the geometry-fixed row
|
||
/// budget chosen by `hint_rows` (ADR-0046 DA1/DA2); the panel does not
|
||
/// resize to the hint, so a short hint simply leaves the spare rows
|
||
/// blank and a long one is ellipsized at the budget.
|
||
///
|
||
/// Resolution order for the body:
|
||
/// 1. An explicit app-set hint (e.g. modal contexts) wins.
|
||
/// 2. Otherwise, with non-empty input, the ambient
|
||
/// typing-assistance hint (ADR-0022 §6) in the effective mode.
|
||
/// 3. Otherwise, the empty-state placeholder.
|
||
///
|
||
/// Prose hints (1, the ambient `Prose` arm, and the placeholder)
|
||
/// word-wrap across up to `MAX_HINT_ROWS` rows. The candidate list
|
||
/// stays a single row and scrolls horizontally with `<` / `>`
|
||
/// markers (`render_candidate_line`) — it already self-fits, so it
|
||
/// is not wrapped.
|
||
///
|
||
/// ADR-0022 Amendment 1: advanced mode no longer skips ambient
|
||
/// hinting. The original §12 carve-out predated the unified
|
||
/// mode-aware walker (ADR-0030/0031/0032); the walker now speaks
|
||
/// SQL, so `ambient_hint_in_mode` surfaces SQL slot hints +
|
||
/// completion candidates in advanced mode too.
|
||
fn resolve_hint_lines(
|
||
app: &App,
|
||
theme: &Theme,
|
||
area_width: u16,
|
||
max_rows: usize,
|
||
) -> Vec<Line<'static>> {
|
||
let inner = area_width.saturating_sub(2) as usize;
|
||
let muted = Style::default().fg(theme.muted);
|
||
let prose = |text: &str| {
|
||
clamp_wrapped(text, inner, max_rows)
|
||
.into_iter()
|
||
.map(|l| Line::from(Span::styled(l, muted)))
|
||
.collect::<Vec<Line<'static>>>()
|
||
};
|
||
|
||
// In one-shot advanced mode (`:` prefix in simple mode) the
|
||
// raw input carries the `:` sigil, which is not part of the
|
||
// grammar. Strip it for the ambient computation so the hint
|
||
// reflects the advanced command — mirroring `App::submit`.
|
||
let (hint_input, hint_cursor) = match app.effective_mode() {
|
||
EffectiveMode::AdvancedOneShot => {
|
||
strip_one_shot_prefix(&app.input, app.input_cursor)
|
||
}
|
||
_ => (app.input.as_str(), app.input_cursor),
|
||
};
|
||
let ambient = crate::input_render::ambient_hint_in_mode(
|
||
hint_input,
|
||
hint_cursor,
|
||
app.last_completion.as_ref(),
|
||
&app.schema_cache,
|
||
app.effective_mode().as_mode(),
|
||
);
|
||
match (app.hint.as_deref(), ambient) {
|
||
(Some(set), _) => prose(set),
|
||
(None, Some(crate::input_render::AmbientHint::Prose(text))) => prose(&text),
|
||
(None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => {
|
||
vec![render_candidate_line(&items, selected, inner, theme)]
|
||
}
|
||
(None, None) => prose(&crate::t!("panel.hint_empty")),
|
||
}
|
||
}
|
||
|
||
fn render_hint_panel(
|
||
theme: &Theme,
|
||
frame: &mut Frame<'_>,
|
||
area: Rect,
|
||
lines: Vec<Line<'static>>,
|
||
) {
|
||
let block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_type(BorderType::Rounded)
|
||
.border_style(Style::default().fg(theme.border))
|
||
.title(Span::styled(
|
||
format!(" {} ", crate::t!("panel.hint_title")),
|
||
Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD),
|
||
))
|
||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||
|
||
// Lines are already wrapped to the inner width by
|
||
// `resolve_hint_lines`, so no Paragraph-level wrapping is needed.
|
||
let paragraph = Paragraph::new(lines).block(block);
|
||
frame.render_widget(paragraph, area);
|
||
}
|
||
|
||
/// Render the candidate-list line for the hint panel
|
||
/// (ADR-0022 §7 + the user's #2). Items are space-separated.
|
||
/// Each candidate is colour-coded by kind — keywords in
|
||
/// `tok_keyword`, identifiers in `tok_identifier` — so the
|
||
/// user can tell command grammar apart from schema names at
|
||
/// a glance (post-stage-8 user feedback). The selected item
|
||
/// (if any) gets bolded; when the items overflow `width`,
|
||
/// scroll markers `<` / `>` appear at the edges with the
|
||
/// window centred on the selection (or item 0 with no
|
||
/// selection).
|
||
///
|
||
/// Returns `Line<'static>` (each item cloned into its span)
|
||
/// so the caller doesn't have to manage the items' lifetime.
|
||
fn render_candidate_line(
|
||
items: &[crate::completion::Candidate],
|
||
selected: Option<usize>,
|
||
width: usize,
|
||
theme: &Theme,
|
||
) -> Line<'static> {
|
||
if items.is_empty() {
|
||
return Line::default();
|
||
}
|
||
let separator_style = Style::default().fg(theme.muted);
|
||
let marker_style = Style::default().fg(theme.fg);
|
||
// (ADR-0035 §4i e) When a shared entry word merged simple + advanced
|
||
// continuations, the list mixes mode-classes — colour SQL-only
|
||
// (`Advanced`) and DSL-only (`Simple`) continuations with the mode
|
||
// palette so a learner sees which is which; `Both` (and every
|
||
// single-mode list) keeps the token-kind colour, so the tint appears
|
||
// only where it is informative.
|
||
let mixed = {
|
||
let mut seen = std::collections::HashSet::new();
|
||
for c in items {
|
||
seen.insert(c.mode.block_order());
|
||
}
|
||
seen.len() > 1
|
||
};
|
||
let style_for = |i: usize| {
|
||
let kind_fg = match items[i].kind {
|
||
crate::completion::CandidateKind::Keyword => theme.tok_keyword,
|
||
crate::completion::CandidateKind::Identifier => theme.tok_identifier,
|
||
crate::completion::CandidateKind::Flag => theme.tok_flag,
|
||
crate::completion::CandidateKind::Punct => theme.tok_punct,
|
||
crate::completion::CandidateKind::Function => theme.tok_function,
|
||
};
|
||
let base_fg = if mixed {
|
||
match items[i].mode {
|
||
crate::completion::ModeClass::Both => kind_fg,
|
||
crate::completion::ModeClass::Advanced => theme.mode_advanced,
|
||
crate::completion::ModeClass::Simple => theme.mode_simple,
|
||
}
|
||
} else {
|
||
kind_fg
|
||
};
|
||
let mut s = Style::default().fg(base_fg);
|
||
if Some(i) == selected {
|
||
s = s.add_modifier(Modifier::BOLD);
|
||
}
|
||
s
|
||
};
|
||
|
||
let total_width: usize = items
|
||
.iter()
|
||
.map(|c| c.text.len() + 1)
|
||
.sum::<usize>()
|
||
.saturating_sub(1);
|
||
if total_width <= width {
|
||
let mut spans: Vec<Span<'static>> = Vec::with_capacity(items.len() * 2);
|
||
for (i, item) in items.iter().enumerate() {
|
||
if i > 0 {
|
||
spans.push(Span::styled(" ".to_string(), separator_style));
|
||
}
|
||
spans.push(Span::styled(item.text.clone(), style_for(i)));
|
||
}
|
||
return Line::from(spans);
|
||
}
|
||
|
||
// Overflow path: window centred on `selected` (or item 0).
|
||
// Reserve 4 chars for the `< ` / ` >` markers we may end
|
||
// up using.
|
||
let center = selected.unwrap_or(0);
|
||
let mut left = center;
|
||
let mut right = center;
|
||
let mut used = items[center].text.len();
|
||
let avail = width.saturating_sub(4);
|
||
while (left > 0 || right + 1 < items.len()) && used < avail {
|
||
if right + 1 < items.len() {
|
||
let cost = items[right + 1].text.len() + 1;
|
||
if used + cost <= avail {
|
||
right += 1;
|
||
used += cost;
|
||
continue;
|
||
}
|
||
}
|
||
if left > 0 {
|
||
let cost = items[left - 1].text.len() + 1;
|
||
if used + cost <= avail {
|
||
left -= 1;
|
||
used += cost;
|
||
continue;
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
let need_left_marker = left > 0;
|
||
let need_right_marker = right + 1 < items.len();
|
||
|
||
let mut spans: Vec<Span<'static>> = Vec::with_capacity((right - left + 1) * 2 + 4);
|
||
if need_left_marker {
|
||
spans.push(Span::styled("< ".to_string(), marker_style));
|
||
}
|
||
for (offset, item) in items[left..=right].iter().enumerate() {
|
||
if offset > 0 {
|
||
spans.push(Span::styled(" ".to_string(), separator_style));
|
||
}
|
||
spans.push(Span::styled(item.text.clone(), style_for(left + offset)));
|
||
}
|
||
if need_right_marker {
|
||
spans.push(Span::styled(" >".to_string(), marker_style));
|
||
}
|
||
Line::from(spans)
|
||
}
|
||
|
||
fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||
let key_style = Style::default()
|
||
.fg(theme.fg)
|
||
.add_modifier(Modifier::BOLD);
|
||
let sep_style = Style::default().fg(theme.muted);
|
||
let label_style = Style::default().fg(theme.muted);
|
||
let bar_style = Style::default().bg(theme.bg).fg(theme.muted);
|
||
|
||
let separator = Span::styled(" · ", sep_style);
|
||
let mut spans: Vec<Span<'_>> = Vec::new();
|
||
|
||
let push_shortcut = |spans: &mut Vec<Span<'_>>, key: &'static str, label: &str| {
|
||
if !spans.is_empty() {
|
||
spans.push(separator.clone());
|
||
}
|
||
spans.push(Span::styled(key, key_style));
|
||
spans.push(Span::raw(" "));
|
||
spans.push(Span::styled(label.to_string(), label_style));
|
||
};
|
||
|
||
let submit = crate::t!("shortcut.submit");
|
||
push_shortcut(&mut spans, "Enter", &submit);
|
||
let switch = crate::t!("shortcut.switch");
|
||
let advanced_once = crate::t!("shortcut.advanced_once");
|
||
let cancel_one_shot = crate::t!("shortcut.cancel_one_shot");
|
||
let quit = crate::t!("shortcut.quit");
|
||
match app.effective_mode() {
|
||
EffectiveMode::Simple => {
|
||
push_shortcut(&mut spans, ":", &advanced_once);
|
||
push_shortcut(&mut spans, "mode advanced", &switch);
|
||
}
|
||
EffectiveMode::AdvancedPersistent => {
|
||
push_shortcut(&mut spans, "mode simple", &switch);
|
||
}
|
||
EffectiveMode::AdvancedOneShot => {
|
||
push_shortcut(&mut spans, "Backspace", &cancel_one_shot);
|
||
}
|
||
}
|
||
push_shortcut(&mut spans, "Ctrl-C", &quit);
|
||
|
||
let paragraph = Paragraph::new(Line::from(spans)).style(bar_style);
|
||
frame.render_widget(paragraph, area);
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::app::{App, OutputSpan};
|
||
use ratatui::Terminal;
|
||
use ratatui::backend::TestBackend;
|
||
|
||
#[test]
|
||
fn styled_runs_payload_renders_each_span_with_its_class_colour() {
|
||
// ADR-0028 §5: an OutputLine carrying a styled-runs
|
||
// payload renders span-by-span, each run's semantic
|
||
// class resolved to a theme colour.
|
||
let theme = Theme::dark();
|
||
let line = OutputLine::styled(
|
||
"SCAN Customers".to_string(),
|
||
OutputKind::System,
|
||
Mode::Simple,
|
||
vec![
|
||
OutputSpan {
|
||
byte_range: (0, 4),
|
||
class: OutputStyleClass::Expensive,
|
||
},
|
||
OutputSpan {
|
||
byte_range: (4, 14),
|
||
class: OutputStyleClass::Neutral,
|
||
},
|
||
],
|
||
);
|
||
let rendered = render_output_line(&line, &theme);
|
||
// tag span + 2 run spans.
|
||
assert_eq!(rendered.spans.len(), 3);
|
||
assert_eq!(rendered.spans[1].content.as_ref(), "SCAN");
|
||
assert_eq!(rendered.spans[1].style.fg, Some(theme.warning));
|
||
assert_eq!(rendered.spans[2].content.as_ref(), " Customers");
|
||
assert_eq!(rendered.spans[2].style.fg, Some(theme.fg));
|
||
}
|
||
|
||
#[test]
|
||
fn hint_class_resolves_to_muted_foreground() {
|
||
// ADR-0038 §4 / §6: the dim style class used for the
|
||
// `Executing SQL:` prefix, the DontConvert caveat, and every
|
||
// category-3 prose note. Pins the `theme.muted` resolution
|
||
// across both palettes.
|
||
for theme in [Theme::dark(), Theme::light()] {
|
||
let style = output_span_style(OutputStyleClass::Hint, &theme);
|
||
assert_eq!(
|
||
style.fg,
|
||
Some(theme.muted),
|
||
"Hint must resolve to theme.muted on {:?} background",
|
||
theme.background,
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn teaching_echo_line_renders_dim_prefix_and_lexed_sql() {
|
||
// ADR-0038 §4 styled-runs polish: a TeachingEcho line is laid
|
||
// out as [tag][dim prefix][lexed SQL spans]. The tag is the green
|
||
// status colour (a `[system]` line — ADR-0037 Amendment, issue
|
||
// #10); the `Executing SQL: ` prefix is `theme.muted`; the SQL
|
||
// portion is re-lexed in advanced mode so it picks up keyword /
|
||
// identifier / literal colours.
|
||
let theme = Theme::dark();
|
||
let line = OutputLine {
|
||
text: format!(
|
||
"{}{}",
|
||
crate::echo::TEACHING_ECHO_LABEL,
|
||
"CREATE TABLE T (id serial PRIMARY KEY)"
|
||
),
|
||
kind: OutputKind::TeachingEcho,
|
||
mode_at_submission: Mode::Advanced,
|
||
styled_runs: None,
|
||
status: None,
|
||
};
|
||
let rendered = render_output_line(&line, &theme);
|
||
// [system] tag, then the dim prefix, then ≥1 SQL spans.
|
||
assert!(rendered.spans.len() >= 3, "tag + prefix + sql: {:?}", rendered.spans);
|
||
assert_eq!(rendered.spans[0].content.as_ref(), "[system] ");
|
||
assert_eq!(rendered.spans[1].content.as_ref(), crate::echo::TEACHING_ECHO_LABEL);
|
||
assert_eq!(
|
||
rendered.spans[1].style.fg,
|
||
Some(theme.muted),
|
||
"prefix is dimmed (theme.muted)",
|
||
);
|
||
// At least one SQL span carries a keyword colour — `CREATE` is
|
||
// the leading keyword and gets `tok_keyword`. Pinning this
|
||
// also confirms the lexer ran in advanced mode (the bare
|
||
// `CREATE` keyword is only highlighted past the entry word in
|
||
// advanced — ADR-0030 §8).
|
||
let has_keyword_span = rendered
|
||
.spans
|
||
.iter()
|
||
.any(|s| s.style.fg == Some(theme.tok_keyword));
|
||
assert!(
|
||
has_keyword_span,
|
||
"expected at least one keyword-coloured SQL span: {:?}",
|
||
rendered.spans
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn plain_text_matches_rendered_line_content() {
|
||
// ADR-0041 drift-lock: `OutputLine::plain_text()` (the `copy`
|
||
// payload) must equal the visible content `render_output_line`
|
||
// produces — the concatenation of its span texts — for every
|
||
// line shape. If the renderer changes how a line reads, this
|
||
// fails until `plain_text` is brought back in step, so the
|
||
// clipboard can never silently diverge from the screen.
|
||
let theme = Theme::dark();
|
||
let label = crate::echo::TEACHING_ECHO_LABEL;
|
||
|
||
let mut pending = OutputLine::echo("create table T", Mode::Simple);
|
||
pending.status = Some(EchoStatus::Pending);
|
||
let mut ok = OutputLine::echo("create table T", Mode::Simple);
|
||
ok.status = Some(EchoStatus::Ok);
|
||
let mut err = OutputLine::echo("insert into T values (1)", Mode::Advanced);
|
||
err.status = Some(EchoStatus::Err);
|
||
|
||
let lines = vec![
|
||
pending,
|
||
ok,
|
||
err,
|
||
OutputLine {
|
||
text: " T".to_string(),
|
||
kind: OutputKind::System,
|
||
mode_at_submission: Mode::Simple,
|
||
styled_runs: None,
|
||
status: None,
|
||
},
|
||
OutputLine {
|
||
text: "no such table".to_string(),
|
||
kind: OutputKind::Error,
|
||
mode_at_submission: Mode::Simple,
|
||
styled_runs: None,
|
||
status: None,
|
||
},
|
||
OutputLine {
|
||
text: format!("{label}CREATE TABLE T (id serial)"),
|
||
kind: OutputKind::TeachingEcho,
|
||
mode_at_submission: Mode::Advanced,
|
||
styled_runs: None,
|
||
status: None,
|
||
},
|
||
OutputLine::styled(
|
||
"SCAN Customers".to_string(),
|
||
OutputKind::System,
|
||
Mode::Simple,
|
||
vec![
|
||
OutputSpan {
|
||
byte_range: (0, 4),
|
||
class: OutputStyleClass::Expensive,
|
||
},
|
||
OutputSpan {
|
||
byte_range: (4, 14),
|
||
class: OutputStyleClass::Neutral,
|
||
},
|
||
],
|
||
),
|
||
];
|
||
|
||
for line in &lines {
|
||
let rendered: String = render_output_line(line, &theme)
|
||
.spans
|
||
.iter()
|
||
.map(|s| s.content.as_ref())
|
||
.collect();
|
||
assert_eq!(
|
||
line.plain_text(),
|
||
rendered,
|
||
"plain_text drifted from render for a {:?} line",
|
||
line.kind,
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn category_three_prose_line_renders_all_dim() {
|
||
// ADR-0038 §6: the existing illuminating client_side notes and
|
||
// the new --dont-convert caveat are de-emphasised prose. A
|
||
// styled-runs payload with a single Hint span over the whole
|
||
// text yields one dim body span (plus the [system] tag).
|
||
let theme = Theme::dark();
|
||
let text = "[client-side] 5 row(s) were transformed".to_string();
|
||
let line = OutputLine::styled(
|
||
text.clone(),
|
||
OutputKind::System,
|
||
Mode::Advanced,
|
||
vec![OutputSpan {
|
||
byte_range: (0, text.len()),
|
||
class: OutputStyleClass::Hint,
|
||
}],
|
||
);
|
||
let rendered = render_output_line(&line, &theme);
|
||
assert_eq!(rendered.spans.len(), 2, "tag + one Hint span");
|
||
assert_eq!(rendered.spans[0].content.as_ref(), "[system] ");
|
||
assert_eq!(rendered.spans[1].content.as_ref(), text.as_str());
|
||
assert_eq!(
|
||
rendered.spans[1].style.fg,
|
||
Some(theme.muted),
|
||
"the whole prose line is dim",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn candidate_line_colours_mixed_mode_continuations() {
|
||
// ADR-0035 §4i (e): when a shared entry word's completions mix
|
||
// mode-classes, Advanced (SQL-only) → mode_advanced, Simple
|
||
// (DSL-only) → mode_simple, Both → token-kind colour. Spans
|
||
// alternate candidate / separator, so candidate `i` is span `2*i`.
|
||
use crate::completion::{Candidate, CandidateKind, ModeClass};
|
||
let theme = Theme::dark();
|
||
let items = vec![
|
||
Candidate { text: "table".into(), kind: CandidateKind::Keyword, mode: ModeClass::Both },
|
||
Candidate { text: "index".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
|
||
Candidate { text: "relationship".into(), kind: CandidateKind::Keyword, mode: ModeClass::Simple },
|
||
];
|
||
let line = render_candidate_line(&items, None, 100, &theme);
|
||
assert_eq!(line.spans[0].content.as_ref(), "table");
|
||
assert_eq!(line.spans[0].style.fg, Some(theme.tok_keyword), "Both keeps the kind colour");
|
||
assert_eq!(line.spans[2].content.as_ref(), "index");
|
||
assert_eq!(line.spans[2].style.fg, Some(theme.mode_advanced), "Advanced → advanced colour");
|
||
assert_eq!(line.spans[4].content.as_ref(), "relationship");
|
||
assert_eq!(line.spans[4].style.fg, Some(theme.mode_simple), "Simple → simple colour");
|
||
}
|
||
|
||
#[test]
|
||
fn candidate_line_single_mode_keeps_kind_colour() {
|
||
// The mode tint applies ONLY when the list mixes classes. An
|
||
// all-one-mode list (the common case, e.g. deep inside a SQL
|
||
// statement) keeps the token-kind colours — no redundant tint.
|
||
use crate::completion::{Candidate, CandidateKind, ModeClass};
|
||
let theme = Theme::dark();
|
||
let items = vec![
|
||
Candidate { text: "values".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
|
||
Candidate { text: "select".into(), kind: CandidateKind::Keyword, mode: ModeClass::Advanced },
|
||
];
|
||
let line = render_candidate_line(&items, None, 100, &theme);
|
||
assert_eq!(
|
||
line.spans[0].style.fg,
|
||
Some(theme.tok_keyword),
|
||
"an all-Advanced list is not tinted (would be redundant noise)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn system_line_renders_green_tag_and_neutral_body() {
|
||
// ADR-0037 Amendment (issue #10): the status-coloured-tag model.
|
||
// A `[system]` line's TAG carries the green status colour; its
|
||
// BODY is neutral `theme.fg`, not flooded green. The mode tint
|
||
// no longer leaks onto system lines (it belongs to the echo line
|
||
// alone — ADR-0037's stated purpose). `mode_at_submission` is
|
||
// Advanced here precisely to prove the tag is NOT the mode tint.
|
||
let theme = Theme::dark();
|
||
let line = OutputLine {
|
||
text: "plain system line".to_string(),
|
||
kind: OutputKind::System,
|
||
mode_at_submission: Mode::Advanced,
|
||
styled_runs: None,
|
||
status: None,
|
||
};
|
||
let rendered = render_output_line(&line, &theme);
|
||
// tag span + single whole-line body span.
|
||
assert_eq!(rendered.spans.len(), 2);
|
||
assert_eq!(rendered.spans[0].content.as_ref(), "[system] ");
|
||
assert_eq!(
|
||
rendered.spans[0].style.fg,
|
||
Some(theme.system),
|
||
"the [system] tag is green (status), not the mode tint",
|
||
);
|
||
assert_eq!(rendered.spans[1].content.as_ref(), "plain system line");
|
||
assert_eq!(
|
||
rendered.spans[1].style.fg,
|
||
Some(theme.fg),
|
||
"the system body is neutral, not flooded green",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn error_line_renders_red_tag_and_bold_neutral_body() {
|
||
// ADR-0037 Amendment (issue #10): the `[error]` TAG carries the
|
||
// red status colour (the leftmost glyph the eye lands on), while
|
||
// the BODY renders in neutral `theme.fg` + BOLD (rustc-style:
|
||
// severity-coloured label, readable bold message). A wall of red
|
||
// prose is hard to read; the red lives on the tag instead. The
|
||
// mode tint does not leak onto error lines.
|
||
let theme = Theme::dark();
|
||
let line = OutputLine {
|
||
text: "no such column: agx".to_string(),
|
||
kind: OutputKind::Error,
|
||
mode_at_submission: Mode::Advanced,
|
||
styled_runs: None,
|
||
status: None,
|
||
};
|
||
let rendered = render_output_line(&line, &theme);
|
||
assert_eq!(rendered.spans.len(), 2);
|
||
assert_eq!(rendered.spans[0].content.as_ref(), "[error] ");
|
||
assert_eq!(
|
||
rendered.spans[0].style.fg,
|
||
Some(theme.error),
|
||
"the [error] tag is red (status), not the mode tint",
|
||
);
|
||
assert_eq!(rendered.spans[1].content.as_ref(), "no such column: agx");
|
||
assert_eq!(
|
||
rendered.spans[1].style.fg,
|
||
Some(theme.fg),
|
||
"the error body is neutral fg, not flooded red",
|
||
);
|
||
assert!(
|
||
rendered.spans[1].style.add_modifier.contains(Modifier::BOLD),
|
||
"the error body is bold for weight without the red-wall readability cost",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn echo_tag_keeps_the_mode_tint_not_a_status_colour() {
|
||
// The echo line is the sole exception to the status-tag model:
|
||
// its tag's whole job is to label the submission mode (ADR-0037),
|
||
// so it keeps the mode tint. Per-command success rides the trailing
|
||
// ✓/✗ marker (ADR-0040), not the tag. Locked for both modes so a
|
||
// future refactor of `tag_style` cannot regress the echo.
|
||
let theme = Theme::dark();
|
||
for (mode, want) in [
|
||
(Mode::Simple, theme.mode_simple),
|
||
(Mode::Advanced, theme.mode_advanced),
|
||
] {
|
||
let line = OutputLine {
|
||
text: format!("{}create table T", crate::dsl::ECHO_PREFIX),
|
||
kind: OutputKind::Echo,
|
||
mode_at_submission: mode,
|
||
styled_runs: None,
|
||
status: None,
|
||
};
|
||
let rendered = render_output_line(&line, &theme);
|
||
assert_eq!(
|
||
rendered.spans[0].style.fg,
|
||
Some(want),
|
||
"echo tag must stay the {mode:?} mode tint",
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn teaching_echo_tag_is_green_like_other_system_lines() {
|
||
// A TeachingEcho is a `[system]`-tagged line, so under the
|
||
// status-tag model its tag is green, not the mode tint. The dim
|
||
// prefix + lexed-SQL body are unchanged (covered separately).
|
||
let theme = Theme::dark();
|
||
let line = OutputLine {
|
||
text: format!(
|
||
"{}{}",
|
||
crate::echo::TEACHING_ECHO_LABEL,
|
||
"CREATE TABLE T (id serial PRIMARY KEY)"
|
||
),
|
||
kind: OutputKind::TeachingEcho,
|
||
mode_at_submission: Mode::Advanced,
|
||
styled_runs: None,
|
||
status: None,
|
||
};
|
||
let rendered = render_output_line(&line, &theme);
|
||
assert_eq!(rendered.spans[0].content.as_ref(), "[system] ");
|
||
assert_eq!(
|
||
rendered.spans[0].style.fg,
|
||
Some(theme.system),
|
||
"the teaching-echo tag is green (a [system] line), not the mode tint",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn error_and_system_tags_are_distinguishable_in_both_themes() {
|
||
// Issue #10 regression guard, stated directly: the `[error]` and
|
||
// `[system]` tags must NOT render in the same colour, and neither
|
||
// may collapse to the mode tint. Asserted on both palettes — the
|
||
// render logic is theme-agnostic, but locking both proves the
|
||
// colours themselves stay distinct end to end.
|
||
for theme in [Theme::dark(), Theme::light()] {
|
||
let tag_fg = |kind| {
|
||
render_output_line(
|
||
&OutputLine {
|
||
text: "x".to_string(),
|
||
kind,
|
||
mode_at_submission: Mode::Advanced,
|
||
styled_runs: None,
|
||
status: None,
|
||
},
|
||
&theme,
|
||
)
|
||
.spans[0]
|
||
.style
|
||
.fg
|
||
};
|
||
let error_tag = tag_fg(OutputKind::Error);
|
||
let system_tag = tag_fg(OutputKind::System);
|
||
assert_ne!(
|
||
error_tag, system_tag,
|
||
"[error] and [system] tags must differ ({:?})",
|
||
theme.background,
|
||
);
|
||
assert_ne!(
|
||
error_tag,
|
||
Some(theme.mode_advanced),
|
||
"the error tag must not be the mode tint ({:?})",
|
||
theme.background,
|
||
);
|
||
assert_ne!(
|
||
system_tag,
|
||
Some(theme.mode_advanced),
|
||
"the system tag must not be the mode tint ({:?})",
|
||
theme.background,
|
||
);
|
||
}
|
||
}
|
||
|
||
fn render_to_string(app: &mut App, theme: &Theme, width: u16, height: u16) -> String {
|
||
// Snapshot tests need realistic state, not the boot
|
||
// fallback "(no project)" — every real session has a
|
||
// project. Set a representative name unless the test
|
||
// already set one.
|
||
if app.project_name.is_none() {
|
||
app.project_name = Some("Term Planner".to_string());
|
||
}
|
||
let backend = TestBackend::new(width, height);
|
||
let mut terminal = Terminal::new(backend).expect("create terminal");
|
||
terminal
|
||
.draw(|f| render(app, theme, f))
|
||
.expect("draw frame");
|
||
let buffer = terminal.backend().buffer().clone();
|
||
let mut out = String::new();
|
||
for y in 0..buffer.area.height {
|
||
for x in 0..buffer.area.width {
|
||
out.push_str(buffer[(x, y)].symbol());
|
||
}
|
||
out.push('\n');
|
||
}
|
||
out
|
||
}
|
||
|
||
// ---- Issue #12: long hints no longer clipped to one row -----
|
||
|
||
const LONG_HINT: &str = "(id, created_at auto-generated — skipped here; list columns explicitly, e.g. `insert into T (...) values (...)`, to set it.)";
|
||
|
||
#[test]
|
||
fn long_prose_hint_shows_tail_across_multiple_rows() {
|
||
// A multi-row hint panel (here 2 rows at a compact 80×20) shows
|
||
// the hint's useful tail rather than clipping it to one row.
|
||
// (Pre-#12 the panel was a fixed 1 row; ADR-0046 keeps it
|
||
// multi-row but now sizes by geometry, not content.)
|
||
let mut app = App::new();
|
||
app.hint = Some(LONG_HINT.to_string());
|
||
let theme = Theme::dark();
|
||
let out = render_to_string(&mut app, &theme, 80, 20);
|
||
assert!(
|
||
out.contains("list columns explicitly"),
|
||
"the hint tail must be visible, not clipped:\n{out}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn hint_panel_height_is_fixed_by_geometry_not_content() {
|
||
// ADR-0046 DA1/DA2 (#20): the panel no longer shrinks to a
|
||
// short hint (the issue #12 "reclaim" behaviour is deliberately
|
||
// reversed). At a compact (height < 40) terminal it is a fixed
|
||
// 2 content rows whether the hint is short or long, so it never
|
||
// resizes mid-typing and shoves the input/output panels.
|
||
let theme = Theme::dark();
|
||
|
||
let mut short = App::new();
|
||
short.hint = Some("Type a command".to_string());
|
||
let short_out = render_to_string(&mut short, &theme, 80, 20);
|
||
assert!(
|
||
short_out.lines().any(|l| l.contains("Type a command")),
|
||
"short hint visible:\n{short_out}"
|
||
);
|
||
|
||
let mut long = App::new();
|
||
long.hint = Some(LONG_HINT.to_string());
|
||
let long_out = render_to_string(&mut long, &theme, 80, 20);
|
||
|
||
assert_eq!(
|
||
hint_content_rows(&short_out),
|
||
2,
|
||
"compact terminal fixes the hint at 2 rows:\n{short_out}"
|
||
);
|
||
assert_eq!(
|
||
hint_content_rows(&short_out),
|
||
hint_content_rows(&long_out),
|
||
"the hint panel height must not differ between a short and a \
|
||
long hint at the same terminal size (#20 anti-jump):\n\
|
||
short:\n{short_out}\nlong:\n{long_out}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn narrow_comfortable_terminal_allows_a_third_hint_row() {
|
||
// ADR-0046 DA2: a 3rd hint row appears only on a comfortable
|
||
// (height ≥ 40) terminal whose hint column is narrow enough
|
||
// (inner < 54) to wrap the longest hint past two lines; the
|
||
// ellipsis backstop still caps it at MAX_HINT_ROWS. (At a
|
||
// compact height the same hint is held to 2 rows.)
|
||
let mut app = App::new();
|
||
app.hint = Some(LONG_HINT.to_string());
|
||
let theme = Theme::dark();
|
||
let out = render_to_string(&mut app, &theme, 44, 50);
|
||
assert_eq!(
|
||
hint_content_rows(&out),
|
||
MAX_HINT_ROWS,
|
||
"narrow + tall terminal caps the long hint at MAX_HINT_ROWS \
|
||
content rows:\n{out}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn panel_heights_are_geometry_driven() {
|
||
// ADR-0046 DA1/DA2/DA4: the pure helper the renderer and these
|
||
// tests share. Height picks the bucket (input 1→2, hint floor);
|
||
// width gates the hint's 3rd row; a tiny terminal degrades hint
|
||
// then input to protect output `Min(5)`.
|
||
let at = |w: u16, h: u16| panel_heights(Rect::new(0, 0, w, h));
|
||
// Compact height → input 1, hint 2, regardless of width.
|
||
assert_eq!(at(90, 25), (1, 2));
|
||
assert_eq!(at(40, 25), (1, 2));
|
||
// Comfortable height → input 2; hint 2 when wide (inner ≥ 54).
|
||
assert_eq!(at(90, 45), (2, 2));
|
||
assert_eq!(at(56, 45), (2, 2)); // inner == 54 is "wide enough"
|
||
// Comfortable + narrow (inner < 54) → hint 3.
|
||
assert_eq!(at(55, 45), (2, 3)); // inner == 53
|
||
assert_eq!(at(50, 45), (2, 3));
|
||
// Very short terminal degrades hint first, then input, to keep
|
||
// the output panel's Min(5).
|
||
assert_eq!(at(90, 11), (1, 1));
|
||
}
|
||
|
||
// ---- ADR-0046 DA3: input horizontal scroll -------------------
|
||
|
||
#[test]
|
||
fn input_scroll_offset_keeps_the_cursor_in_view() {
|
||
// Fits (line shorter than the viewport) → never scrolls.
|
||
assert_eq!(input_scroll_offset(10, 10, 20, 0), 0);
|
||
assert_eq!(input_scroll_offset(19, 19, 20, 5), 0);
|
||
// Overflow, cursor at end → window shows the tail, reserving the
|
||
// two marker columns (eff = tw - 2 = 18): 50 + 1 - 18 = 33.
|
||
assert_eq!(input_scroll_offset(50, 50, 20, 0), 33);
|
||
// Cursor jumped left of the window → scroll left to the cursor.
|
||
assert_eq!(input_scroll_offset(50, 5, 20, 33), 5);
|
||
// Cursor still inside the current window → stable, no change.
|
||
assert_eq!(input_scroll_offset(50, 40, 20, 33), 33);
|
||
// Never scroll past the cursor-at-end cell, even from a stale
|
||
// over-large offset.
|
||
assert_eq!(input_scroll_offset(50, 50, 20, 999), 33);
|
||
}
|
||
|
||
const LONG_INPUT: &str =
|
||
"select * from Customers where id = 12345 and name = 'Alice Wonderland'";
|
||
|
||
#[test]
|
||
fn long_input_scrolls_to_keep_the_tail_and_cursor_visible() {
|
||
// #23: a command longer than the input field must not clip the
|
||
// cursor off the right edge — it scrolls so the tail is visible,
|
||
// with a `<` marker for the hidden head.
|
||
let mut app = App::new();
|
||
app.input.push_str(LONG_INPUT);
|
||
app.input_cursor = app.input.len();
|
||
let theme = Theme::dark();
|
||
// Narrow (sidebar hidden, DB1) so the line overflows the field.
|
||
let out = render_to_string(&mut app, &theme, 60, 24);
|
||
assert!(
|
||
out.contains("'Alice Wonderland'"),
|
||
"the tail around the cursor must be visible:\n{out}"
|
||
);
|
||
assert!(
|
||
!out.lines().any(|l| l.contains("select * from Customers where")),
|
||
"the head must be scrolled off:\n{out}"
|
||
);
|
||
assert!(out.contains('<'), "a left scroll marker signals the hidden head:\n{out}");
|
||
}
|
||
|
||
#[test]
|
||
fn input_at_home_shows_the_head_with_a_right_marker() {
|
||
// With the cursor at Home, the head is visible and a `>` marker
|
||
// signals the hidden tail.
|
||
let mut app = App::new();
|
||
app.input.push_str(LONG_INPUT);
|
||
app.input_cursor = 0;
|
||
let theme = Theme::dark();
|
||
// Narrow (sidebar hidden, DB1) so the line overflows the field.
|
||
let out = render_to_string(&mut app, &theme, 60, 24);
|
||
assert!(out.contains("select * from"), "head visible at Home:\n{out}");
|
||
assert!(out.contains('>'), "a right scroll marker signals the hidden tail:\n{out}");
|
||
assert!(!out.contains("Wonderland"), "the tail must be scrolled off:\n{out}");
|
||
}
|
||
|
||
// ---- ADR-0046 DA4: two-row input on tall terminals -----------
|
||
|
||
#[test]
|
||
fn comfortable_terminal_wraps_input_across_two_rows() {
|
||
// On a tall (height ≥ 40) terminal the input shows two rows, so
|
||
// a medium command wraps instead of scrolling — the whole
|
||
// command is visible at once, head above tail.
|
||
let mut app = App::new();
|
||
app.input.push_str(LONG_INPUT);
|
||
app.input_cursor = app.input.len();
|
||
let theme = Theme::dark();
|
||
// Narrow (sidebar hidden, DB1) so the line wraps across the two
|
||
// rows rather than fitting on the first.
|
||
let out = render_to_string(&mut app, &theme, 60, 44);
|
||
let head = out
|
||
.lines()
|
||
.position(|l| l.contains("select * from Customers"));
|
||
let tail = out.lines().position(|l| l.contains("'Alice Wonderland'"));
|
||
assert!(
|
||
head.is_some() && tail.is_some(),
|
||
"both head and tail are visible across two rows:\n{out}"
|
||
);
|
||
assert!(
|
||
tail.unwrap() > head.unwrap(),
|
||
"the tail wraps onto a row below the head:\n{out}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn two_row_input_scrolls_when_it_overflows_both_rows() {
|
||
// A narrow-but-tall terminal: two rows, but the line is longer
|
||
// than both can hold, so it scrolls to keep the tail/cursor
|
||
// visible with a `<` marker for the hidden head.
|
||
let mut app = App::new();
|
||
app.input.push_str(LONG_INPUT);
|
||
app.input_cursor = app.input.len();
|
||
let theme = Theme::dark();
|
||
// Very narrow + tall: two rows, but the line exceeds both.
|
||
let out = render_to_string(&mut app, &theme, 38, 44);
|
||
assert!(out.contains("Wonderland"), "the tail/cursor stays visible:\n{out}");
|
||
assert!(out.contains('<'), "a left marker signals the hidden head:\n{out}");
|
||
}
|
||
|
||
#[test]
|
||
fn two_row_input_keeps_the_indicator_on_the_first_row() {
|
||
// ADR-0046 DA4 / ADR-0027: the [ERR]/[WRN] indicator stays
|
||
// anchored to the *first* input row (whose 6-column reserve it
|
||
// occupies); the wrapped tail on the second row is untouched.
|
||
let mut app = App::new();
|
||
app.input.push_str(LONG_INPUT);
|
||
app.input_cursor = app.input.len();
|
||
app.input_indicator = Some(crate::dsl::walker::Severity::Error);
|
||
let theme = Theme::dark();
|
||
// Narrow (sidebar hidden, DB1) so the line wraps across two rows.
|
||
let out = render_to_string(&mut app, &theme, 60, 44);
|
||
let err_line = out
|
||
.lines()
|
||
.position(|l| l.contains("[ERR]"))
|
||
.expect("indicator visible");
|
||
let head_line = out
|
||
.lines()
|
||
.position(|l| l.contains("select * from Customers"))
|
||
.expect("head visible");
|
||
assert_eq!(
|
||
err_line, head_line,
|
||
"the indicator shares the first input row with the head:\n{out}"
|
||
);
|
||
assert!(
|
||
out.contains("'Alice Wonderland'"),
|
||
"the wrapped tail on the second row is intact:\n{out}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn two_row_input_snapshot() {
|
||
// Locks the DA4 two-row layout: head on the first (indicator-
|
||
// reserved) row, tail on the full-width second row.
|
||
let mut app = App::new();
|
||
app.input.push_str(LONG_INPUT);
|
||
app.input_cursor = app.input.len();
|
||
let theme = Theme::dark();
|
||
// Narrow (sidebar hidden, DB1) so the command wraps across rows.
|
||
let snapshot = render_to_string(&mut app, &theme, 60, 44);
|
||
insta::assert_snapshot!("two_row_input_dark", snapshot);
|
||
}
|
||
|
||
/// Count the content rows inside the Hint panel of a rendered
|
||
/// screen: the rows between the `╭ Hint …` title border and the
|
||
/// next `╰…╯` bottom border.
|
||
fn hint_content_rows(out: &str) -> usize {
|
||
let lines: Vec<&str> = out.lines().collect();
|
||
let top = lines
|
||
.iter()
|
||
.position(|l| l.contains("Hint") && l.contains('╭'))
|
||
.expect("hint title border present");
|
||
// Rows strictly between the title border and the next
|
||
// bottom border == the content-row count.
|
||
lines[top + 1..]
|
||
.iter()
|
||
.position(|l| l.contains('╰'))
|
||
.expect("hint bottom border present")
|
||
}
|
||
|
||
#[test]
|
||
fn clamp_wrapped_truncates_with_ellipsis_past_max() {
|
||
// ≤ max rows: untouched.
|
||
let two = clamp_wrapped("alpha beta gamma delta", 11, 3);
|
||
assert_eq!(two, vec!["alpha beta", "gamma delta"]);
|
||
// > max rows: clamp to max, last row ends with an ellipsis,
|
||
// and every row stays within the width.
|
||
let many = clamp_wrapped(
|
||
"alpha beta gamma delta epsilon zeta eta theta iota",
|
||
11,
|
||
3,
|
||
);
|
||
assert_eq!(many.len(), 3);
|
||
assert!(many[2].ends_with('…'), "last row ellipsized: {many:?}");
|
||
for row in &many {
|
||
assert!(row.chars().count() <= 11, "row within width: {row:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn dark_theme_default_view_snapshot() {
|
||
let mut app = App::new();
|
||
let theme = Theme::dark();
|
||
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||
insta::assert_snapshot!("default_simple_dark", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn light_theme_default_view_snapshot() {
|
||
let mut app = App::new();
|
||
let theme = Theme::light();
|
||
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||
insta::assert_snapshot!("default_simple_light", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn advanced_mode_default_view_snapshot() {
|
||
let mut app = App::new();
|
||
app.mode = Mode::Advanced;
|
||
let theme = Theme::dark();
|
||
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||
insta::assert_snapshot!("default_advanced_dark", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn advanced_mode_hint_panel_surfaces_sql_candidates() {
|
||
// Regression reproduction (ADR-0022 Amendment 1): in
|
||
// advanced mode the hint panel must surface ambient
|
||
// assistance for SQL — here the FROM-slot table candidate
|
||
// `Customers` — not the empty placeholder. Before the fix
|
||
// `render_hint_panel` returned `None` for advanced mode and
|
||
// the hint resolver/completion ran in simple mode, so a SQL
|
||
// statement got the "this is SQL" gate and no candidates.
|
||
let mut app = App::new();
|
||
app.mode = Mode::Advanced;
|
||
app.schema_cache.tables.push("Customers".to_string());
|
||
app.input.push_str("select * from ");
|
||
app.input_cursor = app.input.len();
|
||
let theme = Theme::dark();
|
||
let rendered = render_to_string(&mut app, &theme, 80, 24);
|
||
assert!(
|
||
rendered.contains("Customers"),
|
||
"advanced-mode hint panel should surface the FROM-slot \
|
||
candidate `Customers`; got:\n{rendered}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn highlighted_input_all_token_classes_snapshot() {
|
||
// ADR-0022 stage 2: representative input that exercises
|
||
// every token class — keyword (insert / into / values
|
||
// / null), identifier (T), number (1), string ('hi'),
|
||
// punct (parens, comma), flag (--all-rows), lex error
|
||
// ($ at the end). The snapshot captures the rendered
|
||
// text symbols only — `render_to_string` does not record
|
||
// ratatui style — so this test is a regression net for
|
||
// text layout, not for colour mappings. Colour mappings
|
||
// are unit-tested in `input_render::tests`.
|
||
let mut app = App::new();
|
||
app.input
|
||
.push_str("insert into T values (1, 'hi', null) --all-rows $");
|
||
app.input_cursor = app.input.len();
|
||
let theme = Theme::dark();
|
||
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||
insta::assert_snapshot!("highlighted_input_all_token_classes_dark", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn one_shot_advanced_prompt_snapshot() {
|
||
// Typing `:sel` in simple mode should flip the input panel
|
||
// label to `Advanced:` while the persistent mode stays simple.
|
||
// The visible input includes the auto-inserted space after `:`.
|
||
// With the cursor after `sel` (ADR-0022 Amendment 1), the hint
|
||
// panel now offers the advanced `select` completion — the `:`
|
||
// sigil is stripped before the ambient walk.
|
||
let mut app = App::new();
|
||
app.input.push_str(": sel");
|
||
app.input_cursor = app.input.len();
|
||
let theme = Theme::dark();
|
||
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||
insta::assert_snapshot!("one_shot_advanced_dark", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn rebuild_confirm_modal_snapshot() {
|
||
use crate::app::{Modal, RebuildConfirmModal};
|
||
let mut app = App::new();
|
||
app.modal = Some(Modal::RebuildConfirm(RebuildConfirmModal {
|
||
summary: "3 tables and 47 rows will be reconstructed; \
|
||
the existing playground.db will be replaced"
|
||
.to_string(),
|
||
}));
|
||
let theme = Theme::dark();
|
||
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||
insta::assert_snapshot!("rebuild_confirm_modal_dark", snapshot);
|
||
}
|
||
|
||
// ---- ADR-0040: echo completion marker -----------------------
|
||
|
||
#[test]
|
||
fn echo_renders_running_then_marker_per_status() {
|
||
use crate::app::EchoStatus;
|
||
let mut app = App::new();
|
||
// Pending → `running: <input>` (current look).
|
||
app.output
|
||
.push_back(OutputLine::echo("drop table Orders", Mode::Advanced));
|
||
// Ok → `<input> ✓`, no `running:`.
|
||
let mut ok = OutputLine::echo("create table T with pk", Mode::Simple);
|
||
ok.status = Some(EchoStatus::Ok);
|
||
app.output.push_back(ok);
|
||
// Err → `<input> ✗`, no `running:`.
|
||
let mut err = OutputLine::echo("insert into T values (1)", Mode::Advanced);
|
||
err.status = Some(EchoStatus::Err);
|
||
app.output.push_back(err);
|
||
|
||
let out = render_to_string(&mut app, &Theme::dark(), 100, 20);
|
||
assert!(out.contains("running: drop table Orders"), "pending keeps running::\n{out}");
|
||
assert!(out.contains("create table T with pk ✓"), "ok shows ✓:\n{out}");
|
||
assert!(out.contains("insert into T values (1) ✗"), "err shows ✗:\n{out}");
|
||
assert!(
|
||
!out.contains("running: create table"),
|
||
"a completed echo drops the running: prefix:\n{out}"
|
||
);
|
||
}
|
||
|
||
// ---- Issue #13: undo confirm dialog -------------------------
|
||
|
||
#[test]
|
||
fn format_local_datetime_renders_fixed_human_form() {
|
||
// Deterministic: a fixed offset (not Local) so the output
|
||
// does not depend on the test machine's timezone.
|
||
let dt = chrono::DateTime::parse_from_rfc3339("2026-05-24T11:05:00+02:00")
|
||
.expect("valid rfc3339");
|
||
assert_eq!(format_local_datetime(dt), "24 May 2026, 11:05");
|
||
// Single-digit day: no leading zero on the day, but zero-
|
||
// padded hour.
|
||
let dt = chrono::DateTime::parse_from_rfc3339("2026-05-04T09:05:00+00:00")
|
||
.expect("valid rfc3339");
|
||
assert_eq!(format_local_datetime(dt), "4 May 2026, 09:05");
|
||
}
|
||
|
||
#[test]
|
||
fn format_snapshot_timestamp_drops_machine_syntax() {
|
||
// The stored UTC string is reformatted: no 'T'/'Z' machine
|
||
// syntax survives, and the year is preserved. (Day/month
|
||
// can shift across the date line depending on local TZ, so
|
||
// we assert only the stable parts.)
|
||
let out = format_snapshot_timestamp("2026-07-24T10:00:00Z");
|
||
assert!(!out.contains('T'), "no date/time 'T' separator: {out}");
|
||
assert!(!out.contains('Z'), "no UTC 'Z' suffix: {out}");
|
||
assert!(out.contains("2026"), "year preserved: {out}");
|
||
}
|
||
|
||
#[test]
|
||
fn format_snapshot_timestamp_falls_back_on_garbage() {
|
||
assert_eq!(format_snapshot_timestamp("not a timestamp"), "not a timestamp");
|
||
}
|
||
|
||
#[test]
|
||
fn undo_dialog_width_grows_to_fit_and_clamps() {
|
||
// Grows to the widest line + 4 (borders + padding).
|
||
assert_eq!(undo_dialog_width([50usize], 120), 54);
|
||
// Floors at MIN (34) for tiny content.
|
||
assert_eq!(undo_dialog_width([3usize], 120), 34);
|
||
// Caps at MAX (100) for absurdly long content.
|
||
assert_eq!(undo_dialog_width([400usize], 120), 100);
|
||
// Never exceeds the available area, and never panics when
|
||
// the area is narrower than MIN.
|
||
assert_eq!(undo_dialog_width([50usize], 40), 40);
|
||
assert_eq!(undo_dialog_width([50usize], 10), 10);
|
||
}
|
||
|
||
#[test]
|
||
fn undo_modal_command_does_not_wrap_on_wide_terminal() {
|
||
use crate::app::{Modal, UndoConfirmModal};
|
||
let mut app = App::new();
|
||
app.modal = Some(Modal::UndoConfirm(UndoConfirmModal {
|
||
command: "insert into Customers values (1, 'Oliver Sturm')".to_string(),
|
||
timestamp: "2026-05-24T10:00:00Z".to_string(),
|
||
is_redo: false,
|
||
}));
|
||
let theme = Theme::dark();
|
||
let out = render_to_string(&mut app, &theme, 120, 30);
|
||
assert!(
|
||
out.lines().any(|l| l.contains(
|
||
"This will undo: insert into Customers values (1, 'Oliver Sturm')"
|
||
)),
|
||
"command must sit on one row on a wide terminal:\n{out}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn undo_modal_uses_capitalized_labels_and_formatted_time() {
|
||
use crate::app::{Modal, UndoConfirmModal};
|
||
let mut app = App::new();
|
||
app.modal = Some(Modal::UndoConfirm(UndoConfirmModal {
|
||
command: "delete from T where id = 1".to_string(),
|
||
timestamp: "2026-05-24T10:00:00Z".to_string(),
|
||
is_redo: false,
|
||
}));
|
||
let theme = Theme::dark();
|
||
let out = render_to_string(&mut app, &theme, 120, 30);
|
||
assert!(out.contains("Snapshot taken"), "capitalized Snapshot:\n{out}");
|
||
assert!(out.contains("[Y] Yes"), "capitalized Yes:\n{out}");
|
||
assert!(out.contains("[N] No"), "capitalized No:\n{out}");
|
||
assert!(
|
||
!out.contains("2026-05-24T10:00:00Z"),
|
||
"raw ISO timestamp must not appear:\n{out}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn populated_with_table_snapshot() {
|
||
// Items panel lists tables; output panel shows the
|
||
// structure of the current table.
|
||
use crate::app::{OutputKind, OutputLine};
|
||
use crate::db::{ColumnDescription, TableDescription};
|
||
|
||
let mut app = App::new();
|
||
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
|
||
use crate::dsl::Type;
|
||
let desc = TableDescription {
|
||
name: "Customers".to_string(),
|
||
columns: vec![
|
||
ColumnDescription {
|
||
name: "id".to_string(),
|
||
user_type: Some(Type::Serial),
|
||
sqlite_type: "INTEGER".to_string(),
|
||
notnull: false,
|
||
primary_key: true,
|
||
unique: false,
|
||
default: None,
|
||
check: None,
|
||
},
|
||
ColumnDescription {
|
||
name: "Name".to_string(),
|
||
user_type: Some(Type::Text),
|
||
sqlite_type: "TEXT".to_string(),
|
||
notnull: false,
|
||
primary_key: false,
|
||
unique: false,
|
||
default: None,
|
||
check: None,
|
||
},
|
||
],
|
||
outbound_relationships: Vec::new(),
|
||
inbound_relationships: Vec::new(),
|
||
indexes: Vec::new(),
|
||
unique_constraints: Vec::new(),
|
||
check_constraints: Vec::new(),
|
||
};
|
||
app.current_table = Some(desc);
|
||
// Mirror what the App writes when a DSL command succeeds
|
||
// (ADR-0040): the command's echo line resolves to a ✓ marker —
|
||
// there is no separate `[ok]` summary line.
|
||
let mut echo = OutputLine::echo("create table Customers", Mode::Simple);
|
||
echo.status = Some(crate::app::EchoStatus::Ok);
|
||
app.output.push_back(echo);
|
||
app.output.push_back(OutputLine {
|
||
text: " Customers".to_string(),
|
||
kind: OutputKind::System,
|
||
mode_at_submission: Mode::Simple,
|
||
styled_runs: None,
|
||
status: None,
|
||
});
|
||
app.output.push_back(OutputLine {
|
||
text: " id serial [PK]".to_string(),
|
||
kind: OutputKind::System,
|
||
mode_at_submission: Mode::Simple,
|
||
styled_runs: None,
|
||
status: None,
|
||
});
|
||
app.output.push_back(OutputLine {
|
||
text: " Name text".to_string(),
|
||
kind: OutputKind::System,
|
||
mode_at_submission: Mode::Simple,
|
||
styled_runs: None,
|
||
status: None,
|
||
});
|
||
|
||
let theme = Theme::dark();
|
||
// Width > SIDEBAR_MIN_WIDTH so the sidebar (tables list) shows
|
||
// alongside the output panel (DB1).
|
||
let snapshot = render_to_string(&mut app, &theme, 110, 24);
|
||
insta::assert_snapshot!("populated_with_table_dark", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn items_panel_nests_indexes_under_their_table() {
|
||
// S2 (ADR-0025): the items panel renders each table
|
||
// with its index names indented beneath it. A UNIQUE index is
|
||
// marked `[unique]` (ADR-0035 §4d).
|
||
use crate::completion::IndexEntry;
|
||
let mut app = App::new();
|
||
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
|
||
app.schema_cache.table_indexes.insert(
|
||
"Customers".to_string(),
|
||
vec![
|
||
IndexEntry { name: "idx_email".to_string(), unique: false },
|
||
IndexEntry { name: "uidx_login".to_string(), unique: true },
|
||
],
|
||
);
|
||
let theme = Theme::dark();
|
||
// Width > SIDEBAR_MIN_WIDTH so the sidebar is shown (DB1).
|
||
let out = render_to_string(&mut app, &theme, 110, 24);
|
||
assert!(out.contains("Customers"), "table listed:\n{out}");
|
||
assert!(out.contains("Orders"), "table listed:\n{out}");
|
||
assert!(out.contains("idx_email"), "index nested in panel:\n{out}");
|
||
assert!(out.contains("uidx_login [unique]"), "unique index marked:\n{out}");
|
||
}
|
||
|
||
#[test]
|
||
fn sidebar_visible_is_width_gated() {
|
||
// ADR-0046 DB1: shown above SIDEBAR_MIN_WIDTH, hidden at/below.
|
||
assert!(!sidebar_visible(80));
|
||
assert!(!sidebar_visible(90)); // the 90-col screencast: hidden
|
||
assert!(sidebar_visible(91));
|
||
assert!(sidebar_visible(120));
|
||
}
|
||
|
||
#[test]
|
||
fn sidebar_hidden_at_or_below_threshold_width() {
|
||
// The Tables panel disappears at a narrow width (the output
|
||
// panel then spans the full width) and returns when wide.
|
||
let mut app = App::new();
|
||
app.tables = vec!["Customers".to_string()];
|
||
let theme = Theme::dark();
|
||
let narrow = render_to_string(&mut app, &theme, 80, 24);
|
||
assert!(!narrow.contains("Tables"), "sidebar hidden at 80 wide:\n{narrow}");
|
||
let wide = render_to_string(&mut app, &theme, 110, 24);
|
||
assert!(wide.contains("Tables"), "sidebar shown at 110 wide:\n{wide}");
|
||
assert!(wide.contains("Customers"), "tables listed when shown:\n{wide}");
|
||
}
|
||
|
||
#[test]
|
||
fn relationships_panel_height_is_content_sized_within_bounds() {
|
||
// ADR-0046 DB4: empty floors at 5; grows with content; capped at
|
||
// half the column; leaves the Tables panel at least 3 rows.
|
||
assert_eq!(relationships_panel_height(40, 0), 5); // empty floor
|
||
assert_eq!(relationships_panel_height(40, 6), 8); // 6 content + borders
|
||
assert_eq!(relationships_panel_height(40, 30), 20); // capped at half
|
||
assert_eq!(relationships_panel_height(7, 0), 3); // tiny: Tables keeps 3
|
||
}
|
||
|
||
fn one_relationship() -> crate::persistence::RelationshipSchema {
|
||
use crate::dsl::action::ReferentialAction;
|
||
crate::persistence::RelationshipSchema {
|
||
name: "Customers_Orders".to_string(),
|
||
parent_table: "Customers".to_string(),
|
||
parent_columns: vec!["id".to_string()],
|
||
child_table: "Orders".to_string(),
|
||
child_columns: vec!["customer_id".to_string()],
|
||
on_delete: ReferentialAction::Cascade,
|
||
on_update: ReferentialAction::Cascade,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn relationships_panel_lists_each_relationship() {
|
||
// ADR-0046 DB2: name, then endpoints broken at the arrow.
|
||
let mut app = App::new();
|
||
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
|
||
app.relationships = vec![one_relationship()];
|
||
let theme = Theme::dark();
|
||
let out = render_to_string(&mut app, &theme, 110, 24);
|
||
assert!(out.contains("Relationships"), "panel title present:\n{out}");
|
||
assert!(out.contains("Customers_Orders"), "relationship name:\n{out}");
|
||
assert!(
|
||
out.lines().any(|l| l.contains("Customers.id ->")),
|
||
"parent endpoint, broken at the arrow:\n{out}"
|
||
);
|
||
assert!(
|
||
out.lines().any(|l| l.contains("Orders.customer_id")),
|
||
"child endpoint, indented:\n{out}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn empty_relationships_panel_shows_none() {
|
||
let mut app = App::new();
|
||
app.tables = vec!["Customers".to_string()];
|
||
let theme = Theme::dark();
|
||
let out = render_to_string(&mut app, &theme, 110, 24);
|
||
assert!(out.contains("Relationships"), "panel title present:\n{out}");
|
||
assert!(out.contains("(none)"), "empty placeholder:\n{out}");
|
||
}
|
||
|
||
#[test]
|
||
fn relationships_panel_snapshot() {
|
||
let mut app = App::new();
|
||
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
|
||
app.relationships = vec![one_relationship()];
|
||
let theme = Theme::dark();
|
||
let snapshot = render_to_string(&mut app, &theme, 110, 24);
|
||
insta::assert_snapshot!("relationships_panel_dark", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn navigation_mode_reveals_and_expands_the_sidebar() {
|
||
// ADR-0046 DC1/DC2: at a narrow width the sidebar is hidden, but
|
||
// focusing a sidebar panel peeks it open as an expanded overlay.
|
||
let mut app = App::new();
|
||
app.tables = vec!["Customers".to_string()];
|
||
app.relationships = vec![one_relationship()];
|
||
let theme = Theme::dark();
|
||
let normal = render_to_string(&mut app, &theme, 80, 24);
|
||
assert!(
|
||
!normal.contains("Tables"),
|
||
"sidebar hidden at 80 wide when not browsing:\n{normal}"
|
||
);
|
||
|
||
app.nav_focus = NavFocus::SidebarTables;
|
||
let focused = render_to_string(&mut app, &theme, 80, 24);
|
||
assert!(focused.contains("Tables"), "sidebar revealed in nav mode:\n{focused}");
|
||
assert!(focused.contains("Customers"), "tables in the overlay:\n{focused}");
|
||
assert!(
|
||
focused.contains("Relationships"),
|
||
"relationships panel in the overlay:\n{focused}"
|
||
);
|
||
assert!(
|
||
focused.contains("Customers_Orders"),
|
||
"relationship listed in the overlay:\n{focused}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn focused_panel_gets_an_accent_border() {
|
||
// ADR-0046 DC3: the focused sidebar panel is accent-bordered.
|
||
let theme = Theme::dark();
|
||
let focused = panel_border_style(&theme, true);
|
||
let normal = panel_border_style(&theme, false);
|
||
assert_eq!(focused.fg, Some(theme.fg));
|
||
assert!(focused.add_modifier.contains(Modifier::BOLD));
|
||
assert_eq!(normal.fg, Some(theme.border));
|
||
assert!(!normal.add_modifier.contains(Modifier::BOLD));
|
||
}
|
||
|
||
#[test]
|
||
fn focused_tables_panel_scrolls_and_clamps() {
|
||
// ADR-0046 DC3: more tables than fit → a large offset reveals the
|
||
// lower entries and clamps so it can't scroll past the end.
|
||
let mut app = App::new();
|
||
app.tables = (0..30).map(|i| format!("Table{i:02}")).collect();
|
||
app.nav_focus = NavFocus::SidebarTables;
|
||
app.tables_scroll = 1000; // way past the end
|
||
let theme = Theme::dark();
|
||
let out = render_to_string(&mut app, &theme, 80, 24);
|
||
assert!(
|
||
out.contains("Table29"),
|
||
"the last table is visible after the offset clamps:\n{out}"
|
||
);
|
||
assert!(
|
||
!out.contains("Table00"),
|
||
"the top tables are scrolled off:\n{out}"
|
||
);
|
||
assert!(
|
||
app.tables_scroll < 30,
|
||
"the stored offset was clamped to the content: {}",
|
||
app.tables_scroll
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn navigation_overlay_snapshot() {
|
||
// The expanded overlay over a full-width base (sidebar hidden at
|
||
// 80), with the Relationships panel focused (accent border).
|
||
let mut app = App::new();
|
||
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
|
||
app.relationships = vec![one_relationship()];
|
||
app.nav_focus = NavFocus::SidebarRelationships;
|
||
let theme = Theme::dark();
|
||
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||
insta::assert_snapshot!("nav_overlay_relationships_focused_dark", snapshot);
|
||
}
|
||
|
||
// ---- ADR-0047 (issue #22): demo-mode keystroke badge ----
|
||
|
||
/// Render to a `TestBackend` buffer (for cell-level style checks the
|
||
/// text-only `render_to_string` cannot make).
|
||
fn render_to_buffer(
|
||
app: &mut App,
|
||
theme: &Theme,
|
||
width: u16,
|
||
height: u16,
|
||
) -> ratatui::buffer::Buffer {
|
||
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");
|
||
terminal.backend().buffer().clone()
|
||
}
|
||
|
||
#[test]
|
||
fn demo_badge_box_renders_at_output_bottom_right() {
|
||
// At the 90×26 cast geometry the sidebar is hidden and the badge
|
||
// box sits inset in the output panel's bottom-right corner.
|
||
let mut app = App::new();
|
||
app.demo_mode = true;
|
||
app.demo_badge = Some("[TAB]");
|
||
let theme = Theme::dark();
|
||
let snapshot = render_to_string(&mut app, &theme, 90, 26);
|
||
insta::assert_snapshot!("demo_badge_tab_dark_90x26", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn demo_badge_box_renders_in_light_theme() {
|
||
let mut app = App::new();
|
||
app.demo_mode = true;
|
||
app.demo_badge = Some("[ENTER]");
|
||
let theme = Theme::light();
|
||
let snapshot = render_to_string(&mut app, &theme, 90, 26);
|
||
insta::assert_snapshot!("demo_badge_enter_light_90x26", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn demo_badge_box_is_black_on_yellow() {
|
||
let mut app = App::new();
|
||
app.demo_mode = true;
|
||
app.demo_badge = Some("[TAB]");
|
||
let theme = Theme::dark();
|
||
let buffer = render_to_buffer(&mut app, &theme, 90, 26);
|
||
// Collect the badge cells (the only ones painted with the fixed
|
||
// overlay background) and confirm the high-contrast pairing.
|
||
let mut badge_cells = 0;
|
||
let mut row_text: std::collections::BTreeMap<u16, String> = Default::default();
|
||
for y in 0..buffer.area.height {
|
||
for x in 0..buffer.area.width {
|
||
let cell = &buffer[(x, y)];
|
||
if cell.bg == crate::theme::DEMO_OVERLAY_BG {
|
||
badge_cells += 1;
|
||
assert_eq!(
|
||
cell.fg,
|
||
crate::theme::DEMO_OVERLAY_FG,
|
||
"badge cell at ({x},{y}) must be black-on-yellow"
|
||
);
|
||
row_text.entry(y).or_default().push_str(cell.symbol());
|
||
}
|
||
}
|
||
}
|
||
assert!(badge_cells > 0, "expected a yellow badge box to be drawn");
|
||
// The label appears on the box's middle (text) row.
|
||
assert!(
|
||
row_text.values().any(|line| line.contains("[TAB]")),
|
||
"badge text not found among styled rows: {row_text:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn demo_caption_box_renders_at_output_bottom_right() {
|
||
let mut app = App::new();
|
||
app.demo_mode = true;
|
||
app.demo_caption = Some("Now press Tab to complete the table name".to_string());
|
||
let theme = Theme::dark();
|
||
let snapshot = render_to_string(&mut app, &theme, 90, 26);
|
||
insta::assert_snapshot!("demo_caption_dark_90x26", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn demo_badge_stacks_above_caption() {
|
||
let mut app = App::new();
|
||
app.demo_mode = true;
|
||
app.demo_badge = Some("[TAB]");
|
||
app.demo_caption = Some("Completing the name".to_string());
|
||
let theme = Theme::dark();
|
||
let snapshot = render_to_string(&mut app, &theme, 90, 26);
|
||
insta::assert_snapshot!("demo_badge_and_caption_stacked_90x26", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn demo_caption_wraps_to_three_lines_and_ellipsises() {
|
||
let mut app = App::new();
|
||
app.demo_mode = true;
|
||
app.demo_caption = Some(
|
||
"This is a deliberately long step caption that must wrap onto \
|
||
several lines and then be clipped to three with an ellipsis \
|
||
so the corner box never grows without bound."
|
||
.to_string(),
|
||
);
|
||
let theme = Theme::dark();
|
||
let snapshot = render_to_string(&mut app, &theme, 90, 26);
|
||
insta::assert_snapshot!("demo_caption_wrapped_90x26", snapshot);
|
||
}
|
||
|
||
#[test]
|
||
fn demo_badge_box_skipped_when_area_too_small() {
|
||
// ADR-0047 D4 clamp guard: a box that cannot fit the given area
|
||
// is not drawn rather than overflowing.
|
||
let backend = TestBackend::new(40, 10);
|
||
let mut terminal = Terminal::new(backend).expect("create terminal");
|
||
terminal
|
||
.draw(|f| super::render_badge_box("[SHIFT-TAB]", Rect::new(0, 0, 5, 3), None, f))
|
||
.expect("draw frame");
|
||
let buffer = terminal.backend().buffer();
|
||
let drew_badge = (0..buffer.area.height).any(|y| {
|
||
(0..buffer.area.width).any(|x| buffer[(x, y)].bg == crate::theme::DEMO_OVERLAY_BG)
|
||
});
|
||
assert!(!drew_badge, "badge must be skipped when it cannot fit");
|
||
}
|
||
}
|