Add batch selection and bulk operations to library view
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk::accessible::Property as AccessibleProperty;
|
use gtk::accessible::Property as AccessibleProperty;
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use crate::core::database::AppImageRecord;
|
use crate::core::database::AppImageRecord;
|
||||||
@@ -36,6 +37,12 @@ pub struct LibraryView {
|
|||||||
list_button: gtk::ToggleButton,
|
list_button: gtk::ToggleButton,
|
||||||
records: Rc<RefCell<Vec<AppImageRecord>>>,
|
records: Rc<RefCell<Vec<AppImageRecord>>>,
|
||||||
search_empty_page: adw::StatusPage,
|
search_empty_page: adw::StatusPage,
|
||||||
|
// Batch selection
|
||||||
|
selection_mode: Rc<Cell<bool>>,
|
||||||
|
selected_ids: Rc<RefCell<HashSet<i64>>>,
|
||||||
|
_action_bar: gtk::ActionBar,
|
||||||
|
select_button: gtk::ToggleButton,
|
||||||
|
selection_label: gtk::Label,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LibraryView {
|
impl LibraryView {
|
||||||
@@ -114,10 +121,18 @@ impl LibraryView {
|
|||||||
add_button.set_action_name(Some("win.show-drop-hint"));
|
add_button.set_action_name(Some("win.show-drop-hint"));
|
||||||
add_button.update_property(&[AccessibleProperty::Label("Add AppImage")]);
|
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()
|
let header_bar = adw::HeaderBar::builder()
|
||||||
.title_widget(&title_widget)
|
.title_widget(&title_widget)
|
||||||
.build();
|
.build();
|
||||||
header_bar.pack_start(&add_button);
|
header_bar.pack_start(&add_button);
|
||||||
|
header_bar.pack_start(&select_button);
|
||||||
header_bar.pack_end(&menu_button);
|
header_bar.pack_end(&menu_button);
|
||||||
header_bar.pack_end(&search_button);
|
header_bar.pack_end(&search_button);
|
||||||
header_bar.pack_end(&view_toggle_box);
|
header_bar.pack_end(&view_toggle_box);
|
||||||
@@ -254,12 +269,42 @@ impl LibraryView {
|
|||||||
.build();
|
.build();
|
||||||
stack.add_named(&list_scroll, Some("list"));
|
stack.add_named(&list_scroll, Some("list"));
|
||||||
|
|
||||||
|
// --- Batch selection state ---
|
||||||
|
let selection_mode = Rc::new(Cell::new(false));
|
||||||
|
let selected_ids: Rc<RefCell<HashSet<i64>>> = 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 ---
|
// --- Assemble toolbar view ---
|
||||||
let content_box = gtk::Box::builder()
|
let content_box = gtk::Box::builder()
|
||||||
.orientation(gtk::Orientation::Vertical)
|
.orientation(gtk::Orientation::Vertical)
|
||||||
.build();
|
.build();
|
||||||
content_box.append(&search_bar);
|
content_box.append(&search_bar);
|
||||||
content_box.append(&stack);
|
content_box.append(&stack);
|
||||||
|
content_box.append(&action_bar);
|
||||||
|
|
||||||
let toolbar_view = adw::ToolbarView::new();
|
let toolbar_view = adw::ToolbarView::new();
|
||||||
toolbar_view.add_top_bar(&header_bar);
|
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 ---
|
// --- Wire up empty state buttons ---
|
||||||
scan_now_btn.set_action_name(Some("win.scan"));
|
scan_now_btn.set_action_name(Some("win.scan"));
|
||||||
prefs_btn.set_action_name(Some("win.preferences"));
|
prefs_btn.set_action_name(Some("win.preferences"));
|
||||||
@@ -412,6 +474,11 @@ impl LibraryView {
|
|||||||
list_button,
|
list_button,
|
||||||
records,
|
records,
|
||||||
search_empty_page,
|
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<i64> {
|
||||||
|
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.
|
/// Programmatically set the view mode by toggling the linked buttons.
|
||||||
pub fn set_view_mode(&self, mode: ViewMode) {
|
pub fn set_view_mode(&self, mode: ViewMode) {
|
||||||
match mode {
|
match mode {
|
||||||
|
|||||||
@@ -349,11 +349,19 @@ impl DriftwoodWindow {
|
|||||||
|
|
||||||
self.set_content(Some(&toast_overlay));
|
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 nav = navigation_view.clone();
|
||||||
let db = self.database().clone();
|
let db = self.database().clone();
|
||||||
|
let window_weak = self.downgrade();
|
||||||
library_view.connect_grid_activated(move |record_id| {
|
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) {
|
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
|
||||||
let page = detail_view::build_detail_page(&record, &db);
|
let page = detail_view::build_detail_page(&record, &db);
|
||||||
nav.push(&page);
|
nav.push(&page);
|
||||||
@@ -363,7 +371,15 @@ impl DriftwoodWindow {
|
|||||||
{
|
{
|
||||||
let nav = navigation_view.clone();
|
let nav = navigation_view.clone();
|
||||||
let db = self.database().clone();
|
let db = self.database().clone();
|
||||||
|
let window_weak = self.downgrade();
|
||||||
library_view.connect_list_activated(move |record_id| {
|
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) {
|
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
|
||||||
let page = detail_view::build_detail_page(&record, &db);
|
let page = detail_view::build_detail_page(&record, &db);
|
||||||
nav.push(&page);
|
nav.push(&page);
|
||||||
@@ -583,6 +599,73 @@ impl DriftwoodWindow {
|
|||||||
show_drop_hint_action,
|
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) ---
|
// --- Context menu actions (parameterized with record ID) ---
|
||||||
let param_type = Some(glib::VariantTy::INT64);
|
let param_type = Some(glib::VariantTy::INT64);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user