Add visible styled keyboard quick reference in bottom panel

This commit is contained in:
2026-10-26 22:59:11 +02:00
parent 7d0f006601
commit e141c03c2c
3 changed files with 677 additions and 16 deletions
+110 -10
View File
@@ -118,7 +118,8 @@ Each row is a delivery milestone, not a completion claim. Expand its record befo
| 6 | Client configuration delivery and reissue lifecycle | 5 | accepted | | 6 | Client configuration delivery and reissue lifecycle | 5 | accepted |
| 7 | Complete terminal management workflow | 6 | accepted | | 7 | Complete terminal management workflow | 6 | accepted |
| 8 | Alpine Testcontainers integration | 7; suitable disposable Docker environment | accepted | | 8 | Alpine Testcontainers integration | 7; suitable disposable Docker environment | accepted |
| 9 | Real systemd acceptance and release readiness | 8; disposable systemd VM | in_progress | | 9 | Real systemd acceptance and release readiness | 8; disposable systemd VM | retired |
| 10 | Visible styled keyboard quick reference in bottom panel | 8; existing M7 TUI | in_progress |
### Milestone 1 — Rust foundation, settings, and domain states ### Milestone 1 — Rust foundation, settings, and domain states
@@ -2977,12 +2978,14 @@ review closed all actionable findings and the host and repeated real-container
gates passed on one source fingerprint. M8-A1–A10 are satisfied within that gates passed on one source fingerprint. M8-A1–A10 are satisfied within that
scope. IPv6 capability and traffic were excluded by user direction; real scope. IPv6 capability and traffic were excluded by user direction; real
systemd/D-Bus behavior remains unverified and belongs to M9. systemd/D-Bus behavior remains unverified and belongs to M9.
Milestone 9 remains pending. Milestone 9 was later retired by user direction; this historical M8 acceptance
does not establish real systemd/D-Bus behavior.
### Milestone 9 — Real systemd acceptance and release readiness ### Milestone 9 — Real systemd acceptance and release readiness
Status: paused (2026-10-26T20:30:15Z; user explicitly stopped Milestone 9 as Status: retired (2026-10-26T20:52:01Z; user judged the milestone goal
over-engineering). No VM acceptance result exists. unnecessary over-engineering and removed it from the development sequence).
No M9 acceptance result exists; real systemd/D-Bus behavior remains unverified.
Prerequisites: milestones 1–8 accepted; explicitly selected disposable Linux VM Prerequisites: milestones 1–8 accepted; explicitly selected disposable Linux VM
with its own kernel, systemd as PID 1, system D-Bus, WireGuard and synthetic data; with its own kernel, systemd as PID 1, system D-Bus, WireGuard and synthetic data;
compatible pinned-toolchain artifacts and a bounded management/console channel. compatible pinned-toolchain artifacts and a bounded management/console channel.
@@ -3261,22 +3264,119 @@ service callback is not systemd evidence. Carry forward that distinction. Initia
M9 evidence is unavailable until actual guest execution; record missing historical M9 evidence is unavailable until actual guest execution; record missing historical
inputs as unavailable rather than reconstructing them. inputs as unavailable rather than reconstructing them.
Next action: none. User stopped Milestone 9. Do not restart the VM or resume Next action: none for M9. The user retired it and authorized development to
M9.1–M9.3 without a new explicit request. No M9-A1–A9 check is complete. continue with M10. Do not restart the VM or resume M9 work without a new explicit
request. No M9-A1–A9 check is complete.
#### Outcome #### Outcome
Paused at the user's request. The separate disposable Debian VM Retired at the user's request. The separate disposable Debian VM
`b14c850b-a21e-4a56-acca-785590f0c4ac` was powered off; a read-only `b14c850b-a21e-4a56-acca-785590f0c4ac` was powered off; a read-only
running-VM inventory was empty afterward. M9.1 remains incomplete, M9.2–M9.3 running-VM inventory was empty afterward. M9.1 remains incomplete, M9.2–M9.3
have not run, and M9-A1–A9 remain unchecked. Prior host gates and M9.4 have not run, and M9-A1–A9 remain unchecked. Prior host gates and M9.4
documentation passed; no release acceptance or deployment is claimed. documentation passed; no release acceptance or deployment is claimed.
Real IPv6 remains separately unverified by the previously accepted test scope. Real IPv6 remains separately unverified by the previously accepted test scope.
### Milestone 10 — Visible styled keyboard quick reference in bottom panel
Status: in_progress (2026-10-26T20:52:01Z). The user retired M9 and explicitly
directed development to continue. M1–M8 are accepted; M9 is no longer a
prerequisite. M10 implementation, review, and validation are starting now.
#### Outcome and scope
Display discoverable keyboard controls inside the bordered panel at the very
bottom of the terminal. Users should be able to find routine operations without
opening Help. Render each hint as a distinct key label followed by its action,
with bold/color emphasis on keys and readable action text. Keep UI text in
English, following repository conventions. These are keyboard hints; adding
mouse interaction is outside this milestone.
The present `src/tui/render.rs::draw` reserves only two rows for a fully bordered
footer. Its top and bottom borders leave no interior row, hiding the existing
plain shortcut text. Repair layout as well as styling; merely recoloring that
paragraph cannot meet the requirement.
#### Context and implementation steps
| Task | Owner | Scope and expected result | State |
| --- | --- | --- | --- |
| M10.1 | implementer | Inspect `src/tui/render.rs`, `state.rs`, `forms.rs` and `terminal.rs`; derive hints from actual supported keys and screen/form state. Implement the bottom panel with sufficient interior height, styled key/action groups and width-aware packing. | needs_changes: initial 7 render tests passed; independent R1/R2 found compact/form key-hint mismatches; targeted repair pending |
| M10.2 | implementer | Cover resize, narrow/minimum terminals, list/details, forms, progress and QR disclosure with synthetic render/navigation checks. Update README shortcut guidance if needed. | pending |
| M10.3 | reviewer / repairer | Independent review of actual layout, hints versus handlers, readability, clipping and state correctness; repair and re-review actionable findings. | pending |
| M10.4 | coordinator | Run integrated gates, record source/artifact identity and actual results, maintain this plan and make the scoped acceptance decision. | pending |
List/details quick reference must include navigation (`Up/Down`, `Enter`, `Esc`
as applicable), `?` Help and `q` Exit, plus the implemented management keys:
`n` New, `r` Rename, `d` Delete, `p` Preview, `c` Commit, `x` Discard,
`R` Restart, `o` First file, `O` Reissue, `b` Backups, `B` Backup,
`s` Setup and `i` Import. Distinguish uppercase from lowercase visibly.
Context-dependent actions must be omitted or explicitly marked unavailable
when their real prerequisites are not met. Form hints must reflect their actual
Enter/Tab/Esc behavior; busy screens must not advertise blocked submissions.
QR disclosure must retain a visible hint for clearing it with Esc.
Use the existing Ratatui styles and dependencies. Select contrasting styles for
navigation, ordinary actions and destructive actions, while keeping key/action
text sufficient without color. Adapt panel height to packed rows and terminal
height, preserve usable main content, and retain the existing too-small-terminal
fallback. At narrow widths prioritize navigation and Help, showing an explicit
Help/overflow hint for omitted commands rather than silently clipping them.
Keep Help as the complete reference and preserve all existing operation semantics.
#### Acceptance and validation
- [ ] M10-A1: the lowest bordered panel has visible interior text; at 80x24 all
applicable list actions are displayed in readable packed rows.
- [ ] M10-A2: key labels are visually distinct with bold/color styling; action
text is readable, and meaning remains understandable without color.
- [ ] M10-A3: hints match actual key handlers, uppercase/lowercase and active
screen/form prerequisites; no hint implies a disabled operation can run.
- [ ] M10-A4: resizing through 120x30, 80x24, 40x8 and the supported minimum
34x8 causes no panic, overlapping borders or invisible footer text; compact
layouts retain navigation/Help and indicate omitted commands. Below-minimum
sizes retain a readable resize message.
- [ ] M10-A5: list/detail navigation, forms, progress and QR remain usable;
footer rendering exposes no secrets and performs no application mutations.
- [ ] M10-A6: focused synthetic rendering checks and all required Rust gates
pass; independent review closes actionable findings with recorded evidence.
Planned validation: `cargo test --locked tui::`,
`cargo test --locked --test tui_workflow`, then `cargo fmt --all -- --check`,
`cargo clippy --locked --all-targets -- -D warnings`, `cargo test --locked`,
`cargo build --locked --release` and `git diff --check`. Use synthetic fixtures
and Ratatui test buffers; inspect styles as well as rendered text and geometry.
No production or VM operations are needed for this presentation change. These
checks have not been run for M10; no implementation or acceptance is claimed.
#### Review, evidence and handoff
M10.1 initial implementation added visible styled footer rows and passed
`cargo test --locked tui::render` (7/7), `cargo fmt --all -- --check`, and
`git diff --check`. Independent review found **M10.1-R1 (P2, open)**: compact
rows advertise list navigation/Help during forms, QR, and pending work;
**M10.1-R2 (P2, open)**: generic form Enter is false for draft review and Tab
is missing from tabbed forms. Targeted repair/re-review is next; no acceptance
item is closed yet.
The user removed M9 as
a prerequisite without accepting its unchecked M9-A1–A9 criteria. The project
configuration specifies GPT-6-Sol/medium for the coordinator and GPT-6-Luna/high
for all four custom roles. Start with M10.1, one writer at a time. The
coordinator owns this plan; record detailed evidence in
`docs/evidence/milestone-10.md` only after actual checks exist. No new
dependencies are planned.
#### Outcome
Milestone in progress; no M10 acceptance is claimed. Next action: implement
M10.1, then independent review/repair and M10.2–M10.4 checks. M9 stays retired.
## Current progress and resume point ## Current progress and resume point
- Bounded TUI maintenance (2026-10-26): repaired list arrows opening client details. Up/Down now only move the highlight; Enter opens details and Esc returns. Initial navigation follows the renderer's first-row highlight. Independent review closed NAV-001 with no remaining scoped findings; validation is recorded in [navigation repair evidence](docs/evidence/tui-navigation.md). This does not advance M9 or supply VM/Alpine acceptance evidence; prior artifact identities remain historical after this Rust change. - Bounded TUI maintenance (2026-10-26): repaired list arrows opening client details. Up/Down now only move the highlight; Enter opens details and Esc returns. Initial navigation follows the renderer's first-row highlight. Independent review closed NAV-001 with no remaining scoped findings; validation is recorded in [navigation repair evidence](docs/evidence/tui-navigation.md). This does not advance M9 or supply VM/Alpine acceptance evidence; prior artifact identities remain historical after this Rust change.
- Milestones 1–8 are accepted; milestone 9 is paused by the user and unaccepted. - Milestones 1–8 are accepted; milestone 9 is retired by user direction and unaccepted. Its real systemd/D-Bus checks remain unverified and are not a prerequisite to M10.
- Milestone 10 is in progress: implement the styled keyboard quick reference in the bottom bordered panel, then review and validate it.
- The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md). - The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md).
- Milestone 1 is expanded and the Rust environment is pinned. - Milestone 1 is expanded and the Rust environment is pinned.
- Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests. - Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests.
@@ -3288,8 +3388,8 @@ Real IPv6 remains separately unverified by the previously accepted test scope.
- Milestone 7 delivered the native terminal workflow, safe startup/session projection, bounded single-owner worker, setup/import/draft/Commit/restart/delivery/backup/restore forms, synthetic PTY and workflow tests, and documentation. Independent R2 closed its list-status finding; all planned coordinator gates passed. Real WireGuard and systemd remain M8/M9. - Milestone 7 delivered the native terminal workflow, safe startup/session projection, bounded single-owner worker, setup/import/draft/Commit/restart/delivery/backup/restore forms, synthetic PTY and workflow tests, and documentation. Independent R2 closed its list-status finding; all planned coordinator gates passed. Real WireGuard and systemd remain M8/M9.
- 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 paused 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: none unless the user explicitly requests more Milestone 9 work. Do not restart the VM, access production, commit, or deploy. - Next action: execute M10.1. Do not restart the VM, access production, commit, or deploy.
## Plan maintenance ## Plan maintenance
+127
View File
@@ -39,6 +39,14 @@ pub enum FormAction {
Feedback(String), Feedback(String),
} }
/// Public, secret-free summary of keyboard capabilities for the active form.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct FormKeyContext {
pub enter_action: Option<&'static str>,
pub supports_tab: bool,
pub supports_selection: bool,
}
enum Form { enum Form {
Setup { Setup {
password: SecretInput, password: SecretInput,
@@ -153,6 +161,67 @@ impl FormController {
matches!(self.active, Some(Form::DraftReview)) matches!(self.active, Some(Form::DraftReview))
} }
/// Describes only keys handled by the active form; never exposes form data.
pub fn key_context(&self) -> Option<FormKeyContext> {
self.active.as_ref().map(|form| match form {
Form::Setup { .. } => FormKeyContext {
enter_action: Some("next / submit"),
supports_tab: true,
supports_selection: false,
},
Form::Create { .. } => FormKeyContext {
enter_action: Some("next / submit"),
supports_tab: true,
supports_selection: false,
},
Form::Rename { .. } => FormKeyContext {
enter_action: Some("submit"),
supports_tab: false,
supports_selection: false,
},
Form::Import { .. } => FormKeyContext {
enter_action: Some("submit"),
supports_tab: false,
supports_selection: false,
},
Form::Confirm(_) => FormKeyContext {
enter_action: Some("confirm"),
supports_tab: false,
supports_selection: false,
},
Form::DraftReview => FormKeyContext {
enter_action: None,
supports_tab: false,
supports_selection: false,
},
Form::DeliveryPath { .. } => FormKeyContext {
enter_action: Some("prepare file"),
supports_tab: true,
supports_selection: false,
},
Form::ReissuePassword { .. } => FormKeyContext {
enter_action: Some("submit password"),
supports_tab: false,
supports_selection: false,
},
Form::Backups { items, .. } => FormKeyContext {
enter_action: (!items.is_empty()).then_some("preview restore"),
supports_tab: false,
supports_selection: !items.is_empty(),
},
Form::Restore(_) => FormKeyContext {
enter_action: Some("confirm restore"),
supports_tab: false,
supports_selection: false,
},
Form::Disclosure { .. } => FormKeyContext {
enter_action: None,
supports_tab: false,
supports_selection: false,
},
})
}
pub fn qr_output(&self) -> Option<&QrCodeOutput> { pub fn qr_output(&self) -> Option<&QrCodeOutput> {
match self.active.as_ref() { match self.active.as_ref() {
Some(Form::Disclosure { qr, .. }) => Some(qr), Some(Form::Disclosure { qr, .. }) => Some(qr),
@@ -888,6 +957,64 @@ mod tests {
use super::*; use super::*;
use crate::backup::{BackupReason, HistoricalApplyStatus}; use crate::backup::{BackupReason, HistoricalApplyStatus};
#[test]
fn key_context_matches_tabbed_forms_and_inert_draft_review() {
let mut form = FormController::default();
form.open_setup();
assert_eq!(
form.key_context(),
Some(FormKeyContext {
enter_action: Some("next / submit"),
supports_tab: true,
supports_selection: false,
})
);
form.open_create();
assert!(form.key_context().unwrap().supports_tab);
assert_eq!(
form.key_context().unwrap().enter_action,
Some("next / submit")
);
form.open_draft_review();
assert_eq!(
form.key_context(),
Some(FormKeyContext {
enter_action: None,
supports_tab: false,
supports_selection: false,
})
);
assert!(matches!(form.handle_key(KeyCode::Enter), FormAction::None));
}
#[test]
fn delivery_path_and_backup_selection_expose_their_actual_keys() {
let identity = PublicKey::from_bytes([0x61; 32]);
let mut form = FormController::default();
form.open_delivery(identity, false);
assert!(form.key_context().unwrap().supports_tab);
assert_eq!(
form.key_context().unwrap().enter_action,
Some("prepare file")
);
form.open_backups(vec![ArchiveSummary {
file_name: "backup.zip".to_owned(),
created_at_utc: "2026-01-01T00:00:00Z".to_owned(),
reason: BackupReason::Manual,
historical_apply: HistoricalApplyStatus::Unknown,
}]);
assert_eq!(
form.key_context(),
Some(FormKeyContext {
enter_action: Some("preview restore"),
supports_tab: false,
supports_selection: true,
})
);
form.open_backups(Vec::new());
assert_eq!(form.key_context().unwrap().enter_action, None);
}
#[test] #[test]
fn setup_password_is_masked_confirmation_required_and_secret_command_has_no_debug() { fn setup_password_is_masked_confirmation_required_and_secret_command_has_no_debug() {
let mut form = FormController::default(); let mut form = FormController::default();
+440 -6
View File
@@ -1,7 +1,7 @@
//! Responsive, secret-free rendering for the client list and detail screens. //! Responsive, secret-free rendering for the client list and detail screens.
use ratatui::Frame; use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text}; use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
@@ -33,12 +33,27 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
); );
return; return;
} }
let compact_height = area.height <= 8;
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 rows = Layout::default() let rows = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(3), Constraint::Length(header_height),
Constraint::Min(3), Constraint::Min(3),
Constraint::Length(2), Constraint::Length(footer_height),
]) ])
.split(area); .split(area);
let status = state let status = state
@@ -108,10 +123,257 @@ pub fn draw(frame: &mut Frame<'_>, state: &UiState, forms: &FormController) {
Screen::ClientList => render_list(frame, rows[1], state), Screen::ClientList => render_list(frame, rows[1], state),
} }
} }
render_keyboard_panel(frame, rows[2], &footer_lines, omitted);
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum HintKind {
Navigation,
Action,
Destructive,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct Hint {
key: &'static str,
action: &'static str,
kind: HintKind,
}
fn keyboard_hints(state: &UiState, forms: &FormController) -> Vec<Hint> {
let mut hints = Vec::new();
let mut add = |key, action, kind| hints.push(Hint { key, action, kind });
if forms.is_active() {
if forms.qr_output().is_some() {
add("Esc", "clear QR", HintKind::Destructive);
} else {
add("Esc", "cancel", HintKind::Destructive);
if let Some(context) = forms.key_context() {
if let Some(action) = context.enter_action {
add("Enter", action, HintKind::Action);
}
if context.supports_tab {
add("Tab", "change field", HintKind::Navigation);
}
if context.supports_selection {
add("↑/↓", "select archive", HintKind::Navigation);
}
}
}
return hints;
}
if state.pending.is_some() || state.screen == Screen::Progress {
add("…", "busy", HintKind::Navigation);
return hints;
}
match state.screen {
Screen::Help => {
add("Esc", "return to list", HintKind::Navigation);
add("?", "Help", HintKind::Navigation);
}
Screen::Message => {
add("Esc", "return to list", HintKind::Navigation);
add("↑/↓", "scroll", HintKind::Navigation);
}
Screen::ClientDetails => {
add("Esc", "client list", HintKind::Navigation);
add("↑/↓", "scroll", HintKind::Navigation);
add("?", "Help", HintKind::Navigation);
add("n", "new", HintKind::Action);
add("i", "import", HintKind::Action);
add("p", "preview", HintKind::Action);
add("c", "Commit", HintKind::Action);
add("x", "discard", HintKind::Destructive);
add("R", "restart", HintKind::Destructive);
add("b", "backups / restore", HintKind::Action);
add("B", "backup", HintKind::Action);
add("!", "issues", HintKind::Action);
if state
.view
.as_ref()
.is_some_and(|view| !view.vault_initialized)
{
add("s", "setup", HintKind::Action);
}
if state.selected_client_mutable() {
add("r", "rename", HintKind::Action);
add("d", "delete", HintKind::Destructive);
let saved = state
.selected_identity
.and_then(|id| state.view.as_ref()?.client_states.get(&id))
.is_some_and(|row| row.saved.is_some());
if saved {
add("o", "first file", HintKind::Action);
add("O", "password reissue", HintKind::Action);
}
}
}
Screen::ClientList => {
let has_view = state.view.is_some();
let selected = state.selected_client_mutable();
add("↑/↓", "select", HintKind::Navigation);
if state
.view
.as_ref()
.is_some_and(|view| !view.clients.is_empty())
{
add("Enter", "details", HintKind::Navigation);
}
add("?", "Help", HintKind::Navigation);
if has_view {
add("n", "new", HintKind::Action);
if selected {
add("r", "rename", HintKind::Action);
add("d", "delete", HintKind::Destructive);
}
add("p", "preview", HintKind::Action);
add("c", "Commit", HintKind::Action);
add("x", "discard", HintKind::Destructive);
add("R", "restart", HintKind::Destructive);
if let Some(id) = state.selected_identity {
if state
.view
.as_ref()
.and_then(|view| view.client_states.get(&id))
.is_some_and(|row| row.saved.is_some())
&& selected
{
add("o", "first file", HintKind::Action);
add("O", "password reissue", HintKind::Action);
}
}
add("b", "backups / restore", HintKind::Action);
add("B", "backup", HintKind::Action);
if state
.view
.as_ref()
.is_some_and(|view| !view.vault_initialized)
{
add("s", "setup", HintKind::Action);
}
}
add("i", "import", HintKind::Action);
if has_view {
add("!", "issues", HintKind::Action);
}
}
Screen::Progress => unreachable!(),
}
add("q", "exit", HintKind::Destructive);
hints
}
fn pack_hints(hints: &[Hint], width: usize) -> Vec<Vec<Hint>> {
let mut rows = Vec::<Vec<Hint>>::new();
let mut used = 0;
for hint in hints {
let cost = hint.key.len() + hint.action.len() + 3;
if rows.is_empty() || used + cost > width {
rows.push(Vec::new());
used = 0;
}
if cost > width {
continue;
}
if !rows.last().is_some_and(Vec::is_empty) {
used += 2;
}
used += cost;
rows.last_mut().expect("row was created").push(hint.clone());
}
rows.retain(|row| !row.is_empty());
rows
}
fn fit_hint_rows(
mut rows: Vec<Vec<Hint>>,
max_rows: usize,
width: usize,
) -> (Vec<Vec<Hint>>, bool) {
if max_rows == 1 && width < 40 {
let hints = rows.into_iter().flatten().collect::<Vec<_>>();
let hint_count = hints.len();
let list_navigation = hints.iter().find(|hint| hint.key == "↑/↓");
let help = hints.iter().find(|hint| hint.key == "?");
let mut compact = Vec::new();
if let Some(navigation) = list_navigation {
compact.push(navigation.clone());
}
if let Some(help) = help {
compact.push(help.clone());
}
if compact.is_empty() {
let candidates = hints
.into_iter()
.map(|mut hint| {
hint.action = match hint.key {
"Enter" => "continue",
"Tab" => "field",
_ => hint.action,
};
hint
})
.collect::<Vec<_>>();
let total_width = candidates
.iter()
.map(|hint| hint.key.len() + hint.action.len() + 1)
.sum::<usize>()
+ candidates.len().saturating_sub(1) * 2;
if total_width <= width {
return (vec![candidates], false);
}
let available = width.saturating_sub(" … more".len());
let mut used = 0;
for hint in candidates {
let cost = hint.key.len() + hint.action.len() + 1;
let with_gap = cost + usize::from(!compact.is_empty()) * 2;
if used + with_gap <= available {
used += with_gap;
compact.push(hint);
}
}
}
let omitted = compact.len() < hint_count;
return (vec![compact], omitted);
}
if rows.len() <= max_rows {
return (rows, false);
}
rows.truncate(max_rows);
(rows, true)
}
fn render_keyboard_panel(frame: &mut Frame<'_>, area: Rect, rows: &[Vec<Hint>], omitted: bool) {
let lines = rows
.iter()
.map(|row| {
let mut spans = Vec::new();
for (index, hint) in row.iter().enumerate() {
if index > 0 {
spans.push(Span::raw(" "));
}
let color = match hint.kind {
HintKind::Navigation => Color::Cyan,
HintKind::Action => Color::Green,
HintKind::Destructive => Color::Red,
};
spans.push(Span::styled(
hint.key,
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
spans.push(Span::raw(hint.action));
}
if omitted {
spans.push(Span::raw(" … more"));
}
Line::from(spans)
})
.collect::<Vec<_>>();
frame.render_widget( frame.render_widget(
Paragraph::new("n new r rename d delete p preview c Commit R restart o first file O reissue b backups B backup s setup i import x discard q exit") Paragraph::new(Text::from(lines))
.block(Block::default().borders(Borders::ALL)), .block(Block::default().borders(Borders::ALL).title("Keys")),
rows[2], area,
); );
} }
@@ -545,6 +807,178 @@ mod tests {
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
fn rendered_text(terminal: &Terminal<TestBackend>) -> String {
let buffer = terminal.backend().buffer();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn empty_view() -> SessionView {
SessionView {
generation: 0,
clients: Vec::new(),
client_states: BTreeMap::new(),
document_issues: Vec::new(),
applied: super::super::state::AppliedState::Unknown,
reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(),
has_draft: true,
vault_initialized: false,
}
}
#[test]
fn bottom_keyboard_panel_has_visible_interior_and_styled_key_labels() {
let state = UiState {
view: Some(empty_view()),
..UiState::default()
};
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
let text = rendered_text(&terminal);
assert!(text.contains("Keys"));
assert!(text.contains("new"));
assert!(text.contains("Commit"));
for hint in [
"R restart",
"b backups / restore",
"B backup",
"s setup",
"i import",
"x discard",
] {
assert!(text.contains(hint), "missing list hint: {hint}");
}
let buffer = terminal.backend().buffer();
assert!(buffer[(0, 23)].symbol().contains('└'));
let key = (0..buffer.area.height)
.flat_map(|y| (0..buffer.area.width).map(move |x| (x, y)))
.find(|(x, y)| {
let style = buffer[(*x, *y)].style();
style.fg == Some(Color::Green) && style.add_modifier.contains(Modifier::BOLD)
})
.unwrap();
let style = buffer[key].style();
assert!(style.add_modifier.contains(Modifier::BOLD));
assert_eq!(style.fg, Some(Color::Green));
}
#[test]
fn compact_layout_keeps_footer_text_and_navigation_help_priority() {
let state = UiState {
view: Some(empty_view()),
..UiState::default()
};
let mut terminal = Terminal::new(TestBackend::new(34, 8)).unwrap();
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
let text = rendered_text(&terminal);
assert!(text.contains("Keys"));
assert!(text.contains("select"));
assert!(text.contains("? Help"));
assert!(text.contains("… more"));
assert!(text.lines().last().unwrap().contains('└'));
}
#[test]
fn compact_form_hints_follow_the_active_form_without_leaking_input() {
let state = UiState::default();
let mut forms = FormController::default();
forms.open_setup();
for character in "secret-value".chars() {
forms.handle_key(crossterm::event::KeyCode::Char(character));
}
let mut terminal = Terminal::new(TestBackend::new(40, 8)).unwrap();
terminal.draw(|frame| draw(frame, &state, &forms)).unwrap();
let text = rendered_text(&terminal);
assert!(text.contains("Esc cancel"));
assert!(text.contains("Enter continue"));
assert!(text.contains("Tab field"));
assert!(!text.contains("secret-value"));
assert!(!text.contains("↑/↓"));
assert!(!text.contains("? Help"));
}
#[test]
fn compact_pending_panel_does_not_offer_commands() {
let state = UiState {
screen: Screen::Progress,
..UiState::default()
};
let mut terminal = Terminal::new(TestBackend::new(34, 8)).unwrap();
terminal
.draw(|frame| draw(frame, &state, &FormController::default()))
.unwrap();
let text = rendered_text(&terminal);
assert!(text.contains("busy"));
assert!(!text.contains("↑/↓"));
assert!(!text.contains("? Help"));
assert!(!text.contains("Enter"));
}
#[test]
fn detail_hints_only_offer_saved_delivery_for_mutable_saved_selection() {
let identity = PublicKey::from_bytes([3; 32]);
let client = crate::domain::Client::new(
identity,
"client",
crate::domain::ClientAddresses {
ipv4: Ipv4Addr::new(10, 0, 0, 2),
ipv6: None,
},
)
.unwrap();
let view = SessionView {
generation: 0,
clients: vec![ClientDisplay {
peer_index: 0,
line: 1,
identity: Some(identity),
label: "client".to_owned(),
allowed_ips: vec![],
issues: vec![],
mutable_identity: Some(identity),
}],
client_states: BTreeMap::from([(
identity,
super::super::state::ClientStateRow {
saved: Some(client.clone()),
draft: Some(client),
},
)]),
document_issues: Vec::new(),
applied: super::super::state::AppliedState::Unknown,
reserved_addresses: Default::default(),
statistics: RuntimeObservation::unknown(),
has_draft: false,
vault_initialized: true,
};
let state = UiState {
screen: Screen::ClientDetails,
selected_identity: Some(identity),
view: Some(view),
..UiState::default()
};
let labels = keyboard_hints(&state, &FormController::default())
.iter()
.map(|hint| hint.key)
.collect::<Vec<_>>();
assert!(labels.contains(&"Esc"));
assert!(labels.contains(&"o"));
assert!(labels.contains(&"O"));
assert!(labels.contains(&"r"));
assert!(labels.contains(&"d"));
}
#[test] #[test]
fn terminal_controls_are_escaped_for_display() { fn terminal_controls_are_escaped_for_display() {
assert_eq!( assert_eq!(