From 2143c2e10172df2fa6cc8f4f3aba1deb188f6323 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 15 Nov 2026 14:33:17 +1000 Subject: [PATCH] refactor(ui): move per-panel key handling into the panel modules Each panel's handle_*_key method (and the free key-map helpers only it uses) now lives beside the panel's rendering under ui/pages/main/components/. Panels whose component file was already large (identity, vetting, repos, communities) get a keys submodule instead. Global routing, the dispatch-by-menu match, the overlays and paste handling stay in ui/pages/main/mod.rs. Pure move: no logic changes. Only imports, visibility (pub(super) / pub(in crate::ui::pages::main)) and two misplaced doc comments reattached to the items they describe. Closes #533 Signed-off-by: Glenn Gore --- .../main/components/capabilities_panel.rs | 47 + .../main/components/communities_panel.rs | 5 + .../main/components/communities_panel/keys.rs | 535 +++ .../ui/pages/main/components/content_panel.rs | 66 + .../main/components/credentials_panel.rs | 180 + .../pages/main/components/elections_panel.rs | 77 + .../pages/main/components/identity_panel.rs | 2 + .../main/components/identity_panel/keys.rs | 411 +++ .../ui/pages/main/components/inbox_panel.rs | 163 + .../ui/pages/main/components/logs_panel.rs | 72 + .../main/components/relationships_panel.rs | 338 ++ .../ui/pages/main/components/repos_panel.rs | 2 + .../pages/main/components/repos_panel/keys.rs | 239 ++ .../ui/pages/main/components/rooms_panel.rs | 18 + .../pages/main/components/settings_panel.rs | 453 +++ .../ui/pages/main/components/vetting_panel.rs | 2 + .../main/components/vetting_panel/keys.rs | 262 ++ .../src/ui/pages/main/components/vta_panel.rs | 108 + openvtc/src/ui/pages/main/mod.rs | 3138 +---------------- 19 files changed, 3115 insertions(+), 3003 deletions(-) create mode 100644 openvtc/src/ui/pages/main/components/communities_panel/keys.rs create mode 100644 openvtc/src/ui/pages/main/components/identity_panel/keys.rs create mode 100644 openvtc/src/ui/pages/main/components/repos_panel/keys.rs create mode 100644 openvtc/src/ui/pages/main/components/vetting_panel/keys.rs diff --git a/openvtc/src/ui/pages/main/components/capabilities_panel.rs b/openvtc/src/ui/pages/main/components/capabilities_panel.rs index 2548510a..e38b4463 100644 --- a/openvtc/src/ui/pages/main/components/capabilities_panel.rs +++ b/openvtc/src/ui/pages/main/components/capabilities_panel.rs @@ -1,6 +1,9 @@ //! Capabilities panel — the per-community `governance/capability/*` view, //! opened from the Communities panel with `c`. +use crate::state_handler::actions::Action; +use crate::ui::pages::main::MainPage; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::style::Style; use ratatui::text::{Line, Span}; use serde_json::Value; @@ -238,3 +241,47 @@ fn render_manifest( kv(lines, "enabled at", at.clone()); } } + +impl MainPage { + /// Keys for the per-community capabilities view. + pub(super) fn handle_capabilities_key( + &mut self, + key: KeyEvent, + view: &crate::state_handler::main_page::content::CapabilitiesView, + ) -> bool { + // Toggle confirmation pending: y/⏎ commits, anything else cancels. + if view.confirm_toggle.is_some() { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + let _ = self.action_tx.send(Action::CapabilitiesToggleCommit); + } + _ => { + let _ = self.action_tx.send(Action::CapabilitiesToggleCancel); + } + } + return true; + } + match key.code { + KeyCode::Esc => { + let _ = self.action_tx.send(Action::CapabilitiesClose); + } + KeyCode::Up | KeyCode::Char('k') => { + let _ = self.action_tx.send(Action::CapabilitiesUp); + } + KeyCode::Down | KeyCode::Char('j') => { + let _ = self.action_tx.send(Action::CapabilitiesDown); + } + 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); + } + _ => return false, + } + true + } +} diff --git a/openvtc/src/ui/pages/main/components/communities_panel.rs b/openvtc/src/ui/pages/main/components/communities_panel.rs index b76be1c6..b8b67ac4 100644 --- a/openvtc/src/ui/pages/main/components/communities_panel.rs +++ b/openvtc/src/ui/pages/main/components/communities_panel.rs @@ -1,3 +1,8 @@ +//! Communities panel — the persona's community memberships and the sub-views +//! opened from them. Key handling lives in the `keys` submodule. + +mod keys; + use super::panel::Panel; use crate::colors::{ COLOR_DARK_GRAY, COLOR_ORANGE, COLOR_SOFT_PURPLE, COLOR_SUCCESS, COLOR_TEXT_DEFAULT, diff --git a/openvtc/src/ui/pages/main/components/communities_panel/keys.rs b/openvtc/src/ui/pages/main/components/communities_panel/keys.rs new file mode 100644 index 00000000..f8bc8b86 --- /dev/null +++ b/openvtc/src/ui/pages/main/components/communities_panel/keys.rs @@ -0,0 +1,535 @@ +//! Communities panel key handling, including the sub-views it routes to +//! (capabilities, repos, rooms, elections, personhood evidence, context +//! deletion and device access). + +use crate::state_handler::actions::Action; +use crate::state_handler::main_page::MainPanel; +use crate::ui::pages::main::MainPage; +use crate::ui::pages::main::components::{ + self as components, + elections_panel::elections_key, + repos_panel::keys::{repos_copy_path, repos_key}, + rooms_panel::rooms_key, +}; +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 + /// (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 { + // Capabilities view open: it owns the keys until closed (Esc). + if let Some(view) = self.props.main_page.content_panel.capabilities.view.clone() { + return self.handle_capabilities_key(key, &view); + } + // Repos view open: the same. + if let Some(view) = self.props.main_page.content_panel.repos.view.clone() { + if key.code == KeyCode::Char('p') + && let Some(path) = repos_copy_path(&view) + { + let said = match crate::clipboard::copy_to_clipboard(&path) { + Ok(method) => format!("✓ Path copied via {}", method.label()), + Err(e) => format!("✗ Copy failed: {e}"), + }; + let _ = self.action_tx.send(Action::Repos( + crate::state_handler::actions::ReposAction::Workspace( + crate::state_handler::actions::WorkspaceAction::Copied(said), + ), + )); + return true; + } + let action = repos_key(key, &view); + let handled = action.is_some(); + if let Some(action) = action { + let _ = self.action_tx.send(Action::Repos(action)); + } + return handled; + } + // Rooms view open: the same. + if let Some(view) = self.props.main_page.content_panel.rooms.view.as_ref() { + let action = rooms_key(key, view); + let handled = action.is_some(); + if let Some(action) = action { + let _ = self.action_tx.send(Action::Rooms(action)); + } + return handled; + } + // Elections view open: the same. + if let Some(view) = self.props.main_page.content_panel.elections.view.as_ref() { + let action = elections_key(key, view); + let handled = action.is_some(); + if let Some(action) = action { + let _ = self.action_tx.send(Action::Elections(action)); + } + return handled; + } + // The personhood witness-credential picker owns the keys while open. + if let Some(view) = self + .props + .main_page + .content_panel + .communities + .personhood_evidence + .clone() + { + let action = personhood_evidence_key(key, &view); + let handled = action.is_some(); + if let Some(action) = action { + let _ = self.action_tx.send(action); + } + return handled; + } + // Deleting a context, or device access, owns the keys while open. + if let Some(view) = self + .props + .main_page + .content_panel + .communities + .context_delete + .clone() + { + return self.handle_context_delete_key(key, &view); + } + if let Some(view) = self + .props + .main_page + .content_panel + .communities + .device_access + .clone() + { + return self.handle_device_access_key(key, &view); + } + let comms = &self.props.main_page.content_panel.communities; + // `count` is the memberships — every index-taking action below indexes + // them, so a cursor on a "Joining" row (`selected >= count`) leaves + // those keys inert. `rows` is what the cursor moves over. + let count = comms.items.len(); + let rows = count + comms.joining.len(); + let selected = comms.selected_index; + let joining = components::communities_panel::joining_selected(comms) + .map(|j| j.application_id.clone()); + + // A removal confirmation is pending: only confirm (y/Enter) or cancel + // (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 => { + let _ = self.action_tx.send(Action::DeleteCommunity(idx)); + } + _ => { + let _ = self.action_tx.send(Action::CommunityCancelDelete); + } + } + return true; + } + // 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 => { + let _ = self.action_tx.send(Action::LeaveCommunity(idx)); + } + _ => { + let _ = self.action_tx.send(Action::CommunityCancelLeave); + } + } + return true; + } + // 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 => { + let _ = self.action_tx.send(Action::WithdrawJoin(idx)); + } + _ => { + let _ = self.action_tx.send(Action::CommunityCancelWithdraw); + } + } + 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); + let sel_pending = comms.items.get(selected).is_some_and(|c| c.is_pending); + let sel_renewable = comms + .items + .get(selected) + .is_some_and(|c| c.is_active || c.approved_awaiting_credential); + let sel_own_context = comms.items.get(selected).is_some_and(|c| c.has_own_context); + + match key.code { + KeyCode::Char('j') => { + let _ = self.action_tx.send(Action::StartJoin); + true + } + KeyCode::Up if rows > 0 => { + let _ = self + .action_tx + .send(Action::CommunitySelect(selected.saturating_sub(1))); + true + } + KeyCode::Down if rows > 0 => { + let _ = self + .action_tx + .send(Action::CommunitySelect((selected + 1).min(rows - 1))); + true + } + KeyCode::Enter if selected < count => { + // Make the highlighted community the working context (R-C-6). + let _ = self.action_tx.send(Action::SetActiveCommunity(selected)); + true + } + KeyCode::Enter if joining.is_some() => { + // A join in progress: its journey on the Vetting page, where + // every step of the application is taken. + if let Some(id) = joining { + let _ = self.action_tx.send(Action::Vetting( + crate::state_handler::actions::VettingAction::OpenApplication(id), + )); + } + true + } + KeyCode::Char('f') if selected < count => { + // Toggle the favourite star (R-C-4); the row re-sorts to the top. + let _ = self.action_tx.send(Action::ToggleFavourite(selected)); + true + } + KeyCode::Char('a') if selected < count => { + // Acknowledge a terminal outcome, clearing its badge (R-S-2). + let _ = self.action_tx.send(Action::AcknowledgeCommunity(selected)); + true + } + KeyCode::Char('l') if sel_active => { + // Leave an Active community (R-L-1) — arm the y/n confirmation. + let _ = self.action_tx.send(Action::CommunityConfirmLeave(selected)); + true + } + 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)); + true + } + KeyCode::Char('R') if sel_renewable => { + // Ask the community to renew this membership — re-issue its + // membership and role credentials (vtc/members/renew/0.1). This + // is how a member replaces credentials set aside as pre-v1, and + // 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)); + true + } + KeyCode::Char('c') if sel_active => { + // Open the community's capabilities view (governance/capability/*). + let _ = self.action_tx.send(Action::CapabilitiesOpen(selected)); + true + } + KeyCode::Char('r') if sel_active => { + // Open the community's Repos view (git-ns/*). + let _ = self.action_tx.send(Action::Repos( + crate::state_handler::actions::ReposAction::Open(selected), + )); + true + } + KeyCode::Char('o') if sel_active => { + // Open the community's data rooms (vtc/rooms/memberships/*). + let _ = self.action_tx.send(Action::Rooms( + crate::state_handler::actions::RoomsAction::Open(selected), + )); + true + } + KeyCode::Char('e') if sel_active => { + // Open the community's elections (vtc/elections/*). + let _ = self.action_tx.send(Action::Elections( + crate::state_handler::actions::ElectionsAction::Open(selected), + )); + true + } + // Cancel is pending-only: a Pending join can be withdrawn (arming on + // any other state would only fail downstream, so it's gated). + KeyCode::Char('c') if sel_pending => { + let _ = self + .action_tx + .send(Action::CommunityConfirmWithdraw(selected)); + true + } + KeyCode::Char('p') if sel_active => { + // Ask this community for a personhood challenge. Active-only: + // a community that has not admitted us has no personhood state + // to assert against. + let _ = self + .action_tx + .send(Action::RequestPersonhoodChallenge(selected)); + true + } + // Assert is offered only while a live challenge is in hand. + // Gating on the challenge rather than on the row means the key does + // nothing surprising when there is nothing to answer — and the + // panel only advertises it in the same condition. + KeyCode::Char('P') + if comms + .personhood_challenge + .as_ref() + .is_some_and(|c| c.is_live(chrono::Utc::now())) => + { + // Asks first: the witness credentials to present are chosen in a picker + // that opens with none chosen. + let _ = self + .action_tx + .send(Action::AssertPersonhood { witnessed: None }); + true + } + KeyCode::Char('x') if sel_inactive => { + // Archive an inactive community (R-C-8). + let _ = self.action_tx.send(Action::ArchiveCommunity(selected)); + true + } + KeyCode::Char('v') => { + // Toggle whether archived communities are listed (R-C-8). + let _ = self.action_tx.send(Action::ToggleShowArchived); + true + } + // Delete is inactive-only (R-C-8): an Active community must be left + // first. Arming on Active would only fail downstream, so it's gated. + KeyCode::Char('d') | KeyCode::Delete if sel_inactive => { + let _ = self + .action_tx + .send(Action::CommunityConfirmDelete(selected)); + true + } + // Delete a finished membership's own context. Never offered for the + // top context or a live membership; what else uses the context is + // checked in the loop before the VTA is asked anything. + KeyCode::Char('D') if sel_inactive && sel_own_context => { + let _ = self.action_tx.send(Action::CommunityContext( + crate::state_handler::actions::CommunityContextAction::DeleteStart(selected), + )); + true + } + // Device access, scoped to this community's own context. + KeyCode::Char('g') if sel_own_context => { + let _ = self.action_tx.send(Action::CommunityContext( + crate::state_handler::actions::CommunityContextAction::DevicesOpen(selected), + )); + true + } + KeyCode::Esc => { + let _ = self + .action_tx + .send(Action::MainPanelSwitch(MainPanel::MainMenu)); + true + } + _ => false, + } + } + + /// Context-deletion keys, while one is open. While the VTA is previewing, + /// only Esc (close). With the preview shown: typing builds the + /// confirmation, Backspace erases, Enter deletes, Esc closes. While the + /// delete runs, nothing — it cannot be called back. Paste is deliberately + /// not accepted into the confirmation: typing it is the point. + fn handle_context_delete_key( + &mut self, + key: KeyEvent, + view: &crate::state_handler::main_page::content::ContextDeleteView, + ) -> bool { + use crate::state_handler::actions::CommunityContextAction as C; + use crate::state_handler::main_page::content::ContextDeletePhase as Phase; + let action = match (&view.phase, key.code) { + (Phase::Deleting, _) => None, + (_, KeyCode::Esc) => Some(C::DeleteCancel), + (Phase::Ready(_), KeyCode::Enter) => Some(C::DeleteConfirm), + (Phase::Ready(_), KeyCode::Backspace) => { + let mut typed = view.typed.clone(); + typed.pop(); + Some(C::DeleteInput(typed)) + } + (Phase::Ready(_), KeyCode::Char(c)) => { + Some(C::DeleteInput(format!("{}{c}", view.typed))) + } + _ => None, + }; + let handled = action.is_some(); + if let Some(action) = action { + let _ = self.action_tx.send(Action::CommunityContext(action)); + } + handled + } + + /// Device-access keys, while the view is open. With the grant form open: + /// typing fills the focused field, Tab/↑/↓ move between fields, ←/→ choose + /// the expiry, Enter grants, Esc closes the form. With a revocation armed: + /// y/Enter revokes, anything else disarms. Otherwise ↑/↓ select, `n` adds a + /// device (live memberships only), `x`/Delete arms a revocation, `r` + /// re-reads, Esc closes. While a request runs, only Esc (close). + fn handle_device_access_key( + &mut self, + key: KeyEvent, + view: &crate::state_handler::main_page::content::DeviceAccessView, + ) -> bool { + use crate::state_handler::actions::CommunityContextAction as C; + use crate::state_handler::main_page::content::GrantForm; + let grants = self + .props + .main_page + .content_panel + .communities + .device_grants + .get(&view.context_id) + .map_or(0, |l| l.grants().len()); + let action = if view.busy { + matches!(key.code, KeyCode::Esc).then_some(C::DevicesClose) + } else if let Some(form) = &view.form { + let fields = GrantForm::FIELDS; + match key.code { + KeyCode::Esc => Some(C::GrantCancel), + KeyCode::Enter => Some(C::GrantSubmit), + KeyCode::Tab | KeyCode::Down => Some(C::GrantField((form.field + 1) % fields)), + KeyCode::BackTab | KeyCode::Up => { + Some(C::GrantField((form.field + fields - 1) % fields)) + } + KeyCode::Left if form.field == 2 => { + Some(C::GrantExpiry(form.expiry.saturating_sub(1))) + } + KeyCode::Right if form.field == 2 => Some(C::GrantExpiry(form.expiry + 1)), + KeyCode::Backspace if form.field < 2 => { + let mut value = grant_form_text(form).to_string(); + value.pop(); + Some(C::GrantInput { + field: form.field, + value, + }) + } + KeyCode::Char(c) if form.field < 2 => Some(C::GrantInput { + field: form.field, + value: format!("{}{c}", grant_form_text(form)), + }), + _ => None, + } + } else if view.confirm_revoke { + Some(match key.code { + KeyCode::Char('y') | KeyCode::Enter => C::RevokeConfirm, + _ => C::RevokeCancel, + }) + } else { + match key.code { + KeyCode::Esc => Some(C::DevicesClose), + KeyCode::Up if grants > 0 => { + Some(C::DevicesSelect(view.selected.saturating_sub(1))) + } + KeyCode::Down if grants > 0 => Some(C::DevicesSelect(view.selected + 1)), + KeyCode::Char('n') if view.can_grant => Some(C::GrantStart), + KeyCode::Char('x') | KeyCode::Delete if grants > 0 => Some(C::RevokeArm), + KeyCode::Char('r') => Some(C::DevicesRefresh), + _ => None, + } + }; + let handled = action.is_some(); + if let Some(action) = action { + let _ = self.action_tx.send(Action::CommunityContext(action)); + } + handled + } +} + +/// A key in the personhood witness-credential picker. +/// +/// "Include all" is two keys: `A` asks, and only `y` to that question includes them; any other +/// key backs out with the choices as they were. Enter asserts with exactly what is chosen — +/// nothing, unless something was. +fn personhood_evidence_key( + key: KeyEvent, + view: &crate::state_handler::main_page::content::PersonhoodEvidenceView, +) -> Option { + use crate::state_handler::actions::PersonhoodEvidenceAction as E; + if view.confirm_all { + return Some(Action::PersonhoodEvidence(match key.code { + KeyCode::Char('y') => E::ConfirmIncludeAll, + _ => E::CancelIncludeAll, + })); + } + let action = match key.code { + KeyCode::Up => E::Up, + KeyCode::Down => E::Down, + KeyCode::Char(' ') => E::Toggle, + KeyCode::Char('A') => E::ArmIncludeAll, + KeyCode::Esc => E::Close, + KeyCode::Enter => { + return Some(Action::AssertPersonhood { + witnessed: Some(view.selected()), + }); + } + _ => return None, + }; + Some(Action::PersonhoodEvidence(action)) +} + +#[cfg(test)] +mod personhood_evidence_key_tests { + use super::*; + use crate::state_handler::actions::PersonhoodEvidenceAction as E; + use crate::state_handler::main_page::content::{PersonhoodEvidenceView, WitnessChoice}; + use crossterm::event::KeyModifiers; + + fn view(chosen: Vec, confirm_all: bool) -> PersonhoodEvidenceView { + PersonhoodEvidenceView { + choices: (0..chosen.len()) + .map(|i| WitnessChoice { + session_id: format!("urn:uuid:{i}"), + witness: "w".into(), + counterparty: "c".into(), + witnessed_at: "t".into(), + digest: "z".into(), + }) + .collect(), + chosen, + confirm_all, + ..PersonhoodEvidenceView::default() + } + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + /// Enter asserts with exactly what is chosen — nothing when nothing is. + #[test] + fn enter_presents_only_the_chosen() { + match personhood_evidence_key(key(KeyCode::Enter), &view(vec![false, false], false)) { + Some(Action::AssertPersonhood { witnessed: Some(w) }) => assert!(w.is_empty()), + _ => panic!("expected an assertion with nothing chosen"), + } + match personhood_evidence_key(key(KeyCode::Enter), &view(vec![false, true], false)) { + Some(Action::AssertPersonhood { witnessed: Some(w) }) => { + assert_eq!(w, vec!["urn:uuid:1".to_string()]) + } + _ => panic!("expected an assertion with the one chosen"), + } + } + + /// `A` only asks; `y` to the question includes all, any other key backs out. + #[test] + fn include_all_is_a_separate_confirmation() { + assert!(matches!( + personhood_evidence_key(key(KeyCode::Char('A')), &view(vec![false], false)), + Some(Action::PersonhoodEvidence(E::ArmIncludeAll)) + )); + assert!( + personhood_evidence_key(key(KeyCode::Char('y')), &view(vec![false], false)).is_none(), + "`y` means nothing until include-all was asked for" + ); + assert!(matches!( + personhood_evidence_key(key(KeyCode::Char('y')), &view(vec![false], true)), + Some(Action::PersonhoodEvidence(E::ConfirmIncludeAll)) + )); + for other in [KeyCode::Enter, KeyCode::Char('n'), KeyCode::Esc] { + assert!(matches!( + personhood_evidence_key(key(other), &view(vec![false], true)), + Some(Action::PersonhoodEvidence(E::CancelIncludeAll)) + )); + } + } +} diff --git a/openvtc/src/ui/pages/main/components/content_panel.rs b/openvtc/src/ui/pages/main/components/content_panel.rs index f5b939ea..8db2a719 100644 --- a/openvtc/src/ui/pages/main/components/content_panel.rs +++ b/openvtc/src/ui/pages/main/components/content_panel.rs @@ -1,7 +1,12 @@ +//! Content panel — the right-hand pane that draws the selected menu's panel, +//! plus the Help view's key handling. + use crate::colors::{ COLOR_BORDER, COLOR_DARK_GRAY, COLOR_SOFT_PURPLE, COLOR_SUCCESS, COLOR_TEXT_DEFAULT, COLOR_WARNING_ACCESSIBLE_RED, }; +use crate::state_handler::actions::Action; +use crate::state_handler::main_page::MainPanel; use crate::state_handler::{ main_page::{ ActivityLogEntry, @@ -10,6 +15,9 @@ use crate::state_handler::{ }, state::{ConnectionState, MediatorStatus}, }; +use crate::ui::pages::main::MainPage; +use crate::ui::pages::main::copy_to_clipboard; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, layout::{Alignment, Margin, Rect}, @@ -352,3 +360,61 @@ fn render_status_help( lines } + +impl MainPage { + pub(in crate::ui::pages::main) fn handle_help_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char('1') => { + // Copy persona DID to clipboard + let did = self + .props + .main_page + .content_panel + .settings + .persona_did + .clone(); + copy_to_clipboard(&did, "Persona DID", &self.action_tx); + true + } + KeyCode::Char('2') => { + // Copy mediator DID to clipboard + let did = self + .props + .main_page + .content_panel + .settings + .mediator_did + .clone(); + copy_to_clipboard(&did, "Mediator DID", &self.action_tx); + true + } + KeyCode::Char('3') => { + if let Some(info) = &self.props.main_page.content_panel.settings.did_git_sign { + copy_to_clipboard( + &info.did_key_id, + "Signing principal (DID#kid)", + &self.action_tx, + ); + } + true + } + KeyCode::Char('4') => { + if let Some(info) = &self.props.main_page.content_panel.settings.did_git_sign { + copy_to_clipboard( + &info.ssh_public_key, + "SSH signing public key", + &self.action_tx, + ); + } + true + } + KeyCode::Esc => { + let _ = self + .action_tx + .send(Action::MainPanelSwitch(MainPanel::MainMenu)); + true + } + _ => false, + } + } +} diff --git a/openvtc/src/ui/pages/main/components/credentials_panel.rs b/openvtc/src/ui/pages/main/components/credentials_panel.rs index 44b4be29..3e68757e 100644 --- a/openvtc/src/ui/pages/main/components/credentials_panel.rs +++ b/openvtc/src/ui/pages/main/components/credentials_panel.rs @@ -1,8 +1,13 @@ +//! Credentials panel — held credentials, their detail and new requests, with +//! the panel's key handling. + use super::panel::Panel; use crate::colors::{ COLOR_DARK_GRAY, COLOR_ORANGE, COLOR_SOFT_PURPLE, COLOR_SUCCESS, COLOR_TEXT_DEFAULT, COLOR_WARNING_ACCESSIBLE_RED, }; +use crate::state_handler::actions::{Action, CredentialAction}; +use crate::state_handler::main_page::MainPanel; use crate::state_handler::{ main_page::content::{ ContentPanelState, CredentialTab, CredentialsMode, CredentialsState, RelationshipsState, @@ -10,6 +15,8 @@ use crate::state_handler::{ state::ConnectionState, }; use crate::ui::badges; +use crate::ui::pages::main::{MainPage, copy_to_clipboard, edit_text}; +use crossterm::event::{KeyCode, KeyEvent}; use openvtc_core::display::display_identifier; use ratatui::{ style::{Style, Stylize}, @@ -384,6 +391,179 @@ fn truncate(text: &str, max: usize) -> String { } } +impl MainPage { + pub(in crate::ui::pages::main) fn handle_credentials_key(&mut self, key: KeyEvent) -> bool { + use crate::state_handler::main_page::content::CredentialsMode; + + let creds = &self.props.main_page.content_panel.credentials; + + match &creds.mode { + CredentialsMode::NewRequest { + relationship_index, + reason_input, + } => { + let rel_idx = *relationship_index; + match key.code { + KeyCode::Esc => { + let _ = self + .action_tx + .send(Action::Credential(CredentialAction::CancelNewRequest)); + true + } + KeyCode::Up if rel_idx > 0 => { + let _ = self.action_tx.send(Action::Credential( + CredentialAction::SelectRelationship(rel_idx - 1), + )); + true + } + KeyCode::Down => { + // Bound check happens in state handler + let _ = self.action_tx.send(Action::Credential( + CredentialAction::SelectRelationship(rel_idx + 1), + )); + true + } + KeyCode::Enter => { + // Get the established relationships from the relationships panel state + let established: Vec<_> = self + .props + .main_page + .content_panel + .relationships + .relationships + .iter() + .filter(|r| r.state == "Established") + .collect(); + if let Some(rel) = established.get(rel_idx) { + let _ = self.action_tx.send(Action::Credential( + CredentialAction::SubmitRequest { + relationship_p_did: rel.remote_p_did.clone(), + reason: if reason_input.trim().is_empty() { + None + } else { + Some(reason_input.clone()) + }, + }, + )); + } + true + } + code => match edit_text(code, reason_input) { + Some(r) => { + let _ = self + .action_tx + .send(Action::Credential(CredentialAction::ReasonUpdate(r))); + true + } + None => false, + }, + } + } + CredentialsMode::Detail { index } => { + let detail_index = *index; + + // A removal confirmation is pending: y/Enter commits, anything + // 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 => { + let _ = self + .action_tx + .send(Action::Credential(CredentialAction::Remove { vrc_id })); + } + _ => { + let _ = self + .action_tx + .send(Action::Credential(CredentialAction::CancelRemove)); + } + } + return true; + } + + match key.code { + KeyCode::Esc => { + let _ = self + .action_tx + .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) + { + let _ = self.action_tx.send(Action::Credential( + CredentialAction::ConfirmRemove { + vrc_id: vrc.vrc_id.clone(), + }, + )); + } + true + } + KeyCode::Char('c') => { + if let Some(vrc) = creds.active_list().get(detail_index) { + copy_to_clipboard( + &vrc.raw_json.to_pretty_json(), + "credential", + &self.action_tx, + ); + } + true + } + _ => false, + } + } + CredentialsMode::List => { + let active_list_len = creds.active_list().len(); + let selected = creds.selected_index; + + match key.code { + KeyCode::Tab => { + let _ = self + .action_tx + .send(Action::Credential(CredentialAction::SwitchTab)); + true + } + KeyCode::Up if selected > 0 => { + let _ = self + .action_tx + .send(Action::Credential(CredentialAction::Select(selected - 1))); + true + } + KeyCode::Down if selected + 1 < active_list_len => { + let _ = self + .action_tx + .send(Action::Credential(CredentialAction::Select(selected + 1))); + true + } + KeyCode::Enter if selected < active_list_len => { + let _ = self + .action_tx + .send(Action::Credential(CredentialAction::OpenDetail(selected))); + true + } + KeyCode::Char('n') => { + let _ = self + .action_tx + .send(Action::Credential(CredentialAction::StartNewRequest)); + true + } + KeyCode::Esc => { + let _ = self + .action_tx + .send(Action::MainPanelSwitch(MainPanel::MainMenu)); + true + } + _ => false, + } + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/openvtc/src/ui/pages/main/components/elections_panel.rs b/openvtc/src/ui/pages/main/components/elections_panel.rs index 00b7459d..5568d4e1 100644 --- a/openvtc/src/ui/pages/main/components/elections_panel.rs +++ b/openvtc/src/ui/pages/main/components/elections_panel.rs @@ -13,6 +13,7 @@ //! tells the member of (VTI-NTF-063); a notified one is shown only as notified — who holds it, //! its identifier, whom it is put to and its period — until it is opened. +use crossterm::event::{KeyCode, KeyEvent}; use std::time::Instant; use ratatui::style::Style; @@ -784,3 +785,79 @@ fn render_result(view: &ElectionsView, lines: &mut Vec>) { keys(lines, &["Esc back"]); } } + +/// The Elections panel's keys, by screen. `None` when the key means nothing here. +/// +/// On the ballot, a text question takes printable keys as its answer, so its blank toggle is +/// `Ctrl-a`; elsewhere on the ballot `a` leaves the focused question blank. On the +/// confirmation only `y` casts — anything else goes back to the ballot, so a key held a +/// moment too long after `⏎` never casts. +pub(super) fn elections_key( + key: KeyEvent, + view: &crate::state_handler::main_page::elections::ElectionsView, +) -> Option { + use crate::state_handler::actions::ElectionsAction as E; + use crate::state_handler::main_page::elections::Screen; + let ctrl = key + .modifiers + .contains(crossterm::event::KeyModifiers::CONTROL); + Some(match view.screen { + Screen::List => { + let last = view.rows().len().saturating_sub(1); + 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, + _ => return None, + } + } + // Only `y` presents; anything else goes back without presenting (VTI-VOT-016). + Screen::Disclose => match key.code { + 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::Ballot => { + let typing = view + .form + .as_ref() + .zip(view.shown.as_ref()) + .is_some_and(|(f, s)| f.typing(s.manifest())); + match key.code { + KeyCode::Esc => E::Back, + KeyCode::Tab => E::BallotQuestion(1), + KeyCode::BackTab => E::BallotQuestion(-1), + KeyCode::Up => E::BallotOption(-1), + KeyCode::Down => E::BallotOption(1), + KeyCode::Enter => E::Review, + KeyCode::Char('a') if ctrl => E::BallotAbstain, + KeyCode::Backspace if typing => E::BallotBackspace, + KeyCode::Char(c) if typing && !ctrl => E::BallotChar(c), + KeyCode::Char(' ') => E::BallotToggle, + KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Right => E::BallotAdjust(1), + KeyCode::Char('-') | KeyCode::Left => E::BallotAdjust(-1), + KeyCode::Char('a') => E::BallotAbstain, + KeyCode::Char('k') => E::BallotOption(-1), + KeyCode::Char('j') => E::BallotOption(1), + _ => return None, + } + } + Screen::Confirm => match key.code { + KeyCode::Char('y') => E::Cast, + _ => E::Back, + }, + Screen::Result => match key.code { + KeyCode::Esc => E::Back, + KeyCode::Char('r') => 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 a93d77ef..2c3e8a6d 100644 --- a/openvtc/src/ui/pages/main/components/identity_panel.rs +++ b/openvtc/src/ui/pages/main/components/identity_panel.rs @@ -44,6 +44,8 @@ //! about a person's own data, so every agent-served tab draws the failure //! rather than an empty list. +mod keys; + use super::panel::Panel; use crate::colors::{ COLOR_DARK_GRAY, COLOR_ORANGE, COLOR_SOFT_PURPLE, COLOR_SUCCESS, COLOR_TEXT_DEFAULT, diff --git a/openvtc/src/ui/pages/main/components/identity_panel/keys.rs b/openvtc/src/ui/pages/main/components/identity_panel/keys.rs new file mode 100644 index 00000000..030c6191 --- /dev/null +++ b/openvtc/src/ui/pages/main/components/identity_panel/keys.rs @@ -0,0 +1,411 @@ +//! Identity-pane key handling, beside the pane's rendering in +//! `identity_panel.rs`. + +use crate::state_handler::actions::Action; +use crate::state_handler::main_page::MainPanel; +use crate::ui::pages::main::MainPage; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +impl MainPage { + /// Identity-pane keys. + /// + /// Three layers, in this order, and the order is what keeps them legible: + /// an open form or picker owns every key; then an armed confirmation owns + /// `y`/`n`; then the active tab's own verbs apply. Anything reaching layer + /// three has already been shown to be unambiguous. + pub(in crate::ui::pages::main) fn handle_personas_key(&mut self, key: KeyEvent) -> bool { + use crate::state_handler::actions::PersonaAction as PA; + use crate::state_handler::main_page::content::{ + PersonaConfirm, PersonaMode, PersonaTab, ProfileFormFocus, WorldFormFocus, + }; + + let personas = &self.props.main_page.content_panel.identity; + let send = |action: PA| { + let _ = self.action_tx.send(Action::Persona(action)); + true + }; + + // ── An open form or picker owns the keyboard ────────────────────── + match &personas.mode { + PersonaMode::Attribute(form) => { + // A write is in flight: swallow input rather than editing a + // form whose result is already on the wire. + if form.working { + return true; + } + return match key.code { + KeyCode::Esc => send(PA::FormCancel), + KeyCode::Enter => send(PA::FormSubmit), + KeyCode::Tab | KeyCode::Down => send(PA::FormField(true)), + KeyCode::BackTab | KeyCode::Up => send(PA::FormField(false)), + KeyCode::Right => send(PA::FormCycle(true)), + KeyCode::Left => send(PA::FormCycle(false)), + _ => send(PA::FormKey(key)), + }; + } + PersonaMode::Profile(form) => { + if form.working { + return true; + } + let on_entries = form.focus == ProfileFormFocus::Entries; + return match key.code { + KeyCode::Esc => send(PA::FormCancel), + KeyCode::Enter => send(PA::FormSubmit), + KeyCode::Tab => send(PA::FormField(true)), + KeyCode::BackTab => send(PA::FormField(false)), + KeyCode::Down if on_entries => send(PA::FormCycle(true)), + KeyCode::Up if on_entries => send(PA::FormCycle(false)), + // Only a keystroke *on the list* ticks an entry. On the + // name field a space is a space — a form that swallowed it + // would refuse to let a profile be called "Work laptop". + KeyCode::Char(' ') if on_entries => send(PA::FormToggleEntry), + _ => send(PA::FormKey(key)), + }; + } + PersonaMode::World(form) => { + if form.working { + return true; + } + let on_colour = form.focus == WorldFormFocus::Colour; + return match key.code { + KeyCode::Esc => send(PA::FormCancel), + KeyCode::Enter => send(PA::FormSubmit), + KeyCode::Tab => send(PA::FormField(true)), + KeyCode::BackTab => send(PA::FormField(false)), + // ←/→ move the colour wherever the focus is, so a holder + // who has not worked out that the swatch row is a field can + // still change it. On the colour field ↑/↓ do it too. + KeyCode::Right => send(PA::FormCycle(true)), + KeyCode::Left => send(PA::FormCycle(false)), + KeyCode::Down if on_colour => send(PA::FormCycle(true)), + KeyCode::Up if on_colour => send(PA::FormCycle(false)), + _ => send(PA::FormKey(key)), + }; + } + PersonaMode::PlaceFace(picker) => { + if picker.working { + return true; + } + return match key.code { + KeyCode::Esc => send(PA::FormCancel), + KeyCode::Enter => send(PA::FormSubmit), + KeyCode::Down => send(PA::FormCycle(true)), + KeyCode::Up => send(PA::FormCycle(false)), + _ => true, + }; + } + PersonaMode::Bind(picker) => { + if picker.working { + return true; + } + return match key.code { + KeyCode::Esc => send(PA::FormCancel), + KeyCode::Enter => send(PA::FormSubmit), + KeyCode::Down => send(PA::FormCycle(true)), + KeyCode::Up => send(PA::FormCycle(false)), + _ => true, + }; + } + PersonaMode::Compose(form) => { + if form.working { + return true; + } + return match key.code { + KeyCode::Esc => send(PA::FormCancel), + KeyCode::Enter => send(PA::FormSubmit), + KeyCode::Tab => send(PA::FormField(true)), + KeyCode::BackTab => send(PA::FormField(false)), + KeyCode::Down => send(PA::FormCycle(true)), + KeyCode::Up => send(PA::FormCycle(false)), + // Not space: a value is text, and "Ada King" has one. + KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => { + send(PA::FormToggleEntry) + } + _ => send(PA::FormKey(key)), + }; + } + PersonaMode::LocalFaces(view) => { + if view.working { + return true; + } + // The form owns the keyboard while it is up, so a name can + // contain the letters the list uses as verbs. + if view.form.is_some() { + return match key.code { + KeyCode::Esc => send(PA::FormCancel), + KeyCode::Enter => send(PA::FormSubmit), + KeyCode::Tab => send(PA::FormField(true)), + KeyCode::BackTab => send(PA::FormField(false)), + KeyCode::Down => send(PA::FormCycle(true)), + KeyCode::Up => send(PA::FormCycle(false)), + _ => send(PA::FormKey(key)), + }; + } + return match key.code { + KeyCode::Esc => send(PA::FormCancel), + KeyCode::Enter => send(PA::FormSubmit), + KeyCode::Down => send(PA::FormCycle(true)), + KeyCode::Up => send(PA::FormCycle(false)), + KeyCode::Char(' ') => send(PA::FormToggleEntry), + KeyCode::Char('n') => send(PA::LocalFaceNew), + KeyCode::Char('w') => send(PA::LocalFaceWear), + KeyCode::Char('d') | KeyCode::Delete => send(PA::LocalFaceDeleteArm), + _ => true, + }; + } + // Read-only overlays: any key but Esc is ignored rather than + // falling through to the tab behind, which is not on screen. + PersonaMode::Renderers(_) | PersonaMode::KnownHere(_) => { + return match key.code { + KeyCode::Esc => send(PA::FormCancel), + _ => true, + }; + } + PersonaMode::People(view) => { + if view.working { + return true; + } + // The form owns the keyboard while it is up; the list's own + // verbs would otherwise eat letters being typed into a field. + if view.form.is_some() { + return match key.code { + KeyCode::Esc => send(PA::FormCancel), + KeyCode::Enter => send(PA::FormSubmit), + KeyCode::Tab => send(PA::FormField(true)), + KeyCode::BackTab => send(PA::FormField(false)), + KeyCode::Down => send(PA::FormCycle(true)), + KeyCode::Up => send(PA::FormCycle(false)), + _ => send(PA::FormKey(key)), + }; + } + 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, + }; + } + PersonaMode::View => {} + } + + // ── An armed confirmation owns y/n ──────────────────────────────── + // + // Removing a persona goes through the existing identity-deletion path + // (`DeleteDid`), which does the VTA delete, the listener teardown and + // the local cleanup. The pane owns the *question*; it does not own a + // second implementation of the answer. + match personas.confirm { + PersonaConfirm::None => {} + PersonaConfirm::DeletePersona(i) => { + let act = match key.code { + KeyCode::Char('y') | KeyCode::Enter => Action::DeleteDid(i), + _ => Action::DidCancelDelete, + }; + let _ = self.action_tx.send(act); + return true; + } + _ => { + return match key.code { + KeyCode::Char('y') | KeyCode::Enter => send(PA::ConfirmYes), + _ => send(PA::ConfirmNo), + }; + } + } + + // ── Tab navigation and the pane-wide verbs ──────────────────────── + match key.code { + KeyCode::Tab | KeyCode::Right => return send(PA::TabNext), + KeyCode::BackTab | KeyCode::Left => return send(PA::TabPrev), + KeyCode::Esc => { + // Esc backs out of the opened profile first, so it is not also + // the key that leaves the panel while a detail view is up. + if personas.open_profile.is_some() { + return send(PA::ProfileClose); + } + let _ = self + .action_tx + .send(Action::MainPanelSwitch(MainPanel::MainMenu)); + return true; + } + KeyCode::Char('r') if personas.tab.needs_agent() => return send(PA::Refresh), + _ => {} + } + + match personas.tab { + PersonaTab::Personas => { + let count = personas.personas.len(); + let selected = personas.persona_selected; + match key.code { + KeyCode::Up if count > 0 => { + let _ = self + .action_tx + .send(Action::DidSelect(selected.saturating_sub(1))); + true + } + KeyCode::Down if count > 0 => { + let _ = self + .action_tx + .send(Action::DidSelect((selected + 1).min(count - 1))); + true + } + KeyCode::Char('n') => { + let _ = self.action_tx.send(Action::StartCreatePersona); + true + } + KeyCode::Char('g') => { + let _ = self.action_tx.send(Action::StartAgentNameManager(selected)); + true + } + // Only an orphan is removable: a persona a community still + // presents cannot be deleted without leaving that community + // 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 => { + if personas + .personas + .get(selected) + .is_some_and(|f| f.bound_communities == 0) + { + let _ = self.action_tx.send(Action::DidConfirmDelete(selected)); + } + true + } + _ => false, + } + } + PersonaTab::Attributes => { + let count = personas.attributes.len(); + let selected = personas.attribute_selected; + match key.code { + KeyCode::Up if count > 0 => send(PA::Select(selected.saturating_sub(1))), + KeyCode::Down if count > 0 => send(PA::Select((selected + 1).min(count - 1))), + KeyCode::Char('v') => send(PA::ToggleValues), + // `s` shows one value; `v` asks the agent for all of them. + // Two different questions, and the narrower one is not a + // shortcut to the wider: `s` on a listing fetched without + // values has nothing to unmask. + KeyCode::Char('s') if selected < count => send(PA::RevealValue(selected)), + KeyCode::Char('n') => send(PA::AttributeNew), + KeyCode::Char('e') if selected < count => send(PA::AttributeEdit(selected)), + // `p` forgets the versions behind the current one. Not on + // `d`'s neighbour keys by accident: it is one-way, and the + // question names what stops being shown. + KeyCode::Char('p') if selected < count => send(PA::AttributePurgeArm(selected)), + KeyCode::Char('d') | KeyCode::Delete if selected < count => { + send(PA::AttributeDeleteArm(selected)) + } + _ => false, + } + } + PersonaTab::Profiles => { + let count = personas.profiles.len(); + let selected = personas.profile_selected; + // The opened detail view is a mode of this tab, not of the + // pane, so its keys live here. + if let Some(detail) = personas.open_profile.as_ref() { + let claims = detail.resolved.len(); + let claim = personas.face_claim_selected; + return match key.code { + KeyCode::Enter => send(PA::ProfileClose), + KeyCode::Up if claims > 0 => { + send(PA::FaceClaimSelect(claim.saturating_sub(1))) + } + KeyCode::Down if claims > 0 => { + send(PA::FaceClaimSelect((claim + 1).min(claims - 1))) + } + // The same `s` the attributes tab uses, on the same + // terms: one claim, the selected one, and pressing it + // again puts the mask back. There is no `v` here + // because there is nothing for it to ask — a face + // detail is resolved in full when it is opened, so + // every value is already in hand and the mask is the + // only thing between it and the screen. + KeyCode::Char('s') if claim < claims => send(PA::RevealFaceClaim(claim)), + KeyCode::Char('e') => send(PA::ProfileEdit(selected)), + KeyCode::Char('h') => send(PA::FaceHistory), + _ => false, + }; + } + // The retired view walks its own list and offers two verbs: + // bring one back, or return to the faces the holder wears. + if personas.show_retired { + let retired = personas.retired_faces.len(); + return match key.code { + KeyCode::Up if retired > 0 => send(PA::Select(selected.saturating_sub(1))), + KeyCode::Down if retired > 0 => { + send(PA::Select((selected + 1).min(retired - 1))) + } + KeyCode::Char('x') if selected < retired => { + send(PA::ProfileReinstate(selected)) + } + KeyCode::Char('z') => send(PA::ToggleRetired), + _ => false, + }; + } + match key.code { + KeyCode::Char('z') => send(PA::ToggleRetired), + KeyCode::Char('x') if selected < count => send(PA::ProfileRetireArm(selected)), + KeyCode::Up if count > 0 => send(PA::Select(selected.saturating_sub(1))), + KeyCode::Down if count > 0 => send(PA::Select((selected + 1).min(count - 1))), + KeyCode::Enter if selected < count => send(PA::ProfileOpen(selected)), + KeyCode::Char('n') => send(PA::ProfileNew), + KeyCode::Char('e') if selected < count => send(PA::ProfileEdit(selected)), + KeyCode::Char('d') | KeyCode::Delete if selected < count => { + send(PA::ProfileDeleteArm(selected)) + } + // `m` for *move*, on the tab where the face being moved is + // visible. A world's membership is edited from here and + // nowhere else — see `PersonaAction::FacePlaceOpen`. + KeyCode::Char('m') if selected < count => send(PA::FacePlaceOpen(selected)), + _ => false, + } + } + PersonaTab::Worlds => { + let count = personas.worlds.len(); + let selected = personas.world_selected; + match key.code { + KeyCode::Up if count > 0 => send(PA::Select(selected.saturating_sub(1))), + KeyCode::Down if count > 0 => send(PA::Select((selected + 1).min(count - 1))), + KeyCode::Char('n') => send(PA::WorldNew), + KeyCode::Char('e') if selected < count => send(PA::WorldEdit(selected)), + KeyCode::Char('d') | KeyCode::Delete if selected < count => { + send(PA::WorldDeleteArm(selected)) + } + _ => false, + } + } + PersonaTab::Disclosures => { + let count = personas.disclosures.len(); + let selected = personas.disclosure_selected; + match key.code { + KeyCode::Up if count > 0 => send(PA::Select(selected.saturating_sub(1))), + KeyCode::Down if count > 0 => send(PA::Select((selected + 1).min(count - 1))), + // What a format carries belongs beside what left through + // it, not on a screen of its own nobody opens. + KeyCode::Char('f') => send(PA::RenderersOpen), + _ => false, + } + } + PersonaTab::Communities => { + let count = personas.memberships.len(); + let selected = personas.membership_selected; + match key.code { + KeyCode::Up if count > 0 => send(PA::Select(selected.saturating_sub(1))), + KeyCode::Down if count > 0 => send(PA::Select((selected + 1).min(count - 1))), + KeyCode::Char('b') if selected < count => send(PA::BindOpen(selected)), + KeyCode::Char('c') if selected < count => send(PA::ComposeOpen(selected)), + KeyCode::Char('f') if selected < count => send(PA::LocalFacesOpen(selected)), + KeyCode::Char('k') if selected < count => send(PA::KnownHereOpen(selected)), + KeyCode::Char('p') if selected < count => send(PA::PeopleOpen(selected)), + KeyCode::Char('u') if selected < count => send(PA::UnbindArm(selected)), + _ => false, + } + } + } + } +} diff --git a/openvtc/src/ui/pages/main/components/inbox_panel.rs b/openvtc/src/ui/pages/main/components/inbox_panel.rs index 72275169..61e47906 100644 --- a/openvtc/src/ui/pages/main/components/inbox_panel.rs +++ b/openvtc/src/ui/pages/main/components/inbox_panel.rs @@ -1,12 +1,18 @@ +//! Inbox panel — pending tasks and their detail, with the panel's key handling. + use super::panel::Panel; use crate::colors::{ COLOR_DARK_GRAY, COLOR_ORANGE, COLOR_SOFT_PURPLE, COLOR_SUCCESS, COLOR_TEXT_DEFAULT, COLOR_WARNING_ACCESSIBLE_RED, }; +use crate::state_handler::actions::{Action, InboxAction}; +use crate::state_handler::main_page::MainPanel; use crate::state_handler::{ main_page::content::{ActiveTaskView, ContentPanelState, InboxConfirm, InboxState, TaskKind}, state::{ConnectionState, MediatorStatus}, }; +use crate::ui::pages::main::MainPage; +use crossterm::event::{KeyCode, KeyEvent}; use openvtc_core::display::display_identifier; use ratatui::{ style::{Style, Stylize}, @@ -401,3 +407,160 @@ pub fn render_task_detail(task: &ActiveTaskView) -> Vec> { lines } + +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). + if let Some(confirm) = inbox.confirm.clone() { + match key.code { + KeyCode::Char('y') | KeyCode::Enter => { + let commit = match confirm { + InboxConfirm::Dismiss { task_id } => InboxAction::DismissTask { task_id }, + InboxConfirm::ClearAll => InboxAction::ClearAll, + }; + let _ = self.action_tx.send(Action::Inbox(commit)); + } + _ => { + let _ = self + .action_tx + .send(Action::Inbox(InboxAction::CancelConfirm)); + } + } + return true; + } + + // If viewing a task detail, handle detail keys + if let Some(active_task) = &inbox.active_task { + // Extract what we need before borrowing self mutably + let task_id = match active_task { + ActiveTaskView::RelationshipRequestInbound { task_id, .. } + | ActiveTaskView::VRCRequestInbound { task_id, .. } + | ActiveTaskView::VRCIssued { task_id, .. } + | ActiveTaskView::RelationshipRequestOutbound { task_id, .. } + | ActiveTaskView::VRCRequestOutbound { task_id, .. } + | ActiveTaskView::Info { task_id, .. } => task_id.clone(), + }; + let is_rel_inbound = matches!( + active_task, + ActiveTaskView::RelationshipRequestInbound { .. } + ); + let is_vrc_issued = matches!(active_task, ActiveTaskView::VRCIssued { .. }); + let is_vrc_request_inbound = + matches!(active_task, ActiveTaskView::VRCRequestInbound { .. }); + + return match key.code { + KeyCode::Esc => { + let _ = self.action_tx.send(Action::Inbox(InboxAction::Back)); + true + } + // Accepting with a pairwise R-DID is the default path (#241); + // `A` stays wired to it so existing muscle memory keeps giving + // the private outcome it always gave. + KeyCode::Char('p') if is_rel_inbound => { + let _ = self + .action_tx + .send(Action::Inbox(InboxAction::AcceptRelationship { + task_id, + generate_r_did: false, + })); + true + } + KeyCode::Char('a') | KeyCode::Char('A') if is_rel_inbound => { + let _ = self + .action_tx + .send(Action::Inbox(InboxAction::AcceptRelationship { + task_id, + generate_r_did: true, + })); + true + } + KeyCode::Char('a') => { + if is_vrc_issued { + let _ = self + .action_tx + .send(Action::Inbox(InboxAction::AcceptVrc { task_id })); + } else if is_vrc_request_inbound { + let _ = self + .action_tx + .send(Action::Inbox(InboxAction::AcceptVrcRequest { task_id })); + } + 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, + })); + } + true + } + KeyCode::Char('d') => { + let _ = self + .action_tx + .send(Action::Inbox(InboxAction::ConfirmDismiss { task_id })); + true + } + _ => false, + }; + } + + // Task list navigation + let selected = inbox.selected_index; + let task_count = inbox.tasks.len(); + + match key.code { + KeyCode::Up if selected > 0 => { + let _ = self + .action_tx + .send(Action::Inbox(InboxAction::SelectTask(selected - 1))); + true + } + KeyCode::Down if selected + 1 < task_count => { + let _ = self + .action_tx + .send(Action::Inbox(InboxAction::SelectTask(selected + 1))); + true + } + KeyCode::Enter if selected < task_count => { + // All task types have detail views — let the state handler build it + let _ = self + .action_tx + .send(Action::Inbox(InboxAction::OpenDetail(selected))); + true + } + KeyCode::Char('d') if selected < task_count => { + let task_id = inbox.tasks[selected].id.clone(); + let _ = self + .action_tx + .send(Action::Inbox(InboxAction::ConfirmDismiss { task_id })); + true + } + KeyCode::Char('c') if task_count > 0 => { + let _ = self + .action_tx + .send(Action::Inbox(InboxAction::ConfirmClearAll)); + true + } + KeyCode::Esc => { + let _ = self + .action_tx + .send(Action::MainPanelSwitch(MainPanel::MainMenu)); + true + } + _ => false, + } + } +} diff --git a/openvtc/src/ui/pages/main/components/logs_panel.rs b/openvtc/src/ui/pages/main/components/logs_panel.rs index 1f937302..0d41940b 100644 --- a/openvtc/src/ui/pages/main/components/logs_panel.rs +++ b/openvtc/src/ui/pages/main/components/logs_panel.rs @@ -2,7 +2,11 @@ use super::status::wrap_text; 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::{MainPage, copy_to_clipboard}; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ style::{Style, Stylize}, text::{Line, Span}, @@ -99,3 +103,71 @@ pub fn render( lines } + +impl MainPage { + pub(in crate::ui::pages::main) fn handle_logs_key(&mut self, key: KeyEvent) -> bool { + let total = self.props.main_page.activity_log.len(); + + // Detail view mode — Esc or Enter to close + if self.logs_detail_view { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + self.logs_detail_view = false; + return true; + } + KeyCode::Char('c') if total > 0 => { + let entries: Vec<_> = self.props.main_page.activity_log.iter().rev().collect(); + if let Some(entry) = entries.get(self.logs_selected) { + copy_to_clipboard(&entry.summary, "Log entry", &self.action_tx); + } + return true; + } + _ => return false, + } + } + + match key.code { + KeyCode::Up if self.logs_selected > 0 => { + self.logs_selected -= 1; + true + } + KeyCode::Down if self.logs_selected + 1 < total => { + self.logs_selected += 1; + true + } + KeyCode::Enter if total > 0 => { + self.logs_detail_view = true; + true + } + KeyCode::Char('c') if total > 0 => { + // Copy selected log entry to clipboard + let entries: Vec<_> = self.props.main_page.activity_log.iter().rev().collect(); + if let Some(entry) = entries.get(self.logs_selected) { + copy_to_clipboard(&entry.summary, "Log entry", &self.action_tx); + } + true + } + KeyCode::Char('a') if total > 0 => { + // Copy all log entries to clipboard + let all_text: String = self + .props + .main_page + .activity_log + .iter() + .rev() + .map(|e| e.summary.as_str()) + .collect::>() + .join("\n"); + copy_to_clipboard(&all_text, "All log entries", &self.action_tx); + true + } + KeyCode::Esc => { + let _ = self + .action_tx + .send(Action::MainPanelSwitch(MainPanel::MainMenu)); + true + } + _ => false, + } + } +} diff --git a/openvtc/src/ui/pages/main/components/relationships_panel.rs b/openvtc/src/ui/pages/main/components/relationships_panel.rs index fa337e52..4781a30e 100644 --- a/openvtc/src/ui/pages/main/components/relationships_panel.rs +++ b/openvtc/src/ui/pages/main/components/relationships_panel.rs @@ -1,12 +1,19 @@ +//! Relationships panel — relationships, their detail and new requests, with +//! the panel's key handling. + use super::panel::Panel; use crate::colors::{ COLOR_DARK_GRAY, COLOR_ORANGE, COLOR_SOFT_PURPLE, COLOR_SUCCESS, COLOR_TEXT_DEFAULT, COLOR_WARNING_ACCESSIBLE_RED, }; +use crate::state_handler::actions::{Action, RelationshipAction}; +use crate::state_handler::main_page::MainPanel; use crate::state_handler::{ main_page::content::{ContentPanelState, RelationshipsMode, RelationshipsState}, state::ConnectionState, }; +use crate::ui::pages::main::{MainPage, edit_text}; +use crossterm::event::{KeyCode, KeyEvent}; use openvtc_core::config::account::RelationshipIdentifierDefault; use openvtc_core::display::display_identifier; use ratatui::{ @@ -682,6 +689,337 @@ fn render_form( lines } +impl MainPage { + pub(in crate::ui::pages::main) fn handle_relationships_key(&mut self, key: KeyEvent) -> bool { + use crate::state_handler::main_page::content::RelationshipsMode; + + let rels = &self.props.main_page.content_panel.relationships; + + match &rels.mode { + RelationshipsMode::NewRequest { + did_input, + alias_input, + reason_input, + generate_r_did, + active_field, + .. + } => { + // Form input handling + let active_field = *active_field; + let generate_r_did = *generate_r_did; + match key.code { + KeyCode::Esc => { + let _ = self + .action_tx + .send(Action::Relationship(RelationshipAction::CancelNewRequest)); + true + } + KeyCode::Tab => { + // Cycle through fields 0->1->2->3->0 + let next = (active_field + 1) % 4; + let _ = self + .action_tx + .send(Action::Relationship(RelationshipAction::FocusField(next))); + true + } + KeyCode::Up if active_field > 0 => { + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::FocusField(active_field - 1), + )); + true + } + KeyCode::Down if active_field < 3 => { + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::FocusField(active_field + 1), + )); + true + } + KeyCode::Char(' ') if active_field == 3 => { + // Toggle the generate_r_did boolean + let _ = self + .action_tx + .send(Action::Relationship(RelationshipAction::ToggleRDid)); + true + } + KeyCode::Enter if active_field == 3 => { + // Submit from the last field + let did = did_input.clone(); + let alias = alias_input.clone(); + let reason = if reason_input.trim().is_empty() { + None + } else { + Some(reason_input.clone()) + }; + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::SubmitRequest { + did, + alias, + reason, + generate_r_did, + }, + )); + 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, + } + } + RelationshipsMode::Detail { + index, + selected_vrc, + } => { + let index = *index; + let current_vrc = *selected_vrc; + + // A removal confirmation is pending: y/Enter commits, anything + // 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 => { + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::Remove { remote_p_did }, + )); + } + _ => { + let _ = self + .action_tx + .send(Action::Relationship(RelationshipAction::CancelRemove)); + } + } + return true; + } + + match key.code { + KeyCode::Down => { + if let Some(rel) = rels.relationships.get(index) { + let total = rel.vrcs_issued.len() + rel.vrcs_received.len(); + if total > 0 { + let next = match current_vrc { + None => Some(0), + Some(n) if n + 1 < total => Some(n + 1), + other => other, + }; + if let RelationshipsMode::Detail { + selected_vrc: ref mut sv, + .. + } = self.props.main_page.content_panel.relationships.mode + { + *sv = next; + } + } + } + true + } + KeyCode::Up => { + let next = match current_vrc { + Some(0) => None, + Some(n) => Some(n - 1), + None => None, + }; + if let RelationshipsMode::Detail { + selected_vrc: ref mut sv, + .. + } = self.props.main_page.content_panel.relationships.mode + { + *sv = next; + } + true + } + KeyCode::Esc => { + let _ = self + .action_tx + .send(Action::Relationship(RelationshipAction::Back)); + true + } + KeyCode::Char('e') => { + if let Some(rel) = rels.relationships.get(index) { + let current = rel.alias.clone().unwrap_or_default(); + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::StartEditAlias { + index, + current_alias: current, + }, + )); + } + true + } + KeyCode::Char('p') => { + if let Some(rel) = rels.relationships.get(index) { + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::Ping { + remote_p_did: rel.remote_p_did.clone(), + }, + )); + } + true + } + KeyCode::Char('d') => { + if let Some(rel) = rels.relationships.get(index) { + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::ConfirmRemove { + remote_p_did: rel.remote_p_did.clone(), + }, + )); + } + true + } + KeyCode::Char('v') => { + if let Some(rel) = rels.relationships.get(index) { + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::RequestVrc { + remote_p_did: rel.remote_p_did.clone(), + }, + )); + } + true + } + KeyCode::Char('w') => { + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::StartWitness { index }, + )); + true + } + _ => false, + } + } + RelationshipsMode::Witness { + index, + witness_input, + share_membership, + } => { + let index = *index; + match key.code { + KeyCode::Esc => { + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::CancelWitness { index }, + )); + true + } + KeyCode::Enter => { + if let Some(rel) = rels.relationships.get(index) { + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::Witness { + remote_p_did: rel.remote_p_did.clone(), + witness: witness_input.clone(), + share_membership: *share_membership, + }, + )); + } + true + } + // Tab, not a letter: the witness is typed into this prompt. + KeyCode::Tab => { + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::ToggleWitnessMembership, + )); + true + } + code => match edit_text(code, witness_input) { + Some(value) => { + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::WitnessInputUpdate(value), + )); + true + } + None => false, + }, + } + } + RelationshipsMode::EditAlias { index, alias_input } => { + let index = *index; + match key.code { + KeyCode::Esc => { + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::CancelEditAlias { index }, + )); + true + } + KeyCode::Enter => { + if let Some(rel) = rels.relationships.get(index) { + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::EditAlias { + remote_p_did: rel.remote_p_did.clone(), + alias: alias_input.clone(), + }, + )); + } + true + } + code => match edit_text(code, alias_input) { + Some(value) => { + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::EditAliasUpdate(value), + )); + true + } + None => false, + }, + } + } + RelationshipsMode::List => { + let selected = rels.selected_index; + let count = rels.relationships.len(); + + match key.code { + KeyCode::Up if selected > 0 => { + let _ = + self.action_tx + .send(Action::Relationship(RelationshipAction::Select( + selected - 1, + ))); + true + } + KeyCode::Down if selected + 1 < count => { + let _ = + self.action_tx + .send(Action::Relationship(RelationshipAction::Select( + selected + 1, + ))); + true + } + KeyCode::Enter if selected < count => { + let _ = self.action_tx.send(Action::Relationship( + RelationshipAction::OpenDetail(selected), + )); + true + } + KeyCode::Char('n') => { + let _ = self + .action_tx + .send(Action::Relationship(RelationshipAction::StartNewRequest)); + true + } + KeyCode::Esc => { + let _ = self + .action_tx + .send(Action::MainPanelSwitch(MainPanel::MainMenu)); + true + } + _ => false, + } + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/openvtc/src/ui/pages/main/components/repos_panel.rs b/openvtc/src/ui/pages/main/components/repos_panel.rs index 05b9dad3..e0aedc23 100644 --- a/openvtc/src/ui/pages/main/components/repos_panel.rs +++ b/openvtc/src/ui/pages/main/components/repos_panel.rs @@ -9,6 +9,8 @@ //! machine: whether it is checked out, where, and whether a commit made there //! would be signed as this persona — with the one key that fixes it. +pub(in crate::ui::pages::main) mod keys; + use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; diff --git a/openvtc/src/ui/pages/main/components/repos_panel/keys.rs b/openvtc/src/ui/pages/main/components/repos_panel/keys.rs new file mode 100644 index 00000000..c2229e1a --- /dev/null +++ b/openvtc/src/ui/pages/main/components/repos_panel/keys.rs @@ -0,0 +1,239 @@ +//! Repos panel key handling — the view's key map, opened from the +//! Communities panel with `r`. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// The Repos view's key map. `None` when the key means nothing here. +/// +/// Innermost first: an armed change takes only `y`/`⏎` (anything else +/// disarms it), then an open form, then the screen. +pub(in crate::ui::pages::main) fn repos_key( + key: KeyEvent, + view: &crate::state_handler::main_page::repos::ReposView, +) -> Option { + use crate::state_handler::actions::ReposAction as R; + use crate::state_handler::main_page::repos::{ + AddPersonForm, EXPIRY_CHOICES, LinkPhase, NewRepoForm, ReposScreen, + }; + use openvtc_core::git_ns::GitRight; + + // Only `y` confirms: Enter is what submitted the form that armed the + // change, and a key held a moment too long must not also confirm it. + if view.confirm.is_some() { + return Some(match key.code { + KeyCode::Char('y') => R::Confirm, + _ => R::Cancel, + }); + } + use crate::state_handler::actions::WorkspaceAction as W; + use crate::state_handler::main_page::repos::WorkspaceForm; + if view.workspace.confirm.is_some() { + return Some(R::Workspace(match key.code { + KeyCode::Char('y') => W::Confirm, + _ => W::Cancel, + })); + } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if let Some(WorkspaceForm::Account(form)) = &view.workspace.form { + return match key.code { + KeyCode::Esc => Some(R::Workspace(W::Cancel)), + KeyCode::Enter => Some(R::Workspace(W::AccountSubmit)), + KeyCode::Tab | KeyCode::BackTab => Some(R::Workspace(W::AccountScope)), + KeyCode::Char('a') + if matches!( + form.picked(), + Some(crate::state_handler::main_page::repos::AccountOption::Gh { .. }) + ) => + { + Some(R::Workspace(W::AccountAuthor)) + } + KeyCode::Up => Some(R::Workspace(W::AccountPick(form.pick.saturating_sub(1)))), + KeyCode::Down => Some(R::Workspace(W::AccountPick(form.pick + 1))), + KeyCode::Backspace if form.typing() => { + let mut v = form.path.clone(); + v.pop(); + Some(R::Workspace(W::AccountInput(v))) + } + KeyCode::Char(c) if form.typing() && !ctrl => { + Some(R::Workspace(W::AccountInput(format!("{}{c}", form.path)))) + } + _ => None, + }; + } + if let Some(form) = &view.workspace.form { + let (value, settings) = match form { + WorkspaceForm::Settings { root, .. } => (root, true), + WorkspaceForm::UsePath { path, .. } => (path, false), + WorkspaceForm::Account(_) => return None, + }; + let edit = |v: String| { + if settings { + W::SettingsInput(v) + } else { + W::UseInput(v) + } + }; + return match key.code { + KeyCode::Esc => Some(R::Workspace(W::Cancel)), + KeyCode::Enter => Some(R::Workspace(if settings { + W::SettingsSubmit + } else { + W::UseSubmit + })), + KeyCode::Tab | KeyCode::BackTab if settings => Some(R::Workspace(W::SettingsProtocol)), + KeyCode::Backspace => { + let mut v = value.clone(); + v.pop(); + Some(R::Workspace(edit(v))) + } + KeyCode::Char(c) if !ctrl => Some(R::Workspace(edit(format!("{value}{c}")))), + _ => None, + }; + } + if let Some(form) = &view.add { + let n = AddPersonForm::FIELDS; + return match (form.field, key.code) { + (_, KeyCode::Esc) => Some(R::Back), + (_, KeyCode::Enter) => Some(R::AddSubmit), + (_, KeyCode::Tab) => Some(R::AddField((form.field + 1) % n)), + (_, KeyCode::BackTab) => Some(R::AddField((form.field + n - 1) % n)), + (0, KeyCode::Char('d')) if ctrl => Some(R::AddToggleExternal), + (0, KeyCode::Up) if !form.external => Some(R::AddPick(form.pick.saturating_sub(1))), + (0, KeyCode::Down) if !form.external => Some(R::AddPick(form.pick + 1)), + (1, KeyCode::Up | KeyCode::Left) => Some(R::AddRight(form.right.saturating_sub(1))), + (1, KeyCode::Down | KeyCode::Right) => Some(R::AddRight( + (form.right + 1).min(GitRight::REPO_RIGHTS.len() - 1), + )), + (2, KeyCode::Left) => Some(R::AddExpiry(form.expiry.saturating_sub(1))), + (2, KeyCode::Right) => Some(R::AddExpiry( + (form.expiry + 1).min(EXPIRY_CHOICES.len() - 1), + )), + (f @ (0 | 3), KeyCode::Backspace) => { + let mut value = if f == 0 { + form.query.clone() + } else { + form.reason.clone() + }; + value.pop(); + Some(R::AddInput { field: f, value }) + } + (f @ (0 | 3), KeyCode::Char(c)) if !ctrl => { + let current = if f == 0 { &form.query } else { &form.reason }; + Some(R::AddInput { + field: f, + value: format!("{current}{c}"), + }) + } + _ => None, + }; + } + if let ReposScreen::NewRepo(form) = &view.screen { + let n = NewRepoForm::FIELDS; + return match (form.field, key.code) { + (_, KeyCode::Esc) => Some(R::Back), + (_, KeyCode::Enter) => Some(R::NewSubmit), + (_, KeyCode::Tab) => Some(R::NewField((form.field + 1) % n)), + (_, KeyCode::BackTab) => Some(R::NewField((form.field + n - 1) % n)), + (0, KeyCode::Left | KeyCode::Up) => { + Some(R::NewNamespace(form.namespace.saturating_sub(1))) + } + (0, KeyCode::Right | KeyCode::Down) => Some(R::NewNamespace(form.namespace + 1)), + (2, KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')) => Some(R::NewVisibility), + (f @ (1 | 3), KeyCode::Backspace) => { + let mut value = if f == 1 { + form.name.clone() + } else { + form.description.clone() + }; + value.pop(); + Some(R::NewInput { field: f, value }) + } + (f @ (1 | 3), KeyCode::Char(c)) if !ctrl => { + let current = if f == 1 { + &form.name + } else { + &form.description + }; + Some(R::NewInput { + field: f, + value: format!("{current}{c}"), + }) + } + (4, KeyCode::Char('d')) if ctrl => Some(R::NewOwnerToggleExternal), + (4, KeyCode::Char('a')) if ctrl => Some(R::NewOwnerAdd), + (4, KeyCode::Up) if !form.owner_external => { + Some(R::NewOwnerPick(form.owner_pick.saturating_sub(1))) + } + (4, KeyCode::Down) if !form.owner_external => { + Some(R::NewOwnerPick(form.owner_pick + 1)) + } + (4, KeyCode::Backspace) => { + if form.owner_query.is_empty() { + Some(R::NewOwnerRemoveLast) + } else { + let mut value = form.owner_query.clone(); + value.pop(); + Some(R::NewOwnerQuery(value)) + } + } + (4, KeyCode::Char(c)) if !ctrl => { + Some(R::NewOwnerQuery(format!("{}{c}", form.owner_query))) + } + _ => None, + }; + } + let link_settled = view.link.as_ref().is_some_and(|l| { + matches!( + l.phase, + LinkPhase::Linked { .. } | LinkPhase::Expired | LinkPhase::Failed(_) + ) + }); + let on_repo = matches!(view.screen, ReposScreen::Repo { .. }); + match key.code { + KeyCode::Esc => Some(R::Back), + KeyCode::Up => Some(R::Select(view.selected.saturating_sub(1))), + KeyCode::Down => Some(R::Select(view.selected + 1)), + KeyCode::Char('r') => Some(R::Refresh), + KeyCode::Char('l') => Some(R::LinkStart), + KeyCode::Char('d') if link_settled => Some(R::LinkDismiss), + KeyCode::Enter if !on_repo => Some(R::OpenRepo), + KeyCode::Char('n') if !on_repo => Some(R::NewStart), + KeyCode::Char('a') if on_repo => Some(R::AddStart), + KeyCode::Char('x') | KeyCode::Delete if on_repo => Some(R::RevokeArm), + KeyCode::Char('t') if on_repo => Some(R::TransferArm), + KeyCode::Char('A') if on_repo => Some(R::ArchiveArm), + KeyCode::Char('v') if on_repo => Some(R::DriftRevertArm), + KeyCode::Char('o') if on_repo => Some(R::DriftAdoptArm), + // This machine: checkouts and signing. + KeyCode::Char('c') => Some(R::Workspace(W::Clone)), + KeyCode::Char('e') => Some(R::Workspace(W::Sign)), + KeyCode::Char('E') => Some(R::Workspace(W::Unsign)), + KeyCode::Char('s') => Some(R::Workspace(W::SetUp)), + KeyCode::Char('S') => Some(R::Workspace(W::RemoveArm)), + KeyCode::Char('u') => Some(R::Workspace(W::UseStart)), + KeyCode::Char('w') => Some(R::Workspace(W::SettingsStart)), + KeyCode::Char('f') => Some(R::Workspace(W::AccountStart)), + KeyCode::Char('F') => Some(R::Workspace(W::Fork)), + KeyCode::Char('R') => Some(R::Workspace(W::RemotesToSsh)), + _ => None, + } +} + +/// The checkout path `p` copies: the highlighted or open repository's, when +/// it is checked out and nothing else has the keys. +pub(in crate::ui::pages::main) fn repos_copy_path( + view: &crate::state_handler::main_page::repos::ReposView, +) -> Option { + if view.confirm.is_some() + || view.add.is_some() + || view.workspace.form.is_some() + || view.workspace.confirm.is_some() + { + return None; + } + let resource = crate::state_handler::repos_workspace::target(view)?; + view.workspace + .checkouts + .get(&resource) + .map(|c| c.facts.path.display().to_string()) +} diff --git a/openvtc/src/ui/pages/main/components/rooms_panel.rs b/openvtc/src/ui/pages/main/components/rooms_panel.rs index 5176bdbb..c8acfd73 100644 --- a/openvtc/src/ui/pages/main/components/rooms_panel.rs +++ b/openvtc/src/ui/pages/main/components/rooms_panel.rs @@ -5,6 +5,7 @@ //! custody (`rooms/keys/list`), read the way the member portal and `pnm` read them, so //! all three show the same thing. +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::style::Style; use ratatui::text::{Line, Span}; @@ -207,3 +208,20 @@ impl Panel for RoomsPanel { lines } } + +/// The Rooms panel's keys: navigate, accept, refresh, close. +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); + 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, + _ => 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 137c1904..22785965 100644 --- a/openvtc/src/ui/pages/main/components/settings_panel.rs +++ b/openvtc/src/ui/pages/main/components/settings_panel.rs @@ -1,13 +1,20 @@ +//! Settings panel — the persona's settings and the forms opened from them, +//! with the panel's key handling. + use super::panel::Panel; use super::status::push_status; use crate::colors::{ COLOR_BORDER, COLOR_DARK_GRAY, COLOR_ORANGE, COLOR_SOFT_PURPLE, COLOR_SUCCESS, COLOR_TEXT_DEFAULT, COLOR_WARNING_ACCESSIBLE_RED, }; +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}, state::ConnectionState, }; +use crate::ui::pages::main::{MainPage, edit_text}; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ style::{Style, Stylize}, text::{Line, Span}, @@ -830,6 +837,452 @@ fn textwrap_warning(warning: &str) -> Vec { out } +impl MainPage { + pub(in crate::ui::pages::main) fn handle_settings_key(&mut self, key: KeyEvent) -> bool { + use crate::state_handler::main_page::content::SettingsMode; + + let settings = &self.props.main_page.content_panel.settings; + + match &settings.mode { + SettingsMode::EditFriendlyName { input } => { + let current = input.clone(); + match key.code { + KeyCode::Esc => { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::CancelEdit)); + true + } + KeyCode::Enter => { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::SubmitEdit { + value: current, + })); + true + } + code => match edit_text(code, ¤t) { + Some(v) => { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::FieldUpdate(v))); + true + } + None => false, + }, + } + } + SettingsMode::ExportConfig { + path_input, + active_field, + .. + } => { + let active = *active_field; + let path = path_input.clone(); + self.handle_config_form_key(key, active, path, true, |path, passphrase| { + SettingsAction::ExportConfig { path, passphrase } + }) + } + SettingsMode::ChangeProtection { + selected_option, + active_field, + .. + } => { + let active = *active_field; + let sel = *selected_option; + match key.code { + KeyCode::Esc => { + self.passphrase_buffer.clear(); + self.confirm_buffer.clear(); + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::CancelEdit)); + true + } + KeyCode::Up if active == 0 && sel > 0 => { + let _ = self.action_tx.send(Action::Settings( + SettingsAction::ProtectionOptionSelect(sel - 1), + )); + true + } + KeyCode::Down if active == 0 && sel < 1 => { + let _ = self.action_tx.send(Action::Settings( + SettingsAction::ProtectionOptionSelect(sel + 1), + )); + true + } + KeyCode::Enter if active == 0 => { + if sel == 0 { + // Switch to passphrase input + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::ProtectionStartInput)); + } else { + // Remove passphrase + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::RemovePassphrase)); + } + true + } + KeyCode::Tab if active >= 1 => { + let next = if active == 1 { 2 } else { 1 }; + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::ProtectionTabSwitch(next))); + true + } + KeyCode::Up if active == 2 => { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::ProtectionTabSwitch(1))); + true + } + KeyCode::Down if active == 1 => { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::ProtectionTabSwitch(2))); + true + } + KeyCode::Enter if active == 1 => { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::ProtectionTabSwitch(2))); + true + } + KeyCode::Enter if active == 2 => { + // Submit passphrase — or say why not. An Enter that + // silently does nothing reads as a frozen form. + let action = match passphrase_problem( + self.passphrase_buffer.as_str(), + self.confirm_buffer.as_str(), + ) { + Some(why) => SettingsAction::FormProblem(why), + None => { + let passphrase = self.passphrase_buffer.take(); + self.confirm_buffer.clear(); + SettingsAction::SetPassphrase { passphrase } + } + }; + let _ = self.action_tx.send(Action::Settings(action)); + true + } + code if active >= 1 => { + // Secure passphrase / confirm buffers: apply the keystroke + // to the active buffer and report only its length. + let buffer = if active == 1 { + &mut self.passphrase_buffer + } else { + &mut self.confirm_buffer + }; + match code { + KeyCode::Backspace => { + buffer.pop(); + } + KeyCode::Char(c) => { + buffer.push(c); + } + _ => return false, + } + let len = buffer.len(); + let action = if active == 1 { + SettingsAction::ProtectionPassphraseLen(len) + } else { + SettingsAction::ProtectionConfirmLen(len) + }; + let _ = self.action_tx.send(Action::Settings(action)); + true + } + _ => false, + } + } + #[cfg(feature = "openpgp-card")] + SettingsMode::TokenManagement { selected_index } => { + let sel = *selected_index; + match key.code { + KeyCode::Esc => { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::TokenBack)); + true + } + KeyCode::Up if sel > 0 => { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::Select(sel - 1))); + true + } + KeyCode::Down if sel < 1 => { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::Select(sel + 1))); + true + } + KeyCode::Enter => { + match sel { + 0 => { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::TokenDetect)); + } + 1 => { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::TokenFactoryResetStart)); + } + _ => {} + } + true + } + _ => false, + } + } + SettingsMode::ImportConfig { + path_input, + active_field, + .. + } => { + let active = *active_field; + let path = path_input.clone(); + self.handle_config_form_key(key, active, path, false, |path, passphrase| { + SettingsAction::ImportConfig { path, passphrase } + }) + } + SettingsMode::View => { + use crate::ui::pages::main::components::settings_panel as rows; + let selected = settings.selected_index; + let max_index = rows::WIPE_ROW; + + match key.code { + KeyCode::Up if selected > 0 => { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::Select(selected - 1))); + true + } + KeyCode::Down if selected < max_index => { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::Select(selected + 1))); + true + } + KeyCode::Enter => { + // Friendly name, export and import each open a form; + // `settings_actions::handle_start_edit` picks which. + if selected == rows::FRIENDLY_NAME_ROW + || selected == rows::EXPORT_ROW + || selected == rows::IMPORT_ROW + { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::StartEdit)); + } else if selected == rows::PROTECTION_ROW { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::ChangeProtection)); + } else if selected == rows::THEME_ROW { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::ThemeOpen)); + } + #[cfg(feature = "openpgp-card")] + if selected == rows::TOKEN_ROW { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::TokenManagement)); + } + if selected == rows::WIPE_ROW { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::WipeProfileStart)); + } + true + } + KeyCode::Esc => { + let _ = self + .action_tx + .send(Action::MainPanelSwitch(MainPanel::MainMenu)); + true + } + _ => false, + } + } + SettingsMode::ThemePicker { rows, selected, .. } => { + let (selected, count) = (*selected, rows.len()); + let action = match key.code { + KeyCode::Up if selected > 0 => SettingsAction::ThemeSelect(selected - 1), + KeyCode::Down if selected + 1 < count => { + SettingsAction::ThemeSelect(selected + 1) + } + KeyCode::Enter => SettingsAction::ThemeApply, + KeyCode::Esc => SettingsAction::ThemeCancel, + KeyCode::Char('c') => SettingsAction::ThemeCopy, + KeyCode::Char('r') => SettingsAction::ThemeReload, + _ => return false, + }; + let _ = self.action_tx.send(Action::Settings(action)); + true + } + #[cfg(feature = "openpgp-card")] + SettingsMode::TokenResetConfirm { confirm_input, .. } => { + let current = confirm_input.clone(); + 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, + }, + }; + let _ = self.action_tx.send(Action::Settings(action)); + true + } + SettingsMode::WipeConfirm { confirm_input } => { + let current = confirm_input.clone(); + match key.code { + KeyCode::Esc => { + // Drop back to the Settings list — wipe is cancelled. + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::CancelEdit)); + true + } + KeyCode::Enter => { + let _ = self + .action_tx + .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, + }, + } + } + } + } + + /// Shared key handling for the Export/Import config forms. The two forms are + /// identical except for the submit Action, supplied via `make_submit` + /// (called with the path and the taken passphrase buffer). `active` is the + /// focused field (0 = path, 1 = passphrase). + fn handle_config_form_key( + &mut self, + key: KeyEvent, + active: usize, + path: String, + confirm: bool, + make_submit: impl FnOnce(String, secrecy::SecretString) -> SettingsAction, + ) -> bool { + // The field Enter submits from: the confirmation on export, the + // passphrase on import. + let last = if confirm { 2 } else { 1 }; + match key.code { + KeyCode::Esc => { + self.passphrase_buffer.clear(); + self.confirm_buffer.clear(); + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::CancelEdit)); + true + } + KeyCode::Tab | KeyCode::Up | KeyCode::Down => { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::FormTabSwitch)); + true + } + KeyCode::Enter if active == 1 && confirm => { + let _ = self + .action_tx + .send(Action::Settings(SettingsAction::FormTabSwitch)); + true + } + KeyCode::Enter if active == last => { + // A typo in an export passphrase makes a backup nobody can + // open, discovered only when it is needed. + let problem = if confirm { + passphrase_problem( + self.passphrase_buffer.as_str(), + self.confirm_buffer.as_str(), + ) + } else { + None + }; + let action = match problem { + Some(why) => SettingsAction::FormProblem(why), + None => { + let passphrase = self.passphrase_buffer.take(); + self.confirm_buffer.clear(); + make_submit(path, passphrase) + } + }; + let _ = self.action_tx.send(Action::Settings(action)); + true + } + 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, + } + } else { + let buffer = if active == 1 { + &mut self.passphrase_buffer + } else { + &mut self.confirm_buffer + }; + match code { + KeyCode::Backspace => { + buffer.pop(); + } + KeyCode::Char(c) => { + buffer.push(c); + } + _ => return false, + } + let len = buffer.len(); + let action = if active == 1 { + SettingsAction::PassphraseLen(len) + } else { + SettingsAction::ExportConfirmLen(len) + }; + let _ = self.action_tx.send(Action::Settings(action)); + true + } + } + } + } +} + +/// Why a new passphrase and its confirmation cannot be submitted, if they +/// cannot. Checked before anything leaves the form. +fn passphrase_problem(passphrase: &str, confirm: &str) -> Option { + if passphrase.is_empty() { + Some("Enter a passphrase.".to_string()) + } else if openvtc_core::config::validate_passphrase(passphrase).is_err() { + Some(format!( + "Passphrases must be at least {} characters.", + openvtc_core::config::MIN_PASSPHRASE_LENGTH + )) + } else if passphrase != confirm { + Some("The passphrases do not match.".to_string()) + } else { + None + } +} + #[cfg(test)] mod wipe_confirm_tests { use super::*; diff --git a/openvtc/src/ui/pages/main/components/vetting_panel.rs b/openvtc/src/ui/pages/main/components/vetting_panel.rs index 5161089e..6295b29b 100644 --- a/openvtc/src/ui/pages/main/components/vetting_panel.rs +++ b/openvtc/src/ui/pages/main/components/vetting_panel.rs @@ -13,6 +13,8 @@ //! Copy says "meets the published requirements", never "approved": only the //! community decides (D12). +mod keys; + use super::panel::Panel; use super::qr::{QrError, qr_lines}; use crate::colors::{ diff --git a/openvtc/src/ui/pages/main/components/vetting_panel/keys.rs b/openvtc/src/ui/pages/main/components/vetting_panel/keys.rs new file mode 100644 index 00000000..01ffdd7c --- /dev/null +++ b/openvtc/src/ui/pages/main/components/vetting_panel/keys.rs @@ -0,0 +1,262 @@ +//! Vetting page key handling, beside the page's rendering in +//! `vetting_panel.rs`. + +use crate::state_handler::actions::Action; +use crate::state_handler::main_page::MainPanel; +use crate::ui::pages::main::{MainPage, edit_text}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +impl MainPage { + /// Vetting page keys. + /// + /// In a list: `Tab` switches tab, `↑/↓` select, and each tab has its own + /// verbs (shown in its footer). In a form: `↑/↓`/`Tab` move between fields, + /// a text field takes typing, `←/→` cycle a choice, `Space` ticks, `Enter` + /// commits and `Esc` leaves. + pub(in crate::ui::pages::main) fn handle_vetting_key(&mut self, key: KeyEvent) -> bool { + use crate::state_handler::actions::VettingAction as V; + use crate::state_handler::main_page::content::{DeskView, VettingMode, VettingTab}; + + let vetting = &self.props.main_page.content_panel.vetting; + if !matches!(vetting.mode, VettingMode::List) { + let text = vetting.mode.focused_text().map(str::to_string); + let confirming = matches!(vetting.mode, VettingMode::ConfirmDecline { .. }); + // 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 + // nothing — and with no field to type in and no rows to move + // between, Tab and the arrows did nothing visible either. The view + // read as frozen, and the only way back to a working `f` was to + // restart. + // `f` chooses the face wherever a missing or wrong one is what the + // page is reporting: the grant page, and the card it reads from. + let chooses_face = matches!( + vetting.mode, + VettingMode::HolderGrant { .. } | VettingMode::SendCard { .. } + ); + let on_result = matches!(&vetting.mode, VettingMode::Directory(view) if view.result_index().is_some()); + let on_event = matches!( + &vetting.mode, + VettingMode::Profile(form) if form.event.is_none() && form.event_index().is_some() + ); + let on_hidden = matches!(vetting.mode, VettingMode::HiddenVetting { .. }); + // Ctrl+V on the one field whose whole content is something pasted. + // Ahead of the text handler, which would otherwise type a literal + // `v` into the ticket link. Bracketed paste still works and is the + // path that survives SSH; this is the one that is on the screen. + if matches!(vetting.mode, VettingMode::RequestVetter { .. }) + && key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('v' | 'V')) + { + let action = match crate::clipboard::read_clipboard() { + Ok(pasted) => V::PasteTicket(pasted), + // 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!( + "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; + } + let action = match key.code { + KeyCode::Esc => Some(V::Back), + KeyCode::Enter => Some(V::Submit), + KeyCode::Tab | KeyCode::Down => Some(V::NextField), + KeyCode::BackTab | KeyCode::Up => Some(V::PrevField), + KeyCode::Left => Some(V::Cycle(false)), + KeyCode::Right => Some(V::Cycle(true)), + 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('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') => { + Some(V::DirectoryPage(false)) + } + None if on_result && code == KeyCode::Char('a') => Some(V::AskListedVetter), + None if on_event && matches!(code, KeyCode::Char('x') | KeyCode::Delete) => { + Some(V::RemoveEvent) + } + None if on_hidden && code == KeyCode::Char('d') => Some(V::DrawNow), + None if on_hidden && code == KeyCode::Char('r') => Some(V::CheckHiddenKey), + None if code == KeyCode::Char(' ') => Some(V::Toggle), + None => None, + }, + }; + return match action { + Some(action) => { + let _ = self.action_tx.send(Action::Vetting(action)); + true + } + None => false, + }; + } + + // A journey is open: its keys are the current step's, and the way out. + // A key for a step not reached yet does nothing — the strip says why — + // so the steps cannot be taken out of order by accident. + if let Some(journey) = &vetting.journey { + use crate::state_handler::main_page::content::JourneySteps; + use openvtc_core::vetting::journey::{ApplicantStep, StepState, VetterStep}; + let reached = |state: StepState| state != StepState::Todo; + let action = match &journey.steps { + JourneySteps::Applicant(steps) => { + let at = |step: ApplicantStep| { + steps.iter().any(|s| s.step == step && reached(s.state)) + }; + match key.code { + KeyCode::Esc => Some(V::CloseJourney), + KeyCode::Char('m') => Some(V::RefreshRequirements), + KeyCode::Char('x') | KeyCode::Delete => Some(V::ArmAbandon), + // The face can be changed until a card has gone. + KeyCode::Char('f') if at(ApplicantStep::Face) => Some(V::ChooseFace), + KeyCode::Char('r') if at(ApplicantStep::Vetters) => Some(V::RequestVetter), + KeyCode::Char('v') if at(ApplicantStep::Vetters) => Some(V::FindVetters), + KeyCode::Enter | KeyCode::Char('c') if at(ApplicantStep::Sessions) => { + Some(V::ReviewCard) + } + KeyCode::Char('j') + if steps.iter().any(|s| { + s.step == ApplicantStep::Join && s.state == StepState::Current + }) => + { + if let Some(application) = vetting.applications.get(vetting.selected) { + let _ = self + .action_tx + .send(Action::StartJoinFor(application.community.clone())); + } + return true; + } + _ => None, + } + } + JourneySteps::Vetter(steps, ending) => { + let current = openvtc_core::vetting::journey::current(steps); + match key.code { + KeyCode::Esc => Some(V::CloseJourney), + _ if ending.is_some() => None, + KeyCode::Char('x') => Some(V::ArmDecline), + KeyCode::Char('o') + if matches!(current, Some(VetterStep::Session | VetterStep::Code)) => + { + Some(V::OpenSession) + } + KeyCode::Enter | KeyCode::Char('a') + if matches!(current, Some(VetterStep::Check | VetterStep::Sign)) => + { + Some(V::StartAttest) + } + _ => None, + } + } + }; + if let Some(action) = action { + let _ = self.action_tx.send(Action::Vetting(action)); + } + // Every key is the journey's while it is open, including the ones + // it ignores: falling through would act on the list behind it. + return true; + } + + // The desk's views take ←/→, which everywhere else on the main page is + // how focus returns to the menu (they fall through to the page-level + // handler). Consuming them without offering another way out would make + // the desk a room whose door is bound to something else, so Esc is that + // way out — and it is bound for the whole page, not just the desk, so + // it means one thing here. + if key.code == KeyCode::Esc { + let _ = self + .action_tx + .send(Action::MainPanelSwitch(MainPanel::MainMenu)); + return true; + } + + let selected = vetting.selected; + let len = vetting.tab_len(); + let view = vetting.desk_view; + // The desk's three views share one key space: the arms match on the tab + // and guard on the view. A key bound on the desk generally (`p`, `g`) + // has no guard, which is the point of the header — the profile and the + // vetter credential belong to the whole desk, not to whichever list is + // open. `p` used to exist only on Tickets, where it had nothing to do + // with tickets. + let action = match (vetting.tab, key.code) { + (_, KeyCode::Tab) => V::SwitchTab, + (VettingTab::Desk, KeyCode::Left) => V::SwitchDeskView(false), + (VettingTab::Desk, KeyCode::Right) => V::SwitchDeskView(true), + (_, KeyCode::Up) if selected > 0 => V::Select(selected - 1), + (_, KeyCode::Down) if selected + 1 < len => V::Select(selected + 1), + (VettingTab::Applications, KeyCode::Char('n')) => V::StartApplication, + (VettingTab::Applications, KeyCode::Char('f')) => V::ChooseFace, + (VettingTab::Applications, KeyCode::Char('r')) => V::RequestVetter, + (VettingTab::Applications, KeyCode::Char('m')) => V::RefreshRequirements, + (VettingTab::Applications, KeyCode::Char('p')) => V::SwitchVettingPath, + (VettingTab::Applications, KeyCode::Char('v')) => V::FindVetters, + // Enter opens the application as a journey; `c` still goes + // straight to the card for someone who knows where they are. + (VettingTab::Applications, KeyCode::Enter) => V::OpenJourney, + (VettingTab::Applications, KeyCode::Char('c')) => V::ReviewCard, + // An application is otherwise permanent, and its persona is fixed + // for its whole life — so one started as the wrong persona would + // own that community's vetting route forever. + (VettingTab::Applications, KeyCode::Char('x') | KeyCode::Delete) => V::ArmAbandon, + // The join this application was made for. It leaves the panel + // rather than sending an action, because the join flow owns the + // screen — and it carries the community, so the DID an application + // already names is never asked for again. + (VettingTab::Applications, KeyCode::Char('j')) => { + let Some(application) = vetting.applications.get(selected) else { + return false; + }; + let _ = self + .action_tx + .send(Action::StartJoinFor(application.community.clone())); + return true; + } + // Anywhere on the desk: the profile the communities hold, and + // asking one to reissue a grant that has lapsed. + (VettingTab::Desk, KeyCode::Char('p')) => V::EditProfile, + (VettingTab::Desk, KeyCode::Char('g')) => V::AskResend, + (VettingTab::Desk, KeyCode::Char('e')) => V::AskEventMode, + (VettingTab::Desk, KeyCode::Char('h')) => V::OpenHiddenVetting, + // Get tokens: enrol if needed, then draw what has begun — the one key a vetter at a + // PCS ZKP community needs before `t` can issue a ticket. + (VettingTab::Desk, KeyCode::Char('k')) => V::DrawNow, + (VettingTab::Desk, KeyCode::Char('o')) if view == DeskView::Requests => V::OpenSession, + (VettingTab::Desk, KeyCode::Enter) if view == DeskView::Requests => V::OpenJourney, + (VettingTab::Desk, KeyCode::Char('a')) if view == DeskView::Requests => V::StartAttest, + (VettingTab::Desk, KeyCode::Char('x')) if view == DeskView::Requests => V::ArmDecline, + (VettingTab::Desk, KeyCode::Char('t')) if view == DeskView::Tickets => V::NewTicket, + (VettingTab::Desk, KeyCode::Enter) if view == DeskView::Tickets => V::ShowTicket, + (VettingTab::Desk, KeyCode::Char('d')) if view == DeskView::Tickets => V::DeleteTicket, + (VettingTab::Desk, KeyCode::Char('u')) if view == DeskView::Tickets => { + let Some(uri) = vetting.tickets.get(selected).and_then(|t| t.uri.clone()) else { + 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}"), + }; + V::Status(status) + } + (VettingTab::Desk, KeyCode::Char('y')) if view == DeskView::Tickets => { + let Some(ticket) = vetting.tickets.get(selected) else { + 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}"), + }; + V::Status(status) + } + (VettingTab::Desk, KeyCode::Char('w')) if view == DeskView::Issued => V::ArmWithdraw, + _ => return false, + }; + let _ = self.action_tx.send(Action::Vetting(action)); + true + } +} diff --git a/openvtc/src/ui/pages/main/components/vta_panel.rs b/openvtc/src/ui/pages/main/components/vta_panel.rs index a9f4032a..797eb8b8 100644 --- a/openvtc/src/ui/pages/main/components/vta_panel.rs +++ b/openvtc/src/ui/pages/main/components/vta_panel.rs @@ -4,10 +4,14 @@ use crate::colors::{ COLOR_DARK_GRAY, COLOR_ORANGE, COLOR_SOFT_PURPLE, COLOR_SUCCESS, COLOR_TEXT_DEFAULT, COLOR_WARNING_ACCESSIBLE_RED, }; +use crate::state_handler::actions::Action; +use crate::state_handler::main_page::MainPanel; use crate::state_handler::{ main_page::content::{ContentPanelState, VicLifecycle, VtaState, VtaTransport}, state::ConnectionState, }; +use crate::ui::pages::main::MainPage; +use crossterm::event::{KeyCode, KeyEvent}; use openvtc_core::display::{display_identifier, truncate_did_centered}; use ratatui::{ style::{Style, Stylize}, @@ -563,6 +567,110 @@ fn render_vics(state: &VtaState, panel_focused: bool, lines: &mut Vec bool { + use crate::state_handler::main_page::content::VicLifecycle; + + let vta = &self.props.main_page.content_panel.vta; + let vic_count = vta.vics.len(); + let vic_selected = vta.vic_selected_index; + let vic_lifecycle = vta.vics.get(vic_selected).map(|v| v.lifecycle); + + // VIC purge / delete confirmation gates: only y/Enter confirms. + if let Some(idx) = vta.confirm_purge_vic { + let act = match key.code { + KeyCode::Char('y') | KeyCode::Enter => Action::PurgeVic(idx), + _ => Action::VicCancelPurge, + }; + let _ = self.action_tx.send(act); + return true; + } + if let Some(idx) = vta.confirm_delete_vic { + let act = match key.code { + KeyCode::Char('y') | KeyCode::Enter => Action::DeleteVic(idx), + _ => Action::VicCancelDelete, + }; + let _ = self.action_tx.send(act); + return true; + } + + match key.code { + // The list is read on demand rather than polled, and this is the + // 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 => { + let _ = self.action_tx.send(Action::VicRefresh); + true + } + KeyCode::Char('a') => { + let _ = self.action_tx.send(Action::StartAddVic); + true + } + KeyCode::Char('i') => { + let _ = self.action_tx.send(Action::VicToggleInactive); + true + } + KeyCode::Esc => { + let _ = self + .action_tx + .send(Action::MainPanelSwitch(MainPanel::MainMenu)); + true + } + KeyCode::Up if vic_count > 0 => { + let _ = self + .action_tx + .send(Action::VicSelect(vic_selected.saturating_sub(1))); + true + } + KeyCode::Down if vic_count > 0 => { + let _ = self + .action_tx + .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) => { + 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)); + } + _ => {} + } + true + } + // d: soft-delete (not already deleted). + KeyCode::Char('d') | KeyCode::Delete + if vic_selected < vic_count && vic_lifecycle != Some(VicLifecycle::Deleted) => + { + let _ = self.action_tx.send(Action::VicConfirmDelete(vic_selected)); + true + } + // p: irreversible purge. + KeyCode::Char('p') if vic_selected < vic_count => { + let _ = self.action_tx.send(Action::VicConfirmPurge(vic_selected)); + true + } + _ => false, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index ab3bcaa5..8ba95a51 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -4,12 +4,8 @@ use crate::colors::{ }; use crate::{ state_handler::{ - actions::{Action, CredentialAction, InboxAction, RelationshipAction, SettingsAction}, - main_page::{ - MainPageState, MainPanel, - content::{ActiveTaskView, InboxConfirm}, - menu::MainMenu, - }, + actions::{Action, CredentialAction, RelationshipAction, SettingsAction}, + main_page::{MainPageState, MainPanel, menu::MainMenu}, state::{ConnectionState, MediatorStatus, State}, }, ui::component::{Component, ComponentRender}, @@ -384,349 +380,6 @@ impl Component for MainPage { } } -/// Why a new passphrase and its confirmation cannot be submitted, if they -/// cannot. Checked before anything leaves the form. -fn passphrase_problem(passphrase: &str, confirm: &str) -> Option { - if passphrase.is_empty() { - Some("Enter a passphrase.".to_string()) - } else if openvtc_core::config::validate_passphrase(passphrase).is_err() { - Some(format!( - "Passphrases must be at least {} characters.", - openvtc_core::config::MIN_PASSPHRASE_LENGTH - )) - } else if passphrase != confirm { - Some("The passphrases do not match.".to_string()) - } else { - None - } -} - -/// The Rooms panel's keys: navigate, accept, refresh, close. -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); - 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, - _ => return None, - }) -} - -/// The Elections panel's keys, by screen. `None` when the key means nothing here. -/// -/// On the ballot, a text question takes printable keys as its answer, so its blank toggle is -/// `Ctrl-a`; elsewhere on the ballot `a` leaves the focused question blank. On the -/// confirmation only `y` casts — anything else goes back to the ballot, so a key held a -/// moment too long after `⏎` never casts. -fn elections_key( - key: KeyEvent, - view: &crate::state_handler::main_page::elections::ElectionsView, -) -> Option { - use crate::state_handler::actions::ElectionsAction as E; - use crate::state_handler::main_page::elections::Screen; - let ctrl = key - .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL); - Some(match view.screen { - Screen::List => { - let last = view.rows().len().saturating_sub(1); - 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, - _ => return None, - } - } - // Only `y` presents; anything else goes back without presenting (VTI-VOT-016). - Screen::Disclose => match key.code { - 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::Ballot => { - let typing = view - .form - .as_ref() - .zip(view.shown.as_ref()) - .is_some_and(|(f, s)| f.typing(s.manifest())); - match key.code { - KeyCode::Esc => E::Back, - KeyCode::Tab => E::BallotQuestion(1), - KeyCode::BackTab => E::BallotQuestion(-1), - KeyCode::Up => E::BallotOption(-1), - KeyCode::Down => E::BallotOption(1), - KeyCode::Enter => E::Review, - KeyCode::Char('a') if ctrl => E::BallotAbstain, - KeyCode::Backspace if typing => E::BallotBackspace, - KeyCode::Char(c) if typing && !ctrl => E::BallotChar(c), - KeyCode::Char(' ') => E::BallotToggle, - KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Right => E::BallotAdjust(1), - KeyCode::Char('-') | KeyCode::Left => E::BallotAdjust(-1), - KeyCode::Char('a') => E::BallotAbstain, - KeyCode::Char('k') => E::BallotOption(-1), - KeyCode::Char('j') => E::BallotOption(1), - _ => return None, - } - } - Screen::Confirm => match key.code { - KeyCode::Char('y') => E::Cast, - _ => E::Back, - }, - Screen::Result => match key.code { - KeyCode::Esc => E::Back, - KeyCode::Char('r') => E::Resend, - _ => return None, - }, - }) -} - -/// The Repos view's key map. `None` when the key means nothing here. -/// -/// Innermost first: an armed change takes only `y`/`⏎` (anything else -/// disarms it), then an open form, then the screen. -fn repos_key( - key: KeyEvent, - view: &crate::state_handler::main_page::repos::ReposView, -) -> Option { - use crate::state_handler::actions::ReposAction as R; - use crate::state_handler::main_page::repos::{ - AddPersonForm, EXPIRY_CHOICES, LinkPhase, NewRepoForm, ReposScreen, - }; - use openvtc_core::git_ns::GitRight; - - // Only `y` confirms: Enter is what submitted the form that armed the - // change, and a key held a moment too long must not also confirm it. - if view.confirm.is_some() { - return Some(match key.code { - KeyCode::Char('y') => R::Confirm, - _ => R::Cancel, - }); - } - use crate::state_handler::actions::WorkspaceAction as W; - use crate::state_handler::main_page::repos::WorkspaceForm; - if view.workspace.confirm.is_some() { - return Some(R::Workspace(match key.code { - KeyCode::Char('y') => W::Confirm, - _ => W::Cancel, - })); - } - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - if let Some(WorkspaceForm::Account(form)) = &view.workspace.form { - return match key.code { - KeyCode::Esc => Some(R::Workspace(W::Cancel)), - KeyCode::Enter => Some(R::Workspace(W::AccountSubmit)), - KeyCode::Tab | KeyCode::BackTab => Some(R::Workspace(W::AccountScope)), - KeyCode::Char('a') - if matches!( - form.picked(), - Some(crate::state_handler::main_page::repos::AccountOption::Gh { .. }) - ) => - { - Some(R::Workspace(W::AccountAuthor)) - } - KeyCode::Up => Some(R::Workspace(W::AccountPick(form.pick.saturating_sub(1)))), - KeyCode::Down => Some(R::Workspace(W::AccountPick(form.pick + 1))), - KeyCode::Backspace if form.typing() => { - let mut v = form.path.clone(); - v.pop(); - Some(R::Workspace(W::AccountInput(v))) - } - KeyCode::Char(c) if form.typing() && !ctrl => { - Some(R::Workspace(W::AccountInput(format!("{}{c}", form.path)))) - } - _ => None, - }; - } - if let Some(form) = &view.workspace.form { - let (value, settings) = match form { - WorkspaceForm::Settings { root, .. } => (root, true), - WorkspaceForm::UsePath { path, .. } => (path, false), - WorkspaceForm::Account(_) => return None, - }; - let edit = |v: String| { - if settings { - W::SettingsInput(v) - } else { - W::UseInput(v) - } - }; - return match key.code { - KeyCode::Esc => Some(R::Workspace(W::Cancel)), - KeyCode::Enter => Some(R::Workspace(if settings { - W::SettingsSubmit - } else { - W::UseSubmit - })), - KeyCode::Tab | KeyCode::BackTab if settings => Some(R::Workspace(W::SettingsProtocol)), - KeyCode::Backspace => { - let mut v = value.clone(); - v.pop(); - Some(R::Workspace(edit(v))) - } - KeyCode::Char(c) if !ctrl => Some(R::Workspace(edit(format!("{value}{c}")))), - _ => None, - }; - } - if let Some(form) = &view.add { - let n = AddPersonForm::FIELDS; - return match (form.field, key.code) { - (_, KeyCode::Esc) => Some(R::Back), - (_, KeyCode::Enter) => Some(R::AddSubmit), - (_, KeyCode::Tab) => Some(R::AddField((form.field + 1) % n)), - (_, KeyCode::BackTab) => Some(R::AddField((form.field + n - 1) % n)), - (0, KeyCode::Char('d')) if ctrl => Some(R::AddToggleExternal), - (0, KeyCode::Up) if !form.external => Some(R::AddPick(form.pick.saturating_sub(1))), - (0, KeyCode::Down) if !form.external => Some(R::AddPick(form.pick + 1)), - (1, KeyCode::Up | KeyCode::Left) => Some(R::AddRight(form.right.saturating_sub(1))), - (1, KeyCode::Down | KeyCode::Right) => Some(R::AddRight( - (form.right + 1).min(GitRight::REPO_RIGHTS.len() - 1), - )), - (2, KeyCode::Left) => Some(R::AddExpiry(form.expiry.saturating_sub(1))), - (2, KeyCode::Right) => Some(R::AddExpiry( - (form.expiry + 1).min(EXPIRY_CHOICES.len() - 1), - )), - (f @ (0 | 3), KeyCode::Backspace) => { - let mut value = if f == 0 { - form.query.clone() - } else { - form.reason.clone() - }; - value.pop(); - Some(R::AddInput { field: f, value }) - } - (f @ (0 | 3), KeyCode::Char(c)) if !ctrl => { - let current = if f == 0 { &form.query } else { &form.reason }; - Some(R::AddInput { - field: f, - value: format!("{current}{c}"), - }) - } - _ => None, - }; - } - if let ReposScreen::NewRepo(form) = &view.screen { - let n = NewRepoForm::FIELDS; - return match (form.field, key.code) { - (_, KeyCode::Esc) => Some(R::Back), - (_, KeyCode::Enter) => Some(R::NewSubmit), - (_, KeyCode::Tab) => Some(R::NewField((form.field + 1) % n)), - (_, KeyCode::BackTab) => Some(R::NewField((form.field + n - 1) % n)), - (0, KeyCode::Left | KeyCode::Up) => { - Some(R::NewNamespace(form.namespace.saturating_sub(1))) - } - (0, KeyCode::Right | KeyCode::Down) => Some(R::NewNamespace(form.namespace + 1)), - (2, KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')) => Some(R::NewVisibility), - (f @ (1 | 3), KeyCode::Backspace) => { - let mut value = if f == 1 { - form.name.clone() - } else { - form.description.clone() - }; - value.pop(); - Some(R::NewInput { field: f, value }) - } - (f @ (1 | 3), KeyCode::Char(c)) if !ctrl => { - let current = if f == 1 { - &form.name - } else { - &form.description - }; - Some(R::NewInput { - field: f, - value: format!("{current}{c}"), - }) - } - (4, KeyCode::Char('d')) if ctrl => Some(R::NewOwnerToggleExternal), - (4, KeyCode::Char('a')) if ctrl => Some(R::NewOwnerAdd), - (4, KeyCode::Up) if !form.owner_external => { - Some(R::NewOwnerPick(form.owner_pick.saturating_sub(1))) - } - (4, KeyCode::Down) if !form.owner_external => { - Some(R::NewOwnerPick(form.owner_pick + 1)) - } - (4, KeyCode::Backspace) => { - if form.owner_query.is_empty() { - Some(R::NewOwnerRemoveLast) - } else { - let mut value = form.owner_query.clone(); - value.pop(); - Some(R::NewOwnerQuery(value)) - } - } - (4, KeyCode::Char(c)) if !ctrl => { - Some(R::NewOwnerQuery(format!("{}{c}", form.owner_query))) - } - _ => None, - }; - } - let link_settled = view.link.as_ref().is_some_and(|l| { - matches!( - l.phase, - LinkPhase::Linked { .. } | LinkPhase::Expired | LinkPhase::Failed(_) - ) - }); - let on_repo = matches!(view.screen, ReposScreen::Repo { .. }); - match key.code { - KeyCode::Esc => Some(R::Back), - KeyCode::Up => Some(R::Select(view.selected.saturating_sub(1))), - KeyCode::Down => Some(R::Select(view.selected + 1)), - KeyCode::Char('r') => Some(R::Refresh), - KeyCode::Char('l') => Some(R::LinkStart), - KeyCode::Char('d') if link_settled => Some(R::LinkDismiss), - KeyCode::Enter if !on_repo => Some(R::OpenRepo), - KeyCode::Char('n') if !on_repo => Some(R::NewStart), - KeyCode::Char('a') if on_repo => Some(R::AddStart), - KeyCode::Char('x') | KeyCode::Delete if on_repo => Some(R::RevokeArm), - KeyCode::Char('t') if on_repo => Some(R::TransferArm), - KeyCode::Char('A') if on_repo => Some(R::ArchiveArm), - KeyCode::Char('v') if on_repo => Some(R::DriftRevertArm), - KeyCode::Char('o') if on_repo => Some(R::DriftAdoptArm), - // This machine: checkouts and signing. - KeyCode::Char('c') => Some(R::Workspace(W::Clone)), - KeyCode::Char('e') => Some(R::Workspace(W::Sign)), - KeyCode::Char('E') => Some(R::Workspace(W::Unsign)), - KeyCode::Char('s') => Some(R::Workspace(W::SetUp)), - KeyCode::Char('S') => Some(R::Workspace(W::RemoveArm)), - KeyCode::Char('u') => Some(R::Workspace(W::UseStart)), - KeyCode::Char('w') => Some(R::Workspace(W::SettingsStart)), - KeyCode::Char('f') => Some(R::Workspace(W::AccountStart)), - KeyCode::Char('F') => Some(R::Workspace(W::Fork)), - KeyCode::Char('R') => Some(R::Workspace(W::RemotesToSsh)), - _ => None, - } -} - -/// The checkout path `p` copies: the highlighted or open repository's, when -/// it is checked out and nothing else has the keys. -fn repos_copy_path(view: &crate::state_handler::main_page::repos::ReposView) -> Option { - if view.confirm.is_some() - || view.add.is_some() - || view.workspace.form.is_some() - || view.workspace.confirm.is_some() - { - return None; - } - let resource = crate::state_handler::repos_workspace::target(view)?; - view.workspace - .checkouts - .get(&resource) - .map(|c| c.facts.path.display().to_string()) -} - /// Paste into the Repos view's focused text field, if one is open. fn repos_paste( view: &crate::state_handler::main_page::repos::ReposView, @@ -817,2591 +470,168 @@ impl MainPage { self.content_scroll = self.content_scroll.saturating_add(CONTENT_PAGE).min(max); return true; } - KeyCode::Home => { - self.content_scroll = 0; - return true; - } - KeyCode::End => { - self.content_scroll = self.content_scroll_max.get(); - return true; - } - _ => {} - } - - let menu = self.props.main_page.menu_panel.selected_menu.clone(); - - match menu { - MainMenu::Inbox => self.handle_inbox_key(key), - MainMenu::Relationships => self.handle_relationships_key(key), - MainMenu::Credentials => self.handle_credentials_key(key), - MainMenu::Settings => self.handle_settings_key(key), - MainMenu::Logs => self.handle_logs_key(key), - MainMenu::Help => self.handle_help_key(key), - MainMenu::Communities => self.handle_communities_key(key), - MainMenu::Identity => self.handle_personas_key(key), - MainMenu::Vta => self.handle_vta_key(key), - MainMenu::Vetting => self.handle_vetting_key(key), - _ => false, - } - } - - /// VTA Service keys. The panel's one manageable list is the invitation - /// credentials: ↑/↓ move the selection, `a` imports, `i` flips the archived - /// filter, `r`/`u`/`d`/`p` drive the lifecycle. Returns true if consumed. - /// - /// The persona DID list that used to share this panel — and the `n`, `g` - /// and `d` verbs that acted on it — moved to the identity pane, which is - /// where the rest of a persona's management already lives. - fn handle_vta_key(&mut self, key: KeyEvent) -> bool { - use crate::state_handler::main_page::content::VicLifecycle; - - let vta = &self.props.main_page.content_panel.vta; - let vic_count = vta.vics.len(); - let vic_selected = vta.vic_selected_index; - let vic_lifecycle = vta.vics.get(vic_selected).map(|v| v.lifecycle); - - // VIC purge / delete confirmation gates: only y/Enter confirms. - if let Some(idx) = vta.confirm_purge_vic { - let act = match key.code { - KeyCode::Char('y') | KeyCode::Enter => Action::PurgeVic(idx), - _ => Action::VicCancelPurge, - }; - let _ = self.action_tx.send(act); - return true; - } - if let Some(idx) = vta.confirm_delete_vic { - let act = match key.code { - KeyCode::Char('y') | KeyCode::Enter => Action::DeleteVic(idx), - _ => Action::VicCancelDelete, - }; - let _ = self.action_tx.send(act); - return true; - } - - match key.code { - // The list is read on demand rather than polled, and this is the - // 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 => { - let _ = self.action_tx.send(Action::VicRefresh); - true - } - KeyCode::Char('a') => { - let _ = self.action_tx.send(Action::StartAddVic); - true - } - KeyCode::Char('i') => { - let _ = self.action_tx.send(Action::VicToggleInactive); - true - } - KeyCode::Esc => { - let _ = self - .action_tx - .send(Action::MainPanelSwitch(MainPanel::MainMenu)); - true - } - KeyCode::Up if vic_count > 0 => { - let _ = self - .action_tx - .send(Action::VicSelect(vic_selected.saturating_sub(1))); - true - } - KeyCode::Down if vic_count > 0 => { - let _ = self - .action_tx - .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) => { - 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)); - } - _ => {} - } - true - } - // d: soft-delete (not already deleted). - KeyCode::Char('d') | KeyCode::Delete - if vic_selected < vic_count && vic_lifecycle != Some(VicLifecycle::Deleted) => - { - let _ = self.action_tx.send(Action::VicConfirmDelete(vic_selected)); - true - } - // p: irreversible purge. - KeyCode::Char('p') if vic_selected < vic_count => { - let _ = self.action_tx.send(Action::VicConfirmPurge(vic_selected)); - true - } - _ => false, - } - } - - /// Identity-pane keys. - /// - /// Three layers, in this order, and the order is what keeps them legible: - /// an open form or picker owns every key; then an armed confirmation owns - /// `y`/`n`; then the active tab's own verbs apply. Anything reaching layer - /// three has already been shown to be unambiguous. - fn handle_personas_key(&mut self, key: KeyEvent) -> bool { - use crate::state_handler::actions::PersonaAction as PA; - use crate::state_handler::main_page::content::{ - PersonaConfirm, PersonaMode, PersonaTab, ProfileFormFocus, WorldFormFocus, - }; - - let personas = &self.props.main_page.content_panel.identity; - let send = |action: PA| { - let _ = self.action_tx.send(Action::Persona(action)); - true - }; - - // ── An open form or picker owns the keyboard ────────────────────── - match &personas.mode { - PersonaMode::Attribute(form) => { - // A write is in flight: swallow input rather than editing a - // form whose result is already on the wire. - if form.working { - return true; - } - return match key.code { - KeyCode::Esc => send(PA::FormCancel), - KeyCode::Enter => send(PA::FormSubmit), - KeyCode::Tab | KeyCode::Down => send(PA::FormField(true)), - KeyCode::BackTab | KeyCode::Up => send(PA::FormField(false)), - KeyCode::Right => send(PA::FormCycle(true)), - KeyCode::Left => send(PA::FormCycle(false)), - _ => send(PA::FormKey(key)), - }; - } - PersonaMode::Profile(form) => { - if form.working { - return true; - } - let on_entries = form.focus == ProfileFormFocus::Entries; - return match key.code { - KeyCode::Esc => send(PA::FormCancel), - KeyCode::Enter => send(PA::FormSubmit), - KeyCode::Tab => send(PA::FormField(true)), - KeyCode::BackTab => send(PA::FormField(false)), - KeyCode::Down if on_entries => send(PA::FormCycle(true)), - KeyCode::Up if on_entries => send(PA::FormCycle(false)), - // Only a keystroke *on the list* ticks an entry. On the - // name field a space is a space — a form that swallowed it - // would refuse to let a profile be called "Work laptop". - KeyCode::Char(' ') if on_entries => send(PA::FormToggleEntry), - _ => send(PA::FormKey(key)), - }; - } - PersonaMode::World(form) => { - if form.working { - return true; - } - let on_colour = form.focus == WorldFormFocus::Colour; - return match key.code { - KeyCode::Esc => send(PA::FormCancel), - KeyCode::Enter => send(PA::FormSubmit), - KeyCode::Tab => send(PA::FormField(true)), - KeyCode::BackTab => send(PA::FormField(false)), - // ←/→ move the colour wherever the focus is, so a holder - // who has not worked out that the swatch row is a field can - // still change it. On the colour field ↑/↓ do it too. - KeyCode::Right => send(PA::FormCycle(true)), - KeyCode::Left => send(PA::FormCycle(false)), - KeyCode::Down if on_colour => send(PA::FormCycle(true)), - KeyCode::Up if on_colour => send(PA::FormCycle(false)), - _ => send(PA::FormKey(key)), - }; - } - PersonaMode::PlaceFace(picker) => { - if picker.working { - return true; - } - return match key.code { - KeyCode::Esc => send(PA::FormCancel), - KeyCode::Enter => send(PA::FormSubmit), - KeyCode::Down => send(PA::FormCycle(true)), - KeyCode::Up => send(PA::FormCycle(false)), - _ => true, - }; - } - PersonaMode::Bind(picker) => { - if picker.working { - return true; - } - return match key.code { - KeyCode::Esc => send(PA::FormCancel), - KeyCode::Enter => send(PA::FormSubmit), - KeyCode::Down => send(PA::FormCycle(true)), - KeyCode::Up => send(PA::FormCycle(false)), - _ => true, - }; - } - PersonaMode::Compose(form) => { - if form.working { - return true; - } - return match key.code { - KeyCode::Esc => send(PA::FormCancel), - KeyCode::Enter => send(PA::FormSubmit), - KeyCode::Tab => send(PA::FormField(true)), - KeyCode::BackTab => send(PA::FormField(false)), - KeyCode::Down => send(PA::FormCycle(true)), - KeyCode::Up => send(PA::FormCycle(false)), - // Not space: a value is text, and "Ada King" has one. - KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => { - send(PA::FormToggleEntry) - } - _ => send(PA::FormKey(key)), - }; - } - PersonaMode::LocalFaces(view) => { - if view.working { - return true; - } - // The form owns the keyboard while it is up, so a name can - // contain the letters the list uses as verbs. - if view.form.is_some() { - return match key.code { - KeyCode::Esc => send(PA::FormCancel), - KeyCode::Enter => send(PA::FormSubmit), - KeyCode::Tab => send(PA::FormField(true)), - KeyCode::BackTab => send(PA::FormField(false)), - KeyCode::Down => send(PA::FormCycle(true)), - KeyCode::Up => send(PA::FormCycle(false)), - _ => send(PA::FormKey(key)), - }; - } - return match key.code { - KeyCode::Esc => send(PA::FormCancel), - KeyCode::Enter => send(PA::FormSubmit), - KeyCode::Down => send(PA::FormCycle(true)), - KeyCode::Up => send(PA::FormCycle(false)), - KeyCode::Char(' ') => send(PA::FormToggleEntry), - KeyCode::Char('n') => send(PA::LocalFaceNew), - KeyCode::Char('w') => send(PA::LocalFaceWear), - KeyCode::Char('d') | KeyCode::Delete => send(PA::LocalFaceDeleteArm), - _ => true, - }; - } - // Read-only overlays: any key but Esc is ignored rather than - // falling through to the tab behind, which is not on screen. - PersonaMode::Renderers(_) | PersonaMode::KnownHere(_) => { - return match key.code { - KeyCode::Esc => send(PA::FormCancel), - _ => true, - }; - } - PersonaMode::People(view) => { - if view.working { - return true; - } - // The form owns the keyboard while it is up; the list's own - // verbs would otherwise eat letters being typed into a field. - if view.form.is_some() { - return match key.code { - KeyCode::Esc => send(PA::FormCancel), - KeyCode::Enter => send(PA::FormSubmit), - KeyCode::Tab => send(PA::FormField(true)), - KeyCode::BackTab => send(PA::FormField(false)), - KeyCode::Down => send(PA::FormCycle(true)), - KeyCode::Up => send(PA::FormCycle(false)), - _ => send(PA::FormKey(key)), - }; - } - 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, - }; - } - PersonaMode::View => {} - } - - // ── An armed confirmation owns y/n ──────────────────────────────── - // - // Removing a persona goes through the existing identity-deletion path - // (`DeleteDid`), which does the VTA delete, the listener teardown and - // the local cleanup. The pane owns the *question*; it does not own a - // second implementation of the answer. - match personas.confirm { - PersonaConfirm::None => {} - PersonaConfirm::DeletePersona(i) => { - let act = match key.code { - KeyCode::Char('y') | KeyCode::Enter => Action::DeleteDid(i), - _ => Action::DidCancelDelete, - }; - let _ = self.action_tx.send(act); - return true; - } - _ => { - return match key.code { - KeyCode::Char('y') | KeyCode::Enter => send(PA::ConfirmYes), - _ => send(PA::ConfirmNo), - }; - } - } - - // ── Tab navigation and the pane-wide verbs ──────────────────────── - match key.code { - KeyCode::Tab | KeyCode::Right => return send(PA::TabNext), - KeyCode::BackTab | KeyCode::Left => return send(PA::TabPrev), - KeyCode::Esc => { - // Esc backs out of the opened profile first, so it is not also - // the key that leaves the panel while a detail view is up. - if personas.open_profile.is_some() { - return send(PA::ProfileClose); - } - let _ = self - .action_tx - .send(Action::MainPanelSwitch(MainPanel::MainMenu)); - return true; - } - KeyCode::Char('r') if personas.tab.needs_agent() => return send(PA::Refresh), - _ => {} - } - - match personas.tab { - PersonaTab::Personas => { - let count = personas.personas.len(); - let selected = personas.persona_selected; - match key.code { - KeyCode::Up if count > 0 => { - let _ = self - .action_tx - .send(Action::DidSelect(selected.saturating_sub(1))); - true - } - KeyCode::Down if count > 0 => { - let _ = self - .action_tx - .send(Action::DidSelect((selected + 1).min(count - 1))); - true - } - KeyCode::Char('n') => { - let _ = self.action_tx.send(Action::StartCreatePersona); - true - } - KeyCode::Char('g') => { - let _ = self.action_tx.send(Action::StartAgentNameManager(selected)); - true - } - // Only an orphan is removable: a persona a community still - // presents cannot be deleted without leaving that community - // 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 => { - if personas - .personas - .get(selected) - .is_some_and(|f| f.bound_communities == 0) - { - let _ = self.action_tx.send(Action::DidConfirmDelete(selected)); - } - true - } - _ => false, - } - } - PersonaTab::Attributes => { - let count = personas.attributes.len(); - let selected = personas.attribute_selected; - match key.code { - KeyCode::Up if count > 0 => send(PA::Select(selected.saturating_sub(1))), - KeyCode::Down if count > 0 => send(PA::Select((selected + 1).min(count - 1))), - KeyCode::Char('v') => send(PA::ToggleValues), - // `s` shows one value; `v` asks the agent for all of them. - // Two different questions, and the narrower one is not a - // shortcut to the wider: `s` on a listing fetched without - // values has nothing to unmask. - KeyCode::Char('s') if selected < count => send(PA::RevealValue(selected)), - KeyCode::Char('n') => send(PA::AttributeNew), - KeyCode::Char('e') if selected < count => send(PA::AttributeEdit(selected)), - // `p` forgets the versions behind the current one. Not on - // `d`'s neighbour keys by accident: it is one-way, and the - // question names what stops being shown. - KeyCode::Char('p') if selected < count => send(PA::AttributePurgeArm(selected)), - KeyCode::Char('d') | KeyCode::Delete if selected < count => { - send(PA::AttributeDeleteArm(selected)) - } - _ => false, - } - } - PersonaTab::Profiles => { - let count = personas.profiles.len(); - let selected = personas.profile_selected; - // The opened detail view is a mode of this tab, not of the - // pane, so its keys live here. - if let Some(detail) = personas.open_profile.as_ref() { - let claims = detail.resolved.len(); - let claim = personas.face_claim_selected; - return match key.code { - KeyCode::Enter => send(PA::ProfileClose), - KeyCode::Up if claims > 0 => { - send(PA::FaceClaimSelect(claim.saturating_sub(1))) - } - KeyCode::Down if claims > 0 => { - send(PA::FaceClaimSelect((claim + 1).min(claims - 1))) - } - // The same `s` the attributes tab uses, on the same - // terms: one claim, the selected one, and pressing it - // again puts the mask back. There is no `v` here - // because there is nothing for it to ask — a face - // detail is resolved in full when it is opened, so - // every value is already in hand and the mask is the - // only thing between it and the screen. - KeyCode::Char('s') if claim < claims => send(PA::RevealFaceClaim(claim)), - KeyCode::Char('e') => send(PA::ProfileEdit(selected)), - KeyCode::Char('h') => send(PA::FaceHistory), - _ => false, - }; - } - // The retired view walks its own list and offers two verbs: - // bring one back, or return to the faces the holder wears. - if personas.show_retired { - let retired = personas.retired_faces.len(); - return match key.code { - KeyCode::Up if retired > 0 => send(PA::Select(selected.saturating_sub(1))), - KeyCode::Down if retired > 0 => { - send(PA::Select((selected + 1).min(retired - 1))) - } - KeyCode::Char('x') if selected < retired => { - send(PA::ProfileReinstate(selected)) - } - KeyCode::Char('z') => send(PA::ToggleRetired), - _ => false, - }; - } - match key.code { - KeyCode::Char('z') => send(PA::ToggleRetired), - KeyCode::Char('x') if selected < count => send(PA::ProfileRetireArm(selected)), - KeyCode::Up if count > 0 => send(PA::Select(selected.saturating_sub(1))), - KeyCode::Down if count > 0 => send(PA::Select((selected + 1).min(count - 1))), - KeyCode::Enter if selected < count => send(PA::ProfileOpen(selected)), - KeyCode::Char('n') => send(PA::ProfileNew), - KeyCode::Char('e') if selected < count => send(PA::ProfileEdit(selected)), - KeyCode::Char('d') | KeyCode::Delete if selected < count => { - send(PA::ProfileDeleteArm(selected)) - } - // `m` for *move*, on the tab where the face being moved is - // visible. A world's membership is edited from here and - // nowhere else — see `PersonaAction::FacePlaceOpen`. - KeyCode::Char('m') if selected < count => send(PA::FacePlaceOpen(selected)), - _ => false, - } - } - PersonaTab::Worlds => { - let count = personas.worlds.len(); - let selected = personas.world_selected; - match key.code { - KeyCode::Up if count > 0 => send(PA::Select(selected.saturating_sub(1))), - KeyCode::Down if count > 0 => send(PA::Select((selected + 1).min(count - 1))), - KeyCode::Char('n') => send(PA::WorldNew), - KeyCode::Char('e') if selected < count => send(PA::WorldEdit(selected)), - KeyCode::Char('d') | KeyCode::Delete if selected < count => { - send(PA::WorldDeleteArm(selected)) - } - _ => false, - } - } - PersonaTab::Disclosures => { - let count = personas.disclosures.len(); - let selected = personas.disclosure_selected; - match key.code { - KeyCode::Up if count > 0 => send(PA::Select(selected.saturating_sub(1))), - KeyCode::Down if count > 0 => send(PA::Select((selected + 1).min(count - 1))), - // What a format carries belongs beside what left through - // it, not on a screen of its own nobody opens. - KeyCode::Char('f') => send(PA::RenderersOpen), - _ => false, - } - } - PersonaTab::Communities => { - let count = personas.memberships.len(); - let selected = personas.membership_selected; - match key.code { - KeyCode::Up if count > 0 => send(PA::Select(selected.saturating_sub(1))), - KeyCode::Down if count > 0 => send(PA::Select((selected + 1).min(count - 1))), - KeyCode::Char('b') if selected < count => send(PA::BindOpen(selected)), - KeyCode::Char('c') if selected < count => send(PA::ComposeOpen(selected)), - KeyCode::Char('f') if selected < count => send(PA::LocalFacesOpen(selected)), - KeyCode::Char('k') if selected < count => send(PA::KnownHereOpen(selected)), - KeyCode::Char('p') if selected < count => send(PA::PeopleOpen(selected)), - KeyCode::Char('u') if selected < count => send(PA::UnbindArm(selected)), - _ => false, - } - } - } - } - - /// Communities overview keys (R-A-5 Stage 4): `j` starts the join flow - /// (incl. from the empty state), ↑/↓ move the selection, `d`/Del removes the - /// selected community. Returns true if consumed. - /// Keys for the per-community capabilities view. - fn handle_capabilities_key( - &mut self, - key: KeyEvent, - view: &crate::state_handler::main_page::content::CapabilitiesView, - ) -> bool { - // Toggle confirmation pending: y/⏎ commits, anything else cancels. - if view.confirm_toggle.is_some() { - match key.code { - KeyCode::Char('y') | KeyCode::Enter => { - let _ = self.action_tx.send(Action::CapabilitiesToggleCommit); - } - _ => { - let _ = self.action_tx.send(Action::CapabilitiesToggleCancel); - } - } - return true; - } - match key.code { - KeyCode::Esc => { - let _ = self.action_tx.send(Action::CapabilitiesClose); - } - KeyCode::Up | KeyCode::Char('k') => { - let _ = self.action_tx.send(Action::CapabilitiesUp); - } - KeyCode::Down | KeyCode::Char('j') => { - let _ = self.action_tx.send(Action::CapabilitiesDown); - } - 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); - } - _ => return false, - } - true - } - - fn handle_communities_key(&mut self, key: KeyEvent) -> bool { - // Capabilities view open: it owns the keys until closed (Esc). - if let Some(view) = self.props.main_page.content_panel.capabilities.view.clone() { - return self.handle_capabilities_key(key, &view); - } - // Repos view open: the same. - if let Some(view) = self.props.main_page.content_panel.repos.view.clone() { - if key.code == KeyCode::Char('p') - && let Some(path) = repos_copy_path(&view) - { - let said = match crate::clipboard::copy_to_clipboard(&path) { - Ok(method) => format!("✓ Path copied via {}", method.label()), - Err(e) => format!("✗ Copy failed: {e}"), - }; - let _ = self.action_tx.send(Action::Repos( - crate::state_handler::actions::ReposAction::Workspace( - crate::state_handler::actions::WorkspaceAction::Copied(said), - ), - )); - return true; - } - let action = repos_key(key, &view); - let handled = action.is_some(); - if let Some(action) = action { - let _ = self.action_tx.send(Action::Repos(action)); - } - return handled; - } - // Rooms view open: the same. - if let Some(view) = self.props.main_page.content_panel.rooms.view.as_ref() { - let action = rooms_key(key, view); - let handled = action.is_some(); - if let Some(action) = action { - let _ = self.action_tx.send(Action::Rooms(action)); - } - return handled; - } - // Elections view open: the same. - if let Some(view) = self.props.main_page.content_panel.elections.view.as_ref() { - let action = elections_key(key, view); - let handled = action.is_some(); - if let Some(action) = action { - let _ = self.action_tx.send(Action::Elections(action)); - } - return handled; - } - // The personhood witness-credential picker owns the keys while open. - if let Some(view) = self - .props - .main_page - .content_panel - .communities - .personhood_evidence - .clone() - { - let action = personhood_evidence_key(key, &view); - let handled = action.is_some(); - if let Some(action) = action { - let _ = self.action_tx.send(action); - } - return handled; - } - // Deleting a context, or device access, owns the keys while open. - if let Some(view) = self - .props - .main_page - .content_panel - .communities - .context_delete - .clone() - { - return self.handle_context_delete_key(key, &view); - } - if let Some(view) = self - .props - .main_page - .content_panel - .communities - .device_access - .clone() - { - return self.handle_device_access_key(key, &view); - } - let comms = &self.props.main_page.content_panel.communities; - // `count` is the memberships — every index-taking action below indexes - // them, so a cursor on a "Joining" row (`selected >= count`) leaves - // those keys inert. `rows` is what the cursor moves over. - let count = comms.items.len(); - let rows = count + comms.joining.len(); - let selected = comms.selected_index; - let joining = components::communities_panel::joining_selected(comms) - .map(|j| j.application_id.clone()); - - // A removal confirmation is pending: only confirm (y/Enter) or cancel - // (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 => { - let _ = self.action_tx.send(Action::DeleteCommunity(idx)); - } - _ => { - let _ = self.action_tx.send(Action::CommunityCancelDelete); - } - } - return true; - } - // 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 => { - let _ = self.action_tx.send(Action::LeaveCommunity(idx)); - } - _ => { - let _ = self.action_tx.send(Action::CommunityCancelLeave); - } - } - return true; - } - // 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 => { - let _ = self.action_tx.send(Action::WithdrawJoin(idx)); - } - _ => { - let _ = self.action_tx.send(Action::CommunityCancelWithdraw); - } - } - 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); - let sel_pending = comms.items.get(selected).is_some_and(|c| c.is_pending); - let sel_renewable = comms - .items - .get(selected) - .is_some_and(|c| c.is_active || c.approved_awaiting_credential); - let sel_own_context = comms.items.get(selected).is_some_and(|c| c.has_own_context); - - match key.code { - KeyCode::Char('j') => { - let _ = self.action_tx.send(Action::StartJoin); - true - } - KeyCode::Up if rows > 0 => { - let _ = self - .action_tx - .send(Action::CommunitySelect(selected.saturating_sub(1))); - true - } - KeyCode::Down if rows > 0 => { - let _ = self - .action_tx - .send(Action::CommunitySelect((selected + 1).min(rows - 1))); - true - } - KeyCode::Enter if selected < count => { - // Make the highlighted community the working context (R-C-6). - let _ = self.action_tx.send(Action::SetActiveCommunity(selected)); - true - } - KeyCode::Enter if joining.is_some() => { - // A join in progress: its journey on the Vetting page, where - // every step of the application is taken. - if let Some(id) = joining { - let _ = self.action_tx.send(Action::Vetting( - crate::state_handler::actions::VettingAction::OpenApplication(id), - )); - } - true - } - KeyCode::Char('f') if selected < count => { - // Toggle the favourite star (R-C-4); the row re-sorts to the top. - let _ = self.action_tx.send(Action::ToggleFavourite(selected)); - true - } - KeyCode::Char('a') if selected < count => { - // Acknowledge a terminal outcome, clearing its badge (R-S-2). - let _ = self.action_tx.send(Action::AcknowledgeCommunity(selected)); - true - } - KeyCode::Char('l') if sel_active => { - // Leave an Active community (R-L-1) — arm the y/n confirmation. - let _ = self.action_tx.send(Action::CommunityConfirmLeave(selected)); - true - } - 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)); - true - } - KeyCode::Char('R') if sel_renewable => { - // Ask the community to renew this membership — re-issue its - // membership and role credentials (vtc/members/renew/0.1). This - // is how a member replaces credentials set aside as pre-v1, and - // 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)); - true - } - KeyCode::Char('c') if sel_active => { - // Open the community's capabilities view (governance/capability/*). - let _ = self.action_tx.send(Action::CapabilitiesOpen(selected)); - true - } - KeyCode::Char('r') if sel_active => { - // Open the community's Repos view (git-ns/*). - let _ = self.action_tx.send(Action::Repos( - crate::state_handler::actions::ReposAction::Open(selected), - )); - true - } - KeyCode::Char('o') if sel_active => { - // Open the community's data rooms (vtc/rooms/memberships/*). - let _ = self.action_tx.send(Action::Rooms( - crate::state_handler::actions::RoomsAction::Open(selected), - )); - true - } - KeyCode::Char('e') if sel_active => { - // Open the community's elections (vtc/elections/*). - let _ = self.action_tx.send(Action::Elections( - crate::state_handler::actions::ElectionsAction::Open(selected), - )); - true - } - // Cancel is pending-only: a Pending join can be withdrawn (arming on - // any other state would only fail downstream, so it's gated). - KeyCode::Char('c') if sel_pending => { - let _ = self - .action_tx - .send(Action::CommunityConfirmWithdraw(selected)); - true - } - KeyCode::Char('p') if sel_active => { - // Ask this community for a personhood challenge. Active-only: - // a community that has not admitted us has no personhood state - // to assert against. - let _ = self - .action_tx - .send(Action::RequestPersonhoodChallenge(selected)); - true - } - // Assert is offered only while a live challenge is in hand. - // Gating on the challenge rather than on the row means the key does - // nothing surprising when there is nothing to answer — and the - // panel only advertises it in the same condition. - KeyCode::Char('P') - if comms - .personhood_challenge - .as_ref() - .is_some_and(|c| c.is_live(chrono::Utc::now())) => - { - // Asks first: the witness credentials to present are chosen in a picker - // that opens with none chosen. - let _ = self - .action_tx - .send(Action::AssertPersonhood { witnessed: None }); - true - } - KeyCode::Char('x') if sel_inactive => { - // Archive an inactive community (R-C-8). - let _ = self.action_tx.send(Action::ArchiveCommunity(selected)); - true - } - KeyCode::Char('v') => { - // Toggle whether archived communities are listed (R-C-8). - let _ = self.action_tx.send(Action::ToggleShowArchived); - true - } - // Delete is inactive-only (R-C-8): an Active community must be left - // first. Arming on Active would only fail downstream, so it's gated. - KeyCode::Char('d') | KeyCode::Delete if sel_inactive => { - let _ = self - .action_tx - .send(Action::CommunityConfirmDelete(selected)); - true - } - // Delete a finished membership's own context. Never offered for the - // top context or a live membership; what else uses the context is - // checked in the loop before the VTA is asked anything. - KeyCode::Char('D') if sel_inactive && sel_own_context => { - let _ = self.action_tx.send(Action::CommunityContext( - crate::state_handler::actions::CommunityContextAction::DeleteStart(selected), - )); - true - } - // Device access, scoped to this community's own context. - KeyCode::Char('g') if sel_own_context => { - let _ = self.action_tx.send(Action::CommunityContext( - crate::state_handler::actions::CommunityContextAction::DevicesOpen(selected), - )); - true - } - KeyCode::Esc => { - let _ = self - .action_tx - .send(Action::MainPanelSwitch(MainPanel::MainMenu)); - true - } - _ => false, - } - } - - /// Context-deletion keys, while one is open. While the VTA is previewing, - /// only Esc (close). With the preview shown: typing builds the - /// confirmation, Backspace erases, Enter deletes, Esc closes. While the - /// delete runs, nothing — it cannot be called back. Paste is deliberately - /// not accepted into the confirmation: typing it is the point. - fn handle_context_delete_key( - &mut self, - key: KeyEvent, - view: &crate::state_handler::main_page::content::ContextDeleteView, - ) -> bool { - use crate::state_handler::actions::CommunityContextAction as C; - use crate::state_handler::main_page::content::ContextDeletePhase as Phase; - let action = match (&view.phase, key.code) { - (Phase::Deleting, _) => None, - (_, KeyCode::Esc) => Some(C::DeleteCancel), - (Phase::Ready(_), KeyCode::Enter) => Some(C::DeleteConfirm), - (Phase::Ready(_), KeyCode::Backspace) => { - let mut typed = view.typed.clone(); - typed.pop(); - Some(C::DeleteInput(typed)) - } - (Phase::Ready(_), KeyCode::Char(c)) => { - Some(C::DeleteInput(format!("{}{c}", view.typed))) - } - _ => None, - }; - let handled = action.is_some(); - if let Some(action) = action { - let _ = self.action_tx.send(Action::CommunityContext(action)); - } - handled - } - - /// Device-access keys, while the view is open. With the grant form open: - /// typing fills the focused field, Tab/↑/↓ move between fields, ←/→ choose - /// the expiry, Enter grants, Esc closes the form. With a revocation armed: - /// y/Enter revokes, anything else disarms. Otherwise ↑/↓ select, `n` adds a - /// device (live memberships only), `x`/Delete arms a revocation, `r` - /// re-reads, Esc closes. While a request runs, only Esc (close). - fn handle_device_access_key( - &mut self, - key: KeyEvent, - view: &crate::state_handler::main_page::content::DeviceAccessView, - ) -> bool { - use crate::state_handler::actions::CommunityContextAction as C; - use crate::state_handler::main_page::content::GrantForm; - let grants = self - .props - .main_page - .content_panel - .communities - .device_grants - .get(&view.context_id) - .map_or(0, |l| l.grants().len()); - let action = if view.busy { - matches!(key.code, KeyCode::Esc).then_some(C::DevicesClose) - } else if let Some(form) = &view.form { - let fields = GrantForm::FIELDS; - match key.code { - KeyCode::Esc => Some(C::GrantCancel), - KeyCode::Enter => Some(C::GrantSubmit), - KeyCode::Tab | KeyCode::Down => Some(C::GrantField((form.field + 1) % fields)), - KeyCode::BackTab | KeyCode::Up => { - Some(C::GrantField((form.field + fields - 1) % fields)) - } - KeyCode::Left if form.field == 2 => { - Some(C::GrantExpiry(form.expiry.saturating_sub(1))) - } - KeyCode::Right if form.field == 2 => Some(C::GrantExpiry(form.expiry + 1)), - KeyCode::Backspace if form.field < 2 => { - let mut value = grant_form_text(form).to_string(); - value.pop(); - Some(C::GrantInput { - field: form.field, - value, - }) - } - KeyCode::Char(c) if form.field < 2 => Some(C::GrantInput { - field: form.field, - value: format!("{}{c}", grant_form_text(form)), - }), - _ => None, - } - } else if view.confirm_revoke { - Some(match key.code { - KeyCode::Char('y') | KeyCode::Enter => C::RevokeConfirm, - _ => C::RevokeCancel, - }) - } else { - match key.code { - KeyCode::Esc => Some(C::DevicesClose), - KeyCode::Up if grants > 0 => { - Some(C::DevicesSelect(view.selected.saturating_sub(1))) - } - KeyCode::Down if grants > 0 => Some(C::DevicesSelect(view.selected + 1)), - KeyCode::Char('n') if view.can_grant => Some(C::GrantStart), - KeyCode::Char('x') | KeyCode::Delete if grants > 0 => Some(C::RevokeArm), - KeyCode::Char('r') => Some(C::DevicesRefresh), - _ => None, - } - }; - let handled = action.is_some(); - if let Some(action) = action { - let _ = self.action_tx.send(Action::CommunityContext(action)); - } - handled - } - - /// Create-persona overlay keys. Label phase: Enter goes on to the path - /// choice, Esc cancels, other keys edit the label. Path phase: ↑/↓ choose - /// server-assigned or typed, typing edits the path (and picks the typed - /// row), Enter goes on to the context choice, Esc goes back. Context phase: - /// ↑/↓ choose, typing names a new sub-context, Enter mints, Esc goes back to - /// the path. Working phase swallows input. Done/Failed: - /// `c` re-copies the DID (Done only), Enter/Esc close. Called only while the - /// overlay is open, where it owns all input. - fn handle_create_persona_key(&mut self, key: KeyEvent) { - let Some(overlay) = self.props.main_page.create_persona.as_ref() else { - return; - }; - crate::ui::pages::create_persona_overlay::handle_key(overlay, key, &self.action_tx); - } - - /// Agent-name manager overlay keys. `Ready` phase: Enter claims the typed - /// name, ↑/↓ move the row selection, Space parks/resumes the selected name, - /// `d`/Delete removes it, Esc closes, other keys edit the input. `Loading` - /// and `Working` swallow input (a mutation is in flight). Called only while - /// the overlay is open, where it owns all input. - fn handle_agent_name_manager_key(&mut self, key: KeyEvent) { - use crate::state_handler::main_page::content::AgentNameManagerPhase; - let Some(overlay) = self.props.main_page.agent_names.as_ref() else { - return; - }; - // A remove is armed: it owns all input — only y/Enter confirms (releasing - // 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 => { - let _ = self.action_tx.send(Action::AgentNameManagerRemove); - } - _ => { - let _ = self.action_tx.send(Action::AgentNameManagerCancelRemove); - } - } - return; - } - match overlay.phase { - AgentNameManagerPhase::Ready => match key.code { - KeyCode::Enter => { - let _ = self.action_tx.send(Action::AgentNameManagerClaim); - } - KeyCode::Esc => { - let _ = self.action_tx.send(Action::AgentNameManagerClose); - } - KeyCode::Up => { - let _ = self.action_tx.send(Action::AgentNameManagerSelect(false)); - } - KeyCode::Down => { - let _ = self.action_tx.send(Action::AgentNameManagerSelect(true)); - } - // Space parks/resumes; `d`/Delete arms the remove confirm — both - // act on the selected row and only when there is one. - KeyCode::Char(' ') if !overlay.names.is_empty() => { - let _ = self.action_tx.send(Action::AgentNameManagerToggle); - } - KeyCode::Char('d') | KeyCode::Delete if !overlay.names.is_empty() => { - let _ = self.action_tx.send(Action::AgentNameManagerConfirmRemove); - } - _ => { - let _ = self.action_tx.send(Action::AgentNameManagerInput(key)); - } - }, - // A list load or mutation is in flight: lock input. - AgentNameManagerPhase::Loading | AgentNameManagerPhase::Working => {} - } - } - - /// Add-VIC overlay keys. Input phase: Enter stores, Esc cancels, other keys - /// edit the paste field. Working swallows input. Done/Failed: any key closes. - /// Called only while the overlay is open, where it owns all input. - fn handle_add_vic_key(&mut self, key: KeyEvent) { - use crate::state_handler::main_page::content::AddVicPhase; - let Some(overlay) = self.props.main_page.add_vic.as_ref() else { - return; - }; - match overlay.phase { - AddVicPhase::Input => match key.code { - KeyCode::Enter => { - let _ = self.action_tx.send(Action::AddVicSubmit); - } - KeyCode::Esc => { - let _ = self.action_tx.send(Action::AddVicClose); - } - _ => { - let _ = self.action_tx.send(Action::AddVicInput(key)); - } - }, - AddVicPhase::Working => {} - AddVicPhase::Done | AddVicPhase::Failed => { - let _ = self.action_tx.send(Action::AddVicClose); - } - } - } - - /// Community switcher overlay keys (R-C-7): ↑/↓ move the highlight, Enter - /// switches the working community, Esc (or Ctrl+K again) dismisses. Called - /// only while the overlay is open, where it owns all input. - fn handle_switcher_key(&mut self, key: KeyEvent) { - let Some(switcher) = self.props.main_page.switcher.as_ref() else { - return; - }; - let count = switcher.items.len(); - let selected = switcher.selected; - match key.code { - KeyCode::Up if count > 0 => { - let _ = self - .action_tx - .send(Action::CommunitySwitcherMove(selected.saturating_sub(1))); - } - KeyCode::Down if count > 0 => { - let _ = self - .action_tx - .send(Action::CommunitySwitcherMove((selected + 1).min(count - 1))); - } - KeyCode::Enter if selected < count => { - let _ = self.action_tx.send(Action::CommunitySwitcherSelect); - } - KeyCode::Esc => { - let _ = self.action_tx.send(Action::CloseCommunitySwitcher); - } - KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => { - let _ = self.action_tx.send(Action::CloseCommunitySwitcher); - } - _ => {} - } - } - - 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). - if let Some(confirm) = inbox.confirm.clone() { - match key.code { - KeyCode::Char('y') | KeyCode::Enter => { - let commit = match confirm { - InboxConfirm::Dismiss { task_id } => InboxAction::DismissTask { task_id }, - InboxConfirm::ClearAll => InboxAction::ClearAll, - }; - let _ = self.action_tx.send(Action::Inbox(commit)); - } - _ => { - let _ = self - .action_tx - .send(Action::Inbox(InboxAction::CancelConfirm)); - } - } - return true; - } - - // If viewing a task detail, handle detail keys - if let Some(active_task) = &inbox.active_task { - // Extract what we need before borrowing self mutably - let task_id = match active_task { - ActiveTaskView::RelationshipRequestInbound { task_id, .. } - | ActiveTaskView::VRCRequestInbound { task_id, .. } - | ActiveTaskView::VRCIssued { task_id, .. } - | ActiveTaskView::RelationshipRequestOutbound { task_id, .. } - | ActiveTaskView::VRCRequestOutbound { task_id, .. } - | ActiveTaskView::Info { task_id, .. } => task_id.clone(), - }; - let is_rel_inbound = matches!( - active_task, - ActiveTaskView::RelationshipRequestInbound { .. } - ); - let is_vrc_issued = matches!(active_task, ActiveTaskView::VRCIssued { .. }); - let is_vrc_request_inbound = - matches!(active_task, ActiveTaskView::VRCRequestInbound { .. }); - - return match key.code { - KeyCode::Esc => { - let _ = self.action_tx.send(Action::Inbox(InboxAction::Back)); - true - } - // Accepting with a pairwise R-DID is the default path (#241); - // `A` stays wired to it so existing muscle memory keeps giving - // the private outcome it always gave. - KeyCode::Char('p') if is_rel_inbound => { - let _ = self - .action_tx - .send(Action::Inbox(InboxAction::AcceptRelationship { - task_id, - generate_r_did: false, - })); - true - } - KeyCode::Char('a') | KeyCode::Char('A') if is_rel_inbound => { - let _ = self - .action_tx - .send(Action::Inbox(InboxAction::AcceptRelationship { - task_id, - generate_r_did: true, - })); - true - } - KeyCode::Char('a') => { - if is_vrc_issued { - let _ = self - .action_tx - .send(Action::Inbox(InboxAction::AcceptVrc { task_id })); - } else if is_vrc_request_inbound { - let _ = self - .action_tx - .send(Action::Inbox(InboxAction::AcceptVrcRequest { task_id })); - } - 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, - })); - } - true - } - KeyCode::Char('d') => { - let _ = self - .action_tx - .send(Action::Inbox(InboxAction::ConfirmDismiss { task_id })); - true - } - _ => false, - }; - } - - // Task list navigation - let selected = inbox.selected_index; - let task_count = inbox.tasks.len(); - - match key.code { - KeyCode::Up if selected > 0 => { - let _ = self - .action_tx - .send(Action::Inbox(InboxAction::SelectTask(selected - 1))); - true - } - KeyCode::Down if selected + 1 < task_count => { - let _ = self - .action_tx - .send(Action::Inbox(InboxAction::SelectTask(selected + 1))); - true - } - KeyCode::Enter if selected < task_count => { - // All task types have detail views — let the state handler build it - let _ = self - .action_tx - .send(Action::Inbox(InboxAction::OpenDetail(selected))); - true - } - KeyCode::Char('d') if selected < task_count => { - let task_id = inbox.tasks[selected].id.clone(); - let _ = self - .action_tx - .send(Action::Inbox(InboxAction::ConfirmDismiss { task_id })); - true - } - KeyCode::Char('c') if task_count > 0 => { - let _ = self - .action_tx - .send(Action::Inbox(InboxAction::ConfirmClearAll)); - true - } - KeyCode::Esc => { - let _ = self - .action_tx - .send(Action::MainPanelSwitch(MainPanel::MainMenu)); - true - } - _ => false, - } - } - - fn handle_relationships_key(&mut self, key: KeyEvent) -> bool { - use crate::state_handler::main_page::content::RelationshipsMode; - - let rels = &self.props.main_page.content_panel.relationships; - - match &rels.mode { - RelationshipsMode::NewRequest { - did_input, - alias_input, - reason_input, - generate_r_did, - active_field, - .. - } => { - // Form input handling - let active_field = *active_field; - let generate_r_did = *generate_r_did; - match key.code { - KeyCode::Esc => { - let _ = self - .action_tx - .send(Action::Relationship(RelationshipAction::CancelNewRequest)); - true - } - KeyCode::Tab => { - // Cycle through fields 0->1->2->3->0 - let next = (active_field + 1) % 4; - let _ = self - .action_tx - .send(Action::Relationship(RelationshipAction::FocusField(next))); - true - } - KeyCode::Up if active_field > 0 => { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::FocusField(active_field - 1), - )); - true - } - KeyCode::Down if active_field < 3 => { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::FocusField(active_field + 1), - )); - true - } - KeyCode::Char(' ') if active_field == 3 => { - // Toggle the generate_r_did boolean - let _ = self - .action_tx - .send(Action::Relationship(RelationshipAction::ToggleRDid)); - true - } - KeyCode::Enter if active_field == 3 => { - // Submit from the last field - let did = did_input.clone(); - let alias = alias_input.clone(); - let reason = if reason_input.trim().is_empty() { - None - } else { - Some(reason_input.clone()) - }; - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::SubmitRequest { - did, - alias, - reason, - generate_r_did, - }, - )); - 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, - } - } - RelationshipsMode::Detail { - index, - selected_vrc, - } => { - let index = *index; - let current_vrc = *selected_vrc; - - // A removal confirmation is pending: y/Enter commits, anything - // 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 => { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::Remove { remote_p_did }, - )); - } - _ => { - let _ = self - .action_tx - .send(Action::Relationship(RelationshipAction::CancelRemove)); - } - } - return true; - } - - match key.code { - KeyCode::Down => { - if let Some(rel) = rels.relationships.get(index) { - let total = rel.vrcs_issued.len() + rel.vrcs_received.len(); - if total > 0 { - let next = match current_vrc { - None => Some(0), - Some(n) if n + 1 < total => Some(n + 1), - other => other, - }; - if let RelationshipsMode::Detail { - selected_vrc: ref mut sv, - .. - } = self.props.main_page.content_panel.relationships.mode - { - *sv = next; - } - } - } - true - } - KeyCode::Up => { - let next = match current_vrc { - Some(0) => None, - Some(n) => Some(n - 1), - None => None, - }; - if let RelationshipsMode::Detail { - selected_vrc: ref mut sv, - .. - } = self.props.main_page.content_panel.relationships.mode - { - *sv = next; - } - true - } - KeyCode::Esc => { - let _ = self - .action_tx - .send(Action::Relationship(RelationshipAction::Back)); - true - } - KeyCode::Char('e') => { - if let Some(rel) = rels.relationships.get(index) { - let current = rel.alias.clone().unwrap_or_default(); - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::StartEditAlias { - index, - current_alias: current, - }, - )); - } - true - } - KeyCode::Char('p') => { - if let Some(rel) = rels.relationships.get(index) { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::Ping { - remote_p_did: rel.remote_p_did.clone(), - }, - )); - } - true - } - KeyCode::Char('d') => { - if let Some(rel) = rels.relationships.get(index) { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::ConfirmRemove { - remote_p_did: rel.remote_p_did.clone(), - }, - )); - } - true - } - KeyCode::Char('v') => { - if let Some(rel) = rels.relationships.get(index) { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::RequestVrc { - remote_p_did: rel.remote_p_did.clone(), - }, - )); - } - true - } - KeyCode::Char('w') => { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::StartWitness { index }, - )); - true - } - _ => false, - } - } - RelationshipsMode::Witness { - index, - witness_input, - share_membership, - } => { - let index = *index; - match key.code { - KeyCode::Esc => { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::CancelWitness { index }, - )); - true - } - KeyCode::Enter => { - if let Some(rel) = rels.relationships.get(index) { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::Witness { - remote_p_did: rel.remote_p_did.clone(), - witness: witness_input.clone(), - share_membership: *share_membership, - }, - )); - } - true - } - // Tab, not a letter: the witness is typed into this prompt. - KeyCode::Tab => { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::ToggleWitnessMembership, - )); - true - } - code => match edit_text(code, witness_input) { - Some(value) => { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::WitnessInputUpdate(value), - )); - true - } - None => false, - }, - } - } - RelationshipsMode::EditAlias { index, alias_input } => { - let index = *index; - match key.code { - KeyCode::Esc => { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::CancelEditAlias { index }, - )); - true - } - KeyCode::Enter => { - if let Some(rel) = rels.relationships.get(index) { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::EditAlias { - remote_p_did: rel.remote_p_did.clone(), - alias: alias_input.clone(), - }, - )); - } - true - } - code => match edit_text(code, alias_input) { - Some(value) => { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::EditAliasUpdate(value), - )); - true - } - None => false, - }, - } - } - RelationshipsMode::List => { - let selected = rels.selected_index; - let count = rels.relationships.len(); - - match key.code { - KeyCode::Up if selected > 0 => { - let _ = - self.action_tx - .send(Action::Relationship(RelationshipAction::Select( - selected - 1, - ))); - true - } - KeyCode::Down if selected + 1 < count => { - let _ = - self.action_tx - .send(Action::Relationship(RelationshipAction::Select( - selected + 1, - ))); - true - } - KeyCode::Enter if selected < count => { - let _ = self.action_tx.send(Action::Relationship( - RelationshipAction::OpenDetail(selected), - )); - true - } - KeyCode::Char('n') => { - let _ = self - .action_tx - .send(Action::Relationship(RelationshipAction::StartNewRequest)); - true - } - KeyCode::Esc => { - let _ = self - .action_tx - .send(Action::MainPanelSwitch(MainPanel::MainMenu)); - true - } - _ => false, - } - } - } - } - - fn handle_credentials_key(&mut self, key: KeyEvent) -> bool { - use crate::state_handler::main_page::content::CredentialsMode; - - let creds = &self.props.main_page.content_panel.credentials; - - match &creds.mode { - CredentialsMode::NewRequest { - relationship_index, - reason_input, - } => { - let rel_idx = *relationship_index; - match key.code { - KeyCode::Esc => { - let _ = self - .action_tx - .send(Action::Credential(CredentialAction::CancelNewRequest)); - true - } - KeyCode::Up if rel_idx > 0 => { - let _ = self.action_tx.send(Action::Credential( - CredentialAction::SelectRelationship(rel_idx - 1), - )); - true - } - KeyCode::Down => { - // Bound check happens in state handler - let _ = self.action_tx.send(Action::Credential( - CredentialAction::SelectRelationship(rel_idx + 1), - )); - true - } - KeyCode::Enter => { - // Get the established relationships from the relationships panel state - let established: Vec<_> = self - .props - .main_page - .content_panel - .relationships - .relationships - .iter() - .filter(|r| r.state == "Established") - .collect(); - if let Some(rel) = established.get(rel_idx) { - let _ = self.action_tx.send(Action::Credential( - CredentialAction::SubmitRequest { - relationship_p_did: rel.remote_p_did.clone(), - reason: if reason_input.trim().is_empty() { - None - } else { - Some(reason_input.clone()) - }, - }, - )); - } - true - } - code => match edit_text(code, reason_input) { - Some(r) => { - let _ = self - .action_tx - .send(Action::Credential(CredentialAction::ReasonUpdate(r))); - true - } - None => false, - }, - } - } - CredentialsMode::Detail { index } => { - let detail_index = *index; - - // A removal confirmation is pending: y/Enter commits, anything - // 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 => { - let _ = self - .action_tx - .send(Action::Credential(CredentialAction::Remove { vrc_id })); - } - _ => { - let _ = self - .action_tx - .send(Action::Credential(CredentialAction::CancelRemove)); - } - } - return true; - } - - match key.code { - KeyCode::Esc => { - let _ = self - .action_tx - .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) - { - let _ = self.action_tx.send(Action::Credential( - CredentialAction::ConfirmRemove { - vrc_id: vrc.vrc_id.clone(), - }, - )); - } - true - } - KeyCode::Char('c') => { - if let Some(vrc) = creds.active_list().get(detail_index) { - copy_to_clipboard( - &vrc.raw_json.to_pretty_json(), - "credential", - &self.action_tx, - ); - } - true - } - _ => false, - } - } - CredentialsMode::List => { - let active_list_len = creds.active_list().len(); - let selected = creds.selected_index; - - match key.code { - KeyCode::Tab => { - let _ = self - .action_tx - .send(Action::Credential(CredentialAction::SwitchTab)); - true - } - KeyCode::Up if selected > 0 => { - let _ = self - .action_tx - .send(Action::Credential(CredentialAction::Select(selected - 1))); - true - } - KeyCode::Down if selected + 1 < active_list_len => { - let _ = self - .action_tx - .send(Action::Credential(CredentialAction::Select(selected + 1))); - true - } - KeyCode::Enter if selected < active_list_len => { - let _ = self - .action_tx - .send(Action::Credential(CredentialAction::OpenDetail(selected))); - true - } - KeyCode::Char('n') => { - let _ = self - .action_tx - .send(Action::Credential(CredentialAction::StartNewRequest)); - true - } - KeyCode::Esc => { - let _ = self - .action_tx - .send(Action::MainPanelSwitch(MainPanel::MainMenu)); - true - } - _ => false, - } - } - } - } - fn handle_settings_key(&mut self, key: KeyEvent) -> bool { - use crate::state_handler::main_page::content::SettingsMode; - - let settings = &self.props.main_page.content_panel.settings; - - match &settings.mode { - SettingsMode::EditFriendlyName { input } => { - let current = input.clone(); - match key.code { - KeyCode::Esc => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::CancelEdit)); - true - } - KeyCode::Enter => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::SubmitEdit { - value: current, - })); - true - } - code => match edit_text(code, ¤t) { - Some(v) => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::FieldUpdate(v))); - true - } - None => false, - }, - } - } - SettingsMode::ExportConfig { - path_input, - active_field, - .. - } => { - let active = *active_field; - let path = path_input.clone(); - self.handle_config_form_key(key, active, path, true, |path, passphrase| { - SettingsAction::ExportConfig { path, passphrase } - }) - } - SettingsMode::ChangeProtection { - selected_option, - active_field, - .. - } => { - let active = *active_field; - let sel = *selected_option; - match key.code { - KeyCode::Esc => { - self.passphrase_buffer.clear(); - self.confirm_buffer.clear(); - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::CancelEdit)); - true - } - KeyCode::Up if active == 0 && sel > 0 => { - let _ = self.action_tx.send(Action::Settings( - SettingsAction::ProtectionOptionSelect(sel - 1), - )); - true - } - KeyCode::Down if active == 0 && sel < 1 => { - let _ = self.action_tx.send(Action::Settings( - SettingsAction::ProtectionOptionSelect(sel + 1), - )); - true - } - KeyCode::Enter if active == 0 => { - if sel == 0 { - // Switch to passphrase input - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::ProtectionStartInput)); - } else { - // Remove passphrase - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::RemovePassphrase)); - } - true - } - KeyCode::Tab if active >= 1 => { - let next = if active == 1 { 2 } else { 1 }; - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::ProtectionTabSwitch(next))); - true - } - KeyCode::Up if active == 2 => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::ProtectionTabSwitch(1))); - true - } - KeyCode::Down if active == 1 => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::ProtectionTabSwitch(2))); - true - } - KeyCode::Enter if active == 1 => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::ProtectionTabSwitch(2))); - true - } - KeyCode::Enter if active == 2 => { - // Submit passphrase — or say why not. An Enter that - // silently does nothing reads as a frozen form. - let action = match passphrase_problem( - self.passphrase_buffer.as_str(), - self.confirm_buffer.as_str(), - ) { - Some(why) => SettingsAction::FormProblem(why), - None => { - let passphrase = self.passphrase_buffer.take(); - self.confirm_buffer.clear(); - SettingsAction::SetPassphrase { passphrase } - } - }; - let _ = self.action_tx.send(Action::Settings(action)); - true - } - code if active >= 1 => { - // Secure passphrase / confirm buffers: apply the keystroke - // to the active buffer and report only its length. - let buffer = if active == 1 { - &mut self.passphrase_buffer - } else { - &mut self.confirm_buffer - }; - match code { - KeyCode::Backspace => { - buffer.pop(); - } - KeyCode::Char(c) => { - buffer.push(c); - } - _ => return false, - } - let len = buffer.len(); - let action = if active == 1 { - SettingsAction::ProtectionPassphraseLen(len) - } else { - SettingsAction::ProtectionConfirmLen(len) - }; - let _ = self.action_tx.send(Action::Settings(action)); - true - } - _ => false, - } - } - #[cfg(feature = "openpgp-card")] - SettingsMode::TokenManagement { selected_index } => { - let sel = *selected_index; - match key.code { - KeyCode::Esc => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::TokenBack)); - true - } - KeyCode::Up if sel > 0 => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::Select(sel - 1))); - true - } - KeyCode::Down if sel < 1 => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::Select(sel + 1))); - true - } - KeyCode::Enter => { - match sel { - 0 => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::TokenDetect)); - } - 1 => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::TokenFactoryResetStart)); - } - _ => {} - } - true - } - _ => false, - } - } - SettingsMode::ImportConfig { - path_input, - active_field, - .. - } => { - let active = *active_field; - let path = path_input.clone(); - self.handle_config_form_key(key, active, path, false, |path, passphrase| { - SettingsAction::ImportConfig { path, passphrase } - }) - } - SettingsMode::View => { - use crate::ui::pages::main::components::settings_panel as rows; - let selected = settings.selected_index; - let max_index = rows::WIPE_ROW; - - match key.code { - KeyCode::Up if selected > 0 => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::Select(selected - 1))); - true - } - KeyCode::Down if selected < max_index => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::Select(selected + 1))); - true - } - KeyCode::Enter => { - // Friendly name, export and import each open a form; - // `settings_actions::handle_start_edit` picks which. - if selected == rows::FRIENDLY_NAME_ROW - || selected == rows::EXPORT_ROW - || selected == rows::IMPORT_ROW - { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::StartEdit)); - } else if selected == rows::PROTECTION_ROW { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::ChangeProtection)); - } else if selected == rows::THEME_ROW { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::ThemeOpen)); - } - #[cfg(feature = "openpgp-card")] - if selected == rows::TOKEN_ROW { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::TokenManagement)); - } - if selected == rows::WIPE_ROW { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::WipeProfileStart)); - } - true - } - KeyCode::Esc => { - let _ = self - .action_tx - .send(Action::MainPanelSwitch(MainPanel::MainMenu)); - true - } - _ => false, - } - } - SettingsMode::ThemePicker { rows, selected, .. } => { - let (selected, count) = (*selected, rows.len()); - let action = match key.code { - KeyCode::Up if selected > 0 => SettingsAction::ThemeSelect(selected - 1), - KeyCode::Down if selected + 1 < count => { - SettingsAction::ThemeSelect(selected + 1) - } - KeyCode::Enter => SettingsAction::ThemeApply, - KeyCode::Esc => SettingsAction::ThemeCancel, - KeyCode::Char('c') => SettingsAction::ThemeCopy, - KeyCode::Char('r') => SettingsAction::ThemeReload, - _ => return false, - }; - let _ = self.action_tx.send(Action::Settings(action)); - true - } - #[cfg(feature = "openpgp-card")] - SettingsMode::TokenResetConfirm { confirm_input, .. } => { - let current = confirm_input.clone(); - 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, - }, - }; - let _ = self.action_tx.send(Action::Settings(action)); - true - } - SettingsMode::WipeConfirm { confirm_input } => { - let current = confirm_input.clone(); - match key.code { - KeyCode::Esc => { - // Drop back to the Settings list — wipe is cancelled. - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::CancelEdit)); - true - } - KeyCode::Enter => { - let _ = self - .action_tx - .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, - }, - } - } - } - } - - /// Shared key handling for the Export/Import config forms. The two forms are - /// identical except for the submit Action, supplied via `make_submit` - /// (called with the path and the taken passphrase buffer). `active` is the - /// focused field (0 = path, 1 = passphrase). - fn handle_config_form_key( - &mut self, - key: KeyEvent, - active: usize, - path: String, - confirm: bool, - make_submit: impl FnOnce(String, secrecy::SecretString) -> SettingsAction, - ) -> bool { - // The field Enter submits from: the confirmation on export, the - // passphrase on import. - let last = if confirm { 2 } else { 1 }; - match key.code { - KeyCode::Esc => { - self.passphrase_buffer.clear(); - self.confirm_buffer.clear(); - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::CancelEdit)); - true - } - KeyCode::Tab | KeyCode::Up | KeyCode::Down => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::FormTabSwitch)); - true - } - KeyCode::Enter if active == 1 && confirm => { - let _ = self - .action_tx - .send(Action::Settings(SettingsAction::FormTabSwitch)); - true - } - KeyCode::Enter if active == last => { - // A typo in an export passphrase makes a backup nobody can - // open, discovered only when it is needed. - let problem = if confirm { - passphrase_problem( - self.passphrase_buffer.as_str(), - self.confirm_buffer.as_str(), - ) - } else { - None - }; - let action = match problem { - Some(why) => SettingsAction::FormProblem(why), - None => { - let passphrase = self.passphrase_buffer.take(); - self.confirm_buffer.clear(); - make_submit(path, passphrase) - } - }; - let _ = self.action_tx.send(Action::Settings(action)); - true + KeyCode::Home => { + self.content_scroll = 0; + return true; } - 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, - } - } else { - let buffer = if active == 1 { - &mut self.passphrase_buffer - } else { - &mut self.confirm_buffer - }; - match code { - KeyCode::Backspace => { - buffer.pop(); - } - KeyCode::Char(c) => { - buffer.push(c); - } - _ => return false, - } - let len = buffer.len(); - let action = if active == 1 { - SettingsAction::PassphraseLen(len) - } else { - SettingsAction::ExportConfirmLen(len) - }; - let _ = self.action_tx.send(Action::Settings(action)); - true - } + KeyCode::End => { + self.content_scroll = self.content_scroll_max.get(); + return true; } + _ => {} + } + + let menu = self.props.main_page.menu_panel.selected_menu.clone(); + + match menu { + MainMenu::Inbox => self.handle_inbox_key(key), + MainMenu::Relationships => self.handle_relationships_key(key), + MainMenu::Credentials => self.handle_credentials_key(key), + MainMenu::Settings => self.handle_settings_key(key), + MainMenu::Logs => self.handle_logs_key(key), + MainMenu::Help => self.handle_help_key(key), + MainMenu::Communities => self.handle_communities_key(key), + MainMenu::Identity => self.handle_personas_key(key), + MainMenu::Vta => self.handle_vta_key(key), + MainMenu::Vetting => self.handle_vetting_key(key), + _ => false, } } - fn handle_logs_key(&mut self, key: KeyEvent) -> bool { - let total = self.props.main_page.activity_log.len(); + /// Create-persona overlay keys. Label phase: Enter goes on to the path + /// choice, Esc cancels, other keys edit the label. Path phase: ↑/↓ choose + /// server-assigned or typed, typing edits the path (and picks the typed + /// row), Enter goes on to the context choice, Esc goes back. Context phase: + /// ↑/↓ choose, typing names a new sub-context, Enter mints, Esc goes back to + /// the path. Working phase swallows input. Done/Failed: + /// `c` re-copies the DID (Done only), Enter/Esc close. Called only while the + /// overlay is open, where it owns all input. + fn handle_create_persona_key(&mut self, key: KeyEvent) { + let Some(overlay) = self.props.main_page.create_persona.as_ref() else { + return; + }; + crate::ui::pages::create_persona_overlay::handle_key(overlay, key, &self.action_tx); + } - // Detail view mode — Esc or Enter to close - if self.logs_detail_view { + /// Agent-name manager overlay keys. `Ready` phase: Enter claims the typed + /// name, ↑/↓ move the row selection, Space parks/resumes the selected name, + /// `d`/Delete removes it, Esc closes, other keys edit the input. `Loading` + /// and `Working` swallow input (a mutation is in flight). Called only while + /// the overlay is open, where it owns all input. + fn handle_agent_name_manager_key(&mut self, key: KeyEvent) { + use crate::state_handler::main_page::content::AgentNameManagerPhase; + let Some(overlay) = self.props.main_page.agent_names.as_ref() else { + return; + }; + // A remove is armed: it owns all input — only y/Enter confirms (releasing + // the name, which anyone can then reclaim); anything else backs out. + if overlay.confirm_remove.is_some() { match key.code { - KeyCode::Esc | KeyCode::Enter => { - self.logs_detail_view = false; - return true; + KeyCode::Char('y') | KeyCode::Enter => { + let _ = self.action_tx.send(Action::AgentNameManagerRemove); } - KeyCode::Char('c') if total > 0 => { - let entries: Vec<_> = self.props.main_page.activity_log.iter().rev().collect(); - if let Some(entry) = entries.get(self.logs_selected) { - copy_to_clipboard(&entry.summary, "Log entry", &self.action_tx); - } - return true; + _ => { + let _ = self.action_tx.send(Action::AgentNameManagerCancelRemove); } - _ => return false, } + return; } - - match key.code { - KeyCode::Up if self.logs_selected > 0 => { - self.logs_selected -= 1; - true - } - KeyCode::Down if self.logs_selected + 1 < total => { - self.logs_selected += 1; - true - } - KeyCode::Enter if total > 0 => { - self.logs_detail_view = true; - true - } - KeyCode::Char('c') if total > 0 => { - // Copy selected log entry to clipboard - let entries: Vec<_> = self.props.main_page.activity_log.iter().rev().collect(); - if let Some(entry) = entries.get(self.logs_selected) { - copy_to_clipboard(&entry.summary, "Log entry", &self.action_tx); + match overlay.phase { + AgentNameManagerPhase::Ready => match key.code { + KeyCode::Enter => { + let _ = self.action_tx.send(Action::AgentNameManagerClaim); } - true - } - KeyCode::Char('a') if total > 0 => { - // Copy all log entries to clipboard - let all_text: String = self - .props - .main_page - .activity_log - .iter() - .rev() - .map(|e| e.summary.as_str()) - .collect::>() - .join("\n"); - copy_to_clipboard(&all_text, "All log entries", &self.action_tx); - true - } - KeyCode::Esc => { - let _ = self - .action_tx - .send(Action::MainPanelSwitch(MainPanel::MainMenu)); - true - } - _ => false, - } - } - - fn handle_help_key(&mut self, key: KeyEvent) -> bool { - match key.code { - KeyCode::Char('1') => { - // Copy persona DID to clipboard - let did = self - .props - .main_page - .content_panel - .settings - .persona_did - .clone(); - copy_to_clipboard(&did, "Persona DID", &self.action_tx); - true - } - KeyCode::Char('2') => { - // Copy mediator DID to clipboard - let did = self - .props - .main_page - .content_panel - .settings - .mediator_did - .clone(); - copy_to_clipboard(&did, "Mediator DID", &self.action_tx); - true - } - KeyCode::Char('3') => { - if let Some(info) = &self.props.main_page.content_panel.settings.did_git_sign { - copy_to_clipboard( - &info.did_key_id, - "Signing principal (DID#kid)", - &self.action_tx, - ); + KeyCode::Esc => { + let _ = self.action_tx.send(Action::AgentNameManagerClose); } - true - } - KeyCode::Char('4') => { - if let Some(info) = &self.props.main_page.content_panel.settings.did_git_sign { - copy_to_clipboard( - &info.ssh_public_key, - "SSH signing public key", - &self.action_tx, - ); + KeyCode::Up => { + let _ = self.action_tx.send(Action::AgentNameManagerSelect(false)); } - true - } - KeyCode::Esc => { - let _ = self - .action_tx - .send(Action::MainPanelSwitch(MainPanel::MainMenu)); - true - } - _ => false, + KeyCode::Down => { + let _ = self.action_tx.send(Action::AgentNameManagerSelect(true)); + } + // Space parks/resumes; `d`/Delete arms the remove confirm — both + // act on the selected row and only when there is one. + KeyCode::Char(' ') if !overlay.names.is_empty() => { + let _ = self.action_tx.send(Action::AgentNameManagerToggle); + } + KeyCode::Char('d') | KeyCode::Delete if !overlay.names.is_empty() => { + let _ = self.action_tx.send(Action::AgentNameManagerConfirmRemove); + } + _ => { + let _ = self.action_tx.send(Action::AgentNameManagerInput(key)); + } + }, + // A list load or mutation is in flight: lock input. + AgentNameManagerPhase::Loading | AgentNameManagerPhase::Working => {} } } -} -/// 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. -impl MainPage { - /// Vetting page keys. - /// - /// In a list: `Tab` switches tab, `↑/↓` select, and each tab has its own - /// verbs (shown in its footer). In a form: `↑/↓`/`Tab` move between fields, - /// a text field takes typing, `←/→` cycle a choice, `Space` ticks, `Enter` - /// commits and `Esc` leaves. - fn handle_vetting_key(&mut self, key: KeyEvent) -> bool { - use crate::state_handler::actions::VettingAction as V; - use crate::state_handler::main_page::content::{DeskView, VettingMode, VettingTab}; - - let vetting = &self.props.main_page.content_panel.vetting; - if !matches!(vetting.mode, VettingMode::List) { - let text = vetting.mode.focused_text().map(str::to_string); - let confirming = matches!(vetting.mode, VettingMode::ConfirmDecline { .. }); - // 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 - // nothing — and with no field to type in and no rows to move - // between, Tab and the arrows did nothing visible either. The view - // read as frozen, and the only way back to a working `f` was to - // restart. - // `f` chooses the face wherever a missing or wrong one is what the - // page is reporting: the grant page, and the card it reads from. - let chooses_face = matches!( - vetting.mode, - VettingMode::HolderGrant { .. } | VettingMode::SendCard { .. } - ); - let on_result = matches!(&vetting.mode, VettingMode::Directory(view) if view.result_index().is_some()); - let on_event = matches!( - &vetting.mode, - VettingMode::Profile(form) if form.event.is_none() && form.event_index().is_some() - ); - let on_hidden = matches!(vetting.mode, VettingMode::HiddenVetting { .. }); - // Ctrl+V on the one field whose whole content is something pasted. - // Ahead of the text handler, which would otherwise type a literal - // `v` into the ticket link. Bracketed paste still works and is the - // path that survives SSH; this is the one that is on the screen. - if matches!(vetting.mode, VettingMode::RequestVetter { .. }) - && key.modifiers.contains(KeyModifiers::CONTROL) - && matches!(key.code, KeyCode::Char('v' | 'V')) - { - let action = match crate::clipboard::read_clipboard() { - Ok(pasted) => V::PasteTicket(pasted), - // 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!( - "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; - } - let action = match key.code { - KeyCode::Esc => Some(V::Back), - KeyCode::Enter => Some(V::Submit), - KeyCode::Tab | KeyCode::Down => Some(V::NextField), - KeyCode::BackTab | KeyCode::Up => Some(V::PrevField), - KeyCode::Left => Some(V::Cycle(false)), - KeyCode::Right => Some(V::Cycle(true)), - 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('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') => { - Some(V::DirectoryPage(false)) - } - None if on_result && code == KeyCode::Char('a') => Some(V::AskListedVetter), - None if on_event && matches!(code, KeyCode::Char('x') | KeyCode::Delete) => { - Some(V::RemoveEvent) - } - None if on_hidden && code == KeyCode::Char('d') => Some(V::DrawNow), - None if on_hidden && code == KeyCode::Char('r') => Some(V::CheckHiddenKey), - None if code == KeyCode::Char(' ') => Some(V::Toggle), - None => None, - }, - }; - return match action { - Some(action) => { - let _ = self.action_tx.send(Action::Vetting(action)); - true + /// Add-VIC overlay keys. Input phase: Enter stores, Esc cancels, other keys + /// edit the paste field. Working swallows input. Done/Failed: any key closes. + /// Called only while the overlay is open, where it owns all input. + fn handle_add_vic_key(&mut self, key: KeyEvent) { + use crate::state_handler::main_page::content::AddVicPhase; + let Some(overlay) = self.props.main_page.add_vic.as_ref() else { + return; + }; + match overlay.phase { + AddVicPhase::Input => match key.code { + KeyCode::Enter => { + let _ = self.action_tx.send(Action::AddVicSubmit); } - None => false, - }; - } - - // A journey is open: its keys are the current step's, and the way out. - // A key for a step not reached yet does nothing — the strip says why — - // so the steps cannot be taken out of order by accident. - if let Some(journey) = &vetting.journey { - use crate::state_handler::main_page::content::JourneySteps; - use openvtc_core::vetting::journey::{ApplicantStep, StepState, VetterStep}; - let reached = |state: StepState| state != StepState::Todo; - let action = match &journey.steps { - JourneySteps::Applicant(steps) => { - let at = |step: ApplicantStep| { - steps.iter().any(|s| s.step == step && reached(s.state)) - }; - match key.code { - KeyCode::Esc => Some(V::CloseJourney), - KeyCode::Char('m') => Some(V::RefreshRequirements), - KeyCode::Char('x') | KeyCode::Delete => Some(V::ArmAbandon), - // The face can be changed until a card has gone. - KeyCode::Char('f') if at(ApplicantStep::Face) => Some(V::ChooseFace), - KeyCode::Char('r') if at(ApplicantStep::Vetters) => Some(V::RequestVetter), - KeyCode::Char('v') if at(ApplicantStep::Vetters) => Some(V::FindVetters), - KeyCode::Enter | KeyCode::Char('c') if at(ApplicantStep::Sessions) => { - Some(V::ReviewCard) - } - KeyCode::Char('j') - if steps.iter().any(|s| { - s.step == ApplicantStep::Join && s.state == StepState::Current - }) => - { - if let Some(application) = vetting.applications.get(vetting.selected) { - let _ = self - .action_tx - .send(Action::StartJoinFor(application.community.clone())); - } - return true; - } - _ => None, - } + KeyCode::Esc => { + let _ = self.action_tx.send(Action::AddVicClose); } - JourneySteps::Vetter(steps, ending) => { - let current = openvtc_core::vetting::journey::current(steps); - match key.code { - KeyCode::Esc => Some(V::CloseJourney), - _ if ending.is_some() => None, - KeyCode::Char('x') => Some(V::ArmDecline), - KeyCode::Char('o') - if matches!(current, Some(VetterStep::Session | VetterStep::Code)) => - { - Some(V::OpenSession) - } - KeyCode::Enter | KeyCode::Char('a') - if matches!(current, Some(VetterStep::Check | VetterStep::Sign)) => - { - Some(V::StartAttest) - } - _ => None, - } + _ => { + let _ = self.action_tx.send(Action::AddVicInput(key)); } - }; - if let Some(action) = action { - let _ = self.action_tx.send(Action::Vetting(action)); + }, + AddVicPhase::Working => {} + AddVicPhase::Done | AddVicPhase::Failed => { + let _ = self.action_tx.send(Action::AddVicClose); } - // Every key is the journey's while it is open, including the ones - // it ignores: falling through would act on the list behind it. - return true; - } - - // The desk's views take ←/→, which everywhere else on the main page is - // how focus returns to the menu (they fall through to the page-level - // handler). Consuming them without offering another way out would make - // the desk a room whose door is bound to something else, so Esc is that - // way out — and it is bound for the whole page, not just the desk, so - // it means one thing here. - if key.code == KeyCode::Esc { - let _ = self - .action_tx - .send(Action::MainPanelSwitch(MainPanel::MainMenu)); - return true; } + } - let selected = vetting.selected; - let len = vetting.tab_len(); - let view = vetting.desk_view; - // The desk's three views share one key space: the arms match on the tab - // and guard on the view. A key bound on the desk generally (`p`, `g`) - // has no guard, which is the point of the header — the profile and the - // vetter credential belong to the whole desk, not to whichever list is - // open. `p` used to exist only on Tickets, where it had nothing to do - // with tickets. - let action = match (vetting.tab, key.code) { - (_, KeyCode::Tab) => V::SwitchTab, - (VettingTab::Desk, KeyCode::Left) => V::SwitchDeskView(false), - (VettingTab::Desk, KeyCode::Right) => V::SwitchDeskView(true), - (_, KeyCode::Up) if selected > 0 => V::Select(selected - 1), - (_, KeyCode::Down) if selected + 1 < len => V::Select(selected + 1), - (VettingTab::Applications, KeyCode::Char('n')) => V::StartApplication, - (VettingTab::Applications, KeyCode::Char('f')) => V::ChooseFace, - (VettingTab::Applications, KeyCode::Char('r')) => V::RequestVetter, - (VettingTab::Applications, KeyCode::Char('m')) => V::RefreshRequirements, - (VettingTab::Applications, KeyCode::Char('p')) => V::SwitchVettingPath, - (VettingTab::Applications, KeyCode::Char('v')) => V::FindVetters, - // Enter opens the application as a journey; `c` still goes - // straight to the card for someone who knows where they are. - (VettingTab::Applications, KeyCode::Enter) => V::OpenJourney, - (VettingTab::Applications, KeyCode::Char('c')) => V::ReviewCard, - // An application is otherwise permanent, and its persona is fixed - // for its whole life — so one started as the wrong persona would - // own that community's vetting route forever. - (VettingTab::Applications, KeyCode::Char('x') | KeyCode::Delete) => V::ArmAbandon, - // The join this application was made for. It leaves the panel - // rather than sending an action, because the join flow owns the - // screen — and it carries the community, so the DID an application - // already names is never asked for again. - (VettingTab::Applications, KeyCode::Char('j')) => { - let Some(application) = vetting.applications.get(selected) else { - return false; - }; + /// Community switcher overlay keys (R-C-7): ↑/↓ move the highlight, Enter + /// switches the working community, Esc (or Ctrl+K again) dismisses. Called + /// only while the overlay is open, where it owns all input. + fn handle_switcher_key(&mut self, key: KeyEvent) { + let Some(switcher) = self.props.main_page.switcher.as_ref() else { + return; + }; + let count = switcher.items.len(); + let selected = switcher.selected; + match key.code { + KeyCode::Up if count > 0 => { let _ = self .action_tx - .send(Action::StartJoinFor(application.community.clone())); - return true; + .send(Action::CommunitySwitcherMove(selected.saturating_sub(1))); } - // Anywhere on the desk: the profile the communities hold, and - // asking one to reissue a grant that has lapsed. - (VettingTab::Desk, KeyCode::Char('p')) => V::EditProfile, - (VettingTab::Desk, KeyCode::Char('g')) => V::AskResend, - (VettingTab::Desk, KeyCode::Char('e')) => V::AskEventMode, - (VettingTab::Desk, KeyCode::Char('h')) => V::OpenHiddenVetting, - // Get tokens: enrol if needed, then draw what has begun — the one key a vetter at a - // PCS ZKP community needs before `t` can issue a ticket. - (VettingTab::Desk, KeyCode::Char('k')) => V::DrawNow, - (VettingTab::Desk, KeyCode::Char('o')) if view == DeskView::Requests => V::OpenSession, - (VettingTab::Desk, KeyCode::Enter) if view == DeskView::Requests => V::OpenJourney, - (VettingTab::Desk, KeyCode::Char('a')) if view == DeskView::Requests => V::StartAttest, - (VettingTab::Desk, KeyCode::Char('x')) if view == DeskView::Requests => V::ArmDecline, - (VettingTab::Desk, KeyCode::Char('t')) if view == DeskView::Tickets => V::NewTicket, - (VettingTab::Desk, KeyCode::Enter) if view == DeskView::Tickets => V::ShowTicket, - (VettingTab::Desk, KeyCode::Char('d')) if view == DeskView::Tickets => V::DeleteTicket, - (VettingTab::Desk, KeyCode::Char('u')) if view == DeskView::Tickets => { - let Some(uri) = vetting.tickets.get(selected).and_then(|t| t.uri.clone()) else { - 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}"), - }; - V::Status(status) + KeyCode::Down if count > 0 => { + let _ = self + .action_tx + .send(Action::CommunitySwitcherMove((selected + 1).min(count - 1))); } - (VettingTab::Desk, KeyCode::Char('y')) if view == DeskView::Tickets => { - let Some(ticket) = vetting.tickets.get(selected) else { - 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}"), - }; - V::Status(status) + KeyCode::Enter if selected < count => { + let _ = self.action_tx.send(Action::CommunitySwitcherSelect); } - (VettingTab::Desk, KeyCode::Char('w')) if view == DeskView::Issued => V::ArmWithdraw, - _ => return false, - }; - let _ = self.action_tx.send(Action::Vetting(action)); - true + KeyCode::Esc => { + let _ = self.action_tx.send(Action::CloseCommunitySwitcher); + } + KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => { + let _ = self.action_tx.send(Action::CloseCommunitySwitcher); + } + _ => {} + } } } +/// 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) => { @@ -3522,38 +752,6 @@ fn view_id(page: &MainPageState) -> String { format!("{menu:?}:{mode}") } -/// A key in the personhood witness-credential picker. -/// -/// "Include all" is two keys: `A` asks, and only `y` to that question includes them; any other -/// key backs out with the choices as they were. Enter asserts with exactly what is chosen — -/// nothing, unless something was. -fn personhood_evidence_key( - key: KeyEvent, - view: &crate::state_handler::main_page::content::PersonhoodEvidenceView, -) -> Option { - use crate::state_handler::actions::PersonhoodEvidenceAction as E; - if view.confirm_all { - return Some(Action::PersonhoodEvidence(match key.code { - KeyCode::Char('y') => E::ConfirmIncludeAll, - _ => E::CancelIncludeAll, - })); - } - let action = match key.code { - KeyCode::Up => E::Up, - KeyCode::Down => E::Down, - KeyCode::Char(' ') => E::Toggle, - KeyCode::Char('A') => E::ArmIncludeAll, - KeyCode::Esc => E::Close, - KeyCode::Enter => { - return Some(Action::AssertPersonhood { - witnessed: Some(view.selected()), - }); - } - _ => return None, - }; - Some(Action::PersonhoodEvidence(action)) -} - /// The account's self-display name for the top-right header slot, or `None` when /// it would only repeat the community chip already shown top-left. /// @@ -4240,12 +1438,13 @@ 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::PersonaAction; + 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, }; + use components::repos_panel::keys::{repos_copy_path, repos_key}; use crossterm::event::KeyModifiers; use std::sync::Arc; use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel}; @@ -7111,70 +4310,3 @@ mod key_handler_tests { } } } - -#[cfg(test)] -mod personhood_evidence_key_tests { - use super::*; - use crate::state_handler::actions::PersonhoodEvidenceAction as E; - use crate::state_handler::main_page::content::{PersonhoodEvidenceView, WitnessChoice}; - use crossterm::event::KeyModifiers; - - fn view(chosen: Vec, confirm_all: bool) -> PersonhoodEvidenceView { - PersonhoodEvidenceView { - choices: (0..chosen.len()) - .map(|i| WitnessChoice { - session_id: format!("urn:uuid:{i}"), - witness: "w".into(), - counterparty: "c".into(), - witnessed_at: "t".into(), - digest: "z".into(), - }) - .collect(), - chosen, - confirm_all, - ..PersonhoodEvidenceView::default() - } - } - - fn key(code: KeyCode) -> KeyEvent { - KeyEvent::new(code, KeyModifiers::NONE) - } - - /// Enter asserts with exactly what is chosen — nothing when nothing is. - #[test] - fn enter_presents_only_the_chosen() { - match personhood_evidence_key(key(KeyCode::Enter), &view(vec![false, false], false)) { - Some(Action::AssertPersonhood { witnessed: Some(w) }) => assert!(w.is_empty()), - _ => panic!("expected an assertion with nothing chosen"), - } - match personhood_evidence_key(key(KeyCode::Enter), &view(vec![false, true], false)) { - Some(Action::AssertPersonhood { witnessed: Some(w) }) => { - assert_eq!(w, vec!["urn:uuid:1".to_string()]) - } - _ => panic!("expected an assertion with the one chosen"), - } - } - - /// `A` only asks; `y` to the question includes all, any other key backs out. - #[test] - fn include_all_is_a_separate_confirmation() { - assert!(matches!( - personhood_evidence_key(key(KeyCode::Char('A')), &view(vec![false], false)), - Some(Action::PersonhoodEvidence(E::ArmIncludeAll)) - )); - assert!( - personhood_evidence_key(key(KeyCode::Char('y')), &view(vec![false], false)).is_none(), - "`y` means nothing until include-all was asked for" - ); - assert!(matches!( - personhood_evidence_key(key(KeyCode::Char('y')), &view(vec![false], true)), - Some(Action::PersonhoodEvidence(E::ConfirmIncludeAll)) - )); - for other in [KeyCode::Enter, KeyCode::Char('n'), KeyCode::Esc] { - assert!(matches!( - personhood_evidence_key(key(other), &view(vec![false], true)), - Some(Action::PersonhoodEvidence(E::CancelIncludeAll)) - )); - } - } -}