From 41bae99ab35666e999e7f08d5782f176604db988 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 18:19:15 +0000 Subject: [PATCH] feat(ui): two-row input display on tall terminals (#23, ADR-0046 DA4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ...ground__ui__tests__two_row_input_dark.snap | 49 +++ src/ui.rs | 387 ++++++++++++++---- 2 files changed, 354 insertions(+), 82 deletions(-) create mode 100644 src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap diff --git a/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap b/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap new file mode 100644 index 0000000..91f7646 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap @@ -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 diff --git a/src/ui.rs b/src/ui.rs index e076712..23ece83 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -513,24 +513,25 @@ const COMFORTABLE_MIN_HEIGHT: u16 = 40; /// measured against `src/friendly/strings/en-US.yaml`). const HINT_THIRD_ROW_MAX_INNER: u16 = 54; -/// Hint-panel content-row count as a pure function of the right -/// column's geometry (ADR-0046 DA1/DA2) — NOT of the hint text. That is -/// the whole point of #20: a height fixed per terminal size cannot jump -/// as the user types. Add 2 for the panel borders. +/// 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`): 2 rows. -/// - Comfortable height: 2 rows, or 3 when the column is narrow enough -/// (`inner < HINT_THIRD_ROW_MAX_INNER`) that the longest hint needs a -/// third line. +/// - 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 the fixed input (3) and hint panels, the hint -/// shrinks toward 1 so the output keeps its floor. -/// -/// (DA4 will fold the input panel's height in here too; today the input -/// is a fixed 3 rows, reflected in the degradation arithmetic.) -const fn hint_rows(area: Rect) -> u16 { +/// `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 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 } else if inner_w < HINT_THIRD_ROW_MAX_INNER { MAX_HINT_ROWS as u16 @@ -538,11 +539,18 @@ const fn hint_rows(area: Rect) -> u16 { 2 }; // 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. - while 5 + 3 + (hint_c + 2) > area.height && hint_c > 1 { - hint_c -= 1; + // 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; + } } - hint_c + (input_c, hint_c) } /// 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 // row budget. The hint panel spans the full column width, so // `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 rows = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Min(5), // Output panel - Constraint::Length(3), // Input panel (DA4 will size this) - Constraint::Length(hint_c + 2), // Hint panel (geometry-fixed) + 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); @@ -1029,51 +1037,82 @@ fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: .title(title) .style(Style::default().bg(theme.bg).fg(theme.fg)); - // Cursor block: render the character at the cursor position - // inverted so the cursor is visible without enabling a real - // terminal cursor. - // - // Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both - // modes — `render_input_runs_in_mode` runs the highlight - // walker with the active mode so SQL keywords / operators / - // CASE / function calls colour correctly in Advanced mode. let cursor = app.input_cursor.min(app.input.len()); // ADR-0027 §4: the rightmost six columns of the input row // (a five-column label plus a one-column gap) are reserved - // unconditionally, so the text area is always - // `inner.width - 6` and the typed command never shifts - // sideways when the validity indicator appears or hides. + // 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 }; - // 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 — - // `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. The cursor cell is rendered - // inverted (an empty-range run) so it is visible without a real - // terminal cursor. + // 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, @@ -1117,25 +1156,126 @@ fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: } else { frame.render_widget(Paragraph::new(Line::from(spans)), text_area); } +} - if let Some(severity) = app.input_indicator { - let (indicator_label, color) = match severity { - crate::dsl::walker::Severity::Error => ("[ERR]", theme.error), - crate::dsl::walker::Severity::Warning => ("[WRN]", theme.warning), - }; - let label_area = Rect { - x: inner.x + inner.width.saturating_sub(5), - width: 5.min(inner.width), - ..inner - }; +/// 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::>(), + ) + }; + + 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(Line::from(Span::styled( - indicator_label, - Style::default().fg(color).add_modifier(Modifier::BOLD), - ))), - label_area, + 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 @@ -1941,22 +2081,24 @@ mod tests { } #[test] - fn hint_rows_is_geometry_driven() { - // ADR-0046 DA1/DA2: the pure helper that the renderer and these - // tests share. Height picks the bucket; width gates the 3rd row; - // a tiny terminal degrades toward 1 to protect output `Min(5)`. - let at = |w: u16, h: u16| hint_rows(Rect::new(0, 0, w, h)); - // Compact height → always 2, regardless of width. - assert_eq!(at(90, 25), 2); - assert_eq!(at(40, 25), 2); - // Comfortable + wide (inner ≥ 54) → 2. - assert_eq!(at(90, 45), 2); - assert_eq!(at(56, 45), 2); // inner == 54 is "wide enough" - // Comfortable + narrow (inner < 54) → 3. - assert_eq!(at(55, 45), 3); // inner == 53 - assert_eq!(at(50, 45), 3); - // Very short terminal degrades the hint to protect output Min(5). - assert_eq!(at(90, 11), 1); + 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 ------------------- @@ -2016,6 +2158,87 @@ mod tests { 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 /// screen: the rows between the `╭ Hint …` title border and the /// next `╰…╯` bottom border.