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
|
source: src/ui.rs
|
||||||
assertion_line: 1583
|
assertion_line: 1976
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||||
@@ -16,14 +16,14 @@ expression: snapshot
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
|
│ ││ │
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ SIMPLE ──────────────────────────────────────────╮
|
│ │╭ SIMPLE ──────────────────────────────────────────╮
|
||||||
│ ││insert into T values (1, 'hi', null) --all-r │
|
│ ││insert into T values (1, 'hi', null) --all-r │
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ Hint ────────────────────────────────────────────╮
|
│ │╭ Hint ────────────────────────────────────────────╮
|
||||||
│ ││after `insert into T values (1, 'hi', null)`, │
|
│ ││after `insert into T values (1, 'hi', null)`, │
|
||||||
│ ││expected end of input — usage: insert into │
|
│ ││expected end of input — usage: insert into… │
|
||||||
│ ││<Table> [(<col>[, ...])] [values] (<value>[, ...])│
|
|
||||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||||
Project: Term Planner
|
Project: Term Planner
|
||||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: src/ui.rs
|
source: src/ui.rs
|
||||||
|
assertion_line: 1992
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||||
@@ -16,13 +17,13 @@ expression: snapshot
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ Advanced: ───────────────────────────────────────╮
|
│ │╭ Advanced: ───────────────────────────────────────╮
|
||||||
│ ││: sel │
|
│ ││: sel │
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ Hint ────────────────────────────────────────────╮
|
│ │╭ Hint ────────────────────────────────────────────╮
|
||||||
│ ││select │
|
│ ││select │
|
||||||
|
│ ││ │
|
||||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||||
Project: Term Planner
|
Project: Term Planner
|
||||||
Enter submit · Backspace cancel one-shot · Ctrl-C quit
|
Enter submit · Backspace cancel one-shot · Ctrl-C quit
|
||||||
|
|||||||
@@ -493,12 +493,58 @@ fn wrap_lines(s: &str, width: usize) -> Vec<String> {
|
|||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maximum content rows the Hint panel may grow to before its last
|
/// Absolute ceiling on Hint-panel content rows. Per ADR-0046 (DA1/DA2)
|
||||||
/// visible row is ellipsis-truncated (issue #12). The panel starts
|
/// the panel's height is no longer driven by the hint *content* — it is
|
||||||
/// at one row and grows only as far as a wrapped hint needs, up to
|
/// a pure function of terminal geometry (`hint_rows`), fixed between
|
||||||
/// this cap, reclaiming the space when the hint is short.
|
/// 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;
|
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`
|
/// Word-wrap `text` to `width`, then clamp to at most `max_rows`
|
||||||
/// rows. If wrapping produced more rows than the cap, the last kept
|
/// rows. If wrapping produced more rows than the cap, the last kept
|
||||||
/// row is truncated to end with an ellipsis so the overflow is
|
/// 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) {
|
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
|
// ADR-0046 DA1/DA2: the Hint panel's height is a pure function of
|
||||||
// the wrapped hint (issue #12): one content row by default,
|
// the column geometry, fixed between resizes — it no longer tracks
|
||||||
// growing up to MAX_HINT_ROWS, reclaiming the space when short.
|
// the hint content, so typing cannot make it resize and shove the
|
||||||
// The hint panel spans the full column width, so `area.width` is
|
// input/output panels (#20). The hint is then clamped to that fixed
|
||||||
// its width too.
|
// row budget. The hint panel spans the full column width, so
|
||||||
let hint_lines = resolve_hint_lines(app, theme, area.width);
|
// `area.width` is its width too.
|
||||||
let hint_content =
|
let hint_c = hint_rows(area);
|
||||||
(hint_lines.len().clamp(1, MAX_HINT_ROWS) as u16).saturating_add(2);
|
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
|
Constraint::Length(3), // Input panel (DA4 will size this)
|
||||||
Constraint::Length(hint_content), // Hint panel (dynamic)
|
Constraint::Length(hint_c + 2), // Hint panel (geometry-fixed)
|
||||||
])
|
])
|
||||||
.split(area);
|
.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
|
/// 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
|
/// to the panel's inner width and clamped to `max_rows` with an
|
||||||
/// ellipsis backstop (issue #12). The returned line count is the
|
/// ellipsis backstop (issue #12). `max_rows` is the geometry-fixed row
|
||||||
/// content-row count `render_right_column` allocates for, so the
|
/// budget chosen by `hint_rows` (ADR-0046 DA1/DA2); the panel does not
|
||||||
/// panel grows for a long hint and reclaims the space for a short
|
/// resize to the hint, so a short hint simply leaves the spare rows
|
||||||
/// one.
|
/// blank and a long one is ellipsized at the budget.
|
||||||
///
|
///
|
||||||
/// Resolution order for the body:
|
/// Resolution order for the body:
|
||||||
/// 1. An explicit app-set hint (e.g. modal contexts) wins.
|
/// 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
|
/// mode-aware walker (ADR-0030/0031/0032); the walker now speaks
|
||||||
/// SQL, so `ambient_hint_in_mode` surfaces SQL slot hints +
|
/// SQL, so `ambient_hint_in_mode` surfaces SQL slot hints +
|
||||||
/// completion candidates in advanced mode too.
|
/// 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 inner = area_width.saturating_sub(2) as usize;
|
||||||
let muted = Style::default().fg(theme.muted);
|
let muted = Style::default().fg(theme.muted);
|
||||||
let prose = |text: &str| {
|
let prose = |text: &str| {
|
||||||
clamp_wrapped(text, inner, MAX_HINT_ROWS)
|
clamp_wrapped(text, inner, max_rows)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|l| Line::from(Span::styled(l, muted)))
|
.map(|l| Line::from(Span::styled(l, muted)))
|
||||||
.collect::<Vec<Line<'static>>>()
|
.collect::<Vec<Line<'static>>>()
|
||||||
@@ -1734,9 +1785,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn long_prose_hint_shows_tail_across_multiple_rows() {
|
fn long_prose_hint_shows_tail_across_multiple_rows() {
|
||||||
// Before the fix the Hint panel was a fixed 1 content row,
|
// A multi-row hint panel (here 2 rows at a compact 80×20) shows
|
||||||
// so this hint's useful tail was clipped. Now the panel
|
// the hint's useful tail rather than clipping it to one row.
|
||||||
// grows (to MAX_HINT_ROWS) so the tail is visible.
|
// (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();
|
let mut app = App::new();
|
||||||
app.hint = Some(LONG_HINT.to_string());
|
app.hint = Some(LONG_HINT.to_string());
|
||||||
let theme = Theme::dark();
|
let theme = Theme::dark();
|
||||||
@@ -1748,37 +1800,78 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn short_hint_keeps_panel_at_one_content_row() {
|
fn hint_panel_height_is_fixed_by_geometry_not_content() {
|
||||||
// Reclaim: a short hint must not inflate the panel.
|
// ADR-0046 DA1/DA2 (#20): the panel no longer shrinks to a
|
||||||
let mut app = App::new();
|
// short hint (the issue #12 "reclaim" behaviour is deliberately
|
||||||
app.hint = Some("Type a command".to_string());
|
// 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 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!(
|
assert!(
|
||||||
out.lines().any(|l| l.contains("Type a command")),
|
short_out.lines().any(|l| l.contains("Type a command")),
|
||||||
"short hint visible:\n{out}"
|
"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!(
|
assert_eq!(
|
||||||
hint_content_rows(&out),
|
hint_content_rows(&short_out),
|
||||||
1,
|
hint_content_rows(&long_out),
|
||||||
"short hint should occupy exactly one content row:\n{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]
|
#[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();
|
let mut app = App::new();
|
||||||
app.hint = Some(LONG_HINT.to_string());
|
app.hint = Some(LONG_HINT.to_string());
|
||||||
let theme = Theme::dark();
|
let theme = Theme::dark();
|
||||||
// Narrow width forces more wrapped lines than the cap.
|
let out = render_to_string(&mut app, &theme, 44, 50);
|
||||||
let out = render_to_string(&mut app, &theme, 44, 20);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hint_content_rows(&out),
|
hint_content_rows(&out),
|
||||||
MAX_HINT_ROWS,
|
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
|
/// 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user