use crate::*; /// How long the pointer has to rest before the lightbox's controls fade out. const CHROME_TIMEOUT: Duration = Duration::from_secs(2); /// Factor one zoom step (a `+`/`-` key, a wheel notch) multiplies by. const ZOOM_STEP: f64 = 1.25; /// The furthest the lightbox zooms in, as a multiple of the image's own pixels. const MAX_ZOOM: f64 = 8.0; /// How long a slideshow shows each photo. A video plays to its end instead. const SLIDESHOW_INTERVAL: Duration = Duration::from_secs(5); /// How often a slideshow checks whether the video on screen has finished. const SLIDESHOW_VIDEO_POLL: Duration = Duration::from_millis(500); /// How close to the end of the loaded photos stepping forward starts paging the /// next batch in, so reaching the end rarely means waiting for it. const PAGE_AHEAD: u32 = 10; /// The lightbox's mutable parts, shared by [`load_photo`], [`navigate_photo`] and /// the button/key handlers so each one takes a single handle instead of a dozen /// widget arguments. pub(crate) struct Viewer { /// The lightbox itself, so a page that lands after it closed can tell. pub(crate) window: gtk4::Window, /// Swaps the still (`"photo"`, inside [`Self::scroller`]) for the player /// (`"video"`). pub(crate) media: gtk4::Stack, pub(crate) scroller: gtk4::ScrolledWindow, pub(crate) picture: gtk4::Picture, pub(crate) video: gtk4::Video, pub(crate) spinner: Spinner, pub(crate) status: gtk4::Label, pub(crate) title: gtk4::Label, pub(crate) counter: gtk4::Label, pub(crate) prev: gtk4::Button, pub(crate) next: gtk4::Button, /// Details panel: the toggle that reveals it, the rows it fills in, and the /// "Show on map" button (hidden when the photo carries no GPS tags). pub(crate) info_toggle: gtk4::ToggleButton, pub(crate) info_revealer: gtk4::Revealer, pub(crate) info_rows: gtk4::Box, pub(crate) info_map: gtk4::Button, /// Coordinates behind `info_map`, once a photo with GPS tags is shown. pub(crate) coords: RefCell>, /// The favorite toggle in the top bar, and whether it is being set from /// the model rather than by the user — the same suppression the details pane /// uses for its pin switch, so painting a photo doesn't fire a round-trip. pub(crate) favorite: gtk4::ToggleButton, pub(crate) favorite_suppress: Cell, /// The button that switches between the files of one shot, and the files it /// switches between (the JPEG and the RAW of a photo, a live photo and its /// clip). Empty, and the button hidden, for a photo stored as one file. pub(crate) group_btn: gtk4::Button, pub(crate) group: RefCell>, /// uid of the photo currently on screen. pub(crate) uid: RefCell, /// Drive name of the photo on screen, for Save a Copy and Open With. pub(crate) name: RefCell>, /// On-disk path of the full-size photo, once it has been downloaded. pub(crate) path: RefCell>, /// True while the full-size photo is still downloading and what's on screen is /// the upscaled thumbnail — a click-outside-to-close hit test has to size the /// image from the *thumbnail's* ratio in that window, but more importantly a /// late reply for a photo the user has already navigated away from must not /// overwrite the new one. Guarded by comparing against `uid`. pub(crate) loading: Cell, /// Zoom as a multiple of the image's own pixels, or `None` while the photo /// is fitted to the window. pub(crate) zoom: Cell>, /// The zoom a pinch started from, and the scroll offsets a pan started from. pub(crate) pinch_start: Cell, pub(crate) drag_start: Cell<(f64, f64)>, /// Last pointer position over the image, which Ctrl+scroll zooms around. pub(crate) pointer: Cell<(f64, f64)>, /// The top bar and the prev/next buttons, which fade out once the pointer /// rests; whether the pointer is over one of them (which keeps them up); /// and the pending fade-out. pub(crate) chrome: Vec, pub(crate) chrome_hover: Cell, pub(crate) chrome_source: RefCell>, /// The slideshow toggle; the timer that moves it on; and whether it put /// the window in fullscreen, so stopping it only leaves a fullscreen it /// entered itself. pub(crate) slideshow: gtk4::ToggleButton, pub(crate) slide_source: RefCell>, pub(crate) slide_fullscreened: Cell, } /// Camera/exposure/location facts pulled from a photo's own EXIF tags, as /// label/value pairs for the details panel. #[derive(Default)] pub(crate) struct ExifInfo { /// `("Camera", "Apple iPhone 15")` and friends, the label already /// translated; empty when the file has no EXIF at all, which is normal for /// screenshots and re-encoded images. pub(crate) fields: Vec<(String, String)>, /// Decimal degrees, if the photo is geotagged. pub(crate) coords: Option<(f64, f64)>, } /// Paint the favorite toggle without firing its handler. pub(crate) fn show_favorite(viewer: &Rc, favorite: bool) { viewer.favorite_suppress.set(true); viewer.favorite.set_active(favorite); set_favorite_icon(&viewer.favorite, favorite); viewer.favorite_suppress.set(false); } /// Filled star for a favorite, outline for the rest. fn set_favorite_icon(button: >k4::ToggleButton, favorite: bool) { button.set_icon_name(if favorite { "starred-symbolic" } else { "non-starred-symbolic" }); } /// Write a favorite change back into the loaded gallery page, so returning to /// the grid (or reopening the photo) shows what was just set without a reload. pub(crate) fn set_gallery_favorite(ui: &Rc, uid: &str, favorite: bool) { let Some(idx) = find_photo_index(&ui.gallery.model, uid) else { return; }; let Some(boxed) = ui.gallery.model.item(idx).and_downcast::() else { return; }; let mut item = boxed.borrow::().clone(); item.favorite = favorite; ui.gallery.model.remove(idx); ui.gallery.model.insert(idx, &BoxedAnyObject::new(item)); } /// Ask the daemon which files this shot was stored as, and offer a switch when /// there is more than one. The grid shows such a shot as a single tile, so the /// lightbox is the only place the RAW is reachable. fn load_photo_group(ui: &Rc, viewer: &Rc, uid: &str) { viewer.group.borrow_mut().clear(); viewer.group_btn.set_visible(false); let rx = spawn_request( ui.dirs.control_socket(), Request::PhotoGroup { uid: uid.to_string(), }, ); let viewer = viewer.clone(); let uid = uid.to_string(); glib::spawn_future_local(async move { let Ok(Ok(Response::Photos { items, .. })) = rx.recv().await else { return; }; // A late reply for a photo the user has already left must not relabel // the button under the one now on screen. if *viewer.uid.borrow() != uid || items.len() < 2 { return; } if let Some(next) = next_group_member(&items, &uid) { viewer .group_btn .set_tooltip_text(Some(&member_tooltip(next))); } *viewer.group.borrow_mut() = items; viewer.group_btn.set_visible(true); }); } /// The member after `uid`, wrapping round — the button steps through a shot's /// files rather than toggling two of them, so a burst of three works too. pub(crate) fn next_group_member<'a>(members: &'a [PhotoItem], uid: &str) -> Option<&'a PhotoItem> { let at = members.iter().position(|item| item.uid == uid)?; members.get((at + 1) % members.len()) } /// What to call one file of a shot: its name, or its kind when the daemon has /// not resolved a name for it. pub(crate) fn member_label(item: &PhotoItem) -> String { match item.name.as_deref() { Some(name) if !name.is_empty() => name.to_string(), _ => match item.kind { PhotoKind::Raw => "the raw file".to_string(), PhotoKind::Video => "the video".to_string(), PhotoKind::Photo => "the photo".to_string(), }, } } /// The group switch's tooltip, naming the file it will show next. fn member_tooltip(item: &PhotoItem) -> String { match item.name.as_deref() { Some(name) if !name.is_empty() => { // Translators: tooltip of the button that switches to another file of the same photo; {name} is a file name. gettext_f("Show {name}", &[("name", &member_label(item))]) } _ => match item.kind { PhotoKind::Raw => gettext("Show the raw file"), PhotoKind::Video => gettext("Show the video"), PhotoKind::Photo => gettext("Show the photo"), }, } } /// The gallery's entry for `uid`, or the shot member it names when the group /// switch has left the timeline's own photo (a RAW is not in the timeline). fn viewer_item(ui: &Rc, viewer: &Rc, uid: &str) -> Option { find_photo_index(&ui.gallery.model, uid) .and_then(|idx| ui.gallery.model.item(idx)) .and_downcast::() .map(|boxed| boxed.borrow::().clone()) .or_else(|| { viewer .group .borrow() .iter() .find(|item| item.uid == uid) .cloned() }) } /// Show the photo behind `uid`: paint its (already cached) thumbnail immediately /// so the lightbox never opens on a blank screen, ask the daemon for the /// full-size file, and swap it in — plus its EXIF — when it lands. A video /// plays in place once its file is down. pub(crate) fn load_photo(ui: &Rc, viewer: &Rc, uid: String) { viewer.spinner.set_visible(true); viewer.status.set_visible(false); viewer.loading.set(true); *viewer.path.borrow_mut() = None; clear_info(viewer); stop_video(viewer); viewer.media.set_visible_child_name("photo"); reset_zoom(viewer); // A slideshow waits for this photo to land before it starts counting. disarm_slide(viewer); // Looked up before the group is refetched, which empties it. let item = viewer_item(ui, viewer, &uid); let is_video = item .as_ref() .is_some_and(|item| item.kind == PhotoKind::Video); // The favorite state comes from the timeline page the gallery already has, // so the button is right on the first frame instead of after a round-trip. show_favorite(viewer, item.as_ref().is_some_and(|item| item.favorite)); *viewer.name.borrow_mut() = item.and_then(|item| item.name); load_photo_group(ui, viewer, &uid); // The thumbnail the gallery already decoded stands in for the full photo // while it downloads: blurry for a moment beats black for a second. match ui.gallery.photo_tex.borrow().get(&uid) { Some(texture) => viewer.picture.set_paintable(Some(texture)), None => viewer.picture.set_paintable(gtk4::gdk::Paintable::NONE), } let rx = spawn_request( ui.dirs.control_socket(), Request::OpenPhoto { uid: uid.clone() }, ); let viewer = viewer.clone(); let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx.recv().await; // The user may have moved on while this was in flight; that photo's own // request owns the viewer now. if *viewer.uid.borrow() != uid { return; } viewer.spinner.set_visible(false); viewer.loading.set(false); // Armed whether or not the photo rendered: a slideshow steps past a // photo it could not show rather than stalling on it. arm_slide(&ui, &viewer); let fail = |message: &str| { viewer.status.set_label(message); viewer.status.set_visible(true); }; match result { Ok(Ok(Response::FilePath { path })) if is_video => { viewer.video.set_filename(Some(&path)); viewer.media.set_visible_child_name("video"); *viewer.path.borrow_mut() = Some(path.clone()); show_info(&viewer, &path, ExifInfo::default()); } Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) { Ok(texture) => { // Zoomed in on the thumbnail: keep the photo the size it is // on screen, now drawn from the full-size pixels. let rescale = viewer .zoom .get() .zip(intrinsic_size(&viewer.picture)) .map(|(zoom, (width, _))| zoom * width / f64::from(texture.width())); viewer.picture.set_paintable(Some(&texture)); if let Some(zoom) = rescale { viewer.zoom.set(Some(zoom)); zoom_to(&viewer, Some(zoom), None); } *viewer.path.borrow_mut() = Some(path.clone()); show_info(&viewer, &path, read_exif(&path)); } Err(e) => { tracing::error!("Failed to load texture for {path}: {e}"); fail(&gettext("Couldn't render this photo.")); } }, Ok(Ok(Response::Error { message, .. })) => fail(&message), Ok(Ok(_)) => fail(&gettext("Unexpected reply from the Proton Drive service.")), Ok(Err(_)) | Err(_) => fail(&gettext("Couldn't reach Proton Drive.")), } }); } /// Start the slideshow: fullscreen, controls out of the way, and the photo on /// screen counted down once it has loaded. fn start_slideshow(ui: &Rc, viewer: &Rc) { if !viewer.window.is_fullscreen() { viewer.window.fullscreen(); viewer.slide_fullscreened.set(true); } viewer .slideshow .set_icon_name("media-playback-pause-symbolic"); hide_chrome(viewer); if !viewer.loading.get() { arm_slide(ui, viewer); } } /// Stop the slideshow, leaving fullscreen only if the slideshow entered it. fn stop_slideshow(viewer: &Rc) { disarm_slide(viewer); if viewer.slide_fullscreened.replace(false) { viewer.window.unfullscreen(); } viewer .slideshow .set_icon_name("media-playback-start-symbolic"); if viewer.slideshow.is_active() { viewer.slideshow.set_active(false); } } /// Move a running slideshow on from the photo on screen: after /// [`SLIDESHOW_INTERVAL`] for a still, once it has played through for a video. fn arm_slide(ui: &Rc, viewer: &Rc) { disarm_slide(viewer); if !viewer.slideshow.is_active() { return; } let playing_video = viewer.media.visible_child_name().as_deref() == Some("video"); let (ui, weak) = (ui.clone(), Rc::downgrade(viewer)); let source = if playing_video { glib::timeout_add_local(SLIDESHOW_VIDEO_POLL, move || { let Some(viewer) = weak.upgrade() else { return glib::ControlFlow::Break; }; let ended = viewer .video .media_stream() .is_some_and(|stream| stream.is_ended()); if !ended { return glib::ControlFlow::Continue; } // This timer is finishing, so its id must not be removed again. viewer.slide_source.borrow_mut().take(); next_slide(&ui, &viewer); glib::ControlFlow::Break }) } else { glib::timeout_add_local_once(SLIDESHOW_INTERVAL, move || { let Some(viewer) = weak.upgrade() else { return; }; viewer.slide_source.borrow_mut().take(); next_slide(&ui, &viewer); }) }; *viewer.slide_source.borrow_mut() = Some(source); } fn disarm_slide(viewer: &Rc) { if let Some(source) = viewer.slide_source.borrow_mut().take() { source.remove(); } } /// Step the slideshow forward, or end it at the last photo there is. fn next_slide(ui: &Rc, viewer: &Rc) { let model = &ui.gallery.model; let last = find_photo_index(model, &viewer.uid.borrow()) .is_some_and(|index| index + 1 >= model.n_items()); if last && !ui.gallery.has_more.get() { stop_slideshow(viewer); return; } navigate_photo(ui, viewer, 1); } /// Stop whatever the player was playing, so stepping off a video (or closing /// the lightbox) doesn't leave its sound running. fn stop_video(viewer: &Rc) { viewer.video.set_media_stream(gtk4::MediaStream::NONE); } /// Warm the cache with the photo `delta` steps away, so stepping there is /// instant. Fire-and-forget: the daemon serves control connections concurrently, /// so this rides alongside whatever the user does next, and a failure here is /// simply a photo that downloads on arrival like it used to. pub(crate) fn prefetch_photo(ui: &Rc, viewer: &Rc, delta: i32) { let model = &ui.gallery.model; let current = find_photo_index(model, &viewer.uid.borrow()).unwrap_or(0) as i32; let index = current + delta; if index < 0 || index >= model.n_items() as i32 { return; } let Some(photo) = model .item(index as u32) .and_downcast::() .map(|boxed| boxed.borrow::().clone()) else { return; }; // Never prefetch a video: the lightbox skips over it, and warming it would // mean downloading a whole clip the user won't watch from here. if photo.kind == PhotoKind::Video { return; } let rx = spawn_request( ui.dirs.control_socket(), Request::OpenPhoto { uid: photo.uid }, ); glib::spawn_future_local(async move { let _ = rx.recv().await; }); } /// Reset the details drawer while the next photo is in flight, so it never shows /// the previous photo's camera against the new image. pub(crate) fn clear_info(viewer: &Rc) { *viewer.coords.borrow_mut() = None; viewer.info_map.set_visible(false); while let Some(row) = viewer.info_rows.first_child() { viewer.info_rows.remove(&row); } let label = gtk4::Label::builder() .label(gettext("Reading photo details…")) .halign(gtk4::Align::Start) .build(); label.add_css_class("dim-label"); viewer.info_rows.append(&label); } /// Fill the details drawer for the photo now on screen: its own file facts /// (size, dimensions) plus whatever EXIF it carries. pub(crate) fn show_info(viewer: &Rc, path: &str, info: ExifInfo) { while let Some(row) = viewer.info_rows.first_child() { viewer.info_rows.remove(&row); } let mut fields: Vec<(String, String)> = Vec::new(); if let Ok(meta) = std::fs::metadata(path) { // Translators: label of a photo's file size in the details panel. fields.push((pgettext("property", "Size"), human_bytes(meta.len()))); } fields.extend(info.fields); if fields.is_empty() { let label = gtk4::Label::builder() .label(gettext("This photo carries no metadata.")) .halign(gtk4::Align::Start) .wrap(true) .build(); label.add_css_class("dim-label"); viewer.info_rows.append(&label); } else { let group = gtk4::ListBox::new(); group.set_selection_mode(gtk4::SelectionMode::None); group.add_css_class("boxed-list"); for (label, value) in fields { let row = adw::ActionRow::builder() .title(label.as_str()) .subtitle(value) .subtitle_selectable(true) .build(); row.add_css_class("property"); group.append(&row); } viewer.info_rows.append(&group); } viewer.info_map.set_visible(info.coords.is_some()); *viewer.coords.borrow_mut() = info.coords; } /// Read the EXIF tags a gallery viewer cares about out of a decrypted photo on /// disk. Anything missing is simply left out — phone screenshots and re-encoded /// images legitimately carry no EXIF at all. pub(crate) fn read_exif(path: &str) -> ExifInfo { let mut fields: Vec<(String, String)> = Vec::new(); let mut coords = None; let reader = match std::fs::File::open(path) { Ok(file) => { match exif::Reader::new().read_from_container(&mut std::io::BufReader::new(file)) { Ok(reader) => reader, Err(e) => { tracing::debug!("no exif in {path}: {e}"); return ExifInfo { fields, coords }; } } } Err(e) => { tracing::warn!("cannot open {path} for exif: {e}"); return ExifInfo { fields, coords }; } }; let field = |tag: exif::Tag| { reader .get_field(tag, exif::In::PRIMARY) .map(|f| f.display_value().with_unit(&reader).to_string()) }; // Text tags, without the quotes `display_value` wraps each string in and // the NUL padding some cameras leave behind. let ascii = |tag: exif::Tag| { let field = reader.get_field(tag, exif::In::PRIMARY)?; let exif::Value::Ascii(parts) = &field.value else { return None; }; let text = parts .iter() .map(|part| String::from_utf8_lossy(part).into_owned()) .collect::>() .join(" "); let text = text.trim_matches(|c: char| c == '\0' || c.is_whitespace()); (!text.is_empty()).then(|| text.to_string()) }; if let Some(size) = field(exif::Tag::PixelXDimension) .zip(field(exif::Tag::PixelYDimension)) // Translators: a photo's pixel dimensions, such as "4032 × 3024". .map(|(w, h)| gettext_f("{width} × {height}", &[("width", &w), ("height", &h)])) { // Translators: label of a photo's pixel dimensions in the details panel. fields.push((gettext("Dimensions"), size)); } if let Some(taken) = ascii(exif::Tag::DateTimeOriginal) { // Translators: label of when a photo was taken, in the details panel. fields.push((pgettext("photo property", "Taken"), taken)); } if let Some(camera) = camera_name(ascii(exif::Tag::Make), ascii(exif::Tag::Model)) { // Translators: label of the camera model in a photo's details panel. fields.push((gettext("Camera"), camera)); } if let Some(lens) = ascii(exif::Tag::LensModel) { // Translators: label of the lens model in a photo's details panel. fields.push((gettext("Lens"), lens)); } let shutter = reader .get_field(exif::Tag::ExposureTime, exif::In::PRIMARY) .and_then(|f| match &f.value { exif::Value::Rational(r) => r.first().map(|r| r.to_f64()), _ => None, }) .filter(|seconds| seconds.is_finite() && *seconds > 0.0) .map(format_shutter); let iso = reader .get_field(exif::Tag::PhotographicSensitivity, exif::In::PRIMARY) .and_then(|f| f.value.get_uint(0)) // Translators: a photo's ISO sensitivity, such as "ISO 400". .map(|n| gettext_f("ISO {value}", &[("value", &n.to_string())])); let exposure: Vec = [ field(exif::Tag::FNumber), shutter, iso, field(exif::Tag::FocalLength), ] .into_iter() .flatten() .collect(); if !exposure.is_empty() { // Translators: label of aperture, shutter speed, ISO and focal length in a photo's details panel. fields.push((gettext("Exposure"), exposure.join(" · "))); } if let Some(lat) = gps_degrees(&reader, exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef) && let Some(lon) = gps_degrees(&reader, exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef) { let (lat_text, lon_text) = (format!("{lat:.5}"), format!("{lon:.5}")); // Translators: label of where a photo was taken, in the details panel. let label = pgettext("photo property", "Location"); // Translators: GPS coordinates in decimal degrees, such as "52.52000, 13.40500". let value = gettext_f( "{latitude}, {longitude}", &[("latitude", &lat_text), ("longitude", &lon_text)], ); fields.push((label, value)); coords = Some((lat, lon)); } ExifInfo { fields, coords } } /// "Google Pixel 6" from Make "Google" and Model "Pixel 6", but just "Canon EOS /// R5" where the model already names the maker. fn camera_name(make: Option, model: Option) -> Option { match (make, model) { (Some(make), Some(model)) if model.to_lowercase().starts_with(&make.to_lowercase()) => { Some(model) } (Some(make), Some(model)) => Some(format!("{make} {model}")), (make, model) => make.or(model), } } /// A shutter speed the way cameras print it: "1/250 s" below a second, "2.5 s" /// from one up. fn format_shutter(seconds: f64) -> String { if seconds >= 0.95 { let text = format!("{seconds:.1}"); format!("{} s", text.strip_suffix(".0").unwrap_or(&text)) } else { format!("1/{} s", (1.0 / seconds).round()) } } /// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to /// decimal degrees, negating for the S/W hemispheres. pub(crate) fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option { let field = reader.get_field(tag, exif::In::PRIMARY)?; let exif::Value::Rational(dms) = &field.value else { return None; }; let [deg, min, sec] = dms.get(..3)? else { return None; }; let degrees = deg.to_f64() + min.to_f64() / 60.0 + sec.to_f64() / 3600.0; let hemisphere = reader .get_field(ref_tag, exif::In::PRIMARY) .map(|f| f.display_value().to_string()) .unwrap_or_default(); let negative = hemisphere.starts_with('S') || hemisphere.starts_with('W'); Some(if negative { -degrees } else { degrees }) } /// The image's own size in pixels, once something is painted. fn intrinsic_size(picture: >k4::Picture) -> Option<(f64, f64)> { let paintable = picture.paintable()?; let (width, height) = ( f64::from(paintable.intrinsic_width()), f64::from(paintable.intrinsic_height()), ); (width > 0.0 && height > 0.0).then_some((width, height)) } /// The scale the photo is drawn at: its zoom, or the one that fits it to the /// window. fn current_scale(viewer: &Rc) -> Option { if let Some(zoom) = viewer.zoom.get() { return Some(zoom); } let (width, height) = intrinsic_size(&viewer.picture)?; let (view_w, view_h) = ( f64::from(viewer.scroller.width()), f64::from(viewer.scroller.height()), ); (view_w > 0.0 && view_h > 0.0).then(|| (view_w / width).min(view_h / height)) } /// Fit the photo to the window again. fn reset_zoom(viewer: &Rc) { viewer.zoom.set(None); viewer.picture.set_size_request(-1, -1); viewer.picture.set_cursor(None); viewer .scroller .set_policy(gtk4::PolicyType::Never, gtk4::PolicyType::Never); } /// Zoom to `scale` × the image's own pixels, keeping the point under `anchor` /// (in the image area's coordinates; its centre when `None`) where it is. /// `None`, or anything at or below the fitted size, fits the photo again. fn zoom_to(viewer: &Rc, scale: Option, anchor: Option<(f64, f64)>) { let Some((width, height)) = intrinsic_size(&viewer.picture) else { return; }; let (view_w, view_h) = ( f64::from(viewer.scroller.width()), f64::from(viewer.scroller.height()), ); if view_w <= 0.0 || view_h <= 0.0 { return; } let fit = (view_w / width).min(view_h / height); let Some(target) = scale .filter(|scale| *scale > fit * 1.01) .map(|scale| scale.min(MAX_ZOOM.max(fit))) else { reset_zoom(viewer); return; }; let (anchor_x, anchor_y) = anchor.unwrap_or((view_w / 2.0, view_h / 2.0)); let current = viewer.zoom.get().unwrap_or(fit); let (h, v) = (viewer.scroller.hadjustment(), viewer.scroller.vadjustment()); // Where the anchor sits on the image, as a fraction of it — the same spot // has to be under the pointer after the resize. An image smaller than the // window on an axis is centred on it, hence the offset. let fraction = |value: f64, anchor: f64, drawn: f64, view: f64| { let offset = ((view - drawn) / 2.0).max(0.0); ((value + anchor - offset) / drawn).clamp(0.0, 1.0) }; let fx = fraction(h.value(), anchor_x, width * current, view_w); let fy = fraction(v.value(), anchor_y, height * current, view_h); let (drawn_w, drawn_h) = (width * target, height * target); viewer.zoom.set(Some(target)); viewer .scroller .set_policy(gtk4::PolicyType::External, gtk4::PolicyType::External); viewer .picture .set_size_request(drawn_w.round() as i32, drawn_h.round() as i32); viewer.picture.set_cursor_from_name(Some("grab")); // The scrolled window only learns the new size at the next layout, so the // adjustments are sized here to let the offset land now. let place = |adj: >k4::Adjustment, fraction: f64, anchor: f64, drawn: f64, view: f64| { let offset = ((view - drawn) / 2.0).max(0.0); let upper = drawn.max(view); let value = (fraction * drawn + offset - anchor).clamp(0.0, upper - view); adj.configure(value, 0.0, upper, view * 0.1, view * 0.9, view); }; place(&h, fx, anchor_x, drawn_w, view_w); place(&v, fy, anchor_y, drawn_h, view_h); } /// Zoom in or out by `factor` around the centre. fn zoom_by(viewer: &Rc, factor: f64) { if let Some(scale) = current_scale(viewer) { zoom_to(viewer, Some(scale * factor), None); } } /// Bring the top bar and the prev/next buttons back, and fade them out again /// once the pointer has rested for [`CHROME_TIMEOUT`]. fn show_chrome(viewer: &Rc) { for revealer in &viewer.chrome { revealer.set_reveal_child(true); revealer.set_can_target(true); } viewer.media.set_cursor(None); if let Some(source) = viewer.chrome_source.borrow_mut().take() { source.remove(); } let weak = Rc::downgrade(viewer); let source = glib::timeout_add_local_once(CHROME_TIMEOUT, move || { let Some(viewer) = weak.upgrade() else { return; }; // This timer has fired, so its id must not be removed again. viewer.chrome_source.borrow_mut().take(); if !viewer.chrome_hover.get() { hide_chrome(&viewer); } }); *viewer.chrome_source.borrow_mut() = Some(source); } /// Fade the controls out. A hidden revealer still covers its corner of the /// photo, so it stops taking clicks too. fn hide_chrome(viewer: &Rc) { for revealer in &viewer.chrome { revealer.set_reveal_child(false); revealer.set_can_target(false); } viewer.media.set_cursor_from_name(Some("none")); } /// Copy the photo on screen to where the user picks, under its Drive name. pub(crate) fn save_photo_to_disk(ui: &Rc, window: >k4::Window, source: &str, name: &str) { let dialog = gtk4::FileDialog::builder() .title(gettext("Save a Copy")) .initial_name(name) .build(); let source = source.to_string(); let ui = ui.clone(); dialog.save(Some(window), gio::Cancellable::NONE, move |res| { // An error here is the dialog being dismissed. let Some(dest) = res.ok().and_then(|file| file.path()) else { return; }; // A video can run to gigabytes; copy it off the GTK thread. glib::spawn_future_local(async move { let copied = gio::spawn_blocking(move || std::fs::copy(&source, &dest).map(|_| dest)).await; match copied { Ok(Ok(dest)) => { let saved = dest.file_name().map_or_else( || dest.display().to_string(), |name| name.to_string_lossy().into_owned(), ); // Translators: {name} is the saved file's name. toast(&ui, &gettext_f("Saved “{name}”", &[("name", &saved)])); } Ok(Err(e)) => toast_error(&ui, &gettext("Couldn't save the copy"), &e.to_string()), Err(_) => toast_error( &ui, &gettext("Couldn't save the copy"), &gettext("The copy was interrupted."), ), } }); }); } /// A symlink to the cached file `path` under its Drive `name`, in a directory /// of its own below `base`. The cache names files by content hash, with no /// extension; the app chooser and the app it launches both need the real name /// to know what they are looking at. pub(crate) fn named_link(base: &Path, path: &str, name: &str) -> Option { let name = Path::new(name).file_name()?; let dir = base.join(Path::new(path).file_name()?); std::fs::create_dir_all(&dir).ok()?; let link = dir.join(name); let _ = std::fs::remove_file(&link); std::os::unix::fs::symlink(path, &link).ok()?; Some(link) } /// Offer every app that can open the photo on screen, rather than launching /// the default one. fn open_with(window: >k4::Window, path: &str, name: Option<&str>) { let base = glib::user_runtime_dir().join("pdfs-open"); let target = name .and_then(|name| named_link(&base, path, name)) .unwrap_or_else(|| PathBuf::from(path)); let launcher = gtk4::FileLauncher::new(Some(&gio::File::for_path(target))); launcher.set_always_ask(true); launcher.launch(Some(window), gio::Cancellable::NONE, |res| { if let Err(e) = res { tracing::warn!("open with failed: {e}"); } }); } /// Step `delta` photos through the flat timeline model and load what lands /// there. Stepping past the last loaded photo pages the next batch in first. pub(crate) fn navigate_photo(ui: &Rc, viewer: &Rc, delta: i32) { let model = &ui.gallery.model; let n = model.n_items(); if n == 0 { return; } let current = find_photo_index(model, &viewer.uid.borrow()).unwrap_or(0); if delta == 1 && current + 1 >= n { if ui.gallery.has_more.get() { wait_for_page(ui, viewer, n); } return; } // Prev/next are ±1; Home/End are huge deltas that clamp to the ends. let index = (i64::from(current) + i64::from(delta)).clamp(0, i64::from(n) - 1) as u32; if index == current { return; } let Some(photo) = model .item(index) .and_downcast::() .map(|boxed| boxed.borrow::().clone()) else { return; }; *viewer.uid.borrow_mut() = photo.uid.clone(); show_photo_position(ui, viewer, index, photo.capture_time); load_photo(ui, viewer, photo.uid); // Keep walking in the same direction: the next one is likely where they're // headed, so have it in the cache before they ask. prefetch_photo(ui, viewer, delta.signum()); if delta > 0 && index + PAGE_AHEAD >= n && ui.gallery.has_more.get() { load_gallery(ui, true); } } /// Load the next timeline page and step onto it once it lands — unless the /// lightbox closed or moved on in the meantime. fn wait_for_page(ui: &Rc, viewer: &Rc, loaded: u32) { viewer.next.set_sensitive(false); let (ui_page, viewer_page) = (ui.clone(), viewer.clone()); ui.gallery.page_waiters.borrow_mut().push(Box::new(move || { if !viewer_page.window.is_visible() { return; } let index = find_photo_index(&ui_page.gallery.model, &viewer_page.uid.borrow()); if index == Some(loaded - 1) && ui_page.gallery.model.n_items() > loaded { navigate_photo(&ui_page, &viewer_page, 1); } else if let Some(index) = index { let capture_time = viewer_item(&ui_page, &viewer_page, &viewer_page.uid.borrow()) .map_or(0, |item| item.capture_time); show_photo_position(&ui_page, &viewer_page, index, capture_time); } })); load_gallery(ui, true); } /// How many photos the lightbox is stepping through: an album's own count, or /// the library's for the active kind. `None` when a date or favorites filter /// narrows the timeline to a count the daemon does not report. fn gallery_total(ui: &Rc) -> Option { if let Some(album) = ui.gallery.album.borrow().as_ref() { return Some(album.photo_count); } if let Some(place) = ui.gallery.place.borrow().as_ref() { return Some(place.photo_count); } if ui.gallery.range.get().is_some() || ui.gallery.favorites.get() || ui.gallery.not_in_album.get() { return None; } let (photos, videos, raw) = ui.gallery.counts.get()?; Some(match ui.gallery.kind.get() { Some(PhotoKind::Photo) => photos, Some(PhotoKind::Video) => videos, Some(PhotoKind::Raw) => raw, None => photos + videos + raw, }) } /// "12 of 340" for the photo at `index`. While more pages are left the total /// is the library's (`total`), or the loaded count with a "+" when that is /// unknown; once everything is loaded the loaded count is exact. pub(crate) fn position_label(index: u32, loaded: u32, more: bool, total: Option) -> String { let (at, loaded) = (index as usize + 1, loaded as usize); match total { // Translators: the lightbox counter, such as "12 of 340"; {position} is the photo's place, {total} how many there are. _ if !more => gettext_f( "{position} of {total}", &[("position", &thousands(at)), ("total", &thousands(loaded))], ), // Translators: the lightbox counter, such as "12 of 340"; {position} is the photo's place, {total} how many there are. Some(total) if total > loaded => gettext_f( "{position} of {total}", &[("position", &thousands(at)), ("total", &thousands(total))], ), // Translators: the lightbox counter while more photos are still loading, such as "12 of 200+"; {loaded} is how many are loaded so far. _ => gettext_f( "{position} of {loaded}+", &[("position", &thousands(at)), ("loaded", &thousands(loaded))], ), } } /// Set the lightbox's title (the photo's date) and its "12 of 340" counter. fn show_photo_position(ui: &Rc, viewer: &Rc, index: u32, capture_time: i64) { let loaded = ui.gallery.model.n_items(); let more = ui.gallery.has_more.get(); viewer.prev.set_sensitive(index > 0); viewer.next.set_sensitive(index + 1 < loaded || more); viewer.title.set_label(&format_capture_time(capture_time)); viewer .counter .set_label(&position_label(index, loaded, more, gallery_total(ui))); } /// The in-app lightbox: the photo or video, edge-to-edge on a dark backdrop, /// with a top bar and prev/next buttons that fade out while the pointer rests, /// and a details panel docked beside the image. /// /// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close /// button, or a click on the backdrop beside the photo all dismiss it. pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { let parent = ui.stack.root().and_downcast::().unwrap(); let window = gtk4::Window::builder() .title(gettext("Photo")) .modal(true) .transient_for(&parent) .default_width(1100) .default_height(760) .build(); window.add_css_class("photo-viewer-window"); let overlay = gtk4::Overlay::new(); overlay.set_hexpand(true); let picture = gtk4::Picture::builder() .content_fit(gtk4::ContentFit::Contain) .hexpand(true) .vexpand(true) .build(); // Fitted, the photo is exactly the window; zoomed, it outgrows it and the // scrolled window pans over it (with its scrollbars kept out of sight). let scroller = gtk4::ScrolledWindow::builder() .hscrollbar_policy(gtk4::PolicyType::Never) .vscrollbar_policy(gtk4::PolicyType::Never) .hexpand(true) .vexpand(true) .child(&picture) .build(); let video = gtk4::Video::builder() .autoplay(true) .hexpand(true) .vexpand(true) .build(); let media = gtk4::Stack::new(); media.add_named(&scroller, Some("photo")); media.add_named(&video, Some("video")); overlay.set_child(Some(&media)); let fade = |child: >k4::Widget, halign: gtk4::Align, valign: gtk4::Align| { gtk4::Revealer::builder() .transition_type(gtk4::RevealerTransitionType::Crossfade) .reveal_child(true) .halign(halign) .valign(valign) .child(child) .build() }; let prev_btn = gtk4::Button::builder() .icon_name("go-previous-symbolic") .tooltip_text(gettext("Previous (←)")) .build(); prev_btn.add_css_class("circular"); prev_btn.add_css_class("flat"); prev_btn.add_css_class("viewer-nav-btn"); let prev_reveal = fade( prev_btn.upcast_ref(), gtk4::Align::Start, gtk4::Align::Center, ); overlay.add_overlay(&prev_reveal); let next_btn = gtk4::Button::builder() .icon_name("go-next-symbolic") .tooltip_text(gettext("Next (→)")) .build(); next_btn.add_css_class("circular"); next_btn.add_css_class("flat"); next_btn.add_css_class("viewer-nav-btn"); let next_reveal = fade(next_btn.upcast_ref(), gtk4::Align::End, gtk4::Align::Center); overlay.add_overlay(&next_reveal); // Top bar: the photo's date and position on the left, actions on the right, // over a gradient so white controls stay legible on a bright photo. let title_label = gtk4::Label::builder() .halign(gtk4::Align::Start) .ellipsize(gtk4::pango::EllipsizeMode::End) .build(); title_label.add_css_class("viewer-title"); let counter_label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); counter_label.add_css_class("viewer-counter"); let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 1); titles.set_hexpand(true); titles.set_valign(gtk4::Align::Center); titles.append(&title_label); titles.append(&counter_label); let action = |icon: &str, tooltip: &str| { let button = gtk4::Button::builder() .icon_name(icon) .tooltip_text(tooltip) .valign(gtk4::Align::Center) .build(); button.add_css_class("flat"); button.add_css_class("viewer-action-btn"); button }; let info_toggle = gtk4::ToggleButton::builder() .icon_name("info-outline-symbolic") .tooltip_text(gettext("Details (i)")) .valign(gtk4::Align::Center) .build(); info_toggle.add_css_class("flat"); info_toggle.add_css_class("viewer-action-btn"); let favorite_btn = gtk4::ToggleButton::builder() .icon_name("non-starred-symbolic") // Translators: tooltip of the star button that marks a photo as a favorite. .tooltip_text(pgettext("verb", "Favorite")) .valign(gtk4::Align::Center) .build(); favorite_btn.add_css_class("flat"); favorite_btn.add_css_class("viewer-action-btn"); let slideshow_btn = gtk4::ToggleButton::builder() .icon_name("media-playback-start-symbolic") .tooltip_text(gettext("Slideshow (F5)")) .valign(gtk4::Align::Center) .build(); slideshow_btn.add_css_class("flat"); slideshow_btn.add_css_class("viewer-action-btn"); // Only shown for a shot stored as more than one file; `load_photo_group` // decides that per photo. let group_btn = action("view-paged-symbolic", &gettext("Other files of this photo")); group_btn.set_visible(false); let download_btn = action("document-save-symbolic", &gettext("Save a Copy…")); let delete_btn = action("user-trash-symbolic", &gettext("Move to Trash (Delete)")); let open_ext_btn = action("document-open-symbolic", &gettext("Open With…")); let close_btn = action("window-close-symbolic", &gettext("Close (Esc)")); close_btn.add_css_class("viewer-close-btn"); let top_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); top_bar.add_css_class("viewer-top-bar"); top_bar.append(&titles); top_bar.append(&group_btn); top_bar.append(&favorite_btn); top_bar.append(&info_toggle); top_bar.append(&slideshow_btn); top_bar.append(&download_btn); top_bar.append(&delete_btn); top_bar.append(&open_ext_btn); top_bar.append(&close_btn); let top_reveal = fade(top_bar.upcast_ref(), gtk4::Align::Fill, gtk4::Align::Start); overlay.add_overlay(&top_reveal); // Details panel: docked beside the image as a real surface, so opening it // narrows the photo instead of covering part of it. let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12); let info_map = gtk4::Button::builder() .child( &adw::ButtonContent::builder() .label(gettext("Show on map")) .icon_name("pdfs-map-symbolic") .build(), ) .halign(gtk4::Align::Start) .build(); info_map.add_css_class("pill"); info_map.set_visible(false); let info_title = gtk4::Label::builder() .label(gettext("Details")) .halign(gtk4::Align::Start) .hexpand(true) .build(); info_title.add_css_class("heading"); let info_close = gtk4::Button::builder() .icon_name("window-close-symbolic") .tooltip_text(gettext("Hide details")) .build(); info_close.add_css_class("flat"); info_close.add_css_class("circular"); let info_header = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); info_header.append(&info_title); info_header.append(&info_close); let info_body = gtk4::Box::new(gtk4::Orientation::Vertical, 12); info_body.set_margin_top(16); info_body.set_margin_bottom(16); info_body.set_margin_start(16); info_body.set_margin_end(16); info_body.append(&info_header); info_body.append(&info_rows); info_body.append(&info_map); // Scrolled, because a photo with a full EXIF block plus a location can // outgrow a short window. let info_scroll = gtk4::ScrolledWindow::builder() .hscrollbar_policy(gtk4::PolicyType::Never) .vexpand(true) .child(&info_body) .build(); let info_panel = gtk4::Box::new(gtk4::Orientation::Vertical, 0); info_panel.add_css_class("viewer-info-panel"); info_panel.set_width_request(320); info_panel.append(&info_scroll); // Set explicitly: the title's hexpand would otherwise propagate up and have // the revealer take half the window even while it is closed. let info_revealer = gtk4::Revealer::builder() .transition_type(gtk4::RevealerTransitionType::SlideLeft) .hexpand(false) .child(&info_panel) .build(); let spinner = spinner(); spinner.set_halign(gtk4::Align::Center); spinner.set_valign(gtk4::Align::Center); spinner.set_size_request(48, 48); spinner.add_css_class("viewer-spinner"); overlay.add_overlay(&spinner); let status_label = gtk4::Label::builder() .wrap(true) .justify(gtk4::Justification::Center) .halign(gtk4::Align::Center) .valign(gtk4::Align::Center) .build(); status_label.add_css_class("viewer-status"); overlay.add_overlay(&status_label); let body = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); body.append(&overlay); body.append(&info_revealer); window.set_child(Some(&body)); let viewer = Rc::new(Viewer { window: window.clone(), media: media.clone(), scroller: scroller.clone(), picture: picture.clone(), video, spinner, status: status_label, title: title_label, counter: counter_label, prev: prev_btn.clone(), next: next_btn.clone(), info_toggle: info_toggle.clone(), info_revealer: info_revealer.clone(), info_rows, info_map: info_map.clone(), favorite: favorite_btn.clone(), favorite_suppress: Cell::new(false), group_btn: group_btn.clone(), group: RefCell::new(Vec::new()), coords: RefCell::new(None), uid: RefCell::new(initial_uid.clone()), name: RefCell::new(None), path: RefCell::new(None), loading: Cell::new(false), zoom: Cell::new(None), pinch_start: Cell::new(1.0), drag_start: Cell::new((0.0, 0.0)), pointer: Cell::new((0.0, 0.0)), chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()], chrome_hover: Cell::new(false), chrome_source: RefCell::new(None), slideshow: slideshow_btn.clone(), slide_source: RefCell::new(None), slide_fullscreened: Cell::new(false), }); let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0); let capture_time = viewer_item(ui, &viewer, &initial_uid).map_or(0, |item| item.capture_time); show_photo_position(ui, &viewer, initial_idx, capture_time); load_photo(ui, &viewer, initial_uid); prefetch_photo(ui, &viewer, 1); show_chrome(&viewer); // Any real pointer movement brings the controls back; resting over one of // them keeps them up. let motion = gtk4::EventControllerMotion::new(); let viewer_motion = viewer.clone(); motion.connect_motion(move |_, x, y| { let (last_x, last_y) = viewer_motion.pointer.get(); viewer_motion.pointer.set((x, y)); if (x - last_x).abs() > 1.0 || (y - last_y).abs() > 1.0 { show_chrome(&viewer_motion); } }); body.add_controller(motion); for revealer in [&top_reveal, &prev_reveal, &next_reveal] { let hover = gtk4::EventControllerMotion::new(); let viewer_enter = viewer.clone(); hover.connect_enter(move |_, _, _| viewer_enter.chrome_hover.set(true)); let viewer_leave = viewer.clone(); hover.connect_leave(move |_| { viewer_leave.chrome_hover.set(false); show_chrome(&viewer_leave); }); revealer.add_controller(hover); } // Ctrl+scroll zooms around the pointer; a plain scroll pans a zoomed photo. let wheel = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL); wheel.set_propagation_phase(gtk4::PropagationPhase::Capture); let viewer_wheel = viewer.clone(); wheel.connect_scroll(move |controller, _, dy| { if !controller .current_event_state() .contains(gtk4::gdk::ModifierType::CONTROL_MASK) { return glib::Propagation::Proceed; } if let Some(scale) = current_scale(&viewer_wheel) { let anchor = viewer_wheel.pointer.get(); zoom_to( &viewer_wheel, Some(scale * ZOOM_STEP.powf(-dy)), Some(anchor), ); } glib::Propagation::Stop }); scroller.add_controller(wheel); let pinch = gtk4::GestureZoom::new(); let viewer_pinch = viewer.clone(); pinch.connect_begin(move |_, _| { viewer_pinch .pinch_start .set(current_scale(&viewer_pinch).unwrap_or(1.0)); }); let viewer_pinch = viewer.clone(); pinch.connect_scale_changed(move |gesture, delta| { let start = viewer_pinch.pinch_start.get(); zoom_to( &viewer_pinch, Some(start * delta), gesture.bounding_box_center(), ); }); scroller.add_controller(pinch); // Dragging a zoomed photo pans it. let drag = gtk4::GestureDrag::new(); let viewer_drag = viewer.clone(); drag.connect_drag_begin(move |_, _, _| { let scroller = &viewer_drag.scroller; viewer_drag.drag_start.set(( scroller.hadjustment().value(), scroller.vadjustment().value(), )); if viewer_drag.zoom.get().is_some() { viewer_drag.picture.set_cursor_from_name(Some("grabbing")); } }); let viewer_drag = viewer.clone(); drag.connect_drag_update(move |_, dx, dy| { if viewer_drag.zoom.get().is_none() { return; } let (x, y) = viewer_drag.drag_start.get(); viewer_drag.scroller.hadjustment().set_value(x - dx); viewer_drag.scroller.vadjustment().set_value(y - dy); }); let viewer_drag = viewer.clone(); drag.connect_drag_end(move |_, _, _| { if viewer_drag.zoom.get().is_some() { viewer_drag.picture.set_cursor_from_name(Some("grab")); } }); scroller.add_controller(drag); let w_close = window.clone(); close_btn.connect_clicked(move |_| { w_close.close(); }); let viewer_closing = viewer.clone(); window.connect_close_request(move |_| { stop_video(&viewer_closing); disarm_slide(&viewer_closing); if let Some(source) = viewer_closing.chrome_source.borrow_mut().take() { source.remove(); } glib::Propagation::Proceed }); let viewer_info = viewer.clone(); info_toggle.connect_toggled(move |toggle| { viewer_info .info_revealer .set_reveal_child(toggle.is_active()); }); let ui_slides = ui.clone(); let viewer_slides = viewer.clone(); slideshow_btn.connect_toggled(move |toggle| { if toggle.is_active() { start_slideshow(&ui_slides, &viewer_slides); } else { stop_slideshow(&viewer_slides); } }); let toggle_off = info_toggle.clone(); info_close.connect_clicked(move |_| toggle_off.set_active(false)); let ui_map = ui.clone(); let viewer_map = viewer.clone(); let w_map = window.clone(); info_map.connect_clicked(move |_| { let coords = *viewer_map.coords.borrow(); if let Some((lat, lon)) = coords { w_map.close(); show_on_map(&ui_map, lat, lon); } }); let ui_fav = ui.clone(); let viewer_fav = viewer.clone(); favorite_btn.connect_toggled(move |btn| { if viewer_fav.favorite_suppress.get() { return; } let favorite = btn.is_active(); set_favorite_icon(btn, favorite); let uid = viewer_fav.uid.borrow().clone(); let rx = spawn_request( ui_fav.dirs.control_socket(), Request::SetPhotoFavorite { uid: uid.clone(), favorite, }, ); let ui_result = ui_fav.clone(); let viewer_result = viewer_fav.clone(); glib::spawn_future_local(async move { let failed = match rx.recv().await { Ok(Ok(Response::Ok { .. })) => { set_gallery_favorite(&ui_result, &uid, favorite); None } Ok(Ok(Response::Error { message, .. })) => Some(message), _ => Some(gettext("The Proton Drive service didn't respond.")), }; if let Some(detail) = failed { toast_error( &ui_result, &gettext("Couldn't change the favorite"), &detail, ); // The server refused, so the button must go back to describing // what is actually stored — without firing this handler again. if *viewer_result.uid.borrow() == uid { show_favorite(&viewer_result, !favorite); } } }); }); // Deleting the photo on screen closes the lightbox: what it was showing is // gone, and leaving it open on a trashed photo invites a second Delete on // something the user cannot see. let ui_delete = ui.clone(); let viewer_delete = viewer.clone(); let w_delete = window.clone(); delete_btn.connect_clicked(move |_| { let uid = viewer_delete.uid.borrow().clone(); w_delete.close(); trash_photos(&ui_delete, vec![uid], false); }); let ui_group = ui.clone(); let viewer_group = viewer.clone(); group_btn.connect_clicked(move |_| { let uid = viewer_group.uid.borrow().clone(); let next = next_group_member(&viewer_group.group.borrow(), &uid).map(|item| item.uid.clone()); if let Some(next) = next { *viewer_group.uid.borrow_mut() = next.clone(); load_photo(&ui_group, &viewer_group, next); } }); let ui_download = ui.clone(); let w_download = window.clone(); let viewer_download = viewer.clone(); download_btn.connect_clicked(move |_| { if let Some(path) = viewer_download.path.borrow().as_deref() { let name = viewer_download.name.borrow(); save_photo_to_disk( &ui_download, &w_download, path, name.as_deref().unwrap_or("photo"), ); } }); let w_ext = window.clone(); let viewer_ext = viewer.clone(); open_ext_btn.connect_clicked(move |_| { if let Some(path) = viewer_ext.path.borrow().as_deref() { open_with(&w_ext, path, viewer_ext.name.borrow().as_deref()); } }); let ui_prev = ui.clone(); let viewer_prev = viewer.clone(); prev_btn.connect_clicked(move |_| { navigate_photo(&ui_prev, &viewer_prev, -1); }); let ui_next = ui.clone(); let viewer_next = viewer.clone(); next_btn.connect_clicked(move |_| { navigate_photo(&ui_next, &viewer_next, 1); }); // Click the backdrop — the dark area beside the photo — to dismiss, the way // every other lightbox behaves. Clicks on the photo itself are left alone, so // reaching for the image doesn't fling the window shut; a zoomed photo has // no backdrop to speak of. Double-click toggles between fitted and 100 %. let backdrop = gtk4::GestureClick::new(); let viewer_click = viewer.clone(); backdrop.connect_pressed(move |_, n_press, x, y| { if n_press != 2 { return; } if viewer_click.zoom.get().is_some() { reset_zoom(&viewer_click); } else if let Some(fit) = current_scale(&viewer_click) { let scroller = &viewer_click.scroller; let anchor = ( x - scroller.hadjustment().value(), y - scroller.vadjustment().value(), ); // A photo smaller than the window is already past 100 % fitted, so // double-clicking it doubles it instead. zoom_to(&viewer_click, Some((fit * 2.0).max(1.0)), Some(anchor)); } }); let viewer_click = viewer.clone(); let w_click = window.clone(); backdrop.connect_released(move |_, n_press, x, y| { if n_press == 1 && viewer_click.zoom.get().is_none() && !over_photo(&viewer_click.picture, x, y) { w_click.close(); } }); picture.add_controller(backdrop); let key_controller = gtk4::EventControllerKey::new(); let ui_key = ui.clone(); let viewer_key = viewer.clone(); let w_key = window.clone(); key_controller.connect_key_pressed(move |_, key, _keycode, state| { let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK); let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video"); match key.name().as_deref() { Some("space") if playing_video => { if let Some(stream) = viewer_key.video.media_stream() { stream.set_playing(!stream.is_playing()); } } Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1), Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1), Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2), Some("End") => navigate_photo(&ui_key, &viewer_key, i32::MAX / 2), Some("plus" | "equal" | "KP_Add") => zoom_by(&viewer_key, ZOOM_STEP), Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP), Some("0" | "KP_0") => reset_zoom(&viewer_key), Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None), Some("F5") => viewer_key .slideshow .set_active(!viewer_key.slideshow.is_active()), // Escape ends a slideshow before it closes anything. Some("Escape") if viewer_key.slideshow.is_active() => { viewer_key.slideshow.set_active(false) } Some("i") => viewer_key .info_toggle .set_active(!viewer_key.info_toggle.is_active()), Some("f" | "F11") => { if w_key.is_fullscreen() { w_key.unfullscreen(); } else { w_key.fullscreen(); } } Some("Delete" | "KP_Delete") => { let uid = viewer_key.uid.borrow().clone(); w_key.close(); trash_photos(&ui_key, vec![uid], false); } Some("Escape" | "q") => w_key.close(), Some("w") if ctrl => w_key.close(), _ => return glib::Propagation::Proceed, } glib::Propagation::Stop }); window.add_controller(key_controller); window.present(); } /// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself /// rather than the backdrop around it. [`gtk4::ContentFit::Contain`] centres the /// image and letterboxes the rest, so the drawn rectangle is the widget scaled /// down by whichever axis binds. pub(crate) fn over_photo(picture: >k4::Picture, x: f64, y: f64) -> bool { let (width, height) = (f64::from(picture.width()), f64::from(picture.height())); let Some(paintable) = picture.paintable() else { return false; }; let (iw, ih) = ( f64::from(paintable.intrinsic_width()), f64::from(paintable.intrinsic_height()), ); if iw <= 0.0 || ih <= 0.0 { return false; } let scale = (width / iw).min(height / ih); let (drawn_w, drawn_h) = (iw * scale, ih * scale); let (left, top) = ((width - drawn_w) / 2.0, (height - drawn_h) / 2.0); x >= left && x <= left + drawn_w && y >= top && y <= top + drawn_h } #[cfg(test)] mod tests { use super::{camera_name, format_shutter, named_link, position_label}; #[test] fn shutter_speeds_read_like_a_camera() { assert_eq!(format_shutter(1.0 / 18.869_704_689_121_615), "1/19 s"); assert_eq!(format_shutter(0.001), "1/1000 s"); assert_eq!(format_shutter(2.5), "2.5 s"); assert_eq!(format_shutter(1.0), "1 s"); } #[test] fn camera_name_does_not_repeat_the_maker() { let s = |v: &str| Some(v.to_string()); assert_eq!(camera_name(s("Google"), s("Pixel 6")), s("Google Pixel 6")); assert_eq!( camera_name(s("Canon"), s("Canon EOS R5")), s("Canon EOS R5") ); assert_eq!(camera_name(None, s("iPhone 15")), s("iPhone 15")); assert_eq!(camera_name(None, None), None); } #[test] fn the_counter_counts_the_library_while_pages_are_left() { assert_eq!(position_label(11, 200, true, Some(1422)), "12 of 1,422"); assert_eq!(position_label(11, 200, true, None), "12 of 200+"); assert_eq!(position_label(0, 37, false, Some(1422)), "1 of 37"); } #[test] fn open_with_sees_the_drive_name() { let base = std::env::temp_dir().join(format!("pdfs-named-link-{}", std::process::id())); let cached = base.join("0a1b2c"); std::fs::create_dir_all(&base).unwrap(); std::fs::write(&cached, b"jpeg").unwrap(); let links = base.join("links"); let link = named_link(&links, cached.to_str().unwrap(), "IMG_0001.JPG").unwrap(); assert_eq!(link.file_name().unwrap(), "IMG_0001.JPG"); assert_eq!(std::fs::read(&link).unwrap(), b"jpeg"); // Opening the same photo again replaces the link rather than failing. assert!(named_link(&links, cached.to_str().unwrap(), "IMG_0001.JPG").is_some()); std::fs::remove_dir_all(&base).unwrap(); } }