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.
### 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
+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.
+290 -26
View File
@@ -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<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) {
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<Hint>],
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::<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]