From 9e0e37b875679b7bb4d833445b2e8067fa44afd3 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 11 Aug 2026 09:33:41 +0200 Subject: [PATCH] Align desktop utility panels --- apps/desktop/src/main.rs | 661 ++++++++++++++++++++++++--------------- docs/desktop-audit.md | 10 +- 2 files changed, 408 insertions(+), 263 deletions(-) diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index d35e19b..5cc6d5b 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -3715,6 +3715,7 @@ enum Icon { Check, ChevronDown, ChevronRight, + Close, Command, Copy, Delete, @@ -3773,6 +3774,12 @@ impl canvas::Program for IconCanvas { path.line_to(Point::new(10.5, 8.0)); path.line_to(Point::new(5.5, 13.0)); } + Icon::Close => { + path.move_to(Point::new(3.0, 3.0)); + path.line_to(Point::new(13.0, 13.0)); + path.move_to(Point::new(13.0, 3.0)); + path.line_to(Point::new(3.0, 13.0)); + } Icon::Command => { path.move_to(Point::new(2.0, 4.0)); path.line_to(Point::new(6.0, 8.0)); @@ -3985,6 +3992,37 @@ fn entry_icon_control(icon: Icon, hint: String, message: Message) -> Element<'st .into() } +fn utility_icon_control( + icon: Icon, + hint: &'static str, + message: Message, + enabled: bool, +) -> Element<'static, Message> { + let control = button(colored_icon_view(icon, Some(ENTRY_TEXT))) + .padding(6) + .style(entry_icon_button); + tooltip( + if enabled { + control.on_press(message) + } else { + control + }, + container(text(hint).size(12)) + .padding([5, 8]) + .style(container::dark), + tooltip::Position::Bottom, + ) + .gap(4) + .delay(Duration::from_millis(350)) + .into() +} + +fn utility_button<'a>( + content: impl Into>, +) -> iced::widget::Button<'a, Message> { + button(content).padding([6, 10]).style(entry_icon_button) +} + fn entry_icon_button(_theme: &Theme, status: button::Status) -> button::Style { button::Style { background: matches!(status, button::Status::Hovered | button::Status::Pressed) @@ -4148,12 +4186,54 @@ fn platform_menu_bar(app: &App) -> Element<'_, Message> { container(menu).width(Length::Fill).into() } +fn utility_title(utility: &UtilityView) -> &'static str { + match utility { + UtilityView::About => "About IronStorage", + UtilityView::Settings(_) => "Settings", + UtilityView::Recipients(form) => match form.kind { + RecipientWorkflowKind::InitializeStore => "Initialize Password Store", + RecipientWorkflowKind::NewFolder => "Create Password Store Folder", + }, + UtilityView::NewEntry(_) => "New Entry", + UtilityView::Search(form) => match form.mode { + SearchMode::Names => "Find Entries and Folders", + SearchMode::Contents => "Search Decrypted Contents", + }, + UtilityView::Mutation(form) => match form.kind { + MutationKind::Move => "Move or Rename", + MutationKind::Copy => "Copy Entry or Folder", + MutationKind::Delete => "Delete Entry or Folder", + }, + UtilityView::Git(_) => "Git Synchronization", + UtilityView::Otp(_) => "One-Time Password", + UtilityView::Kdbx(_) => "Import KeePass Database", + UtilityView::Help => "IronStorage Help", + } +} + +fn utility_icon(utility: &UtilityView) -> Icon { + match utility { + UtilityView::About | UtilityView::Help => Icon::Key, + UtilityView::Settings(_) => Icon::Settings, + UtilityView::Recipients(_) => Icon::Key, + UtilityView::NewEntry(_) => Icon::Add, + UtilityView::Search(_) => Icon::Search, + UtilityView::Mutation(form) => match form.kind { + MutationKind::Move => Icon::Move, + MutationKind::Copy => Icon::Copy, + MutationKind::Delete => Icon::Delete, + }, + UtilityView::Git(_) => Icon::Refresh, + UtilityView::Otp(_) => Icon::Generate, + UtilityView::Kdbx(_) => Icon::Down, + } +} + fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, 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.")) .push(text("Compatible with pass and pass-otp.")) @@ -4163,73 +4243,101 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa )); } UtilityView::Settings(form) => { - content = content - .push(text("Settings").size(28)) - .push(text("Password-store folder")) - .push( - row![ - text_input("Vault path", &form.vault) - .id(settings_vault_id()) - .on_input(Message::SettingsVaultChanged) - .on_submit(Message::SaveSettings), - if form.saving { - button("Choose…") - } else { - button("Choose…").on_press(Message::PickSettingsVault) - }, - ] - .spacing(8), - ) - .push(text("Default OpenPGP key fingerprint or identity")) - .push( - text_input("Default key", &form.default_key) - .on_input(Message::SettingsDefaultKeyChanged) - .on_submit(Message::SaveSettings), - ) - .push(text( - "Authentication inactivity timeout in seconds (1–86400)", - )) - .push( - text_input("Timeout seconds", &form.authentication_timeout) - .on_input(Message::SettingsTimeoutChanged) - .on_submit(Message::SaveSettings), - ) - .push(text("Command-line tools")) - .push(text( + let general = column![ + text("General").size(18), + text("Password-store folder").size(13), + row![ + text_input("Vault path", &form.vault) + .id(settings_vault_id()) + .on_input(Message::SettingsVaultChanged) + .on_submit(Message::SaveSettings) + .style(entry_input_style), + utility_icon_control( + Icon::Folder, + "Choose password-store folder", + Message::PickSettingsVault, + !form.saving, + ), + ] + .align_y(iced::Alignment::Center) + .spacing(6), + text("Default OpenPGP key fingerprint or identity").size(13), + text_input("Default key", &form.default_key) + .on_input(Message::SettingsDefaultKeyChanged) + .on_submit(Message::SaveSettings) + .style(entry_input_style), + text("Authentication inactivity timeout in seconds (1–86400)").size(13), + text_input("Timeout seconds", &form.authentication_timeout) + .on_input(Message::SettingsTimeoutChanged) + .on_submit(Message::SaveSettings) + .style(entry_input_style), + ] + .spacing(8); + content = content.push( + container(general) + .padding(14) + .width(Length::Fill) + .style(|theme| entry_field_style(theme, false)), + ); + + let mut commands = column![ + row![ + text("Command-line tools").size(18).width(Length::Fill), + utility_icon_control( + Icon::Link, + "Install CLI and TUI links", + Message::InstallCommandLinks, + !form.saving, + ), + ] + .align_y(iced::Alignment::Center), + text( "Install links to the CLI and TUI embedded in this application bundle in ~/.local/bin. Existing command files or links are replaced.", - )) - .push(if form.saving { - button("Install CLI and TUI Links") - } else { - button("Install CLI and TUI Links").on_press(Message::InstallCommandLinks) - }); + ) + .size(13), + ] + .spacing(8); if let Some(status) = &form.command_links_status { - content = content.push(text(status)); + commands = commands.push(text(status).size(13)); } + content = content.push( + container(commands) + .padding(14) + .width(Length::Fill) + .style(|theme| entry_field_style(theme, false)), + ); if let Some(storage) = &app.storage { - content = content - .push(text(format!( + content = content.push( + container( + column![ + text("Configuration details").size(18), + text(format!( "Shared configuration: {}", storage.config_source().display() - ))) - .push(text(format!( - "Clipboard cleanup remains {} seconds.", - storage.clipboard_timeout().duration().as_secs() - ))) - .push(text( - "Save validates the repository, key material, entry tree, and authentication session before atomically replacing config.toml.", - )); + )) + .size(13), + text(format!( + "Clipboard cleanup remains {} seconds.", + storage.clipboard_timeout().duration().as_secs() + )) + .size(13), + text( + "Save validates the repository, key material, entry tree, and authentication session before atomically replacing config.toml.", + ) + .size(13), + ] + .spacing(6), + ) + .padding(14) + .width(Length::Fill) + .style(entry_value_style), + ); } if let Some(error) = &form.error { content = content.push(text(format!("Settings error: {error}"))); } } UtilityView::Recipients(form) => { - let title = match form.kind { - RecipientWorkflowKind::InitializeStore => "Initialize Password Store", - RecipientWorkflowKind::NewFolder => "Create Password Store Folder", - }; - content = content.push(text(title).size(28)); match form.kind { RecipientWorkflowKind::InitializeStore => { content = content.push(text( @@ -4242,7 +4350,8 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa .push( text_input("team/services", &form.path) .on_input(Message::RecipientPathChanged) - .on_submit(Message::SubmitRecipient), + .on_submit(Message::SubmitRecipient) + .style(entry_input_style), ) .push(text( "An upstream-compatible nested .gpg-id persists the folder and controls its recipients.", @@ -4256,7 +4365,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa } else { "" }; - let choice = button(text(format!( + let choice = utility_button(text(format!( "[{}] {}{}", if recipient.selected { "x" } else { " " }, recipient.label, @@ -4269,7 +4378,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa }; if form.kind == RecipientWorkflowKind::InitializeStore { let selected_default = recipient.fingerprint == form.default_key; - let default = button(text(if !recipient.can_default { + let default = utility_button(text(if !recipient.can_default { "Public recipient only" } else if selected_default { "Default key ✓" @@ -4286,7 +4395,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa content = content.push(choice); } } - let confirmation = button(text(format!( + let confirmation = utility_button(text(format!( "[{}] Replace this recipient policy and selectively re-encrypt affected entries", if form.confirmed { "x" } else { " " } ))); @@ -4300,15 +4409,13 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa } } UtilityView::NewEntry(form) => { - content = content - .push(text("New Entry").size(28)) - .push(text("Entry path")) - .push( - text_input("folder/account", &form.path) - .on_input(Message::NewEntryPathChanged) - .on_submit(Message::SubmitNewEntry), - ); - let generate = button(text(format!( + content = content.push(text("Entry path")).push( + text_input("folder/account", &form.path) + .on_input(Message::NewEntryPathChanged) + .on_submit(Message::SubmitNewEntry) + .style(entry_input_style), + ); + let generate = utility_button(text(format!( "[{}] Generate the initial password with storage policy", if form.generate { "x" } else { " " } ))); @@ -4321,9 +4428,10 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa content = content.push(text("Password length")).push( text_input("25", &form.length) .on_input(Message::NewEntryLengthChanged) - .on_submit(Message::SubmitNewEntry), + .on_submit(Message::SubmitNewEntry) + .style(entry_input_style), ); - let symbols = button(text(format!( + let symbols = utility_button(text(format!( "[{}] Letters and digits only", if form.no_symbols { "x" } else { " " } ))); @@ -4341,11 +4449,6 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa } } UtilityView::Search(form) => { - let title = match form.mode { - SearchMode::Names => "Find Entries and Folders", - SearchMode::Contents => "Search Decrypted Contents", - }; - content = content.push(text(title).size(28)); let input = text_input( if form.mode == SearchMode::Names { "Name substring" @@ -4355,9 +4458,10 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa &form.query, ) .on_input(Message::SearchQueryChanged) - .on_submit(Message::SubmitSearch); + .on_submit(Message::SubmitSearch) + .style(entry_input_style); content = if form.mode == SearchMode::Names { - let add = button("Add another term"); + let add = utility_button("Add another term"); content.push( row![ input, @@ -4391,7 +4495,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa "Name search remains available while locked. Multiple terms use the storage crate's pass-compatible OR matching semantics.", )); for (index, term) in form.terms.iter().enumerate() { - let remove = button(text(format!("Remove term · {term}"))); + let remove = utility_button(text(format!("Remove term · {term}"))); content = content.push(if form.running { remove } else { @@ -4416,7 +4520,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa "Entry" }; content = content.push( - button(text(format!("{kind} · {}", matched.path()))) + utility_button(text(format!("{kind} · {}", matched.path()))) .on_press(Message::ActivateSearchResult(matched.id().clone())), ); } @@ -4427,7 +4531,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa } for entry in results.entries() { content = content.push( - button(text(format!("Entry · {}", entry.path()))).on_press( + utility_button(text(format!("Entry · {}", entry.path()))).on_press( Message::ActivateSearchResult(TreeNodeId::Entry( entry.path().clone(), )), @@ -4450,35 +4554,29 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa } } UtilityView::Mutation(form) => { - let title = match form.kind { - MutationKind::Move => "Move or Rename", - MutationKind::Copy => "Copy Entry or Folder", - MutationKind::Delete => "Delete Entry or Folder", - }; - content = content - .push(text(title).size(28)) - .push(text(format!("Source: {}", form.source.path().display()))); + content = content.push(text(format!("Source: {}", form.source.path().display()))); if form.kind != MutationKind::Delete { content = content .push(text("Destination entry path or existing folder")) .push( text_input("folder/name", &form.destination) .on_input(Message::MutationDestinationChanged) - .on_submit(Message::SubmitMutation), + .on_submit(Message::SubmitMutation) + .style(entry_input_style), ) .push(text("Choose an existing destination folder")); let mut destinations = row![].spacing(6); for directory in app.navigation.directories() { let path = directory.path().display().to_string(); let label = if path.is_empty() { "Store root" } else { &path }; - let choice = button(text(label.to_owned())); + let choice = utility_button(text(label.to_owned())); destinations = destinations.push(if form.running { choice } else { choice.on_press(Message::SelectMutationDestination(path)) }); } - let overwrite = button(text(format!( + let overwrite = utility_button(text(format!( "[{}] Replace an existing destination entry", if form.overwrite { "x" } else { " " } ))); @@ -4488,7 +4586,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa overwrite.on_press(Message::ToggleMutationOverwrite) }); } else { - let confirmation = button(text(format!( + let confirmation = utility_button(text(format!( "[{}] Permanently remove this {} and commit the deletion", if form.confirmed { "x" } else { " " }, if form.source.is_directory() { @@ -4511,11 +4609,9 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa } } UtilityView::Git(form) => { - content = content - .push(text("Git Synchronization").size(28)) - .push(text( - "All repository, HTTPS transport, credential, merge, and conflict decisions are owned by crates/storage. No git process or credential helper is launched.", - )); + content = content.push(text( + "All repository, HTTPS transport, credential, merge, and conflict decisions are owned by crates/storage. No git process or credential helper is launched.", + )); if let Some(phase) = form.progress { content = content.push(text(format!("Progress: {}", git_phase_name(phase)))); } @@ -4579,14 +4675,14 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa "Choose exactly one complete version for every path. Resolution is committed by crates/storage only after all choices are present.", )); for (index, selection) in form.conflicts.iter().enumerate() { - let local = button(text( + let local = utility_button(text( if selection.choice == Some(GitConflictChoice::Local) { "Local ✓" } else { "Use local" }, )); - let remote = button(text( + let remote = utility_button(text( if selection.choice == Some(GitConflictChoice::Remote) { "Remote ✓" } else { @@ -4619,14 +4715,14 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa } UtilityView::Otp(form) => { content = content - .push(text("One-Time Password").size(28)) .push(text( "Code generation, counters, URI validation, QR payloads, entry mutation, Git commits, and clipboard policy are owned by crates/storage.", )) .push(text("Entry path")) .push( text_input("folder/entry", &form.entry) - .on_input(Message::OtpEntryChanged), + .on_input(Message::OtpEntryChanged) + .style(entry_input_style), ); if let Some(display) = &app.sensitive.otp { content = content @@ -4676,7 +4772,8 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa .push(text("Import or replace OTP").size(20)) .push( text_input("otpauth://…", &form.uri) - .on_input(|value| Message::OtpUriChanged(Zeroizing::new(value))), + .on_input(|value| Message::OtpUriChanged(Zeroizing::new(value))) + .style(entry_input_style), ) .push(search_option( "Replace an existing OTP URI", @@ -4701,7 +4798,6 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa } UtilityView::Kdbx(form) => { content = content - .push(text("Import KeePass Database").size(28)) .push(text( "The import is additive: full mode adds new entries and updates changed entries; quick-add mode only adds entries that do not exist. Nothing is deleted.", )) @@ -4710,11 +4806,12 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa row![ text_input("Database.kdbx", &form.source) .on_input(Message::KdbxSourceChanged) - .on_submit(Message::SubmitKdbxImport), + .on_submit(Message::SubmitKdbxImport) + .style(entry_input_style), if form.running { - button("Choose…") + utility_button("Choose…") } else { - button("Choose…").on_press(Message::PickKdbxSource) + utility_button("Choose…").on_press(Message::PickKdbxSource) }, ] .spacing(8), @@ -4724,11 +4821,12 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa row![ text_input("No key file", &form.key_file) .on_input(Message::KdbxKeyFileChanged) - .on_submit(Message::SubmitKdbxImport), + .on_submit(Message::SubmitKdbxImport) + .style(entry_input_style), if form.running { - button("Choose…") + utility_button("Choose…") } else { - button("Choose…").on_press(Message::PickKdbxKeyFile) + utility_button("Choose…").on_press(Message::PickKdbxKeyFile) }, ] .spacing(8), @@ -4738,7 +4836,8 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa text_input("Password", &form.password) .secure(true) .on_input(|value| Message::KdbxPasswordChanged(Zeroizing::new(value))) - .on_submit(Message::SubmitKdbxImport), + .on_submit(Message::SubmitKdbxImport) + .style(entry_input_style), ) .push(search_option( "Quick add: only add entries not already present", @@ -4760,9 +4859,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa } } UtilityView::Help => { - content = content - .push(text("IronStorage Help").size(28)) - .push(text("Keyboard shortcuts").size(22)); + content = content.push(text("Keyboard shortcuts").size(22)); for spec in action::ACTIONS { if let Some(shortcut) = action::shortcut_label(spec.action) { content = content.push(text(format!( @@ -4795,7 +4892,6 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa )); } } - let done = button("Done (Esc)"); let busy = matches!(utility, UtilityView::Settings(form) if form.saving) || matches!(utility, UtilityView::Recipients(form) if form.running) || matches!(utility, UtilityView::NewEntry(form) if form.running) @@ -4804,230 +4900,246 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa || matches!(utility, UtilityView::Git(form) if form.running) || matches!(utility, UtilityView::Otp(form) if form.running) || matches!(utility, UtilityView::Kdbx(form) if form.running); - let done = if busy { - done - } else { - done.on_press(Message::DismissUtility) - }; let actions = match utility { - UtilityView::Settings(form) => { - let save = button(if form.saving { - "Validating…" + UtilityView::Settings(form) => row![utility_icon_control( + Icon::Check, + if form.saving { + "Validating settings…" } else { - "Save Settings" - }); - row![ - if form.saving { - save - } else { - save.on_press(Message::SaveSettings) - }, - done, - ] - .spacing(8) - } - UtilityView::Recipients(form) => { - let apply = button(if form.running { - "Applying…" + "Save settings" + }, + Message::SaveSettings, + !form.saving, + )], + UtilityView::Recipients(form) => row![utility_icon_control( + Icon::Check, + if form.running { + "Applying recipient policy…" } else { - "Apply Recipient Policy" - }); - row![ - if form.running { - apply - } else { - apply.on_press(Message::SubmitRecipient) - }, - done, - ] - .spacing(8) - } - UtilityView::NewEntry(form) => { - let create = button(if form.running { - "Preparing…" + "Apply recipient policy" + }, + Message::SubmitRecipient, + !form.running, + )], + UtilityView::NewEntry(form) => row![utility_icon_control( + Icon::Check, + if form.running { + "Preparing draft…" } else { - "Create Draft" - }); - row![ - if form.running { - create - } else { - create.on_press(Message::SubmitNewEntry) - }, - done, - ] - .spacing(8) - } - UtilityView::Search(form) => { - let submit = button(if form.running { + "Create draft" + }, + Message::SubmitNewEntry, + !form.running, + )], + UtilityView::Search(form) => row![utility_icon_control( + Icon::Search, + if form.running { "Searching…" } else { "Search" - }); - row![ - if form.running { - submit - } else { - submit.on_press(Message::SubmitSearch) - }, - done, - ] - .spacing(8) - } + }, + Message::SubmitSearch, + !form.running, + )], UtilityView::Mutation(form) => { - let submit = button(if form.running { - "Applying…" - } else { - match form.kind { - MutationKind::Move => "Move", - MutationKind::Copy => "Copy", - MutationKind::Delete => "Delete", - } - }); - row![ + let icon = match form.kind { + MutationKind::Move => Icon::Move, + MutationKind::Copy => Icon::Copy, + MutationKind::Delete => Icon::Delete, + }; + row![utility_icon_control( + icon, if form.running { - submit + "Applying mutation…" } else { - submit.on_press(Message::SubmitMutation) + "Apply mutation" }, - done, - ] - .spacing(8) + Message::SubmitMutation, + !form.running, + )] } UtilityView::Git(form) => { if form.running { - row![ - button("Cancel Git operation").on_press(Message::CancelGit), - done - ] - .spacing(8) + row![utility_button("Cancel Git operation").on_press(Message::CancelGit)] } else { let mut actions = row![ - icon_control( + utility_icon_control( Icon::Refresh, - "Refresh Git status".to_owned(), - Message::RunGit(DesktopGitRequest::Refresh) + "Refresh Git status", + Message::RunGit(DesktopGitRequest::Refresh), + true, ), - icon_control( + utility_icon_control( Icon::Down, - "Pull from remote".to_owned(), - Message::RunGit(DesktopGitRequest::Pull) + "Pull from remote", + Message::RunGit(DesktopGitRequest::Pull), + true, ), - icon_control( + utility_icon_control( Icon::Up, - "Push to remote".to_owned(), - Message::RunGit(DesktopGitRequest::Push) + "Push to remote", + Message::RunGit(DesktopGitRequest::Push), + true, ), - icon_control( + utility_icon_control( Icon::Refresh, - "Synchronize with remote".to_owned(), - Message::RunGit(DesktopGitRequest::Sync) + "Synchronize with remote", + Message::RunGit(DesktopGitRequest::Sync), + true, ), ] .spacing(2); if !form.conflicts.is_empty() { - actions = actions.push(icon_control( + actions = actions.push(utility_icon_control( Icon::Check, - "Resolve selected versions".to_owned(), + "Resolve selected versions", Message::ResolveGitConflicts, + true, )); } - actions.push(done) + actions } } UtilityView::Otp(form) => { if form.running { - row![button("Working…"), done].spacing(8) + row![utility_icon_control( + Icon::Refresh, + "Working…", + Message::RunOtpCode(false), + false, + )] } else { let mut actions = row![ - icon_control( + utility_icon_control( Icon::Refresh, - "Generate OTP code".to_owned(), - Message::RunOtpCode(false) + "Generate OTP code", + Message::RunOtpCode(false), + true, ), - icon_control( + utility_icon_control( Icon::Copy, - "Copy OTP code".to_owned(), - Message::RunOtpCode(true) + "Copy OTP code", + Message::RunOtpCode(true), + true, ), - icon_control( + utility_icon_control( Icon::Link, - "Show provisioning URI".to_owned(), + "Show provisioning URI", Message::RunOtpUri { qr: false, copy: false, - } + }, + true, ), - icon_control( + utility_icon_control( Icon::Copy, - "Copy provisioning URI".to_owned(), + "Copy provisioning URI", Message::RunOtpUri { qr: false, copy: true, - } + }, + true, ), - icon_control( + utility_icon_control( Icon::Qr, - "Show provisioning QR code".to_owned(), + "Show provisioning QR code", Message::RunOtpUri { qr: true, copy: false, - } + }, + true, ), ] .spacing(2); if form.hotp_confirmation { actions = actions.push( - button("Confirm HOTP counter advance").on_press(Message::ConfirmHotp), + utility_button("Confirm HOTP counter advance") + .on_press(Message::ConfirmHotp), ); } actions - .push(icon_control( + .push(utility_icon_control( Icon::Down, - "Import provisioning URI".to_owned(), + "Import provisioning URI", Message::SubmitOtpImport, + true, )) - .push(icon_control( + .push(utility_icon_control( Icon::Qr, - "Import provisioning QR image".to_owned(), + "Import provisioning QR image", Message::PickOtpQr, + true, )) .push( - button("Remove OTP") + utility_button("Remove OTP") .style(button::danger) .on_press(Message::SubmitOtpRemoval), ) - .push(done) } } - UtilityView::Kdbx(form) => { - let import = button(if form.running { + UtilityView::Kdbx(form) => row![utility_icon_control( + Icon::Down, + if form.running { "Importing…" } else if form.quick_add { - "Quick Add" + "Quick add KeePass entries" } else { - "Import" - }); - row![ - if form.running { - import - } else { - import.on_press(Message::SubmitKdbxImport) - }, - done, - ] - .spacing(8) - } - UtilityView::About | UtilityView::Help => row![done], + "Import KeePass entries" + }, + Message::SubmitKdbxImport, + !form.running, + )], + UtilityView::About | UtilityView::Help => row![container(text("")).width(Length::Fill)], }; - container( - column![scrollable(content).height(Length::Fill), actions,] - .spacing(12) - .padding(20), + + let header = container( + row![ + colored_icon_view(utility_icon(utility), Some(ENTRY_TEXT)), + text(utility_title(utility)).size(20).width(Length::Fill), + utility_icon_control( + Icon::Close, + "Close panel (Esc)", + Message::DismissUtility, + !busy, + ), + ] + .align_y(iced::Alignment::Center) + .spacing(8), ) + .padding([6, 10]) .width(Length::Fill) - .height(Length::Fill) - .into() + .style(entry_value_style); + let body = scrollable( + container( + container(content) + .padding(16) + .width(Length::Fill) + .max_width(760) + .style(|theme| entry_field_style(theme, false)), + ) + .padding([12, 16]) + .width(Length::Fill) + .center_x(Length::Fill), + ) + .height(Length::Fill); + let mut layout = column![header, body].spacing(1); + if !matches!(utility, UtilityView::About | UtilityView::Help) { + layout = layout.push( + container( + row![container(text("")).width(Length::Fill), actions] + .align_y(iced::Alignment::Center) + .spacing(2), + ) + .padding([6, 10]) + .width(Length::Fill) + .style(entry_value_style), + ); + } + container(layout) + .width(Length::Fill) + .height(Length::Fill) + .style(entry_area_style) + .into() } fn search_option<'a>( @@ -5036,7 +5148,7 @@ fn search_option<'a>( message: Message, running: bool, ) -> iced::widget::Button<'a, Message> { - let option = button(text(format!( + let option = utility_button(text(format!( "[{}] {label}", if selected { "x" } else { " " } ))); @@ -6103,6 +6215,37 @@ mod tests { let _view = app.view(); } + #[test] + fn utility_panels_share_the_compact_entry_palette() { + let theme = Theme::Dark; + assert_eq!( + entry_area_style(&theme).background, + Some(Background::Color(ENTRY_AREA_BACKGROUND)) + ); + assert_eq!( + entry_field_style(&theme, false).background, + Some(Background::Color(ENTRY_FIELD_BACKGROUND)) + ); + assert_eq!( + entry_input_style(&theme, text_input::Status::Active).background, + Background::Color(ENTRY_INPUT_BACKGROUND) + ); + assert_eq!( + entry_icon_button(&theme, button::Status::Hovered).background, + Some(Background::Color(ENTRY_FIELD_ACTIVE_BACKGROUND)) + ); + + let (_temporary, storage) = fixture_storage(); + let mut app = test_app(None); + app.utility = Some(UtilityView::Settings(SettingsForm::new(&storage))); + app.storage = Some(storage); + assert_eq!( + utility_title(app.utility.as_ref().expect("settings panel")), + "Settings" + ); + let _view = app.view(); + } + #[derive(Clone, Default)] struct ManualClock(Arc>); diff --git a/docs/desktop-audit.md b/docs/desktop-audit.md index bb85d08..01e5943 100644 --- a/docs/desktop-audit.md +++ b/docs/desktop-audit.md @@ -11,7 +11,7 @@ requires every registered action ID to remain present in this document. | Area and compatible operation | Registered desktop action and menu | Direct control, dialog, or view | Command palette | | --- | --- | --- | --- | | Configuration and lock: open configured store | `open-folder` (File) | Compact toolbar control and native folder picker; storage validates and persists configuration | Yes | -| Configuration and lock: edit shared settings | `settings` (IronStorage) | Labelled Settings form for vault, default key, and inactivity timeout | Yes | +| Configuration and lock: edit shared settings | `settings` (IronStorage) | Compact shared utility shell with grouped vault, default-key, timeout, command-link, and configuration-detail panels | Yes | | Configuration and lock: refresh typed tree | `refresh` (View) | Sidebar Refresh control | Yes | | Configuration and lock: lock/unlock | `lock` (Tools) | Toolbar Lock control; opening protected content starts storage authentication | Lock only; unlock is the protected action being resumed | | Base pass: `init` root or nested recipient policy | `initialize-store`, `new-folder` (File) | Recipient/default-key form and explicit replacement confirmation | Yes | @@ -58,9 +58,11 @@ These are deliberate presentation differences, not storage-feature gaps. rows wrap, long names and storage-grouped multiline values are retained, and the supported narrow window floor is 480 by 360 logical pixels. Iced/winit applies native display scaling before layout. -- Compact toolbar controls use one 16-by-16 vector icon system with descriptive - delayed tooltips and registered shortcuts. Focused/selected rows use the - theme's primary contrast pair; field labels remain visible as a non-colour cue. +- Compact main and utility toolbar controls use one 16-by-16 vector icon system + with descriptive delayed tooltips and registered shortcuts. Every utility + panel shares the entry palette, flat controls, responsive 760-pixel content + cap, and compact header/footer chrome; field labels remain visible as a + non-colour cue. - The app implements no animation or motion-driven state transition. The one- second subscription updates lease, OTP, Git, and clipboard presentation state without moving focus or renewing authentication, so reduced-motion mode has