Add UX enhancements: carousel, filter chips, command palette, and more

- Replace featured section Stack with AdwCarousel + indicator dots
- Convert category grid to horizontal scrollable filter chips
- Add grid/list view toggle for catalog with compact row layout
- Add quick launch button on library list rows
- Add stale catalog banner when data is older than 7 days
- Add command palette (Ctrl+K) for quick app search and launch
- Show specific app names in update notifications
- Add per-app auto-update toggle (skip updates switch)
- Add keyboard shortcut hints to button tooltips
- Add source trust badges (AppImageHub/Community) on catalog tiles
- Add undo-based uninstall with toast and record restoration
- Add type-to-search in library view
- Use human-readable catalog source labels
- Show Launch button for installed apps in catalog detail
- Replace external browser link with inline AppImage explainer dialog
This commit is contained in:
lashman
2026-03-01 00:39:43 +02:00
parent 4b939f044a
commit d11546efc6
25 changed files with 1711 additions and 481 deletions

View File

@@ -12,6 +12,7 @@ use crate::core::fuse::{self, FuseStatus};
use crate::core::integrator;
use crate::core::launcher::{self, SandboxMode};
use crate::core::notification;
use crate::core::sandbox;
use crate::core::security;
use crate::core::updater;
use crate::core::wayland::{self, WaylandStatus};
@@ -296,11 +297,41 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box {
}
}
// Windows equivalent hint for novice users
if let Some(equiv) = windows_equivalent(name) {
let equiv_label = gtk::Label::builder()
.label(equiv)
.css_classes(["caption", "dim-label"])
.halign(gtk::Align::Start)
.build();
text_col.append(&equiv_label);
}
text_col.append(&badge_box);
banner.append(&text_col);
banner
}
fn windows_equivalent(app_name: &str) -> Option<&'static str> {
match app_name.to_lowercase().as_str() {
s if s.contains("vlc") => Some("Similar to Windows Media Player"),
s if s.contains("gimp") => Some("Similar to Photoshop"),
s if s.contains("libreoffice") => Some("Similar to Microsoft Office"),
s if s.contains("firefox") => Some("Similar to Edge / Chrome"),
s if s.contains("chromium") || s.contains("brave") => Some("Similar to Chrome"),
s if s.contains("kdenlive") || s.contains("shotcut") => Some("Similar to Windows Video Editor"),
s if s.contains("krita") => Some("Similar to Paint / Photoshop"),
s if s.contains("thunderbird") => Some("Similar to Outlook"),
s if s.contains("telegram") || s.contains("signal") => Some("Similar to WhatsApp Desktop"),
s if s.contains("obs") => Some("Similar to OBS Studio (same app!)"),
s if s.contains("blender") => Some("Similar to Blender (same app!)"),
s if s.contains("audacity") => Some("Similar to Audacity (same app!)"),
s if s.contains("inkscape") => Some("Similar to Illustrator"),
s if s.contains("handbrake") => Some("Similar to HandBrake (same app!)"),
_ => None,
}
}
// ---------------------------------------------------------------------------
// Tab 1: Overview - about, description, links, updates, releases, usage,
// capabilities, file info
@@ -382,6 +413,35 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
about_group.add(&row);
}
// Categories display
if let Some(ref cats) = record.categories {
if !cats.is_empty() {
let cat_row = adw::ActionRow::builder()
.title("Categories")
.build();
let cat_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
cat_box.set_valign(gtk::Align::Center);
for cat in cats.split(';').filter(|c| !c.is_empty()) {
let friendly = match cat.trim() {
"AudioVideo" | "Audio" | "Video" => "Media",
"Development" => "Developer Tools",
"Education" | "Science" => "Science & Education",
"Game" => "Games",
"Graphics" => "Graphics",
"Network" => "Internet",
"Office" => "Office",
"System" => "System Tools",
"Utility" => "Utilities",
other => other,
};
let badge = widgets::status_badge(friendly, "neutral");
cat_box.append(&badge);
}
cat_row.add_suffix(&cat_box);
about_group.add(&cat_row);
}
}
inner.append(&about_group);
}
@@ -620,8 +680,9 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let row = adw::ActionRow::builder()
.title("Update method")
.subtitle(
"This app does not include update information. \
You will need to check for new versions manually."
"This app does not include automatic update information. \
To update manually, download a newer version from the \
developer's website and drag it into Driftwood."
)
.tooltip_text(
"AppImages can include built-in update information that tells Driftwood \
@@ -675,6 +736,22 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.build();
updates_group.add(&row);
}
// Per-app auto-update toggle (pinned = skip updates)
let pin_row = adw::SwitchRow::builder()
.title("Skip auto-updates")
.subtitle("When enabled, this app will be excluded from batch update checks")
.active(record.pinned)
.build();
{
let db_ref = db.clone();
let record_id = record.id;
pin_row.connect_active_notify(move |row| {
let _ = db_ref.set_pinned(record_id, row.is_active());
});
}
updates_group.add(&pin_row);
inner.append(&updates_group);
// -----------------------------------------------------------------------
@@ -837,8 +914,8 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.build();
let type_str = match record.appimage_type {
Some(1) => "Type 1 - older format, still widely supported",
Some(2) => "Type 2 - modern, compressed format",
Some(1) => "Legacy format - an older packaging style. Still works but less common.",
Some(2) => "Modern format - compressed and efficient. This is the standard format for most AppImages.",
_ => "Unknown type",
};
let type_row = adw::ActionRow::builder()
@@ -870,19 +947,19 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let sig_row = adw::ActionRow::builder()
.title("Verified by developer")
.subtitle(if record.has_signature {
"Signed by the developer"
"This app includes a developer signature, which helps verify it has not been tampered with."
} else {
"Not signed"
"This is normal for most AppImages and does not mean the app is unsafe."
})
.tooltip_text(
"This app was signed by its developer, which helps verify \
it hasn't been tampered with since it was published."
"Some developers sign their apps with a cryptographic key. \
This helps verify the file hasn't been modified since it was published."
)
.build();
let sig_badge = if record.has_signature {
widgets::status_badge("Signed", "success")
} else {
widgets::status_badge("Unsigned", "neutral")
widgets::status_badge("No signature", "neutral")
};
sig_badge.set_valign(gtk::Align::Center);
sig_row.add_suffix(&sig_badge);
@@ -1040,7 +1117,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Autostart toggle
let autostart_row = adw::SwitchRow::builder()
.title("Start at login")
.subtitle("Launch this app automatically when you log in")
.subtitle("Launch this app automatically when you log in, like a Windows Startup program")
.active(record.autostart)
.tooltip_text(
"Creates an autostart entry so this app launches \
@@ -1070,9 +1147,9 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
});
integration_group.add(&autostart_row);
// StartupWMClass row with editable override
// StartupWMClass row with editable override - hidden in Advanced expander
let wm_class_row = adw::EntryRow::builder()
.title("Window class (advanced)")
.title("Window class")
.text(record.startup_wm_class.as_deref().unwrap_or(""))
.show_apply_button(true)
.build();
@@ -1090,7 +1167,12 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
}
}
});
integration_group.add(&wm_class_row);
let advanced_expander = adw::ExpanderRow::builder()
.title("Advanced settings")
.show_enable_switch(false)
.build();
advanced_expander.add_row(&wm_class_row);
integration_group.add(&advanced_expander);
// System-wide install toggle
if record.integrated {
@@ -1409,11 +1491,11 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
let fuse_status = fuse_system.status.clone();
let fuse_row = adw::ActionRow::builder()
.title("App mounting")
.title("App startup system")
.subtitle(fuse_user_explanation(&fuse_status))
.tooltip_text(
"FUSE lets apps like AppImages run directly without unpacking first. \
Without it, apps still work but take a little longer to start."
"Most AppImages need a system component called FUSE to mount and run. \
This shows whether it is available."
)
.build();
let fuse_badge = widgets::status_badge_with_icon(
@@ -1434,12 +1516,14 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Per-app launch method
let appimage_path = std::path::Path::new(&record.path);
let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path);
let launch_method_subtitle = if app_fuse_status.as_str() == "extract_and_run" {
"This app unpacks itself each time it starts. This is slower than normal but works without extra system setup.".to_string()
} else {
format!("This app will launch using: {}", app_fuse_status.label())
};
let launch_method_row = adw::ActionRow::builder()
.title("Startup method")
.subtitle(&format!(
"This app will launch using: {}",
app_fuse_status.label()
))
.subtitle(&launch_method_subtitle)
.tooltip_text(
"AppImages can start two ways: mounting (fast, instant startup) or \
unpacking to a temporary folder first (slower, but works everywhere). \
@@ -1457,10 +1541,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Sandboxing group
let sandbox_group = adw::PreferencesGroup::builder()
.title("App Isolation")
.title("Security Restrictions")
.description(
"Restrict what this app can access on your system \
for extra security."
"Control what this app can access on your system. \
When enabled, the app can only reach your Documents and Downloads folders."
)
.build();
@@ -1490,8 +1574,51 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
)
.build();
// Profile status row - shows current sandbox profile info
let profile_row = adw::ActionRow::builder()
.title("Sandbox profile")
.build();
let app_name_for_sandbox = record
.app_name
.clone()
.unwrap_or_else(|| {
std::path::Path::new(&record.filename)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string()
});
// Show current profile status
let loaded_profile = sandbox::load_profile(db, &app_name_for_sandbox)
.ok()
.flatten();
let profile_badge = if loaded_profile.is_some() {
widgets::status_badge("Active", "success")
} else if current_mode == SandboxMode::Firejail {
widgets::status_badge("Default", "neutral")
} else {
widgets::status_badge("None", "dim")
};
profile_badge.set_valign(gtk::Align::Center);
profile_row.add_suffix(&profile_badge);
// Show total profile count across all apps in group description
let all_profiles = sandbox::list_profiles(db);
if !all_profiles.is_empty() {
sandbox_group.set_description(Some(&format!(
"Restrict what this app can access on your system \
for extra security. {} sandbox {} configured across all apps.",
all_profiles.len(),
if all_profiles.len() == 1 { "profile" } else { "profiles" },
)));
}
let record_id = record.id;
let db_ref = db.clone();
let sandbox_name = app_name_for_sandbox.clone();
let profile_row_ref = profile_row.clone();
firejail_row.connect_active_notify(move |row| {
let mode = if row.is_active() {
SandboxMode::Firejail
@@ -1501,9 +1628,72 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
if let Err(e) = db_ref.update_sandbox_mode(record_id, Some(mode.as_str())) {
log::warn!("Failed to update sandbox mode: {}", e);
}
// Auto-generate a default sandbox profile when enabling isolation
if mode == SandboxMode::Firejail {
let existing = sandbox::load_profile(&db_ref, &sandbox_name)
.ok()
.flatten();
if existing.is_none() {
let profile = sandbox::generate_default_profile(&sandbox_name);
match sandbox::save_profile(&db_ref, &profile) {
Ok(path) => {
log::info!("Created default sandbox profile at {}", path.display());
// Update badge to show profile is active
while let Some(child) = profile_row_ref.last_child() {
profile_row_ref.remove(&child);
}
let badge = widgets::status_badge("Active", "success");
badge.set_valign(gtk::Align::Center);
profile_row_ref.add_suffix(&badge);
}
Err(e) => log::warn!("Failed to create sandbox profile: {}", e),
}
}
}
});
sandbox_group.add(&firejail_row);
if firejail_available && current_mode == SandboxMode::Firejail {
// Show profile explanation when sandbox is active
if let Some(path) = sandbox::profile_path_for_app(&app_name_for_sandbox) {
profile_row.set_subtitle(&format!(
"Blocks access to most of your files, camera, microphone, USB devices, and other apps. Only Documents and Downloads folders are accessible.\n{}",
path.display()
));
} else {
profile_row.set_subtitle("Default restrictive profile will be created on next launch");
}
// Reset button to delete current profile and regenerate default
if let Some(ref profile) = loaded_profile {
if let Some(profile_id) = profile.id {
let reset_btn = gtk::Button::builder()
.icon_name("edit-clear-symbolic")
.tooltip_text("Reset to default profile")
.valign(gtk::Align::Center)
.css_classes(["flat"])
.build();
let db_reset = db.clone();
let reset_name = app_name_for_sandbox.clone();
reset_btn.connect_clicked(move |_btn| {
if let Err(e) = sandbox::delete_profile(&db_reset, profile_id) {
log::warn!("Failed to delete sandbox profile: {}", e);
return;
}
// Regenerate a fresh default
let profile = sandbox::generate_default_profile(&reset_name);
if let Err(e) = sandbox::save_profile(&db_reset, &profile) {
log::warn!("Failed to regenerate sandbox profile: {}", e);
} else {
log::info!("Reset sandbox profile for {}", reset_name);
}
});
profile_row.add_suffix(&reset_btn);
}
}
sandbox_group.add(&profile_row);
}
if !firejail_available {
let firejail_cmd = "sudo apt install firejail";
let info_row = adw::ActionRow::builder()
@@ -1892,18 +2082,19 @@ fn build_storage_tab(
}
if !fp.paths.is_empty() {
let categories = [
("Configuration", fp.config_size),
("Application data", fp.data_size),
("Cache", fp.cache_size),
("State", fp.state_size),
("Other", fp.other_size),
let categories: &[(&str, u64, &str)] = &[
("Configuration", fp.config_size, "Your preferences and settings for this app"),
("Application data", fp.data_size, "Files and data saved by this app"),
("Cache", fp.cache_size, "Temporary files that can be safely deleted"),
("State", fp.state_size, "Runtime state like window positions and recent files"),
("Other", fp.other_size, "Other files associated with this app"),
];
for (label, size) in &categories {
for (label, size, tooltip) in categories {
if *size > 0 {
let row = adw::ActionRow::builder()
.title(*label)
.subtitle(&widgets::format_size(*size as i64))
.tooltip_text(*tooltip)
.build();
size_group.add(&row);
}
@@ -2001,11 +2192,40 @@ fn build_storage_tab(
.valign(gtk::Align::Center)
.build();
row.add_suffix(&size_label);
// Open folder button
let open_btn = gtk::Button::builder()
.icon_name("folder-open-symbolic")
.tooltip_text("Open in file manager")
.valign(gtk::Align::Center)
.build();
open_btn.add_css_class("flat");
let path_str = dp.path.to_string_lossy().to_string();
open_btn.connect_clicked(move |_| {
let file = gio::File::for_path(&path_str);
let launcher = gtk::FileLauncher::new(Some(&file));
launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {});
});
row.add_suffix(&open_btn);
paths_group.add(&row);
}
}
inner.append(&paths_group);
// Backup estimate
let est_size = fp.config_size + fp.data_size + fp.state_size;
if est_size > 0 {
let estimate_row = adw::ActionRow::builder()
.title("Backup estimate")
.subtitle(&format!(
"Estimated backup size: {} (settings and data files)",
widgets::format_size(est_size as i64)
))
.build();
paths_group.add(&estimate_row);
}
// Backups group
inner.append(&build_backup_group(record.id, toast_overlay));
@@ -2357,19 +2577,19 @@ fn wayland_user_explanation(status: &WaylandStatus) -> &'static str {
fn fuse_user_explanation(status: &FuseStatus) -> &'static str {
match status {
FuseStatus::FullyFunctional =>
"Everything is set up - apps start instantly.",
"This app can start normally using your system's app loader.",
FuseStatus::Fuse3Only =>
"A small system component is missing. Most apps will still work, \
but some may need it. Copy the install command to fix this.",
"A system component is needed for this app to start normally. \
Without it, the app uses a slower startup method.",
FuseStatus::NoFusermount =>
"A system component is missing, so apps will take a little longer \
to start. They'll still work fine.",
"A system component is needed for this app to start normally. \
Without it, the app uses a slower startup method.",
FuseStatus::NoDevFuse =>
"Your system doesn't support instant app mounting. Apps will unpack \
before starting, which takes a bit longer.",
"A system component is needed for this app to start normally. \
Without it, the app uses a slower startup method.",
FuseStatus::MissingLibfuse2 =>
"A small system component is needed for fast startup. \
Copy the install command to fix this.",
"A system component is needed for this app to start normally. \
Without it, the app uses a slower startup method.",
}
}
@@ -2624,9 +2844,9 @@ pub fn show_uninstall_dialog_with_callback(
db: &Rc<Database>,
is_integrated: bool,
data_paths: &[(String, String, u64)],
on_complete: Option<Box<dyn FnOnce() + 'static>>,
on_complete: Option<Rc<dyn Fn() + 'static>>,
) {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
let name = record.app_name.as_deref().unwrap_or(&record.filename).to_string();
let dialog = adw::AlertDialog::builder()
.heading(&format!("Uninstall {}?", name))
.body("Select what to remove:")
@@ -2675,46 +2895,34 @@ pub fn show_uninstall_dialog_with_callback(
dialog.set_extra_child(Some(&extra));
let record_snapshot = record.clone();
let record_id = record.id;
let record_path = record.path.clone();
let db_ref = db.clone();
let toast_ref = toast_overlay.clone();
let on_complete = std::cell::Cell::new(on_complete);
dialog.connect_response(Some("uninstall"), move |_dlg, _response| {
// Remove integration if checked
if let Some(ref check) = integration_check {
if check.is_active() {
integrator::undo_all_modifications(&db_ref, record_id).ok();
if let Ok(Some(rec)) = db_ref.get_appimage_by_id(record_id) {
integrator::remove_integration(&rec).ok();
}
// Capture deletion choices from checkboxes before dialog closes
let delete_file = appimage_check.is_active();
let should_remove_integration = integration_check.as_ref().map_or(false, |c| c.is_active());
let paths_to_delete: Vec<String> = path_checks.iter()
.filter(|(check, _)| check.is_active())
.map(|(_, path)| path.clone())
.collect();
// Remove integration immediately (before DB delete, since CASCADE
// removes system_modifications entries we need for undo_all_modifications)
if should_remove_integration {
integrator::undo_all_modifications(&db_ref, record_id).ok();
if let Ok(Some(rec)) = db_ref.get_appimage_by_id(record_id) {
integrator::remove_integration(&rec).ok();
}
}
// Remove checked data paths
for (check, path) in &path_checks {
if check.is_active() {
let p = std::path::Path::new(path);
if p.is_dir() {
std::fs::remove_dir_all(p).ok();
} else if p.is_file() {
std::fs::remove_file(p).ok();
}
}
}
// Remove AppImage file if checked
if appimage_check.is_active() {
std::fs::remove_file(&record_path).ok();
}
// Remove from database
// Remove from database (so item vanishes from lists)
db_ref.remove_appimage(record_id).ok();
toast_ref.add_toast(adw::Toast::new("AppImage uninstalled"));
// Run the completion callback if provided
if let Some(cb) = on_complete.take() {
// Run the completion callback to refresh the library view
if let Some(ref cb) = on_complete {
cb();
}
@@ -2723,6 +2931,66 @@ pub fn show_uninstall_dialog_with_callback(
let nav: adw::NavigationView = nav.downcast().unwrap();
nav.pop();
}
// Show undo toast - file deletion is deferred until toast dismisses
let toast = adw::Toast::builder()
.title(&format!("{} uninstalled", name))
.button_label("Undo")
.timeout(7)
.build();
let undo_clicked = Rc::new(Cell::new(false));
// On Undo: restore record to DB, re-integrate if needed, refresh
{
let undo_flag = undo_clicked.clone();
let db_undo = db_ref.clone();
let snapshot = record_snapshot.clone();
let toast_undo = toast_ref.clone();
let on_complete_undo = on_complete.clone();
let name_undo = name.clone();
let was_integrated = should_remove_integration;
toast.connect_button_clicked(move |_| {
undo_flag.set(true);
db_undo.restore_appimage_record(&snapshot).ok();
// Re-integrate if it was previously integrated
if was_integrated {
integrator::integrate(&snapshot).ok();
}
if let Some(ref cb) = on_complete_undo {
cb();
}
toast_undo.add_toast(adw::Toast::new(&format!("{} restored", name_undo)));
});
}
// On dismiss (timeout or any reason): perform actual file deletions if not undone
{
let undo_flag = undo_clicked.clone();
let path = record_path.clone();
toast.connect_dismissed(move |_| {
if undo_flag.get() {
return; // Undo was clicked, nothing to delete
}
// Remove data paths
for data_path in &paths_to_delete {
let p = std::path::Path::new(data_path);
if p.is_dir() {
std::fs::remove_dir_all(p).ok();
} else if p.is_file() {
std::fs::remove_file(p).ok();
}
}
// Remove AppImage file
if delete_file {
std::fs::remove_file(&path).ok();
}
});
}
toast_ref.add_toast(toast);
});
dialog.present(Some(toast_overlay));