Skip to main content

mimick/library/masonry/canvas/
interaction.rs

1use std::collections::HashSet;
2use std::rc::Rc;
3use std::sync::Arc;
4
5use gtk::prelude::*;
6use gtk::subclass::prelude::ObjectSubclassIsExt;
7
8use super::{
9    AssetContextMenuHandler, GridQuality, LibraryAssetModel, MasonryCanvas, ThumbnailCache, imp,
10    item_at_x, row_at_y,
11};
12use crate::app_context::AppContext;
13use crate::library::asset_object::AssetObject;
14
15impl Default for MasonryCanvas {
16    fn default() -> Self {
17        gtk::glib::Object::new()
18    }
19}
20
21impl MasonryCanvas {
22    pub fn new(
23        cache: Arc<ThumbnailCache>,
24        model: LibraryAssetModel,
25        selection: gtk::MultiSelection,
26    ) -> Self {
27        let canvas: Self = gtk::glib::Object::new();
28        let imp = canvas.imp();
29        let _ = imp.cache.set(cache);
30        let _ = imp.model.set(model.clone());
31        let _ = imp.selection.set(selection.clone());
32
33        connect_model_changes(&canvas, &model);
34        connect_selection_changes(&canvas, &selection);
35        canvas.install_gestures();
36        canvas
37    }
38
39    pub fn set_narrow(&self, narrow: bool) {
40        let imp = self.imp();
41        if imp.narrow.replace(narrow) != narrow {
42            imp.invalidate_layout();
43            self.queue_draw();
44        }
45    }
46
47    pub fn set_select_mode(&self, on: bool) {
48        self.imp().select_mode.set(on);
49    }
50
51    pub fn set_quality(&self, quality: GridQuality) {
52        let imp = self.imp();
53        if imp.quality.replace(quality) != quality {
54            self.queue_draw();
55        }
56    }
57
58    pub fn set_border_gap(&self, gap: f32) {
59        let imp = self.imp();
60        if (imp.border_gap.get() - gap).abs() > f32::EPSILON {
61            imp.border_gap.set(gap);
62            imp.invalidate_layout();
63            self.queue_draw();
64        }
65    }
66
67    pub fn set_border_color(&self, color: gdk4::RGBA) {
68        let imp = self.imp();
69        imp.border_color.set(Some(color));
70        if imp.border_gap.get() > 0.0 {
71            self.queue_draw();
72        }
73    }
74
75    pub fn set_activate_handler(&self, f: impl Fn(u32) + 'static) {
76        *self.imp().activate_handler.borrow_mut() = Some(Rc::new(f));
77    }
78
79    pub fn set_context_menu_handler(&self, handler: AssetContextMenuHandler) {
80        *self.imp().context_menu_handler.borrow_mut() = Some(handler);
81    }
82
83    pub fn set_select_mode_changer(&self, f: impl Fn(bool) + 'static) {
84        *self.imp().select_mode_changer.borrow_mut() = Some(Rc::new(f));
85    }
86
87    fn hit_test(&self, x: f64, y: f64) -> Option<u32> {
88        let rows = self.imp().rows.borrow();
89        let r = row_at_y(&rows, y as f32)?;
90        let row = &rows[r];
91        item_at_x(row, x as f32).map(|it| it.asset_index)
92    }
93
94    fn handle_primary_click(&self, gesture: &gtk::GestureClick, x: f64, y: f64) {
95        let Some(pos) = self.hit_test(x, y) else {
96            return;
97        };
98        let imp = self.imp();
99        let mods = gesture.current_event_state();
100        let ctrl = mods.contains(gtk::gdk::ModifierType::CONTROL_MASK);
101        let shift = mods.contains(gtk::gdk::ModifierType::SHIFT_MASK);
102        let Some(sel) = imp.selection.get() else {
103            return;
104        };
105
106        if shift {
107            // Shift+Click: select contiguous range [anchor..pos].
108            let anchor = imp.last_selected.get().unwrap_or(pos);
109            let lo = anchor.min(pos);
110            let hi = anchor.max(pos);
111            // Shift only: replace selection with range. Ctrl+Shift: union.
112            if !ctrl {
113                sel.unselect_all();
114            }
115            select_range(sel, lo, hi);
116            enable_select_mode_if_needed(imp, pos);
117            // Preserve anchor so repeated Shift+Clicks extend from the same origin.
118        } else if ctrl {
119            toggle_selection(sel, pos);
120            enable_select_mode_if_needed(imp, pos);
121            imp.last_selected.set(Some(pos));
122        } else if imp.select_mode.get() {
123            toggle_selection(sel, pos);
124            imp.last_selected.set(Some(pos));
125        } else if let Some(handler) = imp.activate_handler.borrow().clone() {
126            (*handler)(pos);
127        }
128    }
129
130    fn handle_secondary_click(&self, x: f64, y: f64) {
131        let Some(pos) = self.hit_test(x, y) else {
132            return;
133        };
134        let imp = self.imp();
135        if let Some(handler_cell) = imp.context_menu_handler.borrow().clone()
136            && let Some(cb) = handler_cell.borrow().as_ref()
137        {
138            (cb)(pos, x, y);
139        }
140    }
141
142    fn install_gestures(&self) {
143        self.add_controller(primary_click_controller(self));
144        self.add_controller(secondary_click_controller(self));
145    }
146
147    /// Install a `DragSource` for exporting original asset files via drag.
148    ///
149    /// Must be called after `new()` once the thumbnail cache is set up.
150    /// The drag source resolves the original file from either the local path
151    /// (for local/synced assets) or the lightbox preview cache (for remote assets
152    /// that have been viewed in full resolution).
153    pub fn install_drag_source(&self, ctx: Arc<AppContext>) {
154        let drag_source = gtk::DragSource::new();
155        drag_source.set_actions(gtk::gdk::DragAction::COPY);
156
157        let weak = self.downgrade();
158        let ctx_prepare = ctx.clone();
159        drag_source.connect_prepare(move |_source, x, y| {
160            let canvas = weak.upgrade()?;
161            let files = collect_drag_files(&canvas, x, y, &ctx_prepare)?;
162            Some(files_to_content_provider(files))
163        });
164
165        let weak2 = self.downgrade();
166        drag_source.connect_drag_begin(move |source, _drag| {
167            let Some(canvas) = weak2.upgrade() else {
168                return;
169            };
170            canvas.imp().drag_active.set(true);
171            let count = selected_count(&canvas);
172            if count > 1 {
173                source.set_icon(Some(&build_drag_badge(count)), 0, 0);
174            }
175        });
176
177        let weak3 = self.downgrade();
178        drag_source.connect_drag_end(move |_, _, _| {
179            if let Some(canvas) = weak3.upgrade() {
180                canvas.imp().drag_active.set(false);
181            }
182        });
183
184        self.add_controller(drag_source);
185    }
186}
187
188/// Collect gio::Files for the current drag operation (multi-select or single).
189fn collect_drag_files(
190    canvas: &MasonryCanvas,
191    x: f64,
192    y: f64,
193    ctx: &AppContext,
194) -> Option<Vec<gtk::gio::File>> {
195    let imp = canvas.imp();
196    let sel = imp.selection.get()?;
197    let model = imp.model.get()?;
198    let mut files = Vec::new();
199
200    if imp.select_mode.get() {
201        for i in 0..sel.n_items() {
202            if sel.is_selected(i)
203                && let Some(path) = resolve_drag_path(model, i, ctx)
204            {
205                files.push(gtk::gio::File::for_path(&path));
206            }
207        }
208    }
209
210    if files.is_empty() {
211        let pos = canvas.hit_test(x, y)?;
212        let path = resolve_drag_path(model, pos, ctx)?;
213        files.push(gtk::gio::File::for_path(&path));
214    }
215
216    if files.is_empty() { None } else { Some(files) }
217}
218
219fn files_to_content_provider(files: Vec<gtk::gio::File>) -> gtk::gdk::ContentProvider {
220    if files.len() == 1 {
221        gtk::gdk::ContentProvider::for_value(&files[0].to_value())
222    } else {
223        let file_list = gtk::gdk::FileList::from_array(&files);
224        gtk::gdk::ContentProvider::for_value(&file_list.to_value())
225    }
226}
227
228/// Count selected items, or 1 if not in select mode.
229fn selected_count(canvas: &MasonryCanvas) -> u32 {
230    let imp = canvas.imp();
231    if !imp.select_mode.get() {
232        return 1;
233    }
234    imp.selection
235        .get()
236        .map(|s| (0..s.n_items()).filter(|i| s.is_selected(*i)).count() as u32)
237        .unwrap_or(1)
238}
239
240/// Build a badge paintable showing the drag count.
241fn build_drag_badge(count: u32) -> gtk::WidgetPaintable {
242    let label = gtk::Label::builder()
243        .label(format!("{count} files"))
244        .css_classes(["mimick-drag-badge"])
245        .build();
246    let container = gtk::Box::builder()
247        .orientation(gtk::Orientation::Horizontal)
248        .css_classes(["mimick-drag-badge"])
249        .build();
250    container.append(&label);
251    gtk::WidgetPaintable::new(Some(&container))
252}
253
254fn connect_model_changes(canvas: &MasonryCanvas, model: &LibraryAssetModel) {
255    let weak = canvas.downgrade();
256    model.connect_items_changed(move |model, position, removed, added| {
257        if let Some(canvas) = weak.upgrade() {
258            handle_model_changed(&canvas, model, position, removed, added);
259        }
260    });
261}
262
263fn connect_selection_changes(canvas: &MasonryCanvas, selection: &gtk::MultiSelection) {
264    let weak = canvas.downgrade();
265    selection.connect_selection_changed(move |_, _, _| {
266        if let Some(canvas) = weak.upgrade() {
267            canvas.queue_draw();
268        }
269    });
270}
271
272fn handle_model_changed(
273    canvas: &MasonryCanvas,
274    model: &LibraryAssetModel,
275    position: u32,
276    removed: u32,
277    added: u32,
278) {
279    let imp = canvas.imp();
280    retain_failed_ids_for_model(imp, model);
281    log::debug!(
282        "masonry items_changed pos={} removed={} added={} model_n={}",
283        position,
284        removed,
285        added,
286        model.n_items(),
287    );
288    if added == 0 && removed > 0 && model.n_items() == 0 {
289        return;
290    }
291    imp.invalidate_layout();
292    canvas.queue_draw();
293}
294
295fn retain_failed_ids_for_model(imp: &imp::MasonryCanvas, model: &LibraryAssetModel) {
296    let mut current_ids: HashSet<String> = HashSet::with_capacity(model.n_items() as usize);
297    for i in 0..model.n_items() {
298        if let Some(obj) = model.item(i).and_downcast::<AssetObject>() {
299            current_ids.insert(obj.property::<String>("id"));
300        }
301    }
302    imp.failed
303        .borrow_mut()
304        .retain(|id| current_ids.contains(id));
305}
306
307fn enable_select_mode_if_needed(imp: &imp::MasonryCanvas, _pos: u32) {
308    if !imp.select_mode.get()
309        && let Some(changer) = imp.select_mode_changer.borrow().clone()
310    {
311        (*changer)(true);
312    }
313}
314
315fn primary_click_controller(canvas: &MasonryCanvas) -> gtk::GestureClick {
316    let primary = gtk::GestureClick::new();
317    primary.set_button(gtk::gdk::BUTTON_PRIMARY);
318    let weak = canvas.downgrade();
319    // Use `released` instead of `pressed` so a drag gesture that starts
320    // from the same press doesn't also trigger lightbox activation.
321    primary.connect_released(move |gesture, _, x, y| {
322        if let Some(canvas) = weak.upgrade() {
323            if canvas.imp().drag_active.get() {
324                return;
325            }
326            canvas.handle_primary_click(gesture, x, y);
327        }
328    });
329    primary
330}
331
332fn secondary_click_controller(canvas: &MasonryCanvas) -> gtk::GestureClick {
333    let secondary = gtk::GestureClick::new();
334    secondary.set_button(gtk::gdk::BUTTON_SECONDARY);
335    let weak = canvas.downgrade();
336    secondary.connect_pressed(move |_, _, x, y| {
337        if let Some(canvas) = weak.upgrade() {
338            canvas.handle_secondary_click(x, y);
339        }
340    });
341    secondary
342}
343
344fn toggle_selection(sel: &gtk::MultiSelection, pos: u32) {
345    if sel.is_selected(pos) {
346        sel.unselect_item(pos);
347    } else {
348        sel.select_item(pos, false);
349    }
350}
351
352fn select_range(sel: &gtk::MultiSelection, lo: u32, hi: u32) {
353    let count = hi - lo + 1;
354    sel.select_range(lo, count, false);
355}
356
357/// Resolve the drag-export path for the asset at `pos`.
358///
359/// Priority: local path > drag export cache (with original filename)
360///         > lightbox preview cache (hardlinked to proper name)
361///         > on-demand download.
362fn resolve_drag_path(
363    model: &LibraryAssetModel,
364    pos: u32,
365    ctx: &AppContext,
366) -> Option<std::path::PathBuf> {
367    let obj = model.item(pos).and_downcast::<AssetObject>()?;
368    let local_path = obj.property::<String>("local-path");
369    if !local_path.is_empty() {
370        let p = std::path::PathBuf::from(&local_path);
371        if p.exists() {
372            return Some(p);
373        }
374    }
375
376    let remote_id = obj.property::<String>("remote-id");
377    let filename = obj.property::<String>("filename");
378    if remote_id.is_empty() || filename.is_empty() {
379        return None;
380    }
381
382    let export_path = export_cache_path(&remote_id, &filename)?;
383    if export_path.exists() {
384        return Some(export_path);
385    }
386
387    if try_link_from_preview(&remote_id, &filename, &export_path) {
388        return Some(export_path);
389    }
390
391    if download_for_export(ctx, &remote_id, &filename, &export_path) {
392        Some(export_path)
393    } else {
394        None
395    }
396}
397
398fn export_cache_path(remote_id: &str, filename: &str) -> Option<std::path::PathBuf> {
399    let export_dir = crate::profile::cache_dir()?.join("drag_export");
400    let _ = std::fs::create_dir_all(&export_dir);
401    let safe_name = format!("{}_{}", &remote_id[..8.min(remote_id.len())], filename);
402    Some(export_dir.join(&safe_name))
403}
404
405fn try_link_from_preview(remote_id: &str, filename: &str, export_path: &std::path::Path) -> bool {
406    let ext = std::path::Path::new(filename)
407        .extension()
408        .and_then(std::ffi::OsStr::to_str)
409        .filter(|e| !e.is_empty())
410        .unwrap_or("bin");
411    let preview_dir = match crate::profile::cache_dir() {
412        Some(p) => p.join("preview"),
413        None => return false,
414    };
415    let cached = preview_dir.join(format!("{remote_id}.{ext}"));
416
417    cached.exists()
418        && (std::fs::hard_link(&cached, export_path).is_ok()
419            || std::fs::copy(&cached, export_path).is_ok())
420}
421
422fn download_for_export(
423    ctx: &AppContext,
424    remote_id: &str,
425    filename: &str,
426    export_path: &std::path::Path,
427) -> bool {
428    log::debug!("Drag export: downloading {} ({})", remote_id, filename);
429    let api = ctx.api_client.clone();
430    let id = remote_id.to_string();
431    let dest = export_path.to_path_buf();
432
433    let downloaded = std::thread::spawn(move || {
434        let rt = match tokio::runtime::Runtime::new() {
435            Ok(rt) => rt,
436            Err(e) => {
437                log::error!("Failed to create tokio runtime for drag download: {}", e);
438                return false;
439            }
440        };
441        match rt.block_on(api.download_original_to_file(&id, &dest, None)) {
442            Ok(()) => true,
443            Err(e) => {
444                log::warn!("Drag export download failed for {}: {}", id, e);
445                let _ = std::fs::remove_file(&dest);
446                false
447            }
448        }
449    })
450    .join()
451    .unwrap_or(false);
452
453    downloaded && export_path.exists()
454}