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,
ChevronDown,
ChevronRight,
Close,
Command,
Copy,
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(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<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 {
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 (186400)",
))
.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 (186400)").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<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 |
| --- | --- | --- | --- |
| 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