feat(gui): photo viewer rewrite

Auto-hiding controls, zoom and pan, a docked details panel, paging past the loaded photos with the library total in the counter, inline video playback, Save a Copy under the real name with an error toast, and Open With through the app chooser.
This commit is contained in:
nvrl committed 2026-09-23 03:34:29 +02:00
1 parent 43d1afe705
commit a5d29969dc
4 files changed
+634 -142

No files matched your search

+2
View File
@@ -489,6 +489,8 @@ fn build_window(app: &adw::Application) {
favorites: Cell::new(false),
date_suppress: Cell::new(false),
loading: Cell::new(false),
has_more: Cell::new(false),
page_waiters: RefCell::new(Vec::new()),
width: Cell::new(0),
photo_tex: RefCell::new(HashMap::new()),
photo_tex_order: RefCell::new(VecDeque::new()),
+606 -101
View File
@@ -1,17 +1,37 @@
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 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: 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
/// 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,
@@ -31,6 +51,8 @@ pub(crate) struct Viewer {
pub(crate) group: RefCell<Vec<PhotoItem>>,
/// uid of the photo currently on screen.
pub(crate) uid: RefCell<String>,
/// Drive name of the photo on screen, for Save a Copy and Open With.
pub(crate) name: RefCell<Option<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
@@ -39,10 +61,25 @@ pub(crate) struct Viewer {
/// 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>,
/// Zoom as a multiple of the image's own pixels, or `None` while the photo
/// is fitted to the window.
pub(crate) zoom: Cell<Option<f64>>,
/// The zoom a pinch started from, and the scroll offsets a pan started from.
pub(crate) pinch_start: Cell<f64>,
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<gtk4::Revealer>,
pub(crate) chrome_hover: Cell<bool>,
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
}
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
/// label/value pairs for the details drawer.
/// label/value pairs for the details panel.
#[derive(Default)]
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.
@@ -83,9 +120,6 @@ pub(crate) fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) {
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.
/// 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.
@@ -139,6 +173,27 @@ pub(crate) fn member_label(item: &PhotoItem) -> String {
}
}
/// 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<Ui>, viewer: &Rc<Viewer>, uid: &str) -> Option<PhotoItem> {
find_photo_index(&ui.gallery.model, uid)
.and_then(|idx| ui.gallery.model.item(idx))
.and_downcast::<BoxedAnyObject>()
.map(|boxed| boxed.borrow::<PhotoItem>().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<Ui>, viewer: &Rc<Viewer>, uid: String) {
viewer.spinner.set_visible(true);
viewer.spinner.start();
@@ -146,14 +201,19 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
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);
// 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.
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);
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
@@ -184,9 +244,26 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
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));
}
@@ -202,6 +279,12 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
});
}
/// 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>) {
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
@@ -385,93 +468,308 @@ pub(crate) fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Ta
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);
/// The image's own size in pixels, once something is painted.
fn intrinsic_size(picture: &gtk4::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<Viewer>) -> Option<f64> {
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>) {
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<Viewer>, scale: Option<f64>, 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: &gtk4::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<Viewer>, 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<Viewer>) {
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<Viewer>) {
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<Ui>, window: &gtk4::Window, source: &str, name: &str) {
let dialog = gtk4::FileDialog::builder()
.title("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(),
);
toast(&ui, &format!("Saved “{saved}”"));
}
Ok(Err(e)) => toast_error(&ui, "Couldn't save the copy", &e.to_string()),
Err(_) => toast_error(&ui, "Couldn't save the copy", "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<PathBuf> {
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: &gtk4::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.
/// 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<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));
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);
}
probe += step;
return;
}
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 {
// 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::<BoxedAnyObject>()
.map(|boxed| boxed.borrow::<PhotoItem>().clone())
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());
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<Ui>, viewer: &Rc<Viewer>, 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<Ui>) -> Option<usize> {
if let Some(album) = ui.gallery.album.borrow().as_ref() {
return Some(album.photo_count);
}
if ui.gallery.range.get().is_some() || ui.gallery.favorites.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<usize>) -> String {
let (at, loaded) = (index as usize + 1, loaded as usize);
match total {
_ if !more => format!("{} of {}", thousands(at), thousands(loaded)),
Some(total) if total > loaded => format!("{} of {}", thousands(at), thousands(total)),
_ => format!("{} of {}+", thousands(at), thousands(loaded)),
}
}
/// 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) {
fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, 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 < total);
viewer.next.set_sensitive(index + 1 < loaded || more);
viewer.title.set_label(&format_capture_time(capture_time));
viewer
.counter
.set_label(&format!("{} of {total}", index + 1));
.set_label(&position_label(index, loaded, more, gallery_total(ui)));
}
/// 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.
/// 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.
@@ -488,35 +786,65 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
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();
overlay.set_child(Some(&picture));
// 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: &gtk4::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("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 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("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);
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.
@@ -567,16 +895,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let group_btn = action("view-paged-symbolic", "Other files of this photo");
group_btn.set_visible(false);
let download_btn = action("document-save-symbolic", "Save a copy…");
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 open_ext_btn = action("document-open-symbolic", "Open With…");
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(&group_btn);
top_bar.append(&favorite_btn);
@@ -585,10 +911,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
top_bar.append(&delete_btn);
top_bar.append(&open_ext_btn);
top_bar.append(&close_btn);
overlay.add_overlay(&top_bar);
let top_reveal = fade(top_bar.upcast_ref(), gtk4::Align::Fill, gtk4::Align::Start);
overlay.add_overlay(&top_reveal);
// 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.
// 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()
@@ -634,7 +961,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
// outgrow a short window.
let info_scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.propagate_natural_height(true)
.vexpand(true)
.child(&info_body)
.build();
@@ -645,11 +972,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
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)
@@ -669,10 +993,17 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
status_label.add_css_class("viewer-status");
overlay.add_overlay(&status_label);
window.set_child(Some(&overlay));
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,
@@ -689,28 +1020,135 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
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),
});
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);
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);
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
@@ -792,19 +1230,26 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
}
});
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 = format!("{}.jpg", viewer_download.uid.borrow());
save_photo_to_disk(&w_download, path, &name);
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_path(path);
open_with(&w_ext, path, viewer_ext.name.borrow().as_deref());
}
});
@@ -822,12 +1267,34 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
// 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.
// 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 |_, _, x, y| {
if !over_photo(&viewer_click.picture, x, y) {
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();
}
});
@@ -839,11 +1306,21 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
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("i") => viewer_key
.info_toggle
.set_active(!viewer_key.info_toggle.is_active()),
@@ -892,3 +1369,31 @@ pub(crate) fn over_photo(picture: &gtk4::Picture, x: f64, y: f64) -> bool {
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::{named_link, position_label};
#[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();
}
}
+20 -41
View File
@@ -100,6 +100,11 @@ pub(crate) struct GalleryState {
/// True while a timeline page is in flight, so the scroll-to-the-end paging
/// can't fire a second request for the page already coming.
pub(crate) loading: Cell<bool>,
/// Whether the last page came back full, so there may be more to load.
pub(crate) has_more: Cell<bool>,
/// Run once the page in flight has landed in [`Self::model`] — how the
/// lightbox steps past the last loaded photo.
pub(crate) page_waiters: RefCell<Vec<Box<dyn FnOnce()>>>,
/// Content width the grid is currently laid out to. Updated when the
/// ListView is resized, which re-flows the visible sections.
pub(crate) width: Cell<i32>,
@@ -1400,16 +1405,14 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
});
button.add_controller(context);
// A still opens in the in-app lightbox; a video can't render there, so it
// downloads and hands off to an external player instead. In selection mode
// a tile picks instead of opening: the lightbox is one Escape away.
// A tile opens in the in-app lightbox, which plays a video in place. In
// selection mode a tile picks instead of opening: the lightbox is one
// Escape away.
let ui_open = ui.clone();
let uid = tile.photo.uid.clone();
button.connect_clicked(move |_| {
if ui_open.gallery.selecting.get() {
toggle_selected(&ui_open, &uid);
} else if is_video {
play_video(&ui_open, uid.clone());
} else {
open_photo_viewer(&ui_open, uid.clone());
}
@@ -2219,11 +2222,12 @@ pub(crate) fn select_all_photos(ui: &Rc<Ui>) {
fn show_photo_menu(ui: &Rc<Ui>, photo: &PhotoItem, anchor: &gtk4::Button, x: f64, y: f64) {
let mut menu = ActionMenu::new();
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
if photo.kind == PhotoKind::Video {
menu.item("Play", move || play_video(&ui_c, uid.clone()));
let label = if photo.kind == PhotoKind::Video {
"Play"
} else {
menu.item("Open", move || open_photo_viewer(&ui_c, uid.clone()));
}
"Open"
};
menu.item(label, move || open_photo_viewer(&ui_c, uid.clone()));
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
menu.toggle("Favorite", photo.favorite, move |favorite| {
set_photo_favorite(&ui_c, uid.clone(), favorite)
@@ -2340,6 +2344,12 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
ui.busy_end();
ui.gallery.loading.set(false);
ui.gallery.more.set_sensitive(true);
// Whoever waited on this page runs once the reply below has put it in
// the model, whichever way that goes.
let waiters = std::mem::take(&mut *ui.gallery.page_waiters.borrow_mut());
if !waiters.is_empty() {
glib::idle_add_local_once(move || waiters.into_iter().for_each(|waiter| waiter()));
}
match result {
Ok(Ok(Response::Photos {
available,
@@ -2356,6 +2366,7 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
);
return;
}
ui.gallery.has_more.set(items.len() == PHOTOS_PAGE);
// Label the filter tabs with live per-kind counts.
if let Some(counts) = counts {
update_gallery_tabs(&ui, counts);
@@ -2502,38 +2513,6 @@ pub(crate) fn play_external(path: &str) {
open_path(path);
}
/// Download a Photos-library video, then hand it to an external player. Unlike a
/// still photo — which the in-app lightbox can render — a video needs a real
/// player, and the photos volume isn't part of the FUSE mount, so there is no
/// path to stream it from: [`Request::OpenPhoto`] fetches the whole file into the
/// cache (served straight from there on a repeat) and we launch the player on it.
///
/// For large videos kept in an on-demand *drive* folder, streaming through the
/// mount is the better route — that is the file-browser "Play" action, not this.
pub(crate) fn play_video(ui: &Rc<Ui>, uid: String) {
toast(ui, "Preparing video…");
let rx = spawn_request(ui.dirs.control_socket(), Request::OpenPhoto { uid });
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::FilePath { path })) => play_external(&path),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't open this video", &message, kind)
}
Ok(Ok(_)) => toast_error(
&ui,
"Couldn't open this video",
"Unexpected reply from the mount service.",
),
Ok(Err(_)) | Err(_) => toast_error(
&ui,
"Couldn't open this video",
"Couldn't reach Proton Drive.",
),
}
});
}
#[cfg(test)]
mod tests {
use super::empty_timeline_text;
+6
View File
@@ -93,6 +93,12 @@ Schema: **31** (unchanged).
counts albums. Back from an album returns to the grid where you left it instead of reloading it,
and going from Albums back to Timeline keeps the timeline's scroll position unless an album was
opened in between.
- **Photo viewer rewrite.** The lightbox's controls fade out after two seconds without pointer
movement. Photos zoom with Ctrl+scroll, a pinch, `+`/`-`, `1` for 100 % and double-click, and
pan by dragging; `0` fits them again. The details panel docks beside the photo instead of
covering it. Stepping past the last loaded photo loads the next page, and the counter shows the
library's total rather than the loaded count. A video plays inline. Save a Copy uses the photo's
real name and reports failures, and Open With… offers the app chooser.
- **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the
`pdfs favorite` command.