mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
43d1afe705
commit
a5d29969dc
4 files changed
+634
-142
No files matched your search
@@ -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()),
|
||||
|
||||
@@ -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: >k4::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: >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<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: >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<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: >k4::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: >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.
|
||||
/// 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: >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("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: >k4::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();
|
||||
}
|
||||
}
|
||||
@@ -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: >k4::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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user