From 9f5f76b05dc7dfbff965b05a2d0b1dcea92e291e Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Wed, 10 Jun 2026 17:08:25 +0000 Subject: [PATCH] fix(ui): geometry-fixed hint-panel height kills the typing jump (#20, ADR-0046 DA1/DA2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hint panel's height was recomputed every frame from the wrapped hint content (1–3 rows), so it resized as the user typed and shoved the input/output panels — the flicker visible in the screencasts. Make the height a pure function of terminal geometry (new hint_rows), fixed between resizes: 2 content rows on compact (<40-row) terminals, 3 only on comfortable terminals narrow enough (<54 inner cols) to wrap the longest catalog hint past two lines, degrading toward 1 on tiny terminals to protect the output Min(5). resolve_hint_lines clamps to that fixed budget (long hints ellipsize; short ones leave rows blank). This reverses issue #12's shrink-to-content "reclaim"; its two tests are replaced by an anti-jump invariant plus geometry-helper and third-row tests. Two layout snapshots regenerated. --- ...hlighted_input_all_token_classes_dark.snap | 6 +- ...nd__ui__tests__one_shot_advanced_dark.snap | 3 +- src/ui.rs | 171 ++++++++++++++---- 3 files changed, 137 insertions(+), 43 deletions(-) diff --git a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap index 5d836be..fa8dbd9 100644 --- a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 1583 +assertion_line: 1976 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -16,14 +16,14 @@ expression: snapshot │ ││ │ │ ││ │ │ ││ │ +│ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ SIMPLE ──────────────────────────────────────────╮ │ ││insert into T values (1, 'hi', null) --all-r │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ │ ││after `insert into T values (1, 'hi', null)`, │ -│ ││expected end of input — usage: insert into │ -│ ││ [([, ...])] [values] ([, ...])│ +│ ││expected end of input — usage: insert into… │ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap index cb62313..82e536b 100644 --- a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 1992 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -16,13 +17,13 @@ expression: snapshot │ ││ │ │ ││ │ │ ││ │ -│ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Advanced: ───────────────────────────────────────╮ │ ││: sel │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Hint ────────────────────────────────────────────╮ │ ││select │ +│ ││ │ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ Project: Term Planner Enter submit · Backspace cancel one-shot · Ctrl-C quit diff --git a/src/ui.rs b/src/ui.rs index cd60b95..9057cef 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -493,12 +493,58 @@ fn wrap_lines(s: &str, width: usize) -> Vec { lines } -/// Maximum content rows the Hint panel may grow to before its last -/// visible row is ellipsis-truncated (issue #12). The panel starts -/// at one row and grows only as far as a wrapped hint needs, up to -/// this cap, reclaiming the space when the hint is short. +/// 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; + +/// 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. +/// +/// - 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. +/// - 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 { + let inner_w = area.width.saturating_sub(2); + let mut hint_c: u16 = if area.height < COMFORTABLE_MIN_HEIGHT { + 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) + 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; + } + 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 @@ -551,21 +597,21 @@ fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: R } fn render_right_column(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { - // Resolve the hint first so the layout can size the Hint panel to - // the wrapped hint (issue #12): one content row by default, - // growing up to MAX_HINT_ROWS, reclaiming the space when short. - // The hint panel spans the full column width, so `area.width` is - // its width too. - let hint_lines = resolve_hint_lines(app, theme, area.width); - let hint_content = - (hint_lines.len().clamp(1, MAX_HINT_ROWS) as u16).saturating_add(2); + // 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 hint_c = hint_rows(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 - Constraint::Length(hint_content), // Hint panel (dynamic) + Constraint::Min(5), // Output panel + Constraint::Length(3), // Input panel (DA4 will size this) + Constraint::Length(hint_c + 2), // Hint panel (geometry-fixed) ]) .split(area); @@ -1041,11 +1087,11 @@ fn strip_one_shot_prefix(input: &str, cursor: usize) -> (&str, usize) { } /// Resolve the Hint panel body into its rendered lines, pre-wrapped -/// to the panel's inner width and clamped to `MAX_HINT_ROWS` with an -/// ellipsis backstop (issue #12). The returned line count is the -/// content-row count `render_right_column` allocates for, so the -/// panel grows for a long hint and reclaims the space for a short -/// one. +/// 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. @@ -1064,11 +1110,16 @@ fn strip_one_shot_prefix(input: &str, cursor: usize) -> (&str, usize) { /// 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) -> Vec> { +fn resolve_hint_lines( + app: &App, + theme: &Theme, + area_width: u16, + max_rows: usize, +) -> Vec> { 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_HINT_ROWS) + clamp_wrapped(text, inner, max_rows) .into_iter() .map(|l| Line::from(Span::styled(l, muted))) .collect::>>() @@ -1734,9 +1785,10 @@ mod tests { #[test] fn long_prose_hint_shows_tail_across_multiple_rows() { - // Before the fix the Hint panel was a fixed 1 content row, - // so this hint's useful tail was clipped. Now the panel - // grows (to MAX_HINT_ROWS) so the tail is visible. + // 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(); @@ -1748,37 +1800,78 @@ mod tests { } #[test] - fn short_hint_keeps_panel_at_one_content_row() { - // Reclaim: a short hint must not inflate the panel. - let mut app = App::new(); - app.hint = Some("Type a command".to_string()); + 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 out = render_to_string(&mut app, &theme, 80, 20); + + 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!( - out.lines().any(|l| l.contains("Type a command")), - "short hint visible:\n{out}" + 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(&out), - 1, - "short hint should occupy exactly one content row:\n{out}" + 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 long_hint_grows_panel_but_caps_at_max_rows() { + 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(); - // Narrow width forces more wrapped lines than the cap. - let out = render_to_string(&mut app, &theme, 44, 20); + let out = render_to_string(&mut app, &theme, 44, 50); assert_eq!( hint_content_rows(&out), MAX_HINT_ROWS, - "long hint caps at MAX_HINT_ROWS content rows:\n{out}" + "narrow + tall terminal caps the long hint at MAX_HINT_ROWS \ + content rows:\n{out}" ); } + #[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); + } + /// Count the content rows inside the Hint panel of a rendered /// screen: the rows between the `╭ Hint …` title border and the /// next `╰…╯` bottom border.