From a0388a5340d4ad760a61741746d38d65a8accdb0 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 16:42:43 +1000 Subject: [PATCH 01/11] ux(status): typed status severity on every panel, drop the wording heuristic (#577) Every main-page panel's status_message (communities, identity, vetting, inbox, relationships, credentials, settings, capabilities) and the device access view's message are now a typed Status { severity, text }. The producer that knows whether its operation worked sets the severity; the renderer colours by severity only. The string-prefix/marker heuristic in components/status.rs is deleted. Severity and Status move from main_page/repos.rs to main_page/status.rs (re-exported where they were) so rooms, elections and repos share one type and one style table (severity_style). Actions that carried a status string (VettingAction::Status, SettingsAction::ClipboardCopied) carry a Status. Signed-off-by: Glenn Gore --- openvtc/src/state_handler/actions/mod.rs | 4 +- .../src/state_handler/background_dispatch.rs | 4 +- .../src/state_handler/capability_actions.rs | 17 ++- .../src/state_handler/community_actions.rs | 53 +++++--- .../community_context_actions.rs | 82 +++++++----- .../src/state_handler/credential_actions.rs | 3 +- openvtc/src/state_handler/dispatch_util.rs | 9 +- openvtc/src/state_handler/inbox_actions.rs | 28 ++-- .../src/state_handler/main_page/content.rs | 18 +-- openvtc/src/state_handler/main_page/mod.rs | 1 + openvtc/src/state_handler/main_page/repos.rs | 40 +----- openvtc/src/state_handler/main_page/status.rs | 71 ++++++++++ openvtc/src/state_handler/mod.rs | 43 +++--- .../src/state_handler/persona_actions/form.rs | 3 +- .../state_handler/persona_actions/outcome.rs | 26 ++-- .../state_handler/persona_actions/reducer.rs | 9 +- .../src/state_handler/relationship_actions.rs | 41 +++--- openvtc/src/state_handler/runtime_actions.rs | 122 +++++++++++------- openvtc/src/state_handler/settings_actions.rs | 40 +++--- .../state_handler/vetting_actions/answers.rs | 21 ++- .../vetting_actions/applicant.rs | 61 ++++++--- .../src/state_handler/vetting_actions/desk.rs | 38 ++++-- .../state_handler/vetting_actions/dispatch.rs | 73 +++++++---- .../state_handler/vetting_actions/faces.rs | 41 +++--- .../state_handler/vetting_actions/hidden.rs | 30 +++-- .../state_handler/vetting_actions/outcome.rs | 6 +- .../src/state_handler/vetting_actions/pcs.rs | 38 ++++-- .../src/state_handler/vetting_actions/send.rs | 8 +- .../state_handler/vetting_actions/tickets.rs | 30 +++-- .../state_handler/vetting_actions/vetter.rs | 19 +-- .../state_handler/vetting_actions/words.rs | 1 + openvtc/src/state_handler/witness_actions.rs | 8 +- .../pages/main/components/elections_panel.rs | 7 +- .../pages/main/components/identity_panel.rs | 14 +- .../ui/pages/main/components/rooms_panel.rs | 7 +- .../src/ui/pages/main/components/status.rs | 116 ++++++++--------- .../main/components/vetting_panel/keys.rs | 17 ++- .../src/ui/pages/main/components/vta_panel.rs | 6 +- openvtc/src/ui/pages/main/mod.rs | 18 ++- 39 files changed, 719 insertions(+), 454 deletions(-) create mode 100644 openvtc/src/state_handler/main_page/status.rs diff --git a/openvtc/src/state_handler/actions/mod.rs b/openvtc/src/state_handler/actions/mod.rs index e5d2a16c..07fcc55b 100644 --- a/openvtc/src/state_handler/actions/mod.rs +++ b/openvtc/src/state_handler/actions/mod.rs @@ -233,7 +233,7 @@ pub enum SettingsAction { #[cfg(feature = "openpgp-card")] TokenBack, /// Clipboard copy result message for display on the status panel. - ClipboardCopied(String), + ClipboardCopied(crate::state_handler::main_page::status::Status), /// Open the theme picker. ThemeOpen, /// Highlight — and preview — the theme at this row. @@ -410,7 +410,7 @@ pub enum VettingAction { /// Commit the open form. Submit, /// Show a status line (e.g. a clipboard result). - Status(String), + Status(crate::state_handler::main_page::status::Status), /// Pasted text that is a `vetting-ticket:` link, for the request form. PasteTicket(String), // ── Applicant ──────────────────────────────────────────────────────── diff --git a/openvtc/src/state_handler/background_dispatch.rs b/openvtc/src/state_handler/background_dispatch.rs index 3b56ae74..cae8824c 100644 --- a/openvtc/src/state_handler/background_dispatch.rs +++ b/openvtc/src/state_handler/background_dispatch.rs @@ -31,6 +31,7 @@ //! R13 migrates the mediator change/reconnect path as the single proving case; //! R14 migrates the remaining network dispatches onto the same mechanism. +use crate::state_handler::main_page::status::Status; use openvtc_core::config::Config; use crate::state_handler::didcomm::ReconnectOutcome; @@ -523,7 +524,8 @@ pub(crate) fn apply_outcome( } } ProgressUpdate::Witness(step) => { - state.main_page.content_panel.relationships.status_message = Some(step); + state.main_page.content_panel.relationships.status_message = + Some(Status::progress(step)); } } return AfterApply::Nothing; diff --git a/openvtc/src/state_handler/capability_actions.rs b/openvtc/src/state_handler/capability_actions.rs index 62f4c83e..dec74bee 100644 --- a/openvtc/src/state_handler/capability_actions.rs +++ b/openvtc/src/state_handler/capability_actions.rs @@ -26,6 +26,7 @@ //! pending thread, was dropped, and the view sat out the 30 s reply window to //! report a host that had in fact answered. +use crate::state_handler::main_page::status::Status; use std::sync::Arc; use std::time::Instant; @@ -195,10 +196,10 @@ fn arm( view.pending_thid = Some(thid.to_string()); view.sent_at = Some(Instant::now()); if let Some((slug, enable)) = toggle { - view.status_message = Some(format!( + view.status_message = Some(Status::progress(format!( "{} {slug}… awaiting the community's reply", if enable { "enabling" } else { "disabling" } - )); + ))); } } @@ -245,7 +246,7 @@ impl CapabilityOutcome { // A failed *write* keeps the list on screen — it is still valid, // the change simply did not go out. Some(_) => { - view.status_message = Some(format!("couldn't send the change: {e}")); + view.status_message = Some(Status::error(format!("couldn't send the change: {e}"))); tracing::error!("capability toggle failed: {e}"); } // A failed *query* has nothing to show, so the view says so. @@ -422,7 +423,10 @@ mod tests { let v = view(&state); assert!(v.pending_thid.is_none()); - assert_eq!(v.status_message.as_deref(), Some("chat is now enabled")); + assert_eq!( + v.status_message.as_ref().map(|s| s.text.as_str()), + Some("chat is now enabled") + ); } /// A query that never left has nothing to show, so the view fails outright. @@ -477,7 +481,8 @@ mod tests { ); assert!( v.status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) .is_some_and(|m| m.contains("couldn't send")), "{:?}", v.status_message @@ -531,7 +536,7 @@ mod tests { let v = view(&state); assert_eq!( - v.status_message.as_deref(), + v.status_message.as_ref().map(|s| s.text.as_str()), Some("only the community's administrators can enable or disable capabilities") ); assert!( diff --git a/openvtc/src/state_handler/community_actions.rs b/openvtc/src/state_handler/community_actions.rs index e1411a24..87a8467d 100644 --- a/openvtc/src/state_handler/community_actions.rs +++ b/openvtc/src/state_handler/community_actions.rs @@ -15,6 +15,7 @@ //! community to deregister — and the runtime loop performs it. The degraded //! loop has no sessions to tear down and ignores the answer. +use crate::state_handler::main_page::status::Status; use std::sync::Arc; use affinidi_tdk::messaging::ATM; @@ -256,7 +257,7 @@ impl CommunityOutcome { ) -> Option<(String, PersonaId)> { // Set through a local rather than a long-lived `&mut` into `state`: // two of the arms also want to log, which needs `main_page` again. - fn status(state: &mut State, msg: String) { + fn status(state: &mut State, msg: Status) { state.main_page.content_panel.communities.status_message = Some(msg); } match (self.error, self.performed) { @@ -269,7 +270,7 @@ impl CommunityOutcome { c.leave(); } save.mark_dirty(); - status(state, "Left the community.".to_string()); + status(state, Status::success("Left the community.")); state.main_page.sync_from_config(config); return Some((self.vtc_did, self.persona)); } @@ -294,8 +295,10 @@ impl CommunityOutcome { // about the difference. status( state, - "Membership credential sent — waiting for the community to acknowledge it." - .to_string(), + Status::progress( + "Membership credential sent — waiting for the community to acknowledge it." + .to_string(), + ), ); } (None, Performed::RequestPersonhoodChallenge) => { @@ -304,14 +307,19 @@ impl CommunityOutcome { // waited for rather than implying the ceremony has moved on. status( state, - "Asked the community for a personhood challenge — waiting for its reply." - .to_string(), + Status::progress( + "Asked the community for a personhood challenge — waiting for its reply." + .to_string(), + ), ); } (None, Performed::AssertPersonhood) => { status( state, - "Personhood assertion sent — waiting for the community's decision.".to_string(), + Status::progress( + "Personhood assertion sent — waiting for the community's decision." + .to_string(), + ), ); } (None, Performed::Renew) => { @@ -319,14 +327,16 @@ impl CommunityOutcome { // community's reply, which replaces this line with the outcome. status( state, - "Renewal requested — waiting for the community to re-issue your \ + Status::progress( + "Renewal requested — waiting for the community to re-issue your \ credentials." - .to_string(), + .to_string(), + ), ); } (Some(e), Performed::Leave) => { state.main_page.log_error("Leave failed", e.as_str()); - status(state, format!("Couldn't leave: {e}")); + status(state, Status::error(format!("Couldn't leave: {e}"))); } (Some(e), Performed::IssueVmc) => { state @@ -334,20 +344,23 @@ impl CommunityOutcome { .log_error("Issue membership credential failed", e.as_str()); status( state, - format!("Couldn't issue the membership credential: {e}"), + Status::error(format!("Couldn't issue the membership credential: {e}")), ); } (Some(e), Performed::RequestPersonhoodChallenge) => { state .main_page .log_error("Personhood challenge request failed", e.as_str()); - status(state, format!("Couldn't ask for a challenge: {e}")); + status( + state, + Status::error(format!("Couldn't ask for a challenge: {e}")), + ); } (Some(e), Performed::Renew) => { state .main_page .log_error("Membership renewal request failed", e.as_str()); - status(state, format!("Couldn't ask to renew: {e}")); + status(state, Status::error(format!("Couldn't ask to renew: {e}"))); } (Some(e), Performed::AssertPersonhood) => { // The challenge is single-use, but a send that never left does @@ -356,7 +369,10 @@ impl CommunityOutcome { state .main_page .log_error("Personhood assertion failed", e.as_str()); - status(state, format!("Couldn't assert personhood: {e}")); + status( + state, + Status::error(format!("Couldn't assert personhood: {e}")), + ); } } None @@ -387,7 +403,8 @@ mod tests { .content_panel .communities .status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) } /// A successful leave asks the loop to tear the session down. Nothing else @@ -425,7 +442,8 @@ mod tests { .content_panel .communities .status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) .is_some_and(|m| m.contains("Couldn't leave")), ); } @@ -449,7 +467,8 @@ mod tests { .content_panel .communities .status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) // The status must say the credential was *sent*, not accepted: // a DIDComm `Ok` is a local hand-off. This assertion used to // look for "issued", which is what the old wording claimed. diff --git a/openvtc/src/state_handler/community_context_actions.rs b/openvtc/src/state_handler/community_context_actions.rs index 1aa512a3..32343aee 100644 --- a/openvtc/src/state_handler/community_context_actions.rs +++ b/openvtc/src/state_handler/community_context_actions.rs @@ -23,6 +23,7 @@ //! changes — typing, moving the highlight, opening the grant form — go through //! [`reduce`], which both loops share. +use crate::state_handler::main_page::status::Status; use std::future::Future; use chrono::Utc; @@ -257,9 +258,10 @@ fn spawn(ctx: &ActionCtx<'_>, job: impl Future + Send + }); } +/// Report a grant or revoke that could not start — always a refusal. fn set_view_message(state: &mut State, message: String) { if let Some(view) = panel(state).device_access.as_mut() { - view.message = Some(message); + view.message = Some(Status::error(message)); } } @@ -276,7 +278,7 @@ fn start_deletion(ctx: &mut ActionCtx<'_>, index: usize) { ) { Ok(deletion) => deletion, Err(refusal) => { - panel(ctx.state).status_message = Some(refusal.describe()); + panel(ctx.state).status_message = Some(Status::warning(refusal.describe())); return; } }; @@ -294,7 +296,7 @@ fn start_deletion(ctx: &mut ActionCtx<'_>, index: usize) { let client = match claim(ctx, "delete a context") { Ok(client) => client, Err(message) => { - panel(ctx.state).status_message = Some(message); + panel(ctx.state).status_message = Some(Status::error(message)); return; } }; @@ -318,10 +320,10 @@ fn confirm_deletion(ctx: &mut ActionCtx<'_>) { return; } if !community_context::confirms_deletion(&view.typed) { - panel(ctx.state).status_message = Some(format!( + panel(ctx.state).status_message = Some(Status::info(format!( "Type {DELETE_CONFIRMATION} in capitals to delete {}.", view.context_id - )); + ))); return; } // Checked again, against the config as it is now: the preview may have @@ -350,14 +352,14 @@ fn confirm_deletion(ctx: &mut ActionCtx<'_>) { if let Some(problem) = problem { let communities = panel(ctx.state); communities.context_delete = None; - communities.status_message = Some(problem); + communities.status_message = Some(Status::warning(problem)); return; } let top = config.account.top_context_id.clone(); let client = match claim(ctx, "delete a context") { Ok(client) => client, Err(message) => { - panel(ctx.state).status_message = Some(message); + panel(ctx.state).status_message = Some(Status::error(message)); return; } }; @@ -414,11 +416,11 @@ fn open_devices(ctx: &mut ActionCtx<'_>, index: usize) { &membership.sub_context_id, &config.account.top_context_id, ) { - panel(ctx.state).status_message = Some( + panel(ctx.state).status_message = Some(Status::warning( "This community lives in your top context. Device access is scoped to a \ community's own context, and access to the top context would reach every community." .to_string(), - ); + )); return; } let view = DeviceAccessView { @@ -483,7 +485,10 @@ fn submit_grant(ctx: &mut ActionCtx<'_>) { }; if let Some(open) = panel(ctx.state).device_access.as_mut() { open.busy = true; - open.message = Some(format!("Granting {}…", shorten_did(&did, 40))); + open.message = Some(Status::progress(format!( + "Granting {}…", + shorten_did(&did, 40) + ))); } let context_id = view.context_id; spawn(ctx, async move { @@ -528,7 +533,10 @@ fn confirm_revoke(ctx: &mut ActionCtx<'_>) { }; if let Some(open) = panel(ctx.state).device_access.as_mut() { open.busy = true; - open.message = Some(format!("Revoking {}…", shorten_did(&grant.did, 40))); + open.message = Some(Status::progress(format!( + "Revoking {}…", + shorten_did(&grant.did, 40) + ))); } spawn(ctx, async move { let result = community_access::revoke_device_grant(&client, &context_id, &grant.did) @@ -628,9 +636,9 @@ impl ContextOutcome { Ok(preview) => view.phase = ContextDeletePhase::Ready(preview), Err(e) => { communities.context_delete = None; - communities.status_message = Some(format!( + communities.status_message = Some(Status::error(format!( "Couldn't ask your VTA what deleting {context_id} would remove: {e}" - )); + ))); state .main_page .log_error("Context deletion preview failed", e.as_str()); @@ -699,7 +707,7 @@ impl ContextOutcome { && config.account.delete_membership(&vtc_did, persona).is_ok(); let communities = panel(state); communities.device_grants.remove(context_id); - communities.status_message = Some(match taken { + communities.status_message = Some(Status::success(match taken { Some(t) if membership_removed => format!( "Deleted context {context_id}, persona {} and your membership of {community}.", t.name @@ -708,7 +716,7 @@ impl ContextOutcome { format!("Deleted context {context_id} and persona {}.", t.name) } None => format!("Deleted context {context_id} and everything in it."), - }); + })); save.mark_dirty(); state.main_page.sync_from_config(config); state.main_page.log(format!( @@ -719,14 +727,14 @@ impl ContextOutcome { )); } Err(e) => { - panel(state).status_message = Some(match taken { + panel(state).status_message = Some(Status::error(match taken { Some(t) => format!( "Removed persona {}'s DID, but couldn't delete {context_id}: {e} \ The context can be deleted again from here.", t.name ), None => format!("Couldn't delete {context_id}: {e}"), - }); + })); state .main_page .log_error("Context deletion failed", e.as_str()); @@ -748,7 +756,7 @@ impl ContextOutcome { "Granted {did} the {} role in {context_id}", grant.role )); - format!( + Status::success(format!( "Granted {} {} in this context{}.", shorten_did(&did, 40), grant.role, @@ -756,13 +764,13 @@ impl ContextOutcome { .expires_at .map(|at| format!(" until {}", at.format("%Y-%m-%d %H:%M UTC"))) .unwrap_or_default() - ) + )) } Err(e) => { state .main_page .log_error("Device access grant failed", e.as_str()); - format!("Couldn't grant access: {e}") + Status::error(format!("Couldn't grant access: {e}")) } }; store_listing(state, &context_id, listing); @@ -785,7 +793,7 @@ impl ContextOutcome { state .main_page .log(format!("Revoked {did}'s access in {context_id}")); - match revoked { + Status::success(match revoked { Revoked::Deleted => format!("Revoked {short}'s access."), Revoked::Narrowed(rest) => format!( "Removed {short} from this community; it keeps access in {}.", @@ -794,13 +802,13 @@ impl ContextOutcome { Revoked::AlreadyGone => { format!("{short} had no access left at your VTA.") } - } + }) } Err(e) => { state .main_page .log_error("Device access revocation failed", e.as_str()); - format!("Couldn't revoke access: {e}") + Status::error(format!("Couldn't revoke access: {e}")) } }; store_listing(state, &context_id, listing); @@ -1118,7 +1126,8 @@ mod tests { assert!( communities .status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) .is_some_and(|m| m.contains("could not reach")) ); } @@ -1185,7 +1194,8 @@ mod tests { assert!( communities .status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) .is_some_and(|m| m.contains("Deleted context")) ); } @@ -1211,7 +1221,8 @@ mod tests { assert!( panel(&mut state) .status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) .is_some_and(|m| m.contains("Couldn't delete")) ); } @@ -1292,7 +1303,11 @@ mod tests { assert!(!config.account.personas.contains_key(&persona)); assert_eq!(config.account.memberships().count(), 0); assert!(save.is_pending()); - let status = panel(&mut state).status_message.clone().unwrap_or_default(); + let status = panel(&mut state) + .status_message + .clone() + .map(|s| s.text) + .unwrap_or_default(); assert!( status.contains("persona Kernel me") && status.contains("membership of Acme"), "{status}" @@ -1320,7 +1335,11 @@ mod tests { .next() .expect("the record stays"); assert_eq!(membership.sub_context_id, CTX); - let status = panel(&mut state).status_message.clone().unwrap_or_default(); + let status = panel(&mut state) + .status_message + .clone() + .map(|s| s.text) + .unwrap_or_default(); assert!(status.contains("can be deleted again"), "{status}"); } @@ -1368,7 +1387,8 @@ mod tests { assert!(!view.busy && view.form.is_none()); assert!( view.message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) .is_some_and(|m| m.contains("Granted")) ); assert_eq!(communities.device_grants[CTX].grants().len(), 1); @@ -1394,7 +1414,7 @@ mod tests { .apply(&mut state, &mut config, &mut save); let view = panel(&mut state).device_access.clone().unwrap(); assert!(view.form.is_some()); - assert!(view.message.unwrap().contains("already has access")); + assert!(view.message.unwrap().text.contains("already has access")); } #[test] @@ -1417,7 +1437,7 @@ mod tests { let view = panel(&mut state).device_access.clone().unwrap(); assert!(!view.busy); assert_eq!(view.selected, 0, "the highlight stays on the list"); - assert!(view.message.unwrap().contains("openvtc/work")); + assert!(view.message.unwrap().text.contains("openvtc/work")); } #[test] diff --git a/openvtc/src/state_handler/credential_actions.rs b/openvtc/src/state_handler/credential_actions.rs index 52ccdd88..dc748000 100644 --- a/openvtc/src/state_handler/credential_actions.rs +++ b/openvtc/src/state_handler/credential_actions.rs @@ -443,7 +443,8 @@ mod vrc_request_tests { .content_panel .credentials .status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) .is_some_and(|m| m.contains("unreachable")), ); } diff --git a/openvtc/src/state_handler/dispatch_util.rs b/openvtc/src/state_handler/dispatch_util.rs index d262727f..774d058b 100644 --- a/openvtc/src/state_handler/dispatch_util.rs +++ b/openvtc/src/state_handler/dispatch_util.rs @@ -17,6 +17,7 @@ use openvtc_core::config::Config; use crate::state_handler::main_page::MainPageState; +use crate::state_handler::main_page::status::Status; use crate::state_handler::save_coalesce::SaveScheduler; /// Activity-log entry to write after an action completes. @@ -80,11 +81,11 @@ pub(crate) fn save_and_sync( config: &Config, save: &mut SaveScheduler, persist: Persist, - status: impl FnOnce(&mut MainPageState) -> &mut Option, + status: impl FnOnce(&mut MainPageState) -> &mut Option, success_status: impl Into, log: SyncLog, ) { - *status(main_page) = Some(success_status.into()); + *status(main_page) = Some(Status::success(success_status)); persist.apply(main_page, config, save); log.write(main_page); } @@ -93,11 +94,11 @@ pub(crate) fn save_and_sync( /// (reached via `status`) and log the error with `context`. pub(crate) fn record_error( main_page: &mut MainPageState, - status: impl FnOnce(&mut MainPageState) -> &mut Option, + status: impl FnOnce(&mut MainPageState) -> &mut Option, context: impl Into, err: &anyhow::Error, ) { - *status(main_page) = Some(format!("Error: {err:#}")); + *status(main_page) = Some(Status::error(format!("Error: {err:#}"))); main_page.log_error(context, err); } diff --git a/openvtc/src/state_handler/inbox_actions.rs b/openvtc/src/state_handler/inbox_actions.rs index d79c0064..dd096c82 100644 --- a/openvtc/src/state_handler/inbox_actions.rs +++ b/openvtc/src/state_handler/inbox_actions.rs @@ -4,6 +4,7 @@ //! relationship requests, accept VRCs, dismiss tasks). They operate on //! `&mut Config` and `&TDK` owned by the StateHandler. +use crate::state_handler::main_page::status::Status; use std::sync::Arc; use affinidi_tdk::TDK; @@ -710,14 +711,16 @@ async fn prepare_accept_relationship( .secret; if generate_r_did { - state.main_page.content_panel.inbox.status_message = - Some("Accepting with R-DID — creating keys...".to_string()); + state.main_page.content_panel.inbox.status_message = Some(Status::progress( + "Accepting with R-DID — creating keys...".to_string(), + )); state .main_page .log("Accepting relationship request (creating R-DID)..."); } else { - state.main_page.content_panel.inbox.status_message = - Some("Accepting relationship request...".to_string()); + state.main_page.content_panel.inbox.status_message = Some(Status::progress( + "Accepting relationship request...".to_string(), + )); state.main_page.log("Accepting relationship request..."); } @@ -758,8 +761,9 @@ fn prepare_reject_relationship( let persona_did = config.persona_did_arc(); let listener_id = super::didcomm::listener_id_for_did(&persona_did, config); let msg = build_reject_message(&persona_did, &from_did, reason, &task_id)?; - state.main_page.content_panel.inbox.status_message = - Some("Rejecting relationship request...".to_string()); + state.main_page.content_panel.inbox.status_message = Some(Status::progress( + "Rejecting relationship request...".to_string(), + )); Ok(InboxJob::Send(InboxSend { service: service.clone(), listener_id, @@ -873,7 +877,8 @@ async fn prepare_accept_vrc_request( let msg = vrc.message(&our_r_did, &their_r_did, Some(&task_id))?; let listener_id = super::didcomm::listener_id_for_did(&our_r_did, config); - state.main_page.content_panel.inbox.status_message = Some("Issuing VRC...".to_string()); + state.main_page.content_panel.inbox.status_message = + Some(Status::progress("Issuing VRC...".to_string())); Ok(InboxJob::Send(InboxSend { service: service.clone(), @@ -931,7 +936,7 @@ fn prepare_reject_vrc_request( )?; let listener_id = super::didcomm::listener_id_for_did(&our_r_did, config); state.main_page.content_panel.inbox.status_message = - Some("Rejecting VRC request...".to_string()); + Some(Status::progress("Rejecting VRC request...".to_string())); Ok(InboxJob::Send(InboxSend { service: service.clone(), listener_id, @@ -1131,7 +1136,8 @@ mod r14_tests { .content_panel .inbox .status_message - .as_deref(), + .as_ref() + .map(|s| s.text.as_str()), Some("Relationship request accepted") ); } @@ -1176,6 +1182,7 @@ mod r14_tests { .inbox .status_message .clone() + .map(|s| s.text) .unwrap_or_default(); assert!(status.starts_with("Error:"), "got: {status}"); } @@ -1209,7 +1216,8 @@ mod r14_tests { .content_panel .inbox .status_message - .as_deref(), + .as_ref() + .map(|s| s.text.as_str()), Some("Relationship request rejected") ); } diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index 84706920..dc1882db 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -180,7 +180,7 @@ pub struct CapabilitiesView { /// When the in-flight request was sent (reply-timeout sweep). pub sent_at: Option, /// Transient status message. - pub status_message: Option, + pub status_message: Option, } impl CapabilitiesView { @@ -237,7 +237,7 @@ pub struct CommunitiesState { /// Number of communities raising the actions-required indicator (R-C-3). pub actions_required: usize, /// Transient status message. - pub status_message: Option, + pub status_message: Option, /// When `Some(index)`, a removal of that community is awaiting `y`/`n` /// confirmation (the panel shows a prompt and other keys are suppressed). pub confirm_delete: Option, @@ -366,7 +366,7 @@ pub struct DeviceAccessView { /// A read, grant or revoke is running. pub busy: bool, /// The last result or problem. - pub message: Option, + pub message: Option, } /// The new-grant form. @@ -1615,7 +1615,7 @@ pub struct IdentityState { pub confirm: PersonaConfirm, pub mode: PersonaMode, /// Transient status line. - pub status_message: Option, + pub status_message: Option, } impl IdentityState { @@ -2244,7 +2244,7 @@ pub struct VettingState { pub desk_view: DeskView, pub selected: usize, pub mode: VettingMode, - pub status_message: Option, + pub status_message: Option, pub applications: Arc<[ApplicationRow]>, pub desk: Arc<[DeskRow]>, pub tickets: Arc<[TicketRow]>, @@ -3549,7 +3549,7 @@ pub struct InboxState { /// When viewing a specific task's details pub active_task: Option, /// Transient status message (e.g., "Task accepted", "Error: ...") - pub status_message: Option, + pub status_message: Option, /// When `Some`, a destructive inbox action (dismiss one task, or clear all) /// is awaiting `y`/`n` confirmation; the panel shows a prompt and other keys /// are suppressed. Mirrors the Communities/VTA-DID confirm pattern (R25). @@ -3739,7 +3739,7 @@ pub struct RelationshipsState { /// Current panel mode (list, detail, new request form) pub mode: RelationshipsMode, /// Transient status message - pub status_message: Option, + pub status_message: Option, /// When `Some(remote_p_did)`, removal of that relationship is awaiting /// `y`/`n` confirmation (armed from the detail view). Mirrors the /// Communities/VTA-DID confirm pattern (R25). @@ -3888,7 +3888,7 @@ pub struct CredentialsState { /// Current panel mode pub mode: CredentialsMode, /// Transient status message - pub status_message: Option, + pub status_message: Option, /// Stored VRCs set aside on load because they pre-date DTG Credentials v1 /// ([`openvtc_core::vrc::Vrcs::retired`]), across every store. Shown as a /// warning so the member knows to ask those peers for fresh ones. @@ -4065,7 +4065,7 @@ pub struct SettingsState { /// Current panel mode pub mode: SettingsMode, /// Transient status message - pub status_message: Option, + pub status_message: Option, /// Hardware token management state #[cfg(feature = "openpgp-card")] pub token: TokenManagementState, diff --git a/openvtc/src/state_handler/main_page/mod.rs b/openvtc/src/state_handler/main_page/mod.rs index 85e41984..d1982315 100644 --- a/openvtc/src/state_handler/main_page/mod.rs +++ b/openvtc/src/state_handler/main_page/mod.rs @@ -35,6 +35,7 @@ pub mod elections; pub mod menu; pub mod repos; pub mod rooms; +pub mod status; /// Maximum number of activity log entries to keep in the UI. const MAX_ACTIVITY_LOG_ENTRIES: usize = 100; diff --git a/openvtc/src/state_handler/main_page/repos.rs b/openvtc/src/state_handler/main_page/repos.rs index e8857863..c789ad67 100644 --- a/openvtc/src/state_handler/main_page/repos.rs +++ b/openvtc/src/state_handler/main_page/repos.rs @@ -30,45 +30,7 @@ use openvtc_core::git_workspace::{ /// Longest name or DID kept for display. pub const MAX_NAME: usize = 256; -/// How a status line reads: its colour comes from this, never from its text. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Severity { - /// Neutral information. - Info, - /// A request is on its way; cleared when its answer lands. - Progress, - /// A change the community confirmed. - Success, - /// Nothing was done, and why (a key that does not apply here). - Warning, - /// A refusal, a failed send, a timeout, a contract mismatch. - Error, -} - -/// One status line. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Status { - pub text: String, - pub severity: Severity, -} - -impl Status { - #[must_use] - pub fn error(text: impl Into) -> Self { - Self { - text: text.into(), - severity: Severity::Error, - } - } - - #[must_use] - pub fn warning(text: impl Into) -> Self { - Self { - text: text.into(), - severity: Severity::Warning, - } - } -} +pub use super::status::{Severity, Status}; /// Load phase of the view read. #[derive(Clone, Debug, PartialEq)] diff --git a/openvtc/src/state_handler/main_page/status.rs b/openvtc/src/state_handler/main_page/status.rs new file mode 100644 index 00000000..c2396ab5 --- /dev/null +++ b/openvtc/src/state_handler/main_page/status.rs @@ -0,0 +1,71 @@ +//! A panel's status line, typed by severity. +//! +//! Every main-page panel shows at most one status line. Its colour comes from +//! the [`Severity`] the producer set — the code that knows whether the thing it +//! is reporting worked — never from the wording of the text. Deciding colour +//! from wording drew failures phrased without "Error" ("Couldn't archive: …", +//! "Messaging unavailable — …") as successes (#577). + +/// How a status line reads: its colour comes from this, never from its text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Severity { + /// Neutral information. + Info, + /// A request is on its way; cleared when its answer lands. + Progress, + /// A change that was made, or confirmed by the other side. + Success, + /// Nothing was done, and why (a key that does not apply here). + Warning, + /// A refusal, a failed send, a timeout, a contract mismatch. + Error, +} + +/// One status line. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Status { + pub text: String, + pub severity: Severity, +} + +impl Status { + /// A status line with an explicit severity. + #[must_use] + pub fn new(severity: Severity, text: impl Into) -> Self { + Self { + text: text.into(), + severity, + } + } + + #[must_use] + pub fn info(text: impl Into) -> Self { + Self::new(Severity::Info, text) + } + + #[must_use] + pub fn progress(text: impl Into) -> Self { + Self::new(Severity::Progress, text) + } + + #[must_use] + pub fn success(text: impl Into) -> Self { + Self::new(Severity::Success, text) + } + + #[must_use] + pub fn warning(text: impl Into) -> Self { + Self::new(Severity::Warning, text) + } + + #[must_use] + pub fn error(text: impl Into) -> Self { + Self::new(Severity::Error, text) + } +} + +impl std::fmt::Display for Status { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.text) + } +} diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index 25caab4a..9656e261 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -1,3 +1,4 @@ +use crate::state_handler::main_page::status::Status; use crate::{ Interrupted, Terminator, state_handler::{ @@ -1643,13 +1644,13 @@ impl StateHandler { match_code: reply.match_code.clone(), expires_at: reply.expires_at, }); - state.main_page.content_panel.communities.status_message = Some( + state.main_page.content_panel.communities.status_message = Some(Status::info( format!( "Personhood challenge received — confirm the code {} with \ whoever is vetting you, then assert.", reply.match_code ), - ); + )); } // How a renewal the member asked for ended: on the @@ -1666,8 +1667,14 @@ impl StateHandler { &format!("{vtc}: {line}"), ), } + let severity = match &outcome { + openvtc_core::renewal::RenewalOutcome::Renewed { .. } => { + crate::state_handler::main_page::status::Severity::Success + } + _ => crate::state_handler::main_page::status::Severity::Error, + }; state.main_page.content_panel.communities.status_message = - Some(line); + Some(Status::new(severity, line)); } match dispatched @@ -2339,9 +2346,9 @@ impl StateHandler { "no reply within 30s — the community's governance host may be offline".to_string(), ); } else { - view.status_message = Some( + view.status_message = Some(Status::error( "no reply to the change within 30s — refresh (r) to re-check".to_string(), - ); + )); } } } @@ -3502,11 +3509,11 @@ impl StateHandler { // and no restart is needed any more: creating a persona brings // messaging up on its own. Action::Vetting(..) => { - state.main_page.content_panel.vetting.status_message = Some( + state.main_page.content_panel.vetting.status_message = Some(Status::warning( "Vetting needs a persona. Create one under My Identity — it is the \ DID you apply and vet as — and vetting comes online automatically." .to_string(), - ); + )); } // Genuinely unavailable: these need a live community and the @@ -3773,10 +3780,10 @@ fn apply_capability_replies( if let Some(item) = view.items.iter_mut().find(|i| i.slug == capability) { item.enabled = enabled; } - view.status_message = Some(format!( + view.status_message = Some(Status::success(format!( "{capability} is now {}", if enabled { "enabled" } else { "disabled" } - )); + ))); view.phase = CapabilitiesPhase::Loaded; } CapabilityReply::Rejected { code, message } => { @@ -3784,8 +3791,9 @@ fn apply_capability_replies( match view.phase { CapabilitiesPhase::Loaded => { // A rejected toggle: keep the listing, surface why. - view.status_message = - Some(capability_actions::toggle_refusal(&code, &detail)); + view.status_message = Some(Status::error( + capability_actions::toggle_refusal(&code, &detail), + )); } _ => { view.phase = CapabilitiesPhase::Failed(match code.as_str() { @@ -4046,8 +4054,9 @@ fn spawn_community_job( ) { let domain = background_dispatch::DispatchDomain::Community; if !in_flight.try_begin(domain) { - state.main_page.content_panel.communities.status_message = - Some(background_dispatch::InFlight::busy_message(domain)); + state.main_page.content_panel.communities.status_message = Some(Status::warning( + background_dispatch::InFlight::busy_message(domain), + )); return; } background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { @@ -4111,7 +4120,9 @@ fn spawn_capability_job( let domain = background_dispatch::DispatchDomain::Capabilities; if !in_flight.try_begin(domain) { if let Some(view) = state.main_page.content_panel.capabilities.view.as_mut() { - view.status_message = Some(background_dispatch::InFlight::busy_message(domain)); + view.status_message = Some(Status::warning( + background_dispatch::InFlight::busy_message(domain), + )); } return; } @@ -4579,11 +4590,11 @@ fn remove_community( save.mark_dirty(); state.main_page.sync_from_config(config); state.main_page.content_panel.communities.status_message = - Some("Community removed.".to_string()); + Some(Status::success("Community removed.".to_string())); } Err(e) => { state.main_page.content_panel.communities.status_message = - Some(format!("Could not remove community: {e}")); + Some(Status::error(format!("Could not remove community: {e}"))); } } } diff --git a/openvtc/src/state_handler/persona_actions/form.rs b/openvtc/src/state_handler/persona_actions/form.rs index c4bb77f7..f892e567 100644 --- a/openvtc/src/state_handler/persona_actions/form.rs +++ b/openvtc/src/state_handler/persona_actions/form.rs @@ -1,6 +1,7 @@ //! The pane's forms: validating and submitting whichever is open, releasing one //! whose request never left, and filling editors from existing records. +use crate::state_handler::main_page::status::Status; use openvtc_core::persona::{ contacts, lifecycle, pool::{self, AttributeDraft, PoolAttribute}, @@ -345,7 +346,7 @@ pub(crate) fn release_form(state: &mut State, reason: String) { // A read-only overlay has no in-flight write to release; the pane's own // status line is where the reason belongs. PersonaMode::View | PersonaMode::Renderers(_) | PersonaMode::KnownHere(_) => { - p.status_message = Some(reason); + p.status_message = Some(Status::error(reason)); } } } diff --git a/openvtc/src/state_handler/persona_actions/outcome.rs b/openvtc/src/state_handler/persona_actions/outcome.rs index 5fe43439..cd9d5332 100644 --- a/openvtc/src/state_handler/persona_actions/outcome.rs +++ b/openvtc/src/state_handler/persona_actions/outcome.rs @@ -1,6 +1,7 @@ //! [`PersonaOutcome`]: what a persona job produced, folded back into the pane on //! the loop thread. +use crate::state_handler::main_page::status::Status; use std::collections::HashMap; use openvtc_core::persona::{ @@ -245,7 +246,7 @@ impl PersonaOutcome { match error { None => { p.mode = PersonaMode::View; - p.status_message = Some(format!("{verb}.")); + p.status_message = Some(Status::success(format!("{verb}."))); state.main_page.log(format!("{verb}.")); } Some(e) => { @@ -280,7 +281,7 @@ impl PersonaOutcome { view.working = false; view.error = Some(e.clone()); } - _ => p.status_message = Some(e.clone()), + _ => p.status_message = Some(Status::error(e.clone())), } state .main_page @@ -375,7 +376,7 @@ impl PersonaOutcome { if worn == 1 { "" } else { "s" }, if worn == 1 { "that persona" } else { "them" }, ); - p.status_message = Some(message.clone()); + p.status_message = Some(Status::warning(message.clone())); p.confirm = PersonaConfirm::DeleteProfile { profile_id, name, @@ -411,7 +412,7 @@ impl PersonaOutcome { PersonaOutcome::Deleted { message } => { p.refresh_queued = true; p.mode = PersonaMode::View; - p.status_message = Some(message.clone()); + p.status_message = Some(Status::success(message.clone())); state.main_page.log(message); } @@ -448,7 +449,7 @@ impl PersonaOutcome { // Refused rather than opened: saving could not // round-trip an entry this build cannot read, and a // save that silently drops one is invisible. - p.status_message = Some(detail.refusal()); + p.status_message = Some(Status::warning(detail.refusal())); } } else { p.open_profile = Some(detail); @@ -461,7 +462,7 @@ impl PersonaOutcome { } } Err(e) => { - p.status_message = Some(e.clone()); + p.status_message = Some(Status::error(e.clone())); state .main_page .log_error("Reading the profile failed", e.as_str()); @@ -497,12 +498,12 @@ impl PersonaOutcome { p.revealed_attribute = Some(attribute_id); } Ok(_) => { - p.status_message = Some( + p.status_message = Some(Status::error( "That value can't be shown — it has no single stored value.".to_string(), - ); + )); } Err(e) => { - p.status_message = Some(e.clone()); + p.status_message = Some(Status::error(e.clone())); state .main_page .log_error("Revealing the value failed", e.as_str()); @@ -523,7 +524,7 @@ impl PersonaOutcome { } else { format!("Changed the face {community} sees.") }; - p.status_message = Some(msg.clone()); + p.status_message = Some(Status::success(msg.clone())); state.main_page.log(msg); } Some(e) => { @@ -531,7 +532,7 @@ impl PersonaOutcome { picker.working = false; picker.error = Some(e.clone()); } else { - p.status_message = Some(e.clone()); + p.status_message = Some(Status::error(e.clone())); } state .main_page @@ -701,7 +702,8 @@ mod tests { assert!( personas(&state) .status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) .is_some_and(|m| m.contains("cannot read")) ); } diff --git a/openvtc/src/state_handler/persona_actions/reducer.rs b/openvtc/src/state_handler/persona_actions/reducer.rs index a51f31c2..091fed08 100644 --- a/openvtc/src/state_handler/persona_actions/reducer.rs +++ b/openvtc/src/state_handler/persona_actions/reducer.rs @@ -2,6 +2,7 @@ //! and names the network work it needs as a [`PersonaEffect`]; plus answering //! an armed confirmation. +use crate::state_handler::main_page::status::Status; use openvtc_core::persona::{lifecycle, pool, world}; use super::form::{form_for, form_for_world, form_submit, step}; @@ -131,7 +132,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect if let pool::AttributeEdit::Refused(why) = pool::AttributeEdit::refusal(attr.provenance) { - p.status_message = Some(why); + p.status_message = Some(Status::error(why)); } return PersonaEffect::None; } @@ -1170,7 +1171,8 @@ mod tests { assert!( personas(&state) .status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) .is_some_and(|m| m.contains("comes from a credential")), "the refusal has to say why" ); @@ -1607,7 +1609,8 @@ mod tests { assert!( personas(&state) .status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) .is_some_and(|m| m.contains("worn by 1 persona outside")) ); match apply(&mut state, &PersonaAction::ConfirmYes) { diff --git a/openvtc/src/state_handler/relationship_actions.rs b/openvtc/src/state_handler/relationship_actions.rs index dcf29d4f..03d18bb3 100644 --- a/openvtc/src/state_handler/relationship_actions.rs +++ b/openvtc/src/state_handler/relationship_actions.rs @@ -1,5 +1,6 @@ //! Relationship action handlers for the TUI. +use crate::state_handler::main_page::status::Status; use std::sync::Arc; use crate::state_handler::setup_sequence::vta; @@ -421,7 +422,7 @@ impl RelationshipOutcome { // Accessor for the relationships-panel status slot, used by the // `dispatch_util` save/error helpers below (a free fn so its HRTB is // inferred correctly). - fn status(mp: &mut crate::state_handler::main_page::MainPageState) -> &mut Option { + fn status(mp: &mut crate::state_handler::main_page::MainPageState) -> &mut Option { &mut mp.content_panel.relationships.status_message } match self.effect { @@ -624,7 +625,7 @@ impl RelationshipOutcome { } Err(e) => { state.main_page.content_panel.relationships.status_message = - Some(format!("Ping failed: {e:#}")); + Some(Status::error(format!("Ping failed: {e:#}"))); state.main_page.log_detailed( format!("Ping to {display_name} failed: {e}"), format!( @@ -678,7 +679,7 @@ impl RelationshipOutcome { Err(e) => { let err = anyhow::anyhow!("{e}"); state.main_page.content_panel.relationships.status_message = - Some(format!("VRC request failed: {err:#}")); + Some(Status::error(format!("VRC request failed: {err:#}"))); state .main_page .log_error(format!("VRC request to {display_name} failed"), &err); @@ -1028,13 +1029,13 @@ async fn prepare_submit( // In-progress status (shown by the loop's post-arm state send). if generate_r_did { state.main_page.content_panel.relationships.status_message = - Some("Creating relationship DID...".to_string()); + Some(Status::progress("Creating relationship DID...".to_string())); state .main_page .log("Creating relationship DID via key backend..."); } else { state.main_page.content_panel.relationships.status_message = - Some("Sending request...".to_string()); + Some(Status::progress("Sending request...".to_string())); } Ok(RelationshipJob::Create(Box::new(CreateJob { @@ -1083,7 +1084,7 @@ fn prepare_ping( let display_name = resolve_did_to_display(config, remote_p_did); state.main_page.content_panel.relationships.status_message = - Some("Sending ping...".to_string()); + Some(Status::progress("Sending ping...".to_string())); Ok(RelationshipJob::Send(Box::new(RelationshipSend { service: service.clone(), @@ -1120,7 +1121,7 @@ fn prepare_remove( // The confirmation is now resolved. state.main_page.content_panel.relationships.confirm_delete = None; state.main_page.content_panel.relationships.status_message = - Some("Removing relationship...".to_string()); + Some(Status::progress("Removing relationship...".to_string())); RelationshipJob::Remove { service: service.clone(), listener_id, @@ -1157,7 +1158,7 @@ fn prepare_request_vrc( let display_name = resolve_did_to_display(config, remote_p_did); state.main_page.content_panel.relationships.status_message = - Some("Requesting VRC...".to_string()); + Some(Status::progress("Requesting VRC...".to_string())); Ok(RelationshipJob::Send(Box::new(RelationshipSend { service: service.clone(), @@ -1368,7 +1369,7 @@ pub(crate) async fn dispatch( Ok(job) => return RelationshipDispatch::Spawn { job, is_ping: true }, Err(e) => { state.main_page.content_panel.relationships.status_message = - Some(format!("Ping failed: {e:#}")); + Some(Status::error(format!("Ping failed: {e:#}"))); state.main_page.log_error("Failed to send trust-ping", &e); } } @@ -1430,9 +1431,9 @@ pub(crate) async fn dispatch( // its own domain (`witness_actions`). Reaching here means a loop with no runtime // services — say so rather than drop it. RelationshipAction::Witness { .. } => { - state.main_page.content_panel.relationships.status_message = Some( - "Witnessing is not available until messaging and the VTA are connected.".into(), - ); + state.main_page.content_panel.relationships.status_message = Some(Status::error( + "Witnessing is not available until messaging and the VTA are connected.", + )); } RelationshipAction::RequestVrc { remote_p_did } => { match prepare_request_vrc(config, service, state, &remote_p_did) { @@ -1444,7 +1445,7 @@ pub(crate) async fn dispatch( } Err(e) => { state.main_page.content_panel.relationships.status_message = - Some(format!("VRC request failed: {e:#}")); + Some(Status::error(format!("VRC request failed: {e:#}"))); let display_name = resolve_did_to_display(config, &remote_p_did); state .main_page @@ -1675,7 +1676,8 @@ mod tests { .content_panel .relationships .status_message - .as_deref(), + .as_ref() + .map(|s| s.text.as_str()), Some("Ping sent") ); assert!( @@ -1717,6 +1719,7 @@ mod tests { .relationships .status_message .clone() + .map(|s| s.text) .unwrap_or_default(); assert!(status.starts_with("Ping failed"), "got: {status}"); assert!( @@ -1823,7 +1826,8 @@ mod tests { .content_panel .relationships .status_message - .as_deref(), + .as_ref() + .map(|s| s.text.as_str()), // Names the party rather than echoing its DID: the fixture has a // contact alias for this peer, and the status line honours the same // alias → verified agent name → DID precedence as everywhere else. @@ -1944,6 +1948,7 @@ mod tests { .relationships .status_message .clone() + .map(|s| s.text) .unwrap_or_default(); assert!(status.starts_with("Error:"), "got: {status}"); } @@ -1980,7 +1985,8 @@ mod tests { .content_panel .relationships .status_message - .as_deref(), + .as_ref() + .map(|s| s.text.as_str()), Some("Relationship removed") ); } @@ -2062,7 +2068,8 @@ mod tests { ] { let mut state = State::default(); state.main_page.content_panel.relationships.mode = start; - state.main_page.content_panel.relationships.status_message = Some("stale".to_string()); + state.main_page.content_panel.relationships.status_message = + Some(Status::info("stale".to_string())); handle_cancel_or_back(&mut state); assert!(matches!(rel_mode(&state), RelationshipsMode::List)); assert!( diff --git a/openvtc/src/state_handler/runtime_actions.rs b/openvtc/src/state_handler/runtime_actions.rs index 26c9add5..ee4986c9 100644 --- a/openvtc/src/state_handler/runtime_actions.rs +++ b/openvtc/src/state_handler/runtime_actions.rs @@ -16,6 +16,7 @@ //! outcome type, and `StartJoin` drives the join flow's own action loop with the //! receiver this one is selecting on. +use crate::state_handler::main_page::status::Status; use tokio::sync::{mpsc::UnboundedSender, watch}; use crate::state_handler::actions::Action; @@ -252,11 +253,13 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha } (_, Err(e)) => { ctx.state.main_page.content_panel.communities.status_message = - Some(format!("Couldn't sign the departure: {e}")); + Some(Status::error(format!("Couldn't sign the departure: {e}"))); } (None, _) => { ctx.state.main_page.content_panel.communities.status_message = - Some("Messaging unavailable — cannot leave right now.".to_string()); + Some(Status::error( + "Messaging unavailable — cannot leave right now.".to_string(), + )); } } } @@ -385,7 +388,8 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha if let Some(view) = ctx.state.main_page.content_panel.capabilities.view.as_mut() { - view.status_message = Some(format!("couldn't sign the change: {e}")); + view.status_message = + Some(Status::error(format!("couldn't sign the change: {e}"))); } None } @@ -443,11 +447,12 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha .and_then(|c| c.credentials.get(&openvtc_core::CredentialKind::Membership)) .cloned() else { - ctx.state.main_page.content_panel.communities.status_message = Some( - "This community hasn't issued you a membership credential yet — \ + ctx.state.main_page.content_panel.communities.status_message = + Some(Status::warning( + "This community hasn't issued you a membership credential yet — \ there is nothing to acknowledge." - .to_string(), - ); + .to_string(), + )); return Handled::Continue; }; // The signing key is read here for the same reason as @@ -461,13 +466,16 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha ) { (Some(sender), Ok(keys)) => Some((sender, keys)), (_, Err(e)) => { - ctx.state.main_page.content_panel.communities.status_message = - Some(format!("Couldn't sign the membership credential: {e}")); + ctx.state.main_page.content_panel.communities.status_message = Some( + Status::error(format!("Couldn't sign the membership credential: {e}")), + ); None } (None, _) => { ctx.state.main_page.content_panel.communities.status_message = - Some("Messaging unavailable — cannot issue right now.".to_string()); + Some(Status::error( + "Messaging unavailable — cannot issue right now.".to_string(), + )); None } }; @@ -538,15 +546,17 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha }, ), (_, Err(e)) => { - ctx.state.main_page.content_panel.communities.status_message = Some( - format!("Could not load the persona's key to sign the request: {e}"), - ); + ctx.state.main_page.content_panel.communities.status_message = + Some(Status::error(format!( + "Could not load the persona's key to sign the request: {e}" + ))); } (None, _) => { - ctx.state.main_page.content_panel.communities.status_message = Some( - "Messaging unavailable — cannot ask for a challenge right now." - .to_string(), - ); + ctx.state.main_page.content_panel.communities.status_message = + Some(Status::error( + "Messaging unavailable — cannot ask for a challenge right now." + .to_string(), + )); } } } @@ -570,7 +580,9 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha // Progress until the send lands; a busy dispatcher // replaces it with its own line. ctx.state.main_page.content_panel.communities.status_message = - Some("Asking the community to renew your membership…".to_string()); + Some(Status::progress( + "Asking the community to renew your membership…".to_string(), + )); spawn_community_job( ctx.dispatch_tx, ctx.in_flight, @@ -594,13 +606,16 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha ) } (_, Err(e)) => { - ctx.state.main_page.content_panel.communities.status_message = Some( - format!("Could not load the persona's key to sign the renewal: {e}"), - ); + ctx.state.main_page.content_panel.communities.status_message = + Some(Status::error(format!( + "Could not load the persona's key to sign the renewal: {e}" + ))); } (None, _) => { ctx.state.main_page.content_panel.communities.status_message = - Some("Messaging unavailable — cannot renew right now.".to_string()); + Some(Status::error( + "Messaging unavailable — cannot renew right now.".to_string(), + )); } } } @@ -671,8 +686,9 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha ) .await; ctx.state.main_page.sync_from_config(ctx.config); - ctx.state.main_page.content_panel.communities.status_message = - Some("Join cancelled — request withdrawn.".to_string()); + ctx.state.main_page.content_panel.communities.status_message = Some( + Status::success("Join cancelled — request withdrawn.".to_string()), + ); } } } @@ -693,11 +709,11 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha ctx.save.mark_dirty(); ctx.state.main_page.sync_from_config(ctx.config); ctx.state.main_page.content_panel.communities.status_message = - Some("Community archived.".to_string()); + Some(Status::success("Community archived.".to_string())); } Err(e) => { ctx.state.main_page.content_panel.communities.status_message = - Some(format!("Couldn't archive: {e}")); + Some(Status::error(format!("Couldn't archive: {e}"))); } } } @@ -942,7 +958,8 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha let domain = background_dispatch::DispatchDomain::Inbox; if !ctx.in_flight.try_begin(domain) { let msg = background_dispatch::InFlight::busy_message(domain); - ctx.state.main_page.content_panel.inbox.status_message = Some(msg.clone()); + ctx.state.main_page.content_panel.inbox.status_message = + Some(Status::warning(msg.clone())); ctx.state.main_page.log(msg); } else { match inbox_actions::dispatch( @@ -999,7 +1016,7 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha .main_page .content_panel .relationships - .status_message = Some(msg); + .status_message = Some(Status::warning(msg)); return Handled::Continue; } match witness_actions::prepare( @@ -1018,7 +1035,8 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha .main_page .content_panel .relationships - .status_message = Some("Starting the witnessing…".to_string()); + .status_message = + Some(Status::progress("Starting the witnessing…".to_string())); background_dispatch::spawn_dispatch( ctx.dispatch_tx.clone(), domain, @@ -1031,7 +1049,7 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha .main_page .content_panel .relationships - .status_message = Some(reason); + .status_message = Some(Status::error(reason)); } } } @@ -1049,7 +1067,7 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha .main_page .content_panel .relationships - .status_message = Some(msg.clone()); + .status_message = Some(Status::warning(msg.clone())); ctx.state.main_page.log(msg); } else { match relationship_actions::dispatch( @@ -1108,8 +1126,9 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha { let domain = background_dispatch::DispatchDomain::Credential; if !ctx.in_flight.try_begin(domain) { - ctx.state.main_page.content_panel.credentials.status_message = - Some(background_dispatch::InFlight::busy_message(domain)); + ctx.state.main_page.content_panel.credentials.status_message = Some( + Status::warning(background_dispatch::InFlight::busy_message(domain)), + ); } else if let Some(job) = credential_actions::prepare_vrc_request( ctx.config, ctx.didcomm_service, @@ -1123,8 +1142,9 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha ); } else { ctx.in_flight.finish(domain); - ctx.state.main_page.content_panel.credentials.status_message = - Some("That relationship is no longer available.".to_string()); + ctx.state.main_page.content_panel.credentials.status_message = Some( + Status::warning("That relationship is no longer available.".to_string()), + ); } } else { credential_actions::dispatch(ca, ctx.config, ctx.state, ctx.save); @@ -1323,8 +1343,9 @@ async fn assert_personhood(ctx: &mut ActionCtx<'_>, witnessed: Option, witnessed: Option, witnessed: Option { - ctx.state.main_page.content_panel.communities.status_message = - Some(format!("Couldn't sign the personhood presentation: {e}")); + ctx.state.main_page.content_panel.communities.status_message = Some(Status::error( + format!("Couldn't sign the personhood presentation: {e}"), + )); } (None, _) => { - ctx.state.main_page.content_panel.communities.status_message = - Some("Messaging unavailable — cannot assert right now.".to_string()); + ctx.state.main_page.content_panel.communities.status_message = Some(Status::error( + "Messaging unavailable — cannot assert right now.".to_string(), + )); } } Handled::Continue @@ -1736,6 +1760,7 @@ mod tests { .communities .status_message .clone() + .map(|s| s.text) .unwrap_or_default() } @@ -2007,7 +2032,12 @@ mod tests { .device_access .clone() .unwrap(); - assert!(view.message.unwrap_or_default().contains("did:key")); + assert!( + view.message + .map(|s| s.text) + .unwrap_or_default() + .contains("did:key") + ); assert!(view.form.is_some(), "the form stays open to fix the DID"); } @@ -2115,7 +2145,7 @@ mod tests { let comms = &h.state.main_page.content_panel.communities; assert!(comms.personhood_evidence.is_none(), "answered"); assert!( - went_on_to_send(comms.status_message.as_deref()), + went_on_to_send(comms.status_message.as_ref().map(|s| s.text.as_str())), "and it went on to send: {:?}", comms.status_message ); @@ -2133,7 +2163,7 @@ mod tests { let comms = &h.state.main_page.content_panel.communities; assert!(comms.personhood_evidence.is_none()); assert!( - went_on_to_send(comms.status_message.as_deref()), + went_on_to_send(comms.status_message.as_ref().map(|s| s.text.as_str())), "{:?}", comms.status_message ); diff --git a/openvtc/src/state_handler/settings_actions.rs b/openvtc/src/state_handler/settings_actions.rs index 8fc52f73..c8c9f26c 100644 --- a/openvtc/src/state_handler/settings_actions.rs +++ b/openvtc/src/state_handler/settings_actions.rs @@ -1,5 +1,6 @@ //! Settings action handlers for the TUI. +use crate::state_handler::main_page::status::Status; use anyhow::Result; #[cfg(feature = "openpgp-card")] use openvtc_core::config::ConfigProtectionType; @@ -336,7 +337,7 @@ async fn handle_export_config_action( // derive, during which the loop is parked here but the render task keeps // drawing this status (the runtime is no longer pegged by the KDF). state.main_page.content_panel.settings.status_message = - Some("Deriving encryption key…".to_string()); + Some(Status::progress("Deriving encryption key…".to_string())); let _ = state_tx.send(state.clone()); match export_config(config, path, passphrase).await { @@ -371,7 +372,7 @@ async fn handle_export_config_action( } Err(e) => { state.main_page.content_panel.settings.status_message = - Some(format!("Export failed: {e:#}")); + Some(Status::error(format!("Export failed: {e:#}"))); state.main_page.log_error("Config export failed", &e); } } @@ -412,7 +413,7 @@ fn handle_import_config_action( } Err(e) => { state.main_page.content_panel.settings.status_message = - Some(format!("Import failed: {e:#}")); + Some(Status::error(format!("Import failed: {e:#}"))); state.main_page.log_error("Config import failed", &e); } } @@ -441,7 +442,7 @@ async fn handle_set_passphrase( // Push it to the render task now; `set_passphrase` then awaits a // `spawn_blocking` derive, during which the render task keeps drawing. state.main_page.content_panel.settings.status_message = - Some("Deriving encryption key…".to_string()); + Some(Status::progress("Deriving encryption key…".to_string())); let _ = state_tx.send(state.clone()); // R11 force-flush: a protection change re-derives the unlock key and rewrites @@ -551,9 +552,9 @@ fn handle_wipe_confirm(state: &mut State, config: &Config, profile: &str) -> boo _ => return false, }; if !typed.eq_ignore_ascii_case(WIPE_CONFIRM_TOKEN) { - state.main_page.content_panel.settings.status_message = Some(format!( + state.main_page.content_panel.settings.status_message = Some(Status::warning(format!( "Type {WIPE_CONFIRM_TOKEN} (exactly) to confirm — wipe cancelled." - )); + ))); state.main_page.content_panel.settings.mode = SettingsMode::View; return false; } @@ -579,7 +580,7 @@ fn handle_wipe_confirm(state: &mut State, config: &Config, profile: &str) -> boo .main_page .log_error("Failed to wipe openvtc profile", &e); state.main_page.content_panel.settings.status_message = - Some(format!("Wipe failed: {e:#}")); + Some(Status::error(format!("Wipe failed: {e:#}"))); return false; } } @@ -856,7 +857,7 @@ fn handle_theme_select(state: &mut State, roots: &Roots, index: usize) { theme::set_active(&theme); None } - Err(e) => Some(format!("Could not load {}: {e}", row.name)), + Err(e) => Some(Status::error(format!("Could not load {}: {e}", row.name))), }; } @@ -888,10 +889,12 @@ fn handle_theme_apply(state: &mut State, roots: &Roots) { mode.as_str() )); } - settings.status_message = Some(message); + settings.status_message = Some(Status::success(message)); state.main_page.log(format!("Theme set to {shown}")); } - Err(e) => settings.status_message = Some(format!("Could not use that theme: {e}")), + Err(e) => { + settings.status_message = Some(Status::error(format!("Could not use that theme: {e}"))) + } } } @@ -921,12 +924,14 @@ fn handle_theme_copy(state: &mut State, roots: &Roots) { match copied { Ok((copy_id, path)) => { reload_rows(state, roots, ©_id); - state.main_page.content_panel.settings.status_message = Some(format!( + state.main_page.content_panel.settings.status_message = Some(Status::success(format!( "Copied to {} — edit it, then press r here to see your changes.", path.display() - )); + ))); + } + Err(e) => { + settings.status_message = Some(Status::error(format!("Could not copy that theme: {e}"))) } - Err(e) => settings.status_message = Some(format!("Could not copy that theme: {e}")), } } @@ -960,7 +965,7 @@ fn handle_theme_reload(state: &mut State, roots: &Roots) { reload_rows(state, roots, &keep); let settings = &mut state.main_page.content_panel.settings; if settings.status_message.is_none() { - settings.status_message = Some("Themes reloaded.".to_string()); + settings.status_message = Some(Status::success("Themes reloaded.".to_string())); } } @@ -1053,7 +1058,7 @@ pub(crate) async fn dispatch( SettingsAction::TokenBack => handle_token_back(state), SettingsAction::ClipboardCopied(msg) => { state.main_page.content_panel.settings.status_message = Some(msg.clone()); - state.main_page.log(msg); + state.main_page.log(msg.text); } SettingsAction::WipeProfileStart => handle_wipe_start(state), SettingsAction::WipeProfileInput(value) => handle_wipe_input(state, value), @@ -1165,7 +1170,7 @@ mod tests { .settings .status_message .clone(); - assert!(message.is_some_and(|m| m.starts_with("Theme set to Auto — "))); + assert!(message.is_some_and(|m| m.text.starts_with("Theme set to Auto — "))); theme::set_active(&Theme::default_theme()); let _ = std::fs::remove_dir_all(base); @@ -1270,7 +1275,8 @@ mod tests { .content_panel .settings .status_message - .as_deref(), + .as_ref() + .map(|s| s.text.as_str()), Some("Setting saved") ); assert_eq!(config.public.friendly_name, "Alice"); diff --git a/openvtc/src/state_handler/vetting_actions/answers.rs b/openvtc/src/state_handler/vetting_actions/answers.rs index 416d818f..7e674d24 100644 --- a/openvtc/src/state_handler/vetting_actions/answers.rs +++ b/openvtc/src/state_handler/vetting_actions/answers.rs @@ -1,6 +1,7 @@ //! Answers from communities, revocation checks, and the join flow's hand-off //! into the Vetting page. +use crate::state_handler::main_page::status::Status; use chrono::Utc; use openvtc_core::config::Config; use openvtc_core::vetting::applicant::GrantStatus; @@ -110,7 +111,8 @@ pub(crate) fn apply_answers(state: &mut State, config: &Config, answers: Vec { - v.status_message = Some("That request is no longer on your desk.".to_string()); + v.status_message = Some(Status::warning( + "That request is no longer on your desk.".to_string(), + )); } } } @@ -405,7 +409,7 @@ pub(crate) fn focus_application( if let Some(i) = v.applications.iter().position(|a| a.id == application_id) { v.selected = i; } - v.status_message = Some(message); + v.status_message = Some(Status::info(message)); // Every way here — starting from the join page, an Inbox entry — lands on // the application's journey, so the holder always arrives at the same // picture of where they are. @@ -553,7 +557,8 @@ mod tests { .content_panel .vetting .status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) .is_some_and(|m| m.contains("has not named you a vetter")) ); } @@ -633,7 +638,8 @@ mod tests { let v = &state.main_page.content_panel.vetting; assert!( v.status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) .is_some_and(|m| m.contains("has revoked")) ); assert_eq!( @@ -680,7 +686,8 @@ mod tests { assert_eq!(v.tab, VettingTab::Desk); assert!( v.status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) .is_some_and(|m| m.contains("no longer on your desk")) ); } diff --git a/openvtc/src/state_handler/vetting_actions/applicant.rs b/openvtc/src/state_handler/vetting_actions/applicant.rs index c373c071..76fdfc34 100644 --- a/openvtc/src/state_handler/vetting_actions/applicant.rs +++ b/openvtc/src/state_handler/vetting_actions/applicant.rs @@ -1,6 +1,7 @@ //! The applicant side: starting and abandoning applications, switching //! vetting path, requesting a vetter, and searching the vetter directory. +use crate::state_handler::main_page::status::Status; use chrono::Utc; use openvtc_core::config::Config; use openvtc_core::config::community_context::{self, ContextKind, ContextOption}; @@ -116,7 +117,7 @@ pub(super) fn switch_vetting_path(ctx: &mut ActionCtx<'_>) { ), ); } - Err(why) => status(ctx, why), + Err(why) => status(ctx, Status::error(why)), } } @@ -140,7 +141,10 @@ pub(super) async fn refresh_requirements(ctx: &mut ActionCtx<'_>, application_id Ok(d) => d, Err(e) => return abandon(ctx, "Could not ask the community", e), }; - status(ctx, "Asking the community what it requires…"); + status( + ctx, + Status::progress("Asking the community what it requires…"), + ); let sent = Sent::Manifest { community: app.community.clone(), }; @@ -204,10 +208,16 @@ pub(super) async fn start_application( context: Option, ) { if !community.starts_with("did:") { - return status(ctx, "Enter the community's DID (it starts with did:)."); + return status( + ctx, + Status::warning("Enter the community's DID (it starts with did:)."), + ); } let Some(persona) = page(ctx).personas.get(persona_index).cloned() else { - return status(ctx, "Choose the persona you will join with."); + return status( + ctx, + Status::warning("Choose the persona you will join with."), + ); }; let application_id = match ctx.config.private.vetting.start_application( community, @@ -221,7 +231,12 @@ pub(super) async fn start_application( } app.id.clone() } - Err(e) => return status(ctx, format!("Could not start the application: {e}")), + Err(e) => { + return status( + ctx, + Status::error(format!("Could not start the application: {e}")), + ); + } }; // Said, not dropped (R6.4): a criterion this build cannot honour is the one thing that // makes this application gather evidence the community will not count. @@ -282,11 +297,13 @@ pub(super) async fn request_vetter( Err(_) if entry.is_empty() => { return status( ctx, - "Paste the link from the vetter's QR code — it carries their ticket, \ + Status::warning( + "Paste the link from the vetter's QR code — it carries their ticket, \ and a request without one is never answered.", + ), ); } - Err(e) => return status(ctx, sanitize_display(&e.to_string(), 400)), + Err(e) => return status(ctx, Status::error(sanitize_display(&e.to_string(), 400))), } } }; @@ -304,7 +321,7 @@ pub(super) async fn request_vetter( ctx.config.private.vetting.pcs_due = true; return status( ctx, - openvtc_core::vetting::hidden::HiddenError::NotReady.to_string(), + Status::error(openvtc_core::vetting::hidden::HiddenError::NotReady.to_string()), ); } if !begin(ctx) { @@ -394,10 +411,12 @@ pub(super) fn paste_ticket(ctx: &mut ActionCtx<'_>, text: &str) { } status( ctx, - format!("Read the ticket link. It goes to {shown} — Enter sends the request."), + Status::info(format!( + "Read the ticket link. It goes to {shown} — Enter sends the request." + )), ); } - Err(e) => status(ctx, sanitize_display(&e.to_string(), 400)), + Err(e) => status(ctx, Status::error(sanitize_display(&e.to_string(), 400))), } } @@ -406,8 +425,10 @@ pub(super) fn open_directory(ctx: &mut ActionCtx<'_>) { if v.directory_communities.is_empty() { return status( ctx, - "The vetter directory is searched per community: start an application (n) or join a \ + Status::warning( + "The vetter directory is searched per community: start an application (n) or join a \ community first.", + ), ); } // Opened from an application — the list, or its journey — the search @@ -471,7 +492,7 @@ pub(super) async fn search_directory(ctx: &mut ActionCtx<'_>, cursors: Vec, cursors: Vec, forward: bool) { }; if forward { let Some(next) = next else { - return status(ctx, "That is the last page."); + return status(ctx, Status::warning("That is the last page.")); }; cursors.push(Some(next)); } else { if cursors.len() <= 1 { - return status(ctx, "This is the first page."); + return status(ctx, Status::warning("This is the first page.")); } cursors.pop(); } @@ -555,11 +576,11 @@ pub(super) fn ask_listed_vetter(ctx: &mut ActionCtx<'_>) { let Some(application_id) = target.application_id.clone() else { return status( ctx, - format!( + Status::warning(format!( "To ask {} you need an application to {} — start one on the Applications tab (n), \ then find them here again.", row.name, target.name - ), + )), ); }; let how = match &row.contact_hint { @@ -599,7 +620,7 @@ pub(super) fn abandon_application(ctx: &mut ActionCtx<'_>) { .abandon_application(&application_id) else { back(page(ctx)); - return status(ctx, "That application is already gone."); + return status(ctx, Status::warning("That application is already gone.")); }; let community = community_display(ctx.config, &app.community); let asked = app.requests.len(); @@ -609,7 +630,7 @@ pub(super) fn abandon_application(ctx: &mut ActionCtx<'_>) { ctx.save.mark_dirty(); status( ctx, - if asked == 0 { + Status::success(if asked == 0 { format!("Abandoned your application to {community}.") } else { format!( @@ -619,7 +640,7 @@ pub(super) fn abandon_application(ctx: &mut ActionCtx<'_>) { if asked == 1 { "" } else { "s" }, if asked == 1 { "s" } else { "" } ) - }, + }), ); } diff --git a/openvtc/src/state_handler/vetting_actions/desk.rs b/openvtc/src/state_handler/vetting_actions/desk.rs index d47f5a81..3388fc5a 100644 --- a/openvtc/src/state_handler/vetting_actions/desk.rs +++ b/openvtc/src/state_handler/vetting_actions/desk.rs @@ -1,6 +1,7 @@ //! The vetter desk: opening sessions, attesting, declining and withdrawing, //! and keeping the vetter side current with its communities. +use crate::state_handler::main_page::status::Status; use chrono::Utc; use openvtc_core::config::account::PersonaId; use openvtc_core::vetting::book::RequestVetting; @@ -35,7 +36,7 @@ pub(super) async fn open_session(ctx: &mut ActionCtx<'_>, request_id: &str, meth let Some(vetter_did) = persona_did(ctx.config, entry.persona) else { return status( ctx, - "The persona this request was made to is not available.", + Status::error("The persona this request was made to is not available."), ); }; let (required, known) = ctx.config.private.vetting.required_claims_for( @@ -63,7 +64,9 @@ pub(super) async fn open_session(ctx: &mut ActionCtx<'_>, request_id: &str, meth }; status( ctx, - "Asking the community which claims it requires — open the session again in a moment.", + Status::progress( + "Asking the community which claims it requires — open the session again in a moment.", + ), ); let sent = Sent::Manifest { community: entry.community.clone(), @@ -271,10 +274,10 @@ async fn ask_bindings(ctx: &mut ActionCtx<'_>) { if let Err(e) = sign_and_send(ctx, persona, document, sent).await { status( ctx, - format!( + Status::error(format!( "Could not ask {} which hidden-vetting key it holds for you: {e}", community_display(ctx.config, &community) - ), + )), ); } } @@ -384,7 +387,9 @@ pub(super) async fn attest(ctx: &mut ActionCtx<'_>, request_id: &str, form: &Att if !form.attested { return status( ctx, - "Tick the attestation (the last line) — signing is attributable to you in this community.", + Status::warning( + "Tick the attestation (the last line) — signing is attributable to you in this community.", + ), ); } let now = Utc::now(); @@ -392,12 +397,15 @@ pub(super) async fn attest(ctx: &mut ActionCtx<'_>, request_id: &str, form: &Att return; }; let DeskState::CardReceived { session, .. } = &entry.state else { - return status(ctx, "You can attest once their card has arrived."); + return status( + ctx, + Status::warning("You can attest once their card has arrived."), + ); }; let Some(vetter_did) = persona_did(ctx.config, entry.persona) else { return status( ctx, - "The persona this request was made to is not available.", + Status::error("The persona this request was made to is not available."), ); }; let method = VETTING_METHODS[form.method_index.min(VETTING_METHODS.len() - 1)]; @@ -439,9 +447,11 @@ pub(super) async fn attest(ctx: &mut ActionCtx<'_>, request_id: &str, form: &Att RequestVetting::Hidden => hides_vetters(book, &entry.community), RequestVetting::Named { .. } => false, RequestVetting::HiddenWithoutId { criterion } => { - return status(ctx, hidden_without_id_words(&criterion)); + return status(ctx, Status::warning(hidden_without_id_words(&criterion))); + } + RequestVetting::Unreadable(e) => { + return status(ctx, Status::error(format!("Cannot attest: {e}"))); } - RequestVetting::Unreadable(e) => return status(ctx, format!("Cannot attest: {e}")), }; if hidden_request && book @@ -458,9 +468,11 @@ pub(super) async fn attest(ctx: &mut ActionCtx<'_>, request_id: &str, form: &Att page(ctx).mode = VettingMode::List; status( ctx, - "This community proves vetting with a PCS zero-knowledge proof, and you are not \ + Status::warning( + "This community proves vetting with a PCS zero-knowledge proof, and you are not \ enrolled for it yet, so nothing was signed. Enrolling now — attest again in a \ moment.", + ), ); refresh_vetter_side(ctx).await; return; @@ -474,7 +486,7 @@ pub(super) async fn attest(ctx: &mut ActionCtx<'_>, request_id: &str, form: &Att .statement_draft(request_id, &vetter_did, attestation, now) { Ok(draft) => draft, - Err(e) => return status(ctx, format!("Cannot attest yet: {e}")), + Err(e) => return status(ctx, Status::error(format!("Cannot attest yet: {e}"))), }; if !begin(ctx) { return; @@ -551,7 +563,7 @@ pub(super) async fn decline( let Some(vetter_did) = persona_did(ctx.config, entry.persona) else { return status( ctx, - "The persona this request was made to is not available.", + Status::error("The persona this request was made to is not available."), ); }; if !begin(ctx) { @@ -605,7 +617,7 @@ pub(super) async fn withdraw(ctx: &mut ActionCtx<'_>, statement_id: &str, reason let Some(vetter_did) = persona_did(ctx.config, issued.persona) else { return status( ctx, - "The persona that signed this statement is not available.", + Status::error("The persona that signed this statement is not available."), ); }; if !begin(ctx) { diff --git a/openvtc/src/state_handler/vetting_actions/dispatch.rs b/openvtc/src/state_handler/vetting_actions/dispatch.rs index 5e641924..950cc19f 100644 --- a/openvtc/src/state_handler/vetting_actions/dispatch.rs +++ b/openvtc/src/state_handler/vetting_actions/dispatch.rs @@ -1,6 +1,7 @@ //! Maps each `VettingAction` onto the page: the action dispatcher, form //! navigation, and form submission. +use crate::state_handler::main_page::status::Status; use chrono::Utc; use openvtc_core::vetting::vetter::DeskState; use vta_sdk::protocols::vetting::VettingMethod; @@ -37,8 +38,9 @@ pub(super) fn page<'a>(ctx: &'a mut ActionCtx<'_>) -> &'a mut VettingState { &mut ctx.state.main_page.content_panel.vetting } -pub(super) fn status(ctx: &mut ActionCtx<'_>, message: impl Into) { - page(ctx).status_message = Some(message.into()); +/// Set the vetting page's status line, with the severity the caller knows. +pub(super) fn status(ctx: &mut ActionCtx<'_>, message: Status) { + page(ctx).status_message = Some(message); } /// Persist the book and show `message`. @@ -100,8 +102,9 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { } else { // Finished or abandoned between the panel drawing it and the // key arriving: say so where the holder is looking. - ctx.state.main_page.content_panel.communities.status_message = - Some("That application is no longer in progress.".to_string()); + ctx.state.main_page.content_panel.communities.status_message = Some( + Status::warning("That application is no longer in progress.".to_string()), + ); } } VettingAction::CloseJourney => { @@ -164,8 +167,10 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { if page(ctx).resend_candidates.is_empty() { status( ctx, - "Every community you are an active member of has already sent you a live \ + Status::warning( + "Every community you are an active member of has already sent you a live \ vetter credential — or you are not an active member of any.", + ), ); } else { page(ctx).mode = VettingMode::Resend { index: 0 }; @@ -175,8 +180,10 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { if page(ctx).event_offers.is_empty() { status( ctx, - "No community you vet for is running an event. Event mode is the exception, \ + Status::warning( + "No community you vet for is running an event. Event mode is the exception, \ not the setting — an ordinary week is the slow drip.", + ), ); } else { page(ctx).mode = VettingMode::EventMode { index: 0 }; @@ -230,7 +237,9 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { if page(ctx).personas.is_empty() { status( ctx, - "Create a persona under My Identity first — it is the DID you join with.", + Status::warning( + "Create a persona under My Identity first — it is the DID you join with.", + ), ); } else { page(ctx).mode = VettingMode::NewApplication { @@ -302,8 +311,10 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { if page(ctx).hidden.is_empty() { status( ctx, - "None of the communities you vet for hides its vetters, so there is no \ + Status::warning( + "None of the communities you vet for hides its vetters, so there is no \ hidden vetting to show.", + ), ); } else { page(ctx).mode = VettingMode::HiddenVetting { index: 0 }; @@ -325,19 +336,19 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { ctx.save.mark_dirty(); status( ctx, - format!( + Status::progress(format!( "Asking {name} which hidden-vetting key it holds for you, to restore it \ at your VTA…" - ), + )), ); refresh_vetter_side(ctx).await; } else { status( ctx, - format!( + Status::warning(format!( "Nothing to check at {name}: your hidden-vetting key is not in question, \ or a check is already running." - ), + )), ); } } @@ -367,13 +378,13 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { refresh_vetter_side(ctx).await; status( ctx, - if words.is_empty() { + Status::info(if words.is_empty() { "None of the communities you vet for proves vetting with PCS ZKP, so there \ are no tokens to get — hand out tickets with t." .to_string() } else { format!("Getting tokens — {}", words.join(" ")) - }, + }), ); } VettingAction::ReviewCard => { @@ -389,15 +400,17 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { preview: None, }; } - None => status(ctx, "No vetter is waiting for your card."), + None => status(ctx, Status::warning("No vetter is waiting for your card.")), } } VettingAction::NewTicket => { if page(ctx).memberships.is_empty() { status( ctx, - "You can hand out tickets once a community you belong to has named you a \ + Status::warning( + "You can hand out tickets once a community you belong to has named you a \ vetter — ask its admins for the vetter role.", + ), ); } else { page(ctx).mode = VettingMode::NewTicket { @@ -411,7 +424,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { let v = page(ctx); match v.tickets.get(v.selected).cloned() { Some(row) => v.mode = VettingMode::ConfirmDeleteTicket { ticket_id: row.id }, - None => status(ctx, "Highlight a ticket to delete."), + None => status(ctx, Status::warning("Highlight a ticket to delete.")), } } VettingAction::OpenSession => { @@ -423,7 +436,10 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { method_index: 0, }; } - Some(_) => status(ctx, "That request has moved past opening a session."), + Some(_) => status( + ctx, + Status::warning("That request has moved past opening a session."), + ), None => {} } } @@ -458,7 +474,10 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { }, }; } - Some(_) => status(ctx, "You can attest once their card has arrived."), + Some(_) => status( + ctx, + Status::warning("You can attest once their card has arrived."), + ), None => {} } } @@ -473,7 +492,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { field: 0, }; } - Some(_) => status(ctx, "That request is already closed."), + Some(_) => status(ctx, Status::warning("That request is already closed.")), None => {} } } @@ -485,7 +504,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { application_id: row.id, }; } - None => status(ctx, "Highlight an application to abandon."), + None => status(ctx, Status::warning("Highlight an application to abandon.")), } } VettingAction::ArmWithdraw => { @@ -503,7 +522,9 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { } Some(_) => status( ctx, - "That statement is already withdrawn — the community recorded it.", + Status::warning( + "That statement is already withdrawn — the community recorded it.", + ), ), None => {} } @@ -732,7 +753,9 @@ async fn submit(ctx: &mut ActionCtx<'_>) { VettingMode::HiddenVetting { .. } => { status( ctx, - "Reading each community's requirements again and drawing what the schedule owes…", + Status::progress( + "Reading each community's requirements again and drawing what the schedule owes…", + ), ); refresh_vetter_side(ctx).await; } @@ -762,11 +785,11 @@ async fn submit(ctx: &mut ActionCtx<'_>) { if message.chars().count() > DECLINE_MESSAGE_MAX { return status( ctx, - format!( + Status::warning(format!( "The note is {} characters; a decline carries at most \ {DECLINE_MESSAGE_MAX}.", message.chars().count() - ), + )), ); } let message = (!message.is_empty()).then(|| message.to_string()); diff --git a/openvtc/src/state_handler/vetting_actions/faces.rs b/openvtc/src/state_handler/vetting_actions/faces.rs index de3a9c3e..622f5dcd 100644 --- a/openvtc/src/state_handler/vetting_actions/faces.rs +++ b/openvtc/src/state_handler/vetting_actions/faces.rs @@ -1,6 +1,7 @@ //! The face an application wears: listing, creating and wearing faces at the //! VTA, saving attributes for them, and sending the vetting card. +use crate::state_handler::main_page::status::Status; use std::future::Future; use chrono::Utc; @@ -81,7 +82,7 @@ pub(super) fn open_new_face(ctx: &mut ActionCtx<'_>, application_id: &str, requi if !begin(ctx) { return; } - status(ctx, "Reading your attributes…"); + status(ctx, Status::progress("Reading your attributes…")); let job = FaceJob::Pool { client, application_id: application_id.to_string(), @@ -122,13 +123,13 @@ pub(super) fn create_face(ctx: &mut ActionCtx<'_>, mut form: NewFaceForm) { let application_id = form.application_id.clone(); let (context_id, persona_did) = match application_context(ctx.config, &application_id) { Ok(found) => found, - Err(e) => return status(ctx, format!("Cannot make a face: {e}")), + Err(e) => return status(ctx, Status::error(format!("Cannot make a face: {e}"))), }; if !begin(ctx) { return; } page(ctx).mode = VettingMode::List; - status(ctx, format!("Making {name}…")); + status(ctx, Status::progress(format!("Making {name}…"))); let job = FaceJob::Create { client, top_context_id: ctx.config.account.top_context_id.clone(), @@ -270,7 +271,9 @@ fn admin_client(ctx: &mut ActionCtx<'_>) -> Option { if client.is_none() { status( ctx, - "Faces live in your VTA, and it is not connected — try again once it is.", + Status::error( + "Faces live in your VTA, and it is not connected — try again once it is.", + ), ); } client @@ -328,12 +331,12 @@ pub(super) fn list_faces(ctx: &mut ActionCtx<'_>, application_id: &str) { }; let (context_id, persona_did) = match application_context(ctx.config, application_id) { Ok(found) => found, - Err(e) => return status(ctx, format!("Cannot choose a face: {e}")), + Err(e) => return status(ctx, Status::error(format!("Cannot choose a face: {e}"))), }; if !begin(ctx) { return; } - status(ctx, "Reading your faces…"); + status(ctx, Status::progress("Reading your faces…")); let job = FaceJob::List { client, context_id, @@ -346,7 +349,7 @@ pub(super) fn list_faces(ctx: &mut ActionCtx<'_>, application_id: &str) { /// Wear `face` in the application's context. pub(super) fn wear_face(ctx: &mut ActionCtx<'_>, application_id: &str, face: Option) { let Some(face) = face else { - return status(ctx, "Make a face under My Identity first."); + return status(ctx, Status::warning("Make a face under My Identity first.")); }; if face.worn { let v = page(ctx); @@ -356,7 +359,10 @@ pub(super) fn wear_face(ctx: &mut ActionCtx<'_>, application_id: &str, face: Opt .map_or(VettingMode::List, card_mode); return status( ctx, - format!("{} is already the face you show vetters.", face.name), + Status::warning(format!( + "{} is already the face you show vetters.", + face.name + )), ); } let Some(client) = admin_client(ctx) else { @@ -364,13 +370,13 @@ pub(super) fn wear_face(ctx: &mut ActionCtx<'_>, application_id: &str, face: Opt }; let (context_id, persona_did) = match application_context(ctx.config, application_id) { Ok(found) => found, - Err(e) => return status(ctx, format!("Cannot wear that face: {e}")), + Err(e) => return status(ctx, Status::error(format!("Cannot wear that face: {e}"))), }; if !begin(ctx) { return; } page(ctx).mode = VettingMode::List; - status(ctx, format!("Wearing {}…", face.name)); + status(ctx, Status::progress(format!("Wearing {}…", face.name))); let job = FaceJob::Wear { client, top_context_id: ctx.config.account.top_context_id.clone(), @@ -405,30 +411,33 @@ pub(super) async fn send_card( .session(session_id) .map(|(vetter, s)| (vetter.to_string(), s.expires_at)) else { - return status(ctx, "That session has closed."); + return status(ctx, Status::warning("That session has closed.")); }; if expires_at <= Utc::now() { return status( ctx, - "The session has expired — ask the vetter to open another.", + Status::warning("The session has expired — ask the vetter to open another."), ); } if let Some(problem) = preview.as_ref().and_then(|p| p.problem.clone()) { - return status(ctx, problem); + return status(ctx, Status::warning(problem)); } let Some(client) = admin_client(ctx) else { return; }; let context_id = match application_context(ctx.config, application_id) { Ok((id, _)) => id, - Err(e) => return status(ctx, format!("Cannot send a card: {e}")), + Err(e) => return status(ctx, Status::error(format!("Cannot send a card: {e}"))), }; if !begin(ctx) { return; } let step = match preview { None => { - status(ctx, "Asking your VTA what your face shows this vetter…"); + status( + ctx, + Status::progress("Asking your VTA what your face shows this vetter…"), + ); CardStep::Preview } Some(preview) => { @@ -446,7 +455,7 @@ pub(super) async fn send_card( ), Err(e) => return abandon(ctx, "Could not sign the card", e), }; - status(ctx, "Releasing and signing your card…"); + status(ctx, Status::progress("Releasing and signing your card…")); CardStep::Present(Box::new(Presenting { preview_id: preview.preview_id, signer, diff --git a/openvtc/src/state_handler/vetting_actions/hidden.rs b/openvtc/src/state_handler/vetting_actions/hidden.rs index 2b1b6e03..b61281af 100644 --- a/openvtc/src/state_handler/vetting_actions/hidden.rs +++ b/openvtc/src/state_handler/vetting_actions/hidden.rs @@ -1,6 +1,7 @@ //! Hidden vetting from the vetter's side: the token schedule tick, minting the //! engine, and attesting without a statement; plus the applicant's challenge request. +use crate::state_handler::main_page::status::Status; use chrono::{DateTime, Utc}; use openvtc_core::config::Config; use openvtc_core::config::account::PersonaId; @@ -59,7 +60,10 @@ pub(crate) async fn ask_for_challenge(ctx: &mut ActionCtx<'_>, application_id: & kind: QueryKind::PcsChallenge, sent_at: Utc::now(), }); - status(ctx, "Asking the community for a submission challenge…"); + status( + ctx, + Status::progress("Asking the community for a submission challenge…"), + ); let sent = Sent::Query { document_id, community: app.community.clone(), @@ -290,10 +294,10 @@ pub(super) fn ensure_hidden_vetter(ctx: &mut ActionCtx<'_>, community: &str) { ctx.save.mark_dirty(); status( ctx, - format!( + Status::progress(format!( "{} hides its vetters. Enrolling you — your attestations there will name nobody.", community_display(ctx.config, community) - ), + )), ); } @@ -315,12 +319,15 @@ pub(super) async fn attest_hidden( Err(e) => return cannot_attest_hidden(ctx, entry, &e.to_string(), is_wait(&e), now), }; let Some(session_id) = session_id_of(entry) else { - return status(ctx, "This request has no open session to attest on."); + return status( + ctx, + Status::warning("This request has no open session to attest on."), + ); }; let Some(context_id) = vetter_context(ctx.config, entry.persona) else { return status( ctx, - "The persona this request was made to is not available.", + Status::error("The persona this request was made to is not available."), ); }; let work = PcsWork::Attest { @@ -331,14 +338,17 @@ pub(super) async fn attest_hidden( }; if start_pcs_work(ctx, vec![work]) { page(ctx).mode = VettingMode::List; - status(ctx, "Your VTA is making the attestation — it names nobody…"); + status( + ctx, + Status::progress("Your VTA is making the attestation — it names nobody…"), + ); } else { status( ctx, - format!( + Status::warning(format!( "{} — attest again in a moment. Nothing was sent; the request stays open.", InFlight::busy_message(DispatchDomain::HiddenVetting) - ), + )), ); } } @@ -369,7 +379,7 @@ pub(super) fn cannot_attest_hidden( .filter(|_| waiting); status( ctx, - match outlook { + Status::error(match outlook { Some(o) => format!( "Cannot attest yet: {why}. Nothing was sent; the request stays open. {}: {}.{}", community_display(ctx.config, &entry.community), @@ -381,7 +391,7 @@ pub(super) fn cannot_attest_hidden( } ), None => format!("Cannot attest: {why}"), - }, + }), ); } diff --git a/openvtc/src/state_handler/vetting_actions/outcome.rs b/openvtc/src/state_handler/vetting_actions/outcome.rs index 0b40b3b5..2fa3bf21 100644 --- a/openvtc/src/state_handler/vetting_actions/outcome.rs +++ b/openvtc/src/state_handler/vetting_actions/outcome.rs @@ -636,7 +636,8 @@ mod tests { assert!(matches!(&v.mode, VettingMode::SendCard { preview: Some(p), .. } if *p == preview)); assert!( v.status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) .is_some_and(|m| m.contains("approve")) ); @@ -824,7 +825,8 @@ mod tests { .content_panel .vetting .status_message - .as_deref() + .as_ref() + .map(|s| s.text.as_str()) .is_some_and(|m| m.contains("unreachable")) ); } diff --git a/openvtc/src/state_handler/vetting_actions/pcs.rs b/openvtc/src/state_handler/vetting_actions/pcs.rs index 061e0c52..0d01173b 100644 --- a/openvtc/src/state_handler/vetting_actions/pcs.rs +++ b/openvtc/src/state_handler/vetting_actions/pcs.rs @@ -1,6 +1,7 @@ //! PCS work at the VTA: deciding when it is due, starting it, and applying //! what comes back (results, recoveries, and inbound PCS messages). +use crate::state_handler::main_page::status::Status; use std::sync::Arc; use chrono::{DateTime, Utc}; @@ -263,10 +264,10 @@ async fn apply_pcs_result( if !matches!(e, PcsError::Unreachable(_)) { status( ctx, - format!( + Status::error(format!( "Could not {what} for {}: {e}", community_display(ctx.config, &community) - ), + )), ); } } @@ -291,10 +292,10 @@ async fn apply_pcs_result( if !matches!(e, PcsError::Unreachable(_)) { status( ctx, - format!( + Status::error(format!( "Could not set up PCS ZKP vetting for {} at your VTA: {e}", community_display(ctx.config, &community) - ), + )), ); } } @@ -416,7 +417,7 @@ async fn send_enrolment( ) { let document = match wire::pcs_root_request(did, community, request) { Ok(d) => d, - Err(e) => return status(ctx, format!("Could not ask to enrol: {e}")), + Err(e) => return status(ctx, Status::error(format!("Could not ask to enrol: {e}"))), }; let document_id = document.id.clone(); if let Some(held) = ctx @@ -436,7 +437,10 @@ async fn send_enrolment( }); status( ctx, - format!("Asking to enrol as a vetter under {}…", request.label), + Status::progress(format!( + "Asking to enrol as a vetter under {}…", + request.label + )), ); let sent = Sent::Query { document_id, @@ -444,7 +448,7 @@ async fn send_enrolment( kind: QueryKind::PcsRoot, }; if let Err(e) = sign_and_send(ctx, persona, document, sent).await { - status(ctx, format!("Could not ask to enrol: {e}")); + status(ctx, Status::error(format!("Could not ask to enrol: {e}"))); } } @@ -459,7 +463,7 @@ async fn send_draw( ) { let document = match wire::pcs_tokens_request(did, community, request) { Ok(d) => d, - Err(e) => return status(ctx, format!("Could not draw tokens: {e}")), + Err(e) => return status(ctx, Status::error(format!("Could not draw tokens: {e}"))), }; let document_id = document.id.clone(); ctx.config.private.vetting.ask(CommunityQuery { @@ -480,7 +484,7 @@ async fn send_draw( kind: QueryKind::PcsTokens, }; if let Err(e) = sign_and_send(ctx, persona, document, sent).await { - status(ctx, format!("Could not draw tokens: {e}")); + status(ctx, Status::error(format!("Could not draw tokens: {e}"))); } } @@ -507,7 +511,12 @@ fn send_attestation( Ok(message) => message, // The token is spent either way, but the request is not closed on a delivery that never // left. - Err(e) => return status(ctx, format!("Could not send the attestation: {e}")), + Err(e) => { + return status( + ctx, + Status::error(format!("Could not send the attestation: {e}")), + ); + } }; if let Err(e) = ctx .config @@ -515,7 +524,10 @@ fn send_attestation( .vetting .hidden_attested(&job.request_id, now) { - return status(ctx, format!("Could not record the attestation: {e}")); + return status( + ctx, + Status::error(format!("Could not record the attestation: {e}")), + ); } persist(ctx, "Sending your attestation — it names nobody."); let sent = Sent::Attestation { @@ -686,7 +698,9 @@ fn apply_pcs_inbound( tracing::warn!(error = %e, "hidden attestation refused"); status( ctx, - format!("A vetter's attestation was refused by your VTA: {e}"), + Status::error(format!( + "A vetter's attestation was refused by your VTA: {e}" + )), ); } Ok(other) => tracing::warn!(?other, "unexpected answer to verify-attestation"), diff --git a/openvtc/src/state_handler/vetting_actions/send.rs b/openvtc/src/state_handler/vetting_actions/send.rs index e4439c3c..a220b336 100644 --- a/openvtc/src/state_handler/vetting_actions/send.rs +++ b/openvtc/src/state_handler/vetting_actions/send.rs @@ -1,6 +1,7 @@ //! Shared sending helpers: claiming the vetting domain, signing a message //! on the loop and handing its send to a background job. +use crate::state_handler::main_page::status::Status; use affinidi_tdk::didcomm::Message; use openvtc_core::config::Config; use openvtc_core::config::account::PersonaId; @@ -22,7 +23,10 @@ pub(super) fn begin(ctx: &mut ActionCtx<'_>) -> bool { if ctx.in_flight.try_begin(DispatchDomain::Vetting) { return true; } - status(ctx, InFlight::busy_message(DispatchDomain::Vetting)); + status( + ctx, + Status::warning(InFlight::busy_message(DispatchDomain::Vetting)), + ); false } @@ -33,7 +37,7 @@ pub(super) fn abandon(ctx: &mut ActionCtx<'_>, what: &str, error: impl std::fmt: let message = format!("{what}: {error}"); ctx.state.main_page.log(message.clone()); ctx.state.main_page.sync_from_config(ctx.config); - status(ctx, message); + status(ctx, Status::error(message)); } pub(super) fn persona_did(config: &Config, persona: PersonaId) -> Option { diff --git a/openvtc/src/state_handler/vetting_actions/tickets.rs b/openvtc/src/state_handler/vetting_actions/tickets.rs index accc232a..9da895f5 100644 --- a/openvtc/src/state_handler/vetting_actions/tickets.rs +++ b/openvtc/src/state_handler/vetting_actions/tickets.rs @@ -1,6 +1,7 @@ //! Vetter tickets: whether one may be issued, issuing and minting them, //! settling those waiting on a mode reading, and deleting them. +use crate::state_handler::main_page::status::Status; use chrono::{DateTime, Utc}; use openvtc_core::config::Config; use openvtc_core::vetting::VettingBook; @@ -44,7 +45,7 @@ pub(super) fn delete_ticket(ctx: &mut ActionCtx<'_>, ticket_id: &str) { .cloned() else { back(page(ctx)); - return status(ctx, "That ticket is already gone."); + return status(ctx, Status::warning("That ticket is already gone.")); }; ctx.config .private @@ -183,13 +184,17 @@ pub(super) async fn issue_ticket( let name = waiting.membership.name.clone(); return status( ctx, - format!("Still asking {name} how it vets, for the last ticket — a moment."), + Status::progress(format!( + "Still asking {name} how it vets, for the last ticket — a moment." + )), ); } let Some(did) = persona_did(ctx.config, membership.persona) else { return status( ctx, - "No ticket issued: the persona you vet for that community as is not available.", + Status::error( + "No ticket issued: the persona you vet for that community as is not available.", + ), ); }; if !begin(ctx) { @@ -216,7 +221,7 @@ pub(super) async fn issue_ticket( let words = ticket_unconfirmed(&membership.name, &failure, &reading, now); ctx.in_flight.finish(DispatchDomain::Vetting); ctx.state.main_page.log(words.clone()); - return status(ctx, words); + return status(ctx, Status::error(words)); } let name = membership.name.clone(); page(ctx).pending_ticket = Some(PendingTicket { @@ -226,10 +231,10 @@ pub(super) async fn issue_ticket( }); status( ctx, - format!( + Status::progress(format!( "Asking {name} how it vets now — named vetting or PCS ZKP — before issuing the \ ticket…" - ), + )), ); } @@ -359,7 +364,7 @@ pub(crate) fn settle_pending_tickets( }; let v = &mut state.main_page.content_panel.vetting; v.pending_ticket = None; - v.status_message = Some(words.clone()); + v.status_message = Some(Status::warning(words.clone())); state.main_page.log(words); } (Some(_), Some(TicketCheck::Refused(why))) => { @@ -369,12 +374,13 @@ pub(crate) fn settle_pending_tickets( }; let v = &mut state.main_page.content_panel.vetting; v.pending_ticket = None; - v.status_message = Some(words.clone()); + v.status_message = Some(Status::error(words.clone())); state.main_page.log(words); } _ => { if !said.is_empty() { - state.main_page.content_panel.vetting.status_message = Some(said.join(" ")); + state.main_page.content_panel.vetting.status_message = + Some(Status::info(said.join(" "))); } } } @@ -438,7 +444,11 @@ mod tests { assert_eq!(tickets[0].mode, Some(VetterMode::Named)); let v = &state.main_page.content_panel.vetting; assert!(v.pending_ticket.is_none()); - let said = v.status_message.as_deref().unwrap_or_default(); + let said = v + .status_message + .as_ref() + .map(|s| s.text.as_str()) + .unwrap_or_default(); assert!( said.starts_with("first-vtc switched from PCS ZKP to named vetting."), "{said}" diff --git a/openvtc/src/state_handler/vetting_actions/vetter.rs b/openvtc/src/state_handler/vetting_actions/vetter.rs index 9f123eac..7aade199 100644 --- a/openvtc/src/state_handler/vetting_actions/vetter.rs +++ b/openvtc/src/state_handler/vetting_actions/vetter.rs @@ -1,6 +1,7 @@ //! The vetter's own standing with its communities: the directory profile, //! event-mode requests, and credential resends. +use crate::state_handler::main_page::status::Status; use chrono::Utc; use openvtc_core::vetting::VettingBook; use openvtc_core::vetting::queries::{CommunityQuery, QueryKind}; @@ -86,9 +87,9 @@ pub(super) fn open_profile(ctx: &mut ActionCtx<'_>) { }; return status( ctx, - format!( + Status::warning(format!( "A profile is published to a community that named you a vetter, and none has.{hint}" - ), + )), ); } let form = profile_form(v, &ctx.config.private.vetting, 0, 0); @@ -178,7 +179,7 @@ async fn publish_profile(ctx: &mut ActionCtx<'_>, form: &VetterProfileForm) { let Some(vetter_did) = persona_did(ctx.config, membership.persona) else { return status( ctx, - "The persona this community named a vetter is not available.", + Status::error("The persona this community named a vetter is not available."), ); }; if !begin(ctx) { @@ -236,7 +237,7 @@ pub(super) async fn ask_event_mode(ctx: &mut ActionCtx<'_>, index: usize) { let Some(did) = persona_did(ctx.config, offer.persona) else { return status( ctx, - "The persona that belongs to this community is not available.", + Status::error("The persona that belongs to this community is not available."), ); }; if !begin(ctx) { @@ -265,10 +266,10 @@ pub(super) async fn ask_event_mode(ctx: &mut ActionCtx<'_>, index: usize) { page(ctx).mode = VettingMode::List; status( ctx, - format!( + Status::progress(format!( "Asking {} to vet at {} ({})…", offer.community_name, offer.event_id, offer.tier - ), + )), ); let sent = Sent::Query { document_id: document_id.clone(), @@ -288,7 +289,7 @@ pub(super) async fn ask_resend(ctx: &mut ActionCtx<'_>, index: usize) { let Some(did) = persona_did(ctx.config, target.persona) else { return status( ctx, - "The persona that belongs to this community is not available.", + Status::error("The persona that belongs to this community is not available."), ); }; if !begin(ctx) { @@ -309,10 +310,10 @@ pub(super) async fn ask_resend(ctx: &mut ActionCtx<'_>, index: usize) { page(ctx).mode = VettingMode::List; status( ctx, - format!( + Status::progress(format!( "Asking {} to send your vetter credential again…", target.name - ), + )), ); let sent = Sent::Query { document_id: document_id.clone(), diff --git a/openvtc/src/state_handler/vetting_actions/words.rs b/openvtc/src/state_handler/vetting_actions/words.rs index 067d497d..fa7124de 100644 --- a/openvtc/src/state_handler/vetting_actions/words.rs +++ b/openvtc/src/state_handler/vetting_actions/words.rs @@ -941,6 +941,7 @@ mod tests { .vetting .status_message .clone() + .map(|s| s.text) .unwrap_or_default(), config.private.vetting.vetter_refresh_due, ) diff --git a/openvtc/src/state_handler/witness_actions.rs b/openvtc/src/state_handler/witness_actions.rs index 8dd3a807..8a5c5e66 100644 --- a/openvtc/src/state_handler/witness_actions.rs +++ b/openvtc/src/state_handler/witness_actions.rs @@ -11,6 +11,7 @@ //! hands each one to the [`ReplyRouter`], which passes it to the job waiting on that session's //! thread; the job verifies it as the witness's before reading it. +use crate::state_handler::main_page::status::Status; use std::sync::Arc; use affinidi_tdk::TDK; @@ -360,12 +361,13 @@ impl WitnessOutcome { selected_vrc: None, }; } - rels.status_message = Some(line.clone()); + rels.status_message = Some(Status::success(line.clone())); state.main_page.log(line); } Err(e) => { let line = format!("Witnessing failed: {e}"); - state.main_page.content_panel.relationships.status_message = Some(line.clone()); + state.main_page.content_panel.relationships.status_message = + Some(Status::error(line.clone())); state.main_page.log(line); } } @@ -709,7 +711,7 @@ mod tests { .status_message .clone(); assert_eq!( - status.as_deref(), + status.as_ref().map(|s| s.text.as_str()), Some("Witnessing failed: the witness refused") ); assert!(matches!( diff --git a/openvtc/src/ui/pages/main/components/elections_panel.rs b/openvtc/src/ui/pages/main/components/elections_panel.rs index 5568d4e1..bd7431ff 100644 --- a/openvtc/src/ui/pages/main/components/elections_panel.rs +++ b/openvtc/src/ui/pages/main/components/elections_panel.rs @@ -62,12 +62,7 @@ fn wrap(lines: &mut Vec>, body: &str, indent: usize, style: Style) } fn push_status(lines: &mut Vec>, status: &Status) { - let style = match status.severity { - Severity::Info | Severity::Progress => Style::default().fg(COLOR_TEXT_DEFAULT), - Severity::Success => Style::default().fg(COLOR_SUCCESS), - Severity::Warning => Style::default().fg(COLOR_ORANGE), - Severity::Error => Style::default().fg(COLOR_WARNING_ACCESSIBLE_RED).bold(), - }; + let style = super::status::severity_style(status.severity); wrap(lines, &status.text, 4, style); } diff --git a/openvtc/src/ui/pages/main/components/identity_panel.rs b/openvtc/src/ui/pages/main/components/identity_panel.rs index 2c3e8a6d..7619a93f 100644 --- a/openvtc/src/ui/pages/main/components/identity_panel.rs +++ b/openvtc/src/ui/pages/main/components/identity_panel.rs @@ -842,7 +842,7 @@ fn render_profiles(state: &IdentityState, lines: &mut Vec>) { lines.push(Line::from("")); render_face_history(state, lines); if !detail.is_editable_here() { - super::status::push_status(lines, &detail.refusal(), " "); + super::status::push_error(lines, &detail.refusal(), " "); lines.push(Line::from("")); } if detail.resolved.is_empty() { @@ -1347,7 +1347,7 @@ fn push_agent_state(state: &IdentityState, lines: &mut Vec>, noun: lines.push(Line::from(line).fg(COLOR_ORANGE)); } } else { - super::status::push_status(lines, error, " "); + super::status::push_error(lines, error, " "); } lines.push(Line::from("")); lines.push(Line::from(" r: try again").fg(COLOR_DARK_GRAY)); @@ -1425,7 +1425,7 @@ fn render_attribute_form(form: &AttributeForm) -> Vec> { lines.push(Line::from("")); if let Some(error) = &form.error { - super::status::push_status(&mut lines, error, " "); + super::status::push_error(&mut lines, error, " "); lines.push(Line::from("")); } lines.push( @@ -1543,7 +1543,7 @@ fn render_profile_form(state: &IdentityState, form: &ProfileForm) -> Vec Vec> { lines.push(Line::from("")); if let Some(error) = &form.error { - super::status::push_status(&mut lines, error, " "); + super::status::push_error(&mut lines, error, " "); lines.push(Line::from("")); } lines.push( @@ -2322,7 +2322,7 @@ fn render_face_placer(state: &IdentityState, picker: &FacePlacer) -> Vec Vec) -> Span<'static> { } fn push_status(lines: &mut Vec>, status: &Status) { - let style = match status.severity { - Severity::Info | Severity::Progress => Style::default().fg(COLOR_TEXT_DEFAULT), - Severity::Success => Style::default().fg(COLOR_SUCCESS), - Severity::Warning => Style::default().fg(COLOR_ORANGE), - Severity::Error => Style::default().fg(COLOR_WARNING_ACCESSIBLE_RED).bold(), - }; + let style = super::status::severity_style(status.severity); for part in super::status::wrap_text( &status.text, super::status::content_width().saturating_sub(6).max(20), diff --git a/openvtc/src/ui/pages/main/components/status.rs b/openvtc/src/ui/pages/main/components/status.rs index 355769af..0b4c5c10 100644 --- a/openvtc/src/ui/pages/main/components/status.rs +++ b/openvtc/src/ui/pages/main/components/status.rs @@ -4,7 +4,10 @@ //! multi-cause error chains) must be word-wrapped manually before being //! pushed as `Line`s or they get clipped at the panel width. -use crate::colors::{COLOR_TEXT_DEFAULT, COLOR_WARNING_ACCESSIBLE_RED}; +use crate::colors::{ + COLOR_ORANGE, COLOR_SUCCESS, COLOR_TEXT_DEFAULT, COLOR_WARNING_ACCESSIBLE_RED, +}; +pub use crate::state_handler::main_page::status::{Severity, Status}; use ratatui::{ style::{Modifier, Style}, text::{Line, Span}, @@ -72,14 +75,20 @@ pub fn content_height() -> usize { CONTENT_HEIGHT.get() } -/// Push a status message as one or more wrapped lines (no trailing blank). +/// Push a status line as one or more wrapped lines (no trailing blank). /// -/// Messages worded as failures are rendered in the warning (red) colour; -/// everything else in the plain text colour (see [`status_style`]). -/// Each wrapped line is prefixed with `indent` so callers can indent inside -/// a panel. -pub fn push_status(lines: &mut Vec>, msg: &str, indent: &'static str) { - push_wrapped(lines, msg, indent, status_style(msg)); +/// Its colour comes from the [`Severity`] the producer set (see +/// [`severity_style`]), never from the wording of the text (#577). Each +/// wrapped line is prefixed with `indent` so callers can indent inside a +/// panel. +pub fn push_status(lines: &mut Vec>, status: &Status, indent: &'static str) { + push_wrapped(lines, &status.text, indent, severity_style(status.severity)); +} + +/// Push a failure the producer stored as an error (a form's `error` field): +/// [`push_status`] at [`Severity::Error`] without building a [`Status`]. +pub fn push_error(lines: &mut Vec>, msg: &str, indent: &'static str) { + push_wrapped(lines, msg, indent, severity_style(Severity::Error)); } /// [`push_status`] in a style the caller chooses, for a message whose severity @@ -94,38 +103,19 @@ pub fn push_wrapped(lines: &mut Vec>, msg: &str, indent: &'static } } -/// Phrases that mark a message as a failure. Matched case-insensitively -/// anywhere in the message, because failures are worded many ways ("Couldn't -/// archive: …", "Messaging unavailable — …"), not only with an `Error` prefix. -const FAILURE_MARKERS: &[&str] = &[ - "error", - "failed", - "couldn't", - "could not", - "can't", - "cannot", - "unavailable", - "not available", - "timed out", - "refused", - "denied", - "invalid", -]; - -/// Red for a message worded as a failure; otherwise the plain text colour. +/// The one style a status line of each severity is drawn in, on every panel. /// -/// A message that is not recognisably a failure is not thereby a success, so -/// it is no longer drawn in the success green — that made failures worded -/// without "Error"/"Failed" look like confirmations. Panels with a typed -/// severity use [`push_wrapped`] instead. -fn status_style(msg: &str) -> Style { - let lower = msg.to_lowercase(); - if FAILURE_MARKERS.iter().any(|m| lower.contains(m)) { - Style::new() +/// Errors are bold as well as red, so they stay distinguishable where colour +/// is not (a colour-blind theme, a monochrome terminal). +#[must_use] +pub fn severity_style(severity: Severity) -> Style { + match severity { + Severity::Info | Severity::Progress => Style::new().fg(COLOR_TEXT_DEFAULT), + Severity::Success => Style::new().fg(COLOR_SUCCESS), + Severity::Warning => Style::new().fg(COLOR_ORANGE), + Severity::Error => Style::new() .fg(COLOR_WARNING_ACCESSIBLE_RED) - .add_modifier(Modifier::BOLD) - } else { - Style::new().fg(COLOR_TEXT_DEFAULT) + .add_modifier(Modifier::BOLD), } } @@ -189,37 +179,45 @@ mod tests { assert_eq!(wrap_text("", 80), vec![String::new()]); } + /// The colour follows the severity the producer set, not the wording: + /// the failures from #577 that were drawn green are red because they are + /// errors, and a success worded with "failed" in it is still a success. #[test] - fn status_style_flags_errors() { - assert_eq!( - status_style("Error: something broke").fg, - Some(COLOR_WARNING_ACCESSIBLE_RED) - ); - assert_eq!( - status_style("Ping failed: timeout").fg, - Some(COLOR_WARNING_ACCESSIBLE_RED) - ); - assert_eq!( - status_style("Relationship removed").fg, - Some(COLOR_TEXT_DEFAULT) - ); - } - - /// Failures from #577 that were drawn in success green. - #[test] - fn failures_without_an_error_prefix_are_red() { + fn colour_follows_severity_not_wording() { for msg in [ "Messaging unavailable — cannot leave right now.", - "Couldn't sign the departure: bad key", "Couldn't archive: timeout", - "Could not use that theme: parse error", "Witnessing is not available until messaging and the VTA are connected.", ] { + let mut lines = Vec::new(); + push_status(&mut lines, &Status::error(msg), ""); assert_eq!( - status_style(msg).fg, + lines[0].spans[0].style.fg, Some(COLOR_WARNING_ACCESSIBLE_RED), "{msg}" ); } + let mut lines = Vec::new(); + push_status(&mut lines, &Status::success("Retried the failed send"), ""); + assert_eq!(lines[0].spans[0].style.fg, Some(COLOR_SUCCESS)); + let mut lines = Vec::new(); + push_status(&mut lines, &Status::warning("Nothing to dismiss"), ""); + assert_eq!(lines[0].spans[0].style.fg, Some(COLOR_ORANGE)); + } + + #[test] + fn every_severity_has_a_distinct_style() { + let styles = [ + Severity::Info, + Severity::Success, + Severity::Warning, + Severity::Error, + ] + .map(severity_style); + for (i, a) in styles.iter().enumerate() { + for b in &styles[i + 1..] { + assert_ne!(a, b); + } + } } } diff --git a/openvtc/src/ui/pages/main/components/vetting_panel/keys.rs b/openvtc/src/ui/pages/main/components/vetting_panel/keys.rs index 01ffdd7c..3d99460c 100644 --- a/openvtc/src/ui/pages/main/components/vetting_panel/keys.rs +++ b/openvtc/src/ui/pages/main/components/vetting_panel/keys.rs @@ -3,6 +3,7 @@ use crate::state_handler::actions::Action; use crate::state_handler::main_page::MainPanel; +use crate::state_handler::main_page::status::Status; use crate::ui::pages::main::{MainPage, edit_text}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -53,10 +54,10 @@ impl MainPage { // Reading the OS clipboard cannot work over SSH, where a // bracketed paste still can — so the refusal says that // rather than only that it failed. - Err(why) => V::Status(format!( + Err(why) => V::Status(Status::error(format!( "Could not read the clipboard ({why}). Paste directly into this screen \ instead — that works over SSH, where reading the clipboard cannot." - )), + ))), }; let _ = self.action_tx.send(Action::Vetting(action)); return true; @@ -238,8 +239,10 @@ impl MainPage { return true; }; let status = match crate::clipboard::copy_to_clipboard(&uri) { - Ok(method) => format!("Ticket link copied via {}.", method.label()), - Err(e) => format!("Could not copy the link: {e}"), + Ok(method) => { + Status::success(format!("Ticket link copied via {}.", method.label())) + } + Err(e) => Status::error(format!("Could not copy the link: {e}")), }; V::Status(status) } @@ -248,8 +251,10 @@ impl MainPage { return true; }; let status = match crate::clipboard::copy_to_clipboard(&ticket.code) { - Ok(method) => format!("Ticket code copied via {}.", method.label()), - Err(e) => format!("Could not copy the code: {e}"), + Ok(method) => { + Status::success(format!("Ticket code copied via {}.", method.label())) + } + Err(e) => Status::error(format!("Could not copy the code: {e}")), }; V::Status(status) } diff --git a/openvtc/src/ui/pages/main/components/vta_panel.rs b/openvtc/src/ui/pages/main/components/vta_panel.rs index 797eb8b8..bc613b9c 100644 --- a/openvtc/src/ui/pages/main/components/vta_panel.rs +++ b/openvtc/src/ui/pages/main/components/vta_panel.rs @@ -1,5 +1,5 @@ use super::panel::Panel; -use super::status::{push_status, push_wrapped}; +use super::status::{Status, push_status, push_wrapped}; use crate::colors::{ COLOR_DARK_GRAY, COLOR_ORANGE, COLOR_SOFT_PURPLE, COLOR_SUCCESS, COLOR_TEXT_DEFAULT, COLOR_WARNING_ACCESSIBLE_RED, @@ -348,7 +348,9 @@ fn render_transports(state: &VtaState, lines: &mut Vec>) { if let Some(err) = t.advertised.as_ref().and_then(|a| a.error.as_deref()) { push_status( lines, - &format!("Could not read the VTA's advertised transports: {err}"), + &Status::error(format!( + "Could not read the VTA's advertised transports: {err}" + )), " ", ); } diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index 8ba95a51..45937c6b 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -775,16 +775,20 @@ fn copy_to_clipboard( match crate::clipboard::copy_to_clipboard(text) { Ok(method) => { tracing::info!(label, method = method.label(), "copied to clipboard"); - let _ = action_tx.send(Action::Settings(SettingsAction::ClipboardCopied(format!( - "✓ {label} copied via {}", - method.label() - )))); + let _ = action_tx.send(Action::Settings(SettingsAction::ClipboardCopied( + crate::state_handler::main_page::status::Status::success(format!( + "✓ {label} copied via {}", + method.label() + )), + ))); } Err(e) => { tracing::warn!(label, error = %e, "failed to copy to clipboard"); - let _ = action_tx.send(Action::Settings(SettingsAction::ClipboardCopied(format!( - "✗ Copy failed: {e}" - )))); + let _ = action_tx.send(Action::Settings(SettingsAction::ClipboardCopied( + crate::state_handler::main_page::status::Status::error(format!( + "✗ Copy failed: {e}" + )), + ))); } } } From 05a21459e7f8ea3fe74a627da46159f82df07a56 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 16:50:18 +1000 Subject: [PATCH 02/11] =?UTF-8?q?ux(confirm):=20one=20confirmation=20rule?= =?UTF-8?q?=20=E2=80=94=20y=20confirms,=20any=20other=20key=20cancels=20(#?= =?UTF-8?q?579)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Destructive and outward-facing actions are armed by their key and committed by y alone; Enter, the key that opened the view, now cancels like any other key. Shared in ui/pages/main/confirm.rs (is_yes, KEYS, prompt) and used by every prompt, which now reads 'y: confirm · any other key: cancel'. Changed to y-only: community delete/leave/withdraw, device-grant revoke, inbox dismiss/clear-all, relationship and credential remove, invitation credential delete/purge, agent-name remove, persona confirmations, capability toggle, vetting abandon/delete-ticket/withdraw/decline. Now confirmed (previously sent on one key): Inbox reject (sent to the peer), Settings remove passphrase, Communities m (issue VMC) and R (renew). Typed-word confirmation stays for WIPE and RESET. Signed-off-by: Glenn Gore --- openvtc/src/state_handler/actions/mod.rs | 13 + openvtc/src/state_handler/inbox_actions.rs | 4 + .../src/state_handler/main_page/content.rs | 22 +- openvtc/src/state_handler/mod.rs | 9 +- openvtc/src/state_handler/runtime_actions.rs | 2 + .../main/components/capabilities_panel.rs | 19 +- .../main/components/communities_panel.rs | 43 ++- .../main/components/communities_panel/keys.rs | 33 +- .../main/components/credentials_panel.rs | 10 +- .../pages/main/components/identity_panel.rs | 69 ++-- .../main/components/identity_panel/keys.rs | 8 +- .../ui/pages/main/components/inbox_panel.rs | 58 ++-- .../main/components/relationships_panel.rs | 10 +- .../ui/pages/main/components/repos_panel.rs | 12 +- .../pages/main/components/settings_panel.rs | 37 +- .../ui/pages/main/components/vetting_panel.rs | 10 +- .../main/components/vetting_panel/keys.rs | 26 +- .../src/ui/pages/main/components/vta_panel.rs | 22 +- openvtc/src/ui/pages/main/confirm.rs | 64 ++++ openvtc/src/ui/pages/main/mod.rs | 316 ++++++++++++++++-- 20 files changed, 649 insertions(+), 138 deletions(-) create mode 100644 openvtc/src/ui/pages/main/confirm.rs diff --git a/openvtc/src/state_handler/actions/mod.rs b/openvtc/src/state_handler/actions/mod.rs index 07fcc55b..8c996058 100644 --- a/openvtc/src/state_handler/actions/mod.rs +++ b/openvtc/src/state_handler/actions/mod.rs @@ -48,6 +48,13 @@ pub enum InboxAction { }, /// Arm the confirmation for clearing all tasks (R25). ConfirmClearAll, + /// Arm the confirmation for rejecting an inbound request (#579): a + /// rejection is sent to the peer, so it confirms like a removal does. + /// `vrc` is a VRC request; otherwise a relationship request. + ConfirmReject { + task_id: String, + vrc: bool, + }, /// Cancel a pending dismiss/clear-all confirmation (R25). CancelConfirm, Back, @@ -979,6 +986,12 @@ pub enum Action { /// Dismiss a pending cancel confirmation without withdrawing. CommunityCancelWithdraw, + /// Arm the confirmation for a message to a community (issue VMC, renew). + CommunityConfirmSend(crate::state_handler::main_page::content::CommunitySend), + + /// Dismiss a pending send confirmation without sending. + CommunityCancelSend, + /// Cancel the Pending join at this index: best-effort notify the VTC, then /// set it `Withdrawn` and deregister its session so it can be deleted or /// re-joined. Only sent after the user confirms. diff --git a/openvtc/src/state_handler/inbox_actions.rs b/openvtc/src/state_handler/inbox_actions.rs index dd096c82..bdfd1183 100644 --- a/openvtc/src/state_handler/inbox_actions.rs +++ b/openvtc/src/state_handler/inbox_actions.rs @@ -1073,6 +1073,10 @@ pub(crate) async fn dispatch( InboxAction::ConfirmClearAll => { state.main_page.content_panel.inbox.confirm = Some(InboxConfirm::ClearAll); } + InboxAction::ConfirmReject { task_id, vrc } => { + state.main_page.content_panel.inbox.confirm = + Some(InboxConfirm::Reject { task_id, vrc }); + } InboxAction::CancelConfirm => { state.main_page.content_panel.inbox.confirm = None; } diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index dc1882db..8a0ec2ee 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -215,6 +215,15 @@ pub struct CapabilitiesState { // Communities State (R-C-*) // **************************************************************************** +/// A message to a community the Communities panel asks about before sending. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommunitySend { + /// Issue this membership's reciprocal VMC to the community (`m`). + IssueVmc(usize), + /// Ask the community to renew this membership's credentials (`R`). + Renew(usize), +} + /// State for the Communities overview panel — the account's community /// memberships, in display order (favourites first). #[derive(Clone, Debug, Default)] @@ -248,6 +257,9 @@ pub struct CommunitiesState { /// `y`/`n` confirmation. Transitions the record to `Withdrawn` so it can then /// be deleted or re-joined. pub confirm_withdraw: Option, + /// When `Some`, a message to the community (issue the VMC, ask to renew) + /// is awaiting `y` — it is sent to someone else, so it confirms (#579). + pub confirm_send: Option, /// Whether archived communities are included in the list (R-C-8). Off by /// default; toggled so archived records stay discoverable. pub show_archived: bool, @@ -3563,6 +3575,9 @@ pub enum InboxConfirm { Dismiss { task_id: String }, /// Clear every pending task. ClearAll, + /// Reject an inbound request — sent to the peer, so it confirms (#579). + /// `vrc` is a VRC request; otherwise a relationship request. + Reject { task_id: String, vrc: bool }, } /// Lightweight display summary of a task (no Arc/Mutex). @@ -4099,6 +4114,10 @@ pub struct TokenManagementState { pub reset_completed: bool, } +/// `ChangeProtection::active_field` while removing the passphrase awaits `y`: +/// it weakens what protects the key material, so it confirms (#579). +pub const PROTECTION_CONFIRM_REMOVE: usize = 3; + /// Display modes for the settings panel. #[derive(Clone, Debug, Default)] pub enum SettingsMode { @@ -4133,7 +4152,8 @@ pub enum SettingsMode { passphrase_len: usize, /// Length of the confirm passphrase (actual value held only in UI component) confirm_len: usize, - /// Which field is active (0 = option list, 1 = passphrase, 2 = confirm) + /// Which field is active (0 = option list, 1 = passphrase, 2 = confirm, + /// [`PROTECTION_CONFIRM_REMOVE`] = removing the passphrase awaits `y`) active_field: usize, }, /// Token management sub-screen diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index 9656e261..8e142711 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -3464,7 +3464,8 @@ impl StateHandler { Action::CommunitySelect(..) | Action::CommunityConfirmDelete(..) | Action::CommunityCancelDelete | Action::CommunityConfirmLeave(..) | Action::CommunityCancelLeave | Action::CommunityConfirmWithdraw(..) | - Action::CommunityCancelWithdraw | Action::CommunitySwitcherMove(..) | + Action::CommunityCancelWithdraw | Action::CommunityConfirmSend(..) | + Action::CommunityCancelSend | Action::CommunitySwitcherMove(..) | Action::CloseCommunitySwitcher | Action::DidSelect(..) | Action::DidConfirmDelete(..) | Action::DidCancelDelete | Action::StartCreatePersona | Action::CreatePersonaInput(..) | @@ -4703,6 +4704,12 @@ fn handle_nav_action(state: &mut State, action: &Action) -> bool { Action::CommunityCancelWithdraw => { state.main_page.content_panel.communities.confirm_withdraw = None; } + Action::CommunityConfirmSend(send) => { + state.main_page.content_panel.communities.confirm_send = Some(*send); + } + Action::CommunityCancelSend => { + state.main_page.content_panel.communities.confirm_send = None; + } Action::CommunitySwitcherMove(i) => { if let Some(switcher) = state.main_page.switcher.as_mut() { switcher.selected = (*i).min(switcher.items.len().saturating_sub(1)); diff --git a/openvtc/src/state_handler/runtime_actions.rs b/openvtc/src/state_handler/runtime_actions.rs index ee4986c9..34649ea5 100644 --- a/openvtc/src/state_handler/runtime_actions.rs +++ b/openvtc/src/state_handler/runtime_actions.rs @@ -1239,6 +1239,8 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha | Action::CommunityCancelLeave | Action::CommunityConfirmWithdraw(..) | Action::CommunityCancelWithdraw + | Action::CommunityConfirmSend(..) + | Action::CommunityCancelSend | Action::CommunitySwitcherMove(..) | Action::CloseCommunitySwitcher | Action::DidSelect(..) diff --git a/openvtc/src/ui/pages/main/components/capabilities_panel.rs b/openvtc/src/ui/pages/main/components/capabilities_panel.rs index e38b4463..2d659e9c 100644 --- a/openvtc/src/ui/pages/main/components/capabilities_panel.rs +++ b/openvtc/src/ui/pages/main/components/capabilities_panel.rs @@ -132,13 +132,14 @@ impl Panel for CapabilitiesPanel { { lines.push(Line::from("")); lines.push(Line::from(Span::styled( - format!( - " {} {}? This is a signed governance write. y/⏎ confirm · any other key cancels", - if item.enabled { "Disable" } else { "Enable" }, - item.slug, - ), - Style::default().fg(COLOR_ORANGE).bold(), - ))); + format!( + " {} {}? This is a signed governance write. {}", + if item.enabled { "Disable" } else { "Enable" }, + item.slug, + crate::ui::pages::main::confirm::KEYS, + ), + Style::default().fg(COLOR_ORANGE).bold(), + ))); } if let Some(status) = &view.status_message { @@ -249,10 +250,10 @@ impl MainPage { key: KeyEvent, view: &crate::state_handler::main_page::content::CapabilitiesView, ) -> bool { - // Toggle confirmation pending: y/⏎ commits, anything else cancels. + // Toggle confirmation pending: only y commits, anything else cancels (#579). if view.confirm_toggle.is_some() { match key.code { - KeyCode::Char('y') | KeyCode::Enter => { + KeyCode::Char('y') if crate::ui::pages::main::confirm::is_yes(&key) => { let _ = self.action_tx.send(Action::CapabilitiesToggleCommit); } _ => { diff --git a/openvtc/src/ui/pages/main/components/communities_panel.rs b/openvtc/src/ui/pages/main/components/communities_panel.rs index b8b67ac4..fdd40d85 100644 --- a/openvtc/src/ui/pages/main/components/communities_panel.rs +++ b/openvtc/src/ui/pages/main/components/communities_panel.rs @@ -9,7 +9,7 @@ use crate::colors::{ }; use crate::state_handler::{ main_page::content::{ - CommunitiesState, ContentPanelState, ContextDeletePhase, ContextDeleteView, + CommunitiesState, CommunitySend, ContentPanelState, ContextDeletePhase, ContextDeleteView, DeviceAccessView, GrantListing, }, state::ConnectionState, @@ -385,32 +385,47 @@ pub fn render( }; if let Some(idx) = state.confirm_delete { lines.push( - Line::from(format!( - "Delete “{}”? y: confirm n: cancel", + Line::from(crate::ui::pages::main::confirm::prompt(&format!( + "Delete “{}”?", confirm_name(idx) - )) + ))) .fg(COLOR_ORANGE) .bold(), ); } else if let Some(idx) = state.confirm_leave { lines.push( - Line::from(format!( - "Leave “{}”? This sends a self-removal to the community. y: confirm n: cancel", + Line::from(crate::ui::pages::main::confirm::prompt(&format!( + "Leave “{}”? This sends a self-removal to the community.", confirm_name(idx) - )) + ))) .fg(COLOR_ORANGE) .bold(), ); } else if let Some(idx) = state.confirm_withdraw { lines.push( - Line::from(format!( - "Cancel the pending join to “{}”? The request will be marked withdrawn. \ - y: confirm n: cancel", + Line::from(crate::ui::pages::main::confirm::prompt(&format!( + "Cancel the pending join to “{}”? The request will be marked withdrawn.", confirm_name(idx) - )) + ))) .fg(COLOR_ORANGE) .bold(), ); + } else if let Some(send) = state.confirm_send { + let question = match send { + CommunitySend::IssueVmc(idx) => format!( + "Issue your membership credential (VMC) to “{}”? It is signed and sent to the community.", + confirm_name(idx) + ), + CommunitySend::Renew(idx) => format!( + "Ask “{}” to renew your membership? It re-issues your credentials.", + confirm_name(idx) + ), + }; + lines.push( + Line::from(crate::ui::pages::main::confirm::prompt(&question)) + .fg(COLOR_ORANGE) + .bold(), + ); } else { lines.push(Line::from(key_hints(state)).fg(COLOR_DARK_GRAY)); } @@ -676,10 +691,10 @@ fn render_device_access( { lines.push(Line::from("")); lines.push( - Line::from(format!( - " Revoke {}'s access to this community? y: confirm n: cancel", + Line::from(crate::ui::pages::main::confirm::prompt(&format!( + " Revoke {}'s access to this community?", grant.did - )) + ))) .fg(COLOR_ORANGE) .bold(), ); diff --git a/openvtc/src/ui/pages/main/components/communities_panel/keys.rs b/openvtc/src/ui/pages/main/components/communities_panel/keys.rs index f8bc8b86..585c4ebc 100644 --- a/openvtc/src/ui/pages/main/components/communities_panel/keys.rs +++ b/openvtc/src/ui/pages/main/components/communities_panel/keys.rs @@ -4,6 +4,7 @@ use crate::state_handler::actions::Action; use crate::state_handler::main_page::MainPanel; +use crate::state_handler::main_page::content::CommunitySend; use crate::ui::pages::main::MainPage; use crate::ui::pages::main::components::{ self as components, @@ -115,7 +116,7 @@ impl MainPage { // (n/Esc) apply; every other key is swallowed so nothing slips through. if let Some(idx) = comms.confirm_delete { match key.code { - KeyCode::Char('y') | KeyCode::Enter => { + KeyCode::Char('y') if crate::ui::pages::main::confirm::is_yes(&key) => { let _ = self.action_tx.send(Action::DeleteCommunity(idx)); } _ => { @@ -127,7 +128,7 @@ impl MainPage { // A leave confirmation is pending (R-L-1): same y/n gate. if let Some(idx) = comms.confirm_leave { match key.code { - KeyCode::Char('y') | KeyCode::Enter => { + KeyCode::Char('y') if crate::ui::pages::main::confirm::is_yes(&key) => { let _ = self.action_tx.send(Action::LeaveCommunity(idx)); } _ => { @@ -139,7 +140,7 @@ impl MainPage { // A cancel-pending-join confirmation is pending: same y/n gate. if let Some(idx) = comms.confirm_withdraw { match key.code { - KeyCode::Char('y') | KeyCode::Enter => { + KeyCode::Char('y') if crate::ui::pages::main::confirm::is_yes(&key) => { let _ = self.action_tx.send(Action::WithdrawJoin(idx)); } _ => { @@ -149,6 +150,18 @@ impl MainPage { return true; } + // A message to the community is pending: same `y` gate (#579). + if let Some(send) = comms.confirm_send { + let _ = self.action_tx.send(Action::CommunityCancelSend); + if crate::ui::pages::main::confirm::is_yes(&key) { + let _ = self.action_tx.send(match send { + CommunitySend::IssueVmc(idx) => Action::IssueMemberVmc(idx), + CommunitySend::Renew(idx) => Action::RenewMembership(idx), + }); + } + return true; + } + // Status of the highlighted row gates which management keys apply. let sel_active = comms.items.get(selected).is_some_and(|c| c.is_active); let sel_inactive = comms.items.get(selected).is_some_and(|c| c.is_inactive); @@ -209,7 +222,11 @@ impl MainPage { KeyCode::Char('m') if sel_active => { // Issue this membership's reciprocal VMC back to the community and // send it to the VTC (members/vmc/1.0). Active-only. - let _ = self.action_tx.send(Action::IssueMemberVmc(selected)); + let _ = self + .action_tx + .send(Action::CommunityConfirmSend(CommunitySend::IssueVmc( + selected, + ))); true } KeyCode::Char('R') if sel_renewable => { @@ -219,7 +236,9 @@ impl MainPage { // the manual rescue for an approved join whose credential never // arrived: the community re-issues, and the renewal activates // the membership as delivery would have. - let _ = self.action_tx.send(Action::RenewMembership(selected)); + let _ = self + .action_tx + .send(Action::CommunityConfirmSend(CommunitySend::Renew(selected))); true } KeyCode::Char('c') if sel_active => { @@ -411,7 +430,9 @@ impl MainPage { } } else if view.confirm_revoke { Some(match key.code { - KeyCode::Char('y') | KeyCode::Enter => C::RevokeConfirm, + KeyCode::Char('y') if crate::ui::pages::main::confirm::is_yes(&key) => { + C::RevokeConfirm + } _ => C::RevokeCancel, }) } else { diff --git a/openvtc/src/ui/pages/main/components/credentials_panel.rs b/openvtc/src/ui/pages/main/components/credentials_panel.rs index 3e68757e..6d102d8f 100644 --- a/openvtc/src/ui/pages/main/components/credentials_panel.rs +++ b/openvtc/src/ui/pages/main/components/credentials_panel.rs @@ -303,9 +303,11 @@ fn render_detail(state: &CredentialsState, index: usize) -> Vec> { // A pending removal confirmation replaces the footer hint (R25). if state.confirm_delete.is_some() { lines.push( - Line::from("Remove this credential? y: confirm n: cancel") - .fg(COLOR_ORANGE) - .bold(), + Line::from(crate::ui::pages::main::confirm::prompt( + "Remove this credential?", + )) + .fg(COLOR_ORANGE) + .bold(), ); } else if state.selected_tab.allows_local_removal() { lines.push(Line::from("d: remove c: copy JSON Esc: back").fg(COLOR_DARK_GRAY)); @@ -466,7 +468,7 @@ impl MainPage { // else cancels. Mirrors the Communities/VTA-DID pattern (R25). if let Some(vrc_id) = creds.confirm_delete.clone() { match key.code { - KeyCode::Char('y') | KeyCode::Enter => { + KeyCode::Char('y') if crate::ui::pages::main::confirm::is_yes(&key) => { let _ = self .action_tx .send(Action::Credential(CredentialAction::Remove { vrc_id })); diff --git a/openvtc/src/ui/pages/main/components/identity_panel.rs b/openvtc/src/ui/pages/main/components/identity_panel.rs index 7619a93f..1b5be507 100644 --- a/openvtc/src/ui/pages/main/components/identity_panel.rs +++ b/openvtc/src/ui/pages/main/components/identity_panel.rs @@ -179,19 +179,22 @@ fn confirm_prompt(state: &IdentityState) -> Option { Some(name) => format!("{name} ({did})"), None => did.into_owned(), }; - Some(format!("Remove {name}? y: confirm n: cancel")) + Some(crate::ui::pages::main::confirm::prompt(&format!( + "Remove {name}?" + ))) } PersonaConfirm::DeleteAttribute { name, cascade, .. } => { if *cascade { // The cascading question names the consequence the plain one // does not have: profiles that show this value stop showing it. - Some(format!( + Some(crate::ui::pages::main::confirm::prompt(&format!( "\"{name}\" is on one or more faces. Forget it AND remove it from those \ - faces too? Nothing already shared is affected — that has left. \ - y: confirm n: cancel" - )) + faces too? Nothing already shared is affected — that has left." + ))) } else { - Some(format!("Forget \"{name}\"? y: confirm n: cancel")) + Some(crate::ui::pages::main::confirm::prompt(&format!( + "Forget \"{name}\"?" + ))) } } PersonaConfirm::DeleteProfile { @@ -207,15 +210,15 @@ fn confirm_prompt(state: &IdentityState) -> Option { .map(|w| format!(" {w}")) .unwrap_or_default(); if *unbind { - Some(format!( + Some(crate::ui::pages::main::confirm::prompt(&format!( "A persona wears \"{name}\". Delete it and leave that persona showing \ nothing?{spoken} To stop being it without losing it, press n and retire \ - it with x. y: confirm n: cancel" - )) + it with x." + ))) } else { - Some(format!( - "Delete the face \"{name}\"?{spoken} y: confirm n: cancel" - )) + Some(crate::ui::pages::main::confirm::prompt(&format!( + "Delete the face \"{name}\"?{spoken}" + ))) } } PersonaConfirm::PurgeAttribute { name, used_by, .. } => { @@ -232,42 +235,44 @@ fn confirm_prompt(state: &IdentityState) -> Option { " {n} faces use it; any that pin an old version will show nothing for it." ), }; - Some(format!( + Some(crate::ui::pages::main::confirm::prompt(&format!( "Forget every earlier version of \"{name}\", keeping only what it says now?\ - {faces} This cannot be undone. y: confirm n: cancel" - )) + {faces} This cannot be undone." + ))) } PersonaConfirm::DeleteWorld { name, faces, .. } => Some(match faces { // The sentence a holder needs is about what *survives*. "Delete // Work" reads to almost everyone as though the faces in it go too, // and a prompt that leaves that uncorrected is answered under a // belief nobody checked. - 0 => format!("Delete the world \"{name}\"? y: confirm n: cancel"), - 1 => format!( + 0 => crate::ui::pages::main::confirm::prompt(&format!("Delete the world \"{name}\"?")), + 1 => crate::ui::pages::main::confirm::prompt(&format!( "Delete the world \"{name}\"? The face in it is kept and belongs to no world \ - afterwards. y: confirm n: cancel" - ), - n => format!( + afterwards." + )), + n => crate::ui::pages::main::confirm::prompt(&format!( "Delete the world \"{name}\"? The {n} faces in it are kept and belong to no \ - world afterwards. y: confirm n: cancel" - ), + world afterwards." + )), }), PersonaConfirm::RetireFace { name, worn, .. } => Some(match worn { - 0 => format!( + 0 => crate::ui::pages::main::confirm::prompt(&format!( "Retire \"{name}\"? It is worn nowhere now; it is kept with its history and left \ - out of every picker until you reinstate it. y: confirm n: cancel" - ), - n => format!( + out of every picker until you reinstate it." + )), + n => crate::ui::pages::main::confirm::prompt(&format!( "Retire \"{name}\"? It comes off the {n} communit{} wearing it, and is kept with \ its history — reinstate it later with z, then x. Nothing already shared is \ - affected. y: confirm n: cancel", + affected.", if *n == 1 { "y" } else { "ies" } - ), + )), }), - PersonaConfirm::Unbind { community, .. } => Some(format!( - "Take it off — show {community} nothing? Nothing already shared is affected \ - — that has left. y: confirm n: cancel" - )), + PersonaConfirm::Unbind { community, .. } => { + Some(crate::ui::pages::main::confirm::prompt(&format!( + "Take it off — show {community} nothing? Nothing already shared is affected \ + — that has left." + ))) + } } } diff --git a/openvtc/src/ui/pages/main/components/identity_panel/keys.rs b/openvtc/src/ui/pages/main/components/identity_panel/keys.rs index 030c6191..aae02477 100644 --- a/openvtc/src/ui/pages/main/components/identity_panel/keys.rs +++ b/openvtc/src/ui/pages/main/components/identity_panel/keys.rs @@ -202,7 +202,9 @@ impl MainPage { PersonaConfirm::None => {} PersonaConfirm::DeletePersona(i) => { let act = match key.code { - KeyCode::Char('y') | KeyCode::Enter => Action::DeleteDid(i), + KeyCode::Char('y') if crate::ui::pages::main::confirm::is_yes(&key) => { + Action::DeleteDid(i) + } _ => Action::DidCancelDelete, }; let _ = self.action_tx.send(act); @@ -210,7 +212,9 @@ impl MainPage { } _ => { return match key.code { - KeyCode::Char('y') | KeyCode::Enter => send(PA::ConfirmYes), + KeyCode::Char('y') if crate::ui::pages::main::confirm::is_yes(&key) => { + send(PA::ConfirmYes) + } _ => send(PA::ConfirmNo), }; } diff --git a/openvtc/src/ui/pages/main/components/inbox_panel.rs b/openvtc/src/ui/pages/main/components/inbox_panel.rs index 61e47906..716a25c6 100644 --- a/openvtc/src/ui/pages/main/components/inbox_panel.rs +++ b/openvtc/src/ui/pages/main/components/inbox_panel.rs @@ -159,8 +159,16 @@ pub fn render(state: &InboxState, connection: &ConnectionState) -> Vec Line<'static> { let text = match confirm { - InboxConfirm::Dismiss { .. } => "Dismiss this task? y: confirm n: cancel", - InboxConfirm::ClearAll => "Clear all tasks? y: confirm n: cancel", + InboxConfirm::Dismiss { .. } => { + crate::ui::pages::main::confirm::prompt("Dismiss this task?") + } + InboxConfirm::ClearAll => crate::ui::pages::main::confirm::prompt("Clear all tasks?"), + InboxConfirm::Reject { vrc: false, .. } => crate::ui::pages::main::confirm::prompt( + "Reject this relationship request? The rejection is sent to them.", + ), + InboxConfirm::Reject { vrc: true, .. } => crate::ui::pages::main::confirm::prompt( + "Reject this credential request? The rejection is sent to them.", + ), }; Line::from(text).fg(COLOR_ORANGE).bold() } @@ -412,14 +420,27 @@ impl MainPage { pub(in crate::ui::pages::main) fn handle_inbox_key(&mut self, key: KeyEvent) -> bool { let inbox = &self.props.main_page.content_panel.inbox; - // A destructive-action confirmation is pending: only y/Enter commits; - // anything else cancels. Mirrors the Communities/VTA-DID pattern (R25). + // A destructive or outward-facing confirmation is pending: only `y` + // commits; anything else — Enter included — cancels (#579). if let Some(confirm) = inbox.confirm.clone() { match key.code { - KeyCode::Char('y') | KeyCode::Enter => { + KeyCode::Char('y') if crate::ui::pages::main::confirm::is_yes(&key) => { let commit = match confirm { InboxConfirm::Dismiss { task_id } => InboxAction::DismissTask { task_id }, InboxConfirm::ClearAll => InboxAction::ClearAll, + InboxConfirm::Reject { + task_id, + vrc: false, + } => InboxAction::RejectRelationship { + task_id, + reason: None, + }, + InboxConfirm::Reject { task_id, vrc: true } => { + InboxAction::RejectVrcRequest { + task_id, + reason: None, + } + } }; let _ = self.action_tx.send(Action::Inbox(commit)); } @@ -477,7 +498,7 @@ impl MainPage { })); true } - KeyCode::Char('a') => { + KeyCode::Char('a') if is_vrc_issued || is_vrc_request_inbound => { if is_vrc_issued { let _ = self .action_tx @@ -489,22 +510,15 @@ impl MainPage { } true } - KeyCode::Char('r') => { - if is_rel_inbound { - let _ = - self.action_tx - .send(Action::Inbox(InboxAction::RejectRelationship { - task_id, - reason: None, - })); - } else if is_vrc_request_inbound { - let _ = self - .action_tx - .send(Action::Inbox(InboxAction::RejectVrcRequest { - task_id, - reason: None, - })); - } + // Rejecting is sent to the peer, so it is armed and confirmed + // with `y`, like the local-only dismiss beside it (#579). + KeyCode::Char('r') if is_rel_inbound || is_vrc_request_inbound => { + let _ = self + .action_tx + .send(Action::Inbox(InboxAction::ConfirmReject { + task_id, + vrc: is_vrc_request_inbound, + })); true } KeyCode::Char('d') => { diff --git a/openvtc/src/ui/pages/main/components/relationships_panel.rs b/openvtc/src/ui/pages/main/components/relationships_panel.rs index 4781a30e..0f15fdc2 100644 --- a/openvtc/src/ui/pages/main/components/relationships_panel.rs +++ b/openvtc/src/ui/pages/main/components/relationships_panel.rs @@ -416,9 +416,11 @@ fn render_detail( // A pending removal confirmation replaces the footer hint (R25). if state.confirm_delete.is_some() { lines.push( - Line::from("Remove this relationship? y: confirm n: cancel") - .fg(COLOR_ORANGE) - .bold(), + Line::from(crate::ui::pages::main::confirm::prompt( + "Remove this relationship?", + )) + .fg(COLOR_ORANGE) + .bold(), ); } else { lines.push( @@ -793,7 +795,7 @@ impl MainPage { // else cancels. Mirrors the Communities/VTA-DID pattern (R25). if let Some(remote_p_did) = rels.confirm_delete.clone() { match key.code { - KeyCode::Char('y') | KeyCode::Enter => { + KeyCode::Char('y') if crate::ui::pages::main::confirm::is_yes(&key) => { let _ = self.action_tx.send(Action::Relationship( RelationshipAction::Remove { remote_p_did }, )); diff --git a/openvtc/src/ui/pages/main/components/repos_panel.rs b/openvtc/src/ui/pages/main/components/repos_panel.rs index e0aedc23..b2824905 100644 --- a/openvtc/src/ui/pages/main/components/repos_panel.rs +++ b/openvtc/src/ui/pages/main/components/repos_panel.rs @@ -158,8 +158,9 @@ impl Panel for ReposPanel { ))); let class = armed.request.consent_class(); lines.push(Line::from(dim(format!( - " {} change · signed by this persona · y confirm · any other key cancels", - class.label() + " {} change · signed by this persona · {}", + class.label(), + crate::ui::pages::main::confirm::KEYS )))); } @@ -1067,7 +1068,10 @@ fn render_workspace_overlay(lines: &mut Vec>, view: &ReposView) { signing, and its credential is revoked at the VTA.", Style::default().fg(COLOR_ORANGE).bold(), ))); - lines.push(Line::from(dim(" y confirm · any other key cancels"))); + lines.push(Line::from(dim(format!( + " {}", + crate::ui::pages::main::confirm::KEYS + )))); } } @@ -2176,7 +2180,7 @@ mod tests { v.workspace.confirm = Some(WorkspaceChange::RemoveIdentity); let out = rendered(v); assert!(out.contains("revoked at the VTA"), "{out}"); - assert!(out.contains("y confirm"), "{out}"); + assert!(out.contains("y: confirm"), "{out}"); } /// Everything the VTC supplies is cleaned before it is drawn: a reason diff --git a/openvtc/src/ui/pages/main/components/settings_panel.rs b/openvtc/src/ui/pages/main/components/settings_panel.rs index 22785965..dff28ce8 100644 --- a/openvtc/src/ui/pages/main/components/settings_panel.rs +++ b/openvtc/src/ui/pages/main/components/settings_panel.rs @@ -10,7 +10,9 @@ use crate::colors::{ use crate::state_handler::actions::{Action, SettingsAction}; use crate::state_handler::main_page::MainPanel; use crate::state_handler::{ - main_page::content::{ContentPanelState, SettingsMode, SettingsState, ThemeRow}, + main_page::content::{ + ContentPanelState, PROTECTION_CONFIRM_REMOVE, SettingsMode, SettingsState, ThemeRow, + }, state::ConnectionState, }; use crate::ui::pages::main::{MainPage, edit_text}; @@ -713,7 +715,19 @@ fn render_change_protection( ); lines.push(Line::from("")); - if active_field == 0 { + if active_field == PROTECTION_CONFIRM_REMOVE { + lines.push(Line::from( + "Remove the passphrase? The configuration is then protected by the OS keyring only.", + )); + lines.push(Line::from("")); + lines.push( + Line::from(crate::ui::pages::main::confirm::prompt( + "Remove the passphrase?", + )) + .fg(COLOR_ORANGE) + .bold(), + ); + } else if active_field == 0 { // Option selection mode let options = ["Set Passphrase", "Remove Passphrase (keyring only)"]; for (i, label) in options.iter().enumerate() { @@ -890,6 +904,17 @@ impl MainPage { } => { let active = *active_field; let sel = *selected_option; + // Removing the passphrase weakens what protects the keys: it + // is armed by Enter and committed only by `y` (#579). + if active == PROTECTION_CONFIRM_REMOVE { + let action = if crate::ui::pages::main::confirm::is_yes(&key) { + SettingsAction::RemovePassphrase + } else { + SettingsAction::ProtectionTabSwitch(0) + }; + let _ = self.action_tx.send(Action::Settings(action)); + return true; + } match key.code { KeyCode::Esc => { self.passphrase_buffer.clear(); @@ -918,10 +943,10 @@ impl MainPage { .action_tx .send(Action::Settings(SettingsAction::ProtectionStartInput)); } else { - // Remove passphrase - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::RemovePassphrase)); + // Remove passphrase — arm the confirmation. + let _ = self.action_tx.send(Action::Settings( + SettingsAction::ProtectionTabSwitch(PROTECTION_CONFIRM_REMOVE), + )); } true } diff --git a/openvtc/src/ui/pages/main/components/vetting_panel.rs b/openvtc/src/ui/pages/main/components/vetting_panel.rs index 6295b29b..aba30e64 100644 --- a/openvtc/src/ui/pages/main/components/vetting_panel.rs +++ b/openvtc/src/ui/pages/main/components/vetting_panel.rs @@ -797,9 +797,9 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { lines.push(Line::from("")); lines.push( Line::from(if *focused == 1 { - "Enter: decline ↑/↓: field Esc: keep it" + "Enter: done typing ↑/↓: field Esc: keep it" } else { - "y or Enter: decline n or Esc: keep it ↑/↓: field" + "y: decline n or Esc: keep it ↑/↓: field" }) .fg(COLOR_ORANGE) .bold(), @@ -853,7 +853,7 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { lines.push(hint("answer.")); } lines.push(Line::from("")); - lines.push(hint("Enter: abandon Esc: keep it")); + lines.push(hint("y: abandon · any other key: keep it")); } VettingMode::ConfirmDeleteTicket { ticket_id } => { lines.push(heading("Delete this ticket")); @@ -895,7 +895,7 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { "so hand out a new one if they still need to reach you.", )); lines.push(Line::from("")); - lines.push(hint("Enter: delete Esc: keep it")); + lines.push(hint("y: delete · any other key: keep it")); } VettingMode::Withdraw { statement_id, @@ -928,7 +928,7 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { false, )); lines.push(Line::from("")); - lines.push(hint("Enter: withdraw Esc: cancel")); + lines.push(hint("←/→: reason y: withdraw Esc: cancel")); } } } diff --git a/openvtc/src/ui/pages/main/components/vetting_panel/keys.rs b/openvtc/src/ui/pages/main/components/vetting_panel/keys.rs index 3d99460c..0fbcbcc3 100644 --- a/openvtc/src/ui/pages/main/components/vetting_panel/keys.rs +++ b/openvtc/src/ui/pages/main/components/vetting_panel/keys.rs @@ -22,6 +22,25 @@ impl MainPage { if !matches!(vetting.mode, VettingMode::List) { let text = vetting.mode.focused_text().map(str::to_string); let confirming = matches!(vetting.mode, VettingMode::ConfirmDecline { .. }); + // Abandon, delete-ticket and withdraw are committed by `y` alone + // (#579): Enter opened this view, and a key held a moment too long + // must not also commit it. A plain prompt (abandon, delete) takes + // any other key as "keep it". + let plain_prompt = matches!( + vetting.mode, + VettingMode::ConfirmAbandon { .. } | VettingMode::ConfirmDeleteTicket { .. } + ); + let y_commits = + confirming || plain_prompt || matches!(vetting.mode, VettingMode::Withdraw { .. }); + if plain_prompt { + let action = if crate::ui::pages::main::confirm::is_yes(&key) { + V::Submit + } else { + V::Back + }; + let _ = self.action_tx.send(Action::Vetting(action)); + return true; + } // The holder-grant view's whole job is to send you to another // terminal and back, and it ends with "then press f again". That // key was not bound here, so the one thing the screen asks for did @@ -64,6 +83,11 @@ impl MainPage { } let action = match key.code { KeyCode::Esc => Some(V::Back), + // On the decline note, Enter finishes typing and moves on; `y` + // then declines. On the other y-committed views Enter does + // nothing. + KeyCode::Enter if confirming && text.is_some() => Some(V::NextField), + KeyCode::Enter if y_commits => None, KeyCode::Enter => Some(V::Submit), KeyCode::Tab | KeyCode::Down => Some(V::NextField), KeyCode::BackTab | KeyCode::Up => Some(V::PrevField), @@ -72,7 +96,7 @@ impl MainPage { code => match text { Some(current) => edit_text(code, ¤t).map(V::Input), None if chooses_face && code == KeyCode::Char('f') => Some(V::ChooseFace), - None if code == KeyCode::Char('y') && confirming => Some(V::Submit), + None if code == KeyCode::Char('y') && y_commits => Some(V::Submit), None if code == KeyCode::Char('n') && confirming => Some(V::Back), None if on_result && code == KeyCode::Char('n') => Some(V::DirectoryPage(true)), None if on_result && code == KeyCode::Char('p') => { diff --git a/openvtc/src/ui/pages/main/components/vta_panel.rs b/openvtc/src/ui/pages/main/components/vta_panel.rs index bc613b9c..eb673f65 100644 --- a/openvtc/src/ui/pages/main/components/vta_panel.rs +++ b/openvtc/src/ui/pages/main/components/vta_panel.rs @@ -536,9 +536,9 @@ fn render_vics(state: &VtaState, panel_focused: bool, lines: &mut Vec Action::PurgeVic(idx), + KeyCode::Char('y') if crate::ui::pages::main::confirm::is_yes(&key) => { + Action::PurgeVic(idx) + } _ => Action::VicCancelPurge, }; let _ = self.action_tx.send(act); @@ -596,7 +600,9 @@ impl MainPage { } if let Some(idx) = vta.confirm_delete_vic { let act = match key.code { - KeyCode::Char('y') | KeyCode::Enter => Action::DeleteVic(idx), + KeyCode::Char('y') if crate::ui::pages::main::confirm::is_yes(&key) => { + Action::DeleteVic(idx) + } _ => Action::VicCancelDelete, }; let _ = self.action_tx.send(act); diff --git a/openvtc/src/ui/pages/main/confirm.rs b/openvtc/src/ui/pages/main/confirm.rs new file mode 100644 index 00000000..1d490efa --- /dev/null +++ b/openvtc/src/ui/pages/main/confirm.rs @@ -0,0 +1,64 @@ +//! The one confirmation rule for the main page (#579). +//! +//! A destructive or outward-facing action — one that removes something, or +//! sends something to someone else — is armed by its key and committed only by +//! `y`. Every other key cancels, Enter included: Enter is the key that opened +//! the view a moment earlier, so a key held a moment too long must never also +//! commit. Irreversible wipes (WIPE, RESET) keep their typed word instead. +//! +//! Every prompt ends with [`KEYS`], so it says exactly which keys do what. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// What every confirmation prompt says about its keys. +pub(crate) const KEYS: &str = "y: confirm · any other key: cancel"; + +/// Whether `key` commits a pending confirmation: a plain `y`, and nothing else. +#[must_use] +pub(crate) fn is_yes(key: &KeyEvent) -> bool { + key.code == KeyCode::Char('y') + && !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) +} + +/// A confirmation prompt: the question, then [`KEYS`]. +#[must_use] +pub(crate) fn prompt(question: &str) -> String { + format!("{question} {KEYS}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn only_a_plain_y_confirms() { + assert!(is_yes(&key(KeyCode::Char('y')))); + for code in [ + KeyCode::Enter, + KeyCode::Char('Y'), + KeyCode::Char('n'), + KeyCode::Char(' '), + KeyCode::Esc, + ] { + assert!(!is_yes(&key(code)), "{code:?}"); + } + assert!(!is_yes(&KeyEvent::new( + KeyCode::Char('y'), + KeyModifiers::CONTROL + ))); + } + + #[test] + fn a_prompt_says_which_keys_do_what() { + assert_eq!( + prompt("Remove this?"), + "Remove this? y: confirm · any other key: cancel" + ); + } +} diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index 45937c6b..3619e5b3 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -28,6 +28,7 @@ use std::cell::Cell; use tokio::sync::mpsc::UnboundedSender; pub mod components; +pub(crate) mod confirm; mod secret_buffer; use secret_buffer::SecretBuffer; @@ -527,7 +528,7 @@ impl MainPage { // the name, which anyone can then reclaim); anything else backs out. if overlay.confirm_remove.is_some() { match key.code { - KeyCode::Char('y') | KeyCode::Enter => { + KeyCode::Char('y') if crate::ui::pages::main::confirm::is_yes(&key) => { let _ = self.action_tx.send(Action::AgentNameManagerRemove); } _ => { @@ -1214,9 +1215,9 @@ impl MainPage { .map(|row| row.name.as_str()); if let Some(name) = armed { lines.push( - Line::from(format!( - "Release @{name}? It becomes free for anyone to reclaim. y: confirm n: cancel" - )) + Line::from(crate::ui::pages::main::confirm::prompt(&format!( + "Release @{name}? It becomes free for anyone to reclaim." + ))) .fg(COLOR_ORANGE) .bold(), ); @@ -1444,9 +1445,9 @@ mod key_handler_tests { use super::*; use crate::state_handler::actions::{InboxAction, PersonaAction}; use crate::state_handler::main_page::content::{ - CommunitySummary, CommunitySwitcherState, CredentialTab, CredentialsMode, InboxConfirm, - ManagedDid, PersonaConfirm, PersonaMode, PersonaTab, RawCredential, RelationshipSummary, - RelationshipsMode, SwitcherItem, TaskKind, TaskSummary, VrcSummary, + CommunitySend, CommunitySummary, CommunitySwitcherState, CredentialTab, CredentialsMode, + InboxConfirm, ManagedDid, PersonaConfirm, PersonaMode, PersonaTab, RawCredential, + RelationshipSummary, RelationshipsMode, SwitcherItem, TaskKind, TaskSummary, VrcSummary, }; use components::repos_panel::keys::{repos_copy_path, repos_key}; use crossterm::event::KeyModifiers; @@ -2579,19 +2580,20 @@ mod key_handler_tests { Ok(Action::CommunityConfirmLeave(0)) => {} _ => panic!("expected CommunityConfirmLeave(0)"), } - // Active row: `m` issues this membership's reciprocal VMC to the community. + // Active row: `m` arms issuing this membership's reciprocal VMC to the + // community — it is sent to someone else, so it confirms (#579). let (mut page, mut rx) = active(); page.handle_key_event(press(KeyCode::Char('m'))); match rx.try_recv() { - Ok(Action::IssueMemberVmc(0)) => {} - _ => panic!("expected IssueMemberVmc(0)"), + Ok(Action::CommunityConfirmSend(CommunitySend::IssueVmc(0))) => {} + _ => panic!("expected CommunityConfirmSend(IssueVmc(0))"), } - // Active row: `R` asks the community to renew this membership. + // Active row: `R` arms asking the community to renew this membership. let (mut page, mut rx) = active(); page.handle_key_event(press(KeyCode::Char('R'))); match rx.try_recv() { - Ok(Action::RenewMembership(0)) => {} - _ => panic!("expected RenewMembership(0)"), + Ok(Action::CommunityConfirmSend(CommunitySend::Renew(0))) => {} + _ => panic!("expected CommunityConfirmSend(Renew(0))"), } // Active row: `c` opens the community's capabilities view (the // cancel-pending-join meaning of `c` is Pending-only; the two are @@ -2816,11 +2818,17 @@ mod key_handler_tests { let (mut page, mut rx) = page_for(MainMenu::Identity, |s| { s.main_page.content_panel.identity.confirm = PersonaConfirm::DeletePersona(1); }); - page.handle_key_event(press(KeyCode::Enter)); + page.handle_key_event(press(KeyCode::Char('y'))); match rx.try_recv() { Ok(Action::DeleteDid(1)) => {} _ => panic!("expected DeleteDid(1)"), } + // Enter does not delete (#579). + let (mut page, mut rx) = page_for(MainMenu::Identity, |s| { + s.main_page.content_panel.identity.confirm = PersonaConfirm::DeletePersona(1); + }); + page.handle_key_event(press(KeyCode::Enter)); + assert!(!matches!(rx.try_recv(), Ok(Action::DeleteDid(_)))); let (mut page, mut rx) = page_for(MainMenu::Identity, |s| { s.main_page.content_panel.identity.confirm = PersonaConfirm::DeletePersona(0); @@ -3613,9 +3621,13 @@ mod key_handler_tests { page.handle_key_event(press(KeyCode::Char('y'))); assert!(matches!(rx.try_recv(), Ok(Action::AgentNameManagerRemove))); + // Enter does not confirm a removal (#579): it cancels, like any other key. let (mut page, mut rx) = armed(); page.handle_key_event(press(KeyCode::Enter)); - assert!(matches!(rx.try_recv(), Ok(Action::AgentNameManagerRemove))); + assert!(matches!( + rx.try_recv(), + Ok(Action::AgentNameManagerCancelRemove) + )); let (mut page, mut rx) = armed(); page.handle_key_event(press(KeyCode::Esc)); @@ -3797,15 +3809,20 @@ mod key_handler_tests { _ => panic!("expected VicConfirmDelete(0)"), } - // Armed: Enter commits the soft-delete. + // Armed: `y` commits the soft-delete; Enter does not (#579). let (mut page, mut rx) = page_for(MainMenu::Vta, |s| { s.main_page.content_panel.vta.confirm_delete_vic = Some(0); }); - page.handle_key_event(press(KeyCode::Enter)); + page.handle_key_event(press(KeyCode::Char('y'))); match rx.try_recv() { Ok(Action::DeleteVic(0)) => {} _ => panic!("expected DeleteVic(0)"), } + let (mut page, mut rx) = page_for(MainMenu::Vta, |s| { + s.main_page.content_panel.vta.confirm_delete_vic = Some(0); + }); + page.handle_key_event(press(KeyCode::Enter)); + assert!(!matches!(rx.try_recv(), Ok(Action::DeleteVic(_)))); // Armed: any other key cancels. let (mut page, mut rx) = page_for(MainMenu::Vta, |s| { @@ -3960,15 +3977,23 @@ mod key_handler_tests { _ => panic!("expected Inbox(DismissTask)"), } - // A pending clear-all commits on Enter. + // A pending clear-all commits on `y` only; Enter cancels (#579). let (mut page, mut rx) = page_for(MainMenu::Inbox, |s| { s.main_page.content_panel.inbox.confirm = Some(InboxConfirm::ClearAll); }); - page.handle_key_event(press(KeyCode::Enter)); + page.handle_key_event(press(KeyCode::Char('y'))); match rx.try_recv() { Ok(Action::Inbox(InboxAction::ClearAll)) => {} _ => panic!("expected Inbox(ClearAll)"), } + let (mut page, mut rx) = page_for(MainMenu::Inbox, |s| { + s.main_page.content_panel.inbox.confirm = Some(InboxConfirm::ClearAll); + }); + page.handle_key_event(press(KeyCode::Enter)); + match rx.try_recv() { + Ok(Action::Inbox(InboxAction::CancelConfirm)) => {} + _ => panic!("expected Enter to cancel"), + } // Any other key cancels. let (mut page, mut rx) = page_for(MainMenu::Inbox, |s| { @@ -4313,4 +4338,257 @@ mod key_handler_tests { ); } } + // ----- One confirmation rule (#579) ------------------------------------ + + /// Every action sent for `code` on a page armed by `arm`. + fn sent_for(menu: MainMenu, arm: &dyn Fn(&mut State), code: KeyCode) -> Vec { + let (mut page, mut rx) = page_for(menu, arm); + page.handle_key_event(press(code)); + let mut sent = Vec::new(); + while let Ok(action) = rx.try_recv() { + sent.push(action); + } + sent + } + + /// A held Enter — the key that opened the view a moment earlier — must + /// never commit a destructive or outward-facing action; `y` must. + #[test] + fn enter_never_commits_a_confirmation_and_y_does() { + use crate::state_handler::actions::VettingAction as V; + use crate::state_handler::main_page::content::{ + AgentNameManagerPhase, AgentNameManagerState, AgentNameRow, PROTECTION_CONFIRM_REMOVE, + SettingsMode, VettingMode, + }; + type Arm = Box; + type Commit = fn(&Action) -> bool; + fn one_community(s: &mut State) { + s.main_page.content_panel.communities.items = + Arc::from(vec![community_summary("Acme")]); + } + let cases: Vec<(&str, MainMenu, Arm, Commit)> = vec![ + ( + "community delete", + MainMenu::Communities, + Box::new(|s| { + one_community(s); + s.main_page.content_panel.communities.confirm_delete = Some(0); + }), + |a| matches!(a, Action::DeleteCommunity(0)), + ), + ( + "community leave", + MainMenu::Communities, + Box::new(|s| { + one_community(s); + s.main_page.content_panel.communities.confirm_leave = Some(0); + }), + |a| matches!(a, Action::LeaveCommunity(0)), + ), + ( + "community withdraw", + MainMenu::Communities, + Box::new(|s| { + one_community(s); + s.main_page.content_panel.communities.confirm_withdraw = Some(0); + }), + |a| matches!(a, Action::WithdrawJoin(0)), + ), + ( + "issue VMC", + MainMenu::Communities, + Box::new(|s| { + one_community(s); + s.main_page.content_panel.communities.confirm_send = + Some(CommunitySend::IssueVmc(0)); + }), + |a| matches!(a, Action::IssueMemberVmc(0)), + ), + ( + "renew membership", + MainMenu::Communities, + Box::new(|s| { + one_community(s); + s.main_page.content_panel.communities.confirm_send = + Some(CommunitySend::Renew(0)); + }), + |a| matches!(a, Action::RenewMembership(0)), + ), + ( + "inbox dismiss", + MainMenu::Inbox, + Box::new(|s| { + s.main_page.content_panel.inbox.confirm = Some(InboxConfirm::Dismiss { + task_id: "t1".into(), + }); + }), + |a| matches!(a, Action::Inbox(InboxAction::DismissTask { .. })), + ), + ( + "inbox clear all", + MainMenu::Inbox, + Box::new(|s| { + s.main_page.content_panel.inbox.confirm = Some(InboxConfirm::ClearAll); + }), + |a| matches!(a, Action::Inbox(InboxAction::ClearAll)), + ), + ( + "inbox reject (sent to the peer)", + MainMenu::Inbox, + Box::new(|s| { + s.main_page.content_panel.inbox.confirm = Some(InboxConfirm::Reject { + task_id: "t1".into(), + vrc: false, + }); + }), + |a| matches!(a, Action::Inbox(InboxAction::RejectRelationship { .. })), + ), + ( + "relationship remove", + MainMenu::Relationships, + Box::new(|s| { + s.main_page.content_panel.relationships.mode = RelationshipsMode::Detail { + index: 0, + selected_vrc: None, + }; + s.main_page.content_panel.relationships.confirm_delete = + Some("did:example:partner".into()); + }), + |a| matches!(a, Action::Relationship(RelationshipAction::Remove { .. })), + ), + ( + "credential remove", + MainMenu::Credentials, + Box::new(|s| { + s.main_page.content_panel.credentials.mode = + CredentialsMode::Detail { index: 0 }; + s.main_page.content_panel.credentials.confirm_delete = Some("vrc1".into()); + }), + |a| matches!(a, Action::Credential(CredentialAction::Remove { .. })), + ), + ( + "invitation credential delete", + MainMenu::Vta, + Box::new(|s| { + s.main_page.content_panel.vta.confirm_delete_vic = Some(0); + }), + |a| matches!(a, Action::DeleteVic(0)), + ), + ( + "agent name remove", + MainMenu::Vta, + Box::new(|s| { + s.main_page.agent_names = Some(AgentNameManagerState { + phase: AgentNameManagerPhase::Ready, + names: vec![AgentNameRow { + name: "alice".into(), + enabled: true, + }], + confirm_remove: Some(0), + ..Default::default() + }); + }), + |a| matches!(a, Action::AgentNameManagerRemove), + ), + ( + "persona delete", + MainMenu::Identity, + Box::new(|s| { + s.main_page.content_panel.identity.confirm = PersonaConfirm::DeletePersona(0); + }), + |a| matches!(a, Action::DeleteDid(0)), + ), + ( + "remove passphrase", + MainMenu::Settings, + Box::new(|s| { + s.main_page.content_panel.settings.mode = SettingsMode::ChangeProtection { + selected_option: 1, + passphrase_len: 0, + confirm_len: 0, + active_field: PROTECTION_CONFIRM_REMOVE, + }; + }), + |a| matches!(a, Action::Settings(SettingsAction::RemovePassphrase)), + ), + ( + "abandon an application", + MainMenu::Vetting, + Box::new(|s| { + s.main_page.content_panel.vetting.mode = VettingMode::ConfirmAbandon { + application_id: "a1".into(), + }; + }), + |a| matches!(a, Action::Vetting(V::Submit)), + ), + ( + "delete a ticket", + MainMenu::Vetting, + Box::new(|s| { + s.main_page.content_panel.vetting.mode = VettingMode::ConfirmDeleteTicket { + ticket_id: "t1".into(), + }; + }), + |a| matches!(a, Action::Vetting(V::Submit)), + ), + ]; + for (what, menu, arm, is_commit) in &cases { + let on_enter = sent_for(menu.clone(), arm.as_ref(), KeyCode::Enter); + assert!( + !on_enter.iter().any(is_commit), + "{what}: Enter must not commit" + ); + let on_y = sent_for(menu.clone(), arm.as_ref(), KeyCode::Char('y')); + assert!(on_y.iter().any(is_commit), "{what}: y must commit"); + } + } + + /// Reject in the Inbox detail is sent to the peer, so `r` arms it rather + /// than sending it. + #[test] + fn inbox_reject_arms_a_confirmation() { + use crate::state_handler::main_page::content::ActiveTaskView; + let (mut page, mut rx) = page_for(MainMenu::Inbox, |s| { + s.main_page.content_panel.inbox.active_task = + Some(ActiveTaskView::RelationshipRequestInbound { + task_id: "t1".into(), + from_did: "did:example:them".into(), + from_agent_name: None, + their_did: "did:example:them".into(), + reason: None, + name: None, + }); + }); + page.handle_key_event(press(KeyCode::Char('r'))); + match rx.try_recv() { + Ok(Action::Inbox(InboxAction::ConfirmReject { + task_id, + vrc: false, + })) => { + assert_eq!(task_id, "t1"); + } + _ => panic!("expected ConfirmReject"), + } + } + + /// Removing the passphrase from Settings arms a confirmation first. + #[test] + fn remove_passphrase_arms_a_confirmation() { + use crate::state_handler::main_page::content::{PROTECTION_CONFIRM_REMOVE, SettingsMode}; + let (mut page, mut rx) = page_for(MainMenu::Settings, |s| { + s.main_page.content_panel.settings.mode = SettingsMode::ChangeProtection { + selected_option: 1, + passphrase_len: 0, + confirm_len: 0, + active_field: 0, + }; + }); + page.handle_key_event(press(KeyCode::Enter)); + match rx.try_recv() { + Ok(Action::Settings(SettingsAction::ProtectionTabSwitch(f))) => { + assert_eq!(f, PROTECTION_CONFIRM_REMOVE); + } + _ => panic!("expected the removal to be armed"), + } + } } From 67b2e959769437861cb020a198245b5bd6295284 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 17:14:58 +1000 Subject: [PATCH 03/11] ux(keys): one key table per view drives the footer, a ? overlay and feedback (#580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every main-page view now builds its keys as a list of Bindings (ui/pages/main/keymap.rs) and draws them with keymap::footer, which also advertises them for the frame. The ? overlay lists exactly the advertised keys of the focused view plus the global ones; the footer row and the Help / Status shortcut list are generated from the same global table. - one label vocabulary (Enter, Esc, Tab, Shift+Tab, arrows, Space, Del, Ctrl+X) parsed into the key codes it names, so a label cannot name a key it does not match; one format 'key: does · key: does' - a key a view does not take shows 'x does nothing here — ? lists this panel's keys' in the footer row instead of vanishing; a text field keeps ←/→/Tab rather than switching panels; Ctrl/Alt chords reach a view only where it lists them - consistent keys: archive is x (VTA invitations too), show archived is v (was i on the VTA panel), refresh is r (was Tab on the VTA panel); Communities joins with n, and j/k move there as in rooms, elections and the menu - TSP Relationships gets a key handler (↑/↓, Esc) - silent swallows removed (inbox a, credential d, VTA u, persona d, identity read-only overlays and pickers); local-face delete, make reusable and forget-contact now confirm with y only - keymap_tests checks, per view, that the footer lists exactly the keys the handler takes (inbox, relationships, credentials, VTA, communities, settings, help, TSP, identity tabs, capabilities) Signed-off-by: Glenn Gore --- openvtc/src/state_handler/actions/mod.rs | 3 + openvtc/src/state_handler/mod.rs | 7 +- openvtc/src/state_handler/runtime_actions.rs | 1 + .../src/ui/pages/create_persona_overlay.rs | 48 ++- .../main/components/capabilities_panel.rs | 41 +- .../main/components/communities_panel.rs | 171 +++++--- .../main/components/communities_panel/keys.rs | 10 +- .../ui/pages/main/components/content_panel.rs | 64 ++- .../main/components/credentials_panel.rs | 66 ++- .../pages/main/components/elections_panel.rs | 105 +++-- .../pages/main/components/identity_panel.rs | 367 +++++++++++------ .../main/components/identity_panel/keys.rs | 37 +- .../ui/pages/main/components/inbox_panel.rs | 61 ++- .../ui/pages/main/components/logs_panel.rs | 24 +- .../main/components/relationships_panel.rs | 87 +++- .../ui/pages/main/components/repos_panel.rs | 225 ++++++++--- .../ui/pages/main/components/rooms_panel.rs | 47 ++- .../pages/main/components/settings_panel.rs | 122 +++++- .../components/tsp_relationships_panel.rs | 37 ++ .../pages/main/components/vetting_journey.rs | 37 +- .../ui/pages/main/components/vetting_panel.rs | 295 ++++++++++---- .../src/ui/pages/main/components/vta_panel.rs | 87 ++-- openvtc/src/ui/pages/main/confirm.rs | 1 + openvtc/src/ui/pages/main/keymap.rs | 360 +++++++++++++++++ openvtc/src/ui/pages/main/keymap_tests.rs | 380 ++++++++++++++++++ openvtc/src/ui/pages/main/mod.rs | 312 +++++++++++--- 26 files changed, 2409 insertions(+), 586 deletions(-) create mode 100644 openvtc/src/ui/pages/main/keymap.rs create mode 100644 openvtc/src/ui/pages/main/keymap_tests.rs diff --git a/openvtc/src/state_handler/actions/mod.rs b/openvtc/src/state_handler/actions/mod.rs index 8c996058..c9296927 100644 --- a/openvtc/src/state_handler/actions/mod.rs +++ b/openvtc/src/state_handler/actions/mod.rs @@ -942,6 +942,9 @@ pub enum Action { /// Move the Communities-list selection to this index. CommunitySelect(usize), + /// Move the TSP Relationships cursor to this row. + TspSelect(usize), + /// Make the Active community at this index the working context (D10 / R-C-6): /// the community-scoped main page (relationships/inbox/VRCs/identity) switches /// to its persona. No-op for a non-Active (read-only) community. diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index 8e142711..ebb704bb 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -3461,7 +3461,8 @@ impl StateHandler { Action::DismissLoading | Action::CapabilitiesClose | Action::CapabilitiesUp | Action::CapabilitiesDown | Action::CapabilitiesDetail | Action::CapabilitiesToggleArm | Action::CapabilitiesToggleCancel | - Action::CommunitySelect(..) | Action::CommunityConfirmDelete(..) | + Action::CommunitySelect(..) | Action::TspSelect(..) | + Action::CommunityConfirmDelete(..) | Action::CommunityCancelDelete | Action::CommunityConfirmLeave(..) | Action::CommunityCancelLeave | Action::CommunityConfirmWithdraw(..) | Action::CommunityCancelWithdraw | Action::CommunityConfirmSend(..) | @@ -4683,6 +4684,10 @@ fn handle_nav_action(state: &mut State, action: &Action) -> bool { view.confirm_toggle = None; } } + Action::TspSelect(i) => { + let tsp = &mut state.main_page.content_panel.tsp_relationships; + tsp.selected_index = (*i).min(tsp.rows.len().saturating_sub(1)); + } Action::CommunitySelect(i) => { state.main_page.content_panel.communities.selected_index = *i; } diff --git a/openvtc/src/state_handler/runtime_actions.rs b/openvtc/src/state_handler/runtime_actions.rs index 34649ea5..9f245472 100644 --- a/openvtc/src/state_handler/runtime_actions.rs +++ b/openvtc/src/state_handler/runtime_actions.rs @@ -1233,6 +1233,7 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha | Action::CapabilitiesToggleArm | Action::CapabilitiesToggleCancel | Action::CommunitySelect(..) + | Action::TspSelect(..) | Action::CommunityConfirmDelete(..) | Action::CommunityCancelDelete | Action::CommunityConfirmLeave(..) diff --git a/openvtc/src/ui/pages/create_persona_overlay.rs b/openvtc/src/ui/pages/create_persona_overlay.rs index b40905f0..d0216cb5 100644 --- a/openvtc/src/ui/pages/create_persona_overlay.rs +++ b/openvtc/src/ui/pages/create_persona_overlay.rs @@ -23,6 +23,7 @@ use crate::colors::{ use crate::state_handler::actions::Action; use crate::state_handler::main_page::content::CreatePersonaState; +use crate::ui::pages::main::keymap::{self, k}; pub fn handle_key( overlay: &CreatePersonaState, @@ -180,10 +181,11 @@ pub fn render(frame: &mut Frame, overlay: &CreatePersonaState) { Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), ))); } - lines.push(Line::from(Span::styled( - "⏎ next esc cancel", - Style::new().fg(COLOR_BORDER), - ))); + lines.push(keymap::overlay_line(&[ + k(keymap::TYPE, "the label"), + k("Enter", "next"), + k("Esc", "cancel"), + ])); } CreatePersonaPhase::Path => { let custom = overlay.path_choice == PersonaPathChoice::Custom; @@ -290,14 +292,20 @@ pub fn render(frame: &mut Frame, overlay: &CreatePersonaState) { ))); } lines.push(Line::default()); - lines.push(Line::from(Span::styled( - if custom { - "⏎ create esc: let the server pick" - } else { - "⏎ create p: choose the path yourself m: DID method esc back" - }, - Style::new().fg(COLOR_BORDER), - ))); + lines.push(keymap::overlay_line(&if custom { + vec![ + k(keymap::TYPE, "the path"), + k("Enter", "create"), + k("Esc", "let the server pick"), + ] + } else { + vec![ + k("Enter", "create"), + k("p", "choose the path yourself"), + k("m", "DID method"), + k("Esc", "back"), + ] + })); } CreatePersonaPhase::Working => { for msg in &overlay.messages { @@ -324,10 +332,10 @@ pub fn render(frame: &mut Frame, overlay: &CreatePersonaState) { Style::new().fg(COLOR_SUCCESS), ))); } - lines.push(Line::from(Span::styled( - "c: copy again ⏎/esc close", - Style::new().fg(COLOR_BORDER), - ))); + lines.push(keymap::overlay_line(&[ + k("c", "copy again"), + k("Enter/Esc", "close (so does any other key)"), + ])); } CreatePersonaPhase::Failed => { for msg in &overlay.messages { @@ -337,10 +345,10 @@ pub fn render(frame: &mut Frame, overlay: &CreatePersonaState) { ))); } lines.push(Line::default()); - lines.push(Line::from(Span::styled( - "⏎/esc close", - Style::new().fg(COLOR_BORDER), - ))); + lines.push(keymap::overlay_line(&[k( + "Enter/Esc", + "close (so does any key)", + )])); } } diff --git a/openvtc/src/ui/pages/main/components/capabilities_panel.rs b/openvtc/src/ui/pages/main/components/capabilities_panel.rs index 2d659e9c..870482ed 100644 --- a/openvtc/src/ui/pages/main/components/capabilities_panel.rs +++ b/openvtc/src/ui/pages/main/components/capabilities_panel.rs @@ -15,6 +15,7 @@ use crate::state_handler::main_page::content::{CapabilitiesPhase, ContentPanelSt use crate::state_handler::state::ConnectionState; use super::panel::Panel; +use crate::ui::pages::main::keymap::{self, k}; pub struct CapabilitiesPanel; @@ -59,10 +60,7 @@ impl Panel for CapabilitiesPanel { Style::default().fg(COLOR_ORANGE), ))); lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - " r retry Esc back", - Style::default().fg(COLOR_DARK_GRAY), - ))); + keymap::footer(&mut lines, " ", &bindings(view)); return lines; } CapabilitiesPhase::Loaded if view.items.is_empty() => { @@ -148,14 +146,31 @@ impl Panel for CapabilitiesPanel { } lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - " ↑/↓ navigate ⏎ details e enable/disable (administrators) r refresh Esc back", - Style::default().fg(COLOR_DARK_GRAY), - ))); + keymap::footer(&mut lines, " ", &bindings(view)); lines } } +/// The view's keys in each phase, gated as the handler gates them. +fn bindings( + view: &crate::state_handler::main_page::content::CapabilitiesView, +) -> Vec { + match view.phase { + CapabilitiesPhase::Loading => vec![k("Esc", "back")], + CapabilitiesPhase::Failed(_) => vec![k("r", "retry"), k("Esc", "back")], + CapabilitiesPhase::Loaded if view.items.is_empty() => { + vec![k("r", "refresh"), k("Esc", "back")] + } + CapabilitiesPhase::Loaded => vec![ + k("↑/↓ or j/k", "move"), + k("Enter", "details"), + k("e", "enable/disable (administrators)"), + k("r", "refresh"), + k("Esc", "back"), + ], + } +} + fn kv(lines: &mut Vec>, key: &str, value: String) { lines.push(Line::from(vec![ Span::styled( @@ -262,10 +277,17 @@ impl MainPage { } return true; } + let listed = matches!(view.phase, CapabilitiesPhase::Loaded) && !view.items.is_empty(); + let loading = matches!(view.phase, CapabilitiesPhase::Loading); match key.code { KeyCode::Esc => { let _ = self.action_tx.send(Action::CapabilitiesClose); } + _ if loading => return false, + KeyCode::Char('r') => { + let _ = self.action_tx.send(Action::CapabilitiesRefresh); + } + _ if !listed => return false, KeyCode::Up | KeyCode::Char('k') => { let _ = self.action_tx.send(Action::CapabilitiesUp); } @@ -275,9 +297,6 @@ impl MainPage { KeyCode::Enter => { let _ = self.action_tx.send(Action::CapabilitiesDetail); } - KeyCode::Char('r') => { - let _ = self.action_tx.send(Action::CapabilitiesRefresh); - } KeyCode::Char('e') => { let _ = self.action_tx.send(Action::CapabilitiesToggleArm); } diff --git a/openvtc/src/ui/pages/main/components/communities_panel.rs b/openvtc/src/ui/pages/main/components/communities_panel.rs index fdd40d85..6e739468 100644 --- a/openvtc/src/ui/pages/main/components/communities_panel.rs +++ b/openvtc/src/ui/pages/main/components/communities_panel.rs @@ -14,6 +14,7 @@ use crate::state_handler::{ }, state::ConnectionState, }; +use crate::ui::pages::main::keymap::{self, k}; use openvtc_core::community_access::{DEVICE_ROLE, EXPIRY_CHOICES}; use openvtc_core::config::community_context::DELETE_CONFIRMATION; use ratatui::{ @@ -76,7 +77,10 @@ pub fn render( push_personhood_challenge(&mut lines, state); if state.items.is_empty() && state.joining.is_empty() { - return render_empty(lines); + let mut lines = render_empty(lines); + lines.push(Line::from("")); + keymap::footer(&mut lines, "", &self::bindings(state)); + return lines; } // Header with actions-required count (R-C-3). With no membership yet but a @@ -427,7 +431,7 @@ pub fn render( .bold(), ); } else { - lines.push(Line::from(key_hints(state)).fg(COLOR_DARK_GRAY)); + keymap::footer(&mut lines, "", &self::bindings(state)); } lines @@ -539,7 +543,7 @@ fn render_context_delete(lines: &mut Vec>, view: &ContextDeleteVie ContextDeletePhase::Previewing => { lines.push(Line::from(" Asking your VTA what this would remove…").style(dim)); lines.push(Line::from("")); - lines.push(Line::from("esc: cancel").style(dim)); + keymap::footer(lines, "", &[k("Esc", "cancel")]); } ContextDeletePhase::Ready(preview) => { lines.push(Line::from(" Your VTA will permanently remove:").fg(COLOR_TEXT_DEFAULT)); @@ -567,7 +571,7 @@ fn render_context_delete(lines: &mut Vec>, view: &ContextDeleteVie lines.push(Line::from("")); lines.push( Line::from(format!( - " This cannot be undone. Type {DELETE_CONFIRMATION} and press ⏎ to delete." + " This cannot be undone. Type {DELETE_CONFIRMATION} and press Enter to delete." )) .fg(COLOR_ORANGE), ); @@ -579,7 +583,15 @@ fn render_context_delete(lines: &mut Vec>, view: &ContextDeleteVie ), ])); lines.push(Line::from("")); - lines.push(Line::from("⏎ delete esc: cancel").style(dim)); + keymap::footer( + lines, + "", + &[ + k(keymap::TYPE, "the confirmation word"), + k("Enter", "delete"), + k("Esc", "cancel"), + ], + ); } ContextDeletePhase::Deleting => { lines.push(Line::from(" Deleting…").style(dim)); @@ -706,21 +718,37 @@ fn render_device_access( lines.push(Line::from("")); let hints = if view.busy { - "working… esc: close".to_string() - } else if view.form.is_some() { - "Tab: next field ←/→: expiry ⏎ grant esc: cancel".to_string() + lines.push(Line::from("working…").style(dim)); + vec![k("Esc", "close")] + } else if let Some(form) = &view.form { + let mut hints = Vec::new(); + if form.field < 2 { + hints.push(k(keymap::TYPE, "this field")); + } else { + hints.push(k("←/→", "expiry")); + } + hints.extend([ + k("Tab/↓", "next field"), + k("Shift+Tab/↑", "previous field"), + k("Enter", "grant"), + k("Esc", "cancel"), + ]); + hints } else { - let mut hints = vec!["↑/↓ select"]; + let mut hints = Vec::new(); + if !grants.is_empty() { + hints.push(k("↑/↓", "select")); + } if view.can_grant { - hints.push("n: add a device"); + hints.push(k("n", "add a device")); } if !grants.is_empty() { - hints.push("x: revoke"); + hints.push(k("x/Del", "revoke")); } - hints.extend(["r: refresh", "esc: close"]); - hints.join(" ") + hints.extend([k("r", "refresh"), k("Esc", "close")]); + hints }; - lines.push(Line::from(hints).style(dim)); + keymap::footer(lines, "", &hints); if !view.can_grant { lines.push( Line::from( @@ -782,6 +810,7 @@ fn render_personhood_evidence( lines.push(Line::from("")); } if view.confirm_all { + keymap::confirming(); lines.push( Line::from(format!( "Include all {} for this assertion only? The community learns each relationship. y: include all any other key: back", @@ -793,11 +822,16 @@ fn render_personhood_evidence( } else { let n = view.chosen.iter().filter(|c| **c).count(); lines.push(Line::from(format!("{n} chosen")).style(dim)); - lines.push( - Line::from( - "↑/↓ move Space: include/exclude A: include all… Enter: assert Esc: cancel", - ) - .style(dim), + keymap::footer( + lines, + "", + &[ + k("↑/↓", "move"), + k("Space", "include/exclude"), + k("A", "include all…"), + k("Enter", "assert"), + k("Esc", "cancel"), + ], ); } } @@ -855,45 +889,61 @@ fn push_personhood_challenge(lines: &mut Vec>, state: &Communities /// The two ungated keys (`j: join`, `v: show/hide archived`) are always listed: /// they act on the panel, not the selection, and stay available when nothing is /// selected at all. +/// The footer as text, each binding preceded by a space so a test can look +/// for one key (`" c: "`) without matching the tail of another (`"Esc: "`). +#[cfg(test)] fn key_hints(state: &CommunitiesState) -> String { + bindings(state) + .iter() + .map(|b| format!(" {}: {}", b.key, b.does)) + .collect::>() + .join(" ") +} + +/// The list's keys, gated exactly as the key handler gates them. +pub(crate) fn bindings(state: &CommunitiesState) -> Vec { let selected = state.items.get(state.selected_index); - let mut hints = vec!["↑/↓ navigate".to_string()]; + let rows = state.items.len() + state.joining.len(); + let mut hints = Vec::new(); + if rows > 0 { + hints.push(k("↑/↓ or j/k", "move")); + } if let Some(community) = selected { - hints.push("⏎ open".to_string()); - hints.push("f: ★".to_string()); - hints.push("a: acknowledge".to_string()); + hints.push(k("Enter", "make it the working community")); + hints.push(k("f", "★ favourite")); + hints.push(k("a", "acknowledge")); if community.is_active { - hints.push("m: issue VMC".to_string()); - hints.push("R: renew".to_string()); - hints.push("c: capabilities".to_string()); - hints.push("r: repos".to_string()); - hints.push("o: rooms".to_string()); - hints.push("e: elections".to_string()); - hints.push("p: personhood".to_string()); - hints.push("l: leave".to_string()); + hints.push(k("m", "issue VMC")); + hints.push(k("R", "renew")); + hints.push(k("c", "capabilities")); + hints.push(k("r", "repos")); + hints.push(k("o", "rooms")); + hints.push(k("e", "elections")); + hints.push(k("p", "personhood")); + hints.push(k("l", "leave")); } - if community.approved_awaiting_credential { + if community.approved_awaiting_credential && !community.is_active { // The rescue for a lost delivery; matches the key handler's gate. - hints.push("R: renew".to_string()); + hints.push(k("R", "renew")); } if community.is_pending { - hints.push("c: cancel".to_string()); + hints.push(k("c", "cancel the join")); } if community.is_inactive { - hints.push("x: archive".to_string()); - hints.push("d: delete".to_string()); + hints.push(k("x", "archive")); + hints.push(k("d/Del", "delete")); } if community.has_own_context { - hints.push("g: devices".to_string()); + hints.push(k("g", "devices")); if community.is_inactive { - hints.push("D: delete context".to_string()); + hints.push(k("D", "delete context")); } } } else if joining_selected(state).is_some() { // A join in progress has no membership for any of the keys above to act // on; its one action is the journey, where its own keys are. - hints.push("⏎ open application".to_string()); + hints.push(k("Enter", "open application")); } // Gated on the challenge, not on the row — matching the key handler, @@ -905,19 +955,20 @@ fn key_hints(state: &CommunitiesState) -> String { .as_ref() .is_some_and(|c| c.is_live(chrono::Utc::now())) { - hints.push("P: assert personhood".to_string()); + hints.push(k("P", "assert personhood")); } - hints.push("j: join".to_string()); - hints.push( + hints.push(k("n", "join a community")); + hints.push(k( + "v", if state.show_archived { - "v: hide archived" + "hide archived" } else { - "v: show archived" - } - .to_string(), - ); - hints.join(" ") + "show archived" + }, + )); + hints.push(k("Esc", "back")); + hints } /// The "Joining" row under the cursor, if the cursor is past the memberships — @@ -953,7 +1004,7 @@ fn render_empty(mut lines: Vec>) -> Vec> { .fg(COLOR_TEXT_DEFAULT), ); lines.push(Line::from("")); - lines.push(Line::from("Press j to join your first community.").fg(COLOR_ORANGE)); + lines.push(Line::from("Press n to join your first community.").fg(COLOR_ORANGE)); lines } @@ -1019,7 +1070,7 @@ mod key_hint_tests { ] { let hints = hints_for(row(active, inactive, pending)); assert_eq!( - hints.matches("c: ").count(), + hints.matches(" c: ").count(), usize::from(active || pending), "`c` must appear at most once, and only where it does something: {hints}" ); @@ -1061,22 +1112,22 @@ mod key_hint_tests { fn an_inactive_row_offers_archive_and_delete() { let hints = hints_for(row(false, true, false)); assert!( - hints.contains("x: archive") && hints.contains("d: delete"), + hints.contains("x: archive") && hints.contains("d/Del: delete"), "{hints}" ); - assert!(!hints.contains("c: "), "{hints}"); + assert!(!hints.contains(" c: "), "{hints}"); assert!(!hints.contains("l: leave"), "{hints}"); } - /// `j` and `v` act on the panel rather than the selection, so they survive + /// `n` and `v` act on the panel rather than the selection, so they survive /// an empty list — where every selection-gated key must be absent. #[test] fn panel_level_keys_survive_an_empty_selection() { let hints = key_hints(&CommunitiesState::default()); - assert!(hints.contains("j: join"), "{hints}"); + assert!(hints.contains("n: join"), "{hints}"); assert!(hints.contains("v: show archived"), "{hints}"); - assert!(!hints.contains("⏎ open"), "nothing is selected: {hints}"); - assert!(!hints.contains("c: "), "nothing is selected: {hints}"); + assert!(!hints.contains("Enter: "), "nothing is selected: {hints}"); + assert!(!hints.contains(" c: "), "nothing is selected: {hints}"); } /// The archived toggle reflects the current state, as it did before. @@ -1480,7 +1531,7 @@ mod key_hint_tests { assert!(live.contains("application role"), "{live}"); assert!(live.contains("did:key:zLaptop"), "{live}"); assert!( - live.contains("n: add a device") && live.contains("x: revoke"), + live.contains("n: add a device") && live.contains("x/Del: revoke"), "{live}" ); @@ -1537,7 +1588,7 @@ mod key_hint_tests { shown.contains("1 of 1 statement — ready to join"), "{shown}" ); - assert!(shown.contains("⏎ open application"), "{shown}"); + assert!(shown.contains("Enter: open application"), "{shown}"); } /// Several joins under way are counted in the header, not named one by one. @@ -1587,10 +1638,10 @@ mod key_hint_tests { assert!(shown.contains("▸ Globex as Work"), "{shown}"); let hints = key_hints(&state); - assert!(hints.contains("⏎ open application"), "{hints}"); + assert!(hints.contains("Enter: open application"), "{hints}"); assert!(!hints.contains("f: ★"), "{hints}"); assert!(!hints.contains("l: leave"), "{hints}"); - assert!(hints.contains("j: join"), "{hints}"); + assert!(hints.contains("n: join"), "{hints}"); } } diff --git a/openvtc/src/ui/pages/main/components/communities_panel/keys.rs b/openvtc/src/ui/pages/main/components/communities_panel/keys.rs index 585c4ebc..1cb06789 100644 --- a/openvtc/src/ui/pages/main/components/communities_panel/keys.rs +++ b/openvtc/src/ui/pages/main/components/communities_panel/keys.rs @@ -16,7 +16,7 @@ use crate::ui::pages::main::grant_form_text; use crossterm::event::{KeyCode, KeyEvent}; impl MainPage { - /// Communities overview keys (R-A-5 Stage 4): `j` starts the join flow + /// Communities overview keys (R-A-5 Stage 4): `n` starts the join flow /// (incl. from the empty state), ↑/↓ move the selection, `d`/Del removes the /// selected community. Returns true if consumed. pub(in crate::ui::pages::main) fn handle_communities_key(&mut self, key: KeyEvent) -> bool { @@ -173,17 +173,19 @@ impl MainPage { let sel_own_context = comms.items.get(selected).is_some_and(|c| c.has_own_context); match key.code { - KeyCode::Char('j') => { + // `n` joins, as `n` starts something new on every other list; + // `j`/`k` move, as they do in rooms, elections and the menu (#580). + KeyCode::Char('n') => { let _ = self.action_tx.send(Action::StartJoin); true } - KeyCode::Up if rows > 0 => { + KeyCode::Up | KeyCode::Char('k') if rows > 0 => { let _ = self .action_tx .send(Action::CommunitySelect(selected.saturating_sub(1))); true } - KeyCode::Down if rows > 0 => { + KeyCode::Down | KeyCode::Char('j') if rows > 0 => { let _ = self .action_tx .send(Action::CommunitySelect((selected + 1).min(rows - 1))); diff --git a/openvtc/src/ui/pages/main/components/content_panel.rs b/openvtc/src/ui/pages/main/components/content_panel.rs index 8db2a719..db37e8fd 100644 --- a/openvtc/src/ui/pages/main/components/content_panel.rs +++ b/openvtc/src/ui/pages/main/components/content_panel.rs @@ -17,6 +17,7 @@ use crate::state_handler::{ }; use crate::ui::pages::main::MainPage; use crate::ui::pages::main::copy_to_clipboard; +use crate::ui::pages::main::keymap::{self, k}; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, @@ -127,7 +128,7 @@ impl ContentPanelState { MainMenu::Quit => { vec![ Line::from(""), - Line::from("Press to quit the application") + Line::from("Press Enter in the menu, or F10 anywhere, to quit.") .fg(COLOR_WARNING_ACCESSIBLE_RED), ] } @@ -346,21 +347,46 @@ fn render_status_help( ); } - // Keyboard shortcuts section + // Keyboard shortcuts: the same tables the footer row and `?` use, so + // this list cannot drift from the keys that work (#580). lines.push(Line::from("")); lines.push(Line::from(" Keyboard Shortcuts").fg(COLOR_SUCCESS).bold()); lines.push(Line::from("")); - lines.push(Line::from(" Up/Down Navigate").fg(COLOR_TEXT_DEFAULT)); - lines.push(Line::from(" Enter Select / open").fg(COLOR_TEXT_DEFAULT)); - lines.push(Line::from(" Tab / L / R Switch panels").fg(COLOR_TEXT_DEFAULT)); - lines.push(Line::from(" PgUp / PgDn Scroll content").fg(COLOR_TEXT_DEFAULT)); - lines.push(Line::from(" Home / End Jump to top / bottom").fg(COLOR_TEXT_DEFAULT)); - lines.push(Line::from(" Esc Go back").fg(COLOR_TEXT_DEFAULT)); - lines.push(Line::from(" F10 Quit").fg(COLOR_TEXT_DEFAULT)); + let shortcut = |b: &keymap::Binding| { + Line::from(vec![ + Span::styled(format!(" {:<12} ", b.key), Style::new().fg(COLOR_SUCCESS)), + Span::styled(b.does.clone(), Style::new().fg(COLOR_TEXT_DEFAULT)), + ]) + }; + lines.extend(keymap::global().iter().map(shortcut)); + lines.push(Line::from("")); + lines.push(Line::from(" In the menu").fg(COLOR_DARK_GRAY)); + lines.extend(keymap::menu().iter().map(shortcut)); + lines.push(Line::from("")); + lines.push( + Line::from(" Every panel lists its own keys at its foot; ? shows them all.") + .fg(COLOR_DARK_GRAY), + ); + lines.push(Line::from(" Ctrl+C always quits.").fg(COLOR_DARK_GRAY)); + lines.push(Line::from("")); + keymap::footer(&mut lines, " ", &help_bindings(settings)); lines } +/// The Help / Status view's own keys. +fn help_bindings( + settings: &crate::state_handler::main_page::content::SettingsState, +) -> Vec { + let mut keys = vec![k("1", "copy persona DID"), k("2", "copy mediator DID")]; + if settings.did_git_sign.is_some() { + keys.push(k("3", "copy signing principal")); + keys.push(k("4", "copy SSH key")); + } + keys.push(k("Esc", "back")); + keys +} + impl MainPage { pub(in crate::ui::pages::main) fn handle_help_key(&mut self, key: KeyEvent) -> bool { match key.code { @@ -388,7 +414,15 @@ impl MainPage { copy_to_clipboard(&did, "Mediator DID", &self.action_tx); true } - KeyCode::Char('3') => { + KeyCode::Char('3') + if self + .props + .main_page + .content_panel + .settings + .did_git_sign + .is_some() => + { if let Some(info) = &self.props.main_page.content_panel.settings.did_git_sign { copy_to_clipboard( &info.did_key_id, @@ -398,7 +432,15 @@ impl MainPage { } true } - KeyCode::Char('4') => { + KeyCode::Char('4') + if self + .props + .main_page + .content_panel + .settings + .did_git_sign + .is_some() => + { if let Some(info) = &self.props.main_page.content_panel.settings.did_git_sign { copy_to_clipboard( &info.ssh_public_key, diff --git a/openvtc/src/ui/pages/main/components/credentials_panel.rs b/openvtc/src/ui/pages/main/components/credentials_panel.rs index 6d102d8f..b22b2831 100644 --- a/openvtc/src/ui/pages/main/components/credentials_panel.rs +++ b/openvtc/src/ui/pages/main/components/credentials_panel.rs @@ -15,6 +15,7 @@ use crate::state_handler::{ state::ConnectionState, }; use crate::ui::badges; +use crate::ui::pages::main::keymap::{self, k}; use crate::ui::pages::main::{MainPage, copy_to_clipboard, edit_text}; use crossterm::event::{KeyCode, KeyEvent}; use openvtc_core::display::display_identifier; @@ -183,10 +184,17 @@ fn render_list(state: &CredentialsState) -> Vec> { ), ])); } - lines.push( - Line::from("Tab: switch tab ↑/↓ navigate Enter: details n: request VRC") - .fg(COLOR_DARK_GRAY), - ); + let count = state.active_list().len(); + let mut keys = vec![k("Tab", "next tab")]; + if count > 1 { + keys.push(k("↑/↓", "move")); + } + if count > 0 { + keys.push(k("Enter", "details")); + } + keys.push(k("n", "request a credential")); + keys.push(k("Esc", "back")); + keymap::footer(&mut lines, "", &keys); lines } @@ -310,9 +318,13 @@ fn render_detail(state: &CredentialsState, index: usize) -> Vec> { .bold(), ); } else if state.selected_tab.allows_local_removal() { - lines.push(Line::from("d: remove c: copy JSON Esc: back").fg(COLOR_DARK_GRAY)); + keymap::footer( + &mut lines, + "", + &[k("d", "remove"), k("c", "copy JSON"), k("Esc", "back")], + ); } else { - lines.push(Line::from("c: copy JSON Esc: back").fg(COLOR_DARK_GRAY)); + keymap::footer(&mut lines, "", &[k("c", "copy JSON"), k("Esc", "back")]); } lines @@ -342,7 +354,7 @@ fn render_new_request( Line::from("No established relationships available.").fg(COLOR_WARNING_ACCESSIBLE_RED), ); lines.push(Line::from("")); - lines.push(Line::from("Esc: back").fg(COLOR_DARK_GRAY)); + keymap::footer(&mut lines, "", &[k("Esc", "back")]); return lines; } @@ -377,7 +389,16 @@ fn render_new_request( ])); lines.push(Line::from("")); - lines.push(Line::from("↑/↓ select Enter: send request Esc: cancel").fg(COLOR_DARK_GRAY)); + let mut keys = Vec::new(); + if established.len() > 1 { + keys.push(k("↑/↓", "choose")); + } + keys.extend([ + k(keymap::TYPE, "reason"), + k("Enter", "send request"), + k("Esc", "cancel"), + ]); + keymap::footer(&mut lines, "", &keys); lines } @@ -405,6 +426,19 @@ impl MainPage { reason_input, } => { let rel_idx = *relationship_index; + let established = self + .props + .main_page + .content_panel + .relationships + .relationships + .iter() + .filter(|r| r.state == "Established") + .count(); + // With nothing to request from, the form is only a way back. + if established == 0 && key.code != KeyCode::Esc { + return false; + } match key.code { KeyCode::Esc => { let _ = self @@ -418,8 +452,7 @@ impl MainPage { )); true } - KeyCode::Down => { - // Bound check happens in state handler + KeyCode::Down if rel_idx + 1 < established => { let _ = self.action_tx.send(Action::Credential( CredentialAction::SelectRelationship(rel_idx + 1), )); @@ -489,14 +522,11 @@ impl MainPage { .send(Action::Credential(CredentialAction::Back)); true } - KeyCode::Char('d') => { - // Membership, role and vetting credentials are bound to a - // community or an application — there is no local-only - // removal, so `d` is a no-op there (the Remove action - // won't find a matching VRC). - if creds.selected_tab.allows_local_removal() - && let Some(vrc) = creds.active_list().get(detail_index) - { + // Membership, role and vetting credentials are bound to a + // community or an application — there is no local-only + // removal, so `d` is not taken there (and says so). + KeyCode::Char('d') if creds.selected_tab.allows_local_removal() => { + if let Some(vrc) = creds.active_list().get(detail_index) { let _ = self.action_tx.send(Action::Credential( CredentialAction::ConfirmRemove { vrc_id: vrc.vrc_id.clone(), diff --git a/openvtc/src/ui/pages/main/components/elections_panel.rs b/openvtc/src/ui/pages/main/components/elections_panel.rs index bd7431ff..0d59e3ff 100644 --- a/openvtc/src/ui/pages/main/components/elections_panel.rs +++ b/openvtc/src/ui/pages/main/components/elections_panel.rs @@ -34,6 +34,7 @@ use openvtc_core::elections::{ }; use super::panel::Panel; +use crate::ui::pages::main::keymap::{self, k}; pub struct ElectionsPanel; @@ -75,9 +76,18 @@ fn standing_style(s: Standing) -> Style { }) } -fn keys(lines: &mut Vec>, keys: &[&str]) { +fn keys(lines: &mut Vec>, keys: &[keymap::Binding]) { lines.push(Line::from("")); - lines.push(Line::from(dim(format!(" {}", keys.join(" "))))); + keymap::footer(lines, " ", keys); +} + +/// A `y`-only question: its own words, and only `y` goes ahead. +fn ask(lines: &mut Vec>, what: &str, otherwise: &str) { + keymap::confirming(); + lines.push(Line::from("")); + lines.push(Line::from(dim(format!( + " y: {what} · any other key: {otherwise}" + )))); } impl Panel for ElectionsPanel { @@ -124,7 +134,7 @@ fn render_list(view: &ElectionsView, lines: &mut Vec>) { lines.push(Line::from(dim( " asking the community which elections you may take part in…", ))); - keys(lines, &["r retry", "Esc back"]); + keys(lines, &[k("r", "retry"), k("Esc", "back")]); return; } ElectionsPhase::Failed(detail) => { @@ -136,7 +146,7 @@ fn render_list(view: &ElectionsView, lines: &mut Vec>) { severity: Severity::Error, }, ); - keys(lines, &["r retry", "Esc back"]); + keys(lines, &[k("r", "retry"), k("Esc", "back")]); return; } ElectionsPhase::Loaded => {} @@ -189,7 +199,13 @@ fn render_list(view: &ElectionsView, lines: &mut Vec>) { lines.push(Line::from("")); push_status(lines, status); } - keys(lines, &["↑/↓ navigate", "⏎ open", "r refresh", "Esc back"]); + let mut k_ = Vec::new(); + if !view.rows().is_empty() { + k_.push(k("↑/↓ or j/k", "move")); + k_.push(k("Enter", "open")); + } + k_.extend([k("r", "refresh"), k("Esc", "back")]); + keys(lines, &k_); } /// The elections held notifications tell the member of, shown only as notified (VTI-NTF-063): @@ -265,7 +281,7 @@ fn day(t: &str) -> String { fn render_disclose(view: &ElectionsView, lines: &mut Vec>) { lines.push(Line::from("")); let Some(d) = &view.disclose else { - keys(lines, &["Esc back"]); + keys(lines, &[k("Esc", "back")]); return; }; lines.push(Line::from(Span::styled( @@ -295,7 +311,7 @@ fn render_disclose(view: &ElectionsView, lines: &mut Vec>) { lines.push(Line::from("")); push_status(lines, status); } - keys(lines, &["y present and open", "any other key: back"]); + ask(lines, "present and open", "back"); } /// The warning an election whose written answers are released after fewer than 10 ballots @@ -458,7 +474,7 @@ fn render_detail(view: &ElectionsView, lines: &mut Vec>) { if let Some(status) = &view.status { push_status(lines, status); } - keys(lines, &["r retry", "Esc back"]); + keys(lines, &[k("r", "retry"), k("Esc", "back")]); return; }; if !vta_sdk::did_scid::same_reference(&view.target, &view.vtc_did) { @@ -482,15 +498,15 @@ fn render_detail(view: &ElectionsView, lines: &mut Vec>) { lines.push(Line::from("")); push_status(lines, status); } - let mut k = Vec::new(); + let mut k_ = Vec::new(); if view.flow.is_none() && shown.tickets_open() { - k.push("t take ticket"); + k_.push(k("t", "take ticket")); } if view.flow.is_none() && shown.voting_open() { - k.push("v vote"); + k_.push(k("v", "vote")); } - k.extend(["r refresh", "Esc back"]); - keys(lines, &k); + k_.extend([k("r", "refresh"), k("Esc", "back")]); + keys(lines, &k_); } /// One question's rows on the ballot. @@ -611,17 +627,20 @@ fn render_ballot(view: &ElectionsView, recent_ticket: bool, lines: &mut Vec>) { @@ -702,7 +721,7 @@ fn render_confirm(view: &ElectionsView, lines: &mut Vec>) { lines.push(Line::from("")); push_status(lines, status); } - keys(lines, &["y cast", "any other key: back to the ballot"]); + ask(lines, "cast", "back to the ballot"); } fn render_result(view: &ElectionsView, lines: &mut Vec>) { @@ -775,9 +794,9 @@ fn render_result(view: &ElectionsView, lines: &mut Vec>) { } let resendable = matches!(view.result, Some(VoteResult::Failed { resend: true, .. })); if resendable && view.flow.is_none() { - keys(lines, &["r resend", "Esc back"]); + keys(lines, &[k("r", "resend"), k("Esc", "back")]); } else { - keys(lines, &["Esc back"]); + keys(lines, &[k("Esc", "back")]); } } @@ -799,12 +818,17 @@ pub(super) fn elections_key( Some(match view.screen { Screen::List => { let last = view.rows().len().saturating_sub(1); + let listed = matches!(view.phase, ElectionsPhase::Loaded) && !view.rows().is_empty(); match key.code { KeyCode::Esc => E::Back, KeyCode::Char('r') => E::Refresh, - KeyCode::Up | KeyCode::Char('k') => E::Select(view.selected.saturating_sub(1)), - KeyCode::Down | KeyCode::Char('j') => E::Select((view.selected + 1).min(last)), - KeyCode::Enter => E::Show, + KeyCode::Up | KeyCode::Char('k') if listed => { + E::Select(view.selected.saturating_sub(1)) + } + KeyCode::Down | KeyCode::Char('j') if listed => { + E::Select((view.selected + 1).min(last)) + } + KeyCode::Enter if listed => E::Show, _ => return None, } } @@ -813,13 +837,19 @@ pub(super) fn elections_key( KeyCode::Char('y') => E::Present, _ => E::Back, }, - Screen::Detail => match key.code { - KeyCode::Esc => E::Back, - KeyCode::Char('r') => E::Refresh, - KeyCode::Char('t') => E::TakeTicket, - KeyCode::Char('v') => E::Vote, - _ => return None, - }, + Screen::Detail => { + let idle = view.flow.is_none(); + let shown = view.shown.as_ref(); + match key.code { + KeyCode::Esc => E::Back, + KeyCode::Char('r') => E::Refresh, + KeyCode::Char('t') if idle && shown.is_some_and(|s| s.tickets_open()) => { + E::TakeTicket + } + KeyCode::Char('v') if idle && shown.is_some_and(|s| s.voting_open()) => E::Vote, + _ => return None, + } + } Screen::Ballot => { let typing = view .form @@ -851,7 +881,12 @@ pub(super) fn elections_key( }, Screen::Result => match key.code { KeyCode::Esc => E::Back, - KeyCode::Char('r') => E::Resend, + KeyCode::Char('r') + if view.flow.is_none() + && matches!(view.result, Some(VoteResult::Failed { resend: true, .. })) => + { + E::Resend + } _ => return None, }, }) diff --git a/openvtc/src/ui/pages/main/components/identity_panel.rs b/openvtc/src/ui/pages/main/components/identity_panel.rs index 1b5be507..2ff7a4ca 100644 --- a/openvtc/src/ui/pages/main/components/identity_panel.rs +++ b/openvtc/src/ui/pages/main/components/identity_panel.rs @@ -60,6 +60,7 @@ use crate::state_handler::{ }, state::ConnectionState, }; +use crate::ui::pages::main::keymap::{self, k}; use openvtc_core::display::display_identifier; use openvtc_core::persona::correlation; use openvtc_core::persona::family::Family; @@ -152,11 +153,26 @@ fn render_tabs(state: &IdentityState) -> Vec> { lines.push(Line::from("")); match confirm_prompt(state) { Some(prompt) => lines.push(Line::from(prompt).fg(COLOR_ORANGE).bold()), - None => lines.push(Line::from(hints(state)).fg(COLOR_DARK_GRAY)), + None => keymap::footer(&mut lines, "", &tab_bindings(state)), } lines } +/// Draw a form's keys, or what it is doing while a write is in flight (when +/// it takes no keys at all). +fn working_footer( + lines: &mut Vec>, + working: bool, + busy: &'static str, + keys: &[keymap::Binding], +) { + if working { + lines.push(Line::from(busy).fg(COLOR_DARK_GRAY)); + } else { + keymap::footer(lines, " ", keys); + } +} + /// The question to put, worded so a `y` cannot be given to the wrong one. fn confirm_prompt(state: &IdentityState) -> Option { match &state.confirm { @@ -276,31 +292,120 @@ fn confirm_prompt(state: &IdentityState) -> Option { } } -fn hints(state: &IdentityState) -> &'static str { +/// The tabbed view's keys: the tab's own, gated as the handler gates them, +/// then the pane's. +fn tab_bindings(state: &IdentityState) -> Vec { + let mut keys = Vec::new(); + let mv = |keys: &mut Vec, count: usize| { + if count > 0 { + keys.push(k("↑/↓", "select")); + } + }; match state.tab { PersonaTab::Personas => { - "↑/↓ select n: new persona g: agent names d: remove unused ⇥/⇧⇥: tab" + let count = state.personas.len(); + mv(&mut keys, count); + keys.push(k("n", "new persona")); + keys.push(k("g", "agent names")); + if state + .personas + .get(state.persona_selected) + .is_some_and(|p| p.bound_communities == 0) + { + keys.push(k("d/Del", "remove unused")); + } } PersonaTab::Attributes => { - "↑/↓ select n: add an attribute e: edit d: delete p: forget earlier \ - v: values s: show one r: refresh ⇥/⇧⇥: tab" + let count = state.attributes.len(); + let on = state.attribute_selected < count; + mv(&mut keys, count); + keys.push(k("n", "add an attribute")); + if on { + keys.push(k("e", "edit")); + keys.push(k("d/Del", "delete")); + keys.push(k("p", "forget earlier")); + keys.push(k("s", "show one")); + } + keys.push(k("v", "values")); + } + PersonaTab::Profiles if state.open_profile.is_some() => { + let claims = state.open_profile.as_ref().map_or(0, |d| d.resolved.len()); + if claims > 0 { + keys.push(k("↑/↓", "select")); + } + if state.face_claim_selected < claims { + keys.push(k("s", "show one")); + } + keys.push(k("Enter", "back")); + keys.push(k("e", "edit")); + keys.push(k("h", "where and when")); } PersonaTab::Profiles if state.show_retired => { - "↑/↓ select x: reinstate z: back to your faces r: refresh ⇥/⇧⇥: tab" + let retired = state.retired_faces.len(); + mv(&mut keys, retired); + if state.profile_selected < retired { + keys.push(k("x", "reinstate")); + } + keys.push(k("z", "back to your faces")); } PersonaTab::Profiles => { - "↑/↓ select ⏎: what it shows n: make a face e: edit m: move to a world \ - x: retire d: delete z: retired r: refresh ⇥/⇧⇥: tab" + let count = state.profiles.len(); + let on = state.profile_selected < count; + mv(&mut keys, count); + if on { + keys.push(k("Enter", "what it shows")); + } + keys.push(k("n", "make a face")); + if on { + keys.push(k("e", "edit")); + keys.push(k("m", "move to a world")); + keys.push(k("x", "retire")); + keys.push(k("d/Del", "delete")); + } + keys.push(k("z", "retired")); } PersonaTab::Worlds => { - "↑/↓ select n: new world e: edit d: delete r: refresh ⇥/⇧⇥: tab" + let count = state.worlds.len(); + mv(&mut keys, count); + keys.push(k("n", "new world")); + if state.world_selected < count { + keys.push(k("e", "edit")); + keys.push(k("d/Del", "delete")); + } } PersonaTab::Communities => { - "↑/↓ select b: change face c: make a face for it f: faces made here \ - k: who it knows you as p: people u: take it off r: refresh ⇥/⇧⇥: tab" + let count = state.memberships.len(); + mv(&mut keys, count); + if state.membership_selected < count { + keys.extend([ + k("b", "change face"), + k("c", "make a face for it"), + k("f", "faces made here"), + k("k", "who it knows you as"), + k("p", "people"), + k("u", "take it off"), + ]); + } + } + PersonaTab::Disclosures => { + mv(&mut keys, state.disclosures.len()); + keys.push(k("f", "formats")); } - PersonaTab::Disclosures => "↑/↓ select f: formats r: refresh ⇥/⇧⇥: tab", } + if state.tab.needs_agent() { + keys.push(k("r", "refresh")); + } + keys.push(k("Tab/→", "next tab")); + keys.push(k("Shift+Tab/←", "previous tab")); + keys.push(k( + "Esc", + if state.open_profile.is_some() { + "close the face" + } else { + "back" + }, + )); + keys } // --------------------------------------------------------------------------- @@ -938,14 +1043,6 @@ fn render_profiles(state: &IdentityState, lines: &mut Vec>) { ); lines.push(Line::from("")); } - lines.push( - Line::from(if detail.resolved.is_empty() { - " ⏎/Esc: back e: edit h: where and when".to_string() - } else { - " ↑/↓ select s: show one ⏎/Esc: back e: edit h: where and when".to_string() - }) - .fg(COLOR_DARK_GRAY), - ); return; } @@ -1365,7 +1462,7 @@ fn push_agent_state(state: &IdentityState, lines: &mut Vec>, noun: if !state.loaded { lines.push( Line::from(format!( - " Your {noun} have not been read yet. r: read them" + " Your {noun} have not been read yet — r reads them." )) .fg(COLOR_DARK_GRAY), ); @@ -1433,13 +1530,18 @@ fn render_attribute_form(form: &AttributeForm) -> Vec> { super::status::push_error(&mut lines, error, " "); lines.push(Line::from("")); } - lines.push( - Line::from(if form.working { - " Saving…" - } else { - " ⇥: next field ←/→: value type ⏎: save Esc: cancel" - }) - .fg(COLOR_DARK_GRAY), + working_footer( + &mut lines, + form.working, + " Saving…", + &[ + k(keymap::TYPE, "this field"), + k("Tab/↓", "next field"), + k("Shift+Tab/↑", "previous field"), + k("←/→", "value type"), + k("Enter", "save"), + k("Esc", "cancel"), + ], ); lines } @@ -1551,13 +1653,26 @@ fn render_profile_form(state: &IdentityState, form: &ProfileForm) -> Vec Vec> { lines.push(Line::from(format!(" {e}")).fg(COLOR_ORANGE)); lines.push(Line::from("")); } - lines.push( - Line::from(if form.working { - " Making it…" - } else { - " ⇥/⇧⇥ field ↓ on the last: add a value Ctrl-T: use in other faces ⏎: make it \ - Esc: cancel" - }) - .fg(COLOR_DARK_GRAY), + working_footer( + &mut lines, + form.working, + " Making it…", + &[ + k(keymap::TYPE, "this field"), + k("Tab/Shift+Tab", "field"), + k("↑/↓", "move (↓ on the last adds a value)"), + k("Ctrl+T", "use in other faces"), + k("Enter", "make it"), + k("Esc", "cancel"), + ], ); lines } @@ -1672,7 +1791,7 @@ fn render_local_faces(view: &LocalFacesView) -> Vec> { Some(Err(e)) => { lines.push(Line::from(format!(" Could not read them: {e}")).fg(COLOR_ORANGE)); lines.push(Line::from("")); - lines.push(Line::from(" Esc: back").fg(COLOR_DARK_GRAY)); + keymap::footer(&mut lines, " ", &[k("Esc", "back")]); return lines; } Some(Ok(faces)) => faces, @@ -1680,9 +1799,8 @@ fn render_local_faces(view: &LocalFacesView) -> Vec> { if faces.is_empty() { lines.push(Line::from(" No face has been made for this community.").fg(COLOR_DARK_GRAY)); lines.push(Line::from("")); - lines.push( - Line::from(" `c` on the Communities tab makes one. Esc: back").fg(COLOR_DARK_GRAY), - ); + lines.push(Line::from(" `c` on the Communities tab makes one.").fg(COLOR_DARK_GRAY)); + keymap::footer(&mut lines, " ", &[k("Esc", "back")]); return lines; } @@ -1717,10 +1835,10 @@ fn render_local_faces(view: &LocalFacesView) -> Vec> { if let Some((_, name)) = &view.deleting { lines.push( - Line::from(format!( + Line::from(crate::ui::pages::main::confirm::prompt(&format!( " Delete \"{name}\"? It is taken off as it goes, so this community sees nothing \ - from it. ⏎: confirm Esc: cancel" - )) + from it." + ))) .fg(COLOR_ORANGE), ); lines.push(Line::from("")); @@ -1733,21 +1851,26 @@ fn render_local_faces(view: &LocalFacesView) -> Vec> { lines.push( Line::from( " This cannot be undone. Once reusable, a value stays in your attributes; a \ - community that has seen it keeps what it saw. ⏎ again to make it reusable, Esc to \ - leave it as it is.", + community that has seen it keeps what it saw. y: make it reusable · any \ + other key: leave it as it is", ) .fg(COLOR_ORANGE), ); lines.push(Line::from("")); } - lines.push( - Line::from(if view.working { - " Making it reusable…" - } else { - " ↑/↓ value space: choose ⏎: make reusable n: make a face here w: wear it \ - d: delete Esc: back" - }) - .fg(COLOR_DARK_GRAY), + working_footer( + &mut lines, + view.working, + " Making it reusable…", + &[ + k("↑/↓", "value"), + k("Space", "choose"), + k("Enter", "make reusable"), + k("n", "make a face here"), + k("w", "wear it"), + k("d/Del", "delete"), + k("Esc", "back"), + ], ); lines } @@ -1799,13 +1922,17 @@ fn render_local_face_form( lines.push(Line::from(format!(" {e}")).fg(COLOR_ORANGE)); lines.push(Line::from("")); } - lines.push( - Line::from(if view.working { - " Making it…" - } else { - " ⇥/⇧⇥ field ↓ on the last: add a value ⏎: make it Esc: cancel" - }) - .fg(COLOR_DARK_GRAY), + working_footer( + &mut lines, + view.working, + " Making it…", + &[ + k(keymap::TYPE, "this field"), + k("Tab/Shift+Tab", "field"), + k("↑/↓", "move (↓ on the last adds a value)"), + k("Enter", "make it"), + k("Esc", "cancel"), + ], ); lines } @@ -1881,10 +2008,9 @@ fn render_people(view: &PeopleView) -> Vec> { lines.push(Line::from("")); if let Some((_, label)) = &view.deleting { lines.push( - Line::from(format!( - " Forget {label}, and every revision of what they told you? ⏎: confirm \ - Esc: cancel" - )) + Line::from(crate::ui::pages::main::confirm::prompt(&format!( + " Forget {label}, and every revision of what they told you?" + ))) .fg(COLOR_ORANGE), ); lines.push(Line::from("")); @@ -1893,13 +2019,17 @@ fn render_people(view: &PeopleView) -> Vec> { lines.push(Line::from(format!(" {e}")).fg(COLOR_ORANGE)); lines.push(Line::from("")); } - lines.push( - Line::from(if view.working { - " Working…" - } else { - " ↑/↓ select ⏎: what they said n: record someone d: forget Esc: back" - }) - .fg(COLOR_DARK_GRAY), + working_footer( + &mut lines, + view.working, + " Working…", + &[ + k("↑/↓", "select"), + k("Enter", "what they said"), + k("n", "record someone"), + k("d/Del", "forget"), + k("Esc", "back"), + ], ); lines } @@ -1964,13 +2094,11 @@ fn render_contact_detail( } } lines.push(Line::from("")); - lines.push( - Line::from(if view.working { - " Reading…" - } else { - " ↑/↓ another Esc: back" - }) - .fg(COLOR_DARK_GRAY), + working_footer( + &mut lines, + view.working, + " Reading…", + &[k("↑/↓", "another"), k("Esc", "back")], ); lines } @@ -2029,13 +2157,17 @@ fn render_contact_form( lines.push(Line::from(format!(" {e}")).fg(COLOR_ORANGE)); lines.push(Line::from("")); } - lines.push( - Line::from(if view.working { - " Recording…" - } else { - " ⇥/⇧⇥ field ↓ on the last: add a fact ⏎: record Esc: cancel" - }) - .fg(COLOR_DARK_GRAY), + working_footer( + &mut lines, + view.working, + " Recording…", + &[ + k(keymap::TYPE, "this field"), + k("Tab/Shift+Tab", "field"), + k("↑/↓", "move (↓ on the last adds a fact)"), + k("Enter", "record"), + k("Esc", "cancel"), + ], ); lines } @@ -2114,7 +2246,7 @@ fn render_renderers(view: &RenderersView) -> Vec> { } } } - lines.push(Line::from(" Esc: back").fg(COLOR_DARK_GRAY)); + keymap::footer(&mut lines, " ", &[k("Esc", "back")]); lines } @@ -2183,7 +2315,7 @@ fn render_known_here(view: &KnownHereView) -> Vec> { } } } - lines.push(Line::from(" Esc: back").fg(COLOR_DARK_GRAY)); + keymap::footer(&mut lines, " ", &[k("Esc", "back")]); lines } @@ -2279,13 +2411,26 @@ fn render_world_form(form: &WorldForm) -> Vec> { super::status::push_error(&mut lines, error, " "); lines.push(Line::from("")); } - lines.push( - Line::from(if form.working { - " Saving…" + working_footer( + &mut lines, + form.working, + " Saving…", + &if form.focus == WorldFormFocus::Colour { + vec![ + k("Tab/Shift+Tab", "field"), + k("←/→/↑/↓", "colour"), + k("Enter", "save"), + k("Esc", "cancel"), + ] } else { - " ⇥/⇧⇥: field ←/→: colour ⏎: save Esc: cancel" - }) - .fg(COLOR_DARK_GRAY), + vec![ + k(keymap::TYPE, "this field"), + k("Tab/Shift+Tab", "field"), + k("←/→", "colour"), + k("Enter", "save"), + k("Esc", "cancel"), + ] + }, ); lines } @@ -2330,13 +2475,11 @@ fn render_face_placer(state: &IdentityState, picker: &FacePlacer) -> Vec Vec send(PA::FormSubmit), KeyCode::Down => send(PA::FormCycle(true)), KeyCode::Up => send(PA::FormCycle(false)), - _ => true, + _ => false, }; } PersonaMode::Bind(picker) => { @@ -103,7 +103,7 @@ impl MainPage { KeyCode::Enter => send(PA::FormSubmit), KeyCode::Down => send(PA::FormCycle(true)), KeyCode::Up => send(PA::FormCycle(false)), - _ => true, + _ => false, }; } PersonaMode::Compose(form) => { @@ -141,6 +141,15 @@ impl MainPage { _ => send(PA::FormKey(key)), }; } + // A deletion, or making a value reusable — both one-way — is + // committed by `y` alone (#579). + if view.deleting.is_some() || view.confirming { + return if crate::ui::pages::main::confirm::is_yes(&key) { + send(PA::FormSubmit) + } else { + send(PA::FormCancel) + }; + } return match key.code { KeyCode::Esc => send(PA::FormCancel), KeyCode::Enter => send(PA::FormSubmit), @@ -150,7 +159,7 @@ impl MainPage { KeyCode::Char('n') => send(PA::LocalFaceNew), KeyCode::Char('w') => send(PA::LocalFaceWear), KeyCode::Char('d') | KeyCode::Delete => send(PA::LocalFaceDeleteArm), - _ => true, + _ => false, }; } // Read-only overlays: any key but Esc is ignored rather than @@ -158,7 +167,7 @@ impl MainPage { PersonaMode::Renderers(_) | PersonaMode::KnownHere(_) => { return match key.code { KeyCode::Esc => send(PA::FormCancel), - _ => true, + _ => false, }; } PersonaMode::People(view) => { @@ -178,15 +187,22 @@ impl MainPage { _ => send(PA::FormKey(key)), }; } + // Forgetting someone is committed by `y` alone (#579). + if view.deleting.is_some() { + return if crate::ui::pages::main::confirm::is_yes(&key) { + send(PA::FormSubmit) + } else { + send(PA::FormCancel) + }; + } return match key.code { KeyCode::Esc => send(PA::FormCancel), - KeyCode::Enter if view.deleting.is_some() => send(PA::FormSubmit), KeyCode::Enter => send(PA::ContactOpen), KeyCode::Down => send(PA::FormCycle(true)), KeyCode::Up => send(PA::FormCycle(false)), KeyCode::Char('n') => send(PA::ContactNew), KeyCode::Char('d') | KeyCode::Delete => send(PA::ContactDeleteArm), - _ => true, + _ => false, }; } PersonaMode::View => {} @@ -269,14 +285,13 @@ impl MainPage { // first, and the deletion path refuses it anyway. Refusing // to *arm* it keeps the prompt from appearing over a // question that has already been answered no. - KeyCode::Char('d') | KeyCode::Delete if selected < count => { + KeyCode::Char('d') | KeyCode::Delete if personas .personas .get(selected) - .is_some_and(|f| f.bound_communities == 0) - { - let _ = self.action_tx.send(Action::DidConfirmDelete(selected)); - } + .is_some_and(|f| f.bound_communities == 0) => + { + let _ = self.action_tx.send(Action::DidConfirmDelete(selected)); true } _ => false, diff --git a/openvtc/src/ui/pages/main/components/inbox_panel.rs b/openvtc/src/ui/pages/main/components/inbox_panel.rs index 716a25c6..ef741ebf 100644 --- a/openvtc/src/ui/pages/main/components/inbox_panel.rs +++ b/openvtc/src/ui/pages/main/components/inbox_panel.rs @@ -12,6 +12,7 @@ use crate::state_handler::{ state::{ConnectionState, MediatorStatus}, }; use crate::ui::pages::main::MainPage; +use crate::ui::pages::main::keymap::{self, k}; use crossterm::event::{KeyCode, KeyEvent}; use openvtc_core::display::display_identifier; use ratatui::{ @@ -83,6 +84,8 @@ pub fn render(state: &InboxState, connection: &ConnectionState) -> Vec Vec 1 { + keys.push(k("↑/↓", "move")); + } + keys.extend([ + k("Enter", "view"), + k("d", "dismiss"), + k("c", "clear all"), + k("Esc", "back"), + ]); + keymap::footer(&mut lines, "", &keys); } } @@ -256,11 +266,16 @@ pub fn render_task_detail(task: &ActiveTaskView) -> Vec> { Line::from(" can resolve and compare with other contacts.").fg(COLOR_ORANGE), ); lines.push(Line::from("")); - lines.push( - Line::from( - "a: accept (R-DID) p: accept (persona DID) r: reject d: dismiss Esc: back", - ) - .fg(COLOR_DARK_GRAY), + keymap::footer( + &mut lines, + "", + &[ + k("a/A", "accept (R-DID)"), + k("p", "accept (persona DID)"), + k("r", "reject"), + k("d", "dismiss"), + k("Esc", "back"), + ], ); } ActiveTaskView::VRCRequestInbound { @@ -289,9 +304,15 @@ pub fn render_task_detail(task: &ActiveTaskView) -> Vec> { Span::styled(task_id.clone(), Style::new().fg(COLOR_DARK_GRAY)), ])); lines.push(Line::from("")); - lines.push( - Line::from("a: accept (issue VRC) r: reject d: dismiss Esc: back") - .fg(COLOR_DARK_GRAY), + keymap::footer( + &mut lines, + "", + &[ + k("a", "accept (issue VRC)"), + k("r", "reject"), + k("d", "dismiss"), + k("Esc", "back"), + ], ); } ActiveTaskView::VRCIssued { @@ -313,7 +334,15 @@ pub fn render_task_detail(task: &ActiveTaskView) -> Vec> { Span::styled(task_id.clone(), Style::new().fg(COLOR_DARK_GRAY)), ])); lines.push(Line::from("")); - lines.push(Line::from("a: accept (store) d: dismiss Esc: back").fg(COLOR_DARK_GRAY)); + keymap::footer( + &mut lines, + "", + &[ + k("a", "accept (store)"), + k("d", "dismiss"), + k("Esc", "back"), + ], + ); } ActiveTaskView::RelationshipRequestOutbound { task_id, @@ -356,7 +385,7 @@ pub fn render_task_detail(task: &ActiveTaskView) -> Vec> { Span::styled(task_id.clone(), dim), ])); lines.push(Line::from("")); - lines.push(Line::from("d: dismiss Esc: back").fg(COLOR_DARK_GRAY)); + keymap::footer(&mut lines, "", &[k("d", "dismiss"), k("Esc", "back")]); } ActiveTaskView::VRCRequestOutbound { task_id, @@ -381,7 +410,7 @@ pub fn render_task_detail(task: &ActiveTaskView) -> Vec> { Span::styled(task_id.clone(), dim), ])); lines.push(Line::from("")); - lines.push(Line::from("d: dismiss Esc: back").fg(COLOR_DARK_GRAY)); + keymap::footer(&mut lines, "", &[k("d", "dismiss"), k("Esc", "back")]); } ActiveTaskView::Info { task_id, @@ -409,7 +438,7 @@ pub fn render_task_detail(task: &ActiveTaskView) -> Vec> { Span::styled(task_id.clone(), dim), ])); lines.push(Line::from("")); - lines.push(Line::from("d: dismiss Esc: back").fg(COLOR_DARK_GRAY)); + keymap::footer(&mut lines, "", &[k("d", "dismiss"), k("Esc", "back")]); } } diff --git a/openvtc/src/ui/pages/main/components/logs_panel.rs b/openvtc/src/ui/pages/main/components/logs_panel.rs index 0d41940b..9182a210 100644 --- a/openvtc/src/ui/pages/main/components/logs_panel.rs +++ b/openvtc/src/ui/pages/main/components/logs_panel.rs @@ -5,6 +5,7 @@ use crate::colors::{COLOR_DARK_GRAY, COLOR_SUCCESS, COLOR_TEXT_DEFAULT}; use crate::state_handler::actions::Action; use crate::state_handler::main_page::MainPanel; use crate::state_handler::main_page::{ActivityLogEntry, content::LogsState}; +use crate::ui::pages::main::keymap::{self, k}; use crate::ui::pages::main::{MainPage, copy_to_clipboard}; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ @@ -38,6 +39,8 @@ pub fn render( lines.push( Line::from(" Activity will appear here as you use the app.").fg(COLOR_DARK_GRAY), ); + lines.push(Line::from("")); + keymap::footer(&mut lines, " ", &[k("Esc", "back")]); return lines; } @@ -68,8 +71,10 @@ pub fn render( } lines.push(Line::from("")); - lines.push( - Line::from(" Enter/Esc: back to list c: copy to clipboard").fg(COLOR_DARK_GRAY), + keymap::footer( + &mut lines, + " ", + &[k("Enter/Esc", "back to the list"), k("c", "copy")], ); } return lines; @@ -96,10 +101,17 @@ pub fn render( } lines.push(Line::from("")); - lines.push( - Line::from(" ↑/↓ navigate Enter: view detail c: copy selected a: copy all Esc: back") - .fg(COLOR_DARK_GRAY), - ); + let mut keys = Vec::new(); + if total > 1 { + keys.push(k("↑/↓", "move")); + } + keys.extend([ + k("Enter", "view detail"), + k("c", "copy selected"), + k("a", "copy all"), + k("Esc", "back"), + ]); + keymap::footer(&mut lines, " ", &keys); lines } diff --git a/openvtc/src/ui/pages/main/components/relationships_panel.rs b/openvtc/src/ui/pages/main/components/relationships_panel.rs index 0f15fdc2..c779af08 100644 --- a/openvtc/src/ui/pages/main/components/relationships_panel.rs +++ b/openvtc/src/ui/pages/main/components/relationships_panel.rs @@ -12,6 +12,7 @@ use crate::state_handler::{ main_page::content::{ContentPanelState, RelationshipsMode, RelationshipsState}, state::ConnectionState, }; +use crate::ui::pages::main::keymap::{self, k}; use crate::ui::pages::main::{MainPage, edit_text}; use crossterm::event::{KeyCode, KeyEvent}; use openvtc_core::config::account::RelationshipIdentifierDefault; @@ -140,7 +141,16 @@ fn render_list(state: &RelationshipsState) -> Vec> { } lines.push(Line::from("")); - lines.push(Line::from("↑/↓ navigate Enter: details n: new request").fg(COLOR_DARK_GRAY)); + let mut keys = Vec::new(); + if state.relationships.len() > 1 { + keys.push(k("↑/↓", "move")); + } + keys.extend([ + k("Enter", "details"), + k("n", "new request"), + k("Esc", "back"), + ]); + keymap::footer(&mut lines, "", &keys); } lines @@ -423,12 +433,22 @@ fn render_detail( .bold(), ); } else { - lines.push( - Line::from( - "e: edit alias p: ping v: request VRC w: witness d: remove \u{2191}/\u{2193}: browse VRCs Esc: back", - ) - .fg(COLOR_DARK_GRAY), - ); + let mut keys = vec![ + k("e", "edit alias"), + k("p", "ping"), + k("v", "request VRC"), + k("w", "witness"), + k("d", "remove"), + ]; + if state + .relationships + .get(index) + .is_some_and(|r| !r.vrcs_issued.is_empty() || !r.vrcs_received.is_empty()) + { + keys.push(k("↑/↓", "browse VRCs")); + } + keys.push(k("Esc", "back")); + keymap::footer(&mut lines, "", &keys); } lines @@ -459,7 +479,15 @@ fn render_edit_alias( Span::styled("▎", Style::new().fg(COLOR_SUCCESS)), ])); lines.push(Line::from("")); - lines.push(Line::from("Enter: save Esc: cancel").fg(COLOR_DARK_GRAY)); + keymap::footer( + &mut lines, + "", + &[ + k(keymap::TYPE, "alias"), + k("Enter", "save"), + k("Esc", "cancel"), + ], + ); lines } @@ -546,8 +574,15 @@ fn render_witness( lines.push(Line::from("")); } lines.push(Line::from("A DID or an agent name, e.g. example.com/@witness").fg(COLOR_DARK_GRAY)); - lines.push( - Line::from("Enter: witness Tab: membership on/off Esc: cancel").fg(COLOR_DARK_GRAY), + keymap::footer( + &mut lines, + "", + &[ + k(keymap::TYPE, "witness"), + k("Enter", "witness"), + k("Tab", "membership on/off"), + k("Esc", "cancel"), + ], ); lines @@ -683,10 +718,20 @@ fn render_form( lines.push( Line::from("DID accepts an agent name too, e.g. example.com/@bob").fg(COLOR_DARK_GRAY), ); - lines.push( - Line::from("Tab: next field Space: change Enter (on last field): submit Esc: cancel") - .fg(COLOR_DARK_GRAY), - ); + let mut keys = Vec::new(); + if active_field < 3 { + keys.push(k(keymap::TYPE, "this field")); + keys.push(k("Enter/Tab/↓", "next field")); + } else { + keys.push(k("Space", "change")); + keys.push(k("Enter", "send request")); + keys.push(k("Tab", "first field")); + } + if active_field > 0 { + keys.push(k("↑", "previous field")); + } + keys.push(k("Esc", "cancel")); + keymap::footer(&mut lines, "", &keys); lines } @@ -716,7 +761,11 @@ impl MainPage { .send(Action::Relationship(RelationshipAction::CancelNewRequest)); true } - KeyCode::Tab => { + // Enter on a text field moves on, as Tab does; on the last + // field it submits (below). + KeyCode::Tab | KeyCode::Enter + if active_field < 3 || key.code == KeyCode::Tab => + { // Cycle through fields 0->1->2->3->0 let next = (active_field + 1) % 4; let _ = self @@ -809,8 +858,12 @@ impl MainPage { return true; } + let has_vrcs = rels + .relationships + .get(index) + .is_some_and(|r| !r.vrcs_issued.is_empty() || !r.vrcs_received.is_empty()); match key.code { - KeyCode::Down => { + KeyCode::Down if has_vrcs => { if let Some(rel) = rels.relationships.get(index) { let total = rel.vrcs_issued.len() + rel.vrcs_received.len(); if total > 0 { @@ -830,7 +883,7 @@ impl MainPage { } true } - KeyCode::Up => { + KeyCode::Up if has_vrcs => { let next = match current_vrc { Some(0) => None, Some(n) => Some(n - 1), diff --git a/openvtc/src/ui/pages/main/components/repos_panel.rs b/openvtc/src/ui/pages/main/components/repos_panel.rs index b2824905..a584bb61 100644 --- a/openvtc/src/ui/pages/main/components/repos_panel.rs +++ b/openvtc/src/ui/pages/main/components/repos_panel.rs @@ -33,6 +33,7 @@ use openvtc_core::git_signing::{BinaryStatus, CheckoutSigning, MIN_BINARY}; use openvtc_core::git_workspace::{self, CheckoutFacts, CredentialScope, RepoCoords}; use super::panel::Panel; +use crate::ui::pages::main::keymap::{self, k}; pub struct ReposPanel; @@ -52,9 +53,61 @@ fn heading(lines: &mut Vec>, title: &str) { ))); } -fn hints(lines: &mut Vec>, hints: &str) { +fn hints(lines: &mut Vec>, keys: &[keymap::Binding]) { lines.push(Line::from("")); - lines.push(Line::from(dim(format!(" {hints}")))); + keymap::footer(lines, " ", keys); +} + +/// The keys of the screen under any form: the list, or one repository, and +/// what this machine can do for the highlighted one — the ones that apply +/// now. The handler also takes the machine keys where they do not apply, and +/// answers them with a status line saying why. +pub(crate) fn screen_bindings(view: &ReposView) -> Vec { + if !matches!(view.phase, ReposPhase::Loaded) { + return vec![k("r", "retry"), k("Esc", "back")]; + } + let mut keys = Vec::new(); + match &view.screen { + ReposScreen::List => { + keys.extend([k("↑/↓", "move"), k("Enter", "open")]); + if !view.creatable().is_empty() { + keys.push(k("n", "new repo")); + } + } + ReposScreen::Repo { resource } => { + if view.repo(resource).is_none() { + return vec![k("r", "refresh"), k("Esc", "back")]; + } + keys.push(k("↑/↓", "move")); + if view.governs(resource) { + keys.extend([ + k("a", "add"), + k("x/Del", "revoke"), + k("t", "transfer"), + k("A", "archive the repository"), + ]); + if !view.drift(resource).is_empty() { + keys.push(k("v", "revert drift")); + } + } else { + keys.push(k("x/Del", "resign your right")); + } + } + ReposScreen::NewRepo(_) => return Vec::new(), + } + keys.push(k("l", "link account")); + let link_settled = view.link.as_ref().is_some_and(|l| { + matches!( + l.phase, + LinkPhase::Linked { .. } | LinkPhase::Expired | LinkPhase::Failed(_) + ) + }); + if link_settled { + keys.push(k("d", "dismiss the link")); + } + keys.extend([k("r", "refresh"), k("Esc", "back")]); + keys.extend(local_bindings(view)); + keys } fn status_style(status: &RepoStatus) -> Style { @@ -127,13 +180,13 @@ impl Panel for ReposPanel { lines.push(Line::from(dim( " asking the community what it governs on the forges…", ))); - hints(&mut lines, "r retry Esc back"); + hints(&mut lines, &screen_bindings(view)); return lines; } ReposPhase::Failed(detail) => { lines.push(Line::from("")); error(&mut lines, detail); - hints(&mut lines, "r retry Esc back"); + hints(&mut lines, &screen_bindings(view)); return lines; } ReposPhase::Loaded => {} @@ -157,6 +210,7 @@ impl Panel for ReposPanel { Style::default().fg(COLOR_ORANGE).bold(), ))); let class = armed.request.consent_class(); + keymap::confirming(); lines.push(Line::from(dim(format!( " {} change · signed by this persona · {}", class.label(), @@ -328,13 +382,9 @@ fn render_list(lines: &mut Vec>, view: &ReposView, linked: &[Linke if view.workspace.form.is_some() { return; } - let mut keys = vec!["↑/↓ navigate", "⏎ open"]; - if !view.creatable().is_empty() { - keys.push("n new repo"); + if view.add.is_none() && view.confirm.is_none() && view.workspace.confirm.is_none() { + hints(lines, &screen_bindings(view)); } - keys.extend(["l link account", "r refresh", "Esc back"]); - hints(lines, &keys.join(" ")); - local_hints(lines, view); } fn render_accounts(lines: &mut Vec>, view: &ReposView, linked: &[LinkedAccount]) { @@ -677,39 +727,40 @@ fn render_signing(lines: &mut Vec>, view: &ReposView) { } /// The local keys that apply to the highlighted or open repository. -fn local_hints(lines: &mut Vec>, view: &ReposView) { +/// What this machine can do for the highlighted repository. +fn local_bindings(view: &ReposView) -> Vec { let ws = &view.workspace; let target = repos_workspace::target(view); let checkout = target.as_ref().and_then(|r| ws.checkouts.get(r)); let mut keys = Vec::new(); match checkout { - None if target.is_some() => keys.extend(["c clone", "u use existing"]), + None if target.is_some() => keys.extend([k("c", "clone"), k("u", "use existing")]), None => {} Some(c) => { if ws.signs_as_me(c) != Some(true) { - keys.push("e sign here"); + keys.push(k("e", "sign here")); } if !matches!(c.signing, CheckoutSigning::Off) { - keys.push("E stop signing"); + keys.push(k("E", "stop signing")); } - keys.push("p copy path"); + keys.push(k("p", "copy path")); } } - keys.push("s set up signing"); + keys.push(k("s", "set up signing")); if ws.health.as_ref().is_some_and(|h| h.identity.any()) { - keys.push("S remove"); + keys.push(k("S", "remove")); } if let Some(t) = &target { - keys.push("f forge account"); + keys.push(k("f", "forge account")); if ws.push_access.get(t).is_some_and(|(_, can)| !can) { - keys.push("F fork"); + keys.push(k("F", "fork")); } if checkout.is_some_and(|c| push_needs_username(view, t, &c.facts)) { - keys.push("R remotes to SSH"); + keys.push(k("R", "remotes to SSH")); } } - keys.push("w workspace"); - lines.push(Line::from(dim(format!(" {}", keys.join(" "))))); + keys.push(k("w", "workspace")); + keys } /// Whether `git push` in this checkout would stop at "Username for @@ -843,7 +894,7 @@ fn account_line( "▲", COLOR_ORANGE, &format!( - "This checkout is set to {}. f, then ⏎, writes the choice into it.", + "This checkout is set to {}. f, then Enter, writes the choice into it.", sanitize_display(&applied.label(), 256) ), ); @@ -1034,7 +1085,15 @@ fn render_workspace_overlay(lines: &mut Vec>, view: &ReposView) { if let Some(why) = why { error(lines, why); } - hints(lines, "⏎ save Tab automatic/HTTPS/SSH Esc cancel"); + hints( + lines, + &[ + k(keymap::TYPE, "the directory"), + k("Enter", "save"), + k("Tab/Shift+Tab", "automatic/HTTPS/SSH"), + k("Esc", "cancel"), + ], + ); } Some(WorkspaceForm::Account(form)) => render_account_form(lines, form), Some(WorkspaceForm::UsePath { @@ -1057,7 +1116,14 @@ fn render_workspace_overlay(lines: &mut Vec>, view: &ReposView) { if let Some(why) = why { error(lines, why); } - hints(lines, "⏎ use Esc cancel"); + hints( + lines, + &[ + k(keymap::TYPE, "the path"), + k("Enter", "use"), + k("Esc", "cancel"), + ], + ); } None => {} } @@ -1068,6 +1134,7 @@ fn render_workspace_overlay(lines: &mut Vec>, view: &ReposView) { signing, and its credential is revoked at the VTA.", Style::default().fg(COLOR_ORANGE).bold(), ))); + keymap::confirming(); lines.push(Line::from(dim(format!( " {}", crate::ui::pages::main::confirm::KEYS @@ -1152,16 +1219,26 @@ fn render_account_form(lines: &mut Vec>, form: &AccountForm) { if let Some(why) = &form.error { error(lines, why); } - hints( - lines, - &format!( - "↑↓ choose Tab {} ⏎ save and apply Esc cancel", + let mut keys = vec![ + k("↑/↓", "choose"), + k( + "Tab/Shift+Tab", match form.scope { CredentialScope::Repo => format!("all of {} in this community", form.forge), CredentialScope::Forge => "this repository only".to_string(), - } + }, ), - ); + ]; + if form.typing() { + keys.push(k(keymap::TYPE, "the path")); + } else if matches!( + form.picked(), + Some(crate::state_handler::main_page::repos::AccountOption::Gh { .. }) + ) { + keys.push(k("a", "keep your own identity")); + } + keys.extend([k("Enter", "save and apply"), k("Esc", "cancel")]); + hints(lines, &keys); } // **************************************************************************** @@ -1179,7 +1256,7 @@ fn render_repo( lines.push(Line::from(dim(format!( " The community's view has no {resource} — r to refresh." )))); - hints(lines, "r refresh Esc back"); + hints(lines, &screen_bindings(view)); return; }; let status = RepoStatus::of(repo); @@ -1328,15 +1405,9 @@ fn render_repo( if view.workspace.form.is_some() { return; } - let keys = if view.governs(resource) && !drift.is_empty() { - "↑/↓ navigate a add x revoke t transfer A archive v revert drift l link account r refresh Esc back" - } else if view.governs(resource) { - "↑/↓ navigate a add x revoke t transfer A archive l link account r refresh Esc back" - } else { - "↑/↓ navigate x resign your right l link account r refresh Esc back" - }; - hints(lines, keys); - local_hints(lines, view); + if view.confirm.is_none() && view.workspace.confirm.is_none() { + hints(lines, &screen_bindings(view)); + } } fn field_label(lines: &mut Vec>, label: &str, focused: bool) { @@ -1478,7 +1549,24 @@ fn render_add(lines: &mut Vec>, view: &ReposView, form: &AddPerson lines.push(Line::from("")); push_status(lines, err); } - hints(lines, "Tab next field ↑/↓ choose ⏎ grant Esc cancel"); + let mut keys = vec![k("Tab/Shift+Tab", "next/previous field")]; + match form.field { + 0 => { + keys.push(k( + keymap::TYPE, + if form.external { "a DID" } else { "filter" }, + )); + if !form.external { + keys.push(k("↑/↓", "choose")); + } + keys.push(k("Ctrl+D", "paste a DID / pick a member")); + } + 1 => keys.push(k("↑/↓/←/→", "right")), + 2 => keys.push(k("←/→", "expiry")), + _ => keys.push(k(keymap::TYPE, "reason")), + } + keys.extend([k("Enter", "grant"), k("Esc", "cancel")]); + hints(lines, &keys); } // **************************************************************************** @@ -1627,10 +1715,23 @@ fn render_new(lines: &mut Vec>, view: &ReposView, form: &NewRepoFo lines.push(Line::from("")); push_status(lines, err); } - hints( - lines, - "Tab next field ←/→ choose Ctrl+A add owner ⏎ create Esc cancel", - ); + let mut keys = vec![k("Tab/Shift+Tab", "next/previous field")]; + match form.field { + 0 => keys.push(k("↑/↓/←/→", "namespace")), + 1 => keys.push(k(keymap::TYPE, "name")), + 2 => keys.push(k("←/→/Space", "visibility")), + 3 => keys.push(k(keymap::TYPE, "description")), + _ => { + keys.push(k(keymap::TYPE, "owner")); + if !form.owner_external { + keys.push(k("↑/↓", "choose")); + } + keys.push(k("Ctrl+A", "add owner")); + keys.push(k("Ctrl+D", "paste a DID / pick a member")); + } + } + keys.extend([k("Enter", "create"), k("Esc", "cancel")]); + hints(lines, &keys); } #[cfg(test)] @@ -1703,7 +1804,7 @@ mod tests { out.contains("You can create repos in github.com/acme"), "{out}" ); - assert!(out.contains("n new repo"), "{out}"); + assert!(out.contains("n: new repo"), "{out}"); } const KEY: &str = "did:webvh:QmBobScid2:acme-vtc.example:bob#key-0"; @@ -1815,7 +1916,10 @@ mod tests { let out = rendered(v.clone()); assert!(out.contains("THIS MACHINE"), "{out}"); assert!(out.contains("not cloned"), "{out}"); - assert!(out.contains("c clone u use existing"), "{out}"); + assert!( + out.contains("c: clone") && out.contains("u: use existing"), + "{out}" + ); let mut cloned = v; cloned.workspace.checkouts.insert( @@ -1835,8 +1939,11 @@ mod tests { out.contains("/w/github.com/acme/gadgets · main · ↑2 ↓0 · 1 changed"), "{out}" ); - assert!(out.contains("E stop signing p copy path"), "{out}"); - assert!(!out.contains("e sign here"), "{out}"); + assert!( + out.contains("E: stop signing") && out.contains("p: copy path"), + "{out}" + ); + assert!(!out.contains("e: sign here"), "{out}"); } /// The rendered text with every wrap and indent collapsed to one space. @@ -1916,7 +2023,7 @@ mod tests { "{out}" ); assert!(out.contains("R switches this checkout's"), "{out}"); - assert!(out.contains("R remotes to SSH"), "{out}"); + assert!(out.contains("R: remotes to SSH"), "{out}"); // A helper applies: no warning. let mut helped = v.clone(); @@ -2058,7 +2165,7 @@ mod tests { out.contains("This checkout is set to gh account alice"), "{out}" ); - assert!(out.contains("f forge account"), "{out}"); + assert!(out.contains("f: forge account"), "{out}"); v.workspace.form = Some(WorkspaceForm::Account(AccountForm::new( resource.into(), @@ -2084,7 +2191,7 @@ mod tests { ); assert!(out.contains("SSH key /h/.ssh/id_ed25519"), "{out}"); assert!(out.contains("Another SSH key"), "{out}"); - assert!(out.contains("Tab this repository only"), "{out}"); + assert!(out.contains("Tab/Shift+Tab: this repository only"), "{out}"); assert!(out.contains("a: keep your own identity"), "{out}"); } @@ -2173,8 +2280,11 @@ mod tests { assert!(out.contains("~/code"), "{out}"); assert!(out.contains("protocol: SSH"), "{out}"); assert!(out.contains("Use an absolute path"), "{out}"); - assert!(out.contains("⏎ save"), "{out}"); - assert!(!out.contains("⏎ open"), "a form owns the footer: {out}"); + assert!(out.contains("Enter: save"), "{out}"); + assert!( + !out.contains("Enter: open"), + "a form owns the footer: {out}" + ); v.workspace.form = None; v.workspace.confirm = Some(WorkspaceChange::RemoveIdentity); @@ -2322,7 +2432,10 @@ mod tests { out.contains("▸ ▲ protection weakened · forge shows force-push allowed"), "{out}" ); - assert!(out.contains("v revert drift l link account"), "{out}"); + assert!( + out.contains("v: revert drift") && out.contains("l: link account"), + "{out}" + ); assert!(!out.contains("o adopt"), "{out}"); assert!(out.contains("do it from the admin console or cnm"), "{out}"); } diff --git a/openvtc/src/ui/pages/main/components/rooms_panel.rs b/openvtc/src/ui/pages/main/components/rooms_panel.rs index 1d60d1f0..ee8942b7 100644 --- a/openvtc/src/ui/pages/main/components/rooms_panel.rs +++ b/openvtc/src/ui/pages/main/components/rooms_panel.rs @@ -19,6 +19,7 @@ use crate::state_handler::state::ConnectionState; use openvtc_core::community_rooms::{self, Standing}; use super::panel::Panel; +use crate::ui::pages::main::keymap::{self, k}; pub struct RoomsPanel; @@ -73,7 +74,7 @@ impl Panel for RoomsPanel { " asking the community which rooms it has offered you…", ))); lines.push(Line::from("")); - lines.push(Line::from(dim(" r retry Esc back"))); + keymap::footer(&mut lines, " ", &bindings(view)); return lines; } RoomsPhase::Failed(detail) => { @@ -86,7 +87,7 @@ impl Panel for RoomsPanel { }, ); lines.push(Line::from("")); - lines.push(Line::from(dim(" r retry Esc back"))); + keymap::footer(&mut lines, " ", &bindings(view)); return lines; } RoomsPhase::Loaded => {} @@ -187,16 +188,8 @@ impl Panel for RoomsPanel { push_status(&mut lines, status); } - let mut keys = vec!["↑/↓ navigate"]; - if view.selected_row().is_some_and(|r| { - r.standing.acceptable() && community_rooms::RoomCondition::of(&r.room).admits() - }) && view.accept.is_none() - { - keys.push("⏎ accept"); - } - keys.extend(["r refresh", "Esc back"]); lines.push(Line::from("")); - lines.push(Line::from(dim(format!(" {}", keys.join(" "))))); + keymap::footer(&mut lines, " ", &bindings(view)); lines.push(Line::from(dim( " The same rooms, in the same state, as your member portal and pnm show.", ))); @@ -205,18 +198,44 @@ impl Panel for RoomsPanel { } /// The Rooms panel's keys: navigate, accept, refresh, close. +/// Whether the highlighted room can be accepted now. +fn acceptable(view: &crate::state_handler::main_page::rooms::RoomsView) -> bool { + view.selected_row().is_some_and(|r| { + r.standing.acceptable() && community_rooms::RoomCondition::of(&r.room).admits() + }) && view.accept.is_none() +} + +/// The view's keys, gated as [`rooms_key`] gates them. +fn bindings(view: &crate::state_handler::main_page::rooms::RoomsView) -> Vec { + let mut keys = Vec::new(); + if matches!(view.phase, RoomsPhase::Loaded) { + if !view.rooms.is_empty() { + keys.push(k("↑/↓ or j/k", "move")); + } + if acceptable(view) { + keys.push(k("Enter/a", "accept")); + } + keys.push(k("r", "refresh")); + } else { + keys.push(k("r", "retry")); + } + keys.push(k("Esc", "back")); + keys +} + pub(super) fn rooms_key( key: KeyEvent, view: &crate::state_handler::main_page::rooms::RoomsView, ) -> Option { use crate::state_handler::actions::RoomsAction as R; let last = view.rooms.len().saturating_sub(1); + let listed = matches!(view.phase, RoomsPhase::Loaded) && !view.rooms.is_empty(); Some(match key.code { KeyCode::Esc => R::Back, KeyCode::Char('r') => R::Refresh, - KeyCode::Up | KeyCode::Char('k') => R::Select(view.selected.saturating_sub(1)), - KeyCode::Down | KeyCode::Char('j') => R::Select((view.selected + 1).min(last)), - KeyCode::Enter | KeyCode::Char('a') => R::Accept, + KeyCode::Up | KeyCode::Char('k') if listed => R::Select(view.selected.saturating_sub(1)), + KeyCode::Down | KeyCode::Char('j') if listed => R::Select((view.selected + 1).min(last)), + KeyCode::Enter | KeyCode::Char('a') if acceptable(view) => R::Accept, _ => return None, }) } diff --git a/openvtc/src/ui/pages/main/components/settings_panel.rs b/openvtc/src/ui/pages/main/components/settings_panel.rs index dff28ce8..bf127d39 100644 --- a/openvtc/src/ui/pages/main/components/settings_panel.rs +++ b/openvtc/src/ui/pages/main/components/settings_panel.rs @@ -15,6 +15,7 @@ use crate::state_handler::{ }, state::ConnectionState, }; +use crate::ui::pages::main::keymap::{self, k}; use crate::ui::pages::main::{MainPage, edit_text}; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ @@ -186,9 +187,16 @@ fn render_theme_picker( .push(Line::from(format!("Your themes live in {}", dir.display())).fg(COLOR_DARK_GRAY)); } lines.push(Line::from("")); - lines.push( - Line::from("↑/↓ preview Enter: use c: copy to edit r: reload Esc: cancel") - .fg(COLOR_DARK_GRAY), + keymap::footer( + &mut lines, + "", + &[ + k("↑/↓", "preview"), + k("Enter", "use"), + k("c", "copy to edit"), + k("r", "reload"), + k("Esc", "cancel"), + ], ); lines } @@ -269,7 +277,15 @@ fn render_wipe_confirm(state: &SettingsState, confirm_input: &str) -> Vec Vec> { ])); lines.push(Line::from("")); - lines.push(Line::from("↑/↓ navigate Enter: edit/open").fg(COLOR_DARK_GRAY)); + keymap::footer( + &mut lines, + "", + &[k("↑/↓", "move"), k("Enter", "edit/open"), k("Esc", "back")], + ); lines } @@ -520,7 +540,11 @@ fn render_token_management(state: &SettingsState, selected_index: usize) -> Vec< } lines.push(Line::from("")); - lines.push(Line::from("↑/↓ navigate Enter: execute Esc: back").fg(COLOR_DARK_GRAY)); + keymap::footer( + &mut lines, + "", + &[k("↑/↓", "move"), k("Enter", "run"), k("Esc", "back")], + ); lines } @@ -584,13 +608,21 @@ fn render_token_reset_confirm( ), ])); lines.push(Line::from("")); - lines.push(Line::from(" Esc: cancel | Enter: confirm").fg(COLOR_DARK_GRAY)); + keymap::footer( + &mut lines, + " ", + &[ + k(keymap::TYPE, "the word"), + k("Enter", "confirm"), + k("Esc", "cancel"), + ], + ); lines } /// Render inline edit for a settings field. fn render_edit(label: &str, input: &str) -> Vec> { - vec![ + let mut lines = vec![ Line::from(""), Line::from(format!("Editing: {}", label)) .fg(COLOR_SUCCESS) @@ -602,8 +634,17 @@ fn render_edit(label: &str, input: &str) -> Vec> { Span::styled("▎", Style::new().fg(COLOR_SUCCESS)), ]), Line::from(""), - Line::from("Enter: save Esc: cancel").fg(COLOR_DARK_GRAY), - ] + ]; + keymap::footer( + &mut lines, + "", + &[ + k(keymap::TYPE, "edit"), + k("Enter", "save"), + k("Esc", "cancel"), + ], + ); + lines } /// Render a config form (export or import) with path and passphrase fields. @@ -680,13 +721,28 @@ fn render_export_form( push_form_problem(&mut lines, problem); lines.push(Line::from("")); - lines.push( - Line::from(if confirm_len.is_some() { - "Tab: switch field Enter (on confirm): export Esc: cancel" - } else { - "Tab: switch field Enter (on passphrase): import Esc: cancel" - }) - .fg(COLOR_DARK_GRAY), + let last = if confirm_len.is_some() { 2 } else { 1 }; + let submit = if confirm_len.is_some() { + "export" + } else { + "import" + }; + keymap::footer( + &mut lines, + "", + &[ + k(keymap::TYPE, "this field"), + k("Tab/↑/↓", "switch field"), + k( + "Enter", + if active_field == last { + submit + } else { + "next field" + }, + ), + k("Esc", "cancel"), + ], ); lines @@ -743,7 +799,15 @@ fn render_change_protection( )])); } lines.push(Line::from("")); - lines.push(Line::from("↑/↓ select Enter: choose Esc: cancel").fg(COLOR_DARK_GRAY)); + keymap::footer( + &mut lines, + "", + &[ + k(if selected_option == 0 { "↓" } else { "↑" }, "select"), + k("Enter", "choose"), + k("Esc", "cancel"), + ], + ); } else { // Passphrase input mode — display masked lengths only lines.push(Line::from(vec![ @@ -800,9 +864,23 @@ fn render_change_protection( push_form_problem(&mut lines, problem); lines.push(Line::from("")); - lines.push( - Line::from("Tab: next field Enter (on confirm): save Esc: cancel") - .fg(COLOR_DARK_GRAY), + keymap::footer( + &mut lines, + "", + &[ + k(keymap::TYPE, "passphrase"), + k("Tab", "switch field"), + k(if active_field == 1 { "↓" } else { "↑" }, "switch field"), + k( + "Enter", + if active_field == 2 { + "save" + } else { + "next field" + }, + ), + k("Esc", "cancel"), + ], ); } @@ -1223,7 +1301,7 @@ impl MainPage { .send(Action::Settings(SettingsAction::FormTabSwitch)); true } - KeyCode::Enter if active == 1 && confirm => { + KeyCode::Enter if active == 0 || (active == 1 && confirm) => { let _ = self .action_tx .send(Action::Settings(SettingsAction::FormTabSwitch)); diff --git a/openvtc/src/ui/pages/main/components/tsp_relationships_panel.rs b/openvtc/src/ui/pages/main/components/tsp_relationships_panel.rs index 7ad3f712..d9862305 100644 --- a/openvtc/src/ui/pages/main/components/tsp_relationships_panel.rs +++ b/openvtc/src/ui/pages/main/components/tsp_relationships_panel.rs @@ -17,6 +17,7 @@ use crate::state_handler::{ main_page::content::{ContentPanelState, TspRelationshipsState}, state::ConnectionState, }; +use crate::ui::pages::main::keymap::{self, k}; use openvtc_core::display::truncate_did_centered; use ratatui::{ style::{Style, Stylize}, @@ -65,6 +66,8 @@ pub fn render(state: &TspRelationshipsState, panel_focused: bool) -> Vec Vec Vec { + let mut keys = Vec::new(); + if state.rows.len() > 1 { + keys.push(k("↑/↓", "move")); + } + keys.push(k("Esc", "back")); + keys +} + +impl crate::ui::pages::main::MainPage { + /// TSP Relationships keys (#580): the panel draws a cursor when focused, + /// so ↑/↓ move it, and Esc returns to the menu. + pub(in crate::ui::pages::main) fn handle_tsp_key( + &mut self, + key: crossterm::event::KeyEvent, + ) -> bool { + use crate::state_handler::actions::Action; + use crate::state_handler::main_page::MainPanel; + use crossterm::event::KeyCode; + let tsp = &self.props.main_page.content_panel.tsp_relationships; + let (selected, count) = (tsp.selected_index, tsp.rows.len()); + let action = match key.code { + KeyCode::Up if count > 1 => Action::TspSelect(selected.saturating_sub(1)), + KeyCode::Down if count > 1 => Action::TspSelect((selected + 1).min(count - 1)), + KeyCode::Esc => Action::MainPanelSwitch(MainPanel::MainMenu), + _ => return false, + }; + let _ = self.action_tx.send(action); + true + } +} diff --git a/openvtc/src/ui/pages/main/components/vetting_journey.rs b/openvtc/src/ui/pages/main/components/vetting_journey.rs index 8bc22fad..8d3fb630 100644 --- a/openvtc/src/ui/pages/main/components/vetting_journey.rs +++ b/openvtc/src/ui/pages/main/components/vetting_journey.rs @@ -26,7 +26,10 @@ fn dim() -> Style { Style::new().fg(COLOR_DARK_GRAY) } -fn key(k: &str, what: &str) -> Vec> { +fn key(k: &'static str, what: &str) -> Vec> { + // A step's key is drawn on its own line; it is one of the journey's keys + // for `?` all the same. + crate::ui::pages::main::keymap::advertise(&[crate::ui::pages::main::keymap::k(k, what)]); vec![ Span::styled(format!(" {k}"), Style::new().fg(COLOR_SUCCESS).bold()), Span::styled(format!(" {what}"), Style::new().fg(COLOR_TEXT_DEFAULT)), @@ -204,10 +207,15 @@ fn applicant_part( } } lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "Esc: back to your applications (nothing is lost) x: abandon this application", - dim(), - ))); + crate::ui::pages::main::keymap::footer( + lines, + "", + &[ + crate::ui::pages::main::keymap::k("Esc", "back to your applications (nothing is lost)"), + crate::ui::pages::main::keymap::k("x/Del", "abandon this application"), + crate::ui::pages::main::keymap::k("m", "refresh requirements"), + ], + ); } /// The vetter's part now. @@ -286,14 +294,17 @@ fn vetter_part( } } lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - if ending.is_some() { - "Esc: back to your desk" - } else { - "Esc: back to your desk (nothing is lost) x: decline (a reason is optional)" - }, - dim(), - ))); + let mut out = vec![crate::ui::pages::main::keymap::k( + "Esc", + "back to your desk (nothing is lost)", + )]; + if ending.is_none() { + out.push(crate::ui::pages::main::keymap::k( + "x", + "decline (a reason is optional)", + )); + } + crate::ui::pages::main::keymap::footer(lines, "", &out); } #[cfg(test)] diff --git a/openvtc/src/ui/pages/main/components/vetting_panel.rs b/openvtc/src/ui/pages/main/components/vetting_panel.rs index aba30e64..53a5b86c 100644 --- a/openvtc/src/ui/pages/main/components/vetting_panel.rs +++ b/openvtc/src/ui/pages/main/components/vetting_panel.rs @@ -101,6 +101,8 @@ fn dim() -> Style { fn heading(text: impl Into) -> Line<'static> { Line::from(text.into()).fg(COLOR_SUCCESS).bold() } +use crate::ui::pages::main::keymap::{self, k}; + fn hint(text: impl Into) -> Line<'static> { Line::from(text.into()).fg(COLOR_DARK_GRAY) } @@ -353,9 +355,16 @@ fn new_face(lines: &mut Vec>, form: &NewFaceForm) { lines.push(Line::from(Span::styled(format!(" {error}"), bad()))); } lines.push(Line::from("")); - lines.push(hint( - "Tab/↑/↓: move ←/→ or Space: choose Enter: continue Esc: cancel", - )); + keymap::footer( + lines, + "", + &[ + k("Tab/↑/↓", "move"), + k("←/→ or Space", "choose"), + k("Enter", "continue"), + k("Esc", "cancel"), + ], + ); } /// What the page draws for its current mode — the list, or whichever form or @@ -413,9 +422,16 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { )); lines.push(hint("other communities.")); lines.push(Line::from("")); - lines.push(hint( - "Enter: start Tab: next field ←/→: choose Esc: cancel", - )); + keymap::footer( + lines, + "", + &[ + k("Enter", "start"), + k("Tab", "next field"), + k("←/→", "choose"), + k("Esc", "cancel"), + ], + ); } VettingMode::HolderGrant { credential_did } => { lines.push(heading("Your faces need one more grant")); @@ -435,7 +451,7 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { // Both keys named. This view has no field and no rows, so with only // "Esc: back" on it every other key looked broken rather than // unbound — including the `f` the line above asks for. - lines.push(hint("f: try again Esc: back")); + keymap::footer(lines, "", &[k("f", "try again"), k("Esc", "back")]); } VettingMode::NewFace(form) => new_face(lines, form), VettingMode::ChooseFace { @@ -553,7 +569,15 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { ), ])); lines.push(Line::from("")); - lines.push(hint("↑/↓: choose Enter: wear it Esc: cancel")); + keymap::footer( + lines, + "", + &[ + k("↑/↓", "choose"), + k("Enter", "wear it"), + k("Esc", "cancel"), + ], + ); } VettingMode::RequestVetter { entry, @@ -588,7 +612,7 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { )); } lines.push(Line::from("")); - lines.push(hint("Enter: send Esc: cancel")); + keymap::footer(lines, "", &[k("Enter", "send"), k("Esc", "cancel")]); } VettingMode::Directory(view) => directory(lines, v, view), VettingMode::Profile(form) => match &form.event { @@ -619,7 +643,11 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { } lines.push(line); lines.push(Line::from("")); - lines.push(hint("Enter: ask ←/→: choose Esc: cancel")); + keymap::footer( + lines, + "", + &[k("Enter", "ask"), k("←/→", "choose"), k("Esc", "cancel")], + ); } VettingMode::HiddenVetting { index } => hidden_vetting(lines, v, *index), VettingMode::EventMode { index } => { @@ -686,7 +714,11 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { } lines.push(Line::from("")); } - lines.push(hint("Enter: ask ←/→: choose Esc: cancel")); + keymap::footer( + lines, + "", + &[k("Enter", "ask"), k("←/→", "choose"), k("Esc", "cancel")], + ); } VettingMode::SendCard { application_id, @@ -719,7 +751,15 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { false, )); lines.push(Line::from("")); - lines.push(hint("Enter: issue Tab: next field Esc: cancel")); + keymap::footer( + lines, + "", + &[ + k("Enter", "issue"), + k("Tab", "next field"), + k("Esc", "cancel"), + ], + ); } VettingMode::ShowTicket { index } => show_ticket(lines, v, *index), VettingMode::OpenSession { @@ -754,7 +794,7 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { false, )); lines.push(Line::from("")); - lines.push(hint("Enter: open Esc: cancel")); + keymap::footer(lines, "", &[k("Enter", "open"), k("Esc", "cancel")]); } VettingMode::Attest { request_id, form } => attest(lines, v, request_id, form), VettingMode::ConfirmDecline { @@ -795,14 +835,24 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { true, )); lines.push(Line::from("")); - lines.push( - Line::from(if *focused == 1 { - "Enter: done typing ↑/↓: field Esc: keep it" + keymap::footer( + lines, + "", + &if *focused == 1 { + vec![ + k(keymap::TYPE, "the note"), + k("Enter", "done typing"), + k("↑/↓", "field"), + k("Esc", "keep it"), + ] } else { - "y: decline n or Esc: keep it ↑/↓: field" - }) - .fg(COLOR_ORANGE) - .bold(), + vec![ + k("y", "decline"), + k("n or Esc", "keep it"), + k("↑/↓", "field"), + k("←/→", "reason"), + ] + }, ); } VettingMode::ConfirmAbandon { application_id } => { @@ -853,6 +903,7 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { lines.push(hint("answer.")); } lines.push(Line::from("")); + keymap::confirming(); lines.push(hint("y: abandon · any other key: keep it")); } VettingMode::ConfirmDeleteTicket { ticket_id } => { @@ -895,6 +946,7 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { "so hand out a new one if they still need to reach you.", )); lines.push(Line::from("")); + keymap::confirming(); lines.push(hint("y: delete · any other key: keep it")); } VettingMode::Withdraw { @@ -928,7 +980,11 @@ fn mode_lines(lines: &mut Vec>, v: &VettingState) { false, )); lines.push(Line::from("")); - lines.push(hint("←/→: reason y: withdraw Esc: cancel")); + keymap::footer( + lines, + "", + &[k("←/→", "reason"), k("y", "withdraw"), k("Esc", "cancel")], + ); } } } @@ -959,7 +1015,11 @@ fn applications(lines: &mut Vec>, v: &VettingState) { "then ask vetters for their ticket codes and send each a request.", )); lines.push(Line::from("")); - lines.push(hint("n: new application")); + keymap::footer( + lines, + "", + &[k("n", "new application"), k("Tab", "the desk")], + ); return; } for (i, app) in v.applications.iter().enumerate() { @@ -1158,9 +1218,18 @@ fn applications(lines: &mut Vec>, v: &VettingState) { } } lines.push(Line::from("")); - lines.push(hint( - "n: new f: face r: ask a vetter v: find vetters c: send card m: refresh requirements", - )); + keymap::footer( + lines, + "", + &[ + k("n", "new"), + k("f", "face"), + k("r", "ask a vetter"), + k("v", "find vetters"), + k("c", "send card"), + k("m", "refresh requirements"), + ], + ); lines.push(hint( "p: switch criterion or path (PCS ZKP / named) — before any vetting is held", )); @@ -1289,11 +1358,26 @@ fn directory(lines: &mut Vec>, v: &VettingState, view: &DirectoryV view.cursors.len().max(1) ))); } - lines.push(hint(if f >= DIRECTORY_FIELDS { - "Enter or a: ask this vetter ↑/↓: move Esc: back" + let mut keys = if f >= DIRECTORY_FIELDS { + vec![k("Enter or a", "ask this vetter"), k("↑/↓", "move")] } else { - "Enter: search ↑/↓ or Tab: move ←/→: choose Esc: back" - })); + vec![ + k(keymap::TYPE, "filter"), + k("Enter", "search"), + k("↑/↓ or Tab", "move"), + k("←/→", "choose"), + ] + }; + if view.searched && f >= DIRECTORY_FIELDS { + if view.next_cursor.is_some() { + keys.push(k("n", "next page")); + } + if view.cursors.len() > 1 { + keys.push(k("p", "previous page")); + } + } + keys.push(k("Esc", "back")); + keymap::footer(lines, "", &keys); } fn profile(lines: &mut Vec>, v: &VettingState, form: &VetterProfileForm) { @@ -1401,12 +1485,26 @@ fn profile(lines: &mut Vec>, v: &VettingState, form: &VetterProfil lines.push(Line::from(error.clone()).fg(COLOR_WARNING_ACCESSIBLE_RED)); } lines.push(Line::from("")); - lines.push(hint( - "↑/↓: move type to edit Space: tick Enter on an event: edit x: remove it", - )); - lines.push(hint( - "Enter elsewhere: publish ←/→: community Esc: cancel", - )); + keymap::footer( + lines, + "", + &[ + k("↑/↓", "move"), + k(keymap::TYPE, "edit"), + k("Space", "tick"), + k("Enter", "edit (on an event)"), + k("x", "remove it"), + ], + ); + keymap::footer( + lines, + "", + &[ + k("Enter", "publish (elsewhere)"), + k("←/→", "community"), + k("Esc", "cancel"), + ], + ); } fn event_form(lines: &mut Vec>, event: &EventForm) { @@ -1436,7 +1534,15 @@ fn event_form(lines: &mut Vec>, event: &EventForm) { lines.push(Line::from(error.clone()).fg(COLOR_WARNING_ACCESSIBLE_RED)); } lines.push(Line::from("")); - lines.push(hint("Enter: keep it ↑/↓: move Esc: back to the profile")); + keymap::footer( + lines, + "", + &[ + k("Enter", "keep it"), + k("↑/↓", "move"), + k("Esc", "back to the profile"), + ], + ); } fn send_card( @@ -1496,7 +1602,15 @@ fn send_card( )); lines.push(hint("have seen it and pressed Enter again.")); lines.push(Line::from("")); - lines.push(hint("Enter: preview f: choose the face Esc: not now")); + keymap::footer( + lines, + "", + &[ + k("Enter", "preview"), + k("f", "choose the face"), + k("Esc", "not now"), + ], + ); return; }; lines.push(Line::from(" The card will show").fg(COLOR_SUCCESS)); @@ -1519,7 +1633,11 @@ fn send_card( "No document numbers or images are sent: the vetter looks at your document, not a copy.", )); lines.push(Line::from("")); - lines.push(hint("Enter: approve, sign and send Esc: not now")); + keymap::footer( + lines, + "", + &[k("Enter", "approve, sign and send"), k("Esc", "not now")], + ); } /// Draw `uri` as a QR code into `lines`, given the rows still unspent. @@ -1594,7 +1712,15 @@ fn show_ticket(lines: &mut Vec>, v: &VettingState, index: usize) { None => lines.push(hint("This ticket has no link to scan.")), } lines.push(Line::from("")); - lines.push(hint("Esc or Enter: back (y: copy code u: copy link)")); + keymap::footer( + lines, + "", + &[ + k("Esc or Enter", "back"), + k("y", "copy code"), + k("u", "copy link"), + ], + ); } /// The desk's header: what each community has made us, and what it holds of our @@ -1707,7 +1833,7 @@ fn desk(lines: &mut Vec>, v: &VettingState) { lines.push(hint( "Requests arrive only with one of your tickets — hand them out under Tickets (→).", )); - lines.push(hint(DESK_KEYS)); + keymap::footer(lines, "", &desk_keys()); return; } for (i, row) in v.desk.iter().enumerate() { @@ -1801,16 +1927,25 @@ fn desk(lines: &mut Vec>, v: &VettingState) { if !keys.is_empty() { lines.push(hint(keys)); } - lines.push(hint(DESK_KEYS)); + keymap::footer(lines, "", &desk_keys()); } /// The keys every desk view carries, on its last line. `p`, `g` and `e` are the /// desk's, not any one view's — the profile, the vetter credential and a place /// at an event belong to the whole desk — and `←/→` is how the views are /// reached at all. -const DESK_KEYS: &str = "p: your vetter profile g: ask for your vetter credential again \ - e: vet at an event h: hidden vetting k: get tokens \ - ←/→: Requests · Tickets · Issued Tab: Applications"; +/// The desk's own keys, under every desk view. +fn desk_keys() -> Vec { + vec![ + k("p", "your vetter profile"), + k("g", "ask for your vetter credential again"), + k("e", "vet at an event"), + k("h", "hidden vetting"), + k("k", "get tokens"), + k("←/→", "Requests · Tickets · Issued"), + k("Tab", "Applications"), + ] +} /// The hidden-vetting view: one community at a time, everything the schedule /// is doing for it. @@ -1824,7 +1959,7 @@ fn hidden_vetting(lines: &mut Vec>, v: &VettingState, index: usize let Some(row) = v.hidden.get(index) else { lines.push(hint("No community you vet for hides its vetters.")); lines.push(Line::from("")); - lines.push(hint("Esc: back")); + keymap::footer(lines, "", &[k("Esc", "back")]); return; }; let item = |name: &str, shown: String| { @@ -1976,16 +2111,15 @@ fn hidden_vetting(lines: &mut Vec>, v: &VettingState, index: usize lines.push(Line::from(format!(" Last refusal {refusal}")).fg(COLOR_ORANGE)); } lines.push(Line::from("")); - let check = if row.key.is_empty() { - "" - } else { - "r: check my key again " - }; - lines.push(hint(if v.hidden.len() > 1 { - format!("d or Enter: draw now {check}←/→: community Esc: back") - } else { - format!("d or Enter: draw now {check}Esc: back") - })); + let mut keys = vec![k("d or Enter", "draw now")]; + if !row.key.is_empty() { + keys.push(k("r", "check my key again")); + } + if v.hidden.len() > 1 { + keys.push(k("←/→", "community")); + } + keys.push(k("Esc", "back")); + keymap::footer(lines, "", &keys); } /// The drip's rate as the hidden-vetting view says it, with when it was read from the @@ -2134,9 +2268,17 @@ fn attest(lines: &mut Vec>, v: &VettingState, request_id: &str, fo false, )); lines.push(Line::from("")); - lines.push(hint( - "↑/↓: item ←/→: choose Space: tick Enter: sign and send Esc: cancel", - )); + keymap::footer( + lines, + "", + &[ + k("↑/↓", "item"), + k("←/→", "choose"), + k("Space", "tick"), + k("Enter", "sign and send"), + k("Esc", "cancel"), + ], + ); } fn tickets(lines: &mut Vec>, v: &VettingState) { @@ -2144,9 +2286,13 @@ fn tickets(lines: &mut Vec>, v: &VettingState) { lines.push(hint("You have no tickets out.")); lines.push(Line::from("")); lines.push(hint( - "t: hand out a ticket — read the code to someone, or show them its QR code", + "t hands out a ticket — read the code to someone, or show them its QR code.", )); - lines.push(hint(DESK_KEYS)); + keymap::footer( + lines, + "", + &[&[k("t", "new ticket")][..], &desk_keys()].concat(), + ); return; } for (i, row) in v.tickets.iter().enumerate() { @@ -2196,7 +2342,7 @@ fn tickets(lines: &mut Vec>, v: &VettingState) { ])); lines.push(Line::from("")); lines.push(hint( - "Or let them scan this — ⏎ shows it full-screen. It carries the ticket and your DID:", + "Or let them scan this — Enter shows it full-screen. It carries the ticket and your DID:", )); lines.push(Line::from("")); // The tail below the code: a blank, the link (which wraps on a narrow @@ -2209,17 +2355,25 @@ fn tickets(lines: &mut Vec>, v: &VettingState) { lines.push(hint(uri.clone())); } lines.push(Line::from("")); - lines.push(hint( - "⏎: show the code full-screen t: new ticket y: copy code u: copy link d: delete", - )); - lines.push(hint(DESK_KEYS)); + keymap::footer( + lines, + "", + &[ + k("Enter", "show the code full-screen"), + k("t", "new ticket"), + k("y", "copy code"), + k("u", "copy link"), + k("d", "delete"), + ], + ); + keymap::footer(lines, "", &desk_keys()); } fn issued(lines: &mut Vec>, v: &VettingState) { if v.issued.is_empty() && v.declined.is_empty() && v.attested_hidden.is_empty() { lines.push(hint("You have not finished vetting anyone yet.")); lines.push(Line::from("")); - lines.push(hint(DESK_KEYS)); + keymap::footer(lines, "", &desk_keys()); return; } if !v.issued.is_empty() { @@ -2286,16 +2440,17 @@ fn issued(lines: &mut Vec>, v: &VettingState) { lines.push(Line::from("")); // What `w` does for the highlighted statement: nothing once recorded, and // a resend while a withdrawal is still unconfirmed. + let mut keys = desk_keys(); match v.issued.get(v.selected) { Some(row) if row.withdrawal_recorded => {} Some(row) if row.withdrawal.is_some() => { - lines.push(hint("w: send the withdrawal again")); + keys.insert(0, k("w", "send the withdrawal again")); } - Some(_) => lines.push(hint("w: withdraw")), + Some(_) => keys.insert(0, k("w", "withdraw")), // Nothing signed to withdraw: hidden attestations and declines have no statement. None => {} } - lines.push(hint(DESK_KEYS)); + keymap::footer(lines, "", &keys); } #[cfg(test)] @@ -2839,7 +2994,7 @@ mod desk_tests { "the list says what is short rather than drawing half a code: {listed}" ); assert!( - listed.contains("⏎ shows it full-screen"), + listed.contains("Enter shows it full-screen"), "and points at the view that does fit: {listed}" ); diff --git a/openvtc/src/ui/pages/main/components/vta_panel.rs b/openvtc/src/ui/pages/main/components/vta_panel.rs index eb673f65..7cab8218 100644 --- a/openvtc/src/ui/pages/main/components/vta_panel.rs +++ b/openvtc/src/ui/pages/main/components/vta_panel.rs @@ -11,6 +11,7 @@ use crate::state_handler::{ state::ConnectionState, }; use crate::ui::pages::main::MainPage; +use crate::ui::pages::main::keymap; use crossterm::event::{KeyCode, KeyEvent}; use openvtc_core::display::{display_identifier, truncate_did_centered}; use ratatui::{ @@ -479,10 +480,14 @@ fn render_vics(state: &VtaState, panel_focused: bool, lines: &mut Vec "u: unarchive", - Some(VicLifecycle::Deleted) => "u: restore", - _ => "r: archive", - }; - lines.push( - Line::from(format!( - "↑/↓ select a: import {restore_verb} d: delete p: purge i: inactive" - )) - .fg(COLOR_DARK_GRAY), - ); + crate::ui::pages::main::keymap::footer(lines, "", &bindings(state)); } } +/// The invitation-credential list's keys, gated as the handler gates them. +pub(crate) fn bindings(state: &VtaState) -> Vec { + use crate::ui::pages::main::keymap::k; + let sel = state.vics.get(state.vic_selected_index); + let lifecycle = sel.map(|v| v.lifecycle); + let mut keys = Vec::new(); + if !state.vics.is_empty() { + keys.push(k("↑/↓", "select")); + } + keys.push(k("a", "import")); + match lifecycle { + Some(VicLifecycle::Active) => keys.push(k("x", "archive")), + Some(VicLifecycle::Archived) => keys.push(k("u", "unarchive")), + Some(VicLifecycle::Deleted) => keys.push(k("u", "restore")), + None => {} + } + if sel.is_some() && lifecycle != Some(VicLifecycle::Deleted) { + keys.push(k("d/Del", "delete")); + } + if sel.is_some() { + keys.push(k("p", "purge")); + } + keys.push(k( + "v", + if state.vic_show_inactive { + "hide archived" + } else { + "show archived" + }, + )); + keys.push(k("r", "refresh")); + keys.push(k("Esc", "back")); + keys +} + impl MainPage { /// VTA Service keys. The panel's one manageable list is the invitation /// credentials: ↑/↓ move the selection, `a` imports, `i` flips the archived @@ -614,7 +643,10 @@ impl MainPage { // key that asks. It was Tab because Tab moved focus onto the list // and the load rode along; with one list left, the load is the // whole of what the key does. - KeyCode::Tab => { + // + // Refresh is `r` here as on every other panel (#580); Tab is the + // global panel switch again. + KeyCode::Char('r') => { let _ = self.action_tx.send(Action::VicRefresh); true } @@ -622,7 +654,8 @@ impl MainPage { let _ = self.action_tx.send(Action::StartAddVic); true } - KeyCode::Char('i') => { + // `v` shows or hides archived rows, as on the Communities list. + KeyCode::Char('v') => { let _ = self.action_tx.send(Action::VicToggleInactive); true } @@ -644,21 +677,23 @@ impl MainPage { .send(Action::VicSelect((vic_selected + 1).min(vic_count - 1))); true } - // r: archive an active VIC (reversible, so no confirm). - KeyCode::Char('r') if vic_lifecycle == Some(VicLifecycle::Active) => { + // x: archive an active VIC (reversible, so no confirm) — the + // archive key on every list (#580). + KeyCode::Char('x') if vic_lifecycle == Some(VicLifecycle::Active) => { let _ = self.action_tx.send(Action::VicArchive(vic_selected)); true } // u: unarchive an archived VIC, or restore a soft-deleted one. - KeyCode::Char('u') => { - match vic_lifecycle { - Some(VicLifecycle::Archived) => { - let _ = self.action_tx.send(Action::VicUnarchive(vic_selected)); - } - Some(VicLifecycle::Deleted) => { - let _ = self.action_tx.send(Action::VicRestore(vic_selected)); - } - _ => {} + KeyCode::Char('u') + if matches!( + vic_lifecycle, + Some(VicLifecycle::Archived | VicLifecycle::Deleted) + ) => + { + if vic_lifecycle == Some(VicLifecycle::Archived) { + let _ = self.action_tx.send(Action::VicUnarchive(vic_selected)); + } else { + let _ = self.action_tx.send(Action::VicRestore(vic_selected)); } true } diff --git a/openvtc/src/ui/pages/main/confirm.rs b/openvtc/src/ui/pages/main/confirm.rs index 1d490efa..ac8b4c55 100644 --- a/openvtc/src/ui/pages/main/confirm.rs +++ b/openvtc/src/ui/pages/main/confirm.rs @@ -25,6 +25,7 @@ pub(crate) fn is_yes(key: &KeyEvent) -> bool { /// A confirmation prompt: the question, then [`KEYS`]. #[must_use] pub(crate) fn prompt(question: &str) -> String { + super::keymap::confirming(); format!("{question} {KEYS}") } diff --git a/openvtc/src/ui/pages/main/keymap.rs b/openvtc/src/ui/pages/main/keymap.rs new file mode 100644 index 00000000..ff24a367 --- /dev/null +++ b/openvtc/src/ui/pages/main/keymap.rs @@ -0,0 +1,360 @@ +//! The main page's key map: one table per view, drawn as that view's footer and +//! listed by the `?` help overlay (#580). +//! +//! A view says which keys it takes by building a list of [`Binding`]s and +//! drawing it with [`footer`]. Drawing also *advertises* the list for the frame +//! ([`take_advertised`]), and that advertised list is what `?` shows and what +//! decides whether an unhandled key earns a "nothing here" hint. The footer and +//! the help overlay are therefore the same table, and the key-handler tests +//! check each view's handler against it — a handled key cannot be +//! unadvertised, nor an advertised key unhandled. +//! +//! Key labels use one vocabulary on every panel (`Enter`, `Esc`, `Tab`, +//! `Shift+Tab`, `↑/↓`, `←/→`, `Space`, `Del`, `Ctrl+X`, single characters), +//! parsed into the [`KeyCode`]s they stand for by [`codes`], so the label shown +//! and the key matched cannot disagree. + +use std::cell::RefCell; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + style::Style, + text::{Line, Span}, +}; + +use crate::colors::{COLOR_DARK_GRAY, COLOR_TEXT_DEFAULT}; + +/// One key (or set of keys) a view takes, and what it does there. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Binding { + /// The keys as shown, e.g. `"↑/↓"`, `"Enter"`, `"d/Del"`, `"Ctrl+A"`. + pub key: &'static str, + /// What the keys do here, in a few words. + pub does: String, +} + +/// Shorthand for a [`Binding`]. +pub(crate) fn k(key: &'static str, does: impl Into) -> Binding { + debug_assert!( + key == TYPE || !codes(key).is_empty(), + "unknown key label {key:?}" + ); + Binding { + key, + does: does.into(), + } +} + +/// The label of the binding that says a text field takes typing: every +/// printable character, Backspace/Del, ←/→ and Home/End edit the field. +pub(crate) const TYPE: &str = "type"; + +impl Binding { + /// The keys this binding stands for. + pub(crate) fn codes(&self) -> Vec<(KeyCode, KeyModifiers)> { + codes(self.key) + } + + /// Whether this binding is a text field taking typing. + pub(crate) fn is_typing(&self) -> bool { + self.key == TYPE + } + + /// Whether `key` is one of this binding's keys. + pub(crate) fn matches(&self, key: &KeyEvent) -> bool { + if self.is_typing() { + return is_text_edit(key); + } + let mods = key.modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT); + self.codes() + .iter() + .any(|(code, m)| *code == key.code && *m == mods) + } +} + +/// Whether `key` is a keystroke a text field takes: a character without Ctrl +/// or Alt, or Backspace. +pub(crate) fn is_text_edit(key: &KeyEvent) -> bool { + match key.code { + KeyCode::Char(_) => !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT), + KeyCode::Backspace => true, + _ => false, + } +} + +/// Parse a key label into the keys it stands for. Alternatives are separated +/// by `/` (`"↑/↓"`, `"d/Del"`) or `" or "`; an unknown token parses to nothing, +/// which [`k`] rejects in debug builds and the tests reject always. +pub(crate) fn codes(label: &str) -> Vec<(KeyCode, KeyModifiers)> { + let mut out = Vec::new(); + for part in label.split(" or ") { + // "↑/↓" splits on '/', but "/" alone is the slash key. + let tokens: Vec<&str> = if part == "/" { + vec!["/"] + } else { + part.split('/').collect() + }; + for token in tokens { + match token_code(token.trim()) { + Some(code) => out.push(code), + None => return Vec::new(), + } + } + } + out +} + +fn token_code(token: &str) -> Option<(KeyCode, KeyModifiers)> { + let none = KeyModifiers::NONE; + if let Some(rest) = token.strip_prefix("Ctrl+") { + let mut chars = rest.chars(); + let c = chars.next()?; + if chars.next().is_some() { + return None; + } + return Some((KeyCode::Char(c.to_ascii_lowercase()), KeyModifiers::CONTROL)); + } + let code = match token { + "Enter" => KeyCode::Enter, + "Esc" => KeyCode::Esc, + "Tab" => KeyCode::Tab, + "Shift+Tab" => KeyCode::BackTab, + "↑" => KeyCode::Up, + "↓" => KeyCode::Down, + "←" => KeyCode::Left, + "→" => KeyCode::Right, + "Space" => KeyCode::Char(' '), + "Del" => KeyCode::Delete, + "Backspace" => KeyCode::Backspace, + "PgUp" => KeyCode::PageUp, + "PgDn" => KeyCode::PageDown, + "Home" => KeyCode::Home, + "End" => KeyCode::End, + "F10" => KeyCode::F(10), + t => { + let mut chars = t.chars(); + let c = chars.next()?; + if chars.next().is_some() || !c.is_ascii_graphic() { + return None; + } + KeyCode::Char(c) + } + }; + Some((code, none)) +} + +thread_local! { + /// The bindings advertised by the footers drawn so far this frame. + /// + /// Carried like the status wrap width (see `components::status`): panels + /// render to `Vec` without a handle on the page, the render path is + /// single-threaded, and the page clears it at the top of every frame. + static ADVERTISED: RefCell> = const { RefCell::new(Vec::new()) }; + + /// A confirmation prompt was drawn this frame: it owns the keyboard, so + /// its keys are the only ones advertised whatever else was drawn. + static CONFIRMING: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Start a frame: nothing is advertised yet. +pub(crate) fn begin_frame() { + ADVERTISED.with(|a| a.borrow_mut().clear()); + CONFIRMING.set(false); +} + +/// Everything advertised since [`begin_frame`], leaving it empty. +pub(crate) fn take_advertised() -> Vec { + let advertised = ADVERTISED.with(|a| std::mem::take(&mut *a.borrow_mut())); + if CONFIRMING.replace(false) { + return vec![ + k("y", "confirm"), + k("Esc", "cancel (so does any other key)"), + ]; + } + advertised +} + +/// A confirmation prompt is on screen (see `confirm::prompt`). +pub(crate) fn confirming() { + CONFIRMING.set(true); +} + +/// Advertise `bindings` without drawing them (a view whose keys are shown some +/// other way, such as a prompt that names its keys in its own words). +pub(crate) fn advertise(bindings: &[Binding]) { + ADVERTISED.with(|a| a.borrow_mut().extend_from_slice(bindings)); +} + +/// One footer line: `key: does · key: does`, dim, prefixed with `indent`. +pub(crate) fn line(indent: &'static str, bindings: &[Binding]) -> Line<'static> { + let mut spans = vec![Span::raw(indent)]; + for (i, b) in bindings.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" · ", Style::new().fg(COLOR_DARK_GRAY))); + } + spans.push(Span::styled(b.key, Style::new().fg(COLOR_TEXT_DEFAULT))); + spans.push(Span::styled( + format!(": {}", b.does), + Style::new().fg(COLOR_DARK_GRAY), + )); + } + Line::from(spans) +} + +/// One footer line for an overlay, advertised for the frame. An overlay owns +/// the keyboard while it is open, so its keys replace the panel's. +pub(crate) fn overlay_line(bindings: &[Binding]) -> Line<'static> { + advertise(bindings); + line("", bindings) +} + +/// Draw `bindings` as a footer and advertise them for `?`. +/// +/// A footer wider than the panel wraps onto further lines at a binding +/// boundary rather than being clipped (#576). +pub(crate) fn footer(lines: &mut Vec>, indent: &'static str, bindings: &[Binding]) { + advertise(bindings); + let width = super::components::status::content_width(); + let mut row: Vec = Vec::new(); + let mut used = indent.chars().count(); + for b in bindings { + let w = b.key.chars().count() + 2 + b.does.chars().count(); + let sep = if row.is_empty() { 0 } else { 3 }; + if !row.is_empty() && used + sep + w > width { + lines.push(line(indent, &row)); + row.clear(); + used = indent.chars().count(); + } + used += if row.is_empty() { w } else { 3 + w }; + row.push(b.clone()); + } + if !row.is_empty() { + lines.push(line(indent, &row)); + } +} + +/// The keys that work on every view of the main page. +pub(crate) fn global() -> Vec { + vec![ + k("F10", "quit"), + k("?", "keys"), + k("Tab", "switch panel"), + k("Ctrl+K", "switch community"), + k("PgUp/PgDn", "scroll"), + k("Home/End", "top / bottom"), + ] +} + +/// The menu's own keys, while it has the focus. +pub(crate) fn menu() -> Vec { + vec![ + k("↑/↓", "choose"), + k("Enter/→", "open"), + k("j/k", "down / up"), + ] +} + +/// A short name for a key, for "that key does nothing here". +pub(crate) fn describe(key: &KeyEvent) -> String { + let base = match key.code { + KeyCode::Char(' ') => "Space".to_string(), + KeyCode::Char(c) => c.to_string(), + KeyCode::Enter => "Enter".into(), + KeyCode::Esc => "Esc".into(), + KeyCode::Tab => "Tab".into(), + KeyCode::BackTab => "Shift+Tab".into(), + KeyCode::Up => "↑".into(), + KeyCode::Down => "↓".into(), + KeyCode::Left => "←".into(), + KeyCode::Right => "→".into(), + KeyCode::Backspace => "Backspace".into(), + KeyCode::Delete => "Del".into(), + KeyCode::F(n) => format!("F{n}"), + other => format!("{other:?}"), + }; + if key.modifiers.contains(KeyModifiers::CONTROL) { + format!("Ctrl+{base}") + } else if key.modifiers.contains(KeyModifiers::ALT) { + format!("Alt+{base}") + } else { + base + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn labels_parse_to_the_keys_they_name() { + assert_eq!( + codes("↑/↓"), + vec![ + (KeyCode::Up, KeyModifiers::NONE), + (KeyCode::Down, KeyModifiers::NONE) + ] + ); + assert_eq!( + codes("d/Del"), + vec![ + (KeyCode::Char('d'), KeyModifiers::NONE), + (KeyCode::Delete, KeyModifiers::NONE) + ] + ); + assert_eq!( + codes("Ctrl+K"), + vec![(KeyCode::Char('k'), KeyModifiers::CONTROL)] + ); + assert_eq!( + codes("Esc or Enter"), + vec![ + (KeyCode::Esc, KeyModifiers::NONE), + (KeyCode::Enter, KeyModifiers::NONE) + ] + ); + assert_eq!(codes("?"), vec![(KeyCode::Char('?'), KeyModifiers::NONE)]); + assert!(codes("⏎").is_empty(), "one vocabulary: Enter, not ⏎"); + assert!(codes("Return").is_empty()); + } + + #[test] + fn every_global_and_menu_label_parses() { + for b in global().iter().chain(menu().iter()) { + assert!(!b.codes().is_empty(), "{}", b.key); + } + } + + #[test] + fn a_footer_wraps_rather_than_clipping() { + super::super::components::status::set_wrap_width(30); + let mut lines = Vec::new(); + footer( + &mut lines, + "", + &[ + k("↑/↓", "move"), + k("Enter", "open"), + k("d", "dismiss"), + k("c", "clear all"), + k("Esc", "back"), + ], + ); + assert!(lines.len() > 1); + for l in &lines { + assert!(l.width() <= 30, "{l:?}"); + } + let advertised = take_advertised(); + assert_eq!(advertised.len(), 5); + } + + #[test] + fn typing_matches_text_keys_only() { + let t = k(TYPE, "edit"); + assert!(t.matches(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE))); + assert!(t.matches(&KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT))); + assert!(!t.matches(&KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL))); + assert!(!t.matches(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))); + } +} diff --git a/openvtc/src/ui/pages/main/keymap_tests.rs b/openvtc/src/ui/pages/main/keymap_tests.rs new file mode 100644 index 00000000..9bdfd383 --- /dev/null +++ b/openvtc/src/ui/pages/main/keymap_tests.rs @@ -0,0 +1,380 @@ +//! The key map's contract (#580): on every view, the keys its footer — and so +//! the `?` overlay — lists are exactly the keys its handler takes. +//! +//! Each case builds a page on one view, draws it once (which is what +//! advertises the view's keys), then presses every key in [`universe`] on a +//! fresh copy and asks the view's handler whether it took it. A key taken but +//! not listed is a hidden key; a key listed but not taken is a lie in the +//! footer. Either fails the case, naming the key. + +use super::key_handler_tests::{ + community_summary, community_summary_with, page_for, press, rel_summary, task_summary, vic, + vrc_summary, +}; +use super::*; +use crate::state_handler::main_page::content::{ + ActiveTaskView, CredentialTab, CredentialsMode, RelationshipsMode, SettingsMode, VicLifecycle, +}; +use crossterm::event::KeyModifiers; +use std::sync::Arc; + +/// Every key worth pressing: letters both cases, digits, the punctuation the +/// panels use, and the navigation and editing keys. Not PgUp/PgDn/Home/End, +/// which scroll every view before it is asked. +fn universe() -> Vec { + let mut keys: Vec = ('a'..='z') + .chain('A'..='Z') + .chain('0'..='9') + .chain([' ', '/', '?', '+', '-']) + .map(|c| press(KeyCode::Char(c))) + .collect(); + keys.extend( + [ + KeyCode::Enter, + KeyCode::Esc, + KeyCode::Tab, + KeyCode::BackTab, + KeyCode::Up, + KeyCode::Down, + KeyCode::Left, + KeyCode::Right, + KeyCode::Backspace, + KeyCode::Delete, + ] + .map(press), + ); + keys.extend( + ['a', 'e', 'k', 't', 'u', 'v', 'w'] + .map(|c| KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)), + ); + keys +} + +/// The keys the view advertised when drawn. +fn advertised(page: &MainPage) -> Vec { + use ratatui::{Terminal, backend::TestBackend}; + let mut terminal = Terminal::new(TestBackend::new(140, 60)).expect("test terminal"); + terminal + .draw(|frame| page.render(frame, ())) + .expect("render"); + page.panel_keys.borrow().clone() +} + +/// Assert the view's footer lists exactly the keys its handler takes. +#[track_caller] +fn keys_match(what: &str, menu: MainMenu, arm: impl Fn(&mut State) + Copy) { + let (page, _rx) = page_for(menu.clone(), arm); + let listed = advertised(&page); + assert!(!listed.is_empty(), "{what}: the view advertises no keys"); + for b in &listed { + assert!( + b.is_typing() || !b.codes().is_empty(), + "{what}: unknown key label {:?}", + b.key + ); + } + let mut problems = Vec::new(); + for key in universe() { + let (mut page, _rx) = page_for(menu.clone(), arm); + let _ = advertised(&page); + let taken = page.handle_content_key_event(key); + let shown = listed.iter().any(|b| b.matches(&key)); + if taken != shown { + problems.push(format!( + "{} {}", + keymap::describe(&key), + if taken { + "is taken but not listed" + } else { + "is listed but not taken" + } + )); + } + } + assert!( + problems.is_empty(), + "{what}: footer and handler disagree:\n {}\n listed: {:?}", + problems.join("\n "), + listed.iter().map(|b| b.key).collect::>() + ); +} + +fn three(make: impl Fn(&str) -> T) -> Arc<[T]> { + Arc::from(vec![make("a"), make("b"), make("c")]) +} + +#[test] +fn inbox_keys_are_exactly_its_footer() { + keys_match("inbox, empty", MainMenu::Inbox, |_| {}); + keys_match("inbox list", MainMenu::Inbox, |s| { + s.main_page.content_panel.inbox.tasks = three(task_summary); + s.main_page.content_panel.inbox.selected_index = 1; + }); + keys_match("inbox: relationship request", MainMenu::Inbox, |s| { + s.main_page.content_panel.inbox.active_task = + Some(ActiveTaskView::RelationshipRequestInbound { + task_id: "t1".into(), + from_did: "did:example:them".into(), + from_agent_name: None, + their_did: "did:example:them".into(), + reason: None, + name: None, + }); + }); +} + +#[test] +fn relationships_keys_are_exactly_its_footer() { + let rels = |s: &mut State| { + s.main_page.content_panel.relationships.relationships = three(rel_summary); + s.main_page.content_panel.relationships.selected_index = 1; + }; + keys_match("relationships list", MainMenu::Relationships, rels); + keys_match("relationship detail", MainMenu::Relationships, move |s| { + rels(s); + s.main_page.content_panel.relationships.mode = RelationshipsMode::Detail { + index: 1, + selected_vrc: None, + }; + }); + keys_match("relationship alias", MainMenu::Relationships, move |s| { + rels(s); + s.main_page.content_panel.relationships.mode = RelationshipsMode::EditAlias { + index: 1, + alias_input: "Al".into(), + }; + }); + for field in 0..4 { + keys_match( + &format!("new relationship, field {field}"), + MainMenu::Relationships, + move |s| { + s.main_page.content_panel.relationships.mode = RelationshipsMode::NewRequest { + did_input: String::new(), + alias_input: String::new(), + reason_input: String::new(), + generate_r_did: true, + active_field: field, + community_default: None, + }; + }, + ); + } +} + +#[test] +fn credentials_keys_are_exactly_its_footer() { + let creds = |s: &mut State| { + s.main_page.content_panel.credentials.selected_tab = CredentialTab::Received; + s.main_page.content_panel.credentials.received = three(vrc_summary); + s.main_page.content_panel.credentials.selected_index = 1; + }; + keys_match("credentials list", MainMenu::Credentials, creds); + keys_match("credential detail", MainMenu::Credentials, move |s| { + creds(s); + s.main_page.content_panel.credentials.mode = CredentialsMode::Detail { index: 1 }; + }); + keys_match( + "credential detail, membership", + MainMenu::Credentials, + move |s| { + creds(s); + s.main_page.content_panel.credentials.selected_tab = CredentialTab::Membership; + s.main_page.content_panel.credentials.membership = three(vrc_summary); + s.main_page.content_panel.credentials.mode = CredentialsMode::Detail { index: 1 }; + }, + ); +} + +#[test] +fn vta_keys_are_exactly_its_footer() { + for lifecycle in [ + VicLifecycle::Active, + VicLifecycle::Archived, + VicLifecycle::Deleted, + ] { + keys_match( + &format!("VTA, {lifecycle:?} VIC"), + MainMenu::Vta, + move |s| { + let vta = &mut s.main_page.content_panel.vta; + vta.vics = vec![vic("v1", lifecycle), vic("v2", lifecycle)].into(); + vta.vic_selected_index = 0; + }, + ); + } + keys_match("VTA, no VICs", MainMenu::Vta, |_| {}); +} + +#[test] +fn communities_keys_are_exactly_its_footer() { + keys_match("communities, active", MainMenu::Communities, |s| { + s.main_page.content_panel.communities.items = Arc::from(vec![ + community_summary("A"), + community_summary("B"), + community_summary("C"), + ]); + s.main_page.content_panel.communities.selected_index = 1; + }); + keys_match("communities, inactive", MainMenu::Communities, |s| { + s.main_page.content_panel.communities.items = Arc::from(vec![ + community_summary_with("A", false, true, false), + community_summary_with("B", false, true, false), + ]); + }); + keys_match("communities, pending", MainMenu::Communities, |s| { + s.main_page.content_panel.communities.items = + Arc::from(vec![community_summary_with("A", false, false, true)]); + }); + keys_match("communities, none", MainMenu::Communities, |_| {}); +} + +#[test] +fn settings_logs_help_and_tsp_keys_are_exactly_their_footers() { + keys_match("settings", MainMenu::Settings, |s| { + s.main_page.content_panel.settings.selected_index = 1; + }); + keys_match("settings, protection", MainMenu::Settings, |s| { + s.main_page.content_panel.settings.mode = SettingsMode::ChangeProtection { + selected_option: 0, + passphrase_len: 0, + confirm_len: 0, + active_field: 0, + }; + }); + keys_match("help / status", MainMenu::Help, |_| {}); + keys_match("TSP relationships", MainMenu::TspRelationships, |_| {}); +} + +/// `?` opens the overlay; it lists the focused view's keys and the global +/// ones, from the tables the footers are drawn from; any key closes it. +#[test] +fn question_mark_lists_the_focused_views_keys() { + let (mut page, _rx) = page_for(MainMenu::Inbox, |s| { + s.main_page.content_panel.inbox.tasks = three(task_summary); + }); + let footer = advertised(&page); + page.handle_key_event(press(KeyCode::Char('?'))); + assert!(page.help_open); + let (_, listed) = page.help_bindings(); + assert_eq!(listed, footer); + let drawn = { + use ratatui::{Terminal, backend::TestBackend}; + let mut terminal = Terminal::new(TestBackend::new(140, 60)).expect("test terminal"); + terminal + .draw(|frame| page.render(frame, ())) + .expect("render"); + terminal + .backend() + .buffer() + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + }; + for b in footer.iter().chain(keymap::global().iter()) { + assert!( + drawn.contains(&b.does), + "{} missing from the overlay", + b.does + ); + } + page.handle_key_event(press(KeyCode::Char('x'))); + assert!(!page.help_open, "any key closes the overlay"); +} + +/// A key a view does not take says so in the footer row instead of vanishing. +#[test] +fn an_unused_key_gives_feedback() { + let (mut page, mut rx) = page_for(MainMenu::Inbox, |_| {}); + let _ = advertised(&page); + page.handle_key_event(press(KeyCode::Char('z'))); + assert!(rx.try_recv().is_err(), "nothing is sent"); + let feedback = page.key_feedback.clone().expect("feedback"); + assert!(feedback.contains("z does nothing here"), "{feedback}"); +} + +/// While a text field has the focus, ←/→ and Tab do not leave the panel. +#[test] +fn a_focused_text_field_keeps_the_arrows() { + let (mut page, mut rx) = page_for(MainMenu::Relationships, |s| { + s.main_page.content_panel.relationships.mode = RelationshipsMode::EditAlias { + index: 0, + alias_input: "Al".into(), + }; + s.main_page.content_panel.relationships.relationships = three(rel_summary); + }); + let _ = advertised(&page); + page.handle_key_event(press(KeyCode::Left)); + while let Ok(action) = rx.try_recv() { + assert!( + !matches!(action, Action::MainPanelSwitch(_)), + "← must not switch panels from a text field" + ); + } +} + +#[test] +fn identity_tab_keys_are_exactly_their_footers() { + use super::key_handler_tests::persona; + use crate::state_handler::main_page::content::PersonaTab; + for tab in PersonaTab::all() { + keys_match( + &format!("identity, {tab:?}, empty"), + MainMenu::Identity, + move |s| { + s.main_page.content_panel.identity.tab = tab; + }, + ); + } + keys_match( + "identity, personas, an orphan selected", + MainMenu::Identity, + |s| { + let id = &mut s.main_page.content_panel.identity; + id.personas = vec![ + persona("did:example:a", "A", 1), + persona("did:example:b", "B", 0), + ] + .into(); + id.persona_selected = 1; + }, + ); + keys_match( + "identity, personas, a bound one selected", + MainMenu::Identity, + |s| { + let id = &mut s.main_page.content_panel.identity; + id.personas = vec![ + persona("did:example:a", "A", 1), + persona("did:example:b", "B", 0), + ] + .into(); + id.persona_selected = 0; + }, + ); +} + +#[test] +fn community_sub_views_keys_are_exactly_their_footers() { + use crate::state_handler::main_page::content::{CapabilitiesPhase, CapabilitiesView}; + for failed in [false, true] { + keys_match( + &format!("capabilities, failed: {failed}"), + MainMenu::Communities, + move |s| { + let mut view = CapabilitiesView::new( + "did:example:vtc".into(), + openvtc_core::config::account::PersonaId(uuid::Uuid::nil()), + "Acme".into(), + ); + view.phase = if failed { + CapabilitiesPhase::Failed("x".into()) + } else { + CapabilitiesPhase::Loading + }; + s.main_page.content_panel.capabilities.view = Some(view); + }, + ); + } +} diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index 3619e5b3..08fd0009 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -29,6 +29,9 @@ use tokio::sync::mpsc::UnboundedSender; pub mod components; pub(crate) mod confirm; +pub(crate) mod keymap; +#[cfg(test)] +mod keymap_tests; mod secret_buffer; use secret_buffer::SecretBuffer; @@ -58,6 +61,15 @@ pub struct MainPage { /// Identifier for the active content view (menu + mode). When it changes /// across frames, we reset `content_scroll` so a fresh view starts at top. last_view_id: String, + /// The focused view's keys, as its footer advertised them in the last + /// frame (see [`keymap`]). What `?` lists, and what decides whether an + /// unhandled key earns a hint. + panel_keys: std::cell::RefCell>, + /// The `?` key overlay is open. + help_open: bool, + /// One line of feedback for a key that did nothing, shown in the footer + /// row until the next key. + key_feedback: Option, } /// Number of lines to scroll per PageUp / PageDown press. @@ -93,6 +105,9 @@ impl Component for MainPage { content_scroll: 0, content_scroll_max: Cell::new(0), last_view_id: view_id(&state.main_page), + panel_keys: std::cell::RefCell::new(Vec::new()), + help_open: false, + key_feedback: None, } .move_with_state(state) } @@ -116,6 +131,13 @@ impl Component for MainPage { if key.kind != KeyEventKind::Press { return; } + self.key_feedback = None; + + // The key overlay is read, then dismissed by any key. + if self.help_open { + self.help_open = false; + return; + } // Create-persona overlay: while open it owns all input. if self.props.main_page.create_persona.is_some() { @@ -146,22 +168,41 @@ impl Component for MainPage { return; } - // Content panel key handling (when content panel is focused) + // `?` lists the focused view's keys — unless a text field has the + // focus, where `?` is a character like any other. let content_selected = self.props.main_page.content_panel.selected; + let typing = content_selected && self.typing(); + if key.code == KeyCode::Char('?') && !typing { + self.help_open = true; + return; + } + + // Content panel key handling (when content panel is focused) if content_selected && self.handle_content_key_event(key) { return; } + // A text field has the focus: a key the form did not take goes no + // further — it must not switch panels or reach the menu (#578). + if typing && key.code != KeyCode::F(10) { + self.key_feedback = Some(format!( + "{} does nothing in this field — Esc leaves it, ? lists the keys", + keymap::describe(&key) + )); + return; + } + + let menu_focused = self.props.main_page.menu_panel.selected && !content_selected; match key.code { KeyCode::F(10) => { let _ = self.action_tx.send(Action::Exit); } - KeyCode::Up if self.props.main_page.menu_panel.selected => { + KeyCode::Up | KeyCode::Char('k') if menu_focused => { let _ = self.action_tx.send(Action::MainMenuSelected( self.props.main_page.menu_panel.selected_menu.prev(), )); } - KeyCode::Down if self.props.main_page.menu_panel.selected => { + KeyCode::Down | KeyCode::Char('j') if menu_focused => { let _ = self.action_tx.send(Action::MainMenuSelected( self.props.main_page.menu_panel.selected_menu.next(), )); @@ -180,9 +221,15 @@ impl Component for MainPage { let _ = self .action_tx .send(Action::MainPanelSwitch(MainPanel::ContentPanel)); + } else { + self.key_feedback = Some(Self::unused(&key)); } } - _ => {} + // PgUp/PgDn/Home/End scroll the content panel from wherever the + // focus is; they are consumed above when it has the focus. + KeyCode::PageUp | KeyCode::PageDown | KeyCode::Home | KeyCode::End => {} + // A key that does nothing here says so, rather than vanishing. + _ => self.key_feedback = Some(Self::unused(&key)), } } @@ -449,6 +496,95 @@ fn grant_form_text(form: &crate::state_handler::main_page::content::GrantForm) - } } +// **************************************************************************** +// Key map: `?` overlay and feedback +// **************************************************************************** +impl MainPage { + /// Whether the focused view is a text field taking typing, by its own + /// advertised keys. + fn typing(&self) -> bool { + self.panel_keys + .borrow() + .iter() + .any(keymap::Binding::is_typing) + } + + /// Feedback for a key the focused view does not take. + fn unused(key: &KeyEvent) -> String { + format!( + "{} does nothing here — ? lists this panel's keys", + keymap::describe(key) + ) + } + + /// The keys `?` lists: the focused view's, or the menu's. + fn help_bindings(&self) -> (String, Vec) { + if self.props.main_page.content_panel.selected { + ( + self.props.main_page.menu_panel.selected_menu.to_string(), + self.panel_keys.borrow().clone(), + ) + } else { + ("Menu".to_string(), keymap::menu()) + } + } + + /// The `?` overlay: the focused view's keys, then the keys that work + /// everywhere — drawn from the same tables the footers are. + fn render_help_overlay(&self, frame: &mut Frame) { + use ratatui::layout::Rect; + use ratatui::widgets::Clear; + let (title, panel) = self.help_bindings(); + let global = keymap::global(); + let width_of = |b: &keymap::Binding| b.key.chars().count(); + let key_col = panel + .iter() + .chain(global.iter()) + .map(width_of) + .max() + .unwrap_or(4) + .max(4); + let row = |b: &keymap::Binding| { + Line::from(vec![ + Span::styled( + format!(" {: {} } + // A Ctrl or Alt chord is never a letter (#578): it reaches the view + // only where the view lists that chord as one of its keys. + // + // Before the first frame nothing is advertised yet, and nothing is + // gated. + let advertised = !self.panel_keys.borrow().is_empty(); + if advertised + && key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + && matches!(key.code, KeyCode::Char(_)) + && !self + .panel_keys + .borrow() + .iter() + .any(|b| !b.is_typing() && b.matches(&key)) + { + return false; + } + let menu = self.props.main_page.menu_panel.selected_menu.clone(); match menu { @@ -495,7 +651,8 @@ impl MainPage { MainMenu::Identity => self.handle_personas_key(key), MainMenu::Vta => self.handle_vta_key(key), MainMenu::Vetting => self.handle_vetting_key(key), - _ => false, + MainMenu::TspRelationships => self.handle_tsp_key(key), + MainMenu::Quit => false, } } @@ -799,6 +956,7 @@ fn copy_to_clipboard( // **************************************************************************** impl ComponentRender<()> for MainPage { fn render(&self, frame: &mut Frame, _props: ()) { + keymap::begin_frame(); let [main_top, main_middle, main_log, main_bottom] = Layout::vertical([Length(2), Min(0), Length(8), Length(1)]).areas(frame.area()); @@ -931,6 +1089,7 @@ impl ComponentRender<()> for MainPage { self.content_scroll, ); self.content_scroll_max.set(max_scroll); + let mut keys = keymap::take_advertised(); // Activity log panel let log_block = Block::bordered() @@ -954,25 +1113,31 @@ impl ComponentRender<()> for MainPage { .collect(); frame.render_widget(Paragraph::new(log_lines), log_inner); - // Bottom key hints (single line). A development trust-anchor override - // takes the front of the line for the whole session, so an overridden - // run cannot be mistaken for a normal one. - let hints = " switch panels switch community scroll quit"; - let bottom_line = match &self.props.main_page.dev_override { - Some(banner) => Line::from(vec![ - Span::styled( - format!(" {banner} "), - ratatui::style::Style::default() - .fg(ratatui::style::Color::Black) - .bg(COLOR_WARNING_ACCESSIBLE_RED) - .bold(), - ), - Span::from(hints).dark_gray(), - ]), - None => Line::from(hints).dark_gray(), - }; + // Bottom row: the keys that work everywhere, from the same table `?` + // lists, quit first so it survives a narrow terminal (#576). Feedback + // for a key that did nothing takes the row until the next key. A + // development trust-anchor override takes the front of the line for + // the whole session, so an overridden run cannot be mistaken for a + // normal one. + let mut bottom = Vec::new(); + if let Some(banner) = &self.props.main_page.dev_override { + bottom.push(Span::styled( + format!(" {banner} "), + ratatui::style::Style::default() + .fg(ratatui::style::Color::Black) + .bg(COLOR_WARNING_ACCESSIBLE_RED) + .bold(), + )); + } + match &self.key_feedback { + Some(feedback) => bottom.push(Span::styled( + format!(" {feedback}"), + ratatui::style::Style::default().fg(COLOR_ORANGE), + )), + None => bottom.extend(keymap::line(" ", &keymap::global()).spans), + } frame.render_widget( - Paragraph::new(bottom_line).alignment(Alignment::Left), + Paragraph::new(Line::from(bottom)).alignment(Alignment::Left), main_bottom, ); @@ -992,6 +1157,18 @@ impl ComponentRender<()> for MainPage { if let Some(overlay) = self.props.main_page.agent_names.as_ref() { self.render_agent_name_manager_overlay(frame, overlay); } + + // An overlay owns the keyboard while it is open, so its keys are the + // ones `?` and the feedback go by. + let overlay_keys = keymap::take_advertised(); + if !overlay_keys.is_empty() { + keys = overlay_keys; + } + self.panel_keys.replace(keys); + + if self.help_open { + self.render_help_overlay(frame); + } } } @@ -1058,10 +1235,11 @@ impl MainPage { }) .collect(); lines.push(Line::default()); - lines.push(Line::from(Span::styled( - "↑/↓ select ⏎ switch esc close", - Style::new().fg(COLOR_BORDER), - ))); + lines.push(keymap::overlay_line(&[ + keymap::k("↑/↓", "select"), + keymap::k("Enter", "switch"), + keymap::k("Esc/Ctrl+K", "close"), + ])); frame.render_widget(Paragraph::new(lines).block(block), popup_area); } @@ -1223,15 +1401,24 @@ impl MainPage { ); } else { let hint = match overlay.phase { - AgentNameManagerPhase::Working | AgentNameManagerPhase::Loading => "working…", + AgentNameManagerPhase::Working | AgentNameManagerPhase::Loading => { + Line::from(Span::styled("working…", Style::new().fg(COLOR_BORDER))) + } AgentNameManagerPhase::Ready => { - "⏎ claim ↑/↓ select space park/resume d remove esc close" + let mut keys = vec![ + keymap::k(keymap::TYPE, "a name"), + keymap::k("Enter", "claim"), + keymap::k("↑/↓", "select"), + ]; + if !overlay.names.is_empty() { + keys.push(keymap::k("Space", "park/resume")); + keys.push(keymap::k("d/Del", "remove")); + } + keys.push(keymap::k("Esc", "close")); + keymap::overlay_line(&keys) } }; - lines.push(Line::from(Span::styled( - hint, - Style::new().fg(COLOR_BORDER), - ))); + lines.push(hint); } frame.render_widget(Paragraph::new(lines).block(block), popup_area); @@ -1272,7 +1459,7 @@ impl MainPage { match overlay.phase { AddVicPhase::Input => { lines.push(Line::from(Span::styled( - "Paste an invitation credential (VIC) JSON, then press ⏎:", + "Paste an invitation credential (VIC) JSON, then press Enter:", Style::new().fg(COLOR_TEXT_DEFAULT), ))); // The pasted JSON can be long; show a char count + short preview @@ -1299,10 +1486,11 @@ impl MainPage { Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), ))); } - lines.push(Line::from(Span::styled( - "⏎ store esc cancel", - Style::new().fg(COLOR_BORDER), - ))); + lines.push(keymap::overlay_line(&[ + keymap::k(keymap::TYPE, "paste the VIC"), + keymap::k("Enter", "store"), + keymap::k("Esc", "cancel"), + ])); } AddVicPhase::Working => { for msg in &overlay.messages { @@ -1318,10 +1506,10 @@ impl MainPage { Style::new().fg(COLOR_SUCCESS).bold(), ))); lines.push(Line::default()); - lines.push(Line::from(Span::styled( - "⏎/esc close", - Style::new().fg(COLOR_BORDER), - ))); + lines.push(keymap::overlay_line(&[keymap::k( + "Enter/Esc", + "close (so does any key)", + )])); } AddVicPhase::Failed => { for msg in &overlay.messages { @@ -1331,10 +1519,10 @@ impl MainPage { ))); } lines.push(Line::default()); - lines.push(Line::from(Span::styled( - "⏎/esc close", - Style::new().fg(COLOR_BORDER), - ))); + lines.push(keymap::overlay_line(&[keymap::k( + "Enter/Esc", + "close (so does any key)", + )])); } } @@ -1456,7 +1644,7 @@ mod key_handler_tests { /// Build a focused `MainPage` on the given menu, after `mutate` populates the /// panel state. Returns the page and the receiver to read emitted actions. - fn page_for( + pub(super) fn page_for( menu: MainMenu, mutate: impl FnOnce(&mut State), ) -> (MainPage, UnboundedReceiver) { @@ -1471,23 +1659,23 @@ mod key_handler_tests { } /// A `Press` key event (the only kind `handle_key_event` acts on). - fn press(code: KeyCode) -> KeyEvent { + pub(super) fn press(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) } /// A Ctrl-modified `Press` key event. - fn ctrl(code: KeyCode) -> KeyEvent { + pub(super) fn ctrl(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::CONTROL) } /// A minimal Communities-panel summary row for key-routing tests. - fn community_summary(name: &str) -> CommunitySummary { + pub(super) fn community_summary(name: &str) -> CommunitySummary { community_summary_with(name, true, false, false) } /// A summary with explicit active/inactive/pending status, for the leave/ /// cancel/archive/delete key-gating tests. - fn community_summary_with( + pub(super) fn community_summary_with( name: &str, is_active: bool, is_inactive: bool, @@ -1528,7 +1716,7 @@ mod key_handler_tests { } } - fn switcher_item(name: &str, is_current: bool) -> SwitcherItem { + pub(super) fn switcher_item(name: &str, is_current: bool) -> SwitcherItem { SwitcherItem { vtc_did: format!("did:example:{name}"), persona_ref: openvtc_core::config::account::PersonaId::new(), @@ -1538,7 +1726,7 @@ mod key_handler_tests { } } - fn rel_summary(remote_p_did: &str) -> RelationshipSummary { + pub(super) fn rel_summary(remote_p_did: &str) -> RelationshipSummary { RelationshipSummary { remote_p_did: remote_p_did.to_string(), alias: None, @@ -1554,7 +1742,7 @@ mod key_handler_tests { } } - fn vrc_summary(vrc_id: &str) -> VrcSummary { + pub(super) fn vrc_summary(vrc_id: &str) -> VrcSummary { VrcSummary { vrc_id: vrc_id.to_string(), remote_p_did: "did:example:them".to_string(), @@ -1578,7 +1766,7 @@ mod key_handler_tests { } } - fn task_summary(id: &str) -> TaskSummary { + pub(super) fn task_summary(id: &str) -> TaskSummary { TaskSummary { id: id.to_string(), type_display: "Trust Ping".to_string(), @@ -2786,7 +2974,7 @@ mod key_handler_tests { // ----- Identity pane: personas --------------------------------------------- - fn persona(did: &str, label: &str, bound: usize) -> ManagedDid { + pub(super) fn persona(did: &str, label: &str, bound: usize) -> ManagedDid { ManagedDid { did: did.to_string(), did_locator: None, @@ -3317,7 +3505,7 @@ mod key_handler_tests { "the example shows the outcome of the screen you are on: {drawn}" ); assert!( - drawn.contains("esc: let the server pick"), + drawn.contains("Esc: let the server pick"), "the way out of the editor is the other outcome, not the label: {drawn}" ); } @@ -3749,7 +3937,7 @@ mod key_handler_tests { AddVicPhase, AddVicState, VicLifecycle, VicSummary, }; - fn vic(id: &str, lifecycle: VicLifecycle) -> VicSummary { + pub(super) fn vic(id: &str, lifecycle: VicLifecycle) -> VicSummary { VicSummary { id: id.to_string(), issuer: "did:webvh:example:community".to_string(), @@ -3761,7 +3949,7 @@ mod key_handler_tests { } /// The VIC list with the given rows, selected at index 0. - fn vta_vics(rows: Vec) -> impl Fn(&mut State) { + pub(super) fn vta_vics(rows: Vec) -> impl Fn(&mut State) { move |s: &mut State| { let vta = &mut s.main_page.content_panel.vta; vta.vics = rows.clone().into(); @@ -3786,9 +3974,9 @@ mod key_handler_tests { /// the identity pane there is one list left, and asking for it is the whole /// of what the key does. #[test] - fn vta_tab_asks_for_the_vic_listing() { + fn vta_r_asks_for_the_vic_listing() { let (mut page, mut rx) = page_for(MainMenu::Vta, |_| {}); - page.handle_key_event(press(KeyCode::Tab)); + page.handle_key_event(press(KeyCode::Char('r'))); match rx.try_recv() { Ok(Action::VicRefresh) => {} _ => panic!("expected VicRefresh"), @@ -4286,7 +4474,7 @@ mod key_handler_tests { assert!(text.contains("Example Ballots"), "{text}"); assert!(text.contains("same operator"), "{text}"); assert!(text.contains("prove how you voted"), "{text}"); - assert!(text.contains("v vote"), "{text}"); + assert!(text.contains("v: vote"), "{text}"); let (page, _rx) = page_for(MainMenu::Communities, with_elections(Screen::Ballot)); let text = drawn(&page); From 4d4f77f919e22fe93472c2b9addce4cc229dd93c Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 17:22:48 +1000 Subject: [PATCH 04/11] ux(input): one text-input behaviour for the main page's forms (#578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every string field on the main page (relationship request, alias and witness; credential reason; settings name, export/import path and the typed WIPE/RESET words; vetting text fields; the device-grant form; the context-delete word; the repos forms; ballot text answers) is listed once in ui/pages/main/text_input.rs and edited there through tui_input, the editor the setup and join screens use: - a visible cursor; ←/→, Ctrl+←/→, Home/End move within the field instead of switching panels; Backspace/Del delete either side; Ctrl+A/E/U/W/K edit; no Ctrl or Alt chord types its letter (a chord the view lists as its own, such as Ctrl+A 'add owner', stays the view's) - a paste is inserted at the cursor in every field that takes one (it was appended on the main page); the typed confirmation words take no paste - the masked passphrase length counts characters, not bytes - ballot text answers get a BallotText action so they edit like any field - the append-only edit_text helper and the per-panel paste code go Tests: editor semantics (insert at cursor, arrows, deletes, chords, non-ASCII), cursor drawing, ← then type, paste at Home, Ctrl+U/Ctrl+T. Signed-off-by: Glenn Gore --- openvtc/src/state_handler/actions/mod.rs | 2 + .../src/state_handler/elections_actions.rs | 5 + .../src/state_handler/main_page/elections.rs | 29 + .../main/components/communities_panel.rs | 27 +- .../main/components/credentials_panel.rs | 31 +- .../pages/main/components/elections_panel.rs | 17 +- .../main/components/relationships_panel.rs | 93 ++-- .../ui/pages/main/components/repos_panel.rs | 22 +- .../pages/main/components/settings_panel.rs | 98 ++-- .../ui/pages/main/components/vetting_panel.rs | 12 +- .../main/components/vetting_panel/keys.rs | 5 +- openvtc/src/ui/pages/main/keymap.rs | 15 +- openvtc/src/ui/pages/main/keymap_tests.rs | 82 +++ openvtc/src/ui/pages/main/mod.rs | 309 +++-------- openvtc/src/ui/pages/main/secret_buffer.rs | 4 +- openvtc/src/ui/pages/main/text_input.rs | 494 ++++++++++++++++++ 16 files changed, 832 insertions(+), 413 deletions(-) create mode 100644 openvtc/src/ui/pages/main/text_input.rs diff --git a/openvtc/src/state_handler/actions/mod.rs b/openvtc/src/state_handler/actions/mod.rs index c9296927..9ad68ae0 100644 --- a/openvtc/src/state_handler/actions/mod.rs +++ b/openvtc/src/state_handler/actions/mod.rs @@ -566,6 +566,8 @@ pub enum ElectionsAction { BallotAdjust(i64), /// Ballot: a character typed into a text answer. BallotChar(char), + /// Ballot: the focused text answer, as edited (cursor editing, paste). + BallotText(String), /// Ballot: delete the last character of a text answer. BallotBackspace, /// Ballot: leave the focused (optional) question blank, or answer it again. diff --git a/openvtc/src/state_handler/elections_actions.rs b/openvtc/src/state_handler/elections_actions.rs index b221f161..da4884bd 100644 --- a/openvtc/src/state_handler/elections_actions.rs +++ b/openvtc/src/state_handler/elections_actions.rs @@ -140,6 +140,11 @@ pub(crate) fn reduce(state: &mut State, action: &Act) -> bool { f.type_char(s.manifest(), *c); } } + Act::BallotText(text) => { + if let (Some(f), Some(s)) = (view.form.as_mut(), view.shown.as_ref()) { + f.set_text(s.manifest(), text); + } + } Act::BallotBackspace => { if let Some(f) = view.form.as_mut() { f.backspace(); diff --git a/openvtc/src/state_handler/main_page/elections.rs b/openvtc/src/state_handler/main_page/elections.rs index fd8181cc..62b4ac57 100644 --- a/openvtc/src/state_handler/main_page/elections.rs +++ b/openvtc/src/state_handler/main_page/elections.rs @@ -341,6 +341,35 @@ impl BallotForm { self.error = None; } + /// The focused text answer as edited, cut to its length; control + /// characters are dropped. + pub fn set_text(&mut self, manifest: &Manifest, value: &str) { + let Some(Question::Text { max_length, .. }) = self.focused(manifest) else { + return; + }; + let max = usize::try_from(max_length.get()).unwrap_or(usize::MAX); + if let Some((_, AnswerDraft::Text(text), abstain)) = + self.draft.answers.get_mut(self.question) + { + *text = value + .chars() + .filter(|c| !c.is_control()) + .take(max) + .collect(); + *abstain = false; + } + self.error = None; + } + + /// The focused text answer, if the focused question takes text. + #[must_use] + pub fn text(&self) -> Option<&str> { + match self.draft.answers.get(self.question) { + Some((_, AnswerDraft::Text(text), _)) => Some(text), + _ => None, + } + } + pub fn backspace(&mut self) { if let Some((_, AnswerDraft::Text(text), _)) = self.draft.answers.get_mut(self.question) { text.pop(); diff --git a/openvtc/src/ui/pages/main/components/communities_panel.rs b/openvtc/src/ui/pages/main/components/communities_panel.rs index 6e739468..e717c3b6 100644 --- a/openvtc/src/ui/pages/main/components/communities_panel.rs +++ b/openvtc/src/ui/pages/main/components/communities_panel.rs @@ -575,13 +575,16 @@ fn render_context_delete(lines: &mut Vec>, view: &ContextDeleteVie )) .fg(COLOR_ORANGE), ); - lines.push(Line::from(vec![ - Span::styled(" > ", dim), - Span::styled( - format!("{}▎", view.typed), - Style::new().fg(COLOR_SOFT_PURPLE).bold(), - ), - ])); + lines.push(Line::from( + [Span::styled(" > ", dim)] + .into_iter() + .chain(crate::ui::pages::main::text_input::cursor_spans( + &view.typed, + true, + Style::new().fg(COLOR_SOFT_PURPLE).bold(), + )) + .collect::>(), + )); lines.push(Line::from("")); keymap::footer( lines, @@ -678,10 +681,14 @@ fn render_device_access( }, ), Span::styled(format!("{name:<10}"), text), - Span::styled(value, Style::new().fg(COLOR_SOFT_PURPLE)), ]; - if focused { - spans.push(Span::styled(if typed { "▎" } else { " ←/→" }, dim)); + spans.extend(crate::ui::pages::main::text_input::cursor_spans( + &value, + focused && typed, + Style::new().fg(COLOR_SOFT_PURPLE), + )); + if focused && !typed { + spans.push(Span::styled(" ←/→", dim)); } Line::from(spans) }; diff --git a/openvtc/src/ui/pages/main/components/credentials_panel.rs b/openvtc/src/ui/pages/main/components/credentials_panel.rs index b22b2831..e2a12330 100644 --- a/openvtc/src/ui/pages/main/components/credentials_panel.rs +++ b/openvtc/src/ui/pages/main/components/credentials_panel.rs @@ -16,7 +16,7 @@ use crate::state_handler::{ }; use crate::ui::badges; use crate::ui::pages::main::keymap::{self, k}; -use crate::ui::pages::main::{MainPage, copy_to_clipboard, edit_text}; +use crate::ui::pages::main::{MainPage, copy_to_clipboard}; use crossterm::event::{KeyCode, KeyEvent}; use openvtc_core::display::display_identifier; use ratatui::{ @@ -382,11 +382,19 @@ fn render_new_request( } lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled(" Reason: ", Style::new().fg(COLOR_TEXT_DEFAULT)), - Span::styled(reason_input.to_string(), Style::new().fg(COLOR_SOFT_PURPLE)), - Span::styled("▎", Style::new().fg(COLOR_SUCCESS)), - ])); + lines.push(Line::from( + vec![Span::styled( + " Reason: ", + Style::new().fg(COLOR_TEXT_DEFAULT), + )] + .into_iter() + .chain(crate::ui::pages::main::text_input::cursor_spans( + reason_input, + true, + Style::new().fg(COLOR_SOFT_PURPLE), + )) + .collect::>(), + )); lines.push(Line::from("")); let mut keys = Vec::new(); @@ -483,15 +491,8 @@ impl MainPage { } true } - code => match edit_text(code, reason_input) { - Some(r) => { - let _ = self - .action_tx - .send(Action::Credential(CredentialAction::ReasonUpdate(r))); - true - } - None => false, - }, + // Typing is the field's (`text_input`). + _ => false, } } CredentialsMode::Detail { index } => { diff --git a/openvtc/src/ui/pages/main/components/elections_panel.rs b/openvtc/src/ui/pages/main/components/elections_panel.rs index 0d59e3ff..3aa87687 100644 --- a/openvtc/src/ui/pages/main/components/elections_panel.rs +++ b/openvtc/src/ui/pages/main/components/elections_panel.rs @@ -582,11 +582,17 @@ fn render_question( } } AnswerDraft::Text(t) => { - let caret = if focused.is_some() { "▏" } else { "" }; - lines.push(Line::from(text(format!( - " › {}{caret}", - crate::state_handler::main_page::sanitize_display(t, 4096) - )))); + let shown = crate::state_handler::main_page::sanitize_display(t, 4096); + lines.push(Line::from( + [Span::raw(" › ")] + .into_iter() + .chain(crate::ui::pages::main::text_input::cursor_spans( + &shown, + focused.is_some(), + text(String::new()).style, + )) + .collect::>(), + )); } } } @@ -632,7 +638,6 @@ fn render_ballot(view: &ElectionsView, recent_ticket: bool, lines: &mut Vec>(), + )); lines.push(Line::from("")); for text in [ " Your VTA presents the relationship credential you issued to the witness, which", @@ -613,7 +617,6 @@ fn render_form( for (i, (label, value)) in fields.iter().enumerate() { let is_active = i == active_field; - let cursor = if is_active { "▎" } else { "" }; let field_style = if is_active { Style::new().fg(COLOR_SUCCESS) } else { @@ -625,12 +628,19 @@ fn render_form( Style::new().fg(COLOR_DARK_GRAY) }; - lines.push(Line::from(vec![ - Span::styled(if is_active { "▸ " } else { " " }, field_style), - Span::styled(label.to_string(), field_style), - Span::styled(value.to_string(), value_style), - Span::styled(cursor, Style::new().fg(COLOR_SUCCESS)), - ])); + lines.push(Line::from( + vec![ + Span::styled(if is_active { "▸ " } else { " " }, field_style), + Span::styled(label.to_string(), field_style), + ] + .into_iter() + .chain(crate::ui::pages::main::text_input::cursor_spans( + value, + is_active, + value_style, + )) + .collect::>(), + )); } // R-DID toggle (field index 3) @@ -811,25 +821,6 @@ impl MainPage { )); true } - code if active_field < 3 => { - let current = match active_field { - 0 => did_input, - 1 => alias_input, - _ => reason_input, - }; - match edit_text(code, current) { - Some(value) => { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::InputUpdate { - field: active_field, - value, - }, - )); - true - } - None => false, - } - } _ => false, } } @@ -987,15 +978,8 @@ impl MainPage { )); true } - code => match edit_text(code, witness_input) { - Some(value) => { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::WitnessInputUpdate(value), - )); - true - } - None => false, - }, + // Typing is the field's (`text_input`). + _ => false, } } RelationshipsMode::EditAlias { index, alias_input } => { @@ -1018,15 +1002,8 @@ impl MainPage { } true } - code => match edit_text(code, alias_input) { - Some(value) => { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::EditAliasUpdate(value), - )); - true - } - None => false, - }, + // Typing is the field's (`text_input`). + _ => false, } } RelationshipsMode::List => { diff --git a/openvtc/src/ui/pages/main/components/repos_panel.rs b/openvtc/src/ui/pages/main/components/repos_panel.rs index a584bb61..2e3d04da 100644 --- a/openvtc/src/ui/pages/main/components/repos_panel.rs +++ b/openvtc/src/ui/pages/main/components/repos_panel.rs @@ -1422,16 +1422,20 @@ fn field_label(lines: &mut Vec>, label: &str, focused: bool) { } fn input(lines: &mut Vec>, value: &str, focused: bool, placeholder: &str) { - let shown = if value.is_empty() { - dim(placeholder.to_string()) + let mut spans = vec![Span::raw(" ")]; + if value.is_empty() { + if focused { + spans.push(Span::raw("▏")); + } + spans.push(dim(placeholder.to_string())); } else { - text(value.to_string()) - }; - lines.push(Line::from(vec![ - Span::raw(" "), - shown, - Span::raw(if focused { "▏" } else { "" }), - ])); + spans.extend(crate::ui::pages::main::text_input::cursor_spans( + value, + focused, + text(String::new()).style, + )); + } + lines.push(Line::from(spans)); } fn render_add(lines: &mut Vec>, view: &ReposView, form: &AddPersonForm) { diff --git a/openvtc/src/ui/pages/main/components/settings_panel.rs b/openvtc/src/ui/pages/main/components/settings_panel.rs index bf127d39..51bb50f3 100644 --- a/openvtc/src/ui/pages/main/components/settings_panel.rs +++ b/openvtc/src/ui/pages/main/components/settings_panel.rs @@ -15,9 +15,9 @@ use crate::state_handler::{ }, state::ConnectionState, }; +use crate::ui::pages::main::MainPage; use crate::ui::pages::main::keymap::{self, k}; -use crate::ui::pages::main::{MainPage, edit_text}; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ style::{Style, Stylize}, text::{Line, Span}, @@ -628,11 +628,16 @@ fn render_edit(label: &str, input: &str) -> Vec> { .fg(COLOR_SUCCESS) .bold(), Line::from(""), - Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(input.to_string(), Style::new().fg(COLOR_SOFT_PURPLE)), - Span::styled("▎", Style::new().fg(COLOR_SUCCESS)), - ]), + Line::from( + vec![Span::styled(" ", Style::default())] + .into_iter() + .chain(crate::ui::pages::main::text_input::cursor_spans( + input, + true, + Style::new().fg(COLOR_SOFT_PURPLE), + )) + .collect::>(), + ), Line::from(""), ]; keymap::footer( @@ -671,15 +676,19 @@ fn render_export_form( } else { Style::new().fg(COLOR_TEXT_DEFAULT) }; - lines.push(Line::from(vec![ - Span::styled(if path_active { "▸ " } else { " " }, path_style), - Span::styled("File path: ", path_style), - Span::styled(path_input.to_string(), Style::new().fg(COLOR_SOFT_PURPLE)), - Span::styled( - if path_active { "▎" } else { "" }, - Style::new().fg(COLOR_SUCCESS), - ), - ])); + lines.push(Line::from( + vec![ + Span::styled(if path_active { "▸ " } else { " " }, path_style), + Span::styled("File path: ", path_style), + ] + .into_iter() + .chain(crate::ui::pages::main::text_input::cursor_spans( + path_input, + path_active, + Style::new().fg(COLOR_SOFT_PURPLE), + )) + .collect::>(), + )); // Passphrase field (index 1) — display masked length only let pass_active = active_field == 1; @@ -953,15 +962,8 @@ impl MainPage { })); true } - code => match edit_text(code, ¤t) { - Some(v) => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::FieldUpdate(v))); - true - } - None => false, - }, + // Typing is the field's (`text_input`). + _ => false, } } SettingsMode::ExportConfig { @@ -1082,7 +1084,11 @@ impl MainPage { KeyCode::Backspace => { buffer.pop(); } - KeyCode::Char(c) => { + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { buffer.push(c); } _ => return false, @@ -1227,22 +1233,18 @@ impl MainPage { true } #[cfg(feature = "openpgp-card")] - SettingsMode::TokenResetConfirm { confirm_input, .. } => { - let current = confirm_input.clone(); + SettingsMode::TokenResetConfirm { .. } => { let action = match key.code { // Back to the token screen — nothing is erased. KeyCode::Esc => SettingsAction::TokenManagement, KeyCode::Enter => SettingsAction::TokenFactoryReset, - code => match edit_text(code, ¤t) { - Some(next) => SettingsAction::TokenFactoryResetInput(next), - None => return false, - }, + // Typing is the field's (`text_input`). + _ => return false, }; let _ = self.action_tx.send(Action::Settings(action)); true } - SettingsMode::WipeConfirm { confirm_input } => { - let current = confirm_input.clone(); + SettingsMode::WipeConfirm { .. } => { match key.code { KeyCode::Esc => { // Drop back to the Settings list — wipe is cancelled. @@ -1257,15 +1259,8 @@ impl MainPage { .send(Action::Settings(SettingsAction::WipeProfileConfirm)); true } - code => match edit_text(code, ¤t) { - Some(next) => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::WipeProfileInput(next))); - true - } - None => false, - }, + // Typing is the field's (`text_input`). + _ => false, } } } @@ -1331,15 +1326,8 @@ impl MainPage { } code => { if active == 0 { - match edit_text(code, &path) { - Some(value) => { - let _ = self.action_tx.send(Action::Settings( - SettingsAction::FormFieldUpdate { field: 0, value }, - )); - true - } - None => false, - } + // Typing is the field's (`text_input`). + false } else { let buffer = if active == 1 { &mut self.passphrase_buffer @@ -1350,7 +1338,11 @@ impl MainPage { KeyCode::Backspace => { buffer.pop(); } - KeyCode::Char(c) => { + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { buffer.push(c); } _ => return false, diff --git a/openvtc/src/ui/pages/main/components/vetting_panel.rs b/openvtc/src/ui/pages/main/components/vetting_panel.rs index 53a5b86c..4a4a7030 100644 --- a/openvtc/src/ui/pages/main/components/vetting_panel.rs +++ b/openvtc/src/ui/pages/main/components/vetting_panel.rs @@ -120,11 +120,17 @@ fn field(name: &str, shown: String, focused: bool, text: bool) -> Line<'static> }, ), Span::styled(format!("{name:<22}"), label()), - Span::styled(shown, value()), ]; if focused && text { - spans.push(Span::styled("▎", Style::new().fg(COLOR_SUCCESS))); - } else if focused { + spans.extend(crate::ui::pages::main::text_input::cursor_spans( + &shown, + true, + value(), + )); + } else { + spans.push(Span::styled(shown, value())); + } + if focused && !text { spans.push(Span::styled(" ←/→", dim())); } Line::from(spans) diff --git a/openvtc/src/ui/pages/main/components/vetting_panel/keys.rs b/openvtc/src/ui/pages/main/components/vetting_panel/keys.rs index 0fbcbcc3..7c72ff75 100644 --- a/openvtc/src/ui/pages/main/components/vetting_panel/keys.rs +++ b/openvtc/src/ui/pages/main/components/vetting_panel/keys.rs @@ -4,7 +4,7 @@ use crate::state_handler::actions::Action; use crate::state_handler::main_page::MainPanel; use crate::state_handler::main_page::status::Status; -use crate::ui::pages::main::{MainPage, edit_text}; +use crate::ui::pages::main::MainPage; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; impl MainPage { @@ -94,7 +94,8 @@ impl MainPage { KeyCode::Left => Some(V::Cycle(false)), KeyCode::Right => Some(V::Cycle(true)), code => match text { - Some(current) => edit_text(code, ¤t).map(V::Input), + // Typing is the field's (`text_input`). + Some(_) => None, None if chooses_face && code == KeyCode::Char('f') => Some(V::ChooseFace), None if code == KeyCode::Char('y') && y_commits => Some(V::Submit), None if code == KeyCode::Char('n') && confirming => Some(V::Back), diff --git a/openvtc/src/ui/pages/main/keymap.rs b/openvtc/src/ui/pages/main/keymap.rs index ff24a367..fef8f618 100644 --- a/openvtc/src/ui/pages/main/keymap.rs +++ b/openvtc/src/ui/pages/main/keymap.rs @@ -72,16 +72,10 @@ impl Binding { } } -/// Whether `key` is a keystroke a text field takes: a character without Ctrl -/// or Alt, or Backspace. +/// Whether `key` is a keystroke a text field takes: a character, Backspace/Del, +/// ←/→, Home/End, or an editing chord (see `text_input`). pub(crate) fn is_text_edit(key: &KeyEvent) -> bool { - match key.code { - KeyCode::Char(_) => !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT), - KeyCode::Backspace => true, - _ => false, - } + super::text_input::is_edit_key(key) } /// Parse a key label into the keys it stands for. Alternatives are separated @@ -354,7 +348,8 @@ mod tests { let t = k(TYPE, "edit"); assert!(t.matches(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE))); assert!(t.matches(&KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT))); - assert!(!t.matches(&KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL))); + assert!(t.matches(&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE))); + assert!(!t.matches(&KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL))); assert!(!t.matches(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))); } } diff --git a/openvtc/src/ui/pages/main/keymap_tests.rs b/openvtc/src/ui/pages/main/keymap_tests.rs index 9bdfd383..09a6e178 100644 --- a/openvtc/src/ui/pages/main/keymap_tests.rs +++ b/openvtc/src/ui/pages/main/keymap_tests.rs @@ -378,3 +378,85 @@ fn community_sub_views_keys_are_exactly_their_footers() { ); } } + +// ----- One text-input behaviour (#578) ---------------------------------- + +fn alias_form(s: &mut State) { + s.main_page.content_panel.relationships.relationships = three(rel_summary); + s.main_page.content_panel.relationships.mode = RelationshipsMode::EditAlias { + index: 0, + alias_input: "Al".into(), + }; +} + +fn alias_sent(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Option { + let mut last = None; + while let Ok(action) = rx.try_recv() { + if let Action::Relationship( + crate::state_handler::actions::RelationshipAction::EditAliasUpdate(v), + ) = action + { + last = Some(v); + } + } + last +} + +/// ← moves the cursor inside the field, and typing inserts there. +#[test] +fn arrows_move_the_cursor_and_typing_inserts_at_it() { + let (mut page, mut rx) = page_for(MainMenu::Relationships, alias_form); + let _ = advertised(&page); + page.handle_key_event(press(KeyCode::Left)); + assert_eq!(alias_sent(&mut rx), None, "a move sends nothing"); + page.handle_key_event(press(KeyCode::Char('x'))); + assert_eq!(alias_sent(&mut rx).as_deref(), Some("Axl")); +} + +/// A paste lands at the cursor, as typing does. +#[test] +fn a_paste_is_inserted_at_the_cursor() { + let (mut page, mut rx) = page_for(MainMenu::Relationships, alias_form); + let _ = advertised(&page); + page.handle_key_event(press(KeyCode::Home)); + page.handle_paste_event("Big"); + assert_eq!(alias_sent(&mut rx).as_deref(), Some("BigAl")); +} + +/// A Ctrl chord edits or does nothing; it never types its letter. +#[test] +fn ctrl_chords_never_type_their_letter() { + let (mut page, mut rx) = page_for(MainMenu::Relationships, alias_form); + let _ = advertised(&page); + page.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + assert_eq!( + alias_sent(&mut rx).as_deref(), + Some(""), + "Ctrl+U clears the line" + ); + let (mut page, mut rx) = page_for(MainMenu::Relationships, alias_form); + let _ = advertised(&page); + page.handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)); + assert_eq!(alias_sent(&mut rx), None, "Ctrl+T is not a letter"); +} + +/// The cursor is drawn where it is. +#[test] +fn the_cursor_is_drawn_in_the_field() { + let (mut page, _rx) = page_for(MainMenu::Relationships, alias_form); + let _ = advertised(&page); + page.handle_key_event(press(KeyCode::Left)); + use ratatui::{Terminal, backend::TestBackend}; + let mut terminal = Terminal::new(TestBackend::new(140, 60)).expect("test terminal"); + terminal + .draw(|frame| page.render(frame, ())) + .expect("render"); + let drawn: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect(); + assert!(drawn.contains("A▏l"), "{drawn}"); +} diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index 08fd0009..9684ab01 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -4,7 +4,7 @@ use crate::colors::{ }; use crate::{ state_handler::{ - actions::{Action, CredentialAction, RelationshipAction, SettingsAction}, + actions::{Action, SettingsAction}, main_page::{MainPageState, MainPanel, menu::MainMenu}, state::{ConnectionState, MediatorStatus, State}, }, @@ -33,6 +33,7 @@ pub(crate) mod keymap; #[cfg(test)] mod keymap_tests; mod secret_buffer; +pub(crate) mod text_input; use secret_buffer::SecretBuffer; @@ -70,6 +71,9 @@ pub struct MainPage { /// One line of feedback for a key that did nothing, shown in the footer /// row until the next key. key_feedback: Option, + /// The focused text field's cursor: the field, the text it held after + /// the last edit, and the cursor (in characters). See [`text_input`]. + field_cursor: Option<(String, String, usize)>, } /// Number of lines to scroll per PageUp / PageDown press. @@ -108,6 +112,7 @@ impl Component for MainPage { panel_keys: std::cell::RefCell::new(Vec::new()), help_open: false, key_feedback: None, + field_cursor: None, } .move_with_state(state) } @@ -234,9 +239,7 @@ impl Component for MainPage { } fn handle_paste_event(&mut self, text: &str) { - use crate::state_handler::main_page::content::{ - CredentialsMode, RelationshipsMode, SettingsMode, - }; + use crate::state_handler::main_page::content::SettingsMode; // The add-VIC overlay owns paste while open — the operator pastes the VIC // JSON straight into the import field (checked before the panel guard). @@ -254,98 +257,33 @@ impl Component for MainPage { let menu = self.props.main_page.menu_panel.selected_menu.clone(); let trimmed = text.trim(); - match menu { - MainMenu::Relationships => { - if let RelationshipsMode::EditAlias { alias_input, .. } = - &self.props.main_page.content_panel.relationships.mode - { - let updated = format!("{}{}", alias_input, trimmed); - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::EditAliasUpdate(updated), - )); - return; - } - if let RelationshipsMode::NewRequest { - did_input, - alias_input, - reason_input, - active_field, - .. - } = &self.props.main_page.content_panel.relationships.mode - { - // Paste into the currently active field - let current = match active_field { - 0 => format!("{}{}", did_input, trimmed), - 1 => format!("{}{}", alias_input, trimmed), - 2 => format!("{}{}", reason_input, trimmed), - _ => return, - }; - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::InputUpdate { - field: *active_field, - value: current, - }, - )); - } - } - MainMenu::Credentials => { - if let CredentialsMode::NewRequest { reason_input, .. } = - &self.props.main_page.content_panel.credentials.mode - { - let updated = format!("{}{}", reason_input, trimmed); - let _ = self - .action_tx - .send(Action::Credential(CredentialAction::ReasonUpdate(updated))); - } - } - MainMenu::Vetting => { - let mode = &self.props.main_page.content_panel.vetting.mode; - // A ticket link pasted into the request form fills the whole - // form, whichever field has the focus. - if matches!( - mode, - crate::state_handler::main_page::content::VettingMode::RequestVetter { .. } - ) && trimmed.to_ascii_lowercase().starts_with("vetting-ticket:") - { - let _ = self.action_tx.send(Action::Vetting( - crate::state_handler::actions::VettingAction::PasteTicket( - trimmed.to_string(), - ), - )); - } else if let Some(current) = mode.focused_text() { - let updated = format!("{current}{trimmed}"); - let _ = self.action_tx.send(Action::Vetting( - crate::state_handler::actions::VettingAction::Input(updated), - )); - } - } - MainMenu::Settings => { + // A ticket link pasted into the request form fills the whole form, + // whichever field has the focus. + if menu == MainMenu::Vetting + && matches!( + self.props.main_page.content_panel.vetting.mode, + crate::state_handler::main_page::content::VettingMode::RequestVetter { .. } + ) + && trimmed.to_ascii_lowercase().starts_with("vetting-ticket:") + { + let _ = self.action_tx.send(Action::Vetting( + crate::state_handler::actions::VettingAction::PasteTicket(trimmed.to_string()), + )); + return; + } + // Every other text field: inserted at the cursor (#578). + if self.paste_into_focused_field(trimmed) { + return; + } + + // Passphrase fields: the secure buffers take the paste. The path + // field is a focused text field, handled above. + if menu == MainMenu::Settings { + { match &self.props.main_page.content_panel.settings.mode { - SettingsMode::EditFriendlyName { input } => { - let updated = format!("{}{}", input, trimmed); - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::FieldUpdate(updated))); - } - SettingsMode::ExportConfig { - path_input, - active_field, - .. - } - | SettingsMode::ImportConfig { - path_input, - active_field, - .. - } => { - if *active_field == 0 { - let updated = format!("{}{}", path_input, trimmed); - let _ = self.action_tx.send(Action::Settings( - SettingsAction::FormFieldUpdate { - field: 0, - value: updated, - }, - )); - } else if *active_field == 1 { + SettingsMode::ExportConfig { active_field, .. } + | SettingsMode::ImportConfig { active_field, .. } => { + if *active_field == 1 { // Passphrase field — append to secure buffer self.passphrase_buffer.push_str(trimmed); let _ = self.action_tx.send(Action::Settings( @@ -377,116 +315,10 @@ impl Component for MainPage { _ => {} } } - // A pasted did:key lands in the grant form's focused text field. The - // deletion confirmation takes no paste: typing it is the point. - MainMenu::Communities => { - // A text answer on a ballot takes the paste, character by character. - if let Some(view) = self.props.main_page.content_panel.elections.view.as_ref() { - let typing = view.screen - == crate::state_handler::main_page::elections::Screen::Ballot - && view - .form - .as_ref() - .zip(view.shown.as_ref()) - .is_some_and(|(f, s)| f.typing(s.manifest())); - if typing { - for c in trimmed.chars() { - let _ = self.action_tx.send(Action::Elections( - crate::state_handler::actions::ElectionsAction::BallotChar(c), - )); - } - } - return; - } - if let Some(view) = self.props.main_page.content_panel.repos.view.as_ref() { - if let Some(action) = repos_paste(view, trimmed) { - let _ = self.action_tx.send(Action::Repos(action)); - } - return; - } - if let Some(form) = self - .props - .main_page - .content_panel - .communities - .device_access - .as_ref() - .filter(|v| !v.busy) - .and_then(|v| v.form.as_ref()) - .filter(|f| f.field < 2) - { - let _ = self.action_tx.send(Action::CommunityContext( - crate::state_handler::actions::CommunityContextAction::GrantInput { - field: form.field, - value: format!("{}{trimmed}", grant_form_text(form)), - }, - )); - } - } - _ => {} } } } -/// Paste into the Repos view's focused text field, if one is open. -fn repos_paste( - view: &crate::state_handler::main_page::repos::ReposView, - text: &str, -) -> Option { - use crate::state_handler::actions::ReposAction as R; - use crate::state_handler::main_page::repos::ReposScreen; - if view.confirm.is_some() { - return None; - } - { - use crate::state_handler::actions::WorkspaceAction as W; - use crate::state_handler::main_page::repos::WorkspaceForm; - let text = text.trim_end_matches(['\n', '\r']); - match &view.workspace.form { - Some(WorkspaceForm::Settings { root, .. }) => { - return Some(R::Workspace(W::SettingsInput(format!("{root}{text}")))); - } - Some(WorkspaceForm::UsePath { path, .. }) => { - return Some(R::Workspace(W::UseInput(format!("{path}{text}")))); - } - Some(WorkspaceForm::Account(form)) => { - return form - .typing() - .then(|| R::Workspace(W::AccountInput(format!("{}{text}", form.path)))); - } - None => {} - } - } - if let Some(form) = &view.add { - return match form.field { - 0 => Some(R::AddInput { - field: 0, - value: format!("{}{text}", form.query), - }), - 3 => Some(R::AddInput { - field: 3, - value: format!("{}{text}", form.reason), - }), - _ => None, - }; - } - if let ReposScreen::NewRepo(form) = &view.screen { - return match form.field { - 1 => Some(R::NewInput { - field: 1, - value: format!("{}{text}", form.name), - }), - 3 => Some(R::NewInput { - field: 3, - value: format!("{}{text}", form.description), - }), - 4 => Some(R::NewOwnerQuery(format!("{}{text}", form.owner_query))), - _ => None, - }; - } - None -} - /// The grant form's focused text field: the DID, or the device's name. fn grant_form_text(form: &crate::state_handler::main_page::content::GrantForm) -> &str { if form.field == 0 { @@ -592,6 +424,23 @@ impl MainPage { /// Handle key events when the content panel is focused. /// Returns true if the event was consumed. fn handle_content_key_event(&mut self, key: KeyEvent) -> bool { + // A focused text field takes its editing keys — characters, ←/→, + // Home/End, Backspace/Del and the editing chords — before anything + // else, so ←/→ move the cursor instead of switching panels (#578). A + // Ctrl or Alt chord the view lists as its own stays the view's. + let chord = key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT); + let listed = chord + && self + .panel_keys + .borrow() + .iter() + .any(|b| !b.is_typing() && b.matches(&key)); + if !listed && self.edit_focused_field(&key) { + return true; + } + // Vertical scroll for the content panel is handled centrally here, // before per-menu dispatch, so it works uniformly on every view // without conflicting with Up/Down selection bindings. @@ -786,26 +635,6 @@ impl MainPage { } } -/// Apply one text-editing keystroke to `current`, returning the new value when -/// the key edits the buffer, or `None` otherwise. Append-only semantics -/// (printable char appends at the end; Backspace removes the last char) — these -/// inline editors have no cursor, matching the prior behavior exactly. -fn edit_text(code: KeyCode, current: &str) -> Option { - match code { - KeyCode::Char(c) => { - let mut s = current.to_string(); - s.push(c); - Some(s) - } - KeyCode::Backspace => { - let mut s = current.to_string(); - s.pop(); - Some(s) - } - _ => None, - } -} - /// Stable identifier for the currently-displayed content view, derived from /// the active menu and its per-panel mode. When this changes across frames /// (e.g. list → detail, or switching menus), the content panel scroll offset @@ -957,6 +786,7 @@ fn copy_to_clipboard( impl ComponentRender<()> for MainPage { fn render(&self, frame: &mut Frame, _props: ()) { keymap::begin_frame(); + text_input::set_cursor(self.focused_field().map(|f| self.field_cursor(&f))); let [main_top, main_middle, main_log, main_bottom] = Layout::vertical([Length(2), Min(0), Length(8), Length(1)]).areas(frame.area()); @@ -1631,7 +1461,9 @@ mod key_handler_tests { //! `Action` and its sub-enums derive neither `PartialEq` nor `Debug`, so //! assertions pattern-match the expected variant rather than `assert_eq!`. use super::*; - use crate::state_handler::actions::{InboxAction, PersonaAction}; + use crate::state_handler::actions::{ + CredentialAction, InboxAction, PersonaAction, RelationshipAction, + }; use crate::state_handler::main_page::content::{ CommunitySend, CommunitySummary, CommunitySwitcherState, CredentialTab, CredentialsMode, InboxConfirm, ManagedDid, PersonaConfirm, PersonaMode, PersonaTab, RawCredential, @@ -1944,11 +1776,14 @@ mod key_handler_tests { // Opened ready, so the focus is on the name; move to the claim row. page.handle_key_event(press(KeyCode::Down)); assert!(matches!(vetting_action(&mut rx), V::NextField)); - page.handle_key_event(press(KeyCode::Right)); - assert!(matches!(vetting_action(&mut rx), V::Cycle(true))); + // The page's props are not re-derived here: put the focus where the + // loop would have. On the name, → moves the cursor (#578); on the + // claim row it chooses. if let VettingMode::NewFace(form) = &mut page.props.main_page.content_panel.vetting.mode { form.focus = NewFaceFocus::Asked(0); } + page.handle_key_event(press(KeyCode::Right)); + assert!(matches!(vetting_action(&mut rx), V::Cycle(true))); page.handle_key_event(press(KeyCode::Char(' '))); assert!(matches!(vetting_action(&mut rx), V::Toggle)); } @@ -2565,10 +2400,6 @@ mod key_handler_tests { repos_key(press(KeyCode::Esc), &form), Some(R::Workspace(W::Cancel)) ); - assert_eq!( - repos_paste(&form, "c/x\n"), - Some(R::Workspace(W::SettingsInput("~/src/x".into()))) - ); let mut armed = repos_view(); armed.workspace.confirm = Some(WorkspaceChange::RemoveIdentity); @@ -2736,23 +2567,6 @@ mod key_handler_tests { } /// A pasted DID lands in the add form's person field. - #[test] - fn a_paste_fills_the_repos_form() { - use crate::state_handler::actions::ReposAction as R; - use crate::state_handler::main_page::repos::AddPersonForm; - let mut v = repos_view(); - let mut form = AddPersonForm::new("github.com/acme/widgets".into()); - form.external = true; - v.add = Some(form); - assert_eq!( - repos_paste(&v, "did:webvh:dan"), - Some(R::AddInput { - field: 0, - value: "did:webvh:dan".into() - }) - ); - } - #[test] fn communities_leave_archive_delete_are_status_gated() { // Active row: `l` arms a leave; `d`/`x` do nothing (must leave first). @@ -4446,10 +4260,13 @@ mod key_handler_tests { f.question = 1; } }); + // Drawn first: the ballot's footer lists Ctrl-a, which is what makes + // the chord the ballot's rather than the text field's. + let _ = drawn(&page); page.handle_key_event(press(KeyCode::Char('a'))); assert!(matches!( elections_action(&mut rx), - Some(E::BallotChar('a')) + Some(E::BallotText(t)) if t == "a" )); page.handle_key_event(ctrl(KeyCode::Char('a'))); assert!(matches!(elections_action(&mut rx), Some(E::BallotAbstain))); diff --git a/openvtc/src/ui/pages/main/secret_buffer.rs b/openvtc/src/ui/pages/main/secret_buffer.rs index 87245b5f..33dd462d 100644 --- a/openvtc/src/ui/pages/main/secret_buffer.rs +++ b/openvtc/src/ui/pages/main/secret_buffer.rs @@ -51,8 +51,10 @@ impl SecretBuffer { } /// Length in bytes. + /// Characters typed, for the masked display — not bytes, so a + /// non-ASCII passphrase shows one `*` per character (#578). pub(crate) fn len(&self) -> usize { - self.0.len() + self.0.chars().count() } /// The text typed so far. diff --git a/openvtc/src/ui/pages/main/text_input.rs b/openvtc/src/ui/pages/main/text_input.rs new file mode 100644 index 00000000..6465804d --- /dev/null +++ b/openvtc/src/ui/pages/main/text_input.rs @@ -0,0 +1,494 @@ +//! One text-input behaviour for the main page's forms (#578). +//! +//! The main page's forms keep their text in `State` as plain strings and set +//! it through actions. They used to edit it append-only: no cursor, ←/→ fell +//! through to the panel switch, and a Ctrl chord typed its letter. Every such +//! field is now edited here, through `tui_input` — the same editor the setup +//! and join screens use — with a cursor kept on the page: +//! +//! - typing inserts at the cursor; Backspace/Del delete either side of it; +//! - ←/→ move within the field, Ctrl+←/→ by word, Home/End (and Ctrl+A/E) to +//! either end; Ctrl+U, Ctrl+W and Ctrl+K delete the line, the word before +//! and the rest of the line; +//! - a paste is inserted at the cursor, wherever a field takes paste; +//! - keys the field does not take go to the form (Enter, Esc, Tab, ↑/↓), and +//! a key the view lists as its own chord (Ctrl+A on a field that "adds an +//! owner") is the view's. +//! +//! [`MainPage::focused_field`] is the single list of the main page's string +//! fields; the cursor is drawn by [`cursor_spans`]. + +use crossterm::event::{Event, KeyEvent}; +use ratatui::{style::Style, text::Span}; +use std::cell::Cell; +use tui_input::{Input, backend::crossterm::EventHandler}; + +use crate::state_handler::actions::{ + Action, CommunityContextAction, CredentialAction, ElectionsAction, RelationshipAction, + ReposAction, SettingsAction, VettingAction, WorkspaceAction, +}; +use crate::state_handler::main_page::content::{ + ContextDeletePhase, CredentialsMode, RelationshipsMode, SettingsMode, +}; +use crate::state_handler::main_page::menu::MainMenu; +use crate::ui::pages::main::MainPage; + +/// The focused text field: what it holds and how to set it. +pub(crate) struct Field { + /// Which field, so the cursor is not carried from one to another. + pub id: String, + pub value: String, + /// Whether a paste is accepted. A typed confirmation word is not: typing + /// it is the point. + pub paste: bool, + /// Backspace in the empty field belongs to the form (it removes the last + /// owner chip on the new-repository form). + form_backspace: bool, + set: Box Action>, +} + +impl Field { + fn new(id: impl Into, value: &str, set: impl Fn(String) -> Action + 'static) -> Self { + Self { + id: id.into(), + value: value.to_string(), + paste: true, + form_backspace: false, + set: Box::new(set), + } + } + + fn form_backspace(mut self) -> Self { + self.form_backspace = true; + self + } + + fn no_paste(mut self) -> Self { + self.paste = false; + self + } + + /// The action that sets the field to `value`. + pub(crate) fn set(&self, value: String) -> Action { + (self.set)(value) + } +} + +thread_local! { + /// The focused field's cursor (in characters) for this frame, recorded by + /// the page before the panels render — carried like the status wrap width. + static CURSOR: Cell> = const { Cell::new(None) }; +} + +/// Record the focused field's cursor for this frame. +pub(crate) fn set_cursor(cursor: Option) { + CURSOR.set(cursor); +} + +/// A field's text with the cursor drawn where it is, when the field has the +/// focus; the text alone otherwise. +pub(crate) fn cursor_spans(value: &str, focused: bool, style: Style) -> Vec> { + if !focused { + return vec![Span::styled(value.to_string(), style)]; + } + let chars = value.chars().count(); + let at = CURSOR.get().unwrap_or(chars).min(chars); + let split = value.char_indices().nth(at).map_or(value.len(), |(i, _)| i); + vec![ + Span::styled(value[..split].to_string(), style), + Span::styled("▏", style), + Span::styled(value[split..].to_string(), style), + ] +} + +/// What `key` does to `value` with the cursor at `cursor`: the new value and +/// cursor, or `None` when the key is not an editing key. +pub(crate) fn apply(value: &str, cursor: usize, key: &KeyEvent) -> Option<(String, usize)> { + if !is_edit_key(key) { + return None; + } + let mut input = Input::new(value.to_string()).with_cursor(cursor); + // `None` here is an edit that changes nothing (→ at the end). + let _ = input.handle_event(&Event::Key(*key)); + Some((input.value().to_string(), input.cursor())) +} + +/// Whether `key` is one the editor takes. +pub(crate) fn is_edit_key(key: &KeyEvent) -> bool { + tui_input::backend::crossterm::to_input_request(&Event::Key(*key)).is_some() +} + +impl MainPage { + /// The text field that has the focus on the main page, if any — the one + /// list of the page's string fields. + pub(crate) fn focused_field(&self) -> Option { + let page = &self.props.main_page; + if !page.content_panel.selected + || page.create_persona.is_some() + || page.agent_names.is_some() + || page.add_vic.is_some() + || page.switcher.is_some() + { + return None; + } + let content = &page.content_panel; + match page.menu_panel.selected_menu { + MainMenu::Relationships => match &content.relationships.mode { + RelationshipsMode::NewRequest { + did_input, + alias_input, + reason_input, + active_field, + .. + } => { + let field = *active_field; + let value = match field { + 0 => did_input, + 1 => alias_input, + 2 => reason_input, + _ => return None, + }; + Some(Field::new(format!("rel-new:{field}"), value, move |v| { + Action::Relationship(RelationshipAction::InputUpdate { field, value: v }) + })) + } + RelationshipsMode::EditAlias { alias_input, .. } => { + Some(Field::new("rel-alias", alias_input, |v| { + Action::Relationship(RelationshipAction::EditAliasUpdate(v)) + })) + } + RelationshipsMode::Witness { witness_input, .. } => { + Some(Field::new("rel-witness", witness_input, |v| { + Action::Relationship(RelationshipAction::WitnessInputUpdate(v)) + })) + } + _ => None, + }, + MainMenu::Credentials => match &content.credentials.mode { + CredentialsMode::NewRequest { reason_input, .. } + if content + .relationships + .relationships + .iter() + .any(|r| r.state == "Established") => + { + Some(Field::new("cred-reason", reason_input, |v| { + Action::Credential(CredentialAction::ReasonUpdate(v)) + })) + } + _ => None, + }, + MainMenu::Settings => match &content.settings.mode { + SettingsMode::EditFriendlyName { input } => { + Some(Field::new("settings-name", input, |v| { + Action::Settings(SettingsAction::FieldUpdate(v)) + })) + } + SettingsMode::ExportConfig { + path_input, + active_field: 0, + .. + } + | SettingsMode::ImportConfig { + path_input, + active_field: 0, + .. + } => Some(Field::new("settings-path", path_input, |v| { + Action::Settings(SettingsAction::FormFieldUpdate { field: 0, value: v }) + })), + SettingsMode::WipeConfirm { confirm_input } => Some( + Field::new("settings-wipe", confirm_input, |v| { + Action::Settings(SettingsAction::WipeProfileInput(v)) + }) + .no_paste(), + ), + #[cfg(feature = "openpgp-card")] + SettingsMode::TokenResetConfirm { confirm_input, .. } => Some( + Field::new("settings-reset", confirm_input, |v| { + Action::Settings(SettingsAction::TokenFactoryResetInput(v)) + }) + .no_paste(), + ), + _ => None, + }, + MainMenu::Vetting => { + let vetting = &content.vetting; + let text = vetting.mode.focused_text()?; + Some(Field::new( + format!("vetting:{}", super::view_id(page)), + text, + |v| Action::Vetting(VettingAction::Input(v)), + )) + } + MainMenu::Communities => self.communities_field(), + _ => None, + } + } + + /// The Communities page's text fields, in the order its views take the + /// keyboard. + fn communities_field(&self) -> Option { + let content = &self.props.main_page.content_panel; + if content.capabilities.view.is_some() { + return None; + } + if let Some(view) = &content.repos.view { + return repos_field(view); + } + if content.rooms.view.is_some() { + return None; + } + if let Some(view) = &content.elections.view { + let form = view.form.as_ref()?; + let shown = view.shown.as_ref()?; + if view.screen != crate::state_handler::main_page::elections::Screen::Ballot + || !form.typing(shown.manifest()) + { + return None; + } + return Some(Field::new( + format!("ballot:{}", form.question), + form.text().unwrap_or_default(), + |v| Action::Elections(ElectionsAction::BallotText(v)), + )); + } + if content.communities.personhood_evidence.is_some() { + return None; + } + if let Some(view) = &content.communities.context_delete { + return matches!(view.phase, ContextDeletePhase::Ready(_)).then(|| { + Field::new("context-delete", &view.typed, |v| { + Action::CommunityContext(CommunityContextAction::DeleteInput(v)) + }) + .no_paste() + }); + } + let view = content.communities.device_access.as_ref()?; + let form = view.form.as_ref().filter(|f| f.field < 2 && !view.busy)?; + let field = form.field; + Some(Field::new( + format!("grant:{field}"), + super::grant_form_text(form), + move |v| { + Action::CommunityContext(CommunityContextAction::GrantInput { field, value: v }) + }, + )) + } + + /// The focused field's cursor: kept while the same field holds the same + /// text it held after the last edit, else at its end. + pub(crate) fn field_cursor(&self, field: &Field) -> usize { + let end = field.value.chars().count(); + match &self.field_cursor { + Some((id, value, at)) if *id == field.id && *value == field.value => (*at).min(end), + _ => end, + } + } + + /// Apply an editing key to the focused field. `true` when the field took + /// the key. + pub(crate) fn edit_focused_field(&mut self, key: &KeyEvent) -> bool { + let Some(field) = self.focused_field() else { + return false; + }; + if !is_edit_key(key) { + return false; + } + let cursor = self.field_cursor(&field); + let Some((value, at)) = apply(&field.value, cursor, key) else { + return false; + }; + if value == field.value && at == cursor { + // Nothing to do at this edge (→ at the end, Backspace at the + // start): the field still took the key — unless the form has a + // use for Backspace in its empty field. + return !(field.form_backspace + && field.value.is_empty() + && key.code == crossterm::event::KeyCode::Backspace); + } + if value != field.value { + let _ = self.action_tx.send(field.set(value.clone())); + } + self.field_cursor = Some((field.id, value, at)); + true + } + + /// Insert a paste at the focused field's cursor. `true` when a field + /// took it. + pub(crate) fn paste_into_focused_field(&mut self, text: &str) -> bool { + let Some(field) = self.focused_field() else { + return false; + }; + if !field.paste { + return true; + } + let cursor = self.field_cursor(&field); + let split = field + .value + .char_indices() + .nth(cursor) + .map_or(field.value.len(), |(i, _)| i); + let pasted: String = text.chars().filter(|c| !c.is_control()).collect(); + let mut value = field.value.clone(); + value.insert_str(split, &pasted); + let at = cursor + pasted.chars().count(); + let _ = self.action_tx.send(field.set(value.clone())); + self.field_cursor = Some((field.id, value, at)); + true + } +} + +/// The Repos view's focused text field. +fn repos_field(view: &crate::state_handler::main_page::repos::ReposView) -> Option { + use crate::state_handler::main_page::repos::{ReposScreen, WorkspaceForm}; + use ReposAction as R; + use WorkspaceAction as W; + if view.confirm.is_some() || view.workspace.confirm.is_some() { + return None; + } + match &view.workspace.form { + Some(WorkspaceForm::Settings { root, .. }) => { + return Some(Field::new("repos-root", root, |v| { + Action::Repos(R::Workspace(W::SettingsInput(v))) + })); + } + Some(WorkspaceForm::UsePath { path, .. }) => { + return Some(Field::new("repos-use", path, |v| { + Action::Repos(R::Workspace(W::UseInput(v))) + })); + } + Some(WorkspaceForm::Account(form)) => { + return form.typing().then(|| { + Field::new("repos-account", &form.path, |v| { + Action::Repos(R::Workspace(W::AccountInput(v))) + }) + }); + } + None => {} + } + if let Some(form) = &view.add { + let field = form.field; + return match field { + 0 => Some(Field::new("repos-add:0", &form.query, |v| { + Action::Repos(R::AddInput { field: 0, value: v }) + })), + 3 => Some(Field::new("repos-add:3", &form.reason, |v| { + Action::Repos(R::AddInput { field: 3, value: v }) + })), + _ => None, + }; + } + if let ReposScreen::NewRepo(form) = &view.screen { + return match form.field { + 1 => Some(Field::new("repos-new:1", &form.name, |v| { + Action::Repos(R::NewInput { field: 1, value: v }) + })), + 3 => Some(Field::new("repos-new:3", &form.description, |v| { + Action::Repos(R::NewInput { field: 3, value: v }) + })), + 4 => Some( + Field::new("repos-new:4", &form.owner_query, |v| { + Action::Repos(R::NewOwnerQuery(v)) + }) + .form_backspace(), + ), + _ => None, + }; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyModifiers}; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn ctrl(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) + } + + #[test] + fn typing_inserts_at_the_cursor() { + assert_eq!( + apply("did:example", 4, &key(KeyCode::Char('X'))), + Some(("did:Xexample".into(), 5)) + ); + } + + #[test] + fn arrows_move_within_the_field() { + assert_eq!( + apply("abc", 3, &key(KeyCode::Left)), + Some(("abc".into(), 2)) + ); + assert_eq!( + apply("abc", 0, &key(KeyCode::Right)), + Some(("abc".into(), 1)) + ); + assert_eq!( + apply("abc", 2, &key(KeyCode::Home)), + Some(("abc".into(), 0)) + ); + assert_eq!(apply("abc", 0, &key(KeyCode::End)), Some(("abc".into(), 3))); + } + + #[test] + fn deletes_either_side_of_the_cursor() { + assert_eq!( + apply("abc", 1, &key(KeyCode::Backspace)), + Some(("bc".into(), 0)) + ); + assert_eq!( + apply("abc", 1, &key(KeyCode::Delete)), + Some(("ac".into(), 1)) + ); + } + + /// Ctrl chords edit; they never type their letter. + #[test] + fn ctrl_chords_edit_and_never_insert_a_letter() { + assert_eq!( + apply("hello world", 11, &ctrl('w')), + Some(("hello ".into(), 6)) + ); + assert_eq!( + apply("hello", 5, &ctrl('u')).map(|(v, _)| v), + Some(String::new()) + ); + assert_eq!(apply("hello", 5, &ctrl('a')), Some(("hello".into(), 0))); + assert_eq!(apply("hello", 0, &ctrl('e')), Some(("hello".into(), 5))); + for c in ['t', 'v', 'x', 'z'] { + let edited = apply("hello", 5, &ctrl(c)); + assert!( + edited.is_none_or(|(v, _)| v == "hello"), + "Ctrl+{c} must not insert" + ); + } + } + + #[test] + fn non_ascii_text_is_edited_by_character() { + assert_eq!( + apply("héllo", 2, &key(KeyCode::Backspace)), + Some(("hllo".into(), 1)) + ); + } + + #[test] + fn the_cursor_is_drawn_where_it_is() { + set_cursor(Some(2)); + let spans = cursor_spans("abcd", true, Style::default()); + let drawn: String = spans.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(drawn, "ab▏cd"); + let unfocused: String = cursor_spans("abcd", false, Style::default()) + .iter() + .map(|s| s.content.as_ref()) + .collect(); + assert_eq!(unfocused, "abcd"); + set_cursor(None); + } +} From 3fff335440bfb11b8cd9dbf94a09b28efc4090f9 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 17:25:16 +1000 Subject: [PATCH 05/11] ux(layout): nothing essential is clipped at 80x24 (#576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the activity log takes 4 rows instead of 8 below 32 rows, so all twelve menu items fit at 24 rows; the menu also scrolls to keep the selection on screen on anything shorter - the menu is at least 23 columns wide (its longest item and a badge), so 'My Relationships', 'TSP Relationships', 'My Credentials' and 'Help / Status' are no longer cut at 80 columns - the footer row is built from the global key table with F10 quit and ? first, and keeps only whole hints that fit - setup step 1 (Run the PNM command): the actions-and-commands body scrolls with ↑/↓ and PgUp/PgDn, the footer says so when it can, and the copy result takes the footer row instead of the end of the body - tests: 80x24 TestBackend renders of the main page (every menu item and 'F10: quit' visible) and setup step 1 (scrolls to the F4 command; copy result in the footer row) Signed-off-by: Glenn Gore --- .../ui/pages/main/components/menu_panel.rs | 11 ++ openvtc/src/ui/pages/main/keymap_tests.rs | 58 ++++++++ openvtc/src/ui/pages/main/mod.rs | 24 +++- .../pages/setup_flow/vta_acl_instructions.rs | 129 ++++++++++++++---- 4 files changed, 190 insertions(+), 32 deletions(-) diff --git a/openvtc/src/ui/pages/main/components/menu_panel.rs b/openvtc/src/ui/pages/main/components/menu_panel.rs index feb58807..cf81bd68 100644 --- a/openvtc/src/ui/pages/main/components/menu_panel.rs +++ b/openvtc/src/ui/pages/main/components/menu_panel.rs @@ -12,6 +12,9 @@ use ratatui::{ }; use strum::IntoEnumIterator; +/// Columns the menu needs: its longest item, a marker and the border. +pub const MENU_WIDTH: u16 = 23; + // **************************************************************************** // Render the Main Menu panel // **************************************************************************** @@ -56,10 +59,18 @@ impl MenuPanelState { } } + // Scroll so the selection is always on screen when the menu is + // taller than its panel (#576). + let inner = menu_block.inner(rect).height as usize; + let selected = MainMenu::iter() + .position(|m| m == self.selected_menu) + .unwrap_or(0); + let offset = selected.saturating_sub(inner.saturating_sub(1)); frame.render_widget( Paragraph::new(lines) .dark_gray() .alignment(Alignment::Left) + .scroll((offset as u16, 0)) .block(menu_block), rect, ); diff --git a/openvtc/src/ui/pages/main/keymap_tests.rs b/openvtc/src/ui/pages/main/keymap_tests.rs index 09a6e178..052811ed 100644 --- a/openvtc/src/ui/pages/main/keymap_tests.rs +++ b/openvtc/src/ui/pages/main/keymap_tests.rs @@ -460,3 +460,61 @@ fn the_cursor_is_drawn_in_the_field() { .collect(); assert!(drawn.contains("A▏l"), "{drawn}"); } + +// ----- 80x24 (#576) ------------------------------------------------------ + +fn screen(page: &MainPage, width: u16, height: u16) -> Vec { + use ratatui::{Terminal, backend::TestBackend}; + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("test terminal"); + terminal + .draw(|frame| page.render(frame, ())) + .expect("render"); + terminal + .backend() + .buffer() + .content() + .chunks(width as usize) + .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect()) + .collect() +} + +/// At the default terminal size every menu item is drawn whole — Quit +/// included — and the footer row keeps the quit and help hints. +#[test] +fn at_80x24_the_menu_and_the_quit_hint_are_whole() { + let (page, _rx) = page_for(MainMenu::Communities, |s| { + s.main_page.content_panel.selected = false; + s.main_page.menu_panel.selected = true; + }); + let rows = screen(&page, 80, 24); + let all = rows.join("\n"); + for item in [ + "Communities", + "My Relationships", + "TSP Relationships", + "My Credentials", + "Help / Status", + "Quit", + ] { + assert!(all.contains(item), "{item} is cut at 80x24:\n{all}"); + } + let footer = rows.last().expect("a footer row"); + assert!(footer.contains("F10: quit"), "{footer}"); + assert!(footer.contains("?: keys"), "{footer}"); + assert!( + !footer.trim_end().ends_with('·'), + "the footer keeps whole hints: {footer}" + ); +} + +/// With the Quit item selected on a short terminal, the menu scrolls so the +/// selection is on screen. +#[test] +fn a_short_terminal_scrolls_the_menu_to_the_selection() { + let (page, _rx) = page_for(MainMenu::Quit, |s| { + s.main_page.content_panel.selected = false; + s.main_page.menu_panel.selected = true; + }); + let all = screen(&page, 80, 16).join("\n"); + assert!(all.contains("Quit"), "{all}"); +} diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index 9684ab01..52804f86 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -787,12 +787,18 @@ impl ComponentRender<()> for MainPage { fn render(&self, frame: &mut Frame, _props: ()) { keymap::begin_frame(); text_input::set_cursor(self.focused_field().map(|f| self.field_cursor(&f))); + // On a short terminal the activity log gives up rows so the whole + // menu fits; on a narrow one the menu keeps the width its longest + // item needs (#576). + let area = frame.area(); + let log_rows = if area.height < 32 { 4 } else { 8 }; let [main_top, main_middle, main_log, main_bottom] = - Layout::vertical([Length(2), Min(0), Length(8), Length(1)]).areas(frame.area()); + Layout::vertical([Length(2), Min(0), Length(log_rows), Length(1)]).areas(area); let top = Layout::horizontal([Percentage(35), Percentage(30), Percentage(35)]).split(main_top); - let middle = Layout::horizontal([Percentage(20), Min(0)]).split(main_middle); + let menu_width = components::menu_panel::MENU_WIDTH.max(area.width / 5); + let middle = Layout::horizontal([Length(menu_width), Min(0)]).split(main_middle); // Top-left: the working community name with a ▾ switcher affordance // (R-C-7a), or the dashboard title when there's no active community. @@ -964,7 +970,19 @@ impl ComponentRender<()> for MainPage { format!(" {feedback}"), ratatui::style::Style::default().fg(COLOR_ORANGE), )), - None => bottom.extend(keymap::line(" ", &keymap::global()).spans), + None => { + // Quit and `?` lead, and the rest is kept only while it fits, + // so the row is never cut off mid-hint. + let mut keys = Vec::new(); + for b in keymap::global() { + keys.push(b); + if keymap::line(" ", &keys).width() > usize::from(main_bottom.width) { + keys.pop(); + break; + } + } + bottom.extend(keymap::line(" ", &keys).spans); + } } frame.render_widget( Paragraph::new(Line::from(bottom)).alignment(Alignment::Left), diff --git a/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs b/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs index a1e21b5d..6c7d99b1 100644 --- a/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs +++ b/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs @@ -103,6 +103,11 @@ pub struct VtaAclInstructions { /// The transport to force, or `None` for the runner's automatic choice. /// Only ever one the VTA advertises: `[TAB]` cycles through those. pub transport: Option, + /// How far the actions-and-commands body is scrolled. On a 24-row + /// terminal the commands do not fit below the intro (#576). + pub body_scroll: u16, + /// The furthest the body can scroll, from the last frame. + pub body_scroll_max: std::cell::Cell, } /// A failed step 2, as carried back to step 1. @@ -140,6 +145,8 @@ impl Default for VtaAclInstructions { copy_status: None, retry_reason: None, transport: None, + body_scroll: 0, + body_scroll_max: std::cell::Cell::new(0), } } } @@ -201,6 +208,20 @@ impl VtaAclInstructions { Err(e) => CopyStatus::Failed(e), }); } + // The body scrolls; the context id is one line, so ↑/↓ are free. + KeyCode::PageDown | KeyCode::Down => { + let page = &mut state.vta_acl_instructions; + let step = if key.code == KeyCode::PageDown { 5 } else { 1 }; + page.body_scroll = page + .body_scroll + .saturating_add(step) + .min(page.body_scroll_max.get()); + } + KeyCode::PageUp | KeyCode::Up => { + let page = &mut state.vta_acl_instructions; + let step = if key.code == KeyCode::PageUp { 5 } else { 1 }; + page.body_scroll = page.body_scroll.saturating_sub(step); + } KeyCode::Tab => { let advertised = &state.props.state.vta.advertised; if offers_choice(advertised) { @@ -251,7 +272,8 @@ impl VtaAclInstructions { let commands = PnmCommands::build(state, self.context_id.value()); - let area = middle.inner(Margin::new(3, 2)); + // A short terminal gives the margins back to the body. + let area = middle.inner(Margin::new(3, if middle.height < 24 { 1 } else { 2 })); let banner = self .retry_reason .as_ref() @@ -365,23 +387,55 @@ impl VtaAclInstructions { .retry_reason .as_ref() .is_some_and(|r| matches!(r.failure, ProvisionFailure::GrantSpent(_))); + let body = self.body_lines(&commands, regrant); + let max_scroll = wrapped_height(&body, rest.width).saturating_sub(rest.height); + self.body_scroll_max.set(max_scroll); + let offset = self.body_scroll.min(max_scroll); frame.render_widget( - Paragraph::new(self.body_lines(&commands, regrant)).wrap(Wrap { trim: false }), + Paragraph::new(body) + .wrap(Wrap { trim: false }) + .scroll((offset, 0)), rest, ); + // The copy result takes the footer row, where it is seen whatever the + // body's scroll; the keys come back with the next key. let key = |k: &'static str| Span::styled(k, Style::new().fg(COLOR_BORDER).bold()); let text = |t: &'static str| Span::styled(t, Style::new().fg(COLOR_TEXT_DEFAULT)); - let bottom_line = Line::from(vec![ - key("[F2-F4]"), - text(" copy | "), - key("[ESC]"), - text(" reset context | "), - key("[ENTER]"), - text(" I've run it | "), - key("[F10]"), - text(" quit"), - ]); + let bottom_line = match &self.copy_status { + Some(CopyStatus::Copied(method)) => Line::styled( + format!("✓ Copied via {method}. Now run it in your PNM session."), + Style::new().fg(COLOR_SUCCESS).bold(), + ), + Some(CopyStatus::Failed(reason)) => Line::styled( + format!("Could not copy to clipboard: {reason}"), + Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), + ), + // Quit first; when the body scrolls, the labels shorten so the + // scroll hint fits an 80-column terminal too. + None if max_scroll > 0 => Line::from(vec![ + key("[F10]"), + text(" quit · "), + key("[ENTER]"), + text(" done · "), + key("[F2-F4]"), + text(" copy · "), + key("[PgUp/PgDn]"), + text(" scroll · "), + key("[ESC]"), + text(" reset"), + ]), + None => Line::from(vec![ + key("[F10]"), + text(" quit | "), + key("[F2-F4]"), + text(" copy | "), + key("[ENTER]"), + text(" I've run it | "), + key("[ESC]"), + text(" reset context"), + ]), + }; frame.render_widget( Paragraph::new(bottom_line).block(Block::new().padding(Padding::new(2, 0, 1, 0))), bottom, @@ -482,23 +536,6 @@ impl VtaAclInstructions { " ", )); - match &self.copy_status { - Some(CopyStatus::Copied(method)) => { - lines.push(Line::default()); - lines.push(Line::styled( - format!("✓ Copied via {method}. Now run it in your PNM session."), - Style::new().fg(COLOR_SUCCESS).bold(), - )); - } - Some(CopyStatus::Failed(reason)) => { - lines.push(Line::default()); - lines.push(Line::styled( - format!("Could not copy to clipboard: {reason}"), - Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), - )); - } - None => {} - } lines } } @@ -983,6 +1020,40 @@ mod tests { assert!(text.contains("3. When PNM prints"), "{text}"); } + /// At 80x24 the commands do not fit under the intro: the body scrolls to + /// the last of them (F4's), and the footer keeps quit and says so (#576). + #[test] + fn at_80x24_the_body_scrolls_to_every_command() { + let mut page = VtaAclInstructions::default(); + let first = rows(&page, 80, 24).join("\n"); + assert!(first.contains("[F10] quit"), "{first}"); + assert!(first.contains("[PgUp/PgDn] scroll"), "{first}"); + let max = page.body_scroll_max.get(); + assert!(max > 0, "the body is taller than its space at 80x24"); + page.body_scroll = max; + let last = rows(&page, 80, 24).join("\n"); + assert!(last.contains("F4"), "the last command is reachable: {last}"); + } + + /// The copy result takes the footer row, where it is seen whatever the + /// body's scroll. + #[test] + fn the_copy_result_is_in_the_footer_row() { + let page = VtaAclInstructions { + copy_status: Some(CopyStatus::Copied("system clipboard".into())), + ..Default::default() + }; + let rows = rows(&page, 80, 24); + let footer = rows + .iter() + .rev() + .take(3) + .cloned() + .collect::>() + .join("\n"); + assert!(footer.contains("Copied via system clipboard"), "{footer}"); + } + fn press(flow: &mut SetupFlow, code: KeyCode) { VtaAclInstructions::handle_key_event( flow, From 46bde20e4c9511f844a9c598afd44ff322519bc2 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 17:27:17 +1000 Subject: [PATCH 06/11] ux(notifications): inbound-only Inbox badge, badges for arrivals, ping results (#584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the Inbox badge counts what was sent to the user (requests, issued credentials, information, vetting), not their own outbound requests or pings; Communities shows its actions-required count and Vetting the requests on the desk, as badges of their own - activity-log entries carry a severity: errors (log_error) and warnings are drawn in their colour with a glyph (✗ / !), routine lines stay dim - a pong is reported in the Relationships panel ('Pong from X (N ms) — they are reachable.'), not only in the log; a ping with no pong in 30 s says so there ('No reply to the ping after 30 s …'), is logged as a warning, and its outbound task is expired so it does not sit in the Inbox - tests: badge counts inbound only; log keeps each entry's severity Signed-off-by: Glenn Gore --- openvtc/src/state_handler/main_page/mod.rs | 104 +++++++++++++++++- openvtc/src/state_handler/mod.rs | 37 +++++++ .../ui/pages/main/components/menu_panel.rs | 22 ++-- openvtc/src/ui/pages/main/mod.rs | 29 ++++- 4 files changed, 175 insertions(+), 17 deletions(-) diff --git a/openvtc/src/state_handler/main_page/mod.rs b/openvtc/src/state_handler/main_page/mod.rs index d1982315..11798644 100644 --- a/openvtc/src/state_handler/main_page/mod.rs +++ b/openvtc/src/state_handler/main_page/mod.rs @@ -45,6 +45,9 @@ const MAX_ACTIVITY_LOG_ENTRIES: usize = 100; pub struct ActivityLogEntry { /// Short summary shown in the list view (includes timestamp). pub summary: String, + /// How the entry reads: an error is drawn as one, in colour and with a + /// glyph, so it does not look like the routine lines around it (#584). + pub severity: status::Severity, /// Detailed information shown when the entry is expanded. /// Includes DIDComm message details, DID addresses, etc. pub detail: Option, @@ -121,21 +124,33 @@ impl MainPageState { /// Push a timestamped entry to the activity log (O(1) bounded insertion). pub fn log(&mut self, message: impl Into) { - self.log_detailed_inner(message.into(), None); + self.log_detailed_inner(message.into(), None, status::Severity::Info); + } + + /// Push a timestamped entry that something did not happen and why — not + /// an error, but not routine either. + pub fn log_warning(&mut self, message: impl Into) { + self.log_detailed_inner(message.into(), None, status::Severity::Warning); } /// Push a timestamped entry with detailed diagnostic info. pub fn log_detailed(&mut self, message: impl Into, detail: impl Into) { - self.log_detailed_inner(message.into(), Some(detail.into())); + self.log_detailed_inner(message.into(), Some(detail.into()), status::Severity::Info); } - fn log_detailed_inner(&mut self, message: String, detail: Option) { + fn log_detailed_inner( + &mut self, + message: String, + detail: Option, + severity: status::Severity, + ) { if self.activity_log.len() >= MAX_ACTIVITY_LOG_ENTRIES { self.activity_log.pop_front(); } let timestamp = chrono::Local::now().format("%H:%M:%S"); self.activity_log.push_back(Arc::new(ActivityLogEntry { summary: format!("[{}] {}", timestamp, message), + severity, detail, })); } @@ -151,7 +166,47 @@ impl MainPageState { let context = context.into(); let summary = format!("{context}: {err}"); let detail = format_error_detail(&context, err); - self.log_detailed_inner(summary, Some(detail)); + self.log_detailed_inner(summary, Some(detail), status::Severity::Error); + } +} + +/// What each menu item has waiting for the user, for its badge (#584). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct MenuBadges { + /// Inbound inbox items: requests and credentials sent to us. Our own + /// outbound requests and pings are not counted — they wait on someone + /// else. + pub inbox: usize, + /// Communities with something to acknowledge or act on. + pub communities: usize, + /// Vetting requests on our desk. + pub vetting: usize, +} + +impl MainPageState { + /// The menu's badges, from the panels' own state. + #[must_use] + pub fn menu_badges(&self) -> MenuBadges { + use content::TaskKind; + let inbox = self + .content_panel + .inbox + .tasks + .iter() + .filter(|t| { + !matches!( + t.kind, + TaskKind::RelationshipRequestOutbound { .. } + | TaskKind::VRCRequestOutbound + | TaskKind::TrustPing + ) + }) + .count(); + MenuBadges { + inbox, + communities: self.content_panel.communities.actions_required, + vetting: self.content_panel.vetting.desk.len(), + } } } @@ -3231,4 +3286,45 @@ mod tests { "activity log entries must be shared by pointer after clone" ); } + /// The Inbox badge counts what was sent to us, not our own pings and + /// requests waiting on someone else (#584). + #[test] + fn the_inbox_badge_counts_inbound_items_only() { + use crate::state_handler::main_page::content::{TaskKind, TaskSummary}; + let task = |kind: TaskKind| TaskSummary { + id: "t".into(), + type_display: String::new(), + kind, + remote_did: String::new(), + remote_agent_name: None, + created: String::new(), + }; + let mut page = MainPageState::default(); + page.content_panel.inbox.tasks = std::sync::Arc::from(vec![ + task(TaskKind::TrustPing), + task(TaskKind::VRCRequestOutbound), + task(TaskKind::VRCIssued), + ]); + assert_eq!(page.menu_badges().inbox, 1); + page.content_panel.communities.actions_required = 2; + assert_eq!(page.menu_badges().communities, 2); + } + + /// An error in the activity log is recorded as one. + #[test] + fn the_activity_log_keeps_each_entrys_severity() { + let mut page = MainPageState::default(); + page.log("routine"); + page.log_warning("nothing happened"); + page.log_error("Ping failed", "timed out"); + let severities: Vec<_> = page.activity_log.iter().map(|e| e.severity).collect(); + assert_eq!( + severities, + vec![ + status::Severity::Info, + status::Severity::Warning, + status::Severity::Error + ] + ); + } } diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index ebb704bb..197f907b 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -1914,6 +1914,14 @@ impl StateHandler { let latency_str = ms .map(|v| format!(" ({v}ms)")) .unwrap_or_default(); + // The answer belongs where the ping was sent, not only + // in the log (#584). + if !sender_did.is_empty() { + state.main_page.content_panel.relationships.status_message = + Some(Status::success(format!( + "Pong from {sender_display}{latency_str} — they are reachable." + ))); + } state.main_page.log_detailed( format!("Pong from {sender_display}{latency_str}"), format!( @@ -2332,6 +2340,31 @@ impl StateHandler { awaiting_wallet = None; join_entry = Some(entry); } + // A ping has a reply window too (#584): past it, say so in + // the panel that sent it and expire the outbound task, so + // the Inbox does not keep a ping nobody will answer. + if ping_sent_at.is_some_and(|t| t.elapsed() > PING_REPLY_WINDOW) { + ping_sent_at = None; + let cutoff = chrono::Utc::now() + - chrono::Duration::from_std(PING_REPLY_WINDOW).unwrap_or_default(); + let before = config.private.tasks.tasks.len(); + config.private.tasks.tasks.retain(|_, t| { + !(matches!(t.type_, openvtc_core::tasks::TaskType::TrustPing { .. }) + && t.created < cutoff) + }); + if config.private.tasks.tasks.len() != before { + save.mark_dirty(); + state.main_page.sync_from_config(&config); + } + let line = format!( + "No reply to the ping after {} s — they may be offline, or not \ + listening on that DID.", + PING_REPLY_WINDOW.as_secs() + ); + state.main_page.content_panel.relationships.status_message = + Some(Status::warning(line.clone())); + state.main_page.log_warning(line); + } // R4.3: a capability query has a defined reply window. The // send was fire-and-forget; if no correlated reply arrived, // fail the view closed with a distinct, actionable message. @@ -5213,6 +5246,10 @@ async fn install_persona_listener( // credential_actions::dispatch // settings_actions::dispatch +/// How long a trust-ping waits for its pong before the panel says there was +/// none and the outbound task is expired (#584). +const PING_REPLY_WINDOW: std::time::Duration = std::time::Duration::from_secs(30); + /// Build a DIDComm trust-pong message in response to a verified ping. /// Used inline by the trust-ping handler in the main loop. fn build_trust_pong( diff --git a/openvtc/src/ui/pages/main/components/menu_panel.rs b/openvtc/src/ui/pages/main/components/menu_panel.rs index cf81bd68..3e3187e7 100644 --- a/openvtc/src/ui/pages/main/components/menu_panel.rs +++ b/openvtc/src/ui/pages/main/components/menu_panel.rs @@ -20,7 +20,12 @@ pub const MENU_WIDTH: u16 = 23; // **************************************************************************** impl MenuPanelState { /// Render the main menu based on current state - pub fn render(&self, frame: &mut Frame, rect: Rect, inbox_task_count: usize) { + pub fn render( + &self, + frame: &mut Frame, + rect: Rect, + badges: &crate::state_handler::main_page::MenuBadges, + ) { // The surrounding block for the menu let menu_block = if self.selected { @@ -45,13 +50,16 @@ impl MenuPanelState { COLOR_TEXT_DEFAULT }; - if item == MainMenu::Inbox && inbox_task_count > 0 { + let badge = match item { + MainMenu::Inbox => badges.inbox, + MainMenu::Communities => badges.communities, + MainMenu::Vetting => badges.vetting, + _ => 0, + }; + if badge > 0 { lines.push(Line::from(vec![ - Span::styled("* Inbox ", base_color), - Span::styled( - format!("({})", inbox_task_count), - COLOR_WARNING_ACCESSIBLE_RED, - ), + Span::styled(format!("* {item} "), base_color), + Span::styled(format!("({badge})"), COLOR_WARNING_ACCESSIBLE_RED), ])); } else { lines diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index 52804f86..c852d2ed 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -907,11 +907,11 @@ impl ComponentRender<()> for MainPage { let content_area = if journey_open { main_middle } else { - let inbox_task_count = self.props.main_page.content_panel.inbox.tasks.len(); - self.props - .main_page - .menu_panel - .render(frame, middle[0], inbox_task_count); + self.props.main_page.menu_panel.render( + frame, + middle[0], + &self.props.main_page.menu_badges(), + ); middle[1] }; let max_scroll = self.props.main_page.content_panel.render( @@ -942,10 +942,27 @@ impl ComponentRender<()> for MainPage { } else { 0 }; + // Each entry in its severity's colour, with a glyph so an error is + // told apart without colour too (#584). let log_lines: Vec = log .iter() .skip(skip) - .map(|entry| Line::from(entry.summary.clone()).dark_gray()) + .map(|entry| { + use crate::state_handler::main_page::status::Severity; + let (glyph, style) = match entry.severity { + Severity::Error => ("✗ ", components::status::severity_style(Severity::Error)), + Severity::Warning => { + ("! ", components::status::severity_style(Severity::Warning)) + } + Severity::Success => { + ("✓ ", components::status::severity_style(Severity::Success)) + } + Severity::Info | Severity::Progress => { + (" ", ratatui::style::Style::default().fg(COLOR_DARK_GRAY)) + } + }; + Line::from(Span::styled(format!("{glyph}{}", entry.summary), style)) + }) .collect(); frame.render_widget(Paragraph::new(log_lines), log_inner); From da8e96cc4842f43e93cfb7b40073e24776a63200 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 17:40:14 +1000 Subject: [PATCH 07/11] ux(connectivity): classify the header's failure and offer a retry (#585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MediatorStatus::Failed carries a ConnectionFailure { reason, class } with the typed openvtc_core::errors::ErrorClass; ReconnectOutcome::Failed carries the class too (class_of the listener error; a listener that never connects is a Timeout) - the header shows the class ('✗ mediator unreachable', 'mediator refused us', 'mediator incompatible', 'mediator not answering', …) and 'see Help' instead of 17 characters of error text; Help / Status shows the class, the full reason and the class's remedy, and r there reconnects - SettingsAction::ReconnectMediator is live: Enter on the Settings mediator row ('[Enter to reconnect]') and r on Help / Status; its #[allow(dead_code)] is gone - 'Messaging unavailable — cannot X right now.' now names the class and the remedy, and points at the retry - the identity pane's agent errors are typed (AgentError { text, class }): the holder-grant hint is offered only for a refusal (AuthRejected / Refused) whose text names the holder credential, never for an unreachable agent; other failures show their class's remedy - tests: header per class, Help / Status cause + r, mediator row Enter Signed-off-by: Glenn Gore --- openvtc-core/src/didcomm.rs | 19 +++++- openvtc/src/state_handler/actions/mod.rs | 5 +- .../src/state_handler/background_dispatch.rs | 22 +++++-- .../src/state_handler/main_page/content.rs | 37 ++++++++++- openvtc/src/state_handler/mod.rs | 18 ++++-- .../src/state_handler/persona_actions/jobs.rs | 19 +++--- .../state_handler/persona_actions/outcome.rs | 37 +++++++---- openvtc/src/state_handler/runtime_actions.rs | 50 ++++++++++----- openvtc/src/state_handler/state.rs | 54 +++++++++++++++- openvtc/src/ui/pages/loading/mod.rs | 12 +++- .../ui/pages/main/components/content_panel.rs | 37 ++++++++--- .../pages/main/components/identity_panel.rs | 39 ++++++++---- .../ui/pages/main/components/inbox_panel.rs | 12 ++-- .../pages/main/components/settings_panel.rs | 12 +++- openvtc/src/ui/pages/main/keymap_tests.rs | 61 +++++++++++++++++++ openvtc/src/ui/pages/main/mod.rs | 25 +++++--- 16 files changed, 361 insertions(+), 98 deletions(-) diff --git a/openvtc-core/src/didcomm.rs b/openvtc-core/src/didcomm.rs index 835de071..65f05a56 100644 --- a/openvtc-core/src/didcomm.rs +++ b/openvtc-core/src/didcomm.rs @@ -1078,7 +1078,12 @@ pub const DIDCOMM_EVENT_CHANNEL_CAPACITY: usize = 256; /// without this helper having to know about the outer state shape. pub enum ReconnectOutcome { Connected, - Failed(String), + /// The reconnect failed: why, and which class of failure (R6.4) — so the + /// caller can tell an unreachable mediator from a refused one. + Failed { + reason: String, + class: crate::errors::ErrorClass, + }, } /// Replace the persona listener and wait for it to come up. Used by the @@ -1113,14 +1118,22 @@ pub async fn reconnect_persona_listener_io( // failure this codebase has already been bitten by (#132). service.remove_listener(&listener_id).await; if let Err(e) = add_listener(service, &new_config).await { - return ReconnectOutcome::Failed(format!("{e:#}")); + return ReconnectOutcome::Failed { + reason: format!("{e:#}"), + class: crate::errors::class_of(&e), + }; } match service .wait_connected(&listener_id, std::time::Duration::from_secs(30)) .await { Ok(()) => ReconnectOutcome::Connected, - Err(e) => ReconnectOutcome::Failed(e), + // The listener never came up: from here that is a mediator that did + // not answer in time, whatever its last state. + Err(e) => ReconnectOutcome::Failed { + reason: e, + class: crate::errors::ErrorClass::Timeout, + }, } } diff --git a/openvtc/src/state_handler/actions/mod.rs b/openvtc/src/state_handler/actions/mod.rs index 9ad68ae0..af2b2df3 100644 --- a/openvtc/src/state_handler/actions/mod.rs +++ b/openvtc/src/state_handler/actions/mod.rs @@ -214,9 +214,8 @@ pub enum SettingsAction { passphrase: SecretString, }, RemovePassphrase, - // Manual mediator-reconnect path: handler is live, but the Settings UI does - // not yet expose a control that emits it. - #[allow(dead_code)] + /// Reconnect messaging: Enter on the Settings mediator row, or `r` on + /// Help / Status. ReconnectMediator, /// Open the wipe-profile confirmation dialog from the Settings menu. WipeProfileStart, diff --git a/openvtc/src/state_handler/background_dispatch.rs b/openvtc/src/state_handler/background_dispatch.rs index cae8824c..47e4d8fc 100644 --- a/openvtc/src/state_handler/background_dispatch.rs +++ b/openvtc/src/state_handler/background_dispatch.rs @@ -538,9 +538,15 @@ pub(crate) fn apply_outcome( state.connection.messaging_active = true; state.main_page.log("Reconnected to mediator"); } - ReconnectOutcome::Failed(reason) => { - state.connection.status = state::MediatorStatus::Failed(reason.clone()); - state.main_page.log(format!("Reconnect failed: {reason}")); + ReconnectOutcome::Failed { reason, class } => { + let failure = state::ConnectionFailure::new(reason, class); + state.main_page.log_warning(format!( + "Reconnect failed ({}): {} {}", + failure.short(), + failure.reason, + class.remedy() + )); + state.connection.status = state::MediatorStatus::Failed(failure); } }, DispatchOutcome::Relationship(outcome) => outcome.apply(state, config, save), @@ -797,11 +803,17 @@ mod tests { &mut config, &mut save, &mut in_flight, - DispatchOutcome::MediatorReconnect(ReconnectOutcome::Failed("dead mediator".into())), + DispatchOutcome::MediatorReconnect(ReconnectOutcome::Failed { + reason: "dead mediator".into(), + class: openvtc_core::errors::ErrorClass::Unreachable, + }), ); match &state.connection.status { - state::MediatorStatus::Failed(reason) => assert_eq!(reason, "dead mediator"), + state::MediatorStatus::Failed(f) => { + assert_eq!(f.reason, "dead mediator"); + assert_eq!(f.short(), "mediator unreachable"); + } other => panic!("expected Failed status, got {other:?}"), } assert!(!state.connection.messaging_active); diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index 8a0ec2ee..621ab248 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -1451,6 +1451,41 @@ impl ComposeForm { } } +/// A failed read from the agent: what it said, and which class of failure +/// it was (R6.4) — an unreachable agent and a refusing one need different +/// answers on screen (#585). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AgentError { + pub text: String, + pub class: openvtc_core::errors::ErrorClass, +} + +impl AgentError { + #[must_use] + pub fn new(text: impl Into, class: openvtc_core::errors::ErrorClass) -> Self { + Self { + text: text.into(), + class, + } + } + + /// An error, classified by its source chain. + #[must_use] + pub fn of(err: impl Into) -> Self { + let err = err.into(); + Self::new( + format!("{err}"), + openvtc_core::errors::class_of(err.as_ref()), + ) + } +} + +impl std::fmt::Display for AgentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.text) + } +} + /// The persona pane: every surface for the holder's own identity, in one place. /// /// Five of the six tabs are served by the agent and one by `Config`, and the @@ -1617,7 +1652,7 @@ pub struct IdentityState { /// An empty pool and an unreachable agent look identical on screen unless /// something says otherwise, and of the two, "you have no attributes" is /// the confident wrong answer about the holder's own data (VTI R6.4). - pub load_error: Option, + pub load_error: Option, /// Whether the agent-served tabs have ever been read in this session. pub loaded: bool, /// A refresh was asked for while one was in flight. Re-issued when the diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index 197f907b..30da77a8 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -711,8 +711,9 @@ impl StateHandler { progress.fail(&mut state.loading); state.startup_diagnosis = Some(std::sync::Arc::new(diagnose_startup(&e, &profile))); - state.connection.status = - state::MediatorStatus::Failed(format!("{e}")); + state.connection.status = state::MediatorStatus::Failed( + state::ConnectionFailure::of(&e), + ); let _ = self.state_tx.send(state.clone()); // No config loaded here — join is unavailable. return self @@ -733,10 +734,12 @@ impl StateHandler { &profile, ), )); - state.connection.status = - state::MediatorStatus::Failed( + state.connection.status = state::MediatorStatus::Failed( + state::ConnectionFailure::new( format!("Internal error: {join_err}"), - ); + openvtc_core::errors::ErrorClass::Local, + ), + ); let _ = self.state_tx.send(state.clone()); return self .run_degraded_loop_terminal( @@ -4430,7 +4433,10 @@ fn spawn_persona_effect( .to_string(); let p = &mut state.main_page.content_panel.identity; p.loading = false; - p.load_error = Some(reason.clone()); + p.load_error = Some(crate::state_handler::main_page::content::AgentError::new( + reason.clone(), + openvtc_core::errors::ErrorClass::Unreachable, + )); if matches!(effect, PersonaEffect::Job(_)) { persona_actions::release_form(state, reason); } diff --git a/openvtc/src/state_handler/persona_actions/jobs.rs b/openvtc/src/state_handler/persona_actions/jobs.rs index 98bd7c0b..9c3cf814 100644 --- a/openvtc/src/state_handler/persona_actions/jobs.rs +++ b/openvtc/src/state_handler/persona_actions/jobs.rs @@ -1,6 +1,7 @@ //! The background work the pane spawns: [`PersonaJob`] round-trips, the read of //! every agent-served tab, and the re-reads that follow a write. +use crate::state_handler::main_page::content::AgentError; use openvtc_core::persona::{ binding, claim_types, contacts, correlation, disclosure, lifecycle, pool::{self, AttributeDraft}, @@ -226,23 +227,19 @@ impl PersonaReadJob { // reveal (`s`) fetches that one value on its own via `pool::reveal`. let attributes = pool::list(&self.admin_vta, self.include_values, false) .await - .map_err(|e| format!("{e}")); - let profiles = profile::list(&self.admin_vta) - .await - .map_err(|e| format!("{e}")); + .map_err(AgentError::of); + let profiles = profile::list(&self.admin_vta).await.map_err(AgentError::of); let retired = lifecycle::list_retired(&self.admin_vta) .await - .map_err(|e| format!("{e}")); - let worlds = world::list(&self.admin_vta) - .await - .map_err(|e| format!("{e}")); + .map_err(AgentError::of); + let worlds = world::list(&self.admin_vta).await.map_err(AgentError::of); let links = correlation::analyze(&self.admin_vta) .await - .map_err(|e| format!("{e}")); + .map_err(AgentError::of); let disclosures = match std::num::NonZeroU64::new(DISCLOSURE_PAGE) { Some(limit) => disclosure::history(&self.admin_vta, limit) .await - .map_err(|e| format!("{e}")), + .map_err(AgentError::of), None => Ok(Vec::new()), }; let claim_types = match self.needs_claim_types { @@ -250,7 +247,7 @@ impl PersonaReadJob { claim_types::Registry::fetch(&self.admin_vta) .await .map(Box::new) - .map_err(|e| format!("{e}")), + .map_err(AgentError::of), ), false => None, }; diff --git a/openvtc/src/state_handler/persona_actions/outcome.rs b/openvtc/src/state_handler/persona_actions/outcome.rs index cd9d5332..2098e1c4 100644 --- a/openvtc/src/state_handler/persona_actions/outcome.rs +++ b/openvtc/src/state_handler/persona_actions/outcome.rs @@ -1,6 +1,7 @@ //! [`PersonaOutcome`]: what a persona job produced, folded back into the pane on //! the loop thread. +use crate::state_handler::main_page::content::AgentError; use crate::state_handler::main_page::status::Status; use std::collections::HashMap; @@ -25,14 +26,14 @@ use crate::state_handler::state::State; /// What a persona job produced. Data only; applied on the loop thread. pub(crate) enum PersonaOutcome { Read { - attributes: Result, String>, - profiles: Result, String>, + attributes: Result, AgentError>, + profiles: Result, AgentError>, /// The retired faces. Its own result: it feeds an optional view, and a /// failure there must not blank the faces the holder wears. - retired: Result, String>, - worlds: Result, String>, - links: Result, String>, - disclosures: Result, String>, + retired: Result, AgentError>, + worlds: Result, AgentError>, + links: Result, AgentError>, + disclosures: Result, AgentError>, bindings: HashMap, /// The claim-type registry, when this read asked for it. `None` means /// it was not asked for — a refresh over a table already held — which @@ -43,7 +44,7 @@ pub(crate) enum PersonaOutcome { /// `clippy::large_enum_variant` will accept: every outcome, including /// the three one-word ones, would then be moved at the width of the /// table. - claim_types: Option, String>>, + claim_types: Option, AgentError>>, include_values: bool, }, Written { @@ -222,7 +223,7 @@ impl PersonaOutcome { p.retired_faces = list.into(); p.retired_error = None; } - Err(e) => p.retired_error = Some(e), + Err(e) => p.retired_error = Some(e.text), } if let Ok(list) = disclosures { p.disclosure_selected = p.disclosure_selected.min(list.len().saturating_sub(1)); @@ -619,7 +620,10 @@ mod tests { }); PersonaOutcome::Read { - attributes: Err("connection refused".to_string()), + attributes: Err(AgentError::new( + "connection refused", + openvtc_core::errors::ErrorClass::Unreachable, + )), profiles: Ok(Vec::new()), retired: Ok(vec![]), disclosures: Ok(Vec::new()), @@ -633,7 +637,10 @@ mod tests { assert_eq!(personas(&state).attributes.len(), 1); assert_eq!( - personas(&state).load_error.as_deref(), + personas(&state) + .load_error + .as_ref() + .map(|e| e.text.as_str()), Some("connection refused") ); } @@ -715,7 +722,7 @@ mod tests { /// untick the holder could not fix it from here. #[test] fn the_editor_drops_references_to_attributes_the_pool_no_longer_holds() { - let open = |loaded: bool, load_error: Option| { + let open = |loaded: bool, load_error: Option| { let mut state = state_with(IdentityState { attributes: vec![attribute("01A")].into(), loaded, @@ -739,7 +746,13 @@ mod tests { assert_eq!(open(true, None), (vec!["01A".to_string()], 1)); // A pool that did not read cleanly is no evidence anything is gone. assert_eq!( - open(true, Some("unreachable".into())), + open( + true, + Some(AgentError::new( + "unreachable", + openvtc_core::errors::ErrorClass::Unreachable, + )), + ), (vec!["01A".to_string(), "01GONE".to_string()], 0) ); assert_eq!( diff --git a/openvtc/src/state_handler/runtime_actions.rs b/openvtc/src/state_handler/runtime_actions.rs index 9f245472..973429b1 100644 --- a/openvtc/src/state_handler/runtime_actions.rs +++ b/openvtc/src/state_handler/runtime_actions.rs @@ -257,9 +257,10 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha } (None, _) => { ctx.state.main_page.content_panel.communities.status_message = - Some(Status::error( - "Messaging unavailable — cannot leave right now.".to_string(), - )); + Some(Status::error(messaging_unavailable( + &ctx.state.connection, + "cannot leave", + ))); } } } @@ -473,9 +474,10 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha } (None, _) => { ctx.state.main_page.content_panel.communities.status_message = - Some(Status::error( - "Messaging unavailable — cannot issue right now.".to_string(), - )); + Some(Status::error(messaging_unavailable( + &ctx.state.connection, + "cannot issue", + ))); None } }; @@ -553,10 +555,10 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha } (None, _) => { ctx.state.main_page.content_panel.communities.status_message = - Some(Status::error( - "Messaging unavailable — cannot ask for a challenge right now." - .to_string(), - )); + Some(Status::error(messaging_unavailable( + &ctx.state.connection, + "cannot ask for a challenge", + ))); } } } @@ -613,9 +615,10 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha } (None, _) => { ctx.state.main_page.content_panel.communities.status_message = - Some(Status::error( - "Messaging unavailable — cannot renew right now.".to_string(), - )); + Some(Status::error(messaging_unavailable( + &ctx.state.connection, + "cannot renew", + ))); } } } @@ -1460,13 +1463,32 @@ async fn assert_personhood(ctx: &mut ActionCtx<'_>, witnessed: Option { ctx.state.main_page.content_panel.communities.status_message = Some(Status::error( - "Messaging unavailable — cannot assert right now.".to_string(), + messaging_unavailable(&ctx.state.connection, "cannot assert"), )); } } Handled::Continue } +/// Why a network action cannot run now: messaging is down. Says which class +/// of failure it is and what to do — not only *what* could not be done +/// (#585, R6.4). +fn messaging_unavailable( + connection: &crate::state_handler::state::ConnectionState, + what: &str, +) -> String { + match &connection.status { + crate::state_handler::state::MediatorStatus::Failed(failure) => format!( + "Messaging unavailable ({}) — {what} right now. {} Help / Status: r retries.", + failure.short(), + failure.class.remedy() + ), + _ => format!( + "Messaging unavailable — {what} right now: messaging is not running. Help / Status \ + shows its state." + ), + } +} #[cfg(test)] mod tests { use super::*; diff --git a/openvtc/src/state_handler/state.rs b/openvtc/src/state_handler/state.rs index 39570231..90565dae 100644 --- a/openvtc/src/state_handler/state.rs +++ b/openvtc/src/state_handler/state.rs @@ -315,6 +315,56 @@ pub struct ConnectionState { pub online_listeners: std::collections::BTreeSet, } +/// Why messaging is down, typed by the class of failure, so the header can +/// tell an unreachable mediator from a refused or incompatible one (#585). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConnectionFailure { + pub reason: String, + pub class: openvtc_core::errors::ErrorClass, +} + +impl ConnectionFailure { + #[must_use] + pub fn new(reason: impl Into, class: openvtc_core::errors::ErrorClass) -> Self { + Self { + reason: reason.into(), + class, + } + } + + /// The failure of an error, classified by its source chain. + #[must_use] + pub fn of(err: &anyhow::Error) -> Self { + Self::new( + format!("{err:#}"), + openvtc_core::errors::class_of(err.as_ref()), + ) + } + + /// A few words for the header: which kind of failure, not its text. + #[must_use] + pub fn short(&self) -> &'static str { + use openvtc_core::errors::ErrorClass as C; + match self.class { + C::Unreachable => "mediator unreachable", + C::Timeout => "mediator not answering", + C::AuthRejected => "mediator refused us", + C::Contract => "mediator incompatible", + C::NotFound => "mediator does not know us", + C::Refused => "mediator declined", + C::RemoteFailed => "mediator failed", + C::Local => "local error", + _ => "messaging failed", + } + } +} + +impl std::fmt::Display for ConnectionFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.reason) + } +} + #[derive(Clone, Debug, Default)] pub enum MediatorStatus { /// Status has not been determined yet. @@ -326,8 +376,8 @@ pub enum MediatorStatus { Connecting, /// Successfully connected. Connected, - /// Connection failed with an error description. - Failed(String), + /// Connection failed: why, and which class of failure (R6.4). + Failed(ConnectionFailure), /// The account has no active community/persona yet (State A, R-A-5/R-C-7): /// there is no DID to open a DIDComm session for. The app runs without /// messaging until the user joins a community. diff --git a/openvtc/src/ui/pages/loading/mod.rs b/openvtc/src/ui/pages/loading/mod.rs index 91ed0c31..5b9adf9a 100644 --- a/openvtc/src/ui/pages/loading/mod.rs +++ b/openvtc/src/ui/pages/loading/mod.rs @@ -649,9 +649,13 @@ impl ComponentRender<()> for LoadingScreen { // Failed with no diagnosis attached: a phase-2 (post-load) // connection failure. Show it plainly rather than inventing advice. lines.push(Line::styled( - reason.clone(), + format!("{} — {}", reason.short(), reason.reason), Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), )); + lines.push(Line::styled( + reason.class.remedy(), + Style::new().fg(COLOR_DARK_GRAY), + )); lines.push(Line::default()); } @@ -760,7 +764,11 @@ mod tests { detail: "No matching credential found".to_string(), }; let mut state = State::default(); - state.connection.status = MediatorStatus::Failed(err.to_string()); + state.connection.status = + MediatorStatus::Failed(crate::state_handler::state::ConnectionFailure::new( + err.to_string(), + openvtc_core::errors::ErrorClass::Local, + )); state.startup_diagnosis = Some(std::sync::Arc::new(diagnose( &err, &DiagnosisContext::new("default"), diff --git a/openvtc/src/ui/pages/main/components/content_panel.rs b/openvtc/src/ui/pages/main/components/content_panel.rs index db37e8fd..c3753b4b 100644 --- a/openvtc/src/ui/pages/main/components/content_panel.rs +++ b/openvtc/src/ui/pages/main/components/content_panel.rs @@ -285,13 +285,22 @@ fn render_status_help( Span::styled(" Connection: ", label_style), Span::styled("Connecting...", label_style), ]), - MediatorStatus::Failed(reason) => Line::from(vec![ - Span::styled(" Connection: ", label_style), - Span::styled( - format!("Failed: {}", reason), - Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), - ), - ]), + MediatorStatus::Failed(failure) => { + // Which class of failure, the error itself, and what to do about + // it (R6.4, #585) — then `r` retries from here. + lines.push(Line::from(vec![ + Span::styled(" Connection: ", label_style), + Span::styled( + failure.short(), + Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED).bold(), + ), + ])); + super::status::push_error(&mut lines, &failure.reason, " "); + Line::from(Span::styled( + format!(" {}", failure.class.remedy()), + Style::new().fg(COLOR_DARK_GRAY), + )) + } MediatorStatus::Initializing(step) => Line::from(vec![ Span::styled(" Connection: ", label_style), Span::styled(format!("Initializing: {}", step), label_style), @@ -378,7 +387,11 @@ fn render_status_help( fn help_bindings( settings: &crate::state_handler::main_page::content::SettingsState, ) -> Vec { - let mut keys = vec![k("1", "copy persona DID"), k("2", "copy mediator DID")]; + let mut keys = vec![ + k("r", "reconnect messaging"), + k("1", "copy persona DID"), + k("2", "copy mediator DID"), + ]; if settings.did_git_sign.is_some() { keys.push(k("3", "copy signing principal")); keys.push(k("4", "copy SSH key")); @@ -390,6 +403,14 @@ fn help_bindings( impl MainPage { pub(in crate::ui::pages::main) fn handle_help_key(&mut self, key: KeyEvent) -> bool { match key.code { + // Retry messaging: the reconnect the Settings mediator row also + // offers (#585). + KeyCode::Char('r') => { + let _ = self.action_tx.send(Action::Settings( + crate::state_handler::actions::SettingsAction::ReconnectMediator, + )); + true + } KeyCode::Char('1') => { // Copy persona DID to clipboard let did = self diff --git a/openvtc/src/ui/pages/main/components/identity_panel.rs b/openvtc/src/ui/pages/main/components/identity_panel.rs index 2ff7a4ca..2548a847 100644 --- a/openvtc/src/ui/pages/main/components/identity_panel.rs +++ b/openvtc/src/ui/pages/main/components/identity_panel.rs @@ -1442,14 +1442,22 @@ fn push_agent_state(state: &IdentityState, lines: &mut Vec>, noun: // Every other failure is echoed verbatim. That text is *data*: it may // name a host, a port, a contract mismatch, and translating what we do // not recognise would be inventing a cause (VTI R6.4). - if crate::holder_grant::needs_holder_grant(error) { + // Only a refusal can be the missing holder grant: an unreachable + // agent is never told to run a grant (#585, R6.4). + let refused = matches!( + error.class, + openvtc_core::errors::ErrorClass::AuthRejected + | openvtc_core::errors::ErrorClass::Refused + ); + if refused && crate::holder_grant::needs_holder_grant(&error.text) { for line in crate::holder_grant::holder_grant_hint(state.agent_credential_did.as_deref()) { lines.push(Line::from(line).fg(COLOR_ORANGE)); } } else { - super::status::push_error(lines, error, " "); + super::status::push_error(lines, &error.text, " "); + lines.push(Line::from(format!(" {}", error.class.remedy())).fg(COLOR_DARK_GRAY)); } lines.push(Line::from("")); lines.push(Line::from(" r: try again").fg(COLOR_DARK_GRAY)); @@ -2618,6 +2626,7 @@ fn shorten_id(id: &str, max: usize) -> String { #[cfg(test)] mod tests { use super::*; + use crate::state_handler::main_page::content::AgentError; use crate::state_handler::main_page::content::{ManagedDid, PersonaMembership}; use openvtc_core::persona::binding::BindingSummary; use openvtc_core::persona::correlation::{Finding, Remedy}; @@ -2708,11 +2717,11 @@ mod tests { // agent's text rather than ours, so it is not this test's to police. let mut refused = populated(tab); loaded(&mut refused); - refused.load_error = Some( + refused.load_error = Some(AgentError::new( "this task reads or writes the holder's attribute pool, which sits above \ - every trust context. It requires an unscoped holder credential" - .to_string(), - ); + every trust context. It requires an unscoped holder credential", + openvtc_core::errors::ErrorClass::AuthRejected, + )); screens.push(text(&render(&refused))); } for mode in [ @@ -2893,7 +2902,10 @@ mod tests { let empty = text(&render(&state)); assert!(empty.contains("No attributes yet"), "{empty}"); - state.load_error = Some("connection refused".to_string()); + state.load_error = Some(AgentError::new( + "connection refused", + openvtc_core::errors::ErrorClass::Unreachable, + )); let failed = text(&render(&state)); assert!( failed.contains("Could not read your attributes"), @@ -2919,11 +2931,11 @@ mod tests { fn the_holder_refusal_carries_the_grant_command() { let mut state = IdentityState { tab: PersonaTab::Attributes, - load_error: Some( + load_error: Some(AgentError::new( "this task reads or writes the holder's attribute pool … it requires an unscoped \ - holder credential" - .to_string(), - ), + holder credential", + openvtc_core::errors::ErrorClass::AuthRejected, + )), agent_credential_did: Some("did:key:z6MkThisInstall".to_string()), ..IdentityState::default() }; @@ -2969,7 +2981,10 @@ mod tests { fn an_unrelated_failure_carries_no_grant_command() { let mut state = IdentityState { tab: PersonaTab::Attributes, - load_error: Some("connection refused".to_string()), + load_error: Some(AgentError::new( + "connection refused", + openvtc_core::errors::ErrorClass::Unreachable, + )), ..IdentityState::default() }; loaded(&mut state); diff --git a/openvtc/src/ui/pages/main/components/inbox_panel.rs b/openvtc/src/ui/pages/main/components/inbox_panel.rs index ef741ebf..fec21aff 100644 --- a/openvtc/src/ui/pages/main/components/inbox_panel.rs +++ b/openvtc/src/ui/pages/main/components/inbox_panel.rs @@ -57,13 +57,11 @@ pub fn render(state: &InboxState, connection: &ConnectionState) -> Vec Line::from("Connected").fg(COLOR_SUCCESS), MediatorStatus::Connecting => Line::from("Connecting...").fg(COLOR_TEXT_DEFAULT), - MediatorStatus::Failed(reason) => { - let display = format!( - "Failed: {}", - openvtc_core::display::truncate_did(reason, 40) - ); - Line::from(display).fg(COLOR_WARNING_ACCESSIBLE_RED) - } + MediatorStatus::Failed(failure) => Line::from(format!( + "Messaging down — {}. Help / Status has the cause; r there retries.", + failure.short() + )) + .fg(COLOR_WARNING_ACCESSIBLE_RED), MediatorStatus::Initializing(step) => { Line::from(format!("Initializing: {}", step)).fg(COLOR_ORANGE) } diff --git a/openvtc/src/ui/pages/main/components/settings_panel.rs b/openvtc/src/ui/pages/main/components/settings_panel.rs index 51bb50f3..f452d28e 100644 --- a/openvtc/src/ui/pages/main/components/settings_panel.rs +++ b/openvtc/src/ui/pages/main/components/settings_panel.rs @@ -341,9 +341,11 @@ fn render_view(state: &SettingsState) -> Vec> { Style::new().fg(COLOR_TEXT_DEFAULT) }; - let edit_hint = if *editable && is_selected { + let edit_hint = if i == MEDIATOR_ROW && is_selected { + " [Enter to reconnect]" + } else if *editable && is_selected { " [Enter to edit]" - } else if !editable { + } else if !editable && i != MEDIATOR_ROW { " (read-only)" } else { "" @@ -1185,6 +1187,12 @@ impl MainPage { let _ = self .action_tx .send(Action::Settings(SettingsAction::StartEdit)); + } else if selected == MEDIATOR_ROW { + // The mediator is fixed by the DID document; what + // can be done from here is reconnect to it (#585). + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::ReconnectMediator)); } else if selected == rows::PROTECTION_ROW { let _ = self .action_tx diff --git a/openvtc/src/ui/pages/main/keymap_tests.rs b/openvtc/src/ui/pages/main/keymap_tests.rs index 052811ed..80813db2 100644 --- a/openvtc/src/ui/pages/main/keymap_tests.rs +++ b/openvtc/src/ui/pages/main/keymap_tests.rs @@ -518,3 +518,64 @@ fn a_short_terminal_scrolls_the_menu_to_the_selection() { let all = screen(&page, 80, 16).join("\n"); assert!(all.contains("Quit"), "{all}"); } + +// ----- Connectivity (#585) ------------------------------------------------- + +fn failed(class: openvtc_core::errors::ErrorClass) -> impl Fn(&mut State) + Copy { + move |s: &mut State| { + s.connection.status = crate::state_handler::state::MediatorStatus::Failed( + crate::state_handler::state::ConnectionFailure::new("tls handshake eof", class), + ); + } +} + +/// The header names the class of failure — unreachable, refused and +/// incompatible read differently — and where the retry is. +#[test] +fn the_header_tells_unreachable_from_rejected_from_incompatible() { + use openvtc_core::errors::ErrorClass as C; + let mut seen = Vec::new(); + for class in [C::Unreachable, C::AuthRejected, C::Contract] { + let (page, _rx) = page_for(MainMenu::Inbox, failed(class)); + let rows = screen(&page, 140, 40); + let header = rows[0].clone(); + assert!(header.contains("see Help"), "{header}"); + seen.push(header); + } + assert!(seen[0].contains("mediator unreachable"), "{}", seen[0]); + assert!(seen[1].contains("mediator refused us"), "{}", seen[1]); + assert!(seen[2].contains("mediator incompatible"), "{}", seen[2]); +} + +/// Help / Status shows the whole reason and the remedy, and `r` there +/// reconnects. +#[test] +fn help_status_shows_the_cause_and_r_retries() { + use openvtc_core::errors::ErrorClass as C; + let (mut page, mut rx) = page_for(MainMenu::Help, failed(C::AuthRejected)); + let all = screen(&page, 140, 60).join("\n"); + assert!(all.contains("tls handshake eof"), "{all}"); + assert!(all.contains("refused our credentials"), "{all}"); + page.handle_key_event(press(KeyCode::Char('r'))); + assert!(matches!( + rx.try_recv(), + Ok(Action::Settings( + crate::state_handler::actions::SettingsAction::ReconnectMediator + )) + )); +} + +/// Enter on the Settings mediator row reconnects. +#[test] +fn the_mediator_row_reconnects() { + let (mut page, mut rx) = page_for(MainMenu::Settings, |s| { + s.main_page.content_panel.settings.selected_index = 1; + }); + page.handle_key_event(press(KeyCode::Enter)); + assert!(matches!( + rx.try_recv(), + Ok(Action::Settings( + crate::state_handler::actions::SettingsAction::ReconnectMediator + )) + )); +} diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index c852d2ed..9af44b52 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -826,16 +826,21 @@ impl ComponentRender<()> for MainPage { "Connecting...", ratatui::style::Style::default().fg(COLOR_TEXT_DEFAULT), )), - MediatorStatus::Failed(reason) => { - let display = format!( - "Failed: {}", - openvtc_core::display::truncate_did(reason, 20) - ); - Line::from(Span::styled( - display, - ratatui::style::Style::default().fg(COLOR_WARNING_ACCESSIBLE_RED), - )) - } + // The class, not a truncated error text: an unreachable mediator, + // a refusal and a contract mismatch need different fixes (R6.4, + // #585). The full reason and the retry are on Help / Status. + MediatorStatus::Failed(failure) => Line::from(vec![ + Span::styled( + format!("✗ {}", failure.short()), + ratatui::style::Style::default() + .fg(COLOR_WARNING_ACCESSIBLE_RED) + .bold(), + ), + Span::styled( + " · see Help", + ratatui::style::Style::default().fg(COLOR_DARK_GRAY), + ), + ]), MediatorStatus::Initializing(step) => Line::from(vec![ Span::styled( "Initializing: ", From 91aeb5724d50f498302d81ea0b713813619fa477 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 17:44:59 +1000 Subject: [PATCH 08/11] ux(setup): Esc goes back on every wizard page, one progress indicator (#588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Esc goes back a page on every setup page that has one (setup_flow::navigation::previous_page), instead of clearing the field: ConfigImport and VTA DID go back to the start, step 1 (PNM command) to the VTA DID, the context-occupied warning to step 1, the unlock-code pages to the unlock-code choice, the token pages to the page before. Back stops at the VTA once its one-time grant is spent. Step 2 keeps its own back (it carries the reason for step 1's banner). Choice pages and the no-code warning say '[ESC] to go back'; the input pages' '[ESC] to clear input' now reads '[ESC] to go back'. - going back keeps what was typed and the setup DID: the ephemeral setup key is minted once per run, so a PNM grant already made for it still applies after going back and forward (it is still new each relaunch: it is never written to disk) - one progress indicator: the header's 'Section n/4'; the boxes are named ('Run the PNM command', 'Connect to the VTA', 'Enter unlock code', …) instead of carrying their own 'Step x/y' counters that disagreed with it - test: Esc walks back from step 1 to the start keeping the context id Signed-off-by: Glenn Gore --- .../src/state_handler/setup_vta_actions.rs | 13 ++++--- .../src/ui/pages/setup_flow/choice_page.rs | 12 +++++- .../src/ui/pages/setup_flow/config_import.rs | 2 +- openvtc/src/ui/pages/setup_flow/mod.rs | 12 ++++++ openvtc/src/ui/pages/setup_flow/navigation.rs | 32 ++++++++++++++++ .../pgp_token/token_factory_reset.rs | 2 +- .../setup_flow/pgp_token/token_select.rs | 2 +- .../pgp_token/token_set_cardholder_name.rs | 2 +- .../setup_flow/pgp_token/token_set_touch.rs | 2 +- .../pages/setup_flow/pgp_token/token_start.rs | 2 +- .../ui/pages/setup_flow/unlock_code_ask.rs | 5 +-- .../ui/pages/setup_flow/unlock_code_set.rs | 4 +- .../ui/pages/setup_flow/unlock_code_warn.rs | 4 +- .../pages/setup_flow/vta_acl_instructions.rs | 37 +++++++++++++++++-- .../src/ui/pages/setup_flow/vta_enter_did.rs | 2 +- .../ui/pages/setup_flow/vta_provisioning.rs | 2 +- 16 files changed, 109 insertions(+), 26 deletions(-) diff --git a/openvtc/src/state_handler/setup_vta_actions.rs b/openvtc/src/state_handler/setup_vta_actions.rs index 2e15aac2..8a069113 100644 --- a/openvtc/src/state_handler/setup_vta_actions.rs +++ b/openvtc/src/state_handler/setup_vta_actions.rs @@ -213,11 +213,14 @@ pub(crate) async fn handle_vta_submit_did( } } - // Mint the ephemeral admin did:key. Held in memory only — a fresh key is - // generated if the wizard restarts, and the operator must re-run the PNM - // ACL step for the new DID. - let setup_key = match EphemeralSetupKey::generate() { - Ok(k) => Arc::new(k), + // Mint the ephemeral admin did:key — once per run. Held in memory only: a + // fresh key is generated if the wizard restarts, and the operator must + // re-run the PNM ACL step for the new DID. Going back to this page and on + // again within a run keeps the key, so a grant already made for it still + // applies (#588). + let existing = state.setup.vta.setup_key.clone(); + let setup_key = match existing.map_or_else(|| EphemeralSetupKey::generate().map(Arc::new), Ok) { + Ok(k) => k, Err(e) => { state.setup.vta.messages.push(MessageType::Error(format!( "Could not generate setup did:key: {e}" diff --git a/openvtc/src/ui/pages/setup_flow/choice_page.rs b/openvtc/src/ui/pages/setup_flow/choice_page.rs index 6bc479a3..7eb0b0ed 100644 --- a/openvtc/src/ui/pages/setup_flow/choice_page.rs +++ b/openvtc/src/ui/pages/setup_flow/choice_page.rs @@ -128,10 +128,18 @@ pub fn render(spec: &ChoiceSpec, selected: usize, state: &SetupState, frame: &mu middle, ); - let bottom_line = Line::from(vec![ + let mut hints = vec![ Span::styled("[F10]", Style::new().fg(COLOR_BORDER).bold()), Span::styled(" to quit", Style::new().fg(COLOR_TEXT_DEFAULT)), - ]); + ]; + if super::navigation::previous_page(state.active_page).is_some() { + hints.extend([ + Span::styled(" | ", Style::new().fg(COLOR_TEXT_DEFAULT)), + Span::styled("[ESC]", Style::new().fg(COLOR_BORDER).bold()), + Span::styled(" to go back", Style::new().fg(COLOR_TEXT_DEFAULT)), + ]); + } + let bottom_line = Line::from(hints); frame.render_widget( Paragraph::new(bottom_line).block(Block::new().padding(Padding::new(2, 0, 1, 0))), bottom, diff --git a/openvtc/src/ui/pages/setup_flow/config_import.rs b/openvtc/src/ui/pages/setup_flow/config_import.rs index 7abe0ea1..3768959d 100644 --- a/openvtc/src/ui/pages/setup_flow/config_import.rs +++ b/openvtc/src/ui/pages/setup_flow/config_import.rs @@ -296,7 +296,7 @@ impl ConfigImport { Span::styled("[TAB]", Style::new().fg(COLOR_BORDER).bold()), Span::styled(" to select | ", Style::new().fg(COLOR_TEXT_DEFAULT)), Span::styled("[ESC]", Style::new().fg(COLOR_BORDER).bold()), - Span::styled(" to clear input | ", Style::new().fg(COLOR_TEXT_DEFAULT)), + Span::styled(" to go back | ", Style::new().fg(COLOR_TEXT_DEFAULT)), Span::styled("[ENTER]", Style::new().fg(COLOR_BORDER).bold()), Span::styled(" to continue", Style::new().fg(COLOR_TEXT_DEFAULT)), ])); diff --git a/openvtc/src/ui/pages/setup_flow/mod.rs b/openvtc/src/ui/pages/setup_flow/mod.rs index 23e23687..c5fea548 100644 --- a/openvtc/src/ui/pages/setup_flow/mod.rs +++ b/openvtc/src/ui/pages/setup_flow/mod.rs @@ -155,6 +155,18 @@ impl Component for SetupFlow { return; } + // Esc goes back a page on every wizard page that has one (#588). Step + // 2 decides its own way back, because it carries the reason for step + // 1's banner; every other page's Esc is this one. + let page = self.props.state.active_page; + if key.code == crossterm::event::KeyCode::Esc + && !matches!(page, SetupPage::VtaProvisioning) + && let Some(previous) = navigation::previous_page(page) + { + self.props.state.active_page = previous; + return; + } + match self.props.state.active_page { SetupPage::StartAsk => StartAskPanel::handle_key_event(self, key), SetupPage::ConfigImport => ConfigImport::handle_key_event(self, key), diff --git a/openvtc/src/ui/pages/setup_flow/navigation.rs b/openvtc/src/ui/pages/setup_flow/navigation.rs index 0dbde63f..c103e422 100644 --- a/openvtc/src/ui/pages/setup_flow/navigation.rs +++ b/openvtc/src/ui/pages/setup_flow/navigation.rs @@ -143,6 +143,38 @@ pub fn navigate(event: SetupEvent, state: &SetupState) -> NavResult { } } +/// The page Esc goes back to (#588), or `None` where there is no way back. +/// +/// Back stops at the VTA: once its admin credential is issued, the one-time +/// grant is spent, so the protection pages do not lead back into provisioning. +/// Within a run going back keeps what was entered and the setup DID, so a +/// grant already made for it still applies. +#[must_use] +pub fn previous_page(page: SetupPage) -> Option { + match page { + SetupPage::StartAsk | SetupPage::FinalPage => None, + SetupPage::ConfigImport | SetupPage::VtaEnterDid => Some(SetupPage::StartAsk), + SetupPage::VtaAclInstructions => Some(SetupPage::VtaEnterDid), + SetupPage::VtaProvisioning | SetupPage::ContextOccupied => { + Some(SetupPage::VtaAclInstructions) + } + SetupPage::RecoverConfirm => Some(SetupPage::ContextOccupied), + #[cfg(feature = "openpgp-card")] + SetupPage::TokenStart | SetupPage::TokenSetTouch | SetupPage::TokenSetCardholderName => { + None + } + #[cfg(feature = "openpgp-card")] + SetupPage::TokenSelect => Some(SetupPage::TokenStart), + #[cfg(feature = "openpgp-card")] + SetupPage::TokenFactoryReset => Some(SetupPage::TokenSelect), + #[cfg(feature = "openpgp-card")] + SetupPage::UnlockCodeAsk => Some(SetupPage::TokenStart), + #[cfg(not(feature = "openpgp-card"))] + SetupPage::UnlockCodeAsk => None, + SetupPage::UnlockCodeSet | SetupPage::UnlockCodeWarn => Some(SetupPage::UnlockCodeAsk), + } +} + /// Entry point into the config-protection sub-flow (token setup on openpgp-card /// builds, otherwise the unlock-code prompt). Reached straight after VTA /// provisioning. diff --git a/openvtc/src/ui/pages/setup_flow/pgp_token/token_factory_reset.rs b/openvtc/src/ui/pages/setup_flow/pgp_token/token_factory_reset.rs index 80411416..c2f61b47 100644 --- a/openvtc/src/ui/pages/setup_flow/pgp_token/token_factory_reset.rs +++ b/openvtc/src/ui/pages/setup_flow/pgp_token/token_factory_reset.rs @@ -127,7 +127,7 @@ impl TokenFactoryReset { let block = Block::bordered() .fg(COLOR_BORDER) .padding(Padding::proportional(1)) - .title(" Step 4/6: Factory reset token "); + .title(" Factory reset token "); let mut lines = vec![ Line::styled( diff --git a/openvtc/src/ui/pages/setup_flow/pgp_token/token_select.rs b/openvtc/src/ui/pages/setup_flow/pgp_token/token_select.rs index 08bdda37..b4309d36 100644 --- a/openvtc/src/ui/pages/setup_flow/pgp_token/token_select.rs +++ b/openvtc/src/ui/pages/setup_flow/pgp_token/token_select.rs @@ -229,7 +229,7 @@ impl TokenSelect { frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled("[ESC]", Style::new().fg(COLOR_BORDER).bold()), - Span::styled(" to clear input | ", Style::new().fg(COLOR_TEXT_DEFAULT)), + Span::styled(" to go back | ", Style::new().fg(COLOR_TEXT_DEFAULT)), Span::styled("[ENTER]", Style::new().fg(COLOR_BORDER).bold()), Span::styled(" to continue", Style::new().fg(COLOR_TEXT_DEFAULT)), ])), diff --git a/openvtc/src/ui/pages/setup_flow/pgp_token/token_set_cardholder_name.rs b/openvtc/src/ui/pages/setup_flow/pgp_token/token_set_cardholder_name.rs index 37529fa0..7d908e81 100644 --- a/openvtc/src/ui/pages/setup_flow/pgp_token/token_set_cardholder_name.rs +++ b/openvtc/src/ui/pages/setup_flow/pgp_token/token_set_cardholder_name.rs @@ -79,7 +79,7 @@ impl TokenSetCardholderName { Block::bordered() .fg(COLOR_BORDER) .padding(Padding::proportional(1)) - .title(" Step 6/6: Set token cardholder name "), + .title(" Set token cardholder name "), middle, ); diff --git a/openvtc/src/ui/pages/setup_flow/pgp_token/token_set_touch.rs b/openvtc/src/ui/pages/setup_flow/pgp_token/token_set_touch.rs index 5fd7be39..954fe01e 100644 --- a/openvtc/src/ui/pages/setup_flow/pgp_token/token_set_touch.rs +++ b/openvtc/src/ui/pages/setup_flow/pgp_token/token_set_touch.rs @@ -81,7 +81,7 @@ impl TokenSetTouch { let block = Block::bordered() .fg(COLOR_BORDER) .padding(Padding::proportional(1)) - .title(" Step 5/6: Set token touch policy "); + .title(" Set token touch policy "); let mut lines = vec![ Line::styled( diff --git a/openvtc/src/ui/pages/setup_flow/pgp_token/token_start.rs b/openvtc/src/ui/pages/setup_flow/pgp_token/token_start.rs index f2ccee9b..8d0653aa 100644 --- a/openvtc/src/ui/pages/setup_flow/pgp_token/token_start.rs +++ b/openvtc/src/ui/pages/setup_flow/pgp_token/token_start.rs @@ -49,7 +49,7 @@ impl TokenStart { let block = Block::bordered() .fg(COLOR_BORDER) .padding(Padding::proportional(1)) - .title(" Step 1/6: Set up hardware token "); + .title(" Set up hardware token "); let lines = vec![ Line::styled( diff --git a/openvtc/src/ui/pages/setup_flow/unlock_code_ask.rs b/openvtc/src/ui/pages/setup_flow/unlock_code_ask.rs index c594a52a..d4a10015 100644 --- a/openvtc/src/ui/pages/setup_flow/unlock_code_ask.rs +++ b/openvtc/src/ui/pages/setup_flow/unlock_code_ask.rs @@ -39,10 +39,7 @@ impl UnlockCodeAsk { fn spec() -> ChoiceSpec { ChoiceSpec { - title: [ - " Step 1/2: Set up unlock code ", - " Step 1/2: Set up unlock code ", - ], + title: [" Set up unlock code ", " Set up unlock code "], intro: vec![ Line::styled( "An unlock code encrypts your cryptographic keys, configuration, and private data stored by OpenVTC.", diff --git a/openvtc/src/ui/pages/setup_flow/unlock_code_set.rs b/openvtc/src/ui/pages/setup_flow/unlock_code_set.rs index d8183012..9c1e75ee 100644 --- a/openvtc/src/ui/pages/setup_flow/unlock_code_set.rs +++ b/openvtc/src/ui/pages/setup_flow/unlock_code_set.rs @@ -133,7 +133,7 @@ impl UnlockCodeSet { Block::bordered() .fg(COLOR_BORDER) .padding(Padding::proportional(1)) - .title(" Step 2/2: Enter unlock code "), + .title(" Enter unlock code "), middle, ); @@ -207,7 +207,7 @@ impl UnlockCodeSet { Span::styled("[TAB]", Style::new().fg(COLOR_BORDER).bold()), Span::styled(" to select | ", Style::new().fg(COLOR_TEXT_DEFAULT)), Span::styled("[ESC]", Style::new().fg(COLOR_BORDER).bold()), - Span::styled(" to clear input | ", Style::new().fg(COLOR_TEXT_DEFAULT)), + Span::styled(" to go back | ", Style::new().fg(COLOR_TEXT_DEFAULT)), Span::styled("[ENTER]", Style::new().fg(COLOR_BORDER).bold()), Span::styled(" to continue", Style::new().fg(COLOR_TEXT_DEFAULT)), ])); diff --git a/openvtc/src/ui/pages/setup_flow/unlock_code_warn.rs b/openvtc/src/ui/pages/setup_flow/unlock_code_warn.rs index 15c9217a..0513844c 100644 --- a/openvtc/src/ui/pages/setup_flow/unlock_code_warn.rs +++ b/openvtc/src/ui/pages/setup_flow/unlock_code_warn.rs @@ -136,7 +136,9 @@ impl UnlockCodeWarn { let bottom_line = Line::from(vec![ Span::styled("[F10]", Style::new().fg(COLOR_BORDER).bold()), - Span::styled(" to quit", Style::new().fg(COLOR_TEXT_DEFAULT)), + Span::styled(" to quit | ", Style::new().fg(COLOR_TEXT_DEFAULT)), + Span::styled("[ESC]", Style::new().fg(COLOR_BORDER).bold()), + Span::styled(" to go back", Style::new().fg(COLOR_TEXT_DEFAULT)), ]); frame.render_widget( diff --git a/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs b/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs index 6c7d99b1..fe2f50f2 100644 --- a/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs +++ b/openvtc/src/ui/pages/setup_flow/vta_acl_instructions.rs @@ -259,7 +259,7 @@ impl VtaAclInstructions { Block::bordered() .fg(COLOR_BORDER) .padding(Padding::proportional(1)) - .title(" Step 1 of 2 — Run the PNM command "), + .title(" Run the PNM command "), middle, ); @@ -423,7 +423,7 @@ impl VtaAclInstructions { key("[PgUp/PgDn]"), text(" scroll · "), key("[ESC]"), - text(" reset"), + text(" back"), ]), None => Line::from(vec![ key("[F10]"), @@ -433,7 +433,7 @@ impl VtaAclInstructions { key("[ENTER]"), text(" I've run it | "), key("[ESC]"), - text(" reset context"), + text(" back"), ]), }; frame.render_widget( @@ -932,7 +932,7 @@ mod tests { #[test] fn the_page_is_a_numbered_action_with_a_visible_success_signal() { let text = flat(&VtaAclInstructions::default()); - assert!(text.contains("Step 1 of 2"), "{text}"); + assert!(text.contains("Run the PNM command"), "{text}"); assert!(text.contains("This step is required"), "{text}"); assert!(text.contains("1. Copy"), "{text}"); assert!(text.contains("2. Run it in your PNM session"), "{text}"); @@ -1149,4 +1149,33 @@ mod tests { assert!(flow.vta_acl_instructions.retry_reason.is_some()); assert!(rx.try_recv().is_err()); } + /// Esc goes back a page, through the whole VTA leg, keeping what was + /// typed — it no longer clears the field (#588). + #[test] + fn esc_walks_back_through_the_wizard_keeping_the_input() { + use crate::ui::component::Component as _; + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let mut flow = SetupFlow::new(&State::default(), tx); + flow.props.state.active_page = SetupPage::VtaAclInstructions; + flow.vta_acl_instructions.context_id = Input::new("my-ctx".to_string()); + let esc = KeyEvent { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + }; + flow.handle_key_event(esc); + assert!(matches!( + flow.props.state.active_page, + SetupPage::VtaEnterDid + )); + assert_eq!(flow.vta_acl_instructions.context_id.value(), "my-ctx"); + flow.handle_key_event(esc); + assert!(matches!(flow.props.state.active_page, SetupPage::StartAsk)); + flow.handle_key_event(esc); + assert!( + matches!(flow.props.state.active_page, SetupPage::StartAsk), + "the first page has nowhere to go back to" + ); + } } diff --git a/openvtc/src/ui/pages/setup_flow/vta_enter_did.rs b/openvtc/src/ui/pages/setup_flow/vta_enter_did.rs index f5208fba..507be0b2 100644 --- a/openvtc/src/ui/pages/setup_flow/vta_enter_did.rs +++ b/openvtc/src/ui/pages/setup_flow/vta_enter_did.rs @@ -172,7 +172,7 @@ impl VtaEnterDid { Line::default(), Line::from(vec![ Span::styled("[ESC]", Style::new().fg(COLOR_BORDER).bold()), - Span::styled(" to clear input | ", Style::new().fg(COLOR_TEXT_DEFAULT)), + Span::styled(" to go back | ", Style::new().fg(COLOR_TEXT_DEFAULT)), Span::styled("[ENTER]", Style::new().fg(COLOR_BORDER).bold()), Span::styled(" to continue", Style::new().fg(COLOR_TEXT_DEFAULT)), ]), diff --git a/openvtc/src/ui/pages/setup_flow/vta_provisioning.rs b/openvtc/src/ui/pages/setup_flow/vta_provisioning.rs index 6a1be65b..2037dc8b 100644 --- a/openvtc/src/ui/pages/setup_flow/vta_provisioning.rs +++ b/openvtc/src/ui/pages/setup_flow/vta_provisioning.rs @@ -133,7 +133,7 @@ impl VtaProvisioning { Block::bordered() .fg(COLOR_BORDER) .padding(Padding::proportional(1)) - .title(" Step 2 of 2 — Connect to the VTA "), + .title(" Connect to the VTA "), middle, ); From 5e7b247c73bbb369b1cec3659217cac0641118d6 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 17:49:51 +1000 Subject: [PATCH 09/11] docs(ui): British spelling and one name per concept in UI strings (#554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - British spelling in the user-visible strings that were American: unauthorised (unlock-code page), recognised (token scan), 'Initialising:' (header, Inbox, Help / Status), 'not initialised', 'Finalised'. Protocol and SDK text keeps its own spelling (the VTA's 'is not authorized on' is matched verbatim; theme 'color' keys and 'organization' JSON are formats). - the profile's secret is the 'unlock code' everywhere: Settings → Protection ('Set an unlock code', 'Remove the unlock code (keyring only)', 'Unlock code:'), its log lines, and the CLI unlock prompt and --unlock-code-file errors. An export file's 'passphrase' is a different secret and keeps its name. - README gains a Glossary (persona, community/VTC, unlock code, Inbox, Help / Status); CONTRIBUTING states the wording convention (British spelling, one name per concept, one key vocabulary via keymap::Binding). - Persisted identifiers and theme ids are unchanged. Signed-off-by: Glenn Gore --- CONTRIBUTING.md | 13 +++++++++++++ README.md | 18 ++++++++++++++++++ openvtc/src/cli.rs | 8 ++++---- openvtc/src/main.rs | 6 +++--- openvtc/src/state_handler/main_page/mod.rs | 2 +- openvtc/src/state_handler/settings_actions.rs | 10 +++++----- .../src/state_handler/setup_sequence/config.rs | 2 +- .../ui/pages/main/components/content_panel.rs | 2 +- .../ui/pages/main/components/inbox_panel.rs | 2 +- .../ui/pages/main/components/settings_panel.rs | 15 +++++++++------ openvtc/src/ui/pages/main/mod.rs | 2 +- .../pages/setup_flow/pgp_token/token_select.rs | 2 +- .../src/ui/pages/setup_flow/unlock_code_ask.rs | 4 ++-- 13 files changed, 60 insertions(+), 26 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b55f03e..18106b15 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,6 +52,19 @@ cargo fmt --all --check cargo clippy --workspace --all-targets -- -D warnings ``` +### Wording in the UI + +- **British spelling** in user-visible strings (`colour`, `recognised`, + `authorise`, `initialising`), as the newer code already uses. Protocol, + SDK and wire text keeps its own spelling (`"kind": "organization"`, a VTA + error matched verbatim). +- **One name per concept**, as in the README's glossary: *persona*, + *community*, *unlock code*, *Inbox*, *Help / Status*. +- **Keys** are named with one vocabulary — `Enter`, `Esc`, `Tab`, + `Shift+Tab`, `↑/↓`, `←/→`, `Space`, `Del`, `Ctrl+X` — and a view's keys + are declared as a `keymap::Binding` list, never typed into a footer + string (`openvtc/src/ui/pages/main/keymap.rs`). + ### Commit Messages - Use [Conventional Commits](https://www.conventionalcommits.org/) style: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:` diff --git a/README.md b/README.md index 16529cf5..e5aa8d63 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ The `did-git-sign` commit signer lives in - [Quickstart](#quickstart) - [Core Concepts](#core-concepts) +- [Glossary](#glossary) - [Decentralised Identity](#decentralised-identity) - [Decentralised Communication](#decentralised-communication) - [Profiles and Configurations](#profiles-and-configurations) @@ -79,6 +80,23 @@ openvtc --help - **DIDComm Messaging Protocol** - An open standard for decentralised communication. Built on the foundation of Decentralised Identifiers (DIDs), it enables parties to exchange verifiable data such as credentials and establishes secure communication channels between parties without relying on centralised servers. +## Glossary + +The TUI uses one name for each of these: + +- **Persona** — an identity you hold: a DID your VTA keeps the keys for. The + *My Identity* panel manages your personas (and their attributes, faces and + worlds). +- **Community** — a Verifiable Trust Community (VTC) you belong to or are + joining. The UI says *community*; *VTC* names the service behind it. +- **Unlock code** — the secret that protects your profile on this machine + (`--unlock-code`; *Settings → Protection*). An export file's *passphrase* + is a different secret, chosen per export. +- **Inbox** — requests and credentials sent to you, waiting for an answer + (the docs' *tasks*). +- **Help / Status** — connection status, your DIDs and the keyboard + shortcuts; `?` on any panel lists that panel's keys. + ## Decentralised Identity OpenVTC uses `did:webvh` for your **persona DID**. It enhances the existing `did:web` method, providing: diff --git a/openvtc/src/cli.rs b/openvtc/src/cli.rs index 8f6da2d3..4e1711fb 100644 --- a/openvtc/src/cli.rs +++ b/openvtc/src/cli.rs @@ -154,18 +154,18 @@ pub fn read_unlock_code_file(path: &str) -> anyhow::Result Result { // Wiped on drop. (dialoguer's own line buffer is beyond reach.) let input = zeroize::Zeroizing::new( Password::with_theme(&ColorfulTheme::default()) - .with_prompt("Please enter unlock passphrase") + .with_prompt("Please enter your unlock code") .allow_empty_password(false) .interact() .map_err(|e| { - eprintln!("Failed to read passphrase input: {e}"); - OpenVTCError::Config(format!("Passphrase input failed: {e}")) + eprintln!("Failed to read the unlock code: {e}"); + OpenVTCError::Config(format!("Unlock code input failed: {e}")) })?, ); let code = UnlockCode::for_existing(&input)?; diff --git a/openvtc/src/state_handler/main_page/mod.rs b/openvtc/src/state_handler/main_page/mod.rs index 11798644..a1f70fee 100644 --- a/openvtc/src/state_handler/main_page/mod.rs +++ b/openvtc/src/state_handler/main_page/mod.rs @@ -285,7 +285,7 @@ impl MainPageState { TaskKind::Informational("Rejected".to_string()) } TaskType::RelationshipRequestFinalized => { - TaskKind::Informational("Finalized".to_string()) + TaskKind::Informational("Finalised".to_string()) } TaskType::TrustPong => TaskKind::Informational("Pong received".to_string()), TaskType::VRCRequestRejected => { diff --git a/openvtc/src/state_handler/settings_actions.rs b/openvtc/src/state_handler/settings_actions.rs index c8c9f26c..081f4fc9 100644 --- a/openvtc/src/state_handler/settings_actions.rs +++ b/openvtc/src/state_handler/settings_actions.rs @@ -68,7 +68,7 @@ pub async fn set_passphrase( config.public.protection = ConfigProtectionType::Encrypted; config.private.logs.insert( LogFamily::Config, - "Config protection changed to passphrase encrypted".to_string(), + "Profile protection changed to an unlock code".to_string(), ); save_config(config, profile)?; info!("config protection set to passphrase encrypted"); @@ -463,8 +463,8 @@ async fn handle_set_passphrase( save, dispatch_util::Persist::SyncOnly, |mp| &mut mp.content_panel.settings.status_message, - "Passphrase protection enabled", - dispatch_util::SyncLog::Plain("Passphrase protection enabled".to_string()), + "Unlock code protection enabled", + dispatch_util::SyncLog::Plain("Unlock code protection enabled".to_string()), ); // Setting a passphrase can move the secret to durable storage (the // file store only accepts an encrypted blob), so the volatile @@ -476,7 +476,7 @@ async fn handle_set_passphrase( dispatch_util::record_error( &mut state.main_page, |mp| &mut mp.content_panel.settings.status_message, - "Failed to set passphrase", + "Failed to set the unlock code", &e, ); } @@ -523,7 +523,7 @@ fn handle_remove_passphrase( dispatch_util::record_error( &mut state.main_page, |mp| &mut mp.content_panel.settings.status_message, - "Failed to remove passphrase", + "Failed to remove the unlock code", &e, ); } diff --git a/openvtc/src/state_handler/setup_sequence/config.rs b/openvtc/src/state_handler/setup_sequence/config.rs index 075cc408..236b2ddf 100644 --- a/openvtc/src/state_handler/setup_sequence/config.rs +++ b/openvtc/src/state_handler/setup_sequence/config.rs @@ -351,7 +351,7 @@ impl ConfigExtension for Config { let atm = tdk .atm .as_ref() - .ok_or_else(|| anyhow::anyhow!("TDK ATM not initialized"))?; + .ok_or_else(|| anyhow::anyhow!("TDK ATM not initialised"))?; let persona_profile = ATMProfile::new( atm, Some("Persona DID".to_string()), diff --git a/openvtc/src/ui/pages/main/components/content_panel.rs b/openvtc/src/ui/pages/main/components/content_panel.rs index c3753b4b..ba44a664 100644 --- a/openvtc/src/ui/pages/main/components/content_panel.rs +++ b/openvtc/src/ui/pages/main/components/content_panel.rs @@ -303,7 +303,7 @@ fn render_status_help( } MediatorStatus::Initializing(step) => Line::from(vec![ Span::styled(" Connection: ", label_style), - Span::styled(format!("Initializing: {}", step), label_style), + Span::styled(format!("Initialising: {}", step), label_style), ]), MediatorStatus::Unknown => Line::from(vec![ Span::styled(" Connection: ", label_style), diff --git a/openvtc/src/ui/pages/main/components/inbox_panel.rs b/openvtc/src/ui/pages/main/components/inbox_panel.rs index fec21aff..f3b02d46 100644 --- a/openvtc/src/ui/pages/main/components/inbox_panel.rs +++ b/openvtc/src/ui/pages/main/components/inbox_panel.rs @@ -63,7 +63,7 @@ pub fn render(state: &InboxState, connection: &ConnectionState) -> Vec { - Line::from(format!("Initializing: {}", step)).fg(COLOR_ORANGE) + Line::from(format!("Initialising: {}", step)).fg(COLOR_ORANGE) } MediatorStatus::Unknown => Line::from("Not connected").fg(COLOR_ORANGE), MediatorStatus::NoActiveCommunity => Line::from("No active community").fg(COLOR_DARK_GRAY), diff --git a/openvtc/src/ui/pages/main/components/settings_panel.rs b/openvtc/src/ui/pages/main/components/settings_panel.rs index f452d28e..9c6b1da8 100644 --- a/openvtc/src/ui/pages/main/components/settings_panel.rs +++ b/openvtc/src/ui/pages/main/components/settings_panel.rs @@ -784,19 +784,22 @@ fn render_change_protection( if active_field == PROTECTION_CONFIRM_REMOVE { lines.push(Line::from( - "Remove the passphrase? The configuration is then protected by the OS keyring only.", + "Remove the unlock code? The profile is then protected by the OS keyring only.", )); lines.push(Line::from("")); lines.push( Line::from(crate::ui::pages::main::confirm::prompt( - "Remove the passphrase?", + "Remove the unlock code?", )) .fg(COLOR_ORANGE) .bold(), ); } else if active_field == 0 { // Option selection mode - let options = ["Set Passphrase", "Remove Passphrase (keyring only)"]; + let options = [ + "Set an unlock code", + "Remove the unlock code (keyring only)", + ]; for (i, label) in options.iter().enumerate() { let is_selected = i == selected_option; let style = if is_selected { @@ -831,7 +834,7 @@ fn render_change_protection( }), ), Span::styled( - "Passphrase: ", + "Unlock code: ", Style::new().fg(if active_field == 1 { COLOR_SUCCESS } else { @@ -858,7 +861,7 @@ fn render_change_protection( }), ), Span::styled( - "Confirm: ", + "Confirm: ", Style::new().fg(if active_field == 2 { COLOR_SUCCESS } else { @@ -879,7 +882,7 @@ fn render_change_protection( &mut lines, "", &[ - k(keymap::TYPE, "passphrase"), + k(keymap::TYPE, "unlock code"), k("Tab", "switch field"), k(if active_field == 1 { "↓" } else { "↑" }, "switch field"), k( diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index 9af44b52..e44623d9 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -843,7 +843,7 @@ impl ComponentRender<()> for MainPage { ]), MediatorStatus::Initializing(step) => Line::from(vec![ Span::styled( - "Initializing: ", + "Initialising: ", ratatui::style::Style::default().fg(COLOR_ORANGE), ), Span::styled( diff --git a/openvtc/src/ui/pages/setup_flow/pgp_token/token_select.rs b/openvtc/src/ui/pages/setup_flow/pgp_token/token_select.rs index b4309d36..55097bee 100644 --- a/openvtc/src/ui/pages/setup_flow/pgp_token/token_select.rs +++ b/openvtc/src/ui/pages/setup_flow/pgp_token/token_select.rs @@ -259,7 +259,7 @@ impl TokenSelect { )); lines.push(Line::default()); lines.push(Line::styled( - "If you have a token, ensure it is properly plugged in and recognized by your system, then rescan.", + "If you have a token, ensure it is properly plugged in and recognised by your system, then rescan.", Style::new().fg(COLOR_TEXT_DEFAULT).bold(), )); lines.push(Line::default()); diff --git a/openvtc/src/ui/pages/setup_flow/unlock_code_ask.rs b/openvtc/src/ui/pages/setup_flow/unlock_code_ask.rs index d4a10015..ebdb3653 100644 --- a/openvtc/src/ui/pages/setup_flow/unlock_code_ask.rs +++ b/openvtc/src/ui/pages/setup_flow/unlock_code_ask.rs @@ -46,7 +46,7 @@ impl UnlockCodeAsk { Style::new().fg(COLOR_DARK_GRAY), ), Line::styled( - "This prevents unauthorized access even if someone gains access to your device.", + "This prevents unauthorised access even if someone gains access to your device.", Style::new().fg(COLOR_DARK_GRAY), ), Line::default(), @@ -60,7 +60,7 @@ impl UnlockCodeAsk { ChoiceOption { label: "Yes, require unlock code (recommended)", description: vec![Line::styled( - " Encrypts your keys, configuration, and private data for protection against unauthorized access.", + " Encrypts your keys, configuration, and private data for protection against unauthorised access.", Style::new().fg(COLOR_DARK_GRAY), )], event: SetupEvent::WantUnlockCode, From 9ba85a637087e954dad4260b2377c93fb13419bc Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 17:50:19 +1000 Subject: [PATCH 10/11] docs(changelog): the UX consistency changes (#576 #577 #578 #579 #580 #584 #585 #588 #554) Signed-off-by: Glenn Gore --- CHANGELOG.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f928b664..9e87aab2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **One confirmation rule** ([#579](https://github.com/OpenVTC/openvtc/issues/579)). A destructive or outward-facing action + is armed by its key and committed by `y` alone; Enter — the key that opened the view — + now cancels like any other key. Every prompt ends "y: confirm · any other key: + cancel". Affected: community delete/leave/cancel-join, device-access revoke, Inbox + dismiss/clear-all, relationship and credential remove, invitation credential + delete/purge, agent-name remove, persona deletions, capability toggle, vetting + abandon/delete-ticket/withdraw/decline, local-face delete, make-reusable and + forget-contact. Typed-word confirmation stays for WIPE, RESET and context deletion. +- **Now confirmed** ([#579](https://github.com/OpenVTC/openvtc/issues/579)): Inbox `r` (the rejection is sent to the peer), + Settings "Remove the unlock code", and Communities `m` (issue VMC) and `R` (renew). +- **A key table per view, a `?` overlay, feedback for unused keys** + ([#580](https://github.com/OpenVTC/openvtc/issues/580)). Every main-page footer is drawn from the view's key table in one + format (`key: does · key: does`, one key vocabulary); `?` lists exactly the focused + view's keys and the global ones; a key a view does not take says "x does nothing here + — ? lists this panel's keys" in the footer row. Help / Status's shortcut list comes + from the same table. +- **Consistent keys** ([#580](https://github.com/OpenVTC/openvtc/issues/580)): archive is `x` on the VTA invitation list (was + `r`), show archived is `v` there (was `i`), refresh is `r` (was Tab); Communities + joins with `n` (was `j`), and `j`/`k` move there and in the menu, as in rooms and + elections. TSP Relationships takes ↑/↓ and Esc. +- **Text fields have a cursor** ([#578](https://github.com/OpenVTC/openvtc/issues/578)). Every main-page form field is edited + through `tui_input`: ←/→, Ctrl+←/→ and Home/End move within the field (they no longer + switch panels), Backspace/Del delete either side, Ctrl+A/E/U/W/K edit, and no Ctrl or + Alt chord types its letter. A paste is inserted at the cursor (it was appended); the + typed WIPE/RESET/DELETE words still take no paste. +- **The header names the class of a messaging failure** ([#585](https://github.com/OpenVTC/openvtc/issues/585)): "✗ mediator + unreachable", "mediator refused us", "mediator incompatible", "mediator not answering" + … with "see Help". Help / Status shows the class, the full reason and what to do, + and `r` there reconnects; so does Enter on the Settings mediator row. "Messaging + unavailable" messages name the class and the remedy. The identity pane offers the + holder-grant command only for a refusal, never for an unreachable agent. +- **Notifications** ([#584](https://github.com/OpenVTC/openvtc/issues/584)): the Inbox badge counts what was sent to you, not + your own pings and requests; Communities and Vetting carry badges of their own; the + activity log draws errors and warnings in colour with a glyph. +- **Setup: Esc goes back** ([#588](https://github.com/OpenVTC/openvtc/issues/588)) on every wizard page that has a page before + it, keeping what was typed (it used to clear the field); choice pages say so. The + setup DID is kept for the whole run, so going back and forward does not invalidate a + PNM grant. The boxes no longer carry their own "Step x/y" counters beside the + header's sections. +- **Wording** ([#554](https://github.com/OpenVTC/openvtc/issues/554)): British spelling in the UI strings that were American; + the profile's secret is the "unlock code" in Settings and the CLI as in setup; README + glossary and CONTRIBUTING wording convention. +- **Status lines are typed** ([#577](https://github.com/OpenVTC/openvtc/issues/577)): every panel's status carries a severity + set by the code that produced it; the colour no longer comes from the wording. + - **Inbound dispatch and the join protocol live in `openvtc-core`** ([#530](https://github.com/OpenVTC/openvtc/issues/530)). `process_inbound_message` and what it reports (`InboundEffects`, the off-loop `VerifyJob`s) moved from the CLI to @@ -104,6 +149,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **Nothing essential is clipped at 80x24** ([#576](https://github.com/OpenVTC/openvtc/issues/576)): all twelve menu items fit + (the activity log shrinks to 4 rows below 32 rows; the menu scrolls to the selection + on anything shorter, and is wide enough for its longest item); the footer row leads + with `F10: quit` and `?: keys` and keeps only whole hints; setup step 1 scrolls to + every PNM command and shows the copy result in its footer row. +- **A ping reports its result** ([#584](https://github.com/OpenVTC/openvtc/issues/584)) in the Relationships panel — the pong + and its latency, or "No reply to the ping after 30 s" — and an unanswered ping's + outbound task is expired instead of sitting in the Inbox. +- **Keys that did nothing silently now say so, and keys a footer did not list are gone + or listed** ([#580](https://github.com/OpenVTC/openvtc/issues/580)): e.g. Inbox `a` on a task with nothing to accept, `d` on + a membership credential, VTA `u` on an active invitation, persona `d` on a persona in + use, letters in the identity pane's read-only views and pickers. +- **The masked passphrase length counts characters**, not bytes ([#578](https://github.com/OpenVTC/openvtc/issues/578)). + - **Setup and backup import refuse an unlock code shorter than 8 characters** ([#574](https://github.com/OpenVTC/openvtc/issues/574)). Every unlock path already required 8, so a profile set up with a shorter code could never be opened again. A From f0615e02cd5a369ff31c17aa3300ae8d7eb5d8d0 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 18:01:01 +1000 Subject: [PATCH 11/11] ux(input): a paste in setup is inserted at the cursor too (#578) Setup's text fields replaced their whole content with a paste while the main page appended it; both now insert at the cursor. Signed-off-by: Glenn Gore --- CHANGELOG.md | 5 +++-- openvtc/src/ui/pages/setup_flow/mod.rs | 25 ++++++++++++++----------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e87aab2..0e8382c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,8 +62,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Text fields have a cursor** ([#578](https://github.com/OpenVTC/openvtc/issues/578)). Every main-page form field is edited through `tui_input`: ←/→, Ctrl+←/→ and Home/End move within the field (they no longer switch panels), Backspace/Del delete either side, Ctrl+A/E/U/W/K edit, and no Ctrl or - Alt chord types its letter. A paste is inserted at the cursor (it was appended); the - typed WIPE/RESET/DELETE words still take no paste. + Alt chord types its letter. A paste is inserted at the cursor on the main page and in + setup (it was appended on one and replaced the whole field on the other); the typed + WIPE/RESET/DELETE words still take no paste. - **The header names the class of a messaging failure** ([#585](https://github.com/OpenVTC/openvtc/issues/585)): "✗ mediator unreachable", "mediator refused us", "mediator incompatible", "mediator not answering" … with "see Help". Help / Status shows the class, the full reason and what to do, diff --git a/openvtc/src/ui/pages/setup_flow/mod.rs b/openvtc/src/ui/pages/setup_flow/mod.rs index c5fea548..0fb15123 100644 --- a/openvtc/src/ui/pages/setup_flow/mod.rs +++ b/openvtc/src/ui/pages/setup_flow/mod.rs @@ -198,8 +198,15 @@ impl Component for SetupFlow { fn handle_paste_event(&mut self, text: &str) { // Handle paste as a single operation instead of per-character key events. - // This makes pasting large strings (DIDs) instant. - let trimmed = text.trim().to_string(); + // This makes pasting large strings (DIDs) instant. The paste is + // inserted at the cursor, as on the main page (#578) — it used to + // replace the whole field here and append there. + let trimmed = text.trim(); + let insert = |target: &mut tui_input::Input| { + for c in trimmed.chars().filter(|c| !c.is_control()) { + target.handle(tui_input::InputRequest::InsertChar(c)); + } + }; match self.props.state.active_page { SetupPage::ConfigImport => { let target = match self.config_import.active_input { @@ -207,7 +214,7 @@ impl Component for SetupFlow { 1 => &mut self.config_import.config_unlock_passphrase, _ => &mut self.config_import.new_unlock_passphrase, }; - *target = tui_input::Input::new(trimmed); + insert(target); } SetupPage::UnlockCodeSet => { let target = if self.unlock_code_set.active_input == 0 { @@ -215,17 +222,13 @@ impl Component for SetupFlow { } else { &mut self.unlock_code_set.confirm }; - *target = tui_input::Input::new(trimmed); - } - SetupPage::VtaEnterDid => { - self.vta_enter_did.vta_did = tui_input::Input::new(trimmed); - } - SetupPage::VtaAclInstructions => { - self.vta_acl_instructions.context_id = tui_input::Input::new(trimmed); + insert(target); } + SetupPage::VtaEnterDid => insert(&mut self.vta_enter_did.vta_did), + SetupPage::VtaAclInstructions => insert(&mut self.vta_acl_instructions.context_id), #[cfg(feature = "openpgp-card")] SetupPage::TokenSetCardholderName => { - self.token_set_cardholder_name.name = tui_input::Input::new(trimmed); + insert(&mut self.token_set_cardholder_name.name); } _ => {} }