Skip to main content

mimick/library/masonry/
canvas.rs

1//! Justified-row masonry layout for the photos grid.
2
3use std::rc::Rc;
4
5use gtk::glib;
6use gtk::prelude::*;
7use gtk::subclass::prelude::*;
8use libadwaita::prelude::*;
9
10use super::grid_view::AssetContextMenuHandler;
11use crate::api_client::ThumbnailSize;
12use crate::library::asset_model::LibraryAssetModel;
13use crate::library::asset_object::AssetObject;
14use crate::library::thumbnail_cache::ThumbnailCache;
15
16type ActivateHandler = Rc<dyn Fn(u32)>;
17type SelectModeChanger = Rc<dyn Fn(bool)>;
18
19pub(super) const CORNER_RADIUS: f32 = 0.0;
20
21const VIEWPORTS_BEHIND: f32 = 2.0;
22const VIEWPORTS_AHEAD: f32 = 4.0;
23
24use super::layout::{
25    LaidItem, LaidRow, LayoutConfig, first_row_at_or_after, item_at_x, pack_rows, row_at_y,
26};
27pub use super::quality::GridQuality;
28use super::quality::{bucket_for_row_height, fallback_bucket};
29
30mod interaction;
31
32mod imp {
33    use std::cell::{Cell, OnceCell, RefCell};
34    use std::collections::HashSet;
35    use std::sync::Arc;
36
37    use gtk::glib;
38    use gtk::graphene::{Rect, Size};
39    use gtk::gsk::RoundedRect;
40    use gtk::prelude::*;
41    use gtk::subclass::prelude::*;
42
43    use super::{
44        ActivateHandler, AssetContextMenuHandler, AssetObject, CORNER_RADIUS, GridQuality,
45        LaidItem, LaidRow, LayoutConfig, LibraryAssetModel, SelectModeChanger, ThumbnailCache,
46        ThumbnailSize, VIEWPORTS_AHEAD, VIEWPORTS_BEHIND, accent_bg_color, bucket_for_asset,
47        bucket_for_row_height, cached_texture, collect_dims, finish_masonry_load,
48        first_row_at_or_after, load_masonry_asset, load_target_for, pack_rows, placeholder_color,
49        resolve_symbolic_icon, resolve_video_icon,
50    };
51
52    pub(super) enum PaintResult {
53        Hit,
54        Miss,
55    }
56
57    struct SnapshotCtx<'a> {
58        model: &'a LibraryAssetModel,
59        cache: &'a Arc<ThumbnailCache>,
60        placeholder: gdk4::RGBA,
61        select_tint: gdk4::RGBA,
62        selection: Option<&'a gtk::MultiSelection>,
63    }
64
65    #[derive(Default)]
66    pub(super) struct SnapshotStats {
67        pub(super) hits: usize,
68        pub(super) misses: usize,
69        pub(super) painted: usize,
70    }
71
72    struct SnapshotViewport {
73        scroll_y: f32,
74        center: f32,
75        top: f32,
76        bottom: f32,
77        band_top: f32,
78        band_bottom: f32,
79    }
80
81    pub(super) struct LoadRequest {
82        pub(super) priority: f32,
83        pub(super) asset_id: String,
84        pub(super) bucket: ThumbnailSize,
85        pub(super) local_path: String,
86        pub(super) is_local: bool,
87    }
88
89    #[derive(Default)]
90    pub struct MasonryCanvas {
91        pub model: OnceCell<LibraryAssetModel>,
92        pub cache: OnceCell<Arc<ThumbnailCache>>,
93        pub selection: OnceCell<gtk::MultiSelection>,
94        pub narrow: Cell<bool>,
95        pub select_mode: Cell<bool>,
96        pub quality: Cell<GridQuality>,
97        pub rows: RefCell<Vec<LaidRow>>,
98        pub cached_width: Cell<f32>,
99        pub layout_h: Cell<f32>,
100        pub pending: RefCell<HashSet<String>>,
101        /// Permanently failed ids; skipped to avoid re-queueing every frame.
102        pub failed: RefCell<HashSet<String>>,
103        pub vadjustment: RefCell<Option<gtk::Adjustment>>,
104        pub activate_handler: RefCell<Option<ActivateHandler>>,
105        pub context_menu_handler: RefCell<Option<AssetContextMenuHandler>>,
106        pub select_mode_changer: RefCell<Option<SelectModeChanger>>,
107        /// Anchor position for Shift+Click range selection.
108        pub last_selected: Cell<Option<u32>>,
109        pub permission_warned: Cell<bool>,
110        /// Cached video badge icon paintable, resolved lazily from the icon theme.
111        pub video_icon: OnceCell<gdk4::Paintable>,
112        /// Cached checkbox icon (selected state).
113        pub check_icon: OnceCell<gdk4::Paintable>,
114        /// Cached checkbox icon (unselected state).
115        pub uncheck_icon: OnceCell<gdk4::Paintable>,
116        /// True while a drag-out operation is in progress; suppresses click-to-activate.
117        pub drag_active: Cell<bool>,
118        /// Configurable gap between grid tiles, in pixels.
119        pub border_gap: Cell<f32>,
120        /// Color painted behind tiles so the gap appears as a border.
121        pub border_color: Cell<Option<gdk4::RGBA>>,
122    }
123
124    #[glib::object_subclass]
125    impl ObjectSubclass for MasonryCanvas {
126        const NAME: &str = "MimickMasonryCanvas";
127        type Type = super::MasonryCanvas;
128        type ParentType = gtk::Widget;
129    }
130
131    impl ObjectImpl for MasonryCanvas {
132        fn constructed(&self) {
133            self.parent_constructed();
134            self.obj().add_css_class("mimick-masonry-canvas");
135            self.cached_width.set(-1.0);
136        }
137    }
138
139    impl WidgetImpl for MasonryCanvas {
140        fn request_mode(&self) -> gtk::SizeRequestMode {
141            gtk::SizeRequestMode::HeightForWidth
142        }
143
144        fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) {
145            match orientation {
146                gtk::Orientation::Horizontal => (120, 200, -1, -1),
147                _ => {
148                    let width = if for_size > 0 {
149                        for_size as f32
150                    } else {
151                        self.cached_width.get().max(120.0)
152                    };
153                    let h = self.layout_for_width(width);
154                    let h_i = h.ceil() as i32;
155                    (h_i, h_i, -1, -1)
156                }
157            }
158        }
159
160        fn size_allocate(&self, width: i32, _height: i32, _baseline: i32) {
161            let w = width.max(0) as f32;
162            if (w - self.cached_width.get()).abs() > 0.5 {
163                self.cached_width.set(-1.0);
164            }
165            let _ = self.layout_for_width(w);
166        }
167
168        fn snapshot(&self, snapshot: &gtk::Snapshot) {
169            let widget = self.obj();
170            let canvas_w = widget.width() as f32;
171            if canvas_w <= 0.0 {
172                return;
173            }
174            let _ = self.layout_for_width(canvas_w);
175
176            let rows = self.rows.borrow();
177            let Some(model) = self.model.get() else {
178                return;
179            };
180            let Some(cache) = self.cache.get() else {
181                return;
182            };
183
184            let viewport = self.snapshot_viewport();
185
186            // Fill the visible area with the border color so gaps between
187            // tiles appear as a coloured border.
188            let gap = self.border_gap.get();
189            if gap > 0.0 {
190                let bc = self
191                    .border_color
192                    .get()
193                    .unwrap_or_else(|| gdk4::RGBA::new(1.0, 1.0, 1.0, 1.0));
194                let canvas_h = self.layout_h.get();
195                let vis_rect = Rect::new(
196                    0.0,
197                    viewport.scroll_y.max(0.0),
198                    canvas_w,
199                    (viewport.bottom - viewport.scroll_y.max(0.0)).min(canvas_h),
200                );
201                snapshot.append_color(&bc, &vis_rect);
202            }
203
204            let sctx = self.snapshot_ctx(model, cache);
205            let (stats, mut to_load) = self.paint_snapshot_rows(snapshot, &sctx, &rows, &viewport);
206            drop(rows);
207            self.finish_snapshot(cache, &viewport, &stats, &mut to_load);
208        }
209    }
210
211    /// Compact layout threshold: canvas widths below this use the
212    /// smallest tile tier for phone-sized screens.
213    const COMPACT_WIDTH_THRESHOLD: f32 = 400.0;
214
215    impl MasonryCanvas {
216        fn cfg(&self) -> LayoutConfig {
217            let gap = self.border_gap.get();
218            if self.narrow.get() {
219                let w = self.cached_width.get();
220                if w > 0.0 && w < COMPACT_WIDTH_THRESHOLD {
221                    LayoutConfig::compact().with_gap(gap)
222                } else {
223                    LayoutConfig::narrow().with_gap(gap)
224                }
225            } else {
226                LayoutConfig::wide().with_gap(gap)
227            }
228        }
229
230        fn layout_for_width(&self, width: f32) -> f32 {
231            let cached = self.cached_width.get();
232            if (width - cached).abs() < 0.5 && cached >= 0.0 {
233                return self.layout_h.get();
234            }
235            let Some(model) = self.model.get() else {
236                self.cached_width.set(width);
237                self.layout_h.set(0.0);
238                return 0.0;
239            };
240            let dims = collect_dims(model);
241            let (rows, h) = pack_rows(&dims, width, self.cfg());
242            *self.rows.borrow_mut() = rows;
243            self.cached_width.set(width);
244            self.layout_h.set(h);
245            h
246        }
247
248        pub(super) fn invalidate_layout(&self) {
249            // Pending and textures are NOT cleared here — they are independent
250            // of layout and are owned per-asset across reflows.
251            self.cached_width.set(-1.0);
252            self.layout_h.set(0.0);
253            self.rows.borrow_mut().clear();
254            self.obj().queue_resize();
255        }
256
257        fn snapshot_ctx<'a>(
258            &'a self,
259            model: &'a LibraryAssetModel,
260            cache: &'a Arc<ThumbnailCache>,
261        ) -> SnapshotCtx<'a> {
262            SnapshotCtx {
263                model,
264                cache,
265                placeholder: placeholder_color(),
266                select_tint: gdk4::RGBA::new(0.30, 0.55, 0.95, 0.35),
267                selection: self.selection.get(),
268            }
269        }
270
271        fn snapshot_viewport(&self) -> SnapshotViewport {
272            let (scroll_y, viewport_h) = self.viewport();
273            SnapshotViewport {
274                scroll_y,
275                center: scroll_y + viewport_h * 0.5,
276                top: scroll_y,
277                bottom: scroll_y + viewport_h,
278                band_top: scroll_y - viewport_h * VIEWPORTS_BEHIND,
279                band_bottom: scroll_y + viewport_h * (1.0 + VIEWPORTS_AHEAD),
280            }
281        }
282
283        fn paint_snapshot_rows(
284            &self,
285            snapshot: &gtk::Snapshot,
286            sctx: &SnapshotCtx<'_>,
287            rows: &[LaidRow],
288            viewport: &SnapshotViewport,
289        ) -> (SnapshotStats, Vec<LoadRequest>) {
290            let mut stats = SnapshotStats::default();
291            let mut to_load = Vec::new();
292            let start_idx = first_row_at_or_after(rows, viewport.band_top);
293
294            for row in rows[start_idx..]
295                .iter()
296                .take_while(|row| row.y <= viewport.band_bottom)
297            {
298                self.paint_row(snapshot, sctx, row, viewport, &mut stats, &mut to_load);
299            }
300            (stats, to_load)
301        }
302
303        fn paint_row(
304            &self,
305            snapshot: &gtk::Snapshot,
306            sctx: &SnapshotCtx<'_>,
307            row: &LaidRow,
308            viewport: &SnapshotViewport,
309            stats: &mut SnapshotStats,
310            to_load: &mut Vec<LoadRequest>,
311        ) {
312            let row_in_viewport = row.y + row.h > viewport.top && row.y < viewport.bottom;
313            let bucket = bucket_for_row_height(row.h, self.quality.get());
314
315            for it in &row.items {
316                stats.record(self.paint_item(snapshot, sctx, row, it, row_in_viewport));
317                self.queue_load_if_needed(sctx.model, it, row, viewport.center, bucket, to_load);
318            }
319        }
320
321        fn paint_item(
322            &self,
323            snapshot: &gtk::Snapshot,
324            sctx: &SnapshotCtx<'_>,
325            row: &LaidRow,
326            it: &LaidItem,
327            row_in_viewport: bool,
328        ) -> PaintResult {
329            let asset = sctx
330                .model
331                .item(it.asset_index)
332                .and_downcast::<AssetObject>();
333            let rect = Rect::new(it.x, row.y, it.w, row.h);
334
335            let Some(asset) = asset else {
336                snapshot.append_color(&sctx.placeholder, &rect);
337                return PaintResult::Miss;
338            };
339
340            let asset_id = asset.property::<String>("id");
341            let local_path = asset.property::<String>("local-path");
342            let bucket = bucket_for_asset(&asset_id, &local_path, row.h, self.quality.get());
343            let cached = cached_texture(sctx.cache, &asset_id, bucket, row_in_viewport);
344
345            let clipped = CORNER_RADIUS > 0.0;
346            if clipped {
347                let corner = Size::new(CORNER_RADIUS, CORNER_RADIUS);
348                let rounded = RoundedRect::new(rect, corner, corner, corner, corner);
349                snapshot.push_rounded_clip(&rounded);
350            }
351
352            let result = if let Some(tex) = cached.as_ref() {
353                snapshot.append_texture(tex, &rect);
354                PaintResult::Hit
355            } else {
356                snapshot.append_color(&sctx.placeholder, &rect);
357                PaintResult::Miss
358            };
359
360            let selected = sctx
361                .selection
362                .map(|s| s.is_selected(it.asset_index))
363                .unwrap_or(false);
364            if selected {
365                snapshot.append_color(&sctx.select_tint, &rect);
366            }
367
368            // Checkbox badge in select mode.
369            if self.select_mode.get() {
370                self.paint_checkbox(snapshot, it.x, row.y, row.h, selected);
371            }
372
373            // Video badge: paint a centred play icon over video asset cells.
374            let is_video = asset
375                .property::<String>("asset-type")
376                .eq_ignore_ascii_case("VIDEO");
377            if is_video {
378                self.paint_video_badge(snapshot, it.x, row.y, it.w, row.h);
379            }
380
381            if clipped {
382                snapshot.pop();
383            }
384            result
385        }
386
387        /// Paint the video play-icon badge centred on the cell.
388        ///
389        /// A semi-transparent dark circle is drawn behind the icon for contrast,
390        /// then the cached `mimick-video-symbolic` icon is rendered on top.
391        fn paint_video_badge(
392            &self,
393            snapshot: &gtk::Snapshot,
394            cell_x: f32,
395            cell_y: f32,
396            cell_w: f32,
397            cell_h: f32,
398        ) {
399            let icon = self
400                .video_icon
401                .get_or_init(|| resolve_video_icon(&self.obj()));
402
403            let icon_size = (cell_h * 0.22).clamp(16.0, 48.0);
404            let bg_size = icon_size * 1.6;
405
406            // Centre the background circle.
407            let bg_x = cell_x + (cell_w - bg_size) * 0.5;
408            let bg_y = cell_y + (cell_h - bg_size) * 0.5;
409            let bg_rect = Rect::new(bg_x, bg_y, bg_size, bg_size);
410            let half = bg_size * 0.5;
411            let corner = Size::new(half, half);
412            let rounded = RoundedRect::new(bg_rect, corner, corner, corner, corner);
413
414            let bg_color = gdk4::RGBA::new(0.0, 0.0, 0.0, 0.55);
415            snapshot.push_rounded_clip(&rounded);
416            snapshot.append_color(&bg_color, &bg_rect);
417            snapshot.pop();
418
419            // Centre the icon inside the circle.
420            let icon_x = cell_x + (cell_w - icon_size) * 0.5;
421            let icon_y = cell_y + (cell_h - icon_size) * 0.5;
422            snapshot.save();
423            snapshot.translate(&gtk::graphene::Point::new(icon_x, icon_y));
424            icon.snapshot(
425                snapshot.upcast_ref::<gdk4::Snapshot>(),
426                icon_size as f64,
427                icon_size as f64,
428            );
429            snapshot.restore();
430        }
431
432        /// Paint a checkbox indicator in the top-left corner of a tile.
433        fn paint_checkbox(
434            &self,
435            snapshot: &gtk::Snapshot,
436            cell_x: f32,
437            cell_y: f32,
438            cell_h: f32,
439            selected: bool,
440        ) {
441            let box_size = (cell_h * 0.14).clamp(16.0, 26.0);
442            let margin = 6.0_f32;
443            let bx = cell_x + margin;
444            let by = cell_y + margin;
445            if selected {
446                self.paint_checked_box(snapshot, bx, by, box_size);
447            } else {
448                Self::paint_unchecked_box(snapshot, bx, by, box_size);
449            }
450        }
451
452        /// Accent-filled square with a checkmark icon.
453        fn paint_checked_box(&self, snapshot: &gtk::Snapshot, bx: f32, by: f32, box_size: f32) {
454            let r = 4.0_f32;
455            let corner = Size::new(r, r);
456            let rect = Rect::new(bx, by, box_size, box_size);
457            let rounded = RoundedRect::new(rect, corner, corner, corner, corner);
458            snapshot.push_rounded_clip(&rounded);
459            snapshot.append_color(&accent_bg_color(), &rect);
460            snapshot.pop();
461
462            let icon = self
463                .check_icon
464                .get_or_init(|| resolve_symbolic_icon(&self.obj(), "object-select-symbolic"));
465            let inset = 3.0_f32;
466            let icon_size = box_size - inset * 2.0;
467            snapshot.save();
468            snapshot.translate(&gtk::graphene::Point::new(bx + inset, by + inset));
469            icon.snapshot(
470                snapshot.upcast_ref::<gdk4::Snapshot>(),
471                icon_size as f64,
472                icon_size as f64,
473            );
474            snapshot.restore();
475        }
476
477        /// Bordered empty square (outline only).
478        fn paint_unchecked_box(snapshot: &gtk::Snapshot, bx: f32, by: f32, box_size: f32) {
479            let r = 4.0_f32;
480            let border_w = 2.0_f32;
481            let corner = Size::new(r, r);
482            let outer_rect = Rect::new(bx, by, box_size, box_size);
483            let outer_round = RoundedRect::new(outer_rect, corner, corner, corner, corner);
484
485            snapshot.push_rounded_clip(&outer_round);
486            snapshot.append_color(&gdk4::RGBA::new(1.0, 1.0, 1.0, 0.8), &outer_rect);
487
488            let ir = (r - border_w).max(0.0);
489            let ic = Size::new(ir, ir);
490            let inner = Rect::new(
491                bx + border_w,
492                by + border_w,
493                box_size - border_w * 2.0,
494                box_size - border_w * 2.0,
495            );
496            let inner_round = RoundedRect::new(inner, ic, ic, ic, ic);
497            snapshot.push_rounded_clip(&inner_round);
498            snapshot.append_color(&gdk4::RGBA::new(0.0, 0.0, 0.0, 0.3), &inner);
499            snapshot.pop();
500            snapshot.pop();
501        }
502
503        fn queue_load_if_needed(
504            &self,
505            model: &LibraryAssetModel,
506            it: &LaidItem,
507            row: &LaidRow,
508            viewport_center: f32,
509            bucket: ThumbnailSize,
510            to_load: &mut Vec<LoadRequest>,
511        ) {
512            let Some((asset_id, local_path, is_local_only)) = load_target_for(model, it) else {
513                return;
514            };
515            let lookup_bucket = if is_local_only {
516                ThumbnailSize::Thumbnail
517            } else {
518                bucket
519            };
520            if self.load_is_blocked(&asset_id, lookup_bucket) {
521                return;
522            }
523            let mut pending = self.pending.borrow_mut();
524            if pending.contains(&asset_id) {
525                return;
526            }
527            pending.insert(asset_id.to_string());
528            let cell_center = row.y + row.h * 0.5;
529            let priority = (cell_center - viewport_center).abs();
530            to_load.push(LoadRequest {
531                priority,
532                asset_id: asset_id.to_string(),
533                bucket,
534                local_path,
535                is_local: is_local_only,
536            });
537        }
538
539        fn load_is_blocked(&self, asset_id: &str, bucket: ThumbnailSize) -> bool {
540            self.failed.borrow().contains(asset_id)
541                || self
542                    .cache
543                    .get()
544                    .is_none_or(|cache| cache.peek_cached(asset_id, bucket).is_some())
545        }
546
547        fn finish_snapshot(
548            &self,
549            cache: &ThumbnailCache,
550            viewport: &SnapshotViewport,
551            stats: &SnapshotStats,
552            to_load: &mut Vec<LoadRequest>,
553        ) {
554            to_load.sort_by(|a, b| {
555                a.priority
556                    .partial_cmp(&b.priority)
557                    .unwrap_or(std::cmp::Ordering::Equal)
558            });
559            self.log_snapshot(cache, viewport, stats, to_load.len());
560            for request in to_load.drain(..) {
561                self.spawn_load(request);
562            }
563        }
564
565        fn log_snapshot(
566            &self,
567            cache: &ThumbnailCache,
568            viewport: &SnapshotViewport,
569            stats: &SnapshotStats,
570            queued: usize,
571        ) {
572            let (cache_n, cache_bytes, cache_max, cache_evicts) = cache.cache_stats();
573            log::debug!(
574                "masonry snapshot y={:.0} painted={} hits={} misses={} pending={} queued={} cache={}/{}/{}MB evicts={}",
575                viewport.scroll_y,
576                stats.painted,
577                stats.hits,
578                stats.misses,
579                self.pending.borrow().len(),
580                queued,
581                cache_n,
582                cache_bytes / (1024 * 1024),
583                cache_max / (1024 * 1024),
584                cache_evicts,
585            );
586        }
587
588        fn viewport(&self) -> (f32, f32) {
589            let adj = self.find_vadjustment();
590            if let Some(adj) = adj {
591                (adj.value() as f32, adj.page_size() as f32)
592            } else {
593                (0.0, self.obj().height() as f32)
594            }
595        }
596
597        fn find_vadjustment(&self) -> Option<gtk::Adjustment> {
598            if let Some(adj) = self.vadjustment.borrow().clone() {
599                return Some(adj);
600            }
601            let mut node: Option<gtk::Widget> = self.obj().parent();
602            while let Some(w) = node {
603                if let Some(sw) = w.downcast_ref::<gtk::ScrolledWindow>() {
604                    let adj = sw.vadjustment();
605                    let weak = self.obj().downgrade();
606                    adj.connect_value_changed(move |_| {
607                        if let Some(canvas) = weak.upgrade() {
608                            canvas.queue_draw();
609                        }
610                    });
611                    *self.vadjustment.borrow_mut() = Some(adj.clone());
612                    return Some(adj);
613                }
614                node = w.parent();
615            }
616            None
617        }
618
619        fn spawn_load(&self, request: LoadRequest) {
620            let Some(cache) = self.cache.get().cloned() else {
621                return;
622            };
623            let Some(model) = self.model.get().cloned() else {
624                return;
625            };
626            let widget = self.obj().clone();
627            log::trace!(
628                "masonry spawn_load id={} bucket={:?} local={}",
629                request.asset_id,
630                request.bucket,
631                request.is_local
632            );
633            glib::MainContext::default().spawn_local(async move {
634                let asset_id = request.asset_id.clone();
635                let result = load_masonry_asset(&cache, &request).await;
636                finish_masonry_load(&widget, &model, &asset_id, result);
637            });
638        }
639    }
640}
641
642use super::load::{collect_dims, load_with_fallback, propagate_dimensions};
643
644glib::wrapper! {
645    pub struct MasonryCanvas(ObjectSubclass<imp::MasonryCanvas>)
646        @extends gtk::Widget,
647        @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
648}
649
650impl imp::SnapshotStats {
651    fn record(&mut self, result: imp::PaintResult) {
652        match result {
653            imp::PaintResult::Hit => self.hits += 1,
654            imp::PaintResult::Miss => self.misses += 1,
655        }
656        self.painted += 1;
657    }
658}
659
660/// Get the current theme accent background colour from libadwaita.
661fn accent_bg_color() -> gdk4::RGBA {
662    let accent = libadwaita::StyleManager::default().accent_color();
663    accent.to_rgba()
664}
665
666fn placeholder_color() -> gdk4::RGBA {
667    if libadwaita::StyleManager::default().is_dark() {
668        gdk4::RGBA::new(0.20, 0.20, 0.22, 1.0)
669    } else {
670        gdk4::RGBA::new(0.90, 0.90, 0.92, 1.0)
671    }
672}
673
674/// Resolve the video badge icon from the icon theme. The result is cached
675/// per-canvas via a `OnceCell` so the theme lookup only happens once.
676fn resolve_video_icon(widget: &MasonryCanvas) -> gdk4::Paintable {
677    resolve_symbolic_icon(widget, "mimick-video-symbolic")
678}
679
680fn resolve_symbolic_icon(widget: &MasonryCanvas, icon_name: &str) -> gdk4::Paintable {
681    let display = widget
682        .native()
683        .map(|n| n.display())
684        .unwrap_or_else(|| gtk::gdk::Display::default().expect("default GDK display"));
685    let theme = gtk::IconTheme::for_display(&display);
686    let icon = theme.lookup_icon(
687        icon_name,
688        &[],
689        48,
690        1,
691        gtk::TextDirection::None,
692        gtk::IconLookupFlags::FORCE_SYMBOLIC,
693    );
694    icon.upcast::<gdk4::Paintable>()
695}
696
697fn bucket_for_asset(
698    asset_id: &str,
699    local_path: &str,
700    row_h: f32,
701    quality: GridQuality,
702) -> ThumbnailSize {
703    if !local_path.is_empty() && asset_id.starts_with(crate::library::LOCAL_ID_PREFIX) {
704        ThumbnailSize::Thumbnail
705    } else {
706        bucket_for_row_height(row_h, quality)
707    }
708}
709
710fn cached_texture(
711    cache: &ThumbnailCache,
712    asset_id: &str,
713    bucket: ThumbnailSize,
714    touch_lru: bool,
715) -> Option<gdk4::Texture> {
716    let get = |size| {
717        if touch_lru {
718            cache.get_cached(asset_id, size)
719        } else {
720            cache.peek_cached(asset_id, size)
721        }
722    };
723    get(bucket).or_else(|| fallback_bucket(bucket).and_then(get))
724}
725
726fn load_target_for(model: &LibraryAssetModel, it: &LaidItem) -> Option<(String, String, bool)> {
727    let asset = model.item(it.asset_index).and_downcast::<AssetObject>()?;
728    let asset_id = asset.property::<String>("id");
729    let local_path = asset.property::<String>("local-path");
730    let is_local = !local_path.is_empty() && asset_id.starts_with(crate::library::LOCAL_ID_PREFIX);
731    Some((asset_id, local_path, is_local))
732}
733
734async fn load_masonry_asset(
735    cache: &ThumbnailCache,
736    request: &imp::LoadRequest,
737) -> Result<gdk4::Texture, String> {
738    let is_cancelled = || false;
739    if request.is_local {
740        let path = std::path::PathBuf::from(&request.local_path);
741        cache
742            .load_local_thumbnail_cancellable(&request.asset_id, &path, &is_cancelled)
743            .await
744    } else {
745        load_with_fallback(cache, &request.asset_id, request.bucket, &is_cancelled).await
746    }
747}
748
749fn finish_masonry_load(
750    widget: &MasonryCanvas,
751    model: &LibraryAssetModel,
752    asset_id: &str,
753    result: Result<gdk4::Texture, String>,
754) {
755    let imp = widget.imp();
756    let dims_changed = match &result {
757        Ok(tex) => {
758            log::trace!(
759                "masonry load OK id={} dims=({}x{})",
760                asset_id,
761                tex.width(),
762                tex.height(),
763            );
764            propagate_dimensions(model, asset_id, tex)
765        }
766        Err(e) => {
767            if e != "cancelled" {
768                imp.failed.borrow_mut().insert(asset_id.to_string());
769
770                if (e.contains("HTTP 401") || e.contains("HTTP 403"))
771                    && !imp.permission_warned.get()
772                {
773                    imp.permission_warned.set(true);
774                    if let Some(window) = widget
775                        .root()
776                        .and_downcast::<libadwaita::ApplicationWindow>()
777                    {
778                        let dialog = libadwaita::AlertDialog::builder()
779                            .heading("Missing API Permissions")
780                            .body("Your API key is missing permissions required to view thumbnails. Please ensure the key has 'asset.view' enabled.")
781                            .build();
782                        dialog.add_response("ok", "OK");
783                        dialog.present(Some(&window));
784                    }
785                }
786            }
787            log::warn!("masonry load ERR id={} err={}", asset_id, e);
788            false
789        }
790    };
791    imp.pending.borrow_mut().remove(asset_id);
792    if dims_changed {
793        imp.invalidate_layout();
794    }
795    widget.queue_draw();
796}