From d49721ff8e6a40c1016e59b2648a6a622f84a4c4 Mon Sep 17 00:00:00 2001 From: sasa Date: Tue, 27 Oct 2026 23:04:52 +0200 Subject: [PATCH] Improve bottom panel TUI design --- PLANS.md | 23 ++- docs/evidence/tui-footer-layout.md | 55 +++++ src/tui/render.rs | 316 ++++++++++++++++++++++++++--- 3 files changed, 367 insertions(+), 27 deletions(-) create mode 100644 docs/evidence/tui-footer-layout.md diff --git a/PLANS.md b/PLANS.md index 2c91755..de96dfa 100644 --- a/PLANS.md +++ b/PLANS.md @@ -3702,8 +3702,29 @@ Available checks: focused service/application tests, `cargo fmt --all -- --check Official systemd source confirms root `systemctl` connects to `/run/systemd/private` directly when PID 1 is systemd; official `systemd(1)` docs caution this socket is private to systemd. Pinned zbus 5.19.0 exposes peer mode behind its approved crate's `p2p` feature. The implementation uses destination-free peer calls and local signal matching without bus owner monitoring; the bus path remains available. M16-R1 (P2, private permission category) was repaired and independently closed. M16-R2 (P2, private-branch behavioral evidence) remains open: local zbus peer credential access returned sandbox `EPERM`, and the prior disposable Debian VM has no installed/verified guest. No production or host socket was touched. The next action is a disposable-VM run with synthetic configuration covering both transports; until then M16 cannot be accepted. +### Bounded TUI maintenance TUI-FL-001 — Stable keyboard footer + +Status: complete (2026-10-27T22:59:34+02:00). This is a user-requested presentation repair to M7 while M16 remains blocked on independent systemd validation; it does not advance numbered milestones. + +Scope: `src/tui/render.rs` footer sizing, hint packing, and focused rendering tests only. Reserve two hint rows plus borders from the first standard render. Keep that height fixed across list selection, empty/populated views and action availability. At narrow widths show essential actions and an explicit route to existing Help without unreserved wrapping or silent clipping. Preserve the very-short-terminal compact path, shortcut behavior and context accuracy. + +| Task | Owner | State | Evidence required | +| --- | --- | --- | --- | +| TUI-FL-001.1 | implementer | complete for implementation scope | Fixed footer layout and synthetic buffer/height regressions | +| TUI-FL-001.2 | independent reviewer; repairer | complete | R1 marker clipping, R2 nine-row content and R3 false omission repaired and independently closed | +| TUI-FL-001.3 | coordinator | complete | Integrated diff, pinned Rust gates and [footer evidence](docs/evidence/tui-footer-layout.md) | + +- [x] Standard footer stays four rows total (two hints plus borders), with stable client-list height at fixed terminal size across selection and action changes. +- [x] Hints have readable, consistent ordering; narrow and very short layouts show context-accurate essentials and Help without extra wrapping or silent clipping. +- [x] Independent review closes findings and focused plus required Rust gates pass; evidence is recorded in [footer evidence](docs/evidence/tui-footer-layout.md). + +Checks: focused `tui::render::tests` and TUI workflow tests, `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked`, `cargo build --locked --release`, `git diff --check` all passed on the final Rust input. No service or production access. Exact outcomes and identities are in [footer evidence](docs/evidence/tui-footer-layout.md). + +First independent review found TUI-FL-001.2-R1 (P2, omission marker overflow) and R2 (P2, nine-row terminals lose client-list interior). A separate repairer fixed both and independent re-review closed them. That re-review found R3 (P3, unconditional marker reserve omits an action at width 102–103 even when all fit); the two-pass packing repair and regression were independently re-reviewed and closed. Final pinned Rust gates passed. [Evidence](docs/evidence/tui-footer-layout.md) separates synthetic render checks from any physical terminal verification. + ## Current progress and resume point +- TUI-FL-001 is complete (2026-10-27): fixed-height two-row keyboard footer, narrow Help/overflow behavior and compact nine-row handling passed synthetic rendering tests, independent R1–R3 closure and pinned Rust gates. [Footer evidence](docs/evidence/tui-footer-layout.md) identifies source/artifact inputs. M16 remains blocked and unaffected. - Milestone 16 is blocked after implementation, independent review/repair and passing pinned Rust gates: synthetic tests cover selection/failures, but private-peer behavior and both real systemd transports lack disposable-VM evidence. [M16 evidence](docs/evidence/milestone-16.md) records the source and artifact. No production result is claimed. - Milestone 15 is accepted (2026-10-27) for synthetic service-adapter and application behavior: typed failure/phase/unit reporting, documented `LoadUnit` resolution, conservative uncertainty, independent R1/R2 closure and pinned Rust gates. [M15 evidence](docs/evidence/milestone-15.md) records source/artifact identity. Disposable-VM systemd acceptance remains incomplete and unclaimed. - Milestone 14 is accepted (2026-10-27) for fixture restart orchestration: complete saved-peer projection, legacy/IPv4-only/broader-route eligibility, safe blockers and durable verified history passed independent review and pinned Rust gates. [M14 evidence](docs/evidence/milestone-14.md) records source/artifact identity. No disposable-VM systemd acceptance or production result is claimed. @@ -3726,7 +3747,7 @@ Official systemd source confirms root `systemctl` connects to `/run/systemd/priv - Milestone 8 delivered reviewed opt-in Alpine Testcontainers coverage for real IPv4 WireGuard peers, application lifecycle, saved/runtime separation, restoration, import/reissue, backup failures and the real Rustix reader. Host gates and three fresh real-container runs passed on one stable source fingerprint; M8-A1–A10 accepted. Real IPv6 behavior is unverified by user-authorized scope. - Milestone 9 is retired and unaccepted: the reviewed disposable VM preparation and guarded first start did not establish Debian/systemd acceptance. The exact VM was powered off after the user stopped work. M9.2–M9.3 were not started; M9-A1–A9 are unchecked. -- Next action: finish the selected disposable VM's guest installation/authorization, then run synthetic private-peer and system-bus restart acceptance before considering M16 accepted. Do not access production, commit, or deploy. +- Next action: M16 separately requires disposable-VM private-peer and system-bus acceptance. Do not access production, commit, or deploy. ## Plan maintenance diff --git a/docs/evidence/tui-footer-layout.md b/docs/evidence/tui-footer-layout.md new file mode 100644 index 0000000..e82019e --- /dev/null +++ b/docs/evidence/tui-footer-layout.md @@ -0,0 +1,55 @@ +# TUI-FL-001 evidence: stable keyboard footer + +Validated 2026-10-27T22:59:34+02:00 on the native +`x86_64-unknown-linux-gnu` development host with synthetic +Ratatui/TestBackend fixtures. No production configuration, host service, or +systemd VM was used. + +## Behavior and review + +The standard TUI layout now reserves four footer rows from the first draw: +two hint rows and their borders. The client-list region therefore keeps the +same height as selection and available actions change. Hints use a stable +priority order and fit into two bounded rows. A first packing pass shows every +hint when they fit; only actual overflow triggers a second pass reserving a +visible `… more` marker on the last row. The existing `? Help` route remains +visible in narrow list layouts. The source of context-sensitive hints and the +keyboard action handlers were not changed. Terminals with 8 or 9 rows use the +compact layout so the client pane retains visible content. + +Buffer-level tests cover empty/populated/selected list geometry at 80×24, +narrow widths 34/36/40, the compact 80×9 case, and widths 102/103 where all +actions fit without a false omission marker. Tests check complete rendered +labels and the marker row, not just source-side packing. Existing TUI workflow +tests cover the unchanged action behavior. + +An independent GPT-6-Luna/high reviewer found TUI-FL-001.2-R1 (P2, clipped +marker on the first row) and R2 (P2, border-only list at height 9). A separate +GPT-6-Luna/high repairer fixed both; re-review closed them. Re-review then found +R3 (P3, unnecessary omission from reserving marker space too early). The +repairer added a two-pass packer and a 102/103-column regression. Independent +re-review closed R3 with no further actionable finding. + +## Checks and identity + +Pinned `rustc`/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 were verified. +The final integrated revision passed from the repository root: + +- `cargo test --locked tui::render::tests`: 31 passed. +- `cargo fmt --all -- --check`: exit 0. +- `cargo clippy --locked --all-targets -- -D warnings`: exit 0. +- `cargo test --locked`: 370 passed, 3 explicitly ignored, 0 failed, including 2 documentation tests. +- `cargo build --locked --release`: exit 0. +- `git diff --check`: exit 0. + +Base commit: `4b53aad20cff6cec5d23f779702b58f0de599d44`. +Dirty Rust input `src/tui/render.rs` SHA256: +`ceae0bec7add49304a38f38987fdac554fc83e9d7d23695cf148f2f8404d3016`. +Native release artifact `target/release/wireguard-admin` SHA256: +`a16214c426b0cc9b29ec03124e965352e4f64f07c82d3ac23e4caf8e45d6273b`. +`PLANS.md` was also dirty with this maintenance record. The M16 private-systemd +validation gap remains separate and unverified; no Alpine or VM suite was rerun. + +Very narrow layouts intentionally show only priority hints plus the Help +affordance; additional shortcuts remain available through the existing Help +screen. A physical-terminal visual check was not performed. diff --git a/src/tui/render.rs b/src/tui/render.rs index a728b9f..46ca363 100644 --- a/src/tui/render.rs +++ b/src/tui/render.rs @@ -32,26 +32,32 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) { ); return; } - let compact_height = area.height <= 8; + let compact_height = area.height <= 9; let header_height = if compact_height { 2 } else { 3 }; let footer_hints = keyboard_hints(state, forms); - let packed = pack_hints(&footer_hints, area.width.saturating_sub(2) as usize); - let max_footer_rows = area.height.saturating_sub(header_height + 3 + 2).max(1) as usize; - let (footer_lines, omitted) = fit_hint_rows( - packed, - max_footer_rows, - area.width.saturating_sub(2) as usize, - ); - let footer_height = footer_lines - .len() - .saturating_add(2) - .min(area.height.saturating_sub(header_height + 3) as usize) - .max(3) as u16; + let hint_width = area.width.saturating_sub(2) as usize; + let (footer_lines, omitted, footer_height) = if compact_height { + let packed = pack_hints(&footer_hints, hint_width); + let (lines, omitted) = fit_hint_rows( + packed, + area.height.saturating_sub(header_height + 3 + 2).max(1) as usize, + hint_width, + ); + let height = lines + .len() + .saturating_add(2) + .min(area.height.saturating_sub(header_height + 3) as usize) + .max(3) as u16; + (lines, omitted, height) + } else { + let (lines, omitted) = standard_hint_rows(&footer_hints, hint_width); + (lines, omitted, 4) + }; let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(header_height), - Constraint::Min(3), + Constraint::Min(1), Constraint::Length(footer_height), ]) .split(area); @@ -337,10 +343,70 @@ fn fit_hint_rows( (rows, true) } +fn standard_hint_rows(hints: &[Hint], width: usize) -> (Vec>, bool) { + const MORE: &str = "… more"; + let mut prioritized = hints.iter().enumerate().collect::>(); + prioritized.sort_by_key(|(index, hint)| (hint_priority(hint), *index)); + + let pack = |reserve_more: bool| { + let mut rows = vec![Vec::new(), Vec::new()]; + let mut used = [0usize; 2]; + let mut omitted = false; + for (_, hint) in &prioritized { + let cost = hint.key.chars().count() + 1 + hint.action.chars().count(); + let mut placed = false; + for row_index in 0..2 { + let gap = usize::from(!rows[row_index].is_empty()) * 2; + let reserve = if reserve_more && row_index == 1 { + MORE.chars().count() + 2 + } else { + 0 + }; + if used[row_index] + gap + cost + reserve <= width { + used[row_index] += gap + cost; + rows[row_index].push((*hint).clone()); + placed = true; + break; + } + } + if !placed { + omitted = true; + } + } + if !omitted { + rows.retain(|row| !row.is_empty()); + } + (rows, omitted) + }; + + let (rows, omitted) = pack(false); + if omitted { pack(true) } else { (rows, false) } +} + +fn hint_priority(hint: &Hint) -> u8 { + match hint.key { + "↑/↓" => 0, + "Enter" => 1, + "?" => 2, + "n" => 3, + "r" => 4, + "d" => 5, + "p" => 6, + "c" => 7, + "R" => 8, + "x" => 9, + "q" => 10, + "Esc" => 0, + "Tab" => 1, + _ => 20, + } +} + fn render_keyboard_panel(frame: &mut Frame<'_>, area: Rect, rows: &[Vec], omitted: bool) { let lines = rows .iter() - .map(|row| { + .enumerate() + .map(|(row_index, row)| { let mut spans = Vec::new(); for (index, hint) in row.iter().enumerate() { if index > 0 { @@ -358,7 +424,7 @@ fn render_keyboard_panel(frame: &mut Frame<'_>, area: Rect, rows: &[Vec], spans.push(Span::raw(" ")); spans.push(Span::raw(hint.action)); } - if omitted { + if omitted && row_index + 1 == rows.len() { spans.push(Span::raw(" … more")); } Line::from(spans) @@ -1375,27 +1441,225 @@ mod tests { "↑/↓ select", "Enter details", "? Help", - "q exit", "n new", "r rename", "d delete", "p preview", "c Commit", - "x discard", "R restart", - "o first file", - "O password reissue", - "b backups / restore", - "B backup", - "i import", - "! issues", ] { assert!( text.contains(expected), - "missing {expected:?} in 80x24 render:\n{text}" + "missing priority hint {expected:?}:\n{text}" ); } - assert!(text.contains("R restart") && text.contains("r rename")); + assert!( + text.contains("… more"), + "omitted actions need an affordance: {text}" + ); + assert!(!text.contains("O password reissue")); + assert!( + keyboard_hints(&state, &FormController::default()) + .iter() + .any(|hint| hint.key == "O" && hint.action == "password reissue") + ); + } + + #[test] + fn standard_footer_is_four_rows_and_list_height_is_stable_across_states() { + let identity = PublicKey::from_bytes([4; 32]); + let states = [ + UiState { + view: Some(empty_view()), + ..UiState::default() + }, + UiState { + view: Some(saved_client_view(identity)), + ..UiState::default() + }, + UiState { + selected_identity: Some(identity), + view: Some(saved_client_view(identity)), + ..UiState::default() + }, + ]; + let mut header_rows = Vec::new(); + for state in &states { + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal + .draw(|frame| draw(frame, state, &FormController::default())) + .unwrap(); + let buffer = terminal.backend().buffer(); + let footer_top = 20; + assert!(buffer[(0, footer_top)].symbol().contains('┌')); + assert!(buffer[(79, footer_top)].symbol().contains('┐')); + assert!(buffer[(0, 23)].symbol().contains('└')); + assert!(buffer[(79, 23)].symbol().contains('┘')); + assert!(buffer[(0, 21)].symbol().contains('│')); + assert!(buffer[(0, 22)].symbol().contains('│')); + let text = rendered_text(&terminal); + assert!(text.lines().any(|line| line.contains("Keys"))); + let header_y = (0..buffer.area.height) + .find(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, *y)].symbol()) + .collect::() + .contains("State") + }) + .unwrap(); + header_rows.push(header_y); + } + assert!(header_rows.windows(2).all(|pair| pair[0] == pair[1])); + } + + #[test] + fn standard_narrow_footer_keeps_help_priority_and_never_wraps_or_clips() { + let identity = PublicKey::from_bytes([4; 32]); + for width in [34, 36, 40] { + for view in [empty_view(), saved_client_view(identity)] { + let state = UiState { + view: Some(view), + ..UiState::default() + }; + let hints = keyboard_hints(&state, &FormController::default()); + let (expected_rows, omitted) = standard_hint_rows(&hints, (width - 2) as usize); + let mut terminal = Terminal::new(TestBackend::new(width, 12)).unwrap(); + terminal + .draw(|frame| draw(frame, &state, &FormController::default())) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert!(buffer[(0, 8)].symbol().contains('┌')); + assert!(buffer[(0, 11)].symbol().contains('└')); + let footer_rows = [9, 10]; + let footer_text = footer_rows + .iter() + .map(|y| { + (1..width - 1) + .map(|x| buffer[(x, *y)].symbol()) + .collect::() + }) + .collect::>(); + let joined = footer_text.join("\n"); + assert!( + joined.contains("? Help"), + "Help missing at {width}: {joined}" + ); + assert!( + joined.contains("↑/↓ select"), + "navigation missing: {joined}" + ); + assert!( + joined.contains("… more"), + "omission marker missing: {joined}" + ); + assert!( + footer_text + .iter() + .all(|line| Span::raw(line.as_str()).width() <= (width - 2) as usize) + ); + for (row_index, expected_row) in expected_rows.iter().enumerate() { + for hint in expected_row { + let full_hint = format!("{} {}", hint.key, hint.action); + assert!( + footer_text[row_index].contains(&full_hint), + "full hint {full_hint:?} was clipped at {width}: {footer_text:?}" + ); + } + } + if omitted { + assert!( + footer_text[expected_rows.len() - 1].contains("… more"), + "reserved marker was clipped at {width}: {footer_text:?}" + ); + assert!( + footer_text[..expected_rows.len() - 1] + .iter() + .all(|line| !line.contains("… more")) + ); + } + if state.selected_client_mutable() { + assert!(joined.contains("r rename") && joined.contains("d delete")); + } else { + assert!(!joined.contains("r rename") && !joined.contains("d delete")); + } + } + } + } + + #[test] + fn standard_footer_does_not_reserve_marker_when_all_actions_fit() { + let identity = PublicKey::from_bytes([4; 32]); + let state = UiState { + selected_identity: Some(identity), + view: Some(saved_client_view(identity)), + ..UiState::default() + }; + let hints = keyboard_hints(&state, &FormController::default()); + + for width in [102, 103] { + let (expected_rows, omitted) = standard_hint_rows(&hints, (width - 2) as usize); + assert!(!omitted, "unexpected omissions at width {width}"); + assert_eq!(expected_rows.len(), 2, "expected two hint rows at {width}"); + + let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap(); + terminal + .draw(|frame| draw(frame, &state, &FormController::default())) + .unwrap(); + let buffer = terminal.backend().buffer(); + let rendered_rows = [21, 22].map(|y| { + (1..width - 1) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }); + for (row_index, expected_row) in expected_rows.iter().enumerate() { + for hint in expected_row { + let full_hint = format!("{} {}", hint.key, hint.action); + assert!( + rendered_rows[row_index].contains(&full_hint), + "full hint {full_hint:?} missing at width {width}: {rendered_rows:?}" + ); + } + } + assert!( + rendered_rows.iter().all(|line| !line.contains("… more")), + "false omission marker at width {width}: {rendered_rows:?}" + ); + assert!(rendered_rows.join("\n").contains("! issues")); + } + } + + #[test] + fn nine_row_terminal_shows_client_header_and_row_with_compact_footer() { + let identity = PublicKey::from_bytes([4; 32]); + let state = UiState { + view: Some(saved_client_view(identity)), + ..UiState::default() + }; + let mut terminal = Terminal::new(TestBackend::new(80, 9)).unwrap(); + terminal + .draw(|frame| draw(frame, &state, &FormController::default())) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let main_text = (0..3) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y + 2)].symbol()) + .collect::() + }) + .collect::>(); + assert!(main_text[0].contains("State") && main_text[0].contains("Name")); + assert!( + main_text[1].contains("synthetic"), + "client row missing: {main_text:?}" + ); + assert!(main_text[0..2].iter().all(|line| !line.contains('┌'))); + assert!(buffer[(0, 5)].symbol().contains('┌')); + assert!(buffer[(0, 8)].symbol().contains('└')); + let footer_header = (0..buffer.area.width) + .map(|x| buffer[(x, 5)].symbol()) + .collect::(); + assert!(footer_header.contains("Keys")); } #[test]