Implement desktop menus and shortcuts
This commit is contained in:
@@ -15,5 +15,8 @@ iced.workspace = true
|
||||
ironstorage.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
376
apps/desktop/src/action.rs
Normal file
376
apps/desktop/src/action.rs
Normal file
@@ -0,0 +1,376 @@
|
||||
//! One desktop action registry shared by every presentation entry point.
|
||||
|
||||
use iced::keyboard::{self, key::Named};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub enum UiAction {
|
||||
About,
|
||||
Settings,
|
||||
NewEntry,
|
||||
OpenEntry,
|
||||
Save,
|
||||
CloseWindow,
|
||||
Quit,
|
||||
Undo,
|
||||
Redo,
|
||||
Cut,
|
||||
CopyField,
|
||||
CopyEditedField,
|
||||
Paste,
|
||||
Find,
|
||||
TogglePaneFocus,
|
||||
Refresh,
|
||||
ReloadEntry,
|
||||
EditEntry,
|
||||
ToggleReveal,
|
||||
Lock,
|
||||
Minimize,
|
||||
Help,
|
||||
}
|
||||
|
||||
impl UiAction {
|
||||
pub const fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::About => "about",
|
||||
Self::Settings => "settings",
|
||||
Self::NewEntry => "new-entry",
|
||||
Self::OpenEntry => "open-entry",
|
||||
Self::Save => "save",
|
||||
Self::CloseWindow => "close-window",
|
||||
Self::Quit => "quit",
|
||||
Self::Undo => "undo",
|
||||
Self::Redo => "redo",
|
||||
Self::Cut => "cut",
|
||||
Self::CopyField => "copy-field",
|
||||
Self::CopyEditedField => "copy-edited-field",
|
||||
Self::Paste => "paste",
|
||||
Self::Find => "find",
|
||||
Self::TogglePaneFocus => "toggle-pane-focus",
|
||||
Self::Refresh => "refresh",
|
||||
Self::ReloadEntry => "reload-entry",
|
||||
Self::EditEntry => "edit-entry",
|
||||
Self::ToggleReveal => "toggle-reveal",
|
||||
Self::Lock => "lock",
|
||||
Self::Minimize => "minimize",
|
||||
Self::Help => "help",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub enum MenuGroup {
|
||||
App,
|
||||
File,
|
||||
Edit,
|
||||
View,
|
||||
Entry,
|
||||
Tools,
|
||||
Window,
|
||||
Help,
|
||||
}
|
||||
|
||||
impl MenuGroup {
|
||||
pub const ALL: [Self; 8] = [
|
||||
Self::App,
|
||||
Self::File,
|
||||
Self::Edit,
|
||||
Self::View,
|
||||
Self::Entry,
|
||||
Self::Tools,
|
||||
Self::Window,
|
||||
Self::Help,
|
||||
];
|
||||
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::App => "IronStorage",
|
||||
Self::File => "File",
|
||||
Self::Edit => "Edit",
|
||||
Self::View => "View",
|
||||
Self::Entry => "Entry",
|
||||
Self::Tools => "Tools",
|
||||
Self::Window => "Window",
|
||||
Self::Help => "Help",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ActionContext {
|
||||
pub storage_ready: bool,
|
||||
pub tree_loading: bool,
|
||||
pub unlocked: bool,
|
||||
pub document_open: bool,
|
||||
pub editing: bool,
|
||||
pub dirty: bool,
|
||||
pub saving: bool,
|
||||
pub focused_field: bool,
|
||||
pub focused_sensitive: bool,
|
||||
pub entry_path: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ActionSpec {
|
||||
pub action: UiAction,
|
||||
pub group: MenuGroup,
|
||||
pub label: &'static str,
|
||||
pub shortcut: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub const ACTIONS: &[ActionSpec] = &[
|
||||
spec(UiAction::About, MenuGroup::App, "About IronStorage", None),
|
||||
spec(UiAction::Settings, MenuGroup::App, "Settings…", Some("⌘,")),
|
||||
spec(
|
||||
UiAction::Quit,
|
||||
MenuGroup::App,
|
||||
"Quit IronStorage",
|
||||
Some("⌘Q"),
|
||||
),
|
||||
spec(UiAction::NewEntry, MenuGroup::File, "New Entry", Some("⌘N")),
|
||||
spec(UiAction::OpenEntry, MenuGroup::File, "Open", Some("⌘O")),
|
||||
spec(UiAction::Save, MenuGroup::File, "Save", Some("⌘S")),
|
||||
spec(
|
||||
UiAction::CloseWindow,
|
||||
MenuGroup::File,
|
||||
"Close Window",
|
||||
Some("⌘W"),
|
||||
),
|
||||
spec(UiAction::Undo, MenuGroup::Edit, "Undo", Some("⌘Z")),
|
||||
spec(UiAction::Redo, MenuGroup::Edit, "Redo", Some("⇧⌘Z")),
|
||||
spec(UiAction::Cut, MenuGroup::Edit, "Cut", Some("⌘X")),
|
||||
spec(UiAction::CopyField, MenuGroup::Entry, "Copy Field", None),
|
||||
spec(
|
||||
UiAction::CopyEditedField,
|
||||
MenuGroup::Entry,
|
||||
"Copy Edited Field",
|
||||
None,
|
||||
),
|
||||
spec(UiAction::Paste, MenuGroup::Edit, "Paste", Some("⌘V")),
|
||||
spec(UiAction::Find, MenuGroup::Edit, "Find", Some("⌘F")),
|
||||
spec(
|
||||
UiAction::TogglePaneFocus,
|
||||
MenuGroup::View,
|
||||
"Next Pane",
|
||||
Some("Tab"),
|
||||
),
|
||||
spec(UiAction::Refresh, MenuGroup::View, "Refresh", Some("⌘R")),
|
||||
spec(
|
||||
UiAction::ReloadEntry,
|
||||
MenuGroup::Entry,
|
||||
"Reload Entry",
|
||||
None,
|
||||
),
|
||||
spec(UiAction::EditEntry, MenuGroup::Entry, "Edit Entry", None),
|
||||
spec(
|
||||
UiAction::ToggleReveal,
|
||||
MenuGroup::Entry,
|
||||
"Reveal or Hide Field",
|
||||
None,
|
||||
),
|
||||
spec(UiAction::Lock, MenuGroup::Tools, "Lock", Some("⌘L")),
|
||||
spec(
|
||||
UiAction::Minimize,
|
||||
MenuGroup::Window,
|
||||
"Minimize",
|
||||
Some("⌘M"),
|
||||
),
|
||||
spec(
|
||||
UiAction::Help,
|
||||
MenuGroup::Help,
|
||||
"IronStorage Help",
|
||||
Some("F1"),
|
||||
),
|
||||
];
|
||||
|
||||
const fn spec(
|
||||
action: UiAction,
|
||||
group: MenuGroup,
|
||||
label: &'static str,
|
||||
shortcut: Option<&'static str>,
|
||||
) -> ActionSpec {
|
||||
ActionSpec {
|
||||
action,
|
||||
group,
|
||||
label,
|
||||
shortcut,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spec_for(action: UiAction) -> &'static ActionSpec {
|
||||
ACTIONS
|
||||
.iter()
|
||||
.find(|spec| spec.action == action)
|
||||
.expect("every UI action is registered")
|
||||
}
|
||||
|
||||
pub fn actions_in(group: MenuGroup) -> impl Iterator<Item = &'static ActionSpec> {
|
||||
ACTIONS.iter().filter(move |spec| spec.group == group)
|
||||
}
|
||||
|
||||
pub fn shortcut_label(action: UiAction) -> Option<String> {
|
||||
let shortcut = spec_for(action).shortcut?;
|
||||
if cfg!(target_os = "macos") {
|
||||
Some(shortcut.to_owned())
|
||||
} else {
|
||||
Some(shortcut.replace('⇧', "Shift+").replace('⌘', "Ctrl+"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enabled(action: UiAction, context: ActionContext) -> bool {
|
||||
match action {
|
||||
UiAction::About | UiAction::Settings | UiAction::Help => true,
|
||||
// Entry creation is not valid until the dedicated workflow exists.
|
||||
UiAction::NewEntry => false,
|
||||
UiAction::OpenEntry => context.storage_ready && context.entry_path && !context.saving,
|
||||
UiAction::Save => context.unlocked && context.editing && context.dirty && !context.saving,
|
||||
UiAction::CloseWindow | UiAction::Quit | UiAction::Minimize => true,
|
||||
UiAction::Undo | UiAction::Redo | UiAction::Cut | UiAction::Paste | UiAction::Find => false,
|
||||
UiAction::CopyField => {
|
||||
context.unlocked && context.document_open && !context.editing && context.focused_field
|
||||
}
|
||||
UiAction::CopyEditedField => context.unlocked && context.editing && context.focused_field,
|
||||
UiAction::TogglePaneFocus => true,
|
||||
UiAction::Refresh => context.storage_ready && !context.tree_loading,
|
||||
UiAction::ReloadEntry => context.unlocked && context.document_open && !context.saving,
|
||||
UiAction::EditEntry => context.unlocked && context.document_open && !context.editing,
|
||||
UiAction::ToggleReveal => {
|
||||
context.unlocked && context.document_open && context.focused_sensitive
|
||||
}
|
||||
UiAction::Lock => context.unlocked,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shortcut_action(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Option<UiAction> {
|
||||
if key.as_ref() == keyboard::Key::Named(Named::Tab) && modifiers.is_empty() {
|
||||
return Some(UiAction::TogglePaneFocus);
|
||||
}
|
||||
if key.as_ref() == keyboard::Key::Named(Named::F1) {
|
||||
return Some(UiAction::Help);
|
||||
}
|
||||
if !modifiers.command() {
|
||||
return None;
|
||||
}
|
||||
match key.as_ref() {
|
||||
keyboard::Key::Character(",") => Some(UiAction::Settings),
|
||||
keyboard::Key::Character("n" | "N") => Some(UiAction::NewEntry),
|
||||
keyboard::Key::Character("o" | "O") => Some(UiAction::OpenEntry),
|
||||
keyboard::Key::Character("s" | "S") => Some(UiAction::Save),
|
||||
keyboard::Key::Character("w" | "W") => Some(UiAction::CloseWindow),
|
||||
keyboard::Key::Character("q" | "Q") => Some(UiAction::Quit),
|
||||
keyboard::Key::Character("z" | "Z") if modifiers.shift() => Some(UiAction::Redo),
|
||||
keyboard::Key::Character("z" | "Z") => Some(UiAction::Undo),
|
||||
keyboard::Key::Character("x" | "X") => Some(UiAction::Cut),
|
||||
keyboard::Key::Character("c" | "C") => Some(UiAction::CopyField),
|
||||
keyboard::Key::Character("v" | "V") => Some(UiAction::Paste),
|
||||
keyboard::Key::Character("f" | "F") => Some(UiAction::Find),
|
||||
keyboard::Key::Character("r" | "R") => Some(UiAction::Refresh),
|
||||
keyboard::Key::Character("l" | "L") => Some(UiAction::Lock),
|
||||
keyboard::Key::Character("m" | "M") => Some(UiAction::Minimize),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn context() -> ActionContext {
|
||||
ActionContext {
|
||||
storage_ready: true,
|
||||
tree_loading: false,
|
||||
unlocked: true,
|
||||
document_open: true,
|
||||
editing: true,
|
||||
dirty: true,
|
||||
saving: false,
|
||||
focused_field: true,
|
||||
focused_sensitive: true,
|
||||
entry_path: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_is_complete_unique_and_grouped_for_every_platform_menu() {
|
||||
let actions = ACTIONS
|
||||
.iter()
|
||||
.map(|spec| spec.action)
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert_eq!(actions.len(), ACTIONS.len());
|
||||
for group in MenuGroup::ALL {
|
||||
assert!(actions_in(group).next().is_some(), "missing {group:?} menu");
|
||||
}
|
||||
for action in actions {
|
||||
assert_eq!(spec_for(action).action, action);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enablement_closes_authentication_dirty_and_loading_bypasses() {
|
||||
let ready = context();
|
||||
assert!(!enabled(UiAction::NewEntry, ready));
|
||||
assert!(enabled(UiAction::Save, ready));
|
||||
assert!(enabled(UiAction::CopyEditedField, ready));
|
||||
assert!(!enabled(UiAction::CopyField, ready));
|
||||
assert!(enabled(UiAction::ToggleReveal, ready));
|
||||
|
||||
let viewing = ActionContext {
|
||||
editing: false,
|
||||
dirty: false,
|
||||
..ready
|
||||
};
|
||||
assert!(enabled(UiAction::CopyField, viewing));
|
||||
assert!(!enabled(UiAction::CopyEditedField, viewing));
|
||||
|
||||
let locked = ActionContext {
|
||||
unlocked: false,
|
||||
..ready
|
||||
};
|
||||
for action in [
|
||||
UiAction::Save,
|
||||
UiAction::CopyField,
|
||||
UiAction::CopyEditedField,
|
||||
UiAction::ToggleReveal,
|
||||
UiAction::Lock,
|
||||
] {
|
||||
assert!(!enabled(action, locked));
|
||||
}
|
||||
assert!(!enabled(
|
||||
UiAction::Refresh,
|
||||
ActionContext {
|
||||
tree_loading: true,
|
||||
..ready
|
||||
}
|
||||
));
|
||||
assert!(!enabled(
|
||||
UiAction::Save,
|
||||
ActionContext {
|
||||
dirty: false,
|
||||
..ready
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conventional_shortcuts_resolve_to_the_registered_actions() {
|
||||
let primary = keyboard::Modifiers::COMMAND;
|
||||
for (key, expected) in [
|
||||
("o", UiAction::OpenEntry),
|
||||
("s", UiAction::Save),
|
||||
("f", UiAction::Find),
|
||||
("n", UiAction::NewEntry),
|
||||
("w", UiAction::CloseWindow),
|
||||
("q", UiAction::Quit),
|
||||
("x", UiAction::Cut),
|
||||
("c", UiAction::CopyField),
|
||||
("v", UiAction::Paste),
|
||||
(",", UiAction::Settings),
|
||||
] {
|
||||
assert_eq!(
|
||||
shortcut_action(&keyboard::Key::Character(key.into()), primary),
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::disallowed_types)]
|
||||
|
||||
mod action;
|
||||
mod editor;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod navigation;
|
||||
|
||||
use std::{
|
||||
@@ -40,18 +43,28 @@ use ironstorage::{
|
||||
use navigation::{NavigationIntent, NavigationKey, NavigationTree};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use action::{ActionContext, MenuGroup, UiAction};
|
||||
#[cfg(target_os = "macos")]
|
||||
use native_menu::NativeMenu;
|
||||
|
||||
type OpenCompletion = Arc<Mutex<Option<Result<EntryDocument, String>>>>;
|
||||
type SaveCompletion = Arc<Mutex<Option<(EntryEditor, Result<WriteOutcome, DesktopError>)>>>;
|
||||
type TreeCompletion = Arc<Mutex<Option<Result<TreeModel, String>>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Message {
|
||||
Action(UiAction),
|
||||
FieldAction(EntryFieldId, UiAction),
|
||||
ToggleMenu(MenuGroup),
|
||||
DismissUtility,
|
||||
WindowResolved(UiAction, Option<window::Id>),
|
||||
#[cfg(target_os = "macos")]
|
||||
PollNativeMenu,
|
||||
StartupLoaded(Box<Result<(DesktopStorage, NativeAuthenticationSession, KeyInfo), String>>),
|
||||
TreeLoaded {
|
||||
generation: u64,
|
||||
completion: TreeCompletion,
|
||||
},
|
||||
RefreshTree,
|
||||
SidebarActivate(TreeNodeId),
|
||||
SidebarNavigate(NavigationKey),
|
||||
TogglePaneFocus,
|
||||
@@ -83,7 +96,6 @@ enum Message {
|
||||
generation: u64,
|
||||
result: Result<String, String>,
|
||||
},
|
||||
Save,
|
||||
SaveFinished {
|
||||
generation: u64,
|
||||
completion: SaveCompletion,
|
||||
@@ -163,6 +175,10 @@ struct App {
|
||||
generate_confirmation: Option<EntryFieldId>,
|
||||
conflict: bool,
|
||||
status: String,
|
||||
open_menu: Option<MenuGroup>,
|
||||
utility: Option<UtilityView>,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu: Option<NativeMenu>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -183,6 +199,13 @@ enum ContentMode {
|
||||
Editor,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum UtilityView {
|
||||
About,
|
||||
Settings,
|
||||
Help,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
enum TreeState {
|
||||
Loading,
|
||||
@@ -226,37 +249,47 @@ fn main() -> iced::Result {
|
||||
|
||||
impl App {
|
||||
fn new() -> (Self, Task<Message>) {
|
||||
let mut app = Self {
|
||||
authentication: AuthenticationView::Loading,
|
||||
storage: None,
|
||||
session: None,
|
||||
key: None,
|
||||
handle: None,
|
||||
sensitive: SensitiveUiState::default(),
|
||||
authentication_generation: 0,
|
||||
operation_generation: 0,
|
||||
tree_generation: 0,
|
||||
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
|
||||
axis: pane_grid::Axis::Vertical,
|
||||
ratio: 0.28,
|
||||
a: Box::new(pane_grid::Configuration::Pane(PaneKind::Sidebar)),
|
||||
b: Box::new(pane_grid::Configuration::Pane(PaneKind::Content)),
|
||||
}),
|
||||
pane_focus: PaneFocus::Sidebar,
|
||||
navigation: NavigationTree::default(),
|
||||
tree_state: TreeState::Loading,
|
||||
after_authentication: None,
|
||||
entry_path: String::new(),
|
||||
editor: None,
|
||||
content_mode: ContentMode::Viewer,
|
||||
saving: false,
|
||||
confirmation: None,
|
||||
after_save: None,
|
||||
generate_confirmation: None,
|
||||
conflict: false,
|
||||
status: "Loading shared configuration…".to_owned(),
|
||||
open_menu: None,
|
||||
utility: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu: None,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
match NativeMenu::install(app.action_context()) {
|
||||
Ok(menu) => app.native_menu = Some(menu),
|
||||
Err(error) => app.status = format!("Native menu unavailable: {error}"),
|
||||
}
|
||||
(
|
||||
Self {
|
||||
authentication: AuthenticationView::Loading,
|
||||
storage: None,
|
||||
session: None,
|
||||
key: None,
|
||||
handle: None,
|
||||
sensitive: SensitiveUiState::default(),
|
||||
authentication_generation: 0,
|
||||
operation_generation: 0,
|
||||
tree_generation: 0,
|
||||
panes: pane_grid::State::with_configuration(pane_grid::Configuration::Split {
|
||||
axis: pane_grid::Axis::Vertical,
|
||||
ratio: 0.28,
|
||||
a: Box::new(pane_grid::Configuration::Pane(PaneKind::Sidebar)),
|
||||
b: Box::new(pane_grid::Configuration::Pane(PaneKind::Content)),
|
||||
}),
|
||||
pane_focus: PaneFocus::Sidebar,
|
||||
navigation: NavigationTree::default(),
|
||||
tree_state: TreeState::Loading,
|
||||
after_authentication: None,
|
||||
entry_path: String::new(),
|
||||
editor: None,
|
||||
content_mode: ContentMode::Viewer,
|
||||
saving: false,
|
||||
confirmation: None,
|
||||
after_save: None,
|
||||
generate_confirmation: None,
|
||||
conflict: false,
|
||||
status: "Loading shared configuration…".to_owned(),
|
||||
},
|
||||
app,
|
||||
Task::perform(load_authentication(), |result| {
|
||||
Message::StartupLoaded(Box::new(result))
|
||||
}),
|
||||
@@ -265,6 +298,35 @@ impl App {
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::Action(action) => return self.invoke_action(action),
|
||||
Message::FieldAction(id, action) => {
|
||||
if let Some(editor) = self.editor.as_mut() {
|
||||
editor.select(id);
|
||||
}
|
||||
return self.invoke_action(action);
|
||||
}
|
||||
Message::ToggleMenu(group) => {
|
||||
self.open_menu = (self.open_menu != Some(group)).then_some(group);
|
||||
}
|
||||
Message::DismissUtility => self.utility = None,
|
||||
Message::WindowResolved(action, id) => {
|
||||
let Some(id) = id else {
|
||||
return Task::none();
|
||||
};
|
||||
return match action {
|
||||
UiAction::CloseWindow | UiAction::Quit => {
|
||||
self.request_action(PendingAction::CloseWindow(id))
|
||||
}
|
||||
UiAction::Minimize => window::minimize(id, true),
|
||||
_ => Task::none(),
|
||||
};
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
Message::PollNativeMenu => {
|
||||
if let Some(action) = self.native_menu.as_ref().and_then(NativeMenu::poll) {
|
||||
return self.invoke_action(action);
|
||||
}
|
||||
}
|
||||
Message::StartupLoaded(result) => match *result {
|
||||
Ok((storage, session, key)) => {
|
||||
self.storage = Some(storage);
|
||||
@@ -307,7 +369,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::RefreshTree => return self.begin_tree_refresh(),
|
||||
Message::SidebarActivate(id) => {
|
||||
self.touch_user_activity();
|
||||
self.pane_focus = PaneFocus::Sidebar;
|
||||
@@ -484,7 +545,6 @@ impl App {
|
||||
self.status = result.unwrap_or_else(|error| format!("Clipboard: {error}"));
|
||||
}
|
||||
}
|
||||
Message::Save => return self.begin_save(),
|
||||
Message::SaveFinished {
|
||||
generation,
|
||||
completion,
|
||||
@@ -575,6 +635,81 @@ impl App {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn action_context(&self) -> ActionContext {
|
||||
let focused = self
|
||||
.editor
|
||||
.as_ref()
|
||||
.and_then(|editor| editor.focused().and_then(|id| editor.document().field(id)));
|
||||
ActionContext {
|
||||
storage_ready: self.storage.is_some(),
|
||||
tree_loading: self.tree_state == TreeState::Loading,
|
||||
unlocked: authentication_allows_content(&self.authentication),
|
||||
document_open: self.editor.is_some(),
|
||||
editing: self.content_mode == ContentMode::Editor,
|
||||
dirty: self.editor.as_ref().is_some_and(EntryEditor::is_dirty),
|
||||
saving: self.saving,
|
||||
focused_field: focused.is_some(),
|
||||
focused_sensitive: focused
|
||||
.is_some_and(|field| field.metadata().sensitivity() == EntrySensitivity::Sensitive),
|
||||
entry_path: !self.entry_path.trim().is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn invoke_action(&mut self, action: UiAction) -> Task<Message> {
|
||||
self.open_menu = None;
|
||||
if !action::enabled(action, self.action_context()) {
|
||||
if self.content_mode == ContentMode::Editor
|
||||
&& matches!(
|
||||
action,
|
||||
UiAction::Undo
|
||||
| UiAction::Redo
|
||||
| UiAction::Cut
|
||||
| UiAction::CopyField
|
||||
| UiAction::Paste
|
||||
)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
self.status = format!(
|
||||
"{} is unavailable in the current state.",
|
||||
action::spec_for(action).label
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
match action {
|
||||
UiAction::About => self.utility = Some(UtilityView::About),
|
||||
UiAction::Settings => self.utility = Some(UtilityView::Settings),
|
||||
UiAction::Help => self.utility = Some(UtilityView::Help),
|
||||
UiAction::OpenEntry => return self.update(Message::OpenEntry),
|
||||
UiAction::Save => return self.begin_save(),
|
||||
UiAction::CloseWindow | UiAction::Quit | UiAction::Minimize => {
|
||||
return window::oldest().map(move |id| Message::WindowResolved(action, id));
|
||||
}
|
||||
UiAction::CopyField | UiAction::CopyEditedField => {
|
||||
if let Some(id) = self.editor.as_ref().and_then(EntryEditor::focused) {
|
||||
return self.update(Message::Copy(id));
|
||||
}
|
||||
}
|
||||
UiAction::TogglePaneFocus => return self.update(Message::TogglePaneFocus),
|
||||
UiAction::Refresh => return self.begin_tree_refresh(),
|
||||
UiAction::ReloadEntry => return self.update(Message::RequestReload),
|
||||
UiAction::EditEntry => return self.update(Message::BeginEdit),
|
||||
UiAction::ToggleReveal => {
|
||||
if let Some(id) = self.editor.as_ref().and_then(EntryEditor::focused) {
|
||||
return self.update(Message::ToggleReveal(id));
|
||||
}
|
||||
}
|
||||
UiAction::Lock => return self.update(Message::Lock),
|
||||
UiAction::NewEntry
|
||||
| UiAction::Undo
|
||||
| UiAction::Redo
|
||||
| UiAction::Cut
|
||||
| UiAction::Paste
|
||||
| UiAction::Find => {}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn begin_tree_refresh(&mut self) -> Task<Message> {
|
||||
let Some(storage) = self.storage.clone() else {
|
||||
return Task::none();
|
||||
@@ -810,20 +945,30 @@ impl App {
|
||||
}
|
||||
|
||||
fn subscription(&self) -> Subscription<Message> {
|
||||
Subscription::batch([
|
||||
let mut subscriptions = vec![
|
||||
time::every(Duration::from_secs(1)).map(|_| Message::Tick),
|
||||
event::listen_with(|event, _status, _window| event_message(&event)),
|
||||
window::close_requests().map(Message::RequestClose),
|
||||
])
|
||||
];
|
||||
#[cfg(target_os = "macos")]
|
||||
subscriptions.push(time::every(Duration::from_millis(50)).map(|_| Message::PollNativeMenu));
|
||||
Subscription::batch(subscriptions)
|
||||
}
|
||||
|
||||
fn view(&self) -> Element<'_, Message> {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(menu) = &self.native_menu {
|
||||
menu.sync(self.action_context());
|
||||
}
|
||||
if let Some(action) = &self.confirmation {
|
||||
return confirmation_view(action);
|
||||
}
|
||||
if let Some(id) = self.generate_confirmation {
|
||||
return generate_confirmation_view(id);
|
||||
}
|
||||
if let Some(utility) = self.utility {
|
||||
return utility_view(self, utility);
|
||||
}
|
||||
|
||||
let authentication = match &self.authentication {
|
||||
AuthenticationView::Loading => "Loading".to_owned(),
|
||||
@@ -857,6 +1002,7 @@ impl App {
|
||||
]
|
||||
.spacing(16)
|
||||
.padding(10),
|
||||
platform_menu_bar(self),
|
||||
panes,
|
||||
]
|
||||
.height(Length::Fill),
|
||||
@@ -875,6 +1021,95 @@ fn viewer_scroll_id() -> iced::widget::Id {
|
||||
iced::widget::Id::new("desktop-entry-viewer")
|
||||
}
|
||||
|
||||
fn platform_menu_bar(app: &App) -> Element<'_, Message> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return container(row![]).height(Length::Fixed(0.0)).into();
|
||||
}
|
||||
let mut headers = row![].spacing(2).padding([0, 8]);
|
||||
for group in MenuGroup::ALL {
|
||||
headers = headers.push(
|
||||
button(group.label())
|
||||
.on_press(Message::ToggleMenu(group))
|
||||
.style(if app.open_menu == Some(group) {
|
||||
button::primary
|
||||
} else {
|
||||
button::text
|
||||
}),
|
||||
);
|
||||
}
|
||||
let mut menu = column![headers].spacing(4);
|
||||
if let Some(group) = app.open_menu {
|
||||
let context = app.action_context();
|
||||
let mut actions = row![].spacing(4).padding([6, 8]);
|
||||
for spec in action::actions_in(group) {
|
||||
let label = action::shortcut_label(spec.action).map_or_else(
|
||||
|| spec.label.to_owned(),
|
||||
|key| format!("{} {key}", spec.label),
|
||||
);
|
||||
let item = button(text(label));
|
||||
actions = actions.push(if action::enabled(spec.action, context) {
|
||||
item.on_press(Message::Action(spec.action))
|
||||
} else {
|
||||
item
|
||||
});
|
||||
}
|
||||
menu = menu.push(
|
||||
scrollable(actions).direction(scrollable::Direction::Horizontal(
|
||||
scrollable::Scrollbar::default(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
container(menu).width(Length::Fill).into()
|
||||
}
|
||||
|
||||
fn utility_view(app: &App, utility: UtilityView) -> Element<'_, Message> {
|
||||
let mut content = column![].spacing(10);
|
||||
match utility {
|
||||
UtilityView::About => {
|
||||
content = content
|
||||
.push(text(ironstorage::PRODUCT_NAME).size(28))
|
||||
.push(text(format!("Version {}", env!("CARGO_PKG_VERSION"))))
|
||||
.push(text("A native, pass-compatible password-store client."));
|
||||
}
|
||||
UtilityView::Settings => {
|
||||
content = content.push(text("Settings").size(28));
|
||||
if let Some(storage) = &app.storage {
|
||||
content = content
|
||||
.push(text(format!(
|
||||
"Authentication inactivity timeout: {} seconds",
|
||||
storage.authentication_timeout().duration().as_secs()
|
||||
)))
|
||||
.push(text(format!(
|
||||
"Clipboard cleanup timeout: {} seconds",
|
||||
storage.clipboard_timeout().duration().as_secs()
|
||||
)))
|
||||
.push(text("Values come from the shared validated configuration."));
|
||||
} else {
|
||||
content = content.push(text("Shared configuration is still loading."));
|
||||
}
|
||||
}
|
||||
UtilityView::Help => {
|
||||
content = content.push(text("Keyboard shortcuts").size(28));
|
||||
for spec in action::ACTIONS {
|
||||
if let Some(shortcut) = action::shortcut_label(spec.action) {
|
||||
content = content.push(text(format!("{shortcut} {}", spec.label)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
container(
|
||||
column![
|
||||
scrollable(content).height(Length::Fill),
|
||||
button("Done").on_press(Message::DismissUtility),
|
||||
]
|
||||
.spacing(12)
|
||||
.padding(20),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn sidebar_view<'a>(
|
||||
navigation: &'a NavigationTree,
|
||||
state: &'a TreeState,
|
||||
@@ -888,7 +1123,7 @@ fn sidebar_view<'a>(
|
||||
"Password Store"
|
||||
})
|
||||
.size(20),
|
||||
button("Refresh").on_press(Message::RefreshTree),
|
||||
button("Refresh").on_press(Message::Action(UiAction::Refresh)),
|
||||
]
|
||||
.spacing(8)
|
||||
]
|
||||
@@ -965,10 +1200,10 @@ fn content_view(app: &App) -> Element<'_, Message> {
|
||||
.size(20),
|
||||
text_input("Entry path", &app.entry_path)
|
||||
.on_input(Message::EntryPathChanged)
|
||||
.on_submit(Message::OpenEntry),
|
||||
button("Open").on_press(Message::OpenEntry),
|
||||
button("Reload").on_press(Message::RequestReload),
|
||||
button("Lock").on_press(Message::Lock),
|
||||
.on_submit(Message::Action(UiAction::OpenEntry)),
|
||||
button("Open").on_press(Message::Action(UiAction::OpenEntry)),
|
||||
button("Reload").on_press(Message::Action(UiAction::ReloadEntry)),
|
||||
button("Lock").on_press(Message::Action(UiAction::Lock)),
|
||||
]
|
||||
.spacing(8);
|
||||
|
||||
@@ -1054,7 +1289,7 @@ fn viewer_view(editor: &EntryEditor) -> Element<'_, Message> {
|
||||
let mut rows = column![
|
||||
row![
|
||||
text(editor.entry()).size(22),
|
||||
button("Edit entry").on_press(Message::BeginEdit),
|
||||
button("Edit entry").on_press(Message::Action(UiAction::EditEntry)),
|
||||
]
|
||||
.spacing(8),
|
||||
text("Use Up/Down/Home/End to select fields; Enter reveals or hides a selected sensitive value.")
|
||||
@@ -1073,15 +1308,18 @@ fn viewer_view(editor: &EntryEditor) -> Element<'_, Message> {
|
||||
ViewerValue::Text(value) => value,
|
||||
ViewerValue::Unavailable => "(binary value)",
|
||||
};
|
||||
let mut actions =
|
||||
row![button(text(format!("Copy {label}"))).on_press(Message::Copy(id)),].spacing(6);
|
||||
let mut actions = row![
|
||||
button(text(format!("Copy {label}")))
|
||||
.on_press(Message::FieldAction(id, UiAction::CopyField)),
|
||||
]
|
||||
.spacing(6);
|
||||
if field.metadata().sensitivity() == EntrySensitivity::Sensitive {
|
||||
actions = actions.push(
|
||||
button(text(format!(
|
||||
"{} {label}",
|
||||
if revealed { "Hide" } else { "Reveal" }
|
||||
)))
|
||||
.on_press(Message::ToggleReveal(id)),
|
||||
.on_press(Message::FieldAction(id, UiAction::ToggleReveal)),
|
||||
);
|
||||
}
|
||||
let mut field_view = column![
|
||||
@@ -1150,7 +1388,7 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> {
|
||||
if editor.is_dirty() { " •" } else { "" }
|
||||
))
|
||||
.size(22),
|
||||
button("Save").on_press(Message::Save),
|
||||
button("Save").on_press(Message::Action(UiAction::Save)),
|
||||
button("Add line").on_press(Message::AddAfter(None)),
|
||||
]
|
||||
.spacing(8)
|
||||
@@ -1197,7 +1435,7 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> {
|
||||
button("↓").on_press(Message::MoveDown(id)),
|
||||
button("Add below").on_press(Message::AddAfter(Some(id))),
|
||||
button("Remove").on_press(Message::Remove(id)),
|
||||
button("Copy value").on_press(Message::Copy(id)),
|
||||
button("Copy value").on_press(Message::FieldAction(id, UiAction::CopyEditedField)),
|
||||
]
|
||||
.spacing(6);
|
||||
if sensitive {
|
||||
@@ -1207,7 +1445,7 @@ fn editor_view(editor: &EntryEditor, conflict: bool) -> Element<'_, Message> {
|
||||
} else {
|
||||
"Reveal"
|
||||
})
|
||||
.on_press(Message::ToggleReveal(id)),
|
||||
.on_press(Message::FieldAction(id, UiAction::ToggleReveal)),
|
||||
);
|
||||
}
|
||||
if sensitive && field.metadata().kind() != EntryFieldKind::OtpUri {
|
||||
@@ -1331,13 +1569,10 @@ fn poll_lease<B: SecretStoreBackend, C: AuthenticationClock>(
|
||||
|
||||
fn event_message(event: &Event) -> Option<Message> {
|
||||
if let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = event {
|
||||
if modifiers.command() && matches!(key.as_ref(), keyboard::Key::Character("s" | "S")) {
|
||||
return Some(Message::Save);
|
||||
if let Some(action) = action::shortcut_action(key, *modifiers) {
|
||||
return Some(Message::Action(action));
|
||||
}
|
||||
let navigation = match key.as_ref() {
|
||||
keyboard::Key::Named(keyboard::key::Named::Tab) => {
|
||||
return Some(Message::TogglePaneFocus);
|
||||
}
|
||||
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => NavigationKey::Previous,
|
||||
keyboard::Key::Named(keyboard::key::Named::ArrowDown) => NavigationKey::Next,
|
||||
keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => NavigationKey::Collapse,
|
||||
@@ -1574,6 +1809,10 @@ mod tests {
|
||||
generate_confirmation: None,
|
||||
conflict: false,
|
||||
status: String::new(),
|
||||
open_menu: None,
|
||||
utility: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1787,6 +2026,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_actions_cannot_bypass_authentication_or_dirty_confirmation() {
|
||||
let (_temporary, storage) = fixture_storage();
|
||||
let mut editor = empty_editor(&storage, "draft");
|
||||
editor.add_after(None).expect("dirty line");
|
||||
let mut app = test_app(Some(editor));
|
||||
app.content_mode = ContentMode::Editor;
|
||||
|
||||
assert!(!action::enabled(UiAction::Save, app.action_context()));
|
||||
let _task = app.invoke_action(UiAction::Save);
|
||||
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
|
||||
|
||||
app.authentication = AuthenticationView::Unlocked(Duration::from_secs(60));
|
||||
assert!(action::enabled(UiAction::Save, app.action_context()));
|
||||
let id = window::Id::unique();
|
||||
let _task = app.update(Message::WindowResolved(UiAction::CloseWindow, Some(id)));
|
||||
assert_eq!(app.confirmation, Some(PendingAction::CloseWindow(id)));
|
||||
assert!(app.editor.as_ref().is_some_and(EntryEditor::is_dirty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_and_expiry_drop_the_complete_editor_and_clipboard_state() {
|
||||
let (_temporary, storage) = fixture_storage();
|
||||
@@ -1840,7 +2099,10 @@ mod tests {
|
||||
text: Some("s".into()),
|
||||
repeat: false,
|
||||
});
|
||||
assert!(matches!(event_message(&save), Some(Message::Save)));
|
||||
assert!(matches!(
|
||||
event_message(&save),
|
||||
Some(Message::Action(UiAction::Save))
|
||||
));
|
||||
|
||||
let tab = Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Tab),
|
||||
@@ -1853,7 +2115,7 @@ mod tests {
|
||||
});
|
||||
assert!(matches!(
|
||||
event_message(&tab),
|
||||
Some(Message::TogglePaneFocus)
|
||||
Some(Message::Action(UiAction::TogglePaneFocus))
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
141
apps/desktop/src/native_menu.rs
Normal file
141
apps/desktop/src/native_menu.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
//! Safe macOS menu adapter over the shared desktop action registry.
|
||||
|
||||
use muda::{
|
||||
Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
|
||||
accelerator::{Accelerator, Code, Modifiers},
|
||||
};
|
||||
|
||||
use crate::action::{self, ActionContext, MenuGroup, UiAction};
|
||||
|
||||
pub struct NativeMenu {
|
||||
_menu: Menu,
|
||||
items: Vec<(UiAction, MenuItem)>,
|
||||
}
|
||||
|
||||
impl NativeMenu {
|
||||
pub fn install(context: ActionContext) -> Result<Self, muda::Error> {
|
||||
let menu = Menu::new();
|
||||
let mut items = Vec::new();
|
||||
for group in MenuGroup::ALL {
|
||||
let submenu = Submenu::new(group.label(), true);
|
||||
match group {
|
||||
MenuGroup::App => {
|
||||
submenu.append(&PredefinedMenuItem::about(None, None))?;
|
||||
submenu.append(&PredefinedMenuItem::separator())?;
|
||||
append_action(&submenu, UiAction::Settings, context, &mut items)?;
|
||||
submenu.append(&PredefinedMenuItem::separator())?;
|
||||
submenu.append(&PredefinedMenuItem::services(None))?;
|
||||
submenu.append(&PredefinedMenuItem::separator())?;
|
||||
submenu.append(&PredefinedMenuItem::hide(None))?;
|
||||
submenu.append(&PredefinedMenuItem::hide_others(None))?;
|
||||
submenu.append(&PredefinedMenuItem::show_all(None))?;
|
||||
submenu.append(&PredefinedMenuItem::separator())?;
|
||||
append_action(&submenu, UiAction::Quit, context, &mut items)?;
|
||||
}
|
||||
MenuGroup::Window => {
|
||||
submenu.append(&PredefinedMenuItem::minimize(None))?;
|
||||
submenu.append(&PredefinedMenuItem::bring_all_to_front(None))?;
|
||||
}
|
||||
MenuGroup::Edit => {
|
||||
submenu.append(&PredefinedMenuItem::undo(None))?;
|
||||
submenu.append(&PredefinedMenuItem::redo(None))?;
|
||||
submenu.append(&PredefinedMenuItem::separator())?;
|
||||
submenu.append(&PredefinedMenuItem::cut(None))?;
|
||||
submenu.append(&PredefinedMenuItem::copy(None))?;
|
||||
submenu.append(&PredefinedMenuItem::paste(None))?;
|
||||
submenu.append(&PredefinedMenuItem::select_all(None))?;
|
||||
submenu.append(&PredefinedMenuItem::separator())?;
|
||||
append_action(&submenu, UiAction::Find, context, &mut items)?;
|
||||
}
|
||||
_ => append_actions(&submenu, group, context, &mut items)?,
|
||||
}
|
||||
menu.append(&submenu)?;
|
||||
}
|
||||
menu.init_for_nsapp();
|
||||
Ok(Self { _menu: menu, items })
|
||||
}
|
||||
|
||||
pub fn sync(&self, context: ActionContext) {
|
||||
for (action, item) in &self.items {
|
||||
item.set_enabled(action::enabled(*action, context));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn poll(&self) -> Option<UiAction> {
|
||||
let event = MenuEvent::receiver().try_recv().ok()?;
|
||||
self.items
|
||||
.iter()
|
||||
.find_map(|(action, item)| (item.id() == &event.id).then_some(*action))
|
||||
}
|
||||
}
|
||||
|
||||
fn append_actions(
|
||||
submenu: &Submenu,
|
||||
group: MenuGroup,
|
||||
context: ActionContext,
|
||||
items: &mut Vec<(UiAction, MenuItem)>,
|
||||
) -> Result<(), muda::Error> {
|
||||
for spec in action::actions_in(group).filter(|spec| {
|
||||
!matches!(
|
||||
spec.action,
|
||||
UiAction::About
|
||||
| UiAction::Undo
|
||||
| UiAction::Redo
|
||||
| UiAction::Cut
|
||||
| UiAction::CopyEditedField
|
||||
| UiAction::Paste
|
||||
| UiAction::Minimize
|
||||
| UiAction::TogglePaneFocus
|
||||
)
|
||||
}) {
|
||||
append_action(submenu, spec.action, context, items)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_action(
|
||||
submenu: &Submenu,
|
||||
action: UiAction,
|
||||
context: ActionContext,
|
||||
items: &mut Vec<(UiAction, MenuItem)>,
|
||||
) -> Result<(), muda::Error> {
|
||||
let spec = action::spec_for(action);
|
||||
let item = MenuItem::with_id(
|
||||
format!("ironstorage.{}", action.id()),
|
||||
spec.label,
|
||||
action::enabled(action, context),
|
||||
accelerator(action),
|
||||
);
|
||||
submenu.append(&item)?;
|
||||
items.push((action, item));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn accelerator(action: UiAction) -> Option<Accelerator> {
|
||||
let command = Modifiers::SUPER;
|
||||
let (modifiers, code) = match action {
|
||||
UiAction::Settings => (command, Code::Comma),
|
||||
UiAction::NewEntry => (command, Code::KeyN),
|
||||
UiAction::OpenEntry => (command, Code::KeyO),
|
||||
UiAction::Save => (command, Code::KeyS),
|
||||
UiAction::CloseWindow => (command, Code::KeyW),
|
||||
UiAction::Quit => (command, Code::KeyQ),
|
||||
UiAction::Undo => (command, Code::KeyZ),
|
||||
UiAction::Redo => (command | Modifiers::SHIFT, Code::KeyZ),
|
||||
UiAction::Cut => (command, Code::KeyX),
|
||||
UiAction::Paste => (command, Code::KeyV),
|
||||
UiAction::Find => (command, Code::KeyF),
|
||||
UiAction::Refresh => (command, Code::KeyR),
|
||||
UiAction::Lock => (command, Code::KeyL),
|
||||
UiAction::Help => (Modifiers::empty(), Code::F1),
|
||||
UiAction::About
|
||||
| UiAction::CopyField
|
||||
| UiAction::CopyEditedField
|
||||
| UiAction::TogglePaneFocus
|
||||
| UiAction::ReloadEntry
|
||||
| UiAction::EditEntry
|
||||
| UiAction::ToggleReveal
|
||||
| UiAction::Minimize => return None,
|
||||
};
|
||||
Some(Accelerator::new(Some(modifiers), code))
|
||||
}
|
||||
Reference in New Issue
Block a user