mimick/library/masonry/canvas/
interaction.rs1use 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: >k::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 let anchor = imp.last_selected.get().unwrap_or(pos);
109 let lo = anchor.min(pos);
110 let hi = anchor.max(pos);
111 if !ctrl {
113 sel.unselect_all();
114 }
115 select_range(sel, lo, hi);
116 enable_select_mode_if_needed(imp, pos);
117 } 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 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
188fn 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
228fn 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
240fn 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: >k::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 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: >k::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: >k::MultiSelection, lo: u32, hi: u32) {
353 let count = hi - lo + 1;
354 sel.select_range(lo, count, false);
355}
356
357fn 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}