Files
proton-drive-linux/crates/pdfs-gui/src/app/pages/photo_viewer.rs
T
nvrl c6bdba024f feat(gui,fuse): delete photos from the gallery and notice remote deletions
The gallery could show photos and nothing else: a photo deleted on the phone
stayed on the page for minutes, and a photo you wanted gone had to be deleted
on another device. Both are now handled here.

Deleting needs a uid-addressed request, because `Request::Delete` takes a
mountpoint path and the photos volume is not in the FUSE mount. `TrashNodes`
trashes through the SDK and answers per-uid outcomes, so a partial failure is
reportable; the daemon then drops those rows with `Db::photos_delete`, album
membership included, rather than leaving the gallery to wait for the next
timeline refresh. The GUI removes the tiles immediately with an Undo toast and
puts back anything the server refused — the photos are in Proton's trash and
stay recoverable there either way. Selection is Ctrl/Shift+click, the Select
toggle or Delete in the lightbox.

Remote deletions arrive on their own now. The Drive volume and the photos
volume have separate event streams, so the photos volume gets its own poller
and its own cursor. Trash and delete events remove rows; anything else only
marks the timeline stale, because there is no inode space to converge on this
volume. With the timeline TTL down to 60 s and an explicit Refresh that waits
for the server instead of only clearing the freshness stamp, a photo deleted
elsewhere leaves the grid in about ten seconds.
2026-09-19 15:25:04 +02:00

812 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::*;
/// 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 {
pub(crate) picture: gtk4::Picture,
pub(crate) spinner: gtk4::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 drawer: 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<Option<(f64, f64)>>,
/// The favourite 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<bool>,
/// uid of the photo currently on screen.
pub(crate) uid: RefCell<String>,
/// On-disk path of the full-size photo, once it has been downloaded.
pub(crate) path: RefCell<Option<String>>,
/// 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<bool>,
}
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
/// label/value pairs for the details drawer.
pub(crate) struct ExifInfo {
/// `("Camera", "Apple iPhone 15")` and friends; empty when the file has no
/// EXIF at all, which is normal for screenshots and re-encoded images.
pub(crate) fields: Vec<(&'static str, String)>,
/// Decimal degrees, if the photo is geotagged.
pub(crate) coords: Option<(f64, f64)>,
}
/// Paint the favourite toggle without firing its handler.
pub(crate) fn show_favorite(viewer: &Rc<Viewer>, 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 favourite, outline for the rest.
fn set_favorite_icon(button: &gtk4::ToggleButton, favorite: bool) {
button.set_icon_name(if favorite {
"starred-symbolic"
} else {
"non-starred-symbolic"
});
}
/// Write a favourite change back into the loaded gallery page, so returning to
/// the grid (or reopening the photo) shows what was just set without a reload.
fn set_gallery_favorite(ui: &Rc<Ui>, 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::<BoxedAnyObject>() else {
return;
};
let mut item = boxed.borrow::<PhotoItem>().clone();
item.favorite = favorite;
ui.gallery.model.remove(idx);
ui.gallery.model.insert(idx, &BoxedAnyObject::new(item));
}
/// 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.
pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
viewer.spinner.set_visible(true);
viewer.spinner.start();
viewer.status.set_visible(false);
viewer.loading.set(true);
*viewer.path.borrow_mut() = None;
clear_info(viewer);
// The favourite 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.
let favorite = find_photo_index(&ui.gallery.model, &uid)
.and_then(|idx| ui.gallery.model.item(idx))
.and_downcast::<BoxedAnyObject>()
.is_some_and(|boxed| boxed.borrow::<PhotoItem>().favorite);
show_favorite(viewer, favorite);
// 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();
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.stop();
viewer.spinner.set_visible(false);
viewer.loading.set(false);
let fail = |message: &str| {
viewer.status.set_label(message);
viewer.status.set_visible(true);
};
match result {
Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) {
Ok(texture) => {
viewer.picture.set_paintable(Some(&texture));
*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("Couldn't render this photo.");
}
},
Ok(Ok(Response::Error { message, .. })) => fail(&message),
Ok(Ok(_)) => fail("Unexpected reply from the mount service."),
Ok(Err(_)) | Err(_) => fail("Couldn't reach Proton Drive."),
}
});
}
/// 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<Ui>, viewer: &Rc<Viewer>, 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::<BoxedAnyObject>()
.map(|boxed| boxed.borrow::<PhotoItem>().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>) {
*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("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<Viewer>, path: &str, info: ExifInfo) {
while let Some(row) = viewer.info_rows.first_child() {
viewer.info_rows.remove(&row);
}
let mut fields: Vec<(&str, String)> = Vec::new();
if let Ok(meta) = std::fs::metadata(path) {
fields.push(("Size", human_bytes(meta.len())));
}
fields.extend(info.fields.iter().map(|(k, v)| (*k, v.clone())));
if fields.is_empty() {
let label = gtk4::Label::builder()
.label("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)
.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<(&'static str, 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())
};
if let Some(size) = field(exif::Tag::PixelXDimension)
.zip(field(exif::Tag::PixelYDimension))
.map(|(w, h)| format!("{w} × {h}"))
{
fields.push(("Dimensions", size));
}
if let Some(taken) = field(exif::Tag::DateTimeOriginal) {
fields.push(("Taken", taken));
}
let camera = [exif::Tag::Make, exif::Tag::Model]
.iter()
.filter_map(|tag| field(*tag))
.collect::<Vec<_>>()
.join(" ");
if !camera.is_empty() {
fields.push(("Camera", camera));
}
if let Some(lens) = field(exif::Tag::LensModel) {
fields.push(("Lens", lens));
}
let exposure: Vec<String> = [
exif::Tag::FNumber,
exif::Tag::ExposureTime,
exif::Tag::PhotographicSensitivity,
exif::Tag::FocalLength,
]
.iter()
.filter_map(|tag| field(*tag))
.collect();
if !exposure.is_empty() {
fields.push(("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)
{
fields.push(("Location", format!("{lat:.5}, {lon:.5}")));
coords = Some((lat, lon));
}
ExifInfo { fields, coords }
}
/// 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<f64> {
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 })
}
pub(crate) fn save_photo_to_disk(window: &gtk4::Window, source_path: &str, original_name: &str) {
let dialog = gtk4::FileDialog::builder()
.title("Save Photo")
.initial_name(original_name)
.build();
let source_path_str = source_path.to_string();
dialog.save(Some(window), gio::Cancellable::NONE, move |res| {
if let Ok(file) = res
&& let Some(dest_path) = file.path()
{
if let Err(e) = std::fs::copy(&source_path_str, &dest_path) {
tracing::error!("Failed to copy file to {:?}: {}", dest_path, e);
} else {
tracing::info!("Saved photo to {:?}", dest_path);
}
}
});
}
/// Step `delta` photos through the flat timeline model and load what lands there.
pub(crate) fn navigate_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, delta: i32) {
let model = &ui.gallery.model;
let n = model.n_items();
if n == 0 {
return;
}
let uid_val = viewer.uid.borrow().clone();
let current_idx = find_photo_index(model, &uid_val).unwrap_or(0);
// Where the raw step lands (prev/next are ±1; Home/End are huge deltas that
// clamp to the ends), and which way it was travelling.
let start = (current_idx as i32 + delta).clamp(0, n as i32 - 1);
let dir = if delta < 0 { -1 } else { 1 };
// The lightbox renders stills only — a video plays in an external player, so
// it has no frame here. Land on the nearest renderable item, preferring the
// travel direction and falling back to scanning inward from a boundary (so
// Home/End still reach the first/last still even when videos sit at the end).
let renderable = |idx: u32| -> Option<PhotoItem> {
model
.item(idx)
.and_then(|obj| {
obj.downcast_ref::<BoxedAnyObject>()
.map(|b| b.borrow::<PhotoItem>().clone())
})
.filter(|photo| photo.kind != PhotoKind::Video)
};
let scan = |from: i32, step: i32| -> Option<(u32, PhotoItem)> {
let mut probe = from;
while (0..n as i32).contains(&probe) {
if let Some(photo) = renderable(probe as u32) {
return Some((probe as u32, photo));
}
probe += step;
}
None
};
// Scan the travel direction first. Only fall back to scanning inward when the
// step landed on a boundary — that is the Home/End case (jump to first/last,
// then step inward past any videos there); a mid-list prev/next with only
// videos ahead should simply stay put rather than reverse.
let at_boundary = start == 0 || start == n as i32 - 1;
let landing = scan(start, dir).or_else(|| at_boundary.then(|| scan(start, -dir)).flatten());
let Some((next_idx, photo)) = landing else {
return;
};
*viewer.uid.borrow_mut() = photo.uid.clone();
show_photo_position(viewer, next_idx, n, photo.capture_time);
load_photo(ui, viewer, photo.uid.clone());
// 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());
}
/// Set the lightbox's title (the photo's date) and its "12 of 340" counter.
pub(crate) fn show_photo_position(viewer: &Rc<Viewer>, index: u32, total: u32, capture_time: i64) {
viewer.prev.set_sensitive(index > 0);
viewer.next.set_sensitive(index + 1 < total);
viewer.title.set_label(&format_capture_time(capture_time));
viewer
.counter
.set_label(&format!("{} of {total}", index + 1));
}
/// The in-app lightbox: the photo, edge-to-edge on a dark backdrop, with a
/// floating top bar, prev/next affordances and a details drawer.
///
/// 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<Ui>, initial_uid: String) {
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
let window = gtk4::Window::builder()
.title("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();
let picture = gtk4::Picture::builder()
.content_fit(gtk4::ContentFit::Contain)
.hexpand(true)
.vexpand(true)
.build();
overlay.set_child(Some(&picture));
let prev_btn = gtk4::Button::builder()
.icon_name("go-previous-symbolic")
.tooltip_text("Previous (←)")
.halign(gtk4::Align::Start)
.valign(gtk4::Align::Center)
.build();
prev_btn.add_css_class("circular");
prev_btn.add_css_class("flat");
prev_btn.add_css_class("viewer-nav-btn");
overlay.add_overlay(&prev_btn);
let next_btn = gtk4::Button::builder()
.icon_name("go-next-symbolic")
.tooltip_text("Next (→)")
.halign(gtk4::Align::End)
.valign(gtk4::Align::Center)
.build();
next_btn.add_css_class("circular");
next_btn.add_css_class("flat");
next_btn.add_css_class("viewer-nav-btn");
overlay.add_overlay(&next_btn);
// 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("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")
.tooltip_text("Favourite")
.valign(gtk4::Align::Center)
.build();
favorite_btn.add_css_class("flat");
favorite_btn.add_css_class("viewer-action-btn");
let download_btn = action("document-save-symbolic", "Save a copy…");
let delete_btn = action("user-trash-symbolic", "Move to Trash (Delete)");
let open_ext_btn = action("document-open-symbolic", "Open with another app");
let close_btn = action("window-close-symbolic", "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.set_valign(gtk4::Align::Start);
top_bar.set_hexpand(true);
top_bar.append(&titles);
top_bar.append(&favorite_btn);
top_bar.append(&info_toggle);
top_bar.append(&download_btn);
top_bar.append(&delete_btn);
top_bar.append(&open_ext_btn);
top_bar.append(&close_btn);
overlay.add_overlay(&top_bar);
// Details drawer: slides in from the right as a real surface (not a wash of
// black over the photo), with its own header and its own way out.
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
let info_map = gtk4::Button::builder()
.label("Show on map")
.icon_name("map-symbolic")
.halign(gtk4::Align::Start)
.build();
info_map.add_css_class("pill");
info_map.set_visible(false);
let info_title = gtk4::Label::builder()
.label("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("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)
.propagate_natural_height(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);
let info_revealer = gtk4::Revealer::builder()
.transition_type(gtk4::RevealerTransitionType::SlideLeft)
.halign(gtk4::Align::End)
.valign(gtk4::Align::Fill)
.child(&info_panel)
.build();
overlay.add_overlay(&info_revealer);
let spinner = gtk4::Spinner::builder()
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.width_request(48)
.height_request(48)
.build();
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);
window.set_child(Some(&overlay));
let viewer = Rc::new(Viewer {
picture: picture.clone(),
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),
coords: RefCell::new(None),
uid: RefCell::new(initial_uid.clone()),
path: RefCell::new(None),
loading: Cell::new(false),
});
let n = ui.gallery.model.n_items();
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
let capture_time = ui
.gallery
.model
.item(initial_idx)
.and_downcast::<BoxedAnyObject>()
.map_or(0, |boxed| boxed.borrow::<PhotoItem>().capture_time);
show_photo_position(&viewer, initial_idx, n, capture_time);
load_photo(ui, &viewer, initial_uid);
prefetch_photo(ui, &viewer, 1);
let w_close = window.clone();
close_btn.connect_clicked(move |_| {
w_close.close();
});
let viewer_info = viewer.clone();
info_toggle.connect_toggled(move |toggle| {
viewer_info
.info_revealer
.set_reveal_child(toggle.is_active());
});
let toggle_off = info_toggle.clone();
info_close.connect_clicked(move |_| toggle_off.set_active(false));
let viewer_map = viewer.clone();
info_map.connect_clicked(move |_| {
if let Some((lat, lon)) = *viewer_map.coords.borrow() {
open_path(&format!(
"https://www.openstreetmap.org/?mlat={lat:.6}&mlon={lon:.6}#map=16/{lat:.6}/{lon:.6}"
));
}
});
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("The mount service didn't respond.".to_string()),
};
if let Some(detail) = failed {
toast_error(&ui_result, "Couldn't change the favourite", &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]);
});
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 = format!("{}.jpg", viewer_download.uid.borrow());
save_photo_to_disk(&w_download, path, &name);
}
});
let viewer_ext = viewer.clone();
open_ext_btn.connect_clicked(move |_| {
if let Some(path) = viewer_ext.path.borrow().as_deref() {
open_path(path);
}
});
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.
let backdrop = gtk4::GestureClick::new();
let viewer_click = viewer.clone();
let w_click = window.clone();
backdrop.connect_released(move |_, _, x, y| {
if !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);
match key.name().as_deref() {
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("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]);
}
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: &gtk4::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
}