feat(ui): two-row input display on tall terminals (#23, ADR-0046 DA4)

On a comfortable terminal (height >= 40) the input panel shows two
rows: the single logical command soft-wraps across them — the first
row stops 6 columns short for the ADR-0027 validity indicator, the
second uses the full width — so a medium command is fully visible
without horizontal scrolling. A line longer than both rows still
scrolls (DA3-style, one column each side reserved for < / > markers)
to keep the cursor visible.

hint_rows generalises to panel_heights(area) -> (input_rows, hint_rows):
compact (<40) stays input 1 / hint 2; comfortable becomes input 2,
degrading hint-then-input on tiny terminals to protect the output
Min(5). render_input_panel splits into render_input_one_row (the
existing DA3 path, unchanged) and render_input_two_rows, with a new
expand_runs_to_cells helper placing styled cells across the rows.

Tests: panel_heights geometry, two-row wrap, overflow-scroll, the
indicator-stays-on-the-first-row case, and a two-row layout snapshot.
Compact one-row snapshots are byte-identical (that path is untouched).
This commit is contained in:
claude@clouddev1
2026-06-10 18:19:15 +00:00
parent e0b9470feb
commit 41bae99ab3
2 changed files with 354 additions and 82 deletions
@@ -0,0 +1,49 @@
---
source: src/ui.rs
assertion_line: 2210
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
│(none yet) ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ SIMPLE ──────────────────────────────────────────╮
│ ││select * from Customers where id = 12345 and │
│ ││ name = 'Alice Wonderland' │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮
│ ││`select` is SQL — available in advanced mode. │
│ ││Switch with `mode advanced`, or prefix the line │
│ ││with `:` to run it once. │
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
+305 -82
View File
@@ -513,24 +513,25 @@ const COMFORTABLE_MIN_HEIGHT: u16 = 40;
/// measured against `src/friendly/strings/en-US.yaml`). /// measured against `src/friendly/strings/en-US.yaml`).
const HINT_THIRD_ROW_MAX_INNER: u16 = 54; const HINT_THIRD_ROW_MAX_INNER: u16 = 54;
/// Hint-panel content-row count as a pure function of the right /// Input- and hint-panel content-row counts as a pure function of the
/// column's geometry (ADR-0046 DA1/DA2) — NOT of the hint text. That is /// right column's geometry (ADR-0046 DA1/DA2/DA4) — NOT of the hint or
/// the whole point of #20: a height fixed per terminal size cannot jump /// input text. That is the point of #20: heights fixed per terminal
/// as the user types. Add 2 for the panel borders. /// 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`): 2 rows. /// - Compact height (`< COMFORTABLE_MIN_HEIGHT`): input 1 row, hint 2.
/// - Comfortable height: 2 rows, or 3 when the column is narrow enough /// - Comfortable height: input 2 rows (DA4 two-row display); hint 2, or
/// (`inner < HINT_THIRD_ROW_MAX_INNER`) that the longest hint needs a /// 3 when the column is narrow enough (`inner <
/// third line. /// 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 /// - Degradation: on a terminal too short to honour the output panel's
/// `Min(5)` plus the fixed input (3) and hint panels, the hint /// `Min(5)` plus both panels, the hint shrinks first, then the input,
/// shrinks toward 1 so the output keeps its floor. /// so the output keeps its floor.
/// const fn panel_heights(area: Rect) -> (u16, u16) {
/// (DA4 will fold the input panel's height in here too; today the input let comfortable = area.height >= COMFORTABLE_MIN_HEIGHT;
/// is a fixed 3 rows, reflected in the degradation arithmetic.)
const fn hint_rows(area: Rect) -> u16 {
let inner_w = area.width.saturating_sub(2); let inner_w = area.width.saturating_sub(2);
let mut hint_c: u16 = if area.height < COMFORTABLE_MIN_HEIGHT { let mut input_c: u16 = if comfortable { 2 } else { 1 };
let mut hint_c: u16 = if !comfortable {
2 2
} else if inner_w < HINT_THIRD_ROW_MAX_INNER { } else if inner_w < HINT_THIRD_ROW_MAX_INNER {
MAX_HINT_ROWS as u16 MAX_HINT_ROWS as u16
@@ -538,11 +539,18 @@ const fn hint_rows(area: Rect) -> u16 {
2 2
}; };
// Honour the output panel's Min(5) first on a very short terminal: // Honour the output panel's Min(5) first on a very short terminal:
// 5 (output) + 3 (input panel) + (hint_c + 2) must fit in the column. // 5 (output) + (input_c + 2) + (hint_c + 2) must fit in the column.
while 5 + 3 + (hint_c + 2) > area.height && hint_c > 1 { // Shrink the hint first, then the input.
hint_c -= 1; 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;
}
} }
hint_c (input_c, hint_c)
} }
/// Word-wrap `text` to `width`, then clamp to at most `max_rows` /// Word-wrap `text` to `width`, then clamp to at most `max_rows`
@@ -603,15 +611,15 @@ fn render_right_column(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area
// input/output panels (#20). The hint is then clamped to that fixed // input/output panels (#20). The hint is then clamped to that fixed
// row budget. The hint panel spans the full column width, so // row budget. The hint panel spans the full column width, so
// `area.width` is its width too. // `area.width` is its width too.
let hint_c = hint_rows(area); let (input_c, hint_c) = panel_heights(area);
let hint_lines = resolve_hint_lines(app, theme, area.width, hint_c as usize); let hint_lines = resolve_hint_lines(app, theme, area.width, hint_c as usize);
let rows = Layout::default() let rows = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Min(5), // Output panel Constraint::Min(5), // Output panel
Constraint::Length(3), // Input panel (DA4 will size this) Constraint::Length(input_c + 2), // Input panel (1 row, or 2 when tall)
Constraint::Length(hint_c + 2), // Hint panel (geometry-fixed) Constraint::Length(hint_c + 2), // Hint panel (geometry-fixed)
]) ])
.split(area); .split(area);
@@ -1029,51 +1037,82 @@ fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
.title(title) .title(title)
.style(Style::default().bg(theme.bg).fg(theme.fg)); .style(Style::default().bg(theme.bg).fg(theme.fg));
// Cursor block: render the character at the cursor position
// inverted so the cursor is visible without enabling a real
// terminal cursor.
//
// Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both
// modes — `render_input_runs_in_mode` runs the highlight
// walker with the active mode so SQL keywords / operators /
// CASE / function calls colour correctly in Advanced mode.
let cursor = app.input_cursor.min(app.input.len()); let cursor = app.input_cursor.min(app.input.len());
// ADR-0027 §4: the rightmost six columns of the input row // ADR-0027 §4: the rightmost six columns of the input row
// (a five-column label plus a one-column gap) are reserved // (a five-column label plus a one-column gap) are reserved
// unconditionally, so the text area is always // unconditionally, so the first row's text area is always
// `inner.width - 6` and the typed command never shifts // `inner.width - 6` and the typed command never shifts sideways
// sideways when the validity indicator appears or hides. // 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 inner = block.inner(area);
let text_area = Rect { let text_area = Rect {
width: inner.width.saturating_sub(6), width: inner.width.saturating_sub(6),
..inner ..inner
}; };
// ADR-0046 DA3: horizontally scroll a long line so the cursor stays
// visible, rather than clipping it off the right edge silently.
// Computed (and the offset stored) *before* the highlight spans
// borrow `app.input`, so the `&mut app` write does not clash.
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;
frame.render_widget(block, area);
// Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both modes — // Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both modes —
// `render_input_runs_in_mode` runs the highlight walker with the // the highlight walker runs with the active mode so SQL keywords /
// active mode so SQL keywords / operators / CASE / function calls // operators / CASE / function calls colour correctly. The cursor
// colour correctly in Advanced mode. The cursor cell is rendered // cell is rendered inverted (an empty-range run) so it is visible
// inverted (an empty-range run) so it is visible without a real // without a real terminal cursor.
// terminal cursor.
let mode_for_render = match effective { let mode_for_render = match effective {
EffectiveMode::Simple => crate::mode::Mode::Simple, EffectiveMode::Simple => crate::mode::Mode::Simple,
EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => { EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => {
crate::mode::Mode::Advanced 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( let runs = crate::input_render::render_input_runs_in_mode(
&app.input, &app.input,
cursor, cursor,
@@ -1117,25 +1156,126 @@ fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area:
} else { } else {
frame.render_widget(Paragraph::new(Line::from(spans)), text_area); frame.render_widget(Paragraph::new(Line::from(spans)), text_area);
} }
}
if let Some(severity) = app.input_indicator { /// Two-row input rendering (ADR-0046 DA4): on a comfortable terminal the
let (indicator_label, color) = match severity { /// single logical line is soft-wrapped across two visual rows — the
crate::dsl::walker::Severity::Error => ("[ERR]", theme.error), /// first row stops 6 columns short (the ADR-0027 indicator reserve), the
crate::dsl::walker::Severity::Warning => ("[WRN]", theme.warning), /// second uses the full width. When the line overflows both rows it
}; /// scrolls horizontally (one column each side reserved for `<` / `>`
let label_area = Rect { /// markers) so the cursor stays visible. `text_area` is the first
x: inner.x + inner.width.saturating_sub(5), /// (narrower) row; `inner` spans both rows.
width: 5.min(inner.width), fn render_input_two_rows(
..inner 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( frame.render_widget(
Paragraph::new(Line::from(Span::styled( Paragraph::new(Span::styled("<", marker)),
indicator_label, Rect {
Style::default().fg(color).add_modifier(Modifier::BOLD), x: text_area.x,
))), y: inner.y,
label_area, 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 /// Convert `StyledRun`s into ratatui `Span`s borrowed from
@@ -1941,22 +2081,24 @@ mod tests {
} }
#[test] #[test]
fn hint_rows_is_geometry_driven() { fn panel_heights_are_geometry_driven() {
// ADR-0046 DA1/DA2: the pure helper that the renderer and these // ADR-0046 DA1/DA2/DA4: the pure helper the renderer and these
// tests share. Height picks the bucket; width gates the 3rd row; // tests share. Height picks the bucket (input 1→2, hint floor);
// a tiny terminal degrades toward 1 to protect output `Min(5)`. // width gates the hint's 3rd row; a tiny terminal degrades hint
let at = |w: u16, h: u16| hint_rows(Rect::new(0, 0, w, h)); // then input to protect output `Min(5)`.
// Compact height → always 2, regardless of width. let at = |w: u16, h: u16| panel_heights(Rect::new(0, 0, w, h));
assert_eq!(at(90, 25), 2); // Compact height → input 1, hint 2, regardless of width.
assert_eq!(at(40, 25), 2); assert_eq!(at(90, 25), (1, 2));
// Comfortable + wide (inner ≥ 54) → 2. assert_eq!(at(40, 25), (1, 2));
assert_eq!(at(90, 45), 2); // Comfortable height → input 2; hint 2 when wide (inner ≥ 54).
assert_eq!(at(56, 45), 2); // inner == 54 is "wide enough" assert_eq!(at(90, 45), (2, 2));
// Comfortable + narrow (inner < 54) → 3. assert_eq!(at(56, 45), (2, 2)); // inner == 54 is "wide enough"
assert_eq!(at(55, 45), 3); // inner == 53 // Comfortable + narrow (inner < 54) → hint 3.
assert_eq!(at(50, 45), 3); assert_eq!(at(55, 45), (2, 3)); // inner == 53
// Very short terminal degrades the hint to protect output Min(5). assert_eq!(at(50, 45), (2, 3));
assert_eq!(at(90, 11), 1); // 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 ------------------- // ---- ADR-0046 DA3: input horizontal scroll -------------------
@@ -2016,6 +2158,87 @@ mod tests {
assert!(!out.contains("Wonderland"), "the tail must be scrolled off:\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();
let out = render_to_string(&mut app, &theme, 80, 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();
let out = render_to_string(&mut app, &theme, 50, 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();
let out = render_to_string(&mut app, &theme, 80, 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();
let snapshot = render_to_string(&mut app, &theme, 80, 44);
insta::assert_snapshot!("two_row_input_dark", snapshot);
}
/// Count the content rows inside the Hint panel of a rendered /// Count the content rows inside the Hint panel of a rendered
/// screen: the rows between the `╭ Hint …` title border and the /// screen: the rows between the `╭ Hint …` title border and the
/// next `╰…╯` bottom border. /// next `╰…╯` bottom border.