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

@@ -232,9 +232,10 @@ impl DriftwoodWindow {
.build();
let drop_overlay_subtitle = gtk::Label::builder()
.label(&i18n("Drop a file here or click to browse"))
.label(&i18n("Drop an AppImage file (.AppImage) here, or click to browse your files"))
.css_classes(["body", "dimmed"])
.halign(gtk::Align::Center)
.wrap(true)
.build();
// The card itself - acts as a clickable button to open file picker
@@ -1061,8 +1062,8 @@ impl DriftwoodWindow {
&db,
is_integrated,
&fp_paths,
Some(Box::new(move || {
// Refresh the library view after uninstall
Some(Rc::new(move || {
// Refresh the library view after uninstall (or undo)
if let Some(lib_view) = window_ref.imp().library_view.get() {
if let Ok(records) = db_refresh.get_all_appimages() {
lib_view.populate(records);
@@ -1112,6 +1113,17 @@ impl DriftwoodWindow {
}
self.add_action(&show_updates_action);
// Command palette (Ctrl+K)
let palette_action = gio::SimpleAction::new("command-palette", None);
{
let window_weak = self.downgrade();
palette_action.connect_activate(move |_, _| {
let Some(window) = window_weak.upgrade() else { return };
window.show_command_palette();
});
}
self.add_action(&palette_action);
// Keyboard shortcuts
if let Some(app) = self.application() {
let gtk_app = app.downcast_ref::<gtk::Application>().unwrap();
@@ -1124,6 +1136,7 @@ impl DriftwoodWindow {
gtk_app.set_accels_for_action("win.show-installed", &["<Control>1"]);
gtk_app.set_accels_for_action("win.show-catalog", &["<Control>2"]);
gtk_app.set_accels_for_action("win.show-updates", &["<Control>3"]);
gtk_app.set_accels_for_action("win.command-palette", &["<Control>k"]);
}
}
@@ -1149,6 +1162,14 @@ impl DriftwoodWindow {
// Scan on startup if enabled in preferences
if self.settings().boolean("auto-scan-on-startup") {
if let Some(toast_overlay) = self.imp().toast_overlay.get() {
toast_overlay.add_toast(
adw::Toast::builder()
.title(&i18n("Scanning for apps in your configured folders..."))
.timeout(2)
.build(),
);
}
self.trigger_scan();
}
@@ -1206,14 +1227,31 @@ impl DriftwoodWindow {
};
if should_check {
let settings_save = settings_upd.clone();
let update_toast = self.imp().toast_overlay.get().cloned();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("DB open failed");
update_dialog::batch_check_updates(&bg_db)
update_dialog::batch_check_updates_detailed(&bg_db)
})
.await;
if let Ok(count) = result {
if let Ok((count, names)) = result {
log::info!("Background update check: {} updates available", count);
if count > 0 {
if let Some(toast_overlay) = update_toast {
let title = if names.len() <= 3 {
format!("Updates available: {}", names.join(", "))
} else {
format!("{} app updates available ({}, ...)",
count, names[..2].join(", "))
};
let toast = adw::Toast::builder()
.title(&title)
.button_label("View")
.action_name("win.show-updates")
.build();
toast_overlay.add_toast(toast);
}
}
}
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
settings_save.set_string("last-update-check", &now).ok();
@@ -1643,6 +1681,174 @@ impl DriftwoodWindow {
}
}
fn show_command_palette(&self) {
let db = self.database().clone();
let dialog = adw::Dialog::builder()
.title("Quick Launch")
.content_width(450)
.content_height(400)
.build();
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
toolbar.add_top_bar(&header);
let content_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.margin_start(12)
.margin_end(12)
.margin_top(8)
.margin_bottom(8)
.build();
let search_entry = gtk::SearchEntry::builder()
.placeholder_text(&i18n("Type to search installed and catalog apps..."))
.hexpand(true)
.build();
content_box.append(&search_entry);
let scrolled = gtk::ScrolledWindow::builder()
.vexpand(true)
.build();
let results_list = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::Single)
.css_classes(["boxed-list"])
.build();
scrolled.set_child(Some(&results_list));
content_box.append(&scrolled);
// Populate results based on search
let db_ref = db.clone();
let results_ref = results_list.clone();
let dialog_ref = dialog.clone();
let window_weak = self.downgrade();
let update_results = std::rc::Rc::new(move |query: &str| {
// Clear existing
while let Some(child) = results_ref.first_child() {
results_ref.remove(&child);
}
if query.is_empty() {
return;
}
let q = query.to_lowercase();
// Search installed apps
let installed = db_ref.get_all_appimages().unwrap_or_default();
let mut count = 0;
for record in &installed {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
if name.to_lowercase().contains(&q) && count < 10 {
let row = adw::ActionRow::builder()
.title(name)
.subtitle(&i18n("Installed - click to launch"))
.activatable(true)
.build();
let icon = widgets::app_icon(
record.icon_path.as_deref(), name, 32,
);
row.add_prefix(&icon);
let play_icon = gtk::Image::from_icon_name("media-playback-start-symbolic");
row.add_suffix(&play_icon);
let record_id = record.id;
let dialog_c = dialog_ref.clone();
let window_w = window_weak.clone();
row.connect_activated(move |_| {
dialog_c.close();
if let Some(win) = window_w.upgrade() {
gio::prelude::ActionGroupExt::activate_action(
&win, "launch-appimage", Some(&record_id.to_variant()),
);
}
});
results_ref.append(&row);
count += 1;
}
}
// Search catalog apps
if let Ok(catalog_results) = db_ref.search_catalog(
query, None, 10, 0,
crate::core::database::CatalogSortOrder::PopularityDesc,
) {
for app in &catalog_results {
if count >= 15 { break; }
let row = adw::ActionRow::builder()
.title(&app.name)
.subtitle(&i18n("Catalog - click to view"))
.activatable(true)
.build();
let icon = widgets::app_icon(None, &app.name, 32);
row.add_prefix(&icon);
let nav_icon = gtk::Image::from_icon_name("go-next-symbolic");
row.add_suffix(&nav_icon);
let app_id = app.id;
let dialog_c = dialog_ref.clone();
let window_w = window_weak.clone();
let db_c = db_ref.clone();
row.connect_activated(move |_| {
dialog_c.close();
if let Some(win) = window_w.upgrade() {
// Switch to catalog tab
if let Some(vs) = win.imp().view_stack.get() {
vs.set_visible_child_name("catalog");
}
// Navigate to the app detail
if let Ok(Some(catalog_app)) = db_c.get_catalog_app(app_id) {
if let Some(toast) = win.imp().toast_overlay.get() {
let detail = crate::ui::catalog_detail::build_catalog_detail_page(
&catalog_app, &db_c, toast,
);
// Push onto the catalog NavigationView
// The catalog page is a NavigationView inside the ViewStack
if let Some(vs) = win.imp().view_stack.get() {
if let Some(child) = vs.child_by_name("catalog") {
if let Ok(nav) = child.downcast::<adw::NavigationView>() {
nav.push(&detail);
}
}
}
}
}
}
});
results_ref.append(&row);
count += 1;
}
}
if count == 0 {
let row = adw::ActionRow::builder()
.title(&i18n("No results found"))
.sensitive(false)
.build();
results_ref.append(&row);
}
});
{
let update_fn = update_results.clone();
search_entry.connect_search_changed(move |entry| {
let query = entry.text().to_string();
update_fn(&query);
});
}
toolbar.set_content(Some(&content_box));
dialog.set_child(Some(&toolbar));
dialog.present(Some(self));
// Focus the search entry after presenting
search_entry.grab_focus();
}
fn show_shortcuts_dialog(&self) {
let dialog = adw::Dialog::builder()
.title("Keyboard Shortcuts")
@@ -1674,6 +1880,7 @@ impl DriftwoodWindow {
nav_group.add(&shortcut_row("Ctrl+1", "Installed"));
nav_group.add(&shortcut_row("Ctrl+2", "Catalog"));
nav_group.add(&shortcut_row("Ctrl+3", "Updates"));
nav_group.add(&shortcut_row("Ctrl+K", "Quick Launch"));
nav_group.add(&shortcut_row("Ctrl+F", "Search"));
nav_group.add(&shortcut_row("Ctrl+D", "Dashboard"));
nav_group.add(&shortcut_row("Ctrl+,", "Preferences"));