Skip to main content

mimick/library/
actions.rs

1//! Selection-mode wiring and bulk batch operations.
2//!
3//! Manages multi-select mode toggling, selection-count tracking, and
4//! batch actions such as bulk download, bulk delete-to-trash, and
5//! bulk add-to-album for the library grid.
6
7use std::cell::Cell;
8use std::rc::Rc;
9
10use glib::clone;
11use gtk::prelude::*;
12use libadwaita::prelude::*;
13
14use crate::library::asset_object::AssetObject;
15
16use super::LOCAL_ID_PREFIX;
17use super::LibraryWindowUi;
18use super::download::start_download_group;
19
20pub(super) fn connect_select_mode(ui: Rc<LibraryWindowUi>, select_toggle: gtk::ToggleButton) {
21    let selection = ui.grid.selection.clone();
22    let bulk_bar = ui.bulk_bar.clone();
23    let count_label = ui.bulk_count_label.clone();
24
25    let refresh = {
26        let selection = selection.clone();
27        let bulk_bar = bulk_bar.clone();
28        let count_label = count_label.clone();
29        let select_toggle = select_toggle.clone();
30        Rc::new(move || {
31            let n = selection_count(&selection);
32            bulk_bar.set_reveal_child(select_toggle.is_active() && n > 0);
33            count_label.set_label(&format!("{} selected", n));
34        })
35    };
36
37    selection.connect_selection_changed({
38        let refresh = refresh.clone();
39        move |_, _, _| (*refresh)()
40    });
41
42    select_toggle.connect_toggled({
43        let selection = selection.clone();
44        let refresh = refresh.clone();
45        move |toggle| {
46            if !toggle.is_active() {
47                selection.unselect_all();
48            }
49            (*refresh)();
50        }
51    });
52
53    // Ctrl-hold transient checkboxes: pressing Ctrl reveals the selection UI;
54    // releasing Ctrl without having selected anything dismisses it again.
55    // Ctrl+click then commits a selection (handled separately in grid_view)
56    // and the release no longer collapses select mode.
57    let transient = Rc::new(Cell::new(false));
58    let key_controller = gtk::EventControllerKey::new();
59    key_controller.set_propagation_phase(gtk::PropagationPhase::Capture);
60    key_controller.connect_key_pressed({
61        let select_toggle = select_toggle.clone();
62        let transient = transient.clone();
63        move |_, keyval, _, _| {
64            if keyval == gtk::gdk::Key::Escape && select_toggle.is_active() {
65                select_toggle.set_active(false);
66                transient.set(false);
67                return glib::Propagation::Stop;
68            }
69            if matches!(keyval, gtk::gdk::Key::Control_L | gtk::gdk::Key::Control_R)
70                && !select_toggle.is_active()
71            {
72                select_toggle.set_active(true);
73                transient.set(true);
74            }
75            glib::Propagation::Proceed
76        }
77    });
78    key_controller.connect_key_released({
79        let select_toggle = select_toggle.clone();
80        let selection = selection.clone();
81        let transient = transient.clone();
82        move |_, keyval, _, _| {
83            if !matches!(keyval, gtk::gdk::Key::Control_L | gtk::gdk::Key::Control_R)
84                || !transient.get()
85            {
86                return;
87            }
88            if selection.selection().size() == 0 {
89                select_toggle.set_active(false);
90            }
91            transient.set(false);
92        }
93    });
94    ui.window.add_controller(key_controller);
95}
96
97pub(super) fn connect_bulk_actions(
98    ui: Rc<LibraryWindowUi>,
99    delete_btn: gtk::Button,
100    download_btn: gtk::Button,
101    clear_btn: gtk::Button,
102) {
103    clear_btn.connect_clicked(clone!(
104        #[strong]
105        ui,
106        move |_| {
107            ui.grid.selection.unselect_all();
108            ui.select_toggle.set_active(false);
109        }
110    ));
111
112    download_btn.connect_clicked(clone!(
113        #[strong]
114        ui,
115        move |_| {
116            let downloads: Vec<(String, String)> = collect_selected_assets(&ui)
117                .into_iter()
118                .filter(|(asset_id, _)| !asset_id.starts_with(LOCAL_ID_PREFIX))
119                .collect();
120            if !downloads.is_empty() {
121                start_download_group(ui.clone(), downloads);
122            }
123        }
124    ));
125
126    delete_btn.connect_clicked(clone!(
127        #[strong]
128        ui,
129        move |_| {
130            confirm_and_bulk_delete(ui.clone());
131        }
132    ));
133}
134
135pub(super) fn selection_count(selection: &gtk::MultiSelection) -> u32 {
136    let bitset = selection.selection();
137    bitset.size() as u32
138}
139
140pub(super) fn collect_selected_assets(ui: &Rc<LibraryWindowUi>) -> Vec<(String, String)> {
141    let bitset = ui.grid.selection.selection();
142    let mut out = Vec::new();
143    let Some((mut iter, first)) = gtk::BitsetIter::init_first(&bitset) else {
144        return out;
145    };
146    let mut pos = Some(first);
147    while let Some(p) = pos {
148        if let Some(item) = ui.grid.model.item(p).and_downcast::<AssetObject>() {
149            out.push((
150                item.property::<String>("id"),
151                item.property::<String>("filename"),
152            ));
153        }
154        pos = iter.next();
155    }
156    out
157}
158
159fn confirm_and_bulk_delete(ui: Rc<LibraryWindowUi>) {
160    let assets = collect_selected_assets(&ui);
161    let remote_ids: Vec<String> = assets
162        .iter()
163        .filter(|(id, _)| !id.starts_with(LOCAL_ID_PREFIX))
164        .map(|(id, _)| id.clone())
165        .collect();
166    if remote_ids.is_empty() {
167        return;
168    }
169
170    let dialog = libadwaita::AlertDialog::builder()
171        .heading(format!("Move {} item(s) to trash?", remote_ids.len()))
172        .body("Items can be restored from the Immich trash.")
173        .build();
174    dialog.add_response("cancel", "Cancel");
175    dialog.add_response("delete", "Move to trash");
176    dialog.set_response_appearance("delete", libadwaita::ResponseAppearance::Destructive);
177    dialog.set_default_response(Some("cancel"));
178    dialog.set_close_response("cancel");
179
180    let ui_for_choice = ui.clone();
181    dialog.connect_response(None, move |dlg, response| {
182        if response != "delete" {
183            return;
184        }
185        let ui = ui_for_choice.clone();
186        let ids = remote_ids.clone();
187        glib::MainContext::default().spawn_local(async move {
188            match ui.ctx.api_client.delete_assets(&ids).await {
189                Ok(()) => {
190                    ui.grid.selection.unselect_all();
191                    ui.select_toggle.set_active(false);
192                    super::refresh_library_after_mutation(ui.clone(), true);
193                }
194                Err(err) => {
195                    log::error!("Bulk delete failed: {}", err);
196                }
197            }
198        });
199        dlg.close();
200    });
201    dialog.present(Some(&ui.window));
202}