From 730452072f02d8d3bbc2e62c4b19ed0af325c860 Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 27 Feb 2026 23:45:01 +0200 Subject: [PATCH] Add batch selection and bulk operations to library view --- src/ui/library_view.rs | 94 ++++++++++++++++++++++++++++++++++++++++++ src/window.rs | 85 +++++++++++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/src/ui/library_view.rs b/src/ui/library_view.rs index b2a4c38..b447373 100644 --- a/src/ui/library_view.rs +++ b/src/ui/library_view.rs @@ -1,6 +1,7 @@ use adw::prelude::*; use gtk::accessible::Property as AccessibleProperty; use std::cell::{Cell, RefCell}; +use std::collections::HashSet; use std::rc::Rc; use crate::core::database::AppImageRecord; @@ -36,6 +37,12 @@ pub struct LibraryView { list_button: gtk::ToggleButton, records: Rc>>, search_empty_page: adw::StatusPage, + // Batch selection + selection_mode: Rc>, + selected_ids: Rc>>, + _action_bar: gtk::ActionBar, + select_button: gtk::ToggleButton, + selection_label: gtk::Label, } impl LibraryView { @@ -114,10 +121,18 @@ impl LibraryView { add_button.set_action_name(Some("win.show-drop-hint")); add_button.update_property(&[AccessibleProperty::Label("Add AppImage")]); + let select_button = gtk::ToggleButton::builder() + .icon_name("selection-mode-symbolic") + .tooltip_text(&i18n("Select multiple")) + .build(); + select_button.add_css_class("flat"); + select_button.update_property(&[AccessibleProperty::Label("Toggle selection mode")]); + let header_bar = adw::HeaderBar::builder() .title_widget(&title_widget) .build(); header_bar.pack_start(&add_button); + header_bar.pack_start(&select_button); header_bar.pack_end(&menu_button); header_bar.pack_end(&search_button); header_bar.pack_end(&view_toggle_box); @@ -254,12 +269,42 @@ impl LibraryView { .build(); stack.add_named(&list_scroll, Some("list")); + // --- Batch selection state --- + let selection_mode = Rc::new(Cell::new(false)); + let selected_ids: Rc>> = Rc::new(RefCell::new(HashSet::new())); + + // --- Bottom action bar (hidden until selection mode) --- + let action_bar = gtk::ActionBar::new(); + action_bar.set_visible(false); + + let selection_label = gtk::Label::builder() + .label("0 selected") + .build(); + action_bar.set_center_widget(Some(&selection_label)); + + let integrate_btn = gtk::Button::builder() + .label(&i18n("Integrate")) + .tooltip_text(&i18n("Add selected to app menu")) + .build(); + integrate_btn.add_css_class("suggested-action"); + integrate_btn.set_action_name(Some("win.batch-integrate")); + action_bar.pack_start(&integrate_btn); + + let delete_btn = gtk::Button::builder() + .label(&i18n("Delete")) + .tooltip_text(&i18n("Delete selected AppImages")) + .build(); + delete_btn.add_css_class("destructive-action"); + delete_btn.set_action_name(Some("win.batch-delete")); + action_bar.pack_end(&delete_btn); + // --- Assemble toolbar view --- let content_box = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .build(); content_box.append(&search_bar); content_box.append(&stack); + content_box.append(&action_bar); let toolbar_view = adw::ToolbarView::new(); toolbar_view.add_top_bar(&header_bar); @@ -394,6 +439,23 @@ impl LibraryView { }); } + // --- Wire up selection mode toggle --- + { + let selection_mode_ref = selection_mode.clone(); + let selected_ids_ref = selected_ids.clone(); + let action_bar_ref = action_bar.clone(); + let selection_label_ref = selection_label.clone(); + select_button.connect_toggled(move |btn| { + let active = btn.is_active(); + selection_mode_ref.set(active); + action_bar_ref.set_visible(active); + if !active { + selected_ids_ref.borrow_mut().clear(); + selection_label_ref.set_label("0 selected"); + } + }); + } + // --- Wire up empty state buttons --- scan_now_btn.set_action_name(Some("win.scan")); prefs_btn.set_action_name(Some("win.preferences")); @@ -412,6 +474,11 @@ impl LibraryView { list_button, records, search_empty_page, + selection_mode, + selected_ids, + _action_bar: action_bar, + select_button, + selection_label, } } @@ -590,6 +657,33 @@ impl LibraryView { } } + /// Get the set of currently selected record IDs. + pub fn selected_ids(&self) -> HashSet { + self.selected_ids.borrow().clone() + } + + /// Toggle selection of a record ID (used by card click in selection mode). + pub fn toggle_selection(&self, id: i64) { + let mut ids = self.selected_ids.borrow_mut(); + if ids.contains(&id) { + ids.remove(&id); + } else { + ids.insert(id); + } + let count = ids.len(); + self.selection_label.set_label(&format!("{} selected", count)); + } + + /// Whether the library is in selection mode. + pub fn in_selection_mode(&self) -> bool { + self.selection_mode.get() + } + + /// Exit selection mode. + pub fn exit_selection_mode(&self) { + self.select_button.set_active(false); + } + /// Programmatically set the view mode by toggling the linked buttons. pub fn set_view_mode(&self, mode: ViewMode) { match mode { diff --git a/src/window.rs b/src/window.rs index 9433c90..7aa5cb2 100644 --- a/src/window.rs +++ b/src/window.rs @@ -349,11 +349,19 @@ impl DriftwoodWindow { self.set_content(Some(&toast_overlay)); - // Wire up card/row activation to push detail view + // Wire up card/row activation to push detail view (or toggle selection) { let nav = navigation_view.clone(); let db = self.database().clone(); + let window_weak = self.downgrade(); library_view.connect_grid_activated(move |record_id| { + if let Some(window) = window_weak.upgrade() { + let lib_view = window.imp().library_view.get().unwrap(); + if lib_view.in_selection_mode() { + lib_view.toggle_selection(record_id); + return; + } + } if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { let page = detail_view::build_detail_page(&record, &db); nav.push(&page); @@ -363,7 +371,15 @@ impl DriftwoodWindow { { let nav = navigation_view.clone(); let db = self.database().clone(); + let window_weak = self.downgrade(); library_view.connect_list_activated(move |record_id| { + if let Some(window) = window_weak.upgrade() { + let lib_view = window.imp().library_view.get().unwrap(); + if lib_view.in_selection_mode() { + lib_view.toggle_selection(record_id); + return; + } + } if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { let page = detail_view::build_detail_page(&record, &db); nav.push(&page); @@ -583,6 +599,73 @@ impl DriftwoodWindow { show_drop_hint_action, ]); + // --- Batch actions --- + let batch_integrate_action = gio::SimpleAction::new("batch-integrate", None); + { + let window_weak = self.downgrade(); + batch_integrate_action.connect_activate(move |_, _| { + let Some(window) = window_weak.upgrade() else { return }; + let lib_view = window.imp().library_view.get().unwrap(); + let ids = lib_view.selected_ids(); + if ids.is_empty() { return; } + let db = window.database().clone(); + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + let mut count = 0; + for id in &ids { + if let Ok(Some(record)) = db.get_appimage_by_id(*id) { + if !record.integrated { + if let Ok(result) = integrator::integrate_tracked(&record, &db) { + let desktop_path = result.desktop_file_path.to_string_lossy().to_string(); + db.set_integrated(*id, true, Some(&desktop_path)).ok(); + count += 1; + } + } + } + } + lib_view.exit_selection_mode(); + if count > 0 { + toast_overlay.add_toast(adw::Toast::new(&format!("Integrated {} apps", count))); + if let Ok(records) = db.get_all_appimages() { + lib_view.populate(records); + } + } + }); + } + self.add_action(&batch_integrate_action); + + let batch_delete_action = gio::SimpleAction::new("batch-delete", None); + { + let window_weak = self.downgrade(); + batch_delete_action.connect_activate(move |_, _| { + let Some(window) = window_weak.upgrade() else { return }; + let lib_view = window.imp().library_view.get().unwrap(); + let ids = lib_view.selected_ids(); + if ids.is_empty() { return; } + let db = window.database().clone(); + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + let mut count = 0; + for id in &ids { + if let Ok(Some(record)) = db.get_appimage_by_id(*id) { + if record.integrated { + integrator::undo_all_modifications(&db, *id).ok(); + integrator::remove_integration(&record).ok(); + } + std::fs::remove_file(&record.path).ok(); + db.remove_appimage(*id).ok(); + count += 1; + } + } + lib_view.exit_selection_mode(); + if count > 0 { + toast_overlay.add_toast(adw::Toast::new(&format!("Deleted {} apps", count))); + if let Ok(records) = db.get_all_appimages() { + lib_view.populate(records); + } + } + }); + } + self.add_action(&batch_delete_action); + // --- Context menu actions (parameterized with record ID) --- let param_type = Some(glib::VariantTy::INT64);