Improve bottom panel TUI design

This commit is contained in:
2026-10-27 23:04:52 +02:00
parent 4b53aad20c
commit d49721ff8e
3 changed files with 367 additions and 27 deletions
+22 -1
View File
@@ -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. 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 ## 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 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 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. - 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 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. - 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 ## Plan maintenance
+55
View File
@@ -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.
+284 -20
View File
@@ -32,26 +32,32 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
); );
return; return;
} }
let compact_height = area.height <= 8; let compact_height = area.height <= 9;
let header_height = if compact_height { 2 } else { 3 }; let header_height = if compact_height { 2 } else { 3 };
let footer_hints = keyboard_hints(state, forms); let footer_hints = keyboard_hints(state, forms);
let packed = pack_hints(&footer_hints, area.width.saturating_sub(2) as usize); let hint_width = 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, footer_height) = if compact_height {
let (footer_lines, omitted) = fit_hint_rows( let packed = pack_hints(&footer_hints, hint_width);
let (lines, omitted) = fit_hint_rows(
packed, packed,
max_footer_rows, area.height.saturating_sub(header_height + 3 + 2).max(1) as usize,
area.width.saturating_sub(2) as usize, hint_width,
); );
let footer_height = footer_lines let height = lines
.len() .len()
.saturating_add(2) .saturating_add(2)
.min(area.height.saturating_sub(header_height + 3) as usize) .min(area.height.saturating_sub(header_height + 3) as usize)
.max(3) as u16; .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() let rows = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(header_height), Constraint::Length(header_height),
Constraint::Min(3), Constraint::Min(1),
Constraint::Length(footer_height), Constraint::Length(footer_height),
]) ])
.split(area); .split(area);
@@ -337,10 +343,70 @@ fn fit_hint_rows(
(rows, true) (rows, true)
} }
fn standard_hint_rows(hints: &[Hint], width: usize) -> (Vec<Vec<Hint>>, bool) {
const MORE: &str = "… more";
let mut prioritized = hints.iter().enumerate().collect::<Vec<_>>();
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<Hint>], omitted: bool) { fn render_keyboard_panel(frame: &mut Frame<'_>, area: Rect, rows: &[Vec<Hint>], omitted: bool) {
let lines = rows let lines = rows
.iter() .iter()
.map(|row| { .enumerate()
.map(|(row_index, row)| {
let mut spans = Vec::new(); let mut spans = Vec::new();
for (index, hint) in row.iter().enumerate() { for (index, hint) in row.iter().enumerate() {
if index > 0 { if index > 0 {
@@ -358,7 +424,7 @@ fn render_keyboard_panel(frame: &mut Frame<'_>, area: Rect, rows: &[Vec<Hint>],
spans.push(Span::raw(" ")); spans.push(Span::raw(" "));
spans.push(Span::raw(hint.action)); spans.push(Span::raw(hint.action));
} }
if omitted { if omitted && row_index + 1 == rows.len() {
spans.push(Span::raw(" … more")); spans.push(Span::raw(" … more"));
} }
Line::from(spans) Line::from(spans)
@@ -1375,27 +1441,225 @@ mod tests {
"↑/↓ select", "↑/↓ select",
"Enter details", "Enter details",
"? Help", "? Help",
"q exit",
"n new", "n new",
"r rename", "r rename",
"d delete", "d delete",
"p preview", "p preview",
"c Commit", "c Commit",
"x discard",
"R restart", "R restart",
"o first file",
"O password reissue",
"b backups / restore",
"B backup",
"i import",
"! issues",
] { ] {
assert!( assert!(
text.contains(expected), 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::<String>()
.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::<String>()
})
.collect::<Vec<_>>();
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::<String>()
});
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::<String>()
})
.collect::<Vec<_>>();
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::<String>();
assert!(footer_header.contains("Keys"));
} }
#[test] #[test]