Improve bottom panel TUI design
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
packed,
|
let (lines, omitted) = fit_hint_rows(
|
||||||
max_footer_rows,
|
packed,
|
||||||
area.width.saturating_sub(2) as usize,
|
area.height.saturating_sub(header_height + 3 + 2).max(1) as usize,
|
||||||
);
|
hint_width,
|
||||||
let footer_height = footer_lines
|
);
|
||||||
.len()
|
let height = lines
|
||||||
.saturating_add(2)
|
.len()
|
||||||
.min(area.height.saturating_sub(header_height + 3) as usize)
|
.saturating_add(2)
|
||||||
.max(3) as u16;
|
.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()
|
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]
|
||||||
|
|||||||
Reference in New Issue
Block a user