Align desktop utility panels

This commit is contained in:
2026-08-11 09:33:41 +02:00
parent d6bcbbec99
commit 9e0e37b875
2 changed files with 408 additions and 263 deletions

View File

@@ -3715,6 +3715,7 @@ enum Icon {
Check, Check,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Close,
Command, Command,
Copy, Copy,
Delete, Delete,
@@ -3773,6 +3774,12 @@ impl<Message> canvas::Program<Message> for IconCanvas {
path.line_to(Point::new(10.5, 8.0)); path.line_to(Point::new(10.5, 8.0));
path.line_to(Point::new(5.5, 13.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 => { Icon::Command => {
path.move_to(Point::new(2.0, 4.0)); path.move_to(Point::new(2.0, 4.0));
path.line_to(Point::new(6.0, 8.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() .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<Element<'a, Message>>,
) -> 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 { fn entry_icon_button(_theme: &Theme, status: button::Status) -> button::Style {
button::Style { button::Style {
background: matches!(status, button::Status::Hovered | button::Status::Pressed) 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() 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> { fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Message> {
let mut content = column![].spacing(10); let mut content = column![].spacing(10);
match utility { match utility {
UtilityView::About => { UtilityView::About => {
content = content content = content
.push(text(ironstorage::PRODUCT_NAME).size(28))
.push(text(format!("Version {}", env!("CARGO_PKG_VERSION")))) .push(text(format!("Version {}", env!("CARGO_PKG_VERSION"))))
.push(text("A native, pass-compatible password-store client.")) .push(text("A native, pass-compatible password-store client."))
.push(text("Compatible with pass and pass-otp.")) .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) => { UtilityView::Settings(form) => {
content = content let general = column![
.push(text("Settings").size(28)) text("General").size(18),
.push(text("Password-store folder")) text("Password-store folder").size(13),
.push(
row![ row![
text_input("Vault path", &form.vault) text_input("Vault path", &form.vault)
.id(settings_vault_id()) .id(settings_vault_id())
.on_input(Message::SettingsVaultChanged) .on_input(Message::SettingsVaultChanged)
.on_submit(Message::SaveSettings), .on_submit(Message::SaveSettings)
if form.saving { .style(entry_input_style),
button("Choose…") utility_icon_control(
} else { Icon::Folder,
button("Choose…").on_press(Message::PickSettingsVault) "Choose password-store folder",
}, Message::PickSettingsVault,
!form.saving,
),
] ]
.spacing(8), .align_y(iced::Alignment::Center)
) .spacing(6),
.push(text("Default OpenPGP key fingerprint or identity")) text("Default OpenPGP key fingerprint or identity").size(13),
.push(
text_input("Default key", &form.default_key) text_input("Default key", &form.default_key)
.on_input(Message::SettingsDefaultKeyChanged) .on_input(Message::SettingsDefaultKeyChanged)
.on_submit(Message::SaveSettings), .on_submit(Message::SaveSettings)
) .style(entry_input_style),
.push(text( text("Authentication inactivity timeout in seconds (186400)").size(13),
"Authentication inactivity timeout in seconds (186400)",
))
.push(
text_input("Timeout seconds", &form.authentication_timeout) text_input("Timeout seconds", &form.authentication_timeout)
.on_input(Message::SettingsTimeoutChanged) .on_input(Message::SettingsTimeoutChanged)
.on_submit(Message::SaveSettings), .on_submit(Message::SaveSettings)
) .style(entry_input_style),
.push(text("Command-line tools")) ]
.push(text( .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.", "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 { .size(13),
button("Install CLI and TUI Links") ]
} else { .spacing(8);
button("Install CLI and TUI Links").on_press(Message::InstallCommandLinks)
});
if let Some(status) = &form.command_links_status { 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 { if let Some(storage) = &app.storage {
content = content content = content.push(
.push(text(format!( container(
column![
text("Configuration details").size(18),
text(format!(
"Shared configuration: {}", "Shared configuration: {}",
storage.config_source().display() storage.config_source().display()
))) ))
.push(text(format!( .size(13),
text(format!(
"Clipboard cleanup remains {} seconds.", "Clipboard cleanup remains {} seconds.",
storage.clipboard_timeout().duration().as_secs() storage.clipboard_timeout().duration().as_secs()
))) ))
.push(text( .size(13),
text(
"Save validates the repository, key material, entry tree, and authentication session before atomically replacing config.toml.", "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 { if let Some(error) = &form.error {
content = content.push(text(format!("Settings error: {error}"))); content = content.push(text(format!("Settings error: {error}")));
} }
} }
UtilityView::Recipients(form) => { 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 { match form.kind {
RecipientWorkflowKind::InitializeStore => { RecipientWorkflowKind::InitializeStore => {
content = content.push(text( content = content.push(text(
@@ -4242,7 +4350,8 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
.push( .push(
text_input("team/services", &form.path) text_input("team/services", &form.path)
.on_input(Message::RecipientPathChanged) .on_input(Message::RecipientPathChanged)
.on_submit(Message::SubmitRecipient), .on_submit(Message::SubmitRecipient)
.style(entry_input_style),
) )
.push(text( .push(text(
"An upstream-compatible nested .gpg-id persists the folder and controls its recipients.", "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 { } else {
"" ""
}; };
let choice = button(text(format!( let choice = utility_button(text(format!(
"[{}] {}{}", "[{}] {}{}",
if recipient.selected { "x" } else { " " }, if recipient.selected { "x" } else { " " },
recipient.label, recipient.label,
@@ -4269,7 +4378,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
}; };
if form.kind == RecipientWorkflowKind::InitializeStore { if form.kind == RecipientWorkflowKind::InitializeStore {
let selected_default = recipient.fingerprint == form.default_key; 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" "Public recipient only"
} else if selected_default { } else if selected_default {
"Default key ✓" "Default key ✓"
@@ -4286,7 +4395,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
content = content.push(choice); 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", "[{}] Replace this recipient policy and selectively re-encrypt affected entries",
if form.confirmed { "x" } else { " " } 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) => { UtilityView::NewEntry(form) => {
content = content content = content.push(text("Entry path")).push(
.push(text("New Entry").size(28))
.push(text("Entry path"))
.push(
text_input("folder/account", &form.path) text_input("folder/account", &form.path)
.on_input(Message::NewEntryPathChanged) .on_input(Message::NewEntryPathChanged)
.on_submit(Message::SubmitNewEntry), .on_submit(Message::SubmitNewEntry)
.style(entry_input_style),
); );
let generate = button(text(format!( let generate = utility_button(text(format!(
"[{}] Generate the initial password with storage policy", "[{}] Generate the initial password with storage policy",
if form.generate { "x" } else { " " } 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( content = content.push(text("Password length")).push(
text_input("25", &form.length) text_input("25", &form.length)
.on_input(Message::NewEntryLengthChanged) .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", "[{}] Letters and digits only",
if form.no_symbols { "x" } else { " " } 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) => { 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( let input = text_input(
if form.mode == SearchMode::Names { if form.mode == SearchMode::Names {
"Name substring" "Name substring"
@@ -4355,9 +4458,10 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
&form.query, &form.query,
) )
.on_input(Message::SearchQueryChanged) .on_input(Message::SearchQueryChanged)
.on_submit(Message::SubmitSearch); .on_submit(Message::SubmitSearch)
.style(entry_input_style);
content = if form.mode == SearchMode::Names { content = if form.mode == SearchMode::Names {
let add = button("Add another term"); let add = utility_button("Add another term");
content.push( content.push(
row![ row![
input, 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.", "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() { 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 { content = content.push(if form.running {
remove remove
} else { } else {
@@ -4416,7 +4520,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
"Entry" "Entry"
}; };
content = content.push( content = content.push(
button(text(format!("{kind} · {}", matched.path()))) utility_button(text(format!("{kind} · {}", matched.path())))
.on_press(Message::ActivateSearchResult(matched.id().clone())), .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() { for entry in results.entries() {
content = content.push( content = content.push(
button(text(format!("Entry · {}", entry.path()))).on_press( utility_button(text(format!("Entry · {}", entry.path()))).on_press(
Message::ActivateSearchResult(TreeNodeId::Entry( Message::ActivateSearchResult(TreeNodeId::Entry(
entry.path().clone(), entry.path().clone(),
)), )),
@@ -4450,35 +4554,29 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
} }
} }
UtilityView::Mutation(form) => { UtilityView::Mutation(form) => {
let title = match form.kind { content = content.push(text(format!("Source: {}", form.source.path().display())));
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())));
if form.kind != MutationKind::Delete { if form.kind != MutationKind::Delete {
content = content content = content
.push(text("Destination entry path or existing folder")) .push(text("Destination entry path or existing folder"))
.push( .push(
text_input("folder/name", &form.destination) text_input("folder/name", &form.destination)
.on_input(Message::MutationDestinationChanged) .on_input(Message::MutationDestinationChanged)
.on_submit(Message::SubmitMutation), .on_submit(Message::SubmitMutation)
.style(entry_input_style),
) )
.push(text("Choose an existing destination folder")); .push(text("Choose an existing destination folder"));
let mut destinations = row![].spacing(6); let mut destinations = row![].spacing(6);
for directory in app.navigation.directories() { for directory in app.navigation.directories() {
let path = directory.path().display().to_string(); let path = directory.path().display().to_string();
let label = if path.is_empty() { "Store root" } else { &path }; 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 { destinations = destinations.push(if form.running {
choice choice
} else { } else {
choice.on_press(Message::SelectMutationDestination(path)) choice.on_press(Message::SelectMutationDestination(path))
}); });
} }
let overwrite = button(text(format!( let overwrite = utility_button(text(format!(
"[{}] Replace an existing destination entry", "[{}] Replace an existing destination entry",
if form.overwrite { "x" } else { " " } 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) overwrite.on_press(Message::ToggleMutationOverwrite)
}); });
} else { } else {
let confirmation = button(text(format!( let confirmation = utility_button(text(format!(
"[{}] Permanently remove this {} and commit the deletion", "[{}] Permanently remove this {} and commit the deletion",
if form.confirmed { "x" } else { " " }, if form.confirmed { "x" } else { " " },
if form.source.is_directory() { if form.source.is_directory() {
@@ -4511,9 +4609,7 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
} }
} }
UtilityView::Git(form) => { UtilityView::Git(form) => {
content = content content = content.push(text(
.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.", "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 { if let Some(phase) = form.progress {
@@ -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.", "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() { for (index, selection) in form.conflicts.iter().enumerate() {
let local = button(text( let local = utility_button(text(
if selection.choice == Some(GitConflictChoice::Local) { if selection.choice == Some(GitConflictChoice::Local) {
"Local ✓" "Local ✓"
} else { } else {
"Use local" "Use local"
}, },
)); ));
let remote = button(text( let remote = utility_button(text(
if selection.choice == Some(GitConflictChoice::Remote) { if selection.choice == Some(GitConflictChoice::Remote) {
"Remote ✓" "Remote ✓"
} else { } else {
@@ -4619,14 +4715,14 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
} }
UtilityView::Otp(form) => { UtilityView::Otp(form) => {
content = content content = content
.push(text("One-Time Password").size(28))
.push(text( .push(text(
"Code generation, counters, URI validation, QR payloads, entry mutation, Git commits, and clipboard policy are owned by crates/storage.", "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("Entry path"))
.push( .push(
text_input("folder/entry", &form.entry) 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 { if let Some(display) = &app.sensitive.otp {
content = content 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("Import or replace OTP").size(20))
.push( .push(
text_input("otpauth://…", &form.uri) 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( .push(search_option(
"Replace an existing OTP URI", "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) => { UtilityView::Kdbx(form) => {
content = content content = content
.push(text("Import KeePass Database").size(28))
.push(text( .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.", "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![ row![
text_input("Database.kdbx", &form.source) text_input("Database.kdbx", &form.source)
.on_input(Message::KdbxSourceChanged) .on_input(Message::KdbxSourceChanged)
.on_submit(Message::SubmitKdbxImport), .on_submit(Message::SubmitKdbxImport)
.style(entry_input_style),
if form.running { if form.running {
button("Choose…") utility_button("Choose…")
} else { } else {
button("Choose…").on_press(Message::PickKdbxSource) utility_button("Choose…").on_press(Message::PickKdbxSource)
}, },
] ]
.spacing(8), .spacing(8),
@@ -4724,11 +4821,12 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
row![ row![
text_input("No key file", &form.key_file) text_input("No key file", &form.key_file)
.on_input(Message::KdbxKeyFileChanged) .on_input(Message::KdbxKeyFileChanged)
.on_submit(Message::SubmitKdbxImport), .on_submit(Message::SubmitKdbxImport)
.style(entry_input_style),
if form.running { if form.running {
button("Choose…") utility_button("Choose…")
} else { } else {
button("Choose…").on_press(Message::PickKdbxKeyFile) utility_button("Choose…").on_press(Message::PickKdbxKeyFile)
}, },
] ]
.spacing(8), .spacing(8),
@@ -4738,7 +4836,8 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
text_input("Password", &form.password) text_input("Password", &form.password)
.secure(true) .secure(true)
.on_input(|value| Message::KdbxPasswordChanged(Zeroizing::new(value))) .on_input(|value| Message::KdbxPasswordChanged(Zeroizing::new(value)))
.on_submit(Message::SubmitKdbxImport), .on_submit(Message::SubmitKdbxImport)
.style(entry_input_style),
) )
.push(search_option( .push(search_option(
"Quick add: only add entries not already present", "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 => { UtilityView::Help => {
content = content content = content.push(text("Keyboard shortcuts").size(22));
.push(text("IronStorage Help").size(28))
.push(text("Keyboard shortcuts").size(22));
for spec in action::ACTIONS { for spec in action::ACTIONS {
if let Some(shortcut) = action::shortcut_label(spec.action) { if let Some(shortcut) = action::shortcut_label(spec.action) {
content = content.push(text(format!( 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) let busy = matches!(utility, UtilityView::Settings(form) if form.saving)
|| matches!(utility, UtilityView::Recipients(form) if form.running) || matches!(utility, UtilityView::Recipients(form) if form.running)
|| matches!(utility, UtilityView::NewEntry(form) if form.running) || matches!(utility, UtilityView::NewEntry(form) if form.running)
@@ -4804,229 +4900,245 @@ fn utility_view<'a>(app: &'a App, utility: &'a UtilityView) -> Element<'a, Messa
|| matches!(utility, UtilityView::Git(form) if form.running) || matches!(utility, UtilityView::Git(form) if form.running)
|| matches!(utility, UtilityView::Otp(form) if form.running) || matches!(utility, UtilityView::Otp(form) if form.running)
|| matches!(utility, UtilityView::Kdbx(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 { let actions = match utility {
UtilityView::Settings(form) => { UtilityView::Settings(form) => row![utility_icon_control(
let save = button(if form.saving { Icon::Check,
"Validating…"
} else {
"Save Settings"
});
row![
if form.saving { if form.saving {
save "Validating settings…"
} else { } else {
save.on_press(Message::SaveSettings) "Save settings"
}, },
done, Message::SaveSettings,
] !form.saving,
.spacing(8) )],
} UtilityView::Recipients(form) => row![utility_icon_control(
UtilityView::Recipients(form) => { Icon::Check,
let apply = button(if form.running {
"Applying…"
} else {
"Apply Recipient Policy"
});
row![
if form.running { if form.running {
apply "Applying recipient policy…"
} else { } else {
apply.on_press(Message::SubmitRecipient) "Apply recipient policy"
}, },
done, Message::SubmitRecipient,
] !form.running,
.spacing(8) )],
} UtilityView::NewEntry(form) => row![utility_icon_control(
UtilityView::NewEntry(form) => { Icon::Check,
let create = button(if form.running {
"Preparing…"
} else {
"Create Draft"
});
row![
if form.running { if form.running {
create "Preparing draft…"
} else { } else {
create.on_press(Message::SubmitNewEntry) "Create draft"
}, },
done, Message::SubmitNewEntry,
] !form.running,
.spacing(8) )],
} UtilityView::Search(form) => row![utility_icon_control(
UtilityView::Search(form) => { Icon::Search,
let submit = button(if form.running { if form.running {
"Searching…" "Searching…"
} else { } else {
"Search" "Search"
});
row![
if form.running {
submit
} else {
submit.on_press(Message::SubmitSearch)
}, },
done, Message::SubmitSearch,
] !form.running,
.spacing(8) )],
}
UtilityView::Mutation(form) => { UtilityView::Mutation(form) => {
let submit = button(if form.running { let icon = match form.kind {
"Applying…" MutationKind::Move => Icon::Move,
} else { MutationKind::Copy => Icon::Copy,
match form.kind { MutationKind::Delete => Icon::Delete,
MutationKind::Move => "Move", };
MutationKind::Copy => "Copy", row![utility_icon_control(
MutationKind::Delete => "Delete", icon,
}
});
row![
if form.running { if form.running {
submit "Applying mutation…"
} else { } else {
submit.on_press(Message::SubmitMutation) "Apply mutation"
}, },
done, Message::SubmitMutation,
] !form.running,
.spacing(8) )]
} }
UtilityView::Git(form) => { UtilityView::Git(form) => {
if form.running { if form.running {
row![ row![utility_button("Cancel Git operation").on_press(Message::CancelGit)]
button("Cancel Git operation").on_press(Message::CancelGit),
done
]
.spacing(8)
} else { } else {
let mut actions = row![ let mut actions = row![
icon_control( utility_icon_control(
Icon::Refresh, Icon::Refresh,
"Refresh Git status".to_owned(), "Refresh Git status",
Message::RunGit(DesktopGitRequest::Refresh) Message::RunGit(DesktopGitRequest::Refresh),
true,
), ),
icon_control( utility_icon_control(
Icon::Down, Icon::Down,
"Pull from remote".to_owned(), "Pull from remote",
Message::RunGit(DesktopGitRequest::Pull) Message::RunGit(DesktopGitRequest::Pull),
true,
), ),
icon_control( utility_icon_control(
Icon::Up, Icon::Up,
"Push to remote".to_owned(), "Push to remote",
Message::RunGit(DesktopGitRequest::Push) Message::RunGit(DesktopGitRequest::Push),
true,
), ),
icon_control( utility_icon_control(
Icon::Refresh, Icon::Refresh,
"Synchronize with remote".to_owned(), "Synchronize with remote",
Message::RunGit(DesktopGitRequest::Sync) Message::RunGit(DesktopGitRequest::Sync),
true,
), ),
] ]
.spacing(2); .spacing(2);
if !form.conflicts.is_empty() { if !form.conflicts.is_empty() {
actions = actions.push(icon_control( actions = actions.push(utility_icon_control(
Icon::Check, Icon::Check,
"Resolve selected versions".to_owned(), "Resolve selected versions",
Message::ResolveGitConflicts, Message::ResolveGitConflicts,
true,
)); ));
} }
actions.push(done) actions
} }
} }
UtilityView::Otp(form) => { UtilityView::Otp(form) => {
if form.running { if form.running {
row![button("Working…"), done].spacing(8) row![utility_icon_control(
Icon::Refresh,
"Working…",
Message::RunOtpCode(false),
false,
)]
} else { } else {
let mut actions = row![ let mut actions = row![
icon_control( utility_icon_control(
Icon::Refresh, Icon::Refresh,
"Generate OTP code".to_owned(), "Generate OTP code",
Message::RunOtpCode(false) Message::RunOtpCode(false),
true,
), ),
icon_control( utility_icon_control(
Icon::Copy, Icon::Copy,
"Copy OTP code".to_owned(), "Copy OTP code",
Message::RunOtpCode(true) Message::RunOtpCode(true),
true,
), ),
icon_control( utility_icon_control(
Icon::Link, Icon::Link,
"Show provisioning URI".to_owned(), "Show provisioning URI",
Message::RunOtpUri { Message::RunOtpUri {
qr: false, qr: false,
copy: false, copy: false,
} },
true,
), ),
icon_control( utility_icon_control(
Icon::Copy, Icon::Copy,
"Copy provisioning URI".to_owned(), "Copy provisioning URI",
Message::RunOtpUri { Message::RunOtpUri {
qr: false, qr: false,
copy: true, copy: true,
} },
true,
), ),
icon_control( utility_icon_control(
Icon::Qr, Icon::Qr,
"Show provisioning QR code".to_owned(), "Show provisioning QR code",
Message::RunOtpUri { Message::RunOtpUri {
qr: true, qr: true,
copy: false, copy: false,
} },
true,
), ),
] ]
.spacing(2); .spacing(2);
if form.hotp_confirmation { if form.hotp_confirmation {
actions = actions.push( actions = actions.push(
button("Confirm HOTP counter advance").on_press(Message::ConfirmHotp), utility_button("Confirm HOTP counter advance")
.on_press(Message::ConfirmHotp),
); );
} }
actions actions
.push(icon_control( .push(utility_icon_control(
Icon::Down, Icon::Down,
"Import provisioning URI".to_owned(), "Import provisioning URI",
Message::SubmitOtpImport, Message::SubmitOtpImport,
true,
)) ))
.push(icon_control( .push(utility_icon_control(
Icon::Qr, Icon::Qr,
"Import provisioning QR image".to_owned(), "Import provisioning QR image",
Message::PickOtpQr, Message::PickOtpQr,
true,
)) ))
.push( .push(
button("Remove OTP") utility_button("Remove OTP")
.style(button::danger) .style(button::danger)
.on_press(Message::SubmitOtpRemoval), .on_press(Message::SubmitOtpRemoval),
) )
.push(done)
} }
} }
UtilityView::Kdbx(form) => { UtilityView::Kdbx(form) => row![utility_icon_control(
let import = button(if form.running { Icon::Down,
if form.running {
"Importing…" "Importing…"
} else if form.quick_add { } else if form.quick_add {
"Quick Add" "Quick add KeePass entries"
} else { } else {
"Import" "Import KeePass entries"
});
row![
if form.running {
import
} else {
import.on_press(Message::SubmitKdbxImport)
}, },
done, Message::SubmitKdbxImport,
] !form.running,
.spacing(8) )],
} UtilityView::About | UtilityView::Help => row![container(text("")).width(Length::Fill)],
UtilityView::About | UtilityView::Help => row![done],
}; };
container(
column![scrollable(content).height(Length::Fill), actions,] let header = container(
.spacing(12) row![
.padding(20), 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)
.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) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.style(entry_area_style)
.into() .into()
} }
@@ -5036,7 +5148,7 @@ fn search_option<'a>(
message: Message, message: Message,
running: bool, running: bool,
) -> iced::widget::Button<'a, Message> { ) -> iced::widget::Button<'a, Message> {
let option = button(text(format!( let option = utility_button(text(format!(
"[{}] {label}", "[{}] {label}",
if selected { "x" } else { " " } if selected { "x" } else { " " }
))); )));
@@ -6103,6 +6215,37 @@ mod tests {
let _view = app.view(); 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)] #[derive(Clone, Default)]
struct ManualClock(Arc<Mutex<Duration>>); struct ManualClock(Arc<Mutex<Duration>>);

View File

@@ -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 | | 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: 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: 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 | | 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 | | 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 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 the supported narrow window floor is 480 by 360 logical pixels. Iced/winit applies native
display scaling before layout. display scaling before layout.
- Compact toolbar controls use one 16-by-16 vector icon system with descriptive - Compact main and utility toolbar controls use one 16-by-16 vector icon system
delayed tooltips and registered shortcuts. Focused/selected rows use the with descriptive delayed tooltips and registered shortcuts. Every utility
theme's primary contrast pair; field labels remain visible as a non-colour cue. 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- - The app implements no animation or motion-driven state transition. The one-
second subscription updates lease, OTP, Git, and clipboard presentation state second subscription updates lease, OTP, Git, and clipboard presentation state
without moving focus or renewing authentication, so reduced-motion mode has without moving focus or renewing authentication, so reduced-motion mode has