Add visible styled keyboard quick reference in bottom panel
This commit is contained in:
@@ -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 |
|
||||
| 7 | Complete terminal management workflow | 6 | 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
|
||||
|
||||
@@ -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
|
||||
scope. IPv6 capability and traffic were excluded by user direction; real
|
||||
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
|
||||
|
||||
Status: paused (2026-10-26T20:30:15Z; user explicitly stopped Milestone 9 as
|
||||
over-engineering). No VM acceptance result exists.
|
||||
Status: retired (2026-10-26T20:52:01Z; user judged the milestone goal
|
||||
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
|
||||
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.
|
||||
@@ -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
|
||||
inputs as unavailable rather than reconstructing them.
|
||||
|
||||
Next action: none. User stopped Milestone 9. Do not restart the VM or resume
|
||||
M9.1–M9.3 without a new explicit request. No M9-A1–A9 check is complete.
|
||||
Next action: none for M9. The user retired it and authorized development to
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
documentation passed; no release acceptance or deployment is claimed.
|
||||
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
|
||||
|
||||
- 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).
|
||||
- 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.
|
||||
@@ -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 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.
|
||||
- Next action: none unless the user explicitly requests more Milestone 9 work. Do not restart the VM, access production, commit, or deploy.
|
||||
- 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: execute M10.1. Do not restart the VM, access production, commit, or deploy.
|
||||
|
||||
## Plan maintenance
|
||||
|
||||
|
||||
@@ -39,6 +39,14 @@ pub enum FormAction {
|
||||
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 {
|
||||
Setup {
|
||||
password: SecretInput,
|
||||
@@ -153,6 +161,67 @@ impl FormController {
|
||||
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> {
|
||||
match self.active.as_ref() {
|
||||
Some(Form::Disclosure { qr, .. }) => Some(qr),
|
||||
@@ -888,6 +957,64 @@ mod tests {
|
||||
use super::*;
|
||||
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]
|
||||
fn setup_password_is_masked_confirmation_required_and_secret_command_has_no_debug() {
|
||||
let mut form = FormController::default();
|
||||
|
||||
+440
-6
@@ -1,7 +1,7 @@
|
||||
//! Responsive, secret-free rendering for the client list and detail screens.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
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;
|
||||
}
|
||||
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()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(header_height),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(footer_height),
|
||||
])
|
||||
.split(area);
|
||||
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),
|
||||
}
|
||||
}
|
||||
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(
|
||||
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")
|
||||
.block(Block::default().borders(Borders::ALL)),
|
||||
rows[2],
|
||||
Paragraph::new(Text::from(lines))
|
||||
.block(Block::default().borders(Borders::ALL).title("Keys")),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -545,6 +807,178 @@ mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
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]
|
||||
fn terminal_controls_are_escaped_for_display() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user