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), .style(entry_input_style),
if form.saving { utility_icon_control(
button("Choose…") Icon::Folder,
} else { "Choose password-store folder",
button("Choose…").on_press(Message::PickSettingsVault) Message::PickSettingsVault,
}, !form.saving,
] ),
.spacing(8), ]
) .align_y(iced::Alignment::Center)
.push(text("Default OpenPGP key fingerprint or identity")) .spacing(6),
.push( text("Default OpenPGP key fingerprint or identity").size(13),
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)", text_input("Timeout seconds", &form.authentication_timeout)
)) .on_input(Message::SettingsTimeoutChanged)
.push( .on_submit(Message::SaveSettings)
text_input("Timeout seconds", &form.authentication_timeout) .style(entry_input_style),
.on_input(Message::SettingsTimeoutChanged) ]
.on_submit(Message::SaveSettings), .spacing(8);
) content = content.push(
.push(text("Command-line tools")) container(general)
.push(text( .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),
"Clipboard cleanup remains {} seconds.", text(format!(
storage.clipboard_timeout().duration().as_secs() "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(
"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)) text_input("folder/account", &form.path)
.push(text("Entry path")) .on_input(Message::NewEntryPathChanged)
.push( .on_submit(Message::SubmitNewEntry)
text_input("folder/account", &form.path) .style(entry_input_style),
.on_input(Message::NewEntryPathChanged) );
.on_submit(Message::SubmitNewEntry), let generate = utility_button(text(format!(
);
let generate = 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,11 +4609,9 @@ 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)) "All repository, HTTPS transport, credential, merge, and conflict decisions are owned by crates/storage. No git process or credential helper is launched.",
.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 { if let Some(phase) = form.progress {
content = content.push(text(format!("Progress: {}", git_phase_name(phase)))); 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.", "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,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::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…" if form.saving {
"Validating settings…"
} else { } else {
"Save Settings" "Save settings"
}); },
row![ Message::SaveSettings,
if form.saving { !form.saving,
save )],
} else { UtilityView::Recipients(form) => row![utility_icon_control(
save.on_press(Message::SaveSettings) Icon::Check,
}, if form.running {
done, "Applying recipient policy…"
]
.spacing(8)
}
UtilityView::Recipients(form) => {
let apply = button(if form.running {
"Applying…"
} else { } else {
"Apply Recipient Policy" "Apply recipient policy"
}); },
row![ Message::SubmitRecipient,
if form.running { !form.running,
apply )],
} else { UtilityView::NewEntry(form) => row![utility_icon_control(
apply.on_press(Message::SubmitRecipient) Icon::Check,
}, if form.running {
done, "Preparing draft…"
]
.spacing(8)
}
UtilityView::NewEntry(form) => {
let create = button(if form.running {
"Preparing…"
} else { } else {
"Create Draft" "Create draft"
}); },
row![ Message::SubmitNewEntry,
if form.running { !form.running,
create )],
} else { UtilityView::Search(form) => row![utility_icon_control(
create.on_press(Message::SubmitNewEntry) Icon::Search,
}, if form.running {
done,
]
.spacing(8)
}
UtilityView::Search(form) => {
let submit = button(if form.running {
"Searching…" "Searching…"
} else { } else {
"Search" "Search"
}); },
row![ Message::SubmitSearch,
if form.running { !form.running,
submit )],
} else {
submit.on_press(Message::SubmitSearch)
},
done,
]
.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![ Message::SubmitKdbxImport,
if form.running { !form.running,
import )],
} else { UtilityView::About | UtilityView::Help => row![container(text("")).width(Length::Fill)],
import.on_press(Message::SubmitKdbxImport)
},
done,
]
.spacing(8)
}
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) .width(Length::Fill)
.height(Length::Fill) .style(entry_value_style);
.into() 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>( fn search_option<'a>(
@@ -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