1use 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 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 pub last_selected: Cell<Option<u32>>,
109 pub permission_warned: Cell<bool>,
110 pub video_icon: OnceCell<gdk4::Paintable>,
112 pub check_icon: OnceCell<gdk4::Paintable>,
114 pub uncheck_icon: OnceCell<gdk4::Paintable>,
116 pub drag_active: Cell<bool>,
118 pub border_gap: Cell<f32>,
120 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: >k::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 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 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 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: >k::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: >k::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: >k::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 if self.select_mode.get() {
370 self.paint_checkbox(snapshot, it.x, row.y, row.h, selected);
371 }
372
373 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 fn paint_video_badge(
392 &self,
393 snapshot: >k::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 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 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(>k::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 fn paint_checkbox(
434 &self,
435 snapshot: >k::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 fn paint_checked_box(&self, snapshot: >k::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(>k::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 fn paint_unchecked_box(snapshot: >k::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
660fn 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
674fn 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}