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:
+3
-3
@@ -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
|
||||
|
||||
@@ -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::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.
|
||||
|
||||
Reference in New Issue
Block a user