fix(ui): geometry-fixed hint-panel height kills the typing jump (#20, ADR-0046 DA1/DA2)

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.
This commit is contained in:
claude@clouddev1
2026-06-10 17:08:25 +00:00
parent 93266b99c9
commit 9f5f76b05d
3 changed files with 137 additions and 43 deletions
@@ -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
│ ││<Table> [(<col>[, ...])] [values] (<value>[, ...])│
│ ││expected end of input — usage: insert into
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -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
+132 -39
View File
@@ -493,12 +493,58 @@ fn wrap_lines(s: &str, width: usize) -> Vec<String> {
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<Line<'static>> {
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_HINT_ROWS)
clamp_wrapped(text, inner, max_rows)
.into_iter()
.map(|l| Line::from(Span::styled(l, muted)))
.collect::<Vec<Line<'static>>>()
@@ -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.