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 |
|
| 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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!(
|
||||||
|
|||||||
Reference in New Issue
Block a user