Skip to main content

mimick/library/
lightbox.rs

1//! Lightbox image viewer: full-screen preview with zoom, pan, EXIF details, and keyboard navigation.
2//!
3//! Loads preview or original resolution images with pinch-zoom and
4//! swipe navigation. Displays an EXIF metadata panel and provides
5//! download-to-folder and delete-to-trash actions.
6
7use 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
25/// Everything we can learn about a local file off the main thread before
26/// handing the data back to GTK. `exif` is the cached EXIF parse; `dims`
27/// comes from a pixbuf header-only read; `mtime_iso` falls back when the
28/// file has no `DateTimeOriginal` so we always show *some* date.
29struct LocalProbe {
30    exif: Option<LocalExif>,
31    file_size: Option<u64>,
32    mtime_iso: Option<String>,
33    dims: Option<(u32, u32)>,
34}
35
36/// Render a `SystemTime` as an RFC3339 string in the local timezone so the
37/// existing `format_datetime_display` pipeline can format it consistently
38/// with EXIF-derived timestamps.
39fn 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
45/// Project local-file metadata into the API EXIF shape so the existing
46/// renderer handles both sources uniformly.
47fn 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
82/// Create a properly-named export copy of a cached file for drag-out.
83///
84/// Returns `Some(path)` with the original filename visible to file managers.
85/// Falls back to `source` if the export directory isn't available.
86fn 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
105/// Populate the details sidebar with sectioned EXIF metadata.
106///
107/// Groups fall into three categories — Camera, Image, Location — each rendered
108/// as an `AdwPreferencesGroup` with accent-coloured prefix icons. Empty groups
109/// (e.g. an image with no GPS) are skipped so the pane stays compact.
110///
111/// `taken_label` decides whether the date row reads "Taken" (EXIF
112/// DateTimeOriginal — the camera's capture moment) or "Modified" (filesystem
113/// mtime fallback when no capture timestamp exists).
114fn fill_exif_box(container: &gtk::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(&note_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
316/// Format a byte count value into a human-readable size string (e.g. KB, MB, GB).
317fn 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
331/// Format an ISO 8601 timestamp for display, converting from UTC to the
332/// user's local timezone.
333///
334/// Immich normalises `date_time_original` and `fileCreatedAt` to UTC before
335/// storage, so a photo taken at 19:55:15+05:30 is stored as
336/// 2024-01-15T14:25:15.000Z. We parse the UTC value and convert it to the
337/// system's local timezone so the displayed time matches what the camera
338/// originally recorded. Falls back to the raw string if parsing fails.
339fn format_datetime_display(iso: &str) -> String {
340    use chrono::{DateTime, Local, Utc};
341    // Try offset-aware parse first (handles +05:30, Z, etc.)
342    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    // Fallback: try treating as UTC
347    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    // Last resort: strip trailing fractional seconds / timezone suffix
352    iso.get(..19).unwrap_or(iso).replace('T', " ").to_string()
353}
354
355/// Truncate a filename to a maximum character limit, appending an ellipsis if needed.
356fn 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
366/// Apply zoom to a lightbox Picture. Zoom is fit-relative: 1.0 = the size the
367/// texture would occupy inside `viewer` under Contain layout. >1.0 overflows
368/// the viewer for panning. Returns the computed content dimensions when zoomed.
369fn apply_lightbox_zoom(
370    picture: &gtk::Picture,
371    viewer: &gtk::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
399/// Construct and present the fullscreen lightbox view for a selected asset.
400pub(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    // Two picture widgets in a stack so navigation can slide between them.
466    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    // Spinner overlay: a centered Mimick app icon that rotates while a
497    // full-resolution texture is being fetched / decoded. Hidden by default;
498    // the load_into_picture closure toggles it.
499    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    // Video poster badge: clickable play icon shown over the still thumbnail
565    // when the current asset is a video; hands off to the grid's player flow.
566    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    // Drag source for exporting the current asset's original file.
598    // `lightbox_drag_path` is updated by the load logic whenever an asset
599    // is displayed (from local path or preview cache).
600    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    // On narrow widths, hide the prev/next header buttons -- Left/Right
725    // keyboard shortcuts still work, and the saved space lets the title fit.
726    // The details toggle stays visible so users can still access the EXIF pane.
727    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    // Increments on every navigation. Async load tasks capture the generation
744    // they were started for and skip UI writes if the user has navigated away
745    // by the time their decode finishes (relevant for slow RAW files).
746    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            // Clear drag path while loading; updated once resolved.
786            *lightbox_drag_path.borrow_mut() = None;
787            // Videos use the still thumbnail as a poster + play badge; image
788            // decoders would all fail and fall through to "unavailable".
789            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            // Reveal the spinner after a short delay so fast cache hits and
820            // quick JPEG decodes don't flash it. Local paths get a longer
821            // delay since most JPEGs decode in well under 250ms, but RAW
822            // and large TIFF decodes can run for seconds and need feedback.
823            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                // Defer the child switch by one idle so the target picture
836                // re-measures with the new texture before the slide starts.
837                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                    // Use the grid's preview thumbnail as the poster.
855                    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    // -1 = back/prev (slide right), +1 = forward/next (slide left), 0 = no transition
983    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            // Load into the *inactive* picture; commit the slide transition
1050            // after the texture is set so the user sees the current image
1051            // (with loader spinner) until the new one is actually ready.
1052            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                // Local image: parse EXIF on a blocking worker.
1081                // Hits the on-disk cache so repeat opens are cheap.
1082                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                        // Pixbuf header-only read — covers JPEG, PNG, GIF,
1099                        // TIFF, WebP and HEIF/AVIF (when loaders installed).
1100                        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                    // Render whatever we have. Files without an EXIF block
1122                    // (Unsplash, screenshots, edited copies) still get the
1123                    // Image group populated from filesystem + Pixbuf, so the
1124                    // user always sees *something* in the details pane.
1125                    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    // Track cursor position over the picture area so zoom can be focal-point
1284    // aware. None when the cursor is outside the viewer; falls back to centre.
1285    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            // Pick the focal point: cursor if inside the viewer, else centre.
1323            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            // Pre-set adjustment ranges to match the new content size so the
1343            // scroll position can be applied in the same frame. Without this
1344            // the value would be clamped to the stale (old-zoom) range and
1345            // corrected only after layout, causing a one-frame flicker.
1346            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    // Trackpad pinch-to-zoom. On scrolled_picture so it shares a stable
1397    // coordinate frame with drag (see drag comment below).
1398    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    // Click-and-drag panning when zoomed in. Attached to scrolled_picture,
1421    // not pic_stack: pic_stack moves under the cursor when we update the
1422    // scroll adjustments, which makes the gesture's pic_stack-local offset
1423    // oscillate frame-to-frame and jitter the image.
1424    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    // Double-click on the picture: zoom in 2x toward the click position.
1453    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    // Middle-click: reset zoom to 100%.
1472    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    // Right-click: open the standard asset context menu.
1484    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    // Horizontal swipe for prev/next navigation; ignored when zoomed in.
1500    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            // Ignore swipes when zoomed in — those should pan instead.
1511            if (zoom_level.get() - 1.0).abs() > 0.01 {
1512                return;
1513            }
1514            // vx < 0 means finger moved left → go to next asset.
1515            // vx > 0 means finger moved right → go to previous asset.
1516            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    // Ctrl+wheel zoom on the picture area, captured before the scrolled window
1580    // can use it for panning. Listening on both axes so trackpad two-finger
1581    // scrolls (which sometimes emit horizontal deltas) still trigger zoom.
1582    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}