1use std::cell::{Cell, RefCell};
8use std::rc::Rc;
9
10use glib::clone;
11use gtk::prelude::*;
12use libadwaita::prelude::*;
13
14use crate::api_client::{ExifInfo, ThumbnailSize};
15use crate::library::asset_object::AssetObject;
16use crate::library::local_exif::{self, LocalExif};
17
18use super::context_menu::show_asset_context_menu;
19use super::download::{
20 begin_download_session, finish_download_item, open_local_with_default_app, spawn_video_handoff,
21 start_download, track_download_item,
22};
23use super::{LOCAL_ID_PREFIX, LibraryWindowUi, load_source_page, load_texture_oriented};
24
25struct LocalProbe {
30 exif: Option<LocalExif>,
31 file_size: Option<u64>,
32 mtime_iso: Option<String>,
33 dims: Option<(u32, u32)>,
34}
35
36fn systemtime_to_rfc3339(t: std::time::SystemTime) -> String {
40 use chrono::{DateTime, Local};
41 let dt: DateTime<Local> = t.into();
42 dt.to_rfc3339()
43}
44
45fn exif_from_local(local: &LocalExif, file_size: Option<u64>) -> ExifInfo {
48 ExifInfo {
49 make: local.make.clone(),
50 model: local.model.clone(),
51 lens_model: local.lens_model.clone(),
52 f_number: local.f_number,
53 focal_length: local.focal_length,
54 iso: local.iso,
55 exposure_time: local.exposure_time.clone(),
56 file_size_in_byte: file_size,
57 date_time_original: local.date_time_original.clone(),
58 city: None,
59 state: None,
60 country: None,
61 latitude: local.latitude,
62 longitude: local.longitude,
63 description: local.description.clone(),
64 exif_image_width: local.image_width,
65 exif_image_height: local.image_height,
66 }
67}
68
69fn original_preview_cache_path(
70 cache_dir: &std::path::Path,
71 asset_id: &str,
72 filename: &str,
73) -> std::path::PathBuf {
74 let ext = std::path::Path::new(filename)
75 .extension()
76 .and_then(|ext| ext.to_str())
77 .filter(|ext| !ext.is_empty())
78 .unwrap_or("bin");
79 cache_dir.join(format!("{asset_id}.{ext}"))
80}
81
82fn drag_export_path(
87 asset_id: &str,
88 filename: &str,
89 source: &std::path::Path,
90) -> Option<std::path::PathBuf> {
91 let export_dir = crate::profile::cache_dir()?.join("drag_export");
92 let _ = std::fs::create_dir_all(&export_dir);
93 let prefix = &asset_id[..8.min(asset_id.len())];
94 let export = export_dir.join(format!("{prefix}_{filename}"));
95 if export.exists() {
96 return Some(export);
97 }
98 if std::fs::hard_link(source, &export).is_ok() || std::fs::copy(source, &export).is_ok() {
99 Some(export)
100 } else {
101 Some(source.to_path_buf())
102 }
103}
104
105fn fill_exif_box(container: >k::Box, exif: &crate::api_client::ExifInfo, taken_label: &str) {
115 if let Some(group) = build_camera_group(exif) {
116 container.append(&group);
117 }
118 if let Some(group) = build_image_group(exif, taken_label) {
119 container.append(&group);
120 }
121 if let Some(group) = build_location_group(exif) {
122 container.append(&group);
123 }
124 if let Some(desc) = &exif.description
125 && !desc.trim().is_empty()
126 {
127 let note_group = libadwaita::PreferencesGroup::builder()
128 .title("Description")
129 .build();
130 let row = libadwaita::ActionRow::builder()
131 .title(desc.as_str())
132 .title_lines(0)
133 .css_classes(["property"])
134 .build();
135 note_group.add(&row);
136 container.append(¬e_group);
137 }
138}
139
140fn build_camera_group(exif: &crate::api_client::ExifInfo) -> Option<libadwaita::PreferencesGroup> {
141 let group = libadwaita::PreferencesGroup::builder()
142 .title("Camera")
143 .build();
144 let mut rows = 0u32;
145 if let Some(c) = format_camera(exif) {
146 group.add(&accent_row(
147 "camera-photo-symbolic",
148 "mimick-accent-camera",
149 "Body",
150 &c,
151 ));
152 rows += 1;
153 }
154 if let Some(l) = &exif.lens_model
155 && !l.trim().is_empty()
156 {
157 group.add(&accent_row(
158 "view-fullscreen-symbolic",
159 "mimick-accent-camera",
160 "Lens",
161 l,
162 ));
163 rows += 1;
164 }
165 if let Some(exposure) = format_exposure(exif) {
166 group.add(&accent_row(
167 "weather-clear-symbolic",
168 "mimick-accent-camera",
169 "Exposure",
170 &exposure,
171 ));
172 rows += 1;
173 }
174 (rows > 0).then_some(group)
175}
176
177fn build_image_group(
178 exif: &crate::api_client::ExifInfo,
179 taken_label: &str,
180) -> Option<libadwaita::PreferencesGroup> {
181 let group = libadwaita::PreferencesGroup::builder()
182 .title("Image")
183 .build();
184 let mut rows = 0u32;
185 if let (Some(w), Some(h)) = (exif.exif_image_width, exif.exif_image_height) {
186 group.add(&accent_row(
187 "view-grid-symbolic",
188 "mimick-accent-image",
189 "Dimensions",
190 &format!("{w} × {h}"),
191 ));
192 rows += 1;
193 }
194 if let Some(size) = exif.file_size_in_byte {
195 group.add(&accent_row(
196 "drive-harddisk-symbolic",
197 "mimick-accent-image",
198 "Size",
199 &format_bytes(size),
200 ));
201 rows += 1;
202 }
203 if let Some(dt) = &exif.date_time_original
204 && !dt.trim().is_empty()
205 {
206 group.add(&accent_row(
207 "x-office-calendar-symbolic",
208 "mimick-accent-image",
209 taken_label,
210 &format_datetime_display(dt),
211 ));
212 rows += 1;
213 }
214 (rows > 0).then_some(group)
215}
216
217fn build_location_group(
218 exif: &crate::api_client::ExifInfo,
219) -> Option<libadwaita::PreferencesGroup> {
220 let group = libadwaita::PreferencesGroup::builder()
221 .title("Location")
222 .build();
223 let mut rows = 0u32;
224 if let Some(loc) = format_location(exif) {
225 group.add(&accent_row(
226 "mark-location-symbolic",
227 "mimick-accent-location",
228 "Place",
229 &loc,
230 ));
231 rows += 1;
232 }
233 if let (Some(lat), Some(lon)) = (exif.latitude, exif.longitude) {
234 group.add(&accent_row(
235 "find-location-symbolic",
236 "mimick-accent-location",
237 "Coordinates",
238 &format!("{lat:.5}, {lon:.5}"),
239 ));
240 rows += 1;
241 }
242 (rows > 0).then_some(group)
243}
244
245fn accent_row(icon: &str, accent_class: &str, title: &str, value: &str) -> libadwaita::ActionRow {
246 let prefix = gtk::Image::builder()
247 .icon_name(icon)
248 .pixel_size(16)
249 .valign(gtk::Align::Center)
250 .halign(gtk::Align::Center)
251 .css_classes(["mimick-detail-icon", accent_class])
252 .build();
253 let row = libadwaita::ActionRow::builder()
254 .title(title)
255 .subtitle(value)
256 .subtitle_lines(2)
257 .css_classes(["property"])
258 .build();
259 row.add_prefix(&prefix);
260 row
261}
262
263fn format_camera(exif: &crate::api_client::ExifInfo) -> Option<String> {
264 match (&exif.make, &exif.model) {
265 (Some(m), Some(n)) => {
266 let m = m.trim();
267 let n = n.trim();
268 if n.starts_with(m) {
269 Some(n.to_string())
270 } else {
271 Some(format!("{m} {n}"))
272 }
273 }
274 (Some(m), None) => Some(m.trim().to_string()),
275 (None, Some(n)) => Some(n.trim().to_string()),
276 _ => None,
277 }
278 .filter(|s| !s.is_empty())
279}
280
281fn format_exposure(exif: &crate::api_client::ExifInfo) -> Option<String> {
282 let mut parts = Vec::new();
283 if let Some(f) = exif.f_number {
284 parts.push(format!("ƒ/{f:.1}"));
285 }
286 if let Some(et) = &exif.exposure_time
287 && !et.trim().is_empty()
288 {
289 parts.push(et.trim().to_string());
290 }
291 if let Some(iso) = exif.iso {
292 parts.push(format!("ISO {iso}"));
293 }
294 if let Some(focal) = exif.focal_length {
295 parts.push(format!("{focal:.0}mm"));
296 }
297 if parts.is_empty() {
298 None
299 } else {
300 Some(parts.join(" · "))
301 }
302}
303
304fn format_location(exif: &crate::api_client::ExifInfo) -> Option<String> {
305 let parts: Vec<&str> = [&exif.city, &exif.state, &exif.country]
306 .into_iter()
307 .filter_map(|s| s.as_deref().map(str::trim).filter(|t| !t.is_empty()))
308 .collect();
309 if parts.is_empty() {
310 None
311 } else {
312 Some(parts.join(", "))
313 }
314}
315
316fn format_bytes(n: u64) -> String {
318 const KIB: f64 = 1024.0;
319 let n_f = n as f64;
320 if n_f >= KIB * KIB * KIB {
321 format!("{:.2} GB", n_f / (KIB * KIB * KIB))
322 } else if n_f >= KIB * KIB {
323 format!("{:.2} MB", n_f / (KIB * KIB))
324 } else if n_f >= KIB {
325 format!("{:.1} KB", n_f / KIB)
326 } else {
327 format!("{} B", n)
328 }
329}
330
331fn format_datetime_display(iso: &str) -> String {
340 use chrono::{DateTime, Local, Utc};
341 if let Ok(dt) = DateTime::parse_from_rfc3339(iso) {
343 let local: DateTime<Local> = dt.into();
344 return local.format("%Y-%m-%d %H:%M:%S UTC%:z").to_string();
345 }
346 if let Ok(dt) = iso.parse::<DateTime<Utc>>() {
348 let local: DateTime<Local> = dt.into();
349 return local.format("%Y-%m-%d %H:%M:%S UTC%:z").to_string();
350 }
351 iso.get(..19).unwrap_or(iso).replace('T', " ").to_string()
353}
354
355fn truncate_filename(name: &str, max_chars: usize) -> String {
357 let count = name.chars().count();
358 if count <= max_chars {
359 return name.to_string();
360 }
361 let keep = max_chars.saturating_sub(1);
362 let head: String = name.chars().take(keep).collect();
363 format!("{}…", head)
364}
365
366fn apply_lightbox_zoom(
370 picture: >k::Picture,
371 viewer: >k::ScrolledWindow,
372 zoom: f64,
373) -> Option<(f64, f64)> {
374 if (zoom - 1.0).abs() < 0.001 {
375 picture.set_size_request(-1, -1);
376 return None;
377 }
378 let Some(paintable) = picture.paintable() else {
379 picture.set_size_request(-1, -1);
380 return None;
381 };
382 let nw = paintable.intrinsic_width().max(1) as f64;
383 let nh = paintable.intrinsic_height().max(1) as f64;
384 let viewer_w = viewer.width().max(1) as f64;
385 let viewer_h = viewer.height().max(1) as f64;
386 let texture_aspect = nw / nh;
387 let viewer_aspect = viewer_w / viewer_h;
388 let (fit_w, fit_h) = if viewer_aspect > texture_aspect {
389 (viewer_h * texture_aspect, viewer_h)
390 } else {
391 (viewer_w, viewer_w / texture_aspect)
392 };
393 let cw = fit_w * zoom;
394 let ch = fit_h * zoom;
395 picture.set_size_request(cw as i32, ch as i32);
396 Some((cw, ch))
397}
398
399pub(super) fn open_lightbox(ui: Rc<LibraryWindowUi>, position: u32) {
401 let Some(item) = ui.grid.model.item(position).and_downcast::<AssetObject>() else {
402 return;
403 };
404 let initial_filename = item.property::<String>("filename");
405 let title_cap = if ui.split.is_collapsed() { 14 } else { 24 };
406 let title_for_header = truncate_filename(&initial_filename, title_cap);
407
408 let page = libadwaita::NavigationPage::builder()
409 .title(&title_for_header)
410 .can_pop(true)
411 .build();
412 let toolbar = libadwaita::ToolbarView::builder().build();
413 let header = libadwaita::HeaderBar::builder()
414 .show_back_button(false)
415 .build();
416 let back_btn = gtk::Button::builder()
417 .icon_name("mimick-library-symbolic")
418 .tooltip_text("Back to library")
419 .build();
420 back_btn.connect_clicked(clone!(
421 #[strong]
422 ui,
423 move |_| {
424 ui.nav.pop();
425 }
426 ));
427 let prev_btn = gtk::Button::builder()
428 .icon_name("go-previous-symbolic")
429 .tooltip_text("Previous (Left)")
430 .build();
431 let next_btn = gtk::Button::builder()
432 .icon_name("go-next-symbolic")
433 .tooltip_text("Next (Right)")
434 .build();
435 let details_btn = gtk::ToggleButton::builder()
436 .icon_name("dialog-information-symbolic")
437 .tooltip_text("Toggle details (I)")
438 .active(false)
439 .build();
440 header.pack_start(&back_btn);
441 header.pack_start(&prev_btn);
442 header.pack_start(&next_btn);
443 header.pack_end(&details_btn);
444 toolbar.add_top_bar(&header);
445
446 let body = libadwaita::OverlaySplitView::builder()
447 .sidebar_position(gtk::PackType::End)
448 .show_sidebar(false)
449 .collapsed(ui.split.is_collapsed())
450 .enable_show_gesture(true)
451 .enable_hide_gesture(true)
452 .min_sidebar_width(180.0)
453 .max_sidebar_width(320.0)
454 .sidebar_width_fraction(0.4)
455 .build();
456 let viewer = gtk::Box::builder()
457 .orientation(gtk::Orientation::Vertical)
458 .spacing(8)
459 .margin_top(4)
460 .margin_bottom(8)
461 .margin_start(4)
462 .margin_end(4)
463 .hexpand(true)
464 .build();
465 let picture_a = gtk::Picture::builder()
467 .content_fit(gtk::ContentFit::Contain)
468 .vexpand(true)
469 .hexpand(true)
470 .css_classes(["mimick-lightbox-picture"])
471 .build();
472 let picture_b = gtk::Picture::builder()
473 .content_fit(gtk::ContentFit::Contain)
474 .vexpand(true)
475 .hexpand(true)
476 .css_classes(["mimick-lightbox-picture"])
477 .build();
478 let pic_stack = gtk::Stack::builder()
479 .transition_duration(180)
480 .vexpand(true)
481 .hexpand(true)
482 .build();
483 pic_stack.add_named(&picture_a, Some("a"));
484 pic_stack.add_named(&picture_b, Some("b"));
485 pic_stack.set_visible_child_name("a");
486 let scrolled_picture = gtk::ScrolledWindow::builder()
487 .hscrollbar_policy(gtk::PolicyType::Automatic)
488 .vscrollbar_policy(gtk::PolicyType::Automatic)
489 .child(&pic_stack)
490 .vexpand(true)
491 .hexpand(true)
492 .kinetic_scrolling(false)
493 .min_content_width(120)
494 .build();
495
496 let loader_icon = gtk::Image::builder()
500 .icon_name("dev.nicx.mimick")
501 .pixel_size(72)
502 .halign(gtk::Align::Center)
503 .valign(gtk::Align::Center)
504 .css_classes(["mimick-loader-icon"])
505 .build();
506 let loader_overlay = gtk::Revealer::builder()
507 .transition_type(gtk::RevealerTransitionType::Crossfade)
508 .transition_duration(180)
509 .reveal_child(false)
510 .halign(gtk::Align::Center)
511 .valign(gtk::Align::Center)
512 .child(&loader_icon)
513 .can_target(false)
514 .build();
515 let picture_overlay = gtk::Overlay::builder().build();
516 picture_overlay.set_child(Some(&scrolled_picture));
517 picture_overlay.add_overlay(&loader_overlay);
518
519 let unavailable_title = gtk::Label::builder()
520 .label("Preview unavailable")
521 .css_classes(["title-3"])
522 .build();
523 let unavailable_filename = gtk::Label::builder()
524 .wrap(true)
525 .wrap_mode(gtk::pango::WrapMode::WordChar)
526 .max_width_chars(42)
527 .build();
528 let unavailable_mime = gtk::Label::builder().css_classes(["dim-label"]).build();
529 let unavailable_open = gtk::Button::builder()
530 .label("Open in external app")
531 .css_classes(["suggested-action"])
532 .build();
533 let unavailable_path = Rc::new(RefCell::new(None::<String>));
534 unavailable_open.connect_clicked({
535 let unavailable_path = unavailable_path.clone();
536 move |_| {
537 if let Some(path) = unavailable_path.borrow().as_deref() {
538 open_local_with_default_app(path);
539 }
540 }
541 });
542 let unavailable_card = gtk::Box::builder()
543 .orientation(gtk::Orientation::Vertical)
544 .spacing(8)
545 .halign(gtk::Align::Center)
546 .valign(gtk::Align::Center)
547 .css_classes(["mimick-preview-unavailable"])
548 .build();
549 unavailable_card.append(&unavailable_title);
550 unavailable_card.append(&unavailable_filename);
551 unavailable_card.append(&unavailable_mime);
552 unavailable_card.append(&unavailable_open);
553 let unavailable_overlay = gtk::Revealer::builder()
554 .transition_type(gtk::RevealerTransitionType::Crossfade)
555 .transition_duration(180)
556 .reveal_child(false)
557 .halign(gtk::Align::Fill)
558 .valign(gtk::Align::Fill)
559 .child(&unavailable_card)
560 .can_target(false)
561 .build();
562 picture_overlay.add_overlay(&unavailable_overlay);
563
564 let video_badge_target: Rc<RefCell<Option<(String, String, String)>>> =
567 Rc::new(RefCell::new(None));
568 let video_badge_icon = gtk::Image::builder()
569 .icon_name("mimick-video-symbolic")
570 .pixel_size(72)
571 .css_classes(vec!["mimick-video-badge".to_string()])
572 .build();
573 let video_badge_button = gtk::Button::builder()
574 .child(&video_badge_icon)
575 .halign(gtk::Align::Center)
576 .valign(gtk::Align::Center)
577 .tooltip_text("Play video in external player")
578 .css_classes(vec!["circular".to_string(), "flat".to_string()])
579 .visible(false)
580 .build();
581 video_badge_button.connect_clicked({
582 let video_badge_target = video_badge_target.clone();
583 let ui_for_video = ui.clone();
584 move |_| {
585 let Some((local_path, asset_id, filename)) = video_badge_target.borrow().clone() else {
586 return;
587 };
588 if !local_path.is_empty() {
589 open_local_with_default_app(&local_path);
590 } else {
591 spawn_video_handoff(ui_for_video.clone(), asset_id, filename);
592 }
593 }
594 });
595 picture_overlay.add_overlay(&video_badge_button);
596
597 let lightbox_drag_path: Rc<RefCell<Option<std::path::PathBuf>>> = Rc::new(RefCell::new(None));
601 {
602 let drag_source = gtk::DragSource::new();
603 drag_source.set_actions(gtk::gdk::DragAction::COPY);
604
605 let drag_path = lightbox_drag_path.clone();
606 drag_source.connect_prepare(move |_source, _x, _y| {
607 let path = drag_path.borrow().clone()?;
608 if !path.exists() {
609 return None;
610 }
611 let file = gtk::gio::File::for_path(&path);
612 Some(gtk::gdk::ContentProvider::for_value(&file.to_value()))
613 });
614
615 picture_overlay.add_controller(drag_source);
616 }
617
618 let active_a = Rc::new(Cell::new(true));
619 let zoom_level = Rc::new(Cell::new(1.0_f64));
620 let initial_full = ui.ctx.config.read().data.library_preview_full_resolution;
621 let resolution_toggle = gtk::ToggleButton::builder()
622 .label(if initial_full { "Raw" } else { "Prev" })
623 .tooltip_text("Toggle preview vs original full-resolution image")
624 .active(initial_full)
625 .build();
626 let download = gtk::Button::builder()
627 .icon_name("mimick-download-symbolic")
628 .tooltip_text("Download asset")
629 .build();
630 let zoom_out_btn = gtk::Button::builder()
631 .icon_name("zoom-out-symbolic")
632 .tooltip_text("Zoom out (Ctrl+-)")
633 .build();
634 let zoom_in_btn = gtk::Button::builder()
635 .icon_name("zoom-in-symbolic")
636 .tooltip_text("Zoom in (Ctrl++)")
637 .build();
638 let zoom_reset_btn = gtk::Button::builder()
639 .label("100%")
640 .tooltip_text("Reset zoom (Ctrl+0)")
641 .build();
642 let zoom_group = gtk::Box::builder()
643 .orientation(gtk::Orientation::Horizontal)
644 .css_classes(vec!["linked".to_string()])
645 .build();
646 zoom_group.append(&zoom_out_btn);
647 zoom_group.append(&zoom_reset_btn);
648 zoom_group.append(&zoom_in_btn);
649 let actions = gtk::Box::builder()
650 .orientation(gtk::Orientation::Horizontal)
651 .spacing(4)
652 .build();
653 let actions_spacer = gtk::Box::builder()
654 .orientation(gtk::Orientation::Horizontal)
655 .hexpand(true)
656 .build();
657 actions.append(&zoom_group);
658 actions.append(&actions_spacer);
659 actions.append(&resolution_toggle);
660 actions.append(&download);
661 viewer.append(&picture_overlay);
662 viewer.append(&actions);
663
664 let details_inner = gtk::Box::builder()
665 .orientation(gtk::Orientation::Vertical)
666 .spacing(14)
667 .margin_top(14)
668 .margin_bottom(14)
669 .margin_start(10)
670 .margin_end(10)
671 .build();
672 let details_pane = gtk::ScrolledWindow::builder()
673 .child(&details_inner)
674 .hscrollbar_policy(gtk::PolicyType::Never)
675 .vexpand(true)
676 .hexpand(false)
677 .min_content_width(180)
678 .max_content_width(320)
679 .css_classes(vec!["mimick-details-pane".to_string()])
680 .build();
681 let details_filename = gtk::Label::builder()
682 .xalign(0.0)
683 .wrap(true)
684 .wrap_mode(gtk::pango::WrapMode::WordChar)
685 .max_width_chars(28)
686 .css_classes(vec!["title-3".to_string()])
687 .build();
688 let details_summary = gtk::Label::builder()
689 .xalign(0.0)
690 .wrap(true)
691 .wrap_mode(gtk::pango::WrapMode::WordChar)
692 .max_width_chars(28)
693 .build();
694 let details_loading = gtk::Label::builder()
695 .xalign(0.0)
696 .label("Loading details…")
697 .css_classes(vec!["dim-label".to_string()])
698 .build();
699 let details_exif = gtk::Box::builder()
700 .orientation(gtk::Orientation::Vertical)
701 .spacing(4)
702 .visible(false)
703 .build();
704 details_inner.append(&details_filename);
705 details_inner.append(&details_summary);
706 details_inner.append(&details_loading);
707 details_inner.append(&details_exif);
708
709 body.set_content(Some(&viewer));
710 body.set_sidebar(Some(&details_pane));
711 toolbar.set_content(Some(&body));
712 page.set_child(Some(&toolbar));
713
714 details_btn
715 .bind_property("active", &body, "show-sidebar")
716 .sync_create()
717 .bidirectional()
718 .build();
719 ui.split
720 .bind_property("collapsed", &body, "collapsed")
721 .sync_create()
722 .build();
723
724 let sync_nav_visibility = {
728 let prev_btn = prev_btn.clone();
729 let next_btn = next_btn.clone();
730 let split = ui.split.clone();
731 move || {
732 let show = !split.is_collapsed();
733 prev_btn.set_visible(show);
734 next_btn.set_visible(show);
735 }
736 };
737 sync_nav_visibility();
738 let sync_clone = sync_nav_visibility.clone();
739 ui.split
740 .connect_notify_local(Some("collapsed"), move |_, _| sync_clone());
741
742 let pos_cell = Rc::new(Cell::new(position));
743 let load_gen = Rc::new(Cell::new(0u64));
747 let load_into_picture = Rc::new({
748 let ui = ui.clone();
749 let loader_overlay = loader_overlay.clone();
750 let unavailable_overlay = unavailable_overlay.clone();
751 let unavailable_filename = unavailable_filename.clone();
752 let unavailable_mime = unavailable_mime.clone();
753 let unavailable_open = unavailable_open.clone();
754 let unavailable_path = unavailable_path.clone();
755 let load_gen = load_gen.clone();
756 let pic_stack = pic_stack.clone();
757 let active_a = active_a.clone();
758 let video_badge_button = video_badge_button.clone();
759 let video_badge_target = video_badge_target.clone();
760 move |target: gtk::Picture,
761 target_is_a: bool,
762 asset_id: String,
763 filename: String,
764 mime: String,
765 local_path: String,
766 full_res: bool| {
767 let ui = ui.clone();
768 let loader = loader_overlay.clone();
769 let unavailable_overlay = unavailable_overlay.clone();
770 let unavailable_filename = unavailable_filename.clone();
771 let unavailable_mime = unavailable_mime.clone();
772 let unavailable_open = unavailable_open.clone();
773 let unavailable_path = unavailable_path.clone();
774 let load_gen = load_gen.clone();
775 let pic_stack = pic_stack.clone();
776 let active_a = active_a.clone();
777 let video_badge_button = video_badge_button.clone();
778 let video_badge_target = video_badge_target.clone();
779 let lightbox_drag_path = lightbox_drag_path.clone();
780 let our_gen = load_gen.get().wrapping_add(1);
781 load_gen.set(our_gen);
782 unavailable_overlay.set_reveal_child(false);
783 unavailable_overlay.set_can_target(false);
784 *unavailable_path.borrow_mut() = None;
785 *lightbox_drag_path.borrow_mut() = None;
787 let is_video =
790 crate::media_kinds::asset_kind(&mime) == crate::media_kinds::AssetKind::Video;
791 log::debug!(
792 "Lightbox load: asset={} file={:?} mime={} source={} full_res={} kind={}",
793 asset_id,
794 filename,
795 mime,
796 if local_path.is_empty() {
797 "remote"
798 } else {
799 "local"
800 },
801 full_res,
802 if is_video { "video" } else { "image" },
803 );
804 if is_video {
805 *video_badge_target.borrow_mut() =
806 Some((local_path.clone(), asset_id.clone(), filename.clone()));
807 video_badge_button.set_visible(true);
808 } else {
809 *video_badge_target.borrow_mut() = None;
810 video_badge_button.set_visible(false);
811 }
812 if let Some(texture) = ui
813 .ctx
814 .thumbnail_cache
815 .get_cached(&asset_id, ThumbnailSize::Preview)
816 {
817 target.set_paintable(Some(&texture));
818 }
819 let arm_delay_ms: u64 = if local_path.is_empty() { 120 } else { 250 };
824 let loader_for_arm = loader.clone();
825 let cancel_loader = Rc::new(Cell::new(false));
826 let cancel_for_arm = cancel_loader.clone();
827 glib::timeout_add_local(std::time::Duration::from_millis(arm_delay_ms), move || {
828 if !cancel_for_arm.get() {
829 loader_for_arm.set_reveal_child(true);
830 }
831 glib::ControlFlow::Break
832 });
833 glib::MainContext::default().spawn_local(async move {
834 let is_current = || load_gen.get() == our_gen;
835 let commit_visible = || {
838 let pic_stack = pic_stack.clone();
839 let active_a = active_a.clone();
840 glib::idle_add_local_once(move || {
841 pic_stack.set_visible_child_name(if target_is_a { "a" } else { "b" });
842 active_a.set(target_is_a);
843 });
844 };
845 let show_unavailable = |path: Option<String>| {
846 unavailable_filename.set_label(&filename);
847 unavailable_mime.set_label(&mime);
848 unavailable_open.set_visible(path.is_some());
849 *unavailable_path.borrow_mut() = path;
850 unavailable_overlay.set_can_target(true);
851 unavailable_overlay.set_reveal_child(true);
852 };
853 if is_video {
854 let thumb_result = ui
856 .ctx
857 .thumbnail_cache
858 .load_thumbnail(&asset_id, ThumbnailSize::Preview)
859 .await;
860 if !is_current() {
861 return;
862 }
863 if let Ok(texture) = thumb_result {
864 target.set_paintable(Some(&texture));
865 }
866 commit_visible();
867 cancel_loader.set(true);
868 loader.set_reveal_child(false);
869 return;
870 }
871 if !local_path.is_empty() {
872 if let Some(texture) =
873 load_texture_oriented(std::path::Path::new(&local_path)).await
874 {
875 if is_current() {
876 target.set_paintable(Some(&texture));
877 *lightbox_drag_path.borrow_mut() =
878 Some(std::path::PathBuf::from(&local_path));
879 commit_visible();
880 cancel_loader.set(true);
881 loader.set_reveal_child(false);
882 }
883 return;
884 }
885 if asset_id.starts_with(crate::library::LOCAL_ID_PREFIX) {
886 if is_current() {
887 show_unavailable(Some(local_path));
888 commit_visible();
889 cancel_loader.set(true);
890 loader.set_reveal_child(false);
891 }
892 return;
893 }
894 }
895 if full_res {
896 if let Some(cache_dir) = crate::profile::cache_dir().map(|p| p.join("preview"))
897 {
898 let _ = std::fs::create_dir_all(&cache_dir);
899 let temp = original_preview_cache_path(&cache_dir, &asset_id, &filename);
900 if temp.exists() {
901 log::debug!(
902 "Lightbox original cache hit for {} at {}",
903 asset_id,
904 temp.display(),
905 );
906 } else {
907 let download_started = std::time::Instant::now();
908 let download_result = {
909 begin_download_session(&ui.ctx, format!("preview {asset_id}"));
910 let progress = track_download_item(
911 &ui.ctx,
912 asset_id.clone(),
913 Some(format!("preview {asset_id}")),
914 None,
915 );
916 let result = ui
917 .ctx
918 .api_client
919 .download_original_to_file(&asset_id, &temp, Some(progress))
920 .await;
921 finish_download_item(&ui.ctx, &asset_id);
922 result
923 };
924 if let Err(err) = download_result {
925 log::warn!("Lightbox original fetch failed: {}", err);
926 if is_current() {
927 show_unavailable(None);
928 commit_visible();
929 cancel_loader.set(true);
930 loader.set_reveal_child(false);
931 }
932 return;
933 }
934 let downloaded_bytes =
935 std::fs::metadata(&temp).map(|m| m.len()).unwrap_or(0);
936 log::debug!(
937 "Lightbox original downloaded for {} in {}ms ({} bytes)",
938 asset_id,
939 download_started.elapsed().as_millis(),
940 downloaded_bytes,
941 );
942 }
943 let decoded = load_texture_oriented(&temp).await;
944 if !is_current() {
945 return;
946 }
947 if let Some(texture) = decoded {
948 target.set_paintable(Some(&texture));
949 *lightbox_drag_path.borrow_mut() =
950 drag_export_path(&asset_id, &filename, &temp);
951 } else {
952 show_unavailable(Some(temp.display().to_string()));
953 }
954 commit_visible();
955 } else if is_current() {
956 show_unavailable(None);
957 commit_visible();
958 }
959 } else {
960 let thumb_result = ui
961 .ctx
962 .thumbnail_cache
963 .load_thumbnail(&asset_id, ThumbnailSize::Preview)
964 .await;
965 if !is_current() {
966 return;
967 }
968 match thumb_result {
969 Ok(texture) => target.set_paintable(Some(&texture)),
970 Err(_) => show_unavailable(None),
971 }
972 commit_visible();
973 }
974 if is_current() {
975 cancel_loader.set(true);
976 loader.set_reveal_child(false);
977 }
978 });
979 }
980 });
981
982 let nav_dir = Rc::new(Cell::new(0i8));
984 let render = Rc::new({
985 let ui = ui.clone();
986 let page = page.clone();
987 let pos_cell = pos_cell.clone();
988 let load_into_picture = load_into_picture.clone();
989 let resolution_toggle = resolution_toggle.clone();
990 let download = download.clone();
991 let zoom_group = zoom_group.clone();
992 let prev_btn = prev_btn.clone();
993 let next_btn = next_btn.clone();
994 let details_filename = details_filename.clone();
995 let details_summary = details_summary.clone();
996 let details_loading = details_loading.clone();
997 let details_exif = details_exif.clone();
998 let pic_stack = pic_stack.clone();
999 let picture_a = picture_a.clone();
1000 let picture_b = picture_b.clone();
1001 let scrolled_picture = scrolled_picture.clone();
1002 let active_a = active_a.clone();
1003 let zoom_level = zoom_level.clone();
1004 let zoom_reset_btn = zoom_reset_btn.clone();
1005 let nav_dir = nav_dir.clone();
1006 move || {
1007 let pos = pos_cell.get();
1008 let n = ui.grid.model.n_items();
1009 let Some(item) = ui.grid.model.item(pos).and_downcast::<AssetObject>() else {
1010 return;
1011 };
1012 let asset_id = item.property::<String>("id");
1013 let filename = item.property::<String>("filename");
1014 let local_path = item.property::<String>("local-path");
1015 let mime = item.property::<String>("mime-type");
1016 let created = item.property::<String>("created-at");
1017 let sync_state = item.property::<u32>("sync-state");
1018
1019 let cap = if ui.split.is_collapsed() { 14 } else { 24 };
1020 page.set_title(&truncate_filename(&filename, cap));
1021 details_filename.set_label(&filename);
1022 let sync_label = match sync_state {
1023 2 => "On Immich and locally",
1024 1 => "Local only",
1025 _ => "On Immich only",
1026 };
1027 details_summary.set_label(&format!(
1028 "{} · {}\nCreated: {}",
1029 mime,
1030 sync_label,
1031 format_datetime_display(&created)
1032 ));
1033
1034 while let Some(c) = details_exif.first_child() {
1035 details_exif.remove(&c);
1036 }
1037 details_exif.set_visible(false);
1038
1039 prev_btn.set_sensitive(pos > 0);
1040 next_btn.set_sensitive(pos + 1 < n);
1041
1042 let is_local = !local_path.is_empty() && asset_id.starts_with(LOCAL_ID_PREFIX);
1043 let is_video =
1044 crate::media_kinds::asset_kind(&mime) == crate::media_kinds::AssetKind::Video;
1045 resolution_toggle.set_visible(!is_local && !is_video);
1046 download.set_visible(!is_local && !is_video);
1047 zoom_group.set_visible(!is_video);
1048
1049 let target_is_a = !active_a.get();
1053 let target = if target_is_a {
1054 picture_a.clone()
1055 } else {
1056 picture_b.clone()
1057 };
1058 zoom_level.set(1.0);
1059 apply_lightbox_zoom(&target, &scrolled_picture, 1.0);
1060 zoom_reset_btn.set_label("100%");
1061 pic_stack.set_transition_type(match nav_dir.get() {
1062 1 => gtk::StackTransitionType::SlideLeft,
1063 -1 => gtk::StackTransitionType::SlideRight,
1064 _ => gtk::StackTransitionType::None,
1065 });
1066 (*load_into_picture)(
1067 target,
1068 target_is_a,
1069 asset_id.clone(),
1070 filename.clone(),
1071 mime.clone(),
1072 local_path.clone(),
1073 resolution_toggle.is_active(),
1074 );
1075 nav_dir.set(0);
1076
1077 if is_local
1078 && crate::media_kinds::asset_kind(&mime) != crate::media_kinds::AssetKind::Video
1079 {
1080 details_loading.set_visible(true);
1083 let pos_cell_async = pos_cell.clone();
1084 let details_loading = details_loading.clone();
1085 let details_exif = details_exif.clone();
1086 let local_path_async = local_path.clone();
1087 glib::MainContext::default().spawn_local(async move {
1088 let cache_root = local_exif::cache_root();
1089 let path_for_blocking = local_path_async.clone();
1090 let probed = tokio::task::spawn_blocking(move || {
1091 let path = std::path::Path::new(&path_for_blocking);
1092 let meta = std::fs::metadata(path).ok();
1093 let file_size = meta.as_ref().map(|m| m.len());
1094 let mtime_iso = meta
1095 .as_ref()
1096 .and_then(|m| m.modified().ok())
1097 .map(systemtime_to_rfc3339);
1098 let dims = gtk::gdk_pixbuf::Pixbuf::file_info(path)
1101 .map(|(_, w, h)| (w, h))
1102 .and_then(|(w, h)| {
1103 let w = u32::try_from(w).ok()?;
1104 let h = u32::try_from(h).ok()?;
1105 Some((w, h))
1106 });
1107 let exif = local_exif::load_or_extract(&cache_root, path);
1108 LocalProbe {
1109 exif,
1110 file_size,
1111 mtime_iso,
1112 dims,
1113 }
1114 })
1115 .await
1116 .ok();
1117 if pos_cell_async.get() != pos {
1118 return;
1119 }
1120 details_loading.set_visible(false);
1121 let Some(probe) = probed else {
1126 return;
1127 };
1128 if probe.exif.is_none()
1129 && probe.file_size.is_none()
1130 && probe.dims.is_none()
1131 && probe.mtime_iso.is_none()
1132 {
1133 return;
1134 }
1135 let mut info = probe
1136 .exif
1137 .as_ref()
1138 .map_or_else(LocalExif::default, Clone::clone);
1139 if info.image_width.is_none() {
1140 info.image_width = probe.dims.map(|(w, _)| w);
1141 }
1142 if info.image_height.is_none() {
1143 info.image_height = probe.dims.map(|(_, h)| h);
1144 }
1145 let used_mtime_fallback = info.date_time_original.is_none();
1146 if used_mtime_fallback {
1147 info.date_time_original = probe.mtime_iso.clone();
1148 }
1149 let taken_label = if used_mtime_fallback {
1150 "Modified"
1151 } else {
1152 "Taken"
1153 };
1154 let projected = exif_from_local(&info, probe.file_size);
1155 fill_exif_box(&details_exif, &projected, taken_label);
1156 details_exif.set_visible(true);
1157 });
1158 return;
1159 }
1160
1161 details_loading.set_visible(true);
1162 let pos_cell_async = pos_cell.clone();
1163 let ui_async = ui.clone();
1164 let details_loading = details_loading.clone();
1165 let details_exif = details_exif.clone();
1166 let asset_id_async = asset_id.clone();
1167 glib::MainContext::default().spawn_local(async move {
1168 let result = ui_async
1169 .ctx
1170 .api_client
1171 .fetch_asset_details(&asset_id_async)
1172 .await;
1173 if pos_cell_async.get() != pos {
1174 return;
1175 }
1176 details_loading.set_visible(false);
1177 let Ok(details) = result else { return };
1178 if let Some(exif) = details.exif_info {
1179 fill_exif_box(&details_exif, &exif, "Taken");
1180 details_exif.set_visible(true);
1181 }
1182 });
1183 }
1184 });
1185
1186 (*render)();
1187
1188 let goto_prev = Rc::new(clone!(
1189 #[strong]
1190 pos_cell,
1191 #[strong]
1192 render,
1193 #[strong]
1194 nav_dir,
1195 move || {
1196 let pos = pos_cell.get();
1197 if pos > 0 {
1198 pos_cell.set(pos - 1);
1199 nav_dir.set(-1);
1200 (*render)();
1201 }
1202 }
1203 ));
1204 prev_btn.connect_clicked(clone!(
1205 #[strong]
1206 goto_prev,
1207 move |_| (*goto_prev)()
1208 ));
1209 let goto_next = Rc::new(clone!(
1210 #[strong]
1211 ui,
1212 #[strong]
1213 pos_cell,
1214 #[strong]
1215 render,
1216 #[strong]
1217 next_btn,
1218 #[strong]
1219 nav_dir,
1220 move || {
1221 let pos = pos_cell.get();
1222 if pos + 1 < ui.grid.model.n_items() {
1223 pos_cell.set(pos + 1);
1224 nav_dir.set(1);
1225 (*render)();
1226 return;
1227 }
1228 let next_request = ui.ctx.library_state.lock().load_next_page_if_needed();
1229 let Some(req) = next_request else {
1230 return;
1231 };
1232 next_btn.set_sensitive(false);
1233 let model = ui.grid.model.clone();
1234 let pos_cell_h = pos_cell.clone();
1235 let render_h = render.clone();
1236 let next_btn_h = next_btn.clone();
1237 let nav_dir_h = nav_dir.clone();
1238 let prev_count = model.n_items();
1239 let handler_id = Rc::new(std::cell::RefCell::new(None::<glib::SignalHandlerId>));
1240 let handler_id_clone = handler_id.clone();
1241 let id = model.connect_items_changed(move |m, _, _, _| {
1242 if m.n_items() <= prev_count {
1243 return;
1244 }
1245 let pos = pos_cell_h.get();
1246 if pos + 1 < m.n_items() {
1247 pos_cell_h.set(pos + 1);
1248 nav_dir_h.set(1);
1249 (*render_h)();
1250 }
1251 next_btn_h.set_sensitive(true);
1252 if let Some(hid) = handler_id_clone.borrow_mut().take() {
1253 m.disconnect(hid);
1254 }
1255 });
1256 *handler_id.borrow_mut() = Some(id);
1257 load_source_page(ui.clone(), req, true);
1258 }
1259 ));
1260
1261 next_btn.connect_clicked(clone!(
1262 #[strong]
1263 goto_next,
1264 move |_| (*goto_next)()
1265 ));
1266
1267 let active_picture = clone!(
1268 #[strong]
1269 active_a,
1270 #[strong]
1271 picture_a,
1272 #[strong]
1273 picture_b,
1274 move || -> gtk::Picture {
1275 if active_a.get() {
1276 picture_a.clone()
1277 } else {
1278 picture_b.clone()
1279 }
1280 }
1281 );
1282
1283 let cursor_pos: Rc<Cell<Option<(f64, f64)>>> = Rc::new(Cell::new(None));
1286 let motion = gtk::EventControllerMotion::new();
1287 motion.connect_motion(clone!(
1288 #[strong]
1289 cursor_pos,
1290 move |_, x, y| {
1291 cursor_pos.set(Some((x, y)));
1292 }
1293 ));
1294 motion.connect_leave(clone!(
1295 #[strong]
1296 cursor_pos,
1297 move |_| {
1298 cursor_pos.set(None);
1299 }
1300 ));
1301 scrolled_picture.add_controller(motion);
1302
1303 let set_zoom = Rc::new(clone!(
1304 #[strong]
1305 zoom_level,
1306 #[strong]
1307 active_picture,
1308 #[strong]
1309 scrolled_picture,
1310 #[strong]
1311 zoom_reset_btn,
1312 #[strong]
1313 cursor_pos,
1314 move |z: f64| {
1315 let z_new = z.clamp(1.0, 10.0);
1316 let z_old = zoom_level.get();
1317 if (z_new - z_old).abs() < 0.0001 {
1318 zoom_reset_btn.set_label(&format!("{}%", (z_new * 100.0).round() as i32));
1319 return;
1320 }
1321
1322 let viewer_w = scrolled_picture.width().max(1) as f64;
1324 let viewer_h = scrolled_picture.height().max(1) as f64;
1325 let (fx, fy) = cursor_pos
1326 .get()
1327 .filter(|&(x, y)| x >= 0.0 && y >= 0.0 && x <= viewer_w && y <= viewer_h)
1328 .unwrap_or((viewer_w / 2.0, viewer_h / 2.0));
1329
1330 let hadj = scrolled_picture.hadjustment();
1331 let vadj = scrolled_picture.vadjustment();
1332 let scroll_x = hadj.value();
1333 let scroll_y = vadj.value();
1334 let ratio = z_new / z_old.max(0.0001);
1335 let target_scroll_x = (scroll_x + fx) * ratio - fx;
1336 let target_scroll_y = (scroll_y + fy) * ratio - fy;
1337
1338 zoom_level.set(z_new);
1339 let content = apply_lightbox_zoom(&active_picture(), &scrolled_picture, z_new);
1340 zoom_reset_btn.set_label(&format!("{}%", (z_new * 100.0).round() as i32));
1341
1342 if let Some((cw, ch)) = content {
1347 hadj.set_upper(cw.max(viewer_w));
1348 hadj.set_page_size(viewer_w);
1349 vadj.set_upper(ch.max(viewer_h));
1350 vadj.set_page_size(viewer_h);
1351 } else {
1352 hadj.set_upper(viewer_w);
1353 hadj.set_page_size(viewer_w);
1354 vadj.set_upper(viewer_h);
1355 vadj.set_page_size(viewer_h);
1356 }
1357 hadj.set_value(target_scroll_x);
1358 vadj.set_value(target_scroll_y);
1359 }
1360 ));
1361
1362 let zoom_by = Rc::new(clone!(
1363 #[strong]
1364 zoom_level,
1365 #[strong]
1366 set_zoom,
1367 move |factor: f64| {
1368 (*set_zoom)(zoom_level.get() * factor);
1369 }
1370 ));
1371
1372 let zoom_reset = Rc::new(clone!(
1373 #[strong]
1374 set_zoom,
1375 move || {
1376 (*set_zoom)(1.0);
1377 }
1378 ));
1379
1380 zoom_in_btn.connect_clicked(clone!(
1381 #[strong]
1382 zoom_by,
1383 move |_| (*zoom_by)(1.2)
1384 ));
1385 zoom_out_btn.connect_clicked(clone!(
1386 #[strong]
1387 zoom_by,
1388 move |_| (*zoom_by)(1.0 / 1.2)
1389 ));
1390 zoom_reset_btn.connect_clicked(clone!(
1391 #[strong]
1392 zoom_reset,
1393 move |_| (*zoom_reset)()
1394 ));
1395
1396 let pinch = gtk::GestureZoom::new();
1399 let pinch_start = Rc::new(Cell::new(1.0_f64));
1400 pinch.connect_begin(clone!(
1401 #[strong]
1402 zoom_level,
1403 #[strong]
1404 pinch_start,
1405 move |_, _| {
1406 pinch_start.set(zoom_level.get());
1407 }
1408 ));
1409 pinch.connect_scale_changed(clone!(
1410 #[strong]
1411 pinch_start,
1412 #[strong]
1413 set_zoom,
1414 move |_, scale| {
1415 (*set_zoom)(pinch_start.get() * scale);
1416 }
1417 ));
1418 scrolled_picture.add_controller(pinch.clone());
1419
1420 let drag_start = Rc::new(Cell::new((0.0_f64, 0.0_f64)));
1425 let drag = gtk::GestureDrag::new();
1426 drag.set_button(gtk::gdk::BUTTON_PRIMARY);
1427 drag.connect_drag_begin(clone!(
1428 #[strong]
1429 scrolled_picture,
1430 #[strong]
1431 drag_start,
1432 move |_, _, _| {
1433 let hadj = scrolled_picture.hadjustment();
1434 let vadj = scrolled_picture.vadjustment();
1435 drag_start.set((hadj.value(), vadj.value()));
1436 }
1437 ));
1438 drag.connect_drag_update(clone!(
1439 #[strong]
1440 scrolled_picture,
1441 #[strong]
1442 drag_start,
1443 move |_, off_x, off_y| {
1444 let (sx0, sy0) = drag_start.get();
1445 scrolled_picture.hadjustment().set_value(sx0 - off_x);
1446 scrolled_picture.vadjustment().set_value(sy0 - off_y);
1447 }
1448 ));
1449 scrolled_picture.add_controller(drag.clone());
1450 drag.group_with(&pinch);
1451
1452 let double_click = gtk::GestureClick::new();
1454 double_click.set_button(gtk::gdk::BUTTON_PRIMARY);
1455 double_click.connect_pressed(clone!(
1456 #[strong]
1457 cursor_pos,
1458 #[strong]
1459 zoom_level,
1460 #[strong]
1461 set_zoom,
1462 move |_, n_press, x, y| {
1463 if n_press == 2 {
1464 cursor_pos.set(Some((x, y)));
1465 (*set_zoom)(zoom_level.get() * 2.0);
1466 }
1467 }
1468 ));
1469 scrolled_picture.add_controller(double_click);
1470
1471 let middle_click = gtk::GestureClick::new();
1473 middle_click.set_button(gtk::gdk::BUTTON_MIDDLE);
1474 middle_click.connect_pressed(clone!(
1475 #[strong]
1476 zoom_reset,
1477 move |_, _, _, _| {
1478 (*zoom_reset)();
1479 }
1480 ));
1481 scrolled_picture.add_controller(middle_click);
1482
1483 let right_click = gtk::GestureClick::new();
1485 right_click.set_button(gtk::gdk::BUTTON_SECONDARY);
1486 right_click.connect_pressed(clone!(
1487 #[strong]
1488 ui,
1489 #[strong]
1490 pos_cell,
1491 #[strong]
1492 scrolled_picture,
1493 move |_, _, x, y| {
1494 show_asset_context_menu(ui.clone(), &scrolled_picture, pos_cell.get(), x, y);
1495 }
1496 ));
1497 scrolled_picture.add_controller(right_click);
1498
1499 let swipe = gtk::GestureSwipe::new();
1501 swipe.set_touch_only(false);
1502 swipe.connect_swipe(clone!(
1503 #[strong]
1504 goto_prev,
1505 #[strong]
1506 goto_next,
1507 #[strong]
1508 zoom_level,
1509 move |_, vx, _vy| {
1510 if (zoom_level.get() - 1.0).abs() > 0.01 {
1512 return;
1513 }
1514 const MIN_VELOCITY: f64 = 50.0;
1517 if vx < -MIN_VELOCITY {
1518 (*goto_next)();
1519 } else if vx > MIN_VELOCITY {
1520 (*goto_prev)();
1521 }
1522 }
1523 ));
1524 pic_stack.add_controller(swipe);
1525
1526 let key_controller = gtk::EventControllerKey::new();
1527 key_controller.connect_key_pressed(clone!(
1528 #[strong]
1529 ui,
1530 #[strong]
1531 details_btn,
1532 #[strong]
1533 goto_prev,
1534 #[strong]
1535 goto_next,
1536 #[strong]
1537 zoom_by,
1538 #[strong]
1539 zoom_reset,
1540 move |_, key, _, mods| {
1541 let ctrl = mods.contains(gtk::gdk::ModifierType::CONTROL_MASK);
1542 match (ctrl, key) {
1543 (true, gtk::gdk::Key::plus)
1544 | (true, gtk::gdk::Key::equal)
1545 | (true, gtk::gdk::Key::KP_Add) => {
1546 (*zoom_by)(1.2);
1547 glib::Propagation::Stop
1548 }
1549 (true, gtk::gdk::Key::minus) | (true, gtk::gdk::Key::KP_Subtract) => {
1550 (*zoom_by)(1.0 / 1.2);
1551 glib::Propagation::Stop
1552 }
1553 (true, gtk::gdk::Key::_0) | (true, gtk::gdk::Key::KP_0) => {
1554 (*zoom_reset)();
1555 glib::Propagation::Stop
1556 }
1557 (false, gtk::gdk::Key::Left) => {
1558 (*goto_prev)();
1559 glib::Propagation::Stop
1560 }
1561 (false, gtk::gdk::Key::Right) => {
1562 (*goto_next)();
1563 glib::Propagation::Stop
1564 }
1565 (false, gtk::gdk::Key::i) | (false, gtk::gdk::Key::I) => {
1566 details_btn.set_active(!details_btn.is_active());
1567 glib::Propagation::Stop
1568 }
1569 (false, gtk::gdk::Key::Escape) => {
1570 ui.nav.pop();
1571 glib::Propagation::Stop
1572 }
1573 _ => glib::Propagation::Proceed,
1574 }
1575 }
1576 ));
1577 page.add_controller(key_controller);
1578
1579 let zoom_scroll = gtk::EventControllerScroll::new(gtk::EventControllerScrollFlags::BOTH_AXES);
1583 zoom_scroll.set_propagation_phase(gtk::PropagationPhase::Capture);
1584 zoom_scroll.connect_scroll(clone!(
1585 #[strong]
1586 zoom_by,
1587 move |ctrl, dx, dy| {
1588 let mods = ctrl.current_event_state();
1589 if !mods.contains(gtk::gdk::ModifierType::CONTROL_MASK) {
1590 return glib::Propagation::Proceed;
1591 }
1592 let delta = if dy != 0.0 { dy } else { dx };
1593 if delta == 0.0 {
1594 return glib::Propagation::Proceed;
1595 }
1596 let factor = if delta < 0.0 { 1.1 } else { 1.0 / 1.1 };
1597 (*zoom_by)(factor);
1598 glib::Propagation::Stop
1599 }
1600 ));
1601 scrolled_picture.add_controller(zoom_scroll);
1602
1603 download.connect_clicked(clone!(
1604 #[strong]
1605 ui,
1606 #[strong]
1607 pos_cell,
1608 move |_| {
1609 let pos = pos_cell.get();
1610 if let Some(item) = ui.grid.model.item(pos).and_downcast::<AssetObject>() {
1611 let asset_id = item.property::<String>("id");
1612 let filename = item.property::<String>("filename");
1613 if !asset_id.starts_with(LOCAL_ID_PREFIX) {
1614 start_download(ui.clone(), asset_id, filename);
1615 }
1616 }
1617 }
1618 ));
1619
1620 resolution_toggle.connect_toggled(clone!(
1621 #[strong]
1622 render,
1623 move |btn| {
1624 btn.set_label(if btn.is_active() { "Raw" } else { "Prev" });
1625 (*render)();
1626 }
1627 ));
1628
1629 ui.nav.push(&page);
1630}
1631
1632#[cfg(test)]
1633mod tests {
1634 use super::original_preview_cache_path;
1635
1636 #[test]
1637 fn original_preview_cache_path_keeps_decoder_extension() {
1638 let cache = std::path::Path::new("/tmp/previews");
1639 assert_eq!(
1640 original_preview_cache_path(cache, "remote-id", "PXL_20250516_222429137.dng"),
1641 cache.join("remote-id.dng")
1642 );
1643 assert_eq!(
1644 original_preview_cache_path(cache, "remote-id", "extensionless"),
1645 cache.join("remote-id.bin")
1646 );
1647 }
1648}