mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
While signed in, the sidebar never collapsed. The window could not get narrower than the sidebar plus the widest page, about 950 pixels, so on a small or scaled screen the minimize, maximize and close buttons ended up off-screen. - The page stack is no longer homogeneous, so only the page on screen sets the minimum width. - The selection bars of My files and Photos scroll sideways instead of holding the window at their full width, even while hidden. - Below 760sp the sidebar and the page take turns, with a back button, and the My files search entry narrows. Picking a sidebar row opens the page. Every page, in English and in German, now fits in a window 480 pixels wide with its window buttons visible.
3637 lines
145 KiB
Rust
3637 lines
145 KiB
Rust
use crate::*;
|
|
|
|
pub(crate) struct GalleryState {
|
|
// Photos (gallery) page.
|
|
/// Every photo loaded so far, newest first — the order the lightbox's
|
|
/// prev/next walks. The visible day sections are derived from this.
|
|
pub(crate) model: gio::ListStore,
|
|
/// The Photos ListView's own model: one item per *rendered row* — a day
|
|
/// heading, or one row of that day's tile grid — rebuilt from
|
|
/// [`Self::model`] by [`repaint_gallery`]. Rows rather than whole days
|
|
/// because the ListView only realises what is on screen, and a day holding
|
|
/// 1,600 photos (a Takeout import lands one) would otherwise be 1,600
|
|
/// widgets built in a single bind.
|
|
pub(crate) groups: gio::ListStore,
|
|
/// Target row height in px, retuned by Ctrl+scroll / Ctrl+± (see
|
|
/// [`zoom_gallery`]). Each row is filled with photos at their own aspect
|
|
/// ratios and then scaled to the content width, so this is the height a row
|
|
/// lands near rather than the height it gets (see [`justify_rows`]).
|
|
pub(crate) row_height: Cell<i32>,
|
|
/// Swaps the Photos content area between the timeline, its status page, and
|
|
/// the Albums grid.
|
|
pub(crate) content: gtk4::Stack,
|
|
pub(crate) status: adw::StatusPage,
|
|
pub(crate) retry: gtk4::Button,
|
|
/// Spins under the timeline while the next page is loading.
|
|
pub(crate) pager: Spinner,
|
|
/// The month scrubber over the timeline's right edge, and the months it
|
|
/// spans (newest first).
|
|
pub(crate) scrubber: Scrubber,
|
|
pub(crate) months: RefCell<Vec<PhotoMonth>>,
|
|
/// The pending jump the scrubber debounces to.
|
|
pub(crate) scrub_source: RefCell<Option<glib::SourceId>>,
|
|
/// A scrubber jump in progress: the end of the month it is headed for.
|
|
/// Pages keep loading until a photo older than this is in the model.
|
|
pub(crate) jump: Cell<Option<i64>>,
|
|
/// Makes the next page [`JUMP_PAGE`] long, for a jump that has far to go.
|
|
pub(crate) burst: Cell<bool>,
|
|
/// Says a Google Photos import is running, with a way to its page.
|
|
pub(crate) import_banner: adw::Banner,
|
|
pub(crate) upload: adw::SplitButton,
|
|
/// Upload / Import offered on the *empty timeline* status page, so a fresh
|
|
/// account is a place to start rather than a dead end. Hidden on every other
|
|
/// status: a load error is not the moment to offer an upload.
|
|
pub(crate) empty_actions: gtk4::Box,
|
|
/// "Photos", or the album's name while one is open.
|
|
/// "1,204 photos" as its subtitle.
|
|
pub(crate) title: adw::WindowTitle,
|
|
/// The album grid, and the stack that swaps it for its own status page.
|
|
pub(crate) albums: gtk4::FlowBox,
|
|
pub(crate) albums_stack: gtk4::Stack,
|
|
pub(crate) albums_status: adw::StatusPage,
|
|
/// The Timeline/Albums/Places switcher (see [`VIEW_TIMELINE`] and its
|
|
/// siblings) — hidden while an album is open, where the back button leads
|
|
/// instead.
|
|
pub(crate) view_switch: ToggleGroup,
|
|
/// True while the album listing is in flight, so a re-entry into the Albums
|
|
/// view can't stack requests.
|
|
pub(crate) albums_loading: Cell<bool>,
|
|
/// The Places grid and its own status page, like the album grid's.
|
|
pub(crate) places: gtk4::FlowBox,
|
|
pub(crate) places_stack: gtk4::Stack,
|
|
pub(crate) places_status: adw::StatusPage,
|
|
/// True while the place listing is in flight.
|
|
pub(crate) places_loading: Cell<bool>,
|
|
/// The row above the place grid saying how far the daemon has got reading
|
|
/// photo locations, its label and bar, and the timer that asks again while
|
|
/// it is reading.
|
|
pub(crate) places_mapping: gtk4::Box,
|
|
pub(crate) places_mapping_label: gtk4::Label,
|
|
pub(crate) places_mapping_bar: gtk4::ProgressBar,
|
|
pub(crate) places_poll: RefCell<Option<glib::SourceId>>,
|
|
/// The place map, and the Grid/Map toggle (see [`PLACES_MAP`]).
|
|
pub(crate) places_map: Rc<PlacesMap>,
|
|
pub(crate) places_layout: ToggleGroup,
|
|
/// How many places the grid shows, for the subtitle when back returns there.
|
|
pub(crate) place_count: Cell<usize>,
|
|
/// The place currently open, paged by [`load_gallery`] like an album.
|
|
pub(crate) place: RefCell<Option<PlaceInfo>>,
|
|
/// The album currently open, or `None` when the timeline is showing. Set by
|
|
/// [`open_album`]; read by [`load_gallery`], which pages that album instead
|
|
/// of the timeline while it is set.
|
|
pub(crate) album: RefCell<Option<AlbumInfo>>,
|
|
/// Leaves an open album for the grid it was opened from. Visible only while
|
|
/// an album is open.
|
|
pub(crate) back: gtk4::Button,
|
|
/// Set when an album replaced the timeline in [`Self::model`], so going
|
|
/// back to the timeline has to reload it; otherwise the timeline is shown
|
|
/// as it was left, scroll position included.
|
|
pub(crate) timeline_stale: Cell<bool>,
|
|
/// The albums the grid shows, as last listed.
|
|
pub(crate) album_list: RefCell<Vec<AlbumInfo>>,
|
|
/// The kind toggles and the date jump, hidden while an album is open — an
|
|
/// album page is served whole, not filtered.
|
|
pub(crate) filters: gtk4::Box,
|
|
/// Which tab (Photos / Videos / Raw) the timeline is filtered to, or `None`
|
|
/// for All. Read by [`load_gallery`] and set by the filter toggles.
|
|
pub(crate) kind: Cell<Option<PhotoKind>>,
|
|
/// The filter toggles, index-aligned with [`kind_for_tab`], kept so their
|
|
/// labels can carry live per-kind counts.
|
|
pub(crate) tabs: ToggleGroup,
|
|
/// Whole-timeline `(photos, videos, raw)` counts from the last page that
|
|
/// carried them, so the subtitle can say how big the library *is* rather
|
|
/// than how much of it has been paged in.
|
|
pub(crate) counts: Cell<Option<(usize, usize, usize)>>,
|
|
/// The Filters button, the count badge on it, and the popover's "Clear
|
|
/// filters" button.
|
|
pub(crate) filter_btn: gtk4::MenuButton,
|
|
pub(crate) filter_count: gtk4::Label,
|
|
pub(crate) clear_filters: gtk4::Button,
|
|
/// The month row of the Filters popover ("All dates" then a month per
|
|
/// timeline entry), and the `[from, to)` window each of its rows selects
|
|
/// (index-aligned; `None` is "All dates"). Selecting a row loads that month
|
|
/// via [`load_gallery`].
|
|
pub(crate) dates: adw::ComboRow,
|
|
pub(crate) date_ranges: RefCell<Vec<Option<(i64, i64)>>>,
|
|
/// The capture-time window the timeline is currently filtered to, or `None`
|
|
/// for the whole span. Read by [`load_gallery`], set by the date dropdown.
|
|
pub(crate) range: Cell<Option<(i64, i64)>>,
|
|
/// The favorites switch, and whether it is on. When on, the timeline is
|
|
/// restricted to photos carrying Proton's `Favorite` tag.
|
|
pub(crate) favorites_row: adw::SwitchRow,
|
|
pub(crate) favorites: Cell<bool>,
|
|
/// The album filter switch, and whether it is on. When on, the timeline is
|
|
/// restricted to photos no album of ours holds yet.
|
|
pub(crate) not_in_album_row: adw::SwitchRow,
|
|
pub(crate) not_in_album: Cell<bool>,
|
|
/// The "On this day" strip, the box holding its cards, and whether there
|
|
/// is anything to show in it. The strip is the timeline's first row (see
|
|
/// [`GalleryRow::Memories`]), so it scrolls away with the grid.
|
|
pub(crate) memories: gtk4::Box,
|
|
pub(crate) memory_cards: gtk4::Box,
|
|
pub(crate) has_memories: Cell<bool>,
|
|
/// Set while the date dropdown is being repopulated, so resetting its model
|
|
/// doesn't fire the selection handler and kick off a spurious reload.
|
|
pub(crate) date_suppress: Cell<bool>,
|
|
/// A fresh load was asked for while a page was still loading.
|
|
pub(crate) reload_pending: Cell<bool>,
|
|
/// 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>,
|
|
/// Whether the account has a photo library. Until the daemon says it has
|
|
/// none, assume it does, so Upload is not greyed out while the page loads.
|
|
pub(crate) library: 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>,
|
|
/// Decoded thumbnails by photo uid, with the insertion order that evicts the
|
|
/// oldest past [`TEXTURE_CACHE_MAX`]. Scrolling back over a day therefore
|
|
/// repaints from memory instead of re-decoding from disk.
|
|
pub(crate) photo_tex: RefCell<HashMap<String, gtk4::gdk::Texture>>,
|
|
pub(crate) photo_tex_order: RefCell<VecDeque<String>>,
|
|
/// Photos the daemon reported as having no thumbnail at all, so a tile that
|
|
/// can never be filled isn't requested again on every scroll past it.
|
|
pub(crate) photo_nothumb: RefCell<HashSet<String>>,
|
|
/// Tiles on screen still waiting for their thumbnail, by uid. Populated as
|
|
/// sections are bound, drained as batches land, cleared on unbind — so a
|
|
/// batch only ever paints a widget that is still showing that photo.
|
|
pub(crate) thumb_wanted: RefCell<HashMap<String, gtk4::Picture>>,
|
|
/// Uids queued for the next [`Request::PhotoThumbs`] batch, and whether a
|
|
/// batch is already in flight (only one at a time, so a long scroll can't
|
|
/// stack requests on the daemon).
|
|
pub(crate) thumb_queue: RefCell<VecDeque<String>>,
|
|
/// Which way the timeline was last scrolled: `true` for downwards, the
|
|
/// direction [`prefetch_thumbs`] warms tiles in. Photos are read newest
|
|
/// first, so downwards is the common case and the default.
|
|
pub(crate) scrolling_down: Cell<bool>,
|
|
/// The scroll offset the direction was last decided at.
|
|
pub(crate) scroll_offset: Cell<f64>,
|
|
pub(crate) thumb_inflight: Cell<bool>,
|
|
/// Thumbnails on disk waiting to be turned into textures, as `(uid, path)`.
|
|
/// Decoding happens on the GTK thread (textures are not `Send`), so it is fed
|
|
/// a few at a time from an idle callback rather than in one blocking burst
|
|
/// that would stutter the scroll. [`Self::decode_idle`] is the "callback
|
|
/// already scheduled" guard.
|
|
pub(crate) decode_queue: RefCell<VecDeque<(String, String)>>,
|
|
pub(crate) decode_idle: Cell<bool>,
|
|
/// Pending debounce timers for the thumbnail queue flush and the section
|
|
/// re-flow, replaced on each new trigger so only the last one fires.
|
|
pub(crate) thumb_source: RefCell<Option<glib::SourceId>>,
|
|
pub(crate) relayout_source: RefCell<Option<glib::SourceId>>,
|
|
/// The rows currently realised by the ListView, as row index -> the uid of
|
|
/// its first photo (absent for a heading row) and the row's widget. A
|
|
/// resize or a zoom step changes how many tiles fit per row, so the row
|
|
/// model has to be rebuilt — this is what lets the rebuild put the user
|
|
/// back where they were. The ListView realises rows beyond both edges of
|
|
/// the viewport, so the widget is what tells which of them is on screen.
|
|
pub(crate) bound: RefCell<BTreeMap<u32, (Option<String>, gtk4::Box)>>,
|
|
/// The tile widgets each row box has built, rebound to new photos as the
|
|
/// ListView recycles the row rather than built afresh on every bind.
|
|
pub(crate) tile_pools: RefCell<HashMap<gtk4::Box, Vec<TileWidget>>>,
|
|
/// The ListView itself, so a rebuild can scroll back to the row the user was
|
|
/// looking at.
|
|
pub(crate) list: gtk4::ListView,
|
|
/// True while the grid is picking photos rather than opening them. A tile
|
|
/// then toggles instead of activating, and shows a checkbox.
|
|
pub(crate) selecting: Cell<bool>,
|
|
/// The photos picked so far, by uid.
|
|
pub(crate) selected: RefCell<HashSet<String>>,
|
|
/// The photo last picked or unpicked by a click, which a Shift+click
|
|
/// selects a range from.
|
|
pub(crate) select_anchor: RefCell<Option<String>>,
|
|
/// The Select toggle, the bar it reveals, and the bar's own widgets.
|
|
pub(crate) select_btn: gtk4::ToggleButton,
|
|
pub(crate) select_bar: gtk4::Revealer,
|
|
pub(crate) select_label: gtk4::Label,
|
|
pub(crate) select_trash: gtk4::Button,
|
|
pub(crate) select_album: gtk4::Button,
|
|
}
|
|
|
|
impl GalleryState {
|
|
/// Whether the gallery shows an album or a place rather than the timeline.
|
|
/// Neither is filtered or dated, so the timeline's extras stay away.
|
|
pub(crate) fn in_collection(&self) -> bool {
|
|
self.album.borrow().is_some() || self.place.borrow().is_some()
|
|
}
|
|
}
|
|
|
|
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
|
pub(crate) const PHOTOS_PAGE: usize = 200;
|
|
|
|
/// Page length while a scrubber jump is loading its way to a month — the
|
|
/// daemon's cap on one reply.
|
|
pub(crate) const JUMP_PAGE: usize = 1000;
|
|
|
|
/// How long the scrubber has to rest on a month before the timeline jumps.
|
|
const SCRUB_DEBOUNCE: Duration = Duration::from_millis(150);
|
|
|
|
/// Gallery tile size in px: the zoom range, its default, and the step one
|
|
/// Ctrl+scroll notch (or Ctrl+±) moves it by. The columns are stretched to
|
|
/// span the content width, so this is the *target* a tile lands near rather
|
|
/// than the size it ends up with (see [`plan_grid`]).
|
|
pub(crate) const ROW_MIN: i32 = 60;
|
|
|
|
pub(crate) const ROW_MAX: i32 = 340;
|
|
|
|
pub(crate) const ROW_DEFAULT: i32 = 180;
|
|
|
|
pub(crate) const ROW_STEP: i32 = 30;
|
|
|
|
/// Tile sizes at which the timeline stops heading each day and heads each
|
|
/// month instead, then each year. Only big tiles get a heading per day: a day
|
|
/// rarely holds a row of photos, and a heading plus a short row per day would
|
|
/// leave most of a wide window empty.
|
|
pub(crate) const GROUP_BY_MONTH_BELOW: i32 = 240;
|
|
|
|
pub(crate) const GROUP_BY_YEAR_BELOW: i32 = 90;
|
|
|
|
/// How the timeline sections its photos, set by the zoom level (see
|
|
/// [`grouping_for`]).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum Grouping {
|
|
Day,
|
|
Month,
|
|
Year,
|
|
}
|
|
|
|
/// The grouping that suits a target tile size.
|
|
pub(crate) fn grouping_for(row_height: i32) -> Grouping {
|
|
if row_height < GROUP_BY_YEAR_BELOW {
|
|
Grouping::Year
|
|
} else if row_height < GROUP_BY_MONTH_BELOW {
|
|
Grouping::Month
|
|
} else {
|
|
Grouping::Day
|
|
}
|
|
}
|
|
|
|
/// Gap between tiles, horizontally and vertically. Tight on purpose: the grid
|
|
/// should read as a sheet of photographs, not as a deck of cards.
|
|
pub(crate) const TILE_GAP: i32 = 2;
|
|
|
|
/// How many thumbnails one on-demand [`Request::PhotoThumbs`] batch asks for.
|
|
/// Small, so the first tiles on screen fill in quickly rather than the whole
|
|
/// page landing at once.
|
|
pub(crate) const THUMB_BATCH: usize = 16;
|
|
|
|
/// Idle pause before a thumbnail batch is sent, so a fast scroll coalesces into
|
|
/// one request per settle instead of one per row that flickers past.
|
|
pub(crate) const THUMB_DEBOUNCE: Duration = Duration::from_millis(60);
|
|
|
|
/// How long to wait before asking again for a thumbnail the daemon is generating
|
|
/// itself. That means downloading the photo's full file, so the wait is measured
|
|
/// in seconds, not milliseconds.
|
|
pub(crate) const THUMB_RETRY: Duration = Duration::from_secs(4);
|
|
|
|
/// Decoded thumbnails held in memory, evicted least-recently-*used* first. Each
|
|
/// is a few hundred KiB of GPU texture, and at the justified row density 600 was
|
|
/// under two screens' worth — a scroll down and back up then had to re-fetch and
|
|
/// re-decode everything it had just shown.
|
|
pub(crate) const TEXTURE_CACHE_MAX: usize = 1500;
|
|
|
|
/// How many tiles beyond the realised rows [`prefetch_thumbs`] warms, so a tile
|
|
/// is decoded before it is scrolled onto rather than after.
|
|
pub(crate) const THUMB_PREFETCH: usize = 32;
|
|
|
|
/// How close to the top or bottom edge a drag-select has to come before the
|
|
/// timeline scrolls under it, how far one tick scrolls at the very edge, and
|
|
/// how often it ticks.
|
|
const DRAG_EDGE: f64 = 48.0;
|
|
|
|
const DRAG_SCROLL_STEP: f64 = 24.0;
|
|
|
|
const DRAG_SCROLL_TICK: Duration = Duration::from_millis(16);
|
|
|
|
/// Pause after a resize/zoom before the visible sections are re-flowed.
|
|
pub(crate) const RELAYOUT_DEBOUNCE: Duration = Duration::from_millis(80);
|
|
|
|
/// One section of the photos timeline: a heading plus the photos captured that
|
|
/// day (or month, or year — see [`Grouping`]), in timeline order. Built from the flat [`Ui::gallery_model`] by
|
|
/// [`group_photos`], then flattened into [`GalleryRow`]s for rendering.
|
|
pub(crate) struct PhotoGroup {
|
|
/// "Today", "Yesterday", or e.g. "3 June 2026".
|
|
pub(crate) heading: String,
|
|
pub(crate) photos: Vec<PhotoItem>,
|
|
}
|
|
|
|
/// One item of the ListView's model — the unit the gallery virtualises at.
|
|
///
|
|
/// A whole day is *not* one item: a Google Photos Takeout drops thousands of
|
|
/// photos onto a single date, and one ListView item per day means GTK builds
|
|
/// every one of those tiles in one bind, on the main thread, before the row can
|
|
/// be shown. Splitting the day into its grid rows keeps a bind to a handful of
|
|
/// widgets no matter how big the day is.
|
|
pub(crate) enum GalleryRow {
|
|
/// The "On this day" strip, as the first row of the whole, unfiltered
|
|
/// timeline. A row rather than a strip above the list: hiding a strip
|
|
/// outside the list as the user scrolled resized the viewport under them,
|
|
/// and the list shook between the two heights near the top.
|
|
Memories,
|
|
/// A day heading: "Today", "3 June 2026".
|
|
Heading(String),
|
|
/// One row of a day's grid, already justified to the current width and zoom.
|
|
Tiles {
|
|
tiles: Vec<Tile>,
|
|
/// True for the last row of its day, which carries the section's bottom
|
|
/// margin so days stay visually separated.
|
|
last: bool,
|
|
},
|
|
}
|
|
|
|
impl GalleryRow {
|
|
/// The uid of the row's first photo, or `None` for a heading — the anchor a
|
|
/// relayout scrolls back to.
|
|
fn anchor(&self) -> Option<String> {
|
|
match self {
|
|
GalleryRow::Memories | GalleryRow::Heading(_) => None,
|
|
GalleryRow::Tiles { tiles, .. } => tiles.first().map(|t| t.photo.uid.clone()),
|
|
}
|
|
}
|
|
|
|
/// Whether re-rendering `self` would produce exactly what `other` shows —
|
|
/// the test [`repaint_gallery`] diffs on, so appending a page only touches
|
|
/// the rows that actually changed.
|
|
fn same_as(&self, other: &GalleryRow) -> bool {
|
|
match (self, other) {
|
|
(GalleryRow::Memories, GalleryRow::Memories) => true,
|
|
(GalleryRow::Heading(a), GalleryRow::Heading(b)) => a == b,
|
|
(
|
|
GalleryRow::Tiles { tiles: a, last: al },
|
|
GalleryRow::Tiles { tiles: b, last: bl },
|
|
) => {
|
|
al == bl
|
|
&& a.len() == b.len()
|
|
&& a.iter().zip(b).all(|(x, y)| {
|
|
x.photo.uid == y.photo.uid
|
|
&& x.width == y.width
|
|
&& x.height == y.height
|
|
&& x.selecting == y.selecting
|
|
&& x.selected == y.selected
|
|
&& x.grouping == y.grouping
|
|
})
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The widgets [`build_gallery_page`] hands back to [`build_window`].
|
|
pub(crate) struct GalleryWidgets {
|
|
/// Flat, newest-first list of every loaded photo. Backs the lightbox's
|
|
/// prev/next navigation; the visible sections are derived from it.
|
|
pub(crate) model: gio::ListStore,
|
|
/// Day sections rendered by the ListView, derived from `model`.
|
|
pub(crate) groups: gio::ListStore,
|
|
/// Swaps between the timeline, the empty/loading/error status page, and the
|
|
/// Albums grid.
|
|
pub(crate) content: gtk4::Stack,
|
|
pub(crate) status: adw::StatusPage,
|
|
pub(crate) title: adw::WindowTitle,
|
|
pub(crate) pager: Spinner,
|
|
pub(crate) scrubber: Scrubber,
|
|
/// Says a Google Photos import is running, with a way to its page.
|
|
pub(crate) import_banner: adw::Banner,
|
|
pub(crate) list: gtk4::ListView,
|
|
pub(crate) scroll: gtk4::ScrolledWindow,
|
|
pub(crate) retry: gtk4::Button,
|
|
pub(crate) upload: adw::SplitButton,
|
|
pub(crate) empty_actions: gtk4::Box,
|
|
pub(crate) empty_upload: gtk4::Button,
|
|
pub(crate) empty_import: gtk4::Button,
|
|
/// The Select toggle and the bar it reveals.
|
|
pub(crate) select_btn: gtk4::ToggleButton,
|
|
pub(crate) select_bar: gtk4::Revealer,
|
|
pub(crate) select_label: gtk4::Label,
|
|
pub(crate) select_trash: gtk4::Button,
|
|
pub(crate) select_album: gtk4::Button,
|
|
pub(crate) select_done: gtk4::Button,
|
|
/// The Albums grid, its own status page and the stack between them, plus the
|
|
/// Photos/Albums switcher and the back button out of an album.
|
|
pub(crate) albums: gtk4::FlowBox,
|
|
pub(crate) albums_stack: gtk4::Stack,
|
|
pub(crate) albums_status: adw::StatusPage,
|
|
pub(crate) view_switch: ToggleGroup,
|
|
pub(crate) places: gtk4::FlowBox,
|
|
pub(crate) places_stack: gtk4::Stack,
|
|
pub(crate) places_status: adw::StatusPage,
|
|
pub(crate) places_mapping: gtk4::Box,
|
|
pub(crate) places_mapping_label: gtk4::Label,
|
|
pub(crate) places_mapping_bar: gtk4::ProgressBar,
|
|
pub(crate) places_map: Rc<PlacesMap>,
|
|
pub(crate) places_layout: ToggleGroup,
|
|
pub(crate) back: gtk4::Button,
|
|
/// The kind toggles and date jump, as one box so an album view can hide them.
|
|
pub(crate) filters: gtk4::Box,
|
|
/// The All / Photos / Videos / Raw filter toggles, in that order (index maps
|
|
/// to [`kind_for_tab`]).
|
|
pub(crate) tabs: ToggleGroup,
|
|
pub(crate) favorites_row: adw::SwitchRow,
|
|
pub(crate) not_in_album_row: adw::SwitchRow,
|
|
/// The "On this day" strip and the box its cards go into.
|
|
pub(crate) memories: gtk4::Box,
|
|
pub(crate) memory_cards: gtk4::Box,
|
|
/// The month row of the Filters popover, populated with the timeline's months.
|
|
pub(crate) dates: adw::ComboRow,
|
|
pub(crate) filter_btn: gtk4::MenuButton,
|
|
pub(crate) filter_count: gtk4::Label,
|
|
pub(crate) clear_filters: gtk4::Button,
|
|
}
|
|
|
|
/// The Photos page: a [`gtk4::ListView`] of day sections, each a heading over
|
|
/// that day's photos laid out as justified rows (see [`justify_rows`]) — every
|
|
/// photo at its own aspect ratio, every row filled edge to edge.
|
|
///
|
|
/// A ListView of sections rather than one flat GridView because GTK's grid has no
|
|
/// row headers and forces square cells: the justified rows and the date headings
|
|
/// both need per-row structure, and the ListView only realises the sections on
|
|
/// screen, which is what keeps a 10,000-photo timeline cheap. The factory is
|
|
/// installed by [`wire_gallery`], which has the [`Ui`] the tiles need (zoom
|
|
/// level, thumbnail cache, click-to-open).
|
|
pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|
let model = gio::ListStore::new::<BoxedAnyObject>();
|
|
let groups = gio::ListStore::new::<BoxedAnyObject>();
|
|
|
|
let selection = gtk4::NoSelection::new(Some(groups.clone()));
|
|
let list = gtk4::ListView::builder()
|
|
.model(&selection)
|
|
.single_click_activate(false)
|
|
.build();
|
|
list.add_css_class("gallery-sections");
|
|
|
|
// Shown only when a load failed because the mount is down; restarts it.
|
|
let retry = gtk4::Button::builder()
|
|
.label(gettext("Retry"))
|
|
.halign(gtk4::Align::Center)
|
|
.build();
|
|
retry.add_css_class("pill");
|
|
retry.add_css_class("suggested-action");
|
|
retry.set_visible(false);
|
|
|
|
// The empty-timeline state's two ways forward. `StatusPage` takes one child,
|
|
// so Retry and these share a box and each is shown only for its own state.
|
|
let empty_upload = gtk4::Button::builder()
|
|
.label(gettext("Upload photos"))
|
|
.build();
|
|
empty_upload.add_css_class("pill");
|
|
empty_upload.add_css_class("suggested-action");
|
|
let empty_import = gtk4::Button::builder()
|
|
.label(gettext("Import from Google Photos"))
|
|
.build();
|
|
empty_import.add_css_class("pill");
|
|
let empty_actions = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
empty_actions.set_halign(gtk4::Align::Center);
|
|
empty_actions.set_visible(false);
|
|
empty_actions.append(&empty_upload);
|
|
empty_actions.append(&empty_import);
|
|
|
|
let status_child = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
|
status_child.append(&retry);
|
|
status_child.append(&empty_actions);
|
|
|
|
let status = adw::StatusPage::builder()
|
|
.icon_name("image-x-generic-symbolic")
|
|
.vexpand(true)
|
|
.child(&status_child)
|
|
.build();
|
|
status.add_css_class("compact");
|
|
|
|
// The timeline pages itself in as the scroll nears the bottom (see
|
|
// [`wire_gallery`]); this only says a page is on its way.
|
|
let pager = spinner();
|
|
pager.set_halign(gtk4::Align::Center);
|
|
pager.set_margin_bottom(6);
|
|
pager.set_visible(false);
|
|
|
|
// Horizontal scrolling is never wanted: the grid is sized to the viewport
|
|
// width. External rather than Never, which would make the widest row the
|
|
// window's minimum width, so a row laid out a pixel too wide would push the
|
|
// window wider, and the next layout wider still.
|
|
let scroll = gtk4::ScrolledWindow::builder()
|
|
.vexpand(true)
|
|
.hscrollbar_policy(gtk4::PolicyType::External)
|
|
.child(&list)
|
|
.build();
|
|
|
|
// Leaves an open album for the grid it came from. Only an open album shows
|
|
// it — the Albums toggle is what leaves the grid itself.
|
|
let back = gtk4::Button::builder()
|
|
.icon_name("go-previous-symbolic")
|
|
.tooltip_text(gettext("Back to albums"))
|
|
.valign(gtk4::Align::Center)
|
|
.visible(false)
|
|
.build();
|
|
back.add_css_class("flat");
|
|
back.add_css_class("circular");
|
|
|
|
// Importing a Google Photos export is another way of adding photos, so it
|
|
// sits in the Upload button's own menu. `label` and `icon_name` both
|
|
// replace a button's child, so the pair needs an `adw::ButtonContent` to
|
|
// show together.
|
|
let upload_menu = gio::Menu::new();
|
|
upload_menu.append(
|
|
Some(&gettext("Import from Google Photos…")),
|
|
Some("win.show-import"),
|
|
);
|
|
let upload = adw::SplitButton::builder()
|
|
.child(
|
|
&adw::ButtonContent::builder()
|
|
.label(pgettext("verb", "Upload"))
|
|
.icon_name("list-add-symbolic")
|
|
.build(),
|
|
)
|
|
.menu_model(&upload_menu)
|
|
.tooltip_text(gettext("Upload photos"))
|
|
.dropdown_tooltip(gettext("More ways to add photos"))
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
upload.add_css_class("suggested-action");
|
|
|
|
// Hunting for duplicates is a rarer, heavier job than adding one photo, so
|
|
// it waits in the page menu rather than taking a header button.
|
|
let page_menu = gio::Menu::new();
|
|
page_menu.append(
|
|
Some(&gettext("Find Duplicates…")),
|
|
Some("win.find-duplicates"),
|
|
);
|
|
let more = gtk4::MenuButton::builder()
|
|
.icon_name("view-more-symbolic")
|
|
.tooltip_text(gettext("More"))
|
|
.valign(gtk4::Align::Center)
|
|
.menu_model(&page_menu)
|
|
.build();
|
|
more.add_css_class("flat");
|
|
|
|
// Picking photos is a mode, so its control is a toggle rather than a
|
|
// button. It has a label: no icon says "select" in every theme.
|
|
let select_btn = gtk4::ToggleButton::builder()
|
|
.label(pgettext("verb", "Select"))
|
|
.tooltip_text(gettext("Select photos"))
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
select_btn.add_css_class("flat");
|
|
|
|
// What the selection can do, revealed with the mode. A revealer rather than
|
|
// a hidden box so the grid slides down instead of jumping.
|
|
let select_label = gtk4::Label::builder()
|
|
.label(gettext("Select photos"))
|
|
.hexpand(true)
|
|
.xalign(0.0)
|
|
.build();
|
|
let select_trash = gtk4::Button::builder()
|
|
.label(gettext("Move to Trash"))
|
|
.valign(gtk4::Align::Center)
|
|
.sensitive(false)
|
|
.build();
|
|
select_trash.add_css_class("destructive-action");
|
|
let select_album = gtk4::Button::builder()
|
|
.label(gettext("Add to Album…"))
|
|
.valign(gtk4::Align::Center)
|
|
.sensitive(false)
|
|
.build();
|
|
let select_done = gtk4::Button::builder()
|
|
.icon_name("window-close-symbolic")
|
|
.tooltip_text(gettext("Leave selection (Esc)"))
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
select_done.add_css_class("flat");
|
|
select_done.add_css_class("circular");
|
|
let select_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
select_box.add_css_class("toolbar");
|
|
select_box.add_css_class("bulk-bar");
|
|
select_box.append(&select_label);
|
|
select_box.append(&select_album);
|
|
select_box.append(&select_trash);
|
|
select_box.append(&select_done);
|
|
let select_bar = gtk4::Revealer::builder()
|
|
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
|
.child(&scroll_sideways(&select_box))
|
|
.build();
|
|
|
|
// All / Photos / Videos / Raw filter. Linked toggles acting as one segmented
|
|
// control: exactly one is active, and flipping it reloads the timeline
|
|
// filtered to that kind (wired in [`wire_gallery`]). Labels gain live counts
|
|
// once a page lands.
|
|
let labels: [String; 4] = std::array::from_fn(tab_label);
|
|
let tabs = ToggleGroup::new(&labels.each_ref().map(|label| (label.as_str(), None)));
|
|
tabs.set_round();
|
|
|
|
// Favorites, "not in an album" and the month: the filters that cut across
|
|
// the kind tabs, together in one popover so the row stays short. The tabs
|
|
// stay out in the open because their counts are worth a glance.
|
|
let favorites_row = adw::SwitchRow::builder()
|
|
.title(gettext("Only favorites"))
|
|
.build();
|
|
let not_in_album_row = adw::SwitchRow::builder()
|
|
.title(gettext("Only photos not in an album"))
|
|
.build();
|
|
// "All dates" plus a row per month, filled in once the timeline's months are
|
|
// known (see [`refresh_photo_months`]).
|
|
let dates = adw::ComboRow::builder()
|
|
.title(gettext("Month"))
|
|
.model(>k4::StringList::new(&[gettext("All dates").as_str()]))
|
|
.build();
|
|
let filter_list = gtk4::ListBox::new();
|
|
filter_list.set_selection_mode(gtk4::SelectionMode::None);
|
|
filter_list.add_css_class("boxed-list");
|
|
filter_list.append(&favorites_row);
|
|
filter_list.append(¬_in_album_row);
|
|
filter_list.append(&dates);
|
|
let clear_filters = gtk4::Button::builder()
|
|
.label(gettext("Clear filters"))
|
|
.halign(gtk4::Align::End)
|
|
.sensitive(false)
|
|
.build();
|
|
clear_filters.add_css_class("flat");
|
|
let filter_panel = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
|
filter_panel.set_width_request(340);
|
|
filter_panel.append(&filter_list);
|
|
filter_panel.append(&clear_filters);
|
|
let filter_popover = gtk4::Popover::builder().child(&filter_panel).build();
|
|
|
|
// The Filters button, with a badge counting the active filters, kind
|
|
// included, so a filtered timeline is never mistaken for the whole one.
|
|
let filter_count = gtk4::Label::builder().visible(false).build();
|
|
filter_count.add_css_class("filter-count");
|
|
let filter_face = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
|
filter_face.append(>k4::Image::from_icon_name("pdfs-filter-symbolic"));
|
|
filter_face.append(>k4::Label::new(Some(&gettext("Filters"))));
|
|
filter_face.append(&filter_count);
|
|
let filter_btn = gtk4::MenuButton::builder()
|
|
.child(&filter_face)
|
|
.popover(&filter_popover)
|
|
.build();
|
|
filter_btn.add_css_class("pill");
|
|
|
|
// The kind toggles and the Filters button travel together: they filter the
|
|
// timeline, and neither applies to the album grid or to an open album.
|
|
let filters = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
filters.set_hexpand(true);
|
|
filters.append(&tabs.widget());
|
|
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
|
spacer.set_hexpand(true);
|
|
filters.append(&spacer);
|
|
filters.append(&filter_btn);
|
|
|
|
// The page's two views, as one segmented control under the title: the
|
|
// timeline, and the albums. This is navigation, not a filter — which is why
|
|
// it sits above the filter row rather than beside the kind toggles.
|
|
// "Timeline", not "Photos": the kind filter below has a Photos tab too.
|
|
let view_switch = ToggleGroup::new(&[
|
|
(&gettext("Timeline"), None),
|
|
(&gettext("Albums"), None),
|
|
(&gettext("Places"), None),
|
|
]);
|
|
view_switch.set_round();
|
|
let view_switch_widget = view_switch.widget();
|
|
view_switch_widget.add_css_class("view-switch");
|
|
view_switch_widget.set_halign(gtk4::Align::Start);
|
|
|
|
let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
filter_bar.append(&filters);
|
|
|
|
// "On this day": a card per earlier year that has photos from today's
|
|
// date. Shown as the first row of the unfiltered timeline (see
|
|
// [`sync_memories`]), so it scrolls away with the grid.
|
|
let memories_title = gtk4::Label::builder()
|
|
.label(gettext("On this day"))
|
|
.xalign(0.0)
|
|
.build();
|
|
memories_title.add_css_class("heading");
|
|
let memory_cards = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
let memory_scroll = gtk4::ScrolledWindow::builder()
|
|
.vscrollbar_policy(gtk4::PolicyType::Never)
|
|
.hscrollbar_policy(gtk4::PolicyType::Automatic)
|
|
.propagate_natural_height(true)
|
|
.child(&memory_cards)
|
|
.build();
|
|
let memories = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
|
memories.set_hexpand(true);
|
|
memories.set_margin_bottom(4);
|
|
memories.append(&memories_title);
|
|
memories.append(&memory_scroll);
|
|
|
|
// The timeline (plus its pager) or the status page, never both.
|
|
let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
|
timeline.append(&select_bar);
|
|
// The scrubber lies over the grid's right edge and only shows when wanted,
|
|
// so the grid keeps its full width.
|
|
let timeline_view = gtk4::Overlay::new();
|
|
timeline_view.set_child(Some(&scroll));
|
|
let scrubber = Scrubber::new(&timeline_view);
|
|
timeline.append(&timeline_view);
|
|
timeline.append(&pager);
|
|
|
|
// The album grid: cover-first cards that flow to the width they are given.
|
|
let albums = gtk4::FlowBox::builder()
|
|
.selection_mode(gtk4::SelectionMode::None)
|
|
.homogeneous(true)
|
|
.row_spacing(TILE_GAP as u32 * 2)
|
|
.column_spacing(TILE_GAP as u32 * 2)
|
|
.min_children_per_line(2)
|
|
.max_children_per_line(8)
|
|
.valign(gtk4::Align::Start)
|
|
.build();
|
|
let albums_scroll = gtk4::ScrolledWindow::builder()
|
|
.vexpand(true)
|
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
|
.child(&albums)
|
|
.build();
|
|
let albums_status = adw::StatusPage::builder()
|
|
.icon_name("view-grid-symbolic")
|
|
.vexpand(true)
|
|
.build();
|
|
albums_status.add_css_class("compact");
|
|
let albums_stack = gtk4::Stack::new();
|
|
albums_stack.set_vexpand(true);
|
|
albums_stack.add_named(&albums_scroll, Some("grid"));
|
|
albums_stack.add_named(&albums_status, Some("status"));
|
|
let new_album = gtk4::Button::builder()
|
|
.child(
|
|
&adw::ButtonContent::builder()
|
|
.label(gettext("New Album…"))
|
|
.icon_name("list-add-symbolic")
|
|
.build(),
|
|
)
|
|
.halign(gtk4::Align::Start)
|
|
.action_name("win.new-album")
|
|
.build();
|
|
new_album.add_css_class("pill");
|
|
let albums_page = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
|
albums_page.append(&new_album);
|
|
albums_page.append(&albums_stack);
|
|
|
|
// The place grid: the same cards, one per town the photos were taken in.
|
|
let places = gtk4::FlowBox::builder()
|
|
.selection_mode(gtk4::SelectionMode::None)
|
|
.homogeneous(true)
|
|
.row_spacing(TILE_GAP as u32 * 2)
|
|
.column_spacing(TILE_GAP as u32 * 2)
|
|
.min_children_per_line(2)
|
|
.max_children_per_line(8)
|
|
.valign(gtk4::Align::Start)
|
|
.build();
|
|
let places_scroll = gtk4::ScrolledWindow::builder()
|
|
.vexpand(true)
|
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
|
.child(&places)
|
|
.build();
|
|
let places_status = adw::StatusPage::builder()
|
|
.icon_name("mark-location-symbolic")
|
|
.vexpand(true)
|
|
.build();
|
|
places_status.add_css_class("compact");
|
|
let places_stack = gtk4::Stack::new();
|
|
places_stack.set_vexpand(true);
|
|
places_stack.add_named(&places_scroll, Some("grid"));
|
|
places_stack.add_named(&places_status, Some("status"));
|
|
let places_map = PlacesMap::new();
|
|
places_stack.add_named(places_map.widget(), Some("map"));
|
|
// The places as cards or on the map.
|
|
let places_layout = ToggleGroup::new(&[
|
|
(&pgettext("layout", "Grid"), None),
|
|
(&pgettext("layout", "Map"), None),
|
|
]);
|
|
// While a refresh reads the library's locations (the first one after an
|
|
// update reads all of them), a bar says how far it has got.
|
|
let places_mapping_label = gtk4::Label::new(None);
|
|
places_mapping_label.add_css_class("dim-label");
|
|
let places_mapping_bar = gtk4::ProgressBar::builder()
|
|
.hexpand(true)
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
let places_mapping = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
|
places_mapping.set_hexpand(true);
|
|
places_mapping.append(&places_mapping_label);
|
|
places_mapping.append(&places_mapping_bar);
|
|
places_mapping.set_visible(false);
|
|
let places_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
|
places_bar.append(&places_layout.widget());
|
|
places_bar.append(&places_mapping);
|
|
let places_page = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
|
places_page.append(&places_bar);
|
|
places_page.append(&places_stack);
|
|
|
|
let content = gtk4::Stack::new();
|
|
content.set_vexpand(true);
|
|
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
|
content.add_named(&timeline, Some("timeline"));
|
|
content.add_named(&status, Some("status"));
|
|
content.add_named(&albums_page, Some("albums"));
|
|
content.add_named(&places_page, Some("places"));
|
|
|
|
let import_banner = adw::Banner::builder()
|
|
.title(gettext("Importing from Google Photos…"))
|
|
.button_label(pgettext("verb", "View"))
|
|
.action_name("win.show-import")
|
|
.build();
|
|
|
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
|
inner.set_margin_top(12);
|
|
inner.set_margin_bottom(12);
|
|
inner.set_margin_start(12);
|
|
inner.set_margin_end(12);
|
|
inner.append(&view_switch_widget);
|
|
inner.append(&filter_bar);
|
|
inner.append(&content);
|
|
|
|
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
|
body.append(&import_banner);
|
|
body.append(&inner);
|
|
let (frame, header, title) = page_frame(&gettext("Photos"), &body);
|
|
header.pack_start(&back);
|
|
header.pack_start(&upload);
|
|
header.pack_end(&more);
|
|
header.pack_end(&select_btn);
|
|
|
|
(
|
|
frame.upcast(),
|
|
GalleryWidgets {
|
|
model,
|
|
groups,
|
|
content,
|
|
status,
|
|
title,
|
|
pager,
|
|
scrubber,
|
|
import_banner,
|
|
list,
|
|
scroll,
|
|
retry,
|
|
upload,
|
|
empty_actions,
|
|
empty_upload,
|
|
empty_import,
|
|
select_btn,
|
|
select_bar,
|
|
select_label,
|
|
select_trash,
|
|
select_album,
|
|
select_done,
|
|
tabs,
|
|
favorites_row,
|
|
not_in_album_row,
|
|
memories,
|
|
memory_cards,
|
|
dates,
|
|
filter_btn,
|
|
filter_count,
|
|
clear_filters,
|
|
albums,
|
|
albums_stack,
|
|
albums_status,
|
|
view_switch,
|
|
places,
|
|
places_stack,
|
|
places_status,
|
|
places_mapping,
|
|
places_mapping_label,
|
|
places_mapping_bar,
|
|
places_map,
|
|
places_layout,
|
|
back,
|
|
filters,
|
|
},
|
|
)
|
|
}
|
|
|
|
/// The [`PhotoKind`] filter a gallery tab index selects: index 0 is All (no
|
|
/// filter), then Photos / Videos / Raw. Index-aligned with the toggle array.
|
|
pub(crate) fn kind_for_tab(index: usize) -> Option<PhotoKind> {
|
|
match index {
|
|
1 => Some(PhotoKind::Photo),
|
|
2 => Some(PhotoKind::Video),
|
|
3 => Some(PhotoKind::Raw),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The `[from, to)` epoch-second window of a local calendar month, or `None` if
|
|
/// the date is somehow unrepresentable. Computed with glib so month rollover and
|
|
/// the local timezone (matching the daemon's month buckets) are handled for us.
|
|
pub(crate) fn month_range(year: i32, month: i32) -> Option<(i64, i64)> {
|
|
let start = glib::DateTime::from_local(year, month, 1, 0, 0, 0.0).ok()?;
|
|
let end = start.add_months(1).ok()?;
|
|
Some((start.to_unix(), end.to_unix()))
|
|
}
|
|
|
|
/// "June 2024": a calendar month in the user's language, for the date jump
|
|
/// and the scrubber. The month name comes from glib, so it follows the locale.
|
|
pub(crate) fn month_label(year: i32, month: i32) -> String {
|
|
// Translators: strftime format for a month on its own, such as "June 2024". %OB is the month name in its standalone (nominative) form; use %B if your language has no separate form.
|
|
let format = gettext("%OB %Y");
|
|
glib::DateTime::from_local(year, month, 1, 0, 0, 0.0)
|
|
.and_then(|date| date.format(&format))
|
|
.map(|label| label.to_string())
|
|
.unwrap_or_else(|_| format!("{year}-{month:02}"))
|
|
}
|
|
|
|
/// How many timeline filters are on: the kind, favorites, "not in an album"
|
|
/// and the month each count once.
|
|
pub(crate) fn active_filters(
|
|
kind: Option<PhotoKind>,
|
|
favorites: bool,
|
|
not_in_album: bool,
|
|
range: Option<(i64, i64)>,
|
|
) -> usize {
|
|
[kind.is_some(), favorites, not_in_album, range.is_some()]
|
|
.into_iter()
|
|
.filter(|on| *on)
|
|
.count()
|
|
}
|
|
|
|
/// Show the active filter count on the Filters button, and offer "Clear
|
|
/// filters" only when there is something to clear.
|
|
fn sync_filter_badge(ui: &Rc<Ui>) {
|
|
let n = active_filters(
|
|
ui.gallery.kind.get(),
|
|
ui.gallery.favorites.get(),
|
|
ui.gallery.not_in_album.get(),
|
|
ui.gallery.range.get(),
|
|
);
|
|
ui.gallery.filter_count.set_label(&n.to_string());
|
|
ui.gallery.filter_count.set_visible(n > 0);
|
|
ui.gallery.clear_filters.set_sensitive(n > 0);
|
|
}
|
|
|
|
/// Rebuild the date-jump dropdown for the active kind: ask the daemon which
|
|
/// months the timeline spans and turn them into "Month YYYY (count)" rows, each
|
|
/// remembering the window it jumps to. Resets the selection to "All dates" — the
|
|
/// caller pairs this with a fresh timeline load. Off the UI thread; a failure
|
|
/// just leaves the dropdown as it was.
|
|
pub(crate) fn refresh_photo_months(ui: &Rc<Ui>) {
|
|
let rx = spawn_request(
|
|
ui.dirs.control_socket(),
|
|
Request::PhotoMonths {
|
|
kind: ui.gallery.kind.get(),
|
|
},
|
|
);
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
let Ok(Ok(Response::PhotoMonths { months })) = rx.recv().await else {
|
|
return;
|
|
};
|
|
let mut labels = vec![gettext("All dates")];
|
|
let mut ranges: Vec<Option<(i64, i64)>> = vec![None];
|
|
fill_scrubber(&ui, &months);
|
|
for m in months {
|
|
let count = m.count.to_string();
|
|
let month = month_label(m.year, m.month);
|
|
// Translators: a row of the date jump; {month} is a month such as "June 2024", {count} how many photos it holds.
|
|
let label = gettext_f("{month} ({count})", &[("month", &month), ("count", &count)]);
|
|
labels.push(label);
|
|
ranges.push(month_range(m.year, m.month));
|
|
}
|
|
let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
|
|
|
|
// Repopulating resets the selection to 0, which would otherwise fire the
|
|
// handler and reload; suppress that — the caller is already reloading.
|
|
ui.gallery.date_suppress.set(true);
|
|
ui.gallery
|
|
.dates
|
|
.set_model(Some(>k4::StringList::new(&label_refs)));
|
|
ui.gallery.dates.set_selected(0);
|
|
*ui.gallery.date_ranges.borrow_mut() = ranges;
|
|
ui.gallery.date_suppress.set(false);
|
|
});
|
|
}
|
|
|
|
/// Label the filter toggles with the whole-timeline `(photos, videos, raw)`
|
|
/// counts, so a glance shows how much sits behind each tab. A tab with nothing
|
|
/// behind it is disabled — you can't filter to an empty set — but the currently
|
|
/// selected one stays clickable so you can always switch back off it.
|
|
pub(crate) fn update_gallery_tabs(ui: &Rc<Ui>, counts: (usize, usize, usize)) {
|
|
ui.gallery.counts.set(Some(counts));
|
|
let (photos, videos, raw) = counts;
|
|
let totals = [photos + videos + raw, photos, videos, raw];
|
|
let active = ui.gallery.tabs.active() as usize;
|
|
for (index, n) in totals.into_iter().enumerate() {
|
|
let tabs = &ui.gallery.tabs;
|
|
tabs.set_label(
|
|
index as u32,
|
|
&format!("{} {}", tab_label(index), thousands(n)),
|
|
);
|
|
tabs.set_enabled(index as u32, n > 0 || index == active);
|
|
}
|
|
}
|
|
|
|
/// The Photos page's views, as indexes into its view switcher.
|
|
pub(crate) const VIEW_TIMELINE: u32 = 0;
|
|
pub(crate) const VIEW_ALBUMS: u32 = 1;
|
|
pub(crate) const VIEW_PLACES: u32 = 2;
|
|
|
|
/// The Places view's Map layout, as an index into its Grid/Map toggle; the
|
|
/// grid is the other one.
|
|
pub(crate) const PLACES_MAP: u32 = 1;
|
|
|
|
/// The name of filter tab `index`: All, Photos, Videos or Raw.
|
|
fn tab_label(index: usize) -> String {
|
|
match index {
|
|
// Translators: a Photos filter tab that shows photos, videos and raw files together.
|
|
0 => pgettext("photo filter", "All"),
|
|
1 => pgettext("photo filter", "Photos"),
|
|
2 => pgettext("photo filter", "Videos"),
|
|
// Translators: a Photos filter tab for raw camera files.
|
|
_ => pgettext("photo filter", "Raw"),
|
|
}
|
|
}
|
|
|
|
/// `1422` as `1,422`. Six-figure libraries are ordinary, and an unseparated run
|
|
/// of digits in a tab label is unreadable at a glance.
|
|
pub(crate) fn thousands(n: usize) -> String {
|
|
let digits = n.to_string();
|
|
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
|
|
for (i, c) in digits.chars().enumerate() {
|
|
if i > 0 && (digits.len() - i).is_multiple_of(3) {
|
|
out.push(',');
|
|
}
|
|
out.push(c);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Wire the gallery: install the section factory, the zoom gestures, the pager
|
|
/// and the upload button. Activating a thumbnail downloads the photo and opens it
|
|
/// in the in-app lightbox.
|
|
/// Wire the empty-timeline buttons. Upload re-fires the header's own Upload
|
|
/// rather than duplicating its file-chooser, so the two can't drift apart.
|
|
pub(crate) fn wire_gallery_empty(ui: &Rc<Ui>, upload: >k4::Button, import: >k4::Button) {
|
|
let ui_upload = ui.clone();
|
|
upload.connect_clicked(move |_| ui_upload.gallery.upload.emit_clicked());
|
|
let ui_import = ui.clone();
|
|
import.connect_clicked(move |_| ui_import.stack.set_visible_child_name("takeout"));
|
|
}
|
|
|
|
pub(crate) fn wire_gallery(
|
|
ui: &Rc<Ui>,
|
|
list: >k4::ListView,
|
|
scroll: >k4::ScrolledWindow,
|
|
select_done: >k4::Button,
|
|
) {
|
|
let factory = gtk4::SignalListItemFactory::new();
|
|
factory.connect_setup(|_, item| {
|
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
// One container for both row kinds: the bind fills it with either a
|
|
// heading label or a strip of tiles from the row's pool (see
|
|
// [`TileWidget`]).
|
|
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP);
|
|
row.set_halign(gtk4::Align::Start);
|
|
item.set_child(Some(&row));
|
|
item.set_activatable(false);
|
|
});
|
|
|
|
let ui_bind = ui.clone();
|
|
factory.connect_bind(move |_, item| {
|
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
let row_box = item.child().and_downcast::<gtk4::Box>().unwrap();
|
|
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
|
let row = obj.borrow::<GalleryRow>();
|
|
|
|
while let Some(child) = row_box.first_child() {
|
|
row_box.remove(&child);
|
|
}
|
|
row_box.set_halign(gtk4::Align::Start);
|
|
match &*row {
|
|
GalleryRow::Memories => {
|
|
// One strip widget, moved into whichever row box shows it.
|
|
let strip = &ui_bind.gallery.memories;
|
|
if let Some(parent) = strip.parent().and_downcast::<gtk4::Box>() {
|
|
parent.remove(strip);
|
|
}
|
|
row_box.set_halign(gtk4::Align::Fill);
|
|
row_box.set_margin_top(8);
|
|
row_box.set_margin_bottom(0);
|
|
row_box.append(strip);
|
|
}
|
|
GalleryRow::Heading(heading) => {
|
|
row_box.set_margin_top(8);
|
|
row_box.set_margin_bottom(0);
|
|
let label = gtk4::Label::builder()
|
|
.label(heading)
|
|
.halign(gtk4::Align::Start)
|
|
.build();
|
|
label.add_css_class("heading");
|
|
label.add_css_class("gallery-day");
|
|
row_box.append(&label);
|
|
}
|
|
GalleryRow::Tiles { tiles, last } => {
|
|
row_box.set_margin_top(0);
|
|
// The last row of a day carries the gap to the next heading.
|
|
row_box.set_margin_bottom(if *last { 4 } else { TILE_GAP });
|
|
let mut pools = ui_bind.gallery.tile_pools.borrow_mut();
|
|
let pool = pools.entry(row_box.clone()).or_default();
|
|
for widget in pool.iter() {
|
|
widget.release(&ui_bind);
|
|
}
|
|
while pool.len() < tiles.len() {
|
|
pool.push(TileWidget::new(&ui_bind));
|
|
}
|
|
for (widget, tile) in pool.iter().zip(tiles) {
|
|
widget.bind(&ui_bind, tile);
|
|
row_box.append(&widget.button);
|
|
}
|
|
drop(pools);
|
|
schedule_thumbs(&ui_bind);
|
|
}
|
|
}
|
|
ui_bind
|
|
.gallery
|
|
.bound
|
|
.borrow_mut()
|
|
.insert(item.position(), (row.anchor(), row_box.clone()));
|
|
});
|
|
|
|
// ListView recycles row widgets, so a scrolled-away row must give up its
|
|
// claim on them — otherwise a thumbnail landing late would paint into a tile
|
|
// that now shows a different photo. Only claims this row widget still
|
|
// holds are given up: after a re-flow, the same photo (and the same
|
|
// position) is often bound again in another row before this one unbinds.
|
|
let ui_unbind = ui.clone();
|
|
factory.connect_unbind(move |_, item| {
|
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
let Some(row_box) = item.child() else {
|
|
return;
|
|
};
|
|
{
|
|
let mut bound = ui_unbind.gallery.bound.borrow_mut();
|
|
if bound
|
|
.get(&item.position())
|
|
.is_some_and(|(_, row)| row.upcast_ref::<gtk4::Widget>() == &row_box)
|
|
{
|
|
bound.remove(&item.position());
|
|
}
|
|
}
|
|
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>()
|
|
&& let GalleryRow::Tiles { tiles, .. } = &*obj.borrow::<GalleryRow>()
|
|
{
|
|
let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut();
|
|
for tile in tiles {
|
|
if wanted
|
|
.get(&tile.photo.uid)
|
|
.is_some_and(|picture| picture.is_ancestor(&row_box))
|
|
{
|
|
wanted.remove(&tile.photo.uid);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
let ui_teardown = ui.clone();
|
|
factory.connect_teardown(move |_, item| {
|
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
if let Some(row_box) = item.child().and_downcast::<gtk4::Box>() {
|
|
ui_teardown.gallery.tile_pools.borrow_mut().remove(&row_box);
|
|
}
|
|
});
|
|
list.set_factory(Some(&factory));
|
|
|
|
// The grid divides the content width, so a resize re-flows whatever is on
|
|
// screen (offscreen sections pick the new width up when they bind). The
|
|
// width is the viewport's, read from the horizontal adjustment's page
|
|
// size: the list's own width follows its widest row, so laying rows out to
|
|
// it would feed each layout's rounding into the next, wider one.
|
|
let ui_width = ui.clone();
|
|
scroll
|
|
.hadjustment()
|
|
.connect_page_size_notify(move |adjustment| {
|
|
let width = adjustment.page_size() as i32;
|
|
if width > 0 && width != ui_width.gallery.width.get() {
|
|
ui_width.gallery.width.set(width);
|
|
schedule_relayout(&ui_width);
|
|
}
|
|
});
|
|
|
|
// Page the timeline in while the user is still a screen and a half from the
|
|
// end, so scrolling at a normal pace never reaches it.
|
|
let ui_scroll = ui.clone();
|
|
scroll.vadjustment().connect_value_changed(move |adj| {
|
|
let last = ui_scroll.gallery.scroll_offset.replace(adj.value());
|
|
if (adj.value() - last).abs() > 1.0 {
|
|
ui_scroll.gallery.scrolling_down.set(adj.value() > last);
|
|
}
|
|
let near_end = adj.value() + adj.page_size() >= adj.upper() - adj.page_size() * 1.5;
|
|
if near_end && ui_scroll.gallery.has_more.get() {
|
|
load_gallery(&ui_scroll, true);
|
|
}
|
|
sync_scrubber_position(&ui_scroll);
|
|
ui_scroll.gallery.scrubber.flash();
|
|
});
|
|
wire_scrubber(ui);
|
|
wire_drag_select(ui);
|
|
|
|
// Ctrl+scroll zoom. Capture phase so the ScrolledWindow doesn't eat the event
|
|
// and scroll the page out from under the gesture.
|
|
let zoom_scroll = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
|
|
zoom_scroll.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
|
let ui_zoom = ui.clone();
|
|
zoom_scroll.connect_scroll(move |controller, _dx, dy| {
|
|
if !controller
|
|
.current_event_state()
|
|
.contains(gtk4::gdk::ModifierType::CONTROL_MASK)
|
|
|| dy == 0.0
|
|
{
|
|
return glib::Propagation::Proceed;
|
|
}
|
|
// Scroll up (negative dy) zooms in, i.e. bigger tiles.
|
|
zoom_gallery(&ui_zoom, if dy < 0.0 { ROW_STEP } else { -ROW_STEP });
|
|
glib::Propagation::Stop
|
|
});
|
|
scroll.add_controller(zoom_scroll);
|
|
|
|
// Ctrl+plus / Ctrl+minus / Ctrl+0, the keyboard equivalents, and the two
|
|
// keys selection mode owns.
|
|
let zoom_keys = gtk4::EventControllerKey::new();
|
|
let ui_keys = ui.clone();
|
|
zoom_keys.connect_key_pressed(move |_, key, _code, state| {
|
|
if ui_keys.gallery.selecting.get() {
|
|
match key.name().as_deref() {
|
|
Some("Escape") => {
|
|
set_selection_mode(&ui_keys, false);
|
|
return glib::Propagation::Stop;
|
|
}
|
|
Some("Delete" | "KP_Delete") => {
|
|
delete_selected(&ui_keys);
|
|
return glib::Propagation::Stop;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if !state.contains(gtk4::gdk::ModifierType::CONTROL_MASK) {
|
|
return glib::Propagation::Proceed;
|
|
}
|
|
match key.name().as_deref() {
|
|
Some("plus" | "equal" | "KP_Add") => zoom_gallery(&ui_keys, ROW_STEP),
|
|
Some("minus" | "KP_Subtract") => zoom_gallery(&ui_keys, -ROW_STEP),
|
|
Some("0" | "KP_0") => set_gallery_tile(&ui_keys, ROW_DEFAULT),
|
|
Some("a" | "A") => select_all_photos(&ui_keys),
|
|
_ => return glib::Propagation::Proceed,
|
|
}
|
|
glib::Propagation::Stop
|
|
});
|
|
list.add_controller(zoom_keys);
|
|
|
|
let ui_select = ui.clone();
|
|
ui.gallery.select_btn.clone().connect_toggled(move |btn| {
|
|
set_selection_mode(&ui_select, btn.is_active());
|
|
});
|
|
let ui_trash = ui.clone();
|
|
let ui_album = ui.clone();
|
|
ui.gallery.select_album.clone().connect_clicked(move |_| {
|
|
let uids: Vec<String> = ui_album.gallery.selected.borrow().iter().cloned().collect();
|
|
prompt_add_to_album(&ui_album, uids);
|
|
});
|
|
ui.gallery.select_trash.clone().connect_clicked(move |_| {
|
|
delete_selected(&ui_trash);
|
|
});
|
|
let ui_done = ui.clone();
|
|
select_done
|
|
.clone()
|
|
.connect_clicked(move |_| set_selection_mode(&ui_done, false));
|
|
|
|
// Filter toggles: flipping to a tab reloads the timeline filtered to that
|
|
// kind. A redundant toggle to the already-current kind is a no-op.
|
|
let ui_tab = ui.clone();
|
|
ui.gallery.tabs.connect_changed(move |index| {
|
|
let kind = kind_for_tab(index as usize);
|
|
if ui_tab.gallery.kind.get() == kind {
|
|
return;
|
|
}
|
|
ui_tab.gallery.kind.set(kind);
|
|
// A different kind has a different set of months; clearing the active
|
|
// window makes the reload rebuild the date jump for the new kind.
|
|
ui_tab.gallery.range.set(None);
|
|
load_gallery(&ui_tab, false);
|
|
});
|
|
let ui_clear = ui.clone();
|
|
ui.gallery.clear_filters.connect_clicked(move |_| {
|
|
let gallery = &ui_clear.gallery;
|
|
gallery.kind.set(None);
|
|
gallery.favorites.set(false);
|
|
gallery.not_in_album.set(false);
|
|
gallery.range.set(None);
|
|
gallery.tabs.set_active(0);
|
|
gallery.favorites_row.set_active(false);
|
|
gallery.not_in_album_row.set_active(false);
|
|
gallery.date_suppress.set(true);
|
|
gallery.dates.set_selected(0);
|
|
gallery.date_suppress.set(false);
|
|
gallery.filter_btn.popdown();
|
|
load_gallery(&ui_clear, false);
|
|
});
|
|
|
|
// Date jump: selecting a month loads that window; "All dates" (row 0) clears
|
|
// it. Skipped while the model is being repopulated (see `gallery_date_suppress`).
|
|
let ui_dates = ui.clone();
|
|
ui.gallery.dates.connect_selected_notify(move |dd| {
|
|
if ui_dates.gallery.date_suppress.get() {
|
|
return;
|
|
}
|
|
let range = ui_dates
|
|
.gallery
|
|
.date_ranges
|
|
.borrow()
|
|
.get(dd.selected() as usize)
|
|
.copied()
|
|
.flatten();
|
|
if ui_dates.gallery.range.get() == range {
|
|
return;
|
|
}
|
|
ui_dates.gallery.range.set(range);
|
|
load_gallery(&ui_dates, false);
|
|
});
|
|
|
|
let ui_upload = ui.clone();
|
|
ui.gallery.upload.connect_clicked(move |_| {
|
|
let dialog = gtk4::FileDialog::builder()
|
|
.title(gettext("Select Photo to Upload"))
|
|
.build();
|
|
|
|
let filter = gtk4::FileFilter::new();
|
|
filter.set_name(Some(&gettext("Images")));
|
|
filter.add_mime_type("image/*");
|
|
let filters = gio::ListStore::new::<gtk4::FileFilter>();
|
|
filters.append(&filter);
|
|
dialog.set_filters(Some(&filters));
|
|
|
|
let ui = ui_upload.clone();
|
|
let parent_win = ui.stack.root().and_downcast::<gtk4::Window>();
|
|
dialog.open(parent_win.as_ref(), gio::Cancellable::NONE, move |res| {
|
|
if let Ok(file) = res
|
|
&& let Some(path) = file.path()
|
|
{
|
|
let name = path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("photo.jpg")
|
|
.to_string();
|
|
let ext = path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("jpg")
|
|
.to_lowercase();
|
|
let media_type = match ext.as_str() {
|
|
"png" => "image/png",
|
|
"gif" => "image/gif",
|
|
"webp" => "image/webp",
|
|
"tiff" | "tif" => "image/tiff",
|
|
_ => "image/jpeg",
|
|
};
|
|
// The daemon dates the photo: by its EXIF, or by the file's
|
|
// modification time when it has none.
|
|
let capture_time = None;
|
|
|
|
ui.busy_begin();
|
|
let rx = spawn_request(
|
|
ui.dirs.control_socket(),
|
|
// The daemon opens the file itself: it runs on this machine,
|
|
// and a photo's bytes through line-delimited JSON is an OOM
|
|
// of both processes (see `Request::UploadPhoto`).
|
|
Request::UploadPhoto {
|
|
name,
|
|
media_type: media_type.to_string(),
|
|
source_path: path.display().to_string(),
|
|
capture_time,
|
|
},
|
|
);
|
|
let ui_clone = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
let res = rx.recv().await;
|
|
ui_clone.busy_end();
|
|
match res {
|
|
Ok(Ok(Response::Ok { message })) => {
|
|
tracing::info!("Photo uploaded: {message}");
|
|
load_gallery(&ui_clone, false);
|
|
toast(&ui_clone, &gettext("Photo uploaded"));
|
|
}
|
|
Ok(Ok(Response::Error { message, kind })) => {
|
|
toast_failure(
|
|
&ui_clone,
|
|
&gettext("Couldn't upload photo"),
|
|
&message,
|
|
kind,
|
|
);
|
|
}
|
|
_ => {
|
|
toast_error(
|
|
&ui_clone,
|
|
&gettext("Couldn't upload photo"),
|
|
&gettext("The Proton Drive service didn't respond."),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/// One tile of the grid: the photo, and the box [`justify_rows`] sized it to.
|
|
#[derive(Clone)]
|
|
pub(crate) struct Tile {
|
|
pub(crate) photo: PhotoItem,
|
|
pub(crate) width: i32,
|
|
pub(crate) height: i32,
|
|
/// Whether the grid is in selection mode, and whether this photo is picked.
|
|
/// Carried on the tile rather than read at bind time so the row diff in
|
|
/// [`repaint_gallery`] sees a selection change and rebuilds that row.
|
|
pub(crate) selecting: bool,
|
|
pub(crate) selected: bool,
|
|
/// How the timeline is sectioned, which decides what the caption says.
|
|
pub(crate) grouping: Grouping,
|
|
}
|
|
|
|
/// Break one section's photos into rows of square tiles spanning `width`.
|
|
///
|
|
/// Squares, because a wide window and a day of a few photos do not mix with
|
|
/// rows justified to each photo's shape: a short row stays short, and portrait
|
|
/// phone shots come out as thin slivers. Every tile of a square grid is the
|
|
/// same size whatever it shows, and the thumbnail is cropped to fill it.
|
|
pub(crate) fn justify_rows(ui: &Rc<Ui>, photos: &[PhotoItem], width: i32) -> Vec<Vec<Tile>> {
|
|
let selecting = ui.gallery.selecting.get();
|
|
let selected = ui.gallery.selected.borrow();
|
|
let target = ui.gallery.row_height.get();
|
|
let grouping = grouping_for(target);
|
|
let (columns, size) = plan_grid(width, target);
|
|
photos
|
|
.chunks(columns)
|
|
.map(|row| {
|
|
row.iter()
|
|
.map(|photo| Tile {
|
|
selecting,
|
|
selected: selecting && selected.contains(&photo.uid),
|
|
grouping,
|
|
photo: photo.clone(),
|
|
width: size,
|
|
height: size,
|
|
})
|
|
.collect()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The layout math: how many square tiles of about `target` px fit across
|
|
/// `width`, and the size that makes them span it exactly, gaps included.
|
|
///
|
|
/// The count is rounded to the nearest whole column, so a tile never ends up
|
|
/// more than half a column off the zoom level asked for.
|
|
pub(crate) fn plan_grid(width: i32, target: i32) -> (usize, i32) {
|
|
let width = width.max(1);
|
|
let target = target.clamp(ROW_MIN, ROW_MAX);
|
|
let columns = (f64::from(width + TILE_GAP) / f64::from(target + TILE_GAP)).round() as i32;
|
|
let columns = columns.max(1);
|
|
let size = ((width - TILE_GAP * (columns - 1)) / columns).max(1);
|
|
(columns as usize, size)
|
|
}
|
|
|
|
/// The width the grid is laid out to: the viewport's width, less a couple of
|
|
/// px so a rounding error can't push a row into a horizontal overflow.
|
|
/// Falls back to a sane guess before the first allocation.
|
|
pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
|
match ui.gallery.width.get() {
|
|
0 => 900,
|
|
w => (w - 2).max(ROW_MIN),
|
|
}
|
|
}
|
|
|
|
/// One photo tile: a fixed-size button wrapping the thumbnail, with the capture
|
|
/// time revealed on hover over a bottom scrim. A button (rather than a bare
|
|
/// picture) so the tile is focusable, keyboard-activatable and gets hover feedback
|
|
/// for free.
|
|
///
|
|
/// The picture sits in an overlay over a placeholder, so a tile is never a hole:
|
|
/// until the thumbnail lands it shows a dim card, and a photo that can never have
|
|
/// one keeps an image glyph instead of an empty rectangle.
|
|
///
|
|
/// A fast scroll binds hundreds of rows, so a tile is built once and then
|
|
/// rebound to whichever photo its row shows next: [`TileWidget::bind`] only
|
|
/// swaps what it shows, and its handlers read the photo it shows now.
|
|
pub(crate) struct TileWidget {
|
|
pub(crate) button: gtk4::Button,
|
|
picture: gtk4::Picture,
|
|
caption: gtk4::Label,
|
|
video: gtk4::Image,
|
|
group: gtk4::Label,
|
|
check: gtk4::Image,
|
|
photo: Rc<RefCell<Option<PhotoItem>>>,
|
|
}
|
|
|
|
impl TileWidget {
|
|
pub(crate) fn new(ui: &Rc<Ui>) -> Self {
|
|
let picture = gtk4::Picture::builder()
|
|
// Every tile is square, so Cover crops the photo to fill it.
|
|
// Contain would letterbox it, and the bars would read as gaps in
|
|
// the sheet. The expands are what make the picture take the whole
|
|
// overlay.
|
|
.content_fit(gtk4::ContentFit::Cover)
|
|
.can_shrink(true)
|
|
.hexpand(true)
|
|
.vexpand(true)
|
|
.build();
|
|
// Zoomed on hover by the stylesheet; the tile clips the overflow.
|
|
picture.add_css_class("photo-thumb");
|
|
|
|
let placeholder = gtk4::Image::builder()
|
|
.icon_name("image-x-generic-symbolic")
|
|
.pixel_size(24)
|
|
.halign(gtk4::Align::Center)
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
placeholder.add_css_class("photo-placeholder");
|
|
|
|
// The capture time, on a gradient that only exists while the pointer
|
|
// is over the tile — legible over any photo, invisible the rest of the
|
|
// time.
|
|
let caption = gtk4::Label::builder()
|
|
// Fill horizontally so the scrim spans the tile; the text itself
|
|
// stays left-aligned inside it.
|
|
.halign(gtk4::Align::Fill)
|
|
.valign(gtk4::Align::End)
|
|
.xalign(0.0)
|
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
|
.build();
|
|
caption.add_css_class("photo-caption");
|
|
|
|
// A video reads as a video at a glance: a play glyph centred over the
|
|
// poster thumbnail. Kept above the caption scrim so it stays legible
|
|
// on hover.
|
|
let video = gtk4::Image::builder()
|
|
.icon_name("media-playback-start-symbolic")
|
|
.halign(gtk4::Align::Center)
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
video.add_css_class("photo-video-badge");
|
|
|
|
// A shot stored as more than one file says so, in the corner the
|
|
// caption does not use.
|
|
let group = gtk4::Label::builder()
|
|
.halign(gtk4::Align::End)
|
|
.valign(gtk4::Align::Start)
|
|
.build();
|
|
group.add_css_class("photo-group-badge");
|
|
|
|
let check = gtk4::Image::builder()
|
|
.pixel_size(16)
|
|
.halign(gtk4::Align::Start)
|
|
.valign(gtk4::Align::Start)
|
|
.margin_start(6)
|
|
.margin_top(6)
|
|
.build();
|
|
check.add_css_class("photo-check");
|
|
|
|
let overlay = gtk4::Overlay::new();
|
|
overlay.set_child(Some(&placeholder));
|
|
overlay.add_overlay(&picture);
|
|
overlay.add_overlay(&caption);
|
|
overlay.add_overlay(&video);
|
|
overlay.add_overlay(&group);
|
|
overlay.add_overlay(&check);
|
|
|
|
let button = gtk4::Button::builder().child(&overlay).build();
|
|
button.add_css_class("photo-tile");
|
|
button.add_css_class("flat");
|
|
// Clip the thumbnail to the tile's rounded corners.
|
|
button.set_overflow(gtk4::Overflow::Hidden);
|
|
|
|
let photo: Rc<RefCell<Option<PhotoItem>>> = Rc::default();
|
|
let uid = {
|
|
let photo = photo.clone();
|
|
move || photo.borrow().as_ref().map(|p| p.uid.clone())
|
|
};
|
|
|
|
// Ctrl or Shift on a plain click starts a selection — the gesture
|
|
// people already use for picking things, without having to find the
|
|
// Select button first. Ctrl picks one photo; Shift picks everything
|
|
// from the last photo clicked to this one, as in a file manager.
|
|
// Claimed in the capture phase so the click never also opens the photo
|
|
// it was picking.
|
|
let modifier = gtk4::GestureClick::new();
|
|
modifier.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
|
let ui_modifier = ui.clone();
|
|
let modifier_uid = uid.clone();
|
|
modifier.connect_pressed(move |gesture, _, _, _| {
|
|
let state = gesture.current_event_state();
|
|
let shift = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
|
|
if !shift && !state.contains(gtk4::gdk::ModifierType::CONTROL_MASK) {
|
|
return;
|
|
}
|
|
let Some(uid) = modifier_uid() else {
|
|
return;
|
|
};
|
|
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
|
set_selection_mode(&ui_modifier, true);
|
|
let anchor = ui_modifier.gallery.select_anchor.borrow().clone();
|
|
match anchor {
|
|
Some(anchor) if shift => {
|
|
let base = ui_modifier.gallery.selected.borrow().clone();
|
|
select_range(&ui_modifier, &anchor, &uid, &base);
|
|
}
|
|
_ => toggle_selected(&ui_modifier, &uid),
|
|
}
|
|
});
|
|
button.add_controller(modifier);
|
|
|
|
let context = gtk4::GestureClick::builder().button(3).build();
|
|
let ui_context = ui.clone();
|
|
let context_photo = photo.clone();
|
|
context.connect_pressed(move |gesture, _, x, y| {
|
|
let Some(photo) = context_photo.borrow().clone() else {
|
|
return;
|
|
};
|
|
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
|
if let Some(anchor) = gesture.widget().and_downcast::<gtk4::Button>() {
|
|
show_photo_menu(&ui_context, &photo, &anchor, x, y);
|
|
}
|
|
});
|
|
button.add_controller(context);
|
|
|
|
// 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();
|
|
button.connect_clicked(move |_| {
|
|
let Some(uid) = uid() else {
|
|
return;
|
|
};
|
|
if ui_open.gallery.selecting.get() {
|
|
toggle_selected(&ui_open, &uid);
|
|
} else {
|
|
open_photo_viewer(&ui_open, uid);
|
|
}
|
|
});
|
|
|
|
Self {
|
|
button,
|
|
picture,
|
|
caption,
|
|
video,
|
|
group,
|
|
check,
|
|
photo,
|
|
}
|
|
}
|
|
|
|
/// Show `tile` in place of whatever the widget showed before.
|
|
pub(crate) fn bind(&self, ui: &Rc<Ui>, tile: &Tile) {
|
|
self.button.set_size_request(tile.width, tile.height);
|
|
self.picture.set_paintable(None::<>k4::gdk::Paintable>);
|
|
|
|
// Under a day heading the clock time is enough; under a month or a
|
|
// year the day is what the heading no longer says.
|
|
self.caption.set_label(&match tile.grouping {
|
|
Grouping::Day => short_capture_time(tile.photo.capture_time),
|
|
Grouping::Month | Grouping::Year => short_capture_date(tile.photo.capture_time),
|
|
});
|
|
|
|
// Zoomed out, the play glyph shrinks with the tile so it marks the
|
|
// video without covering it.
|
|
let small = tile.grouping != Grouping::Day;
|
|
self.video.set_visible(tile.photo.kind == PhotoKind::Video);
|
|
self.video.set_pixel_size(if small { 12 } else { 28 });
|
|
set_css_class(&self.video, "photo-video-badge-small", small);
|
|
|
|
// "RAW" is the useful word when one of the shot's files is a raw file
|
|
// — that is what the person wants to find — and a plain count covers
|
|
// the rest (a live photo, a burst). Only the Day zoom shows it: on the
|
|
// smaller Month and Year tiles a badge on every raw+JPEG shot turns
|
|
// the grid into noise.
|
|
let grouped = tile.photo.has_raw || tile.photo.group_size > 1;
|
|
self.group
|
|
.set_visible(grouped && tile.grouping == Grouping::Day);
|
|
if tile.photo.has_raw {
|
|
// Translators: badge on a photo tile whose shot includes a raw camera file.
|
|
self.group.set_label(&gettext("RAW"));
|
|
} else {
|
|
self.group.set_label(&tile.photo.group_size.to_string());
|
|
}
|
|
|
|
// Selection mode marks every tile, picked or not: a check that only
|
|
// appears once a photo is chosen leaves the user guessing what else is
|
|
// clickable.
|
|
self.check.set_visible(tile.selecting);
|
|
self.check.set_icon_name(Some(if tile.selected {
|
|
"checkbox-checked-symbolic"
|
|
} else {
|
|
"checkbox-symbolic"
|
|
}));
|
|
set_css_class(&self.check, "photo-check-on", tile.selected);
|
|
set_css_class(&self.button, "photo-tile-selected", tile.selected);
|
|
|
|
*self.photo.borrow_mut() = Some(tile.photo.clone());
|
|
want_thumb(ui, &tile.photo, &self.picture);
|
|
}
|
|
|
|
/// Give up the claim on the thumbnail of the photo shown last, so it
|
|
/// cannot land in this widget once it shows another one.
|
|
pub(crate) fn release(&self, ui: &Rc<Ui>) {
|
|
let Some(photo) = self.photo.borrow_mut().take() else {
|
|
return;
|
|
};
|
|
let mut wanted = ui.gallery.thumb_wanted.borrow_mut();
|
|
if wanted.get(&photo.uid) == Some(&self.picture) {
|
|
wanted.remove(&photo.uid);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Add or remove one CSS class, for widgets that are rebound rather than
|
|
/// rebuilt.
|
|
fn set_css_class(widget: &impl IsA<gtk4::Widget>, class: &str, on: bool) {
|
|
if on {
|
|
widget.add_css_class(class);
|
|
} else {
|
|
widget.remove_css_class(class);
|
|
}
|
|
}
|
|
|
|
/// Enter or leave selection mode, repainting the tiles so their checkboxes
|
|
/// appear or go. Leaving drops the selection: a hidden selection that a later
|
|
/// Delete would act on is a trap.
|
|
pub(crate) fn set_selection_mode(ui: &Rc<Ui>, selecting: bool) {
|
|
if ui.gallery.selecting.get() == selecting {
|
|
return;
|
|
}
|
|
ui.gallery.selecting.set(selecting);
|
|
if !selecting {
|
|
ui.gallery.selected.borrow_mut().clear();
|
|
ui.gallery.select_anchor.borrow_mut().take();
|
|
}
|
|
if ui.gallery.select_btn.is_active() != selecting {
|
|
ui.gallery.select_btn.set_active(selecting);
|
|
}
|
|
sync_selection_bar(ui);
|
|
repaint_gallery(ui);
|
|
}
|
|
|
|
/// Pick or unpick one photo.
|
|
fn toggle_selected(ui: &Rc<Ui>, uid: &str) {
|
|
{
|
|
let mut selected = ui.gallery.selected.borrow_mut();
|
|
if !selected.remove(uid) {
|
|
selected.insert(uid.to_string());
|
|
}
|
|
}
|
|
*ui.gallery.select_anchor.borrow_mut() = Some(uid.to_string());
|
|
sync_selection_bar(ui);
|
|
repaint_gallery(ui);
|
|
}
|
|
|
|
/// Make the selection `base` plus every loaded photo from `from` to `to`, in
|
|
/// either direction. `base` rather than the live selection, so a drag that
|
|
/// sweeps back over photos it picked gives them up again.
|
|
fn select_range(ui: &Rc<Ui>, from: &str, to: &str, base: &HashSet<String>) {
|
|
let model = &ui.gallery.model;
|
|
let (Some(a), Some(b)) = (find_photo_index(model, from), find_photo_index(model, to)) else {
|
|
return;
|
|
};
|
|
let mut selected = base.clone();
|
|
for index in a.min(b)..=a.max(b) {
|
|
if let Some(boxed) = model.item(index).and_downcast::<BoxedAnyObject>() {
|
|
selected.insert(boxed.borrow::<PhotoItem>().uid.clone());
|
|
}
|
|
}
|
|
if *ui.gallery.selected.borrow() == selected {
|
|
return;
|
|
}
|
|
*ui.gallery.selected.borrow_mut() = selected;
|
|
sync_selection_bar(ui);
|
|
repaint_gallery(ui);
|
|
}
|
|
|
|
/// A drag-select in progress (see [`wire_drag_select`]).
|
|
#[derive(Default)]
|
|
struct DragSelect {
|
|
/// Whether the drag has moved far enough to be selecting. Until then it
|
|
/// may still turn out to be a click on a tile.
|
|
active: bool,
|
|
/// The selection as it was when the drag began.
|
|
base: HashSet<String>,
|
|
/// The photo the drag started on, or the first one it crossed.
|
|
anchor: Option<String>,
|
|
/// Where the drag started and where the pointer is now, in the list's
|
|
/// coordinates.
|
|
start: (f64, f64),
|
|
pointer: (f64, f64),
|
|
/// The edge autoscroll, while the pointer is near the top or bottom.
|
|
scroll: Option<glib::SourceId>,
|
|
}
|
|
|
|
/// Dragging across the timeline selects every photo from where the drag
|
|
/// started to where the pointer is, scrolling when the pointer nears an edge.
|
|
///
|
|
/// The gesture sits on the list in the capture phase and only claims the
|
|
/// pointer once it has moved past the drag threshold, so a plain click still
|
|
/// reaches the tile and opens the photo.
|
|
fn wire_drag_select(ui: &Rc<Ui>) {
|
|
let drag = gtk4::GestureDrag::builder()
|
|
.button(gtk4::gdk::BUTTON_PRIMARY)
|
|
.propagation_phase(gtk4::PropagationPhase::Capture)
|
|
.build();
|
|
let state = Rc::new(RefCell::new(DragSelect::default()));
|
|
|
|
let state_begin = state.clone();
|
|
drag.connect_drag_begin(move |_, x, y| {
|
|
let mut drag = state_begin.borrow_mut();
|
|
if let Some(source) = drag.scroll.take() {
|
|
source.remove();
|
|
}
|
|
*drag = DragSelect {
|
|
start: (x, y),
|
|
pointer: (x, y),
|
|
..DragSelect::default()
|
|
};
|
|
});
|
|
|
|
let ui_update = ui.clone();
|
|
let state_update = state.clone();
|
|
drag.connect_drag_update(move |gesture, dx, dy| {
|
|
let (sx, sy) = state_update.borrow().start;
|
|
state_update.borrow_mut().pointer = (sx + dx, sy + dy);
|
|
if !state_update.borrow().active {
|
|
let threshold = gtk4::Settings::default()
|
|
.map_or(8, |settings| settings.gtk_dnd_drag_threshold())
|
|
as f64;
|
|
if dx.hypot(dy) < threshold {
|
|
return;
|
|
}
|
|
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
|
let anchor = tile_at(&ui_update, sx, sy);
|
|
set_selection_mode(&ui_update, true);
|
|
let mut drag = state_update.borrow_mut();
|
|
drag.active = true;
|
|
drag.base = ui_update.gallery.selected.borrow().clone();
|
|
drag.anchor = anchor;
|
|
}
|
|
sweep_drag(&ui_update, &state_update);
|
|
autoscroll_drag(&ui_update, &state_update);
|
|
});
|
|
|
|
let ui_end = ui.clone();
|
|
let state_end = state;
|
|
drag.connect_drag_end(move |_, _, _| {
|
|
let mut drag = state_end.borrow_mut();
|
|
if let Some(source) = drag.scroll.take() {
|
|
source.remove();
|
|
}
|
|
if drag.active
|
|
&& let Some(anchor) = drag.anchor.take()
|
|
{
|
|
*ui_end.gallery.select_anchor.borrow_mut() = Some(anchor);
|
|
}
|
|
drag.active = false;
|
|
});
|
|
ui.gallery.list.add_controller(drag);
|
|
}
|
|
|
|
/// Select from the drag's anchor to the photo under the pointer.
|
|
fn sweep_drag(ui: &Rc<Ui>, state: &Rc<RefCell<DragSelect>>) {
|
|
let (x, y) = state.borrow().pointer;
|
|
// Past an edge, the pointer is over the row at that edge: that row is
|
|
// what is scrolling into view under it.
|
|
let height = f64::from(ui.gallery.list.height());
|
|
let Some(current) = tile_at(ui, x, y.clamp(0.0, (height - 1.0).max(0.0))) else {
|
|
return;
|
|
};
|
|
let (anchor, base) = {
|
|
let mut drag = state.borrow_mut();
|
|
let anchor = drag.anchor.get_or_insert_with(|| current.clone()).clone();
|
|
(anchor, std::mem::take(&mut drag.base))
|
|
};
|
|
select_range(ui, &anchor, ¤t, &base);
|
|
state.borrow_mut().base = base;
|
|
}
|
|
|
|
/// Scroll while a drag-select's pointer is near the top or bottom edge, faster
|
|
/// the closer it gets, re-sweeping as rows move under it.
|
|
fn autoscroll_drag(ui: &Rc<Ui>, state: &Rc<RefCell<DragSelect>>) {
|
|
if state.borrow().scroll.is_some() {
|
|
return;
|
|
}
|
|
if drag_scroll_speed(ui, state.borrow().pointer.1) == 0.0 {
|
|
return;
|
|
}
|
|
let ui_tick = ui.clone();
|
|
let state_tick = state.clone();
|
|
let source = glib::timeout_add_local(DRAG_SCROLL_TICK, move || {
|
|
let speed = drag_scroll_speed(&ui_tick, state_tick.borrow().pointer.1);
|
|
let adjustment = ui_tick.gallery.list.vadjustment();
|
|
let (Some(adjustment), true) = (adjustment, speed != 0.0) else {
|
|
state_tick.borrow_mut().scroll.take();
|
|
return glib::ControlFlow::Break;
|
|
};
|
|
let top = adjustment.upper() - adjustment.page_size();
|
|
adjustment.set_value((adjustment.value() + speed).clamp(adjustment.lower(), top));
|
|
sweep_drag(&ui_tick, &state_tick);
|
|
glib::ControlFlow::Continue
|
|
});
|
|
state.borrow_mut().scroll = Some(source);
|
|
}
|
|
|
|
/// Pixels per tick a drag at height `y` scrolls the timeline by: negative near
|
|
/// the top, positive near the bottom, zero in between.
|
|
fn drag_scroll_speed(ui: &Rc<Ui>, y: f64) -> f64 {
|
|
let height = f64::from(ui.gallery.list.height());
|
|
let depth = if y < DRAG_EDGE {
|
|
y - DRAG_EDGE
|
|
} else if y > height - DRAG_EDGE {
|
|
y - (height - DRAG_EDGE)
|
|
} else {
|
|
return 0.0;
|
|
};
|
|
(depth / DRAG_EDGE).clamp(-1.0, 1.0) * DRAG_SCROLL_STEP
|
|
}
|
|
|
|
/// The photo under `(x, y)`, in the list's coordinates: the tile of the row at
|
|
/// that height whose left edge is the last one at or before `x`. `None` over
|
|
/// a heading or a gap between rows.
|
|
fn tile_at(ui: &Rc<Ui>, x: f64, y: f64) -> Option<String> {
|
|
let list = &ui.gallery.list;
|
|
let (position, row) =
|
|
ui.gallery
|
|
.bound
|
|
.borrow()
|
|
.iter()
|
|
.find_map(|(position, (anchor, row))| {
|
|
anchor.as_ref()?;
|
|
let rect = row.compute_bounds(list)?;
|
|
let (top, bottom) = (f64::from(rect.y()), f64::from(rect.y() + rect.height()));
|
|
(row.is_mapped() && y >= top && y < bottom).then(|| (*position, row.clone()))
|
|
})?;
|
|
let mut index = 0;
|
|
let mut child = row.first_child();
|
|
let mut i = 0;
|
|
while let Some(tile) = child {
|
|
if tile
|
|
.compute_bounds(list)
|
|
.is_some_and(|rect| x >= f64::from(rect.x()))
|
|
{
|
|
index = i;
|
|
}
|
|
i += 1;
|
|
child = tile.next_sibling();
|
|
}
|
|
let item = ui.gallery.groups.item(position)?;
|
|
let boxed = item.downcast_ref::<BoxedAnyObject>()?;
|
|
match &*boxed.borrow::<GalleryRow>() {
|
|
GalleryRow::Tiles { tiles, .. } => tiles.get(index).map(|tile| tile.photo.uid.clone()),
|
|
GalleryRow::Memories | GalleryRow::Heading(_) => None,
|
|
}
|
|
}
|
|
|
|
/// Reflect the selection in the bar above the grid.
|
|
fn sync_selection_bar(ui: &Rc<Ui>) {
|
|
let count = ui.gallery.selected.borrow().len();
|
|
ui.gallery.select_label.set_label(&match count {
|
|
0 => gettext("Select photos"),
|
|
// Translators: {n} is how many items are selected: files and folders in My Files, photos here.
|
|
n => ngettext_f("{n} selected", "{n} selected", n as u64, &[]),
|
|
});
|
|
ui.gallery.select_trash.set_sensitive(count > 0);
|
|
ui.gallery.select_album.set_sensitive(count > 0);
|
|
ui.gallery
|
|
.select_bar
|
|
.set_reveal_child(ui.gallery.selecting.get());
|
|
}
|
|
|
|
/// Confirm, then move every selected photo to Proton trash.
|
|
/// How many files the selected tiles stand for. A tile whose photo the model no
|
|
/// longer holds counts as one, which is what it looks like on screen.
|
|
fn selected_file_count(ui: &Rc<Ui>, uids: &[String]) -> usize {
|
|
let wanted: HashSet<&str> = uids.iter().map(String::as_str).collect();
|
|
let mut files = 0;
|
|
for idx in 0..ui.gallery.model.n_items() {
|
|
let Some(boxed) = ui.gallery.model.item(idx).and_downcast::<BoxedAnyObject>() else {
|
|
continue;
|
|
};
|
|
let photo = boxed.borrow::<PhotoItem>();
|
|
if wanted.contains(photo.uid.as_str()) {
|
|
files += photo.group_size.max(1) as usize;
|
|
}
|
|
}
|
|
files.max(uids.len())
|
|
}
|
|
|
|
pub(crate) fn delete_selected(ui: &Rc<Ui>) {
|
|
let uids: Vec<String> = ui.gallery.selected.borrow().iter().cloned().collect();
|
|
confirm_trash_photos(ui, uids);
|
|
}
|
|
|
|
/// Confirm, then move the photos behind `uids` to Proton trash.
|
|
pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
|
|
if uids.is_empty() {
|
|
return;
|
|
}
|
|
// A selected tile can stand for more than one file — a shot kept as RAW and
|
|
// JPEG — and all of them go. The dialog says so rather than letting the user
|
|
// find out from the trash.
|
|
let files = selected_file_count(ui, &uids);
|
|
let body = match (uids.len(), files) {
|
|
(1, 1) => gettext("Move this photo to Trash?"),
|
|
(1, files) => ngettext_f(
|
|
"Move this photo to Trash? It is stored as {n} file.",
|
|
"Move this photo to Trash? It is stored as {n} files.",
|
|
files as u64,
|
|
&[],
|
|
),
|
|
(n, files) if files == n => ngettext_f(
|
|
"Move {n} photo to Trash?",
|
|
"Move {n} photos to Trash?",
|
|
n as u64,
|
|
&[],
|
|
),
|
|
// Translators: {n} is how many photos are selected, {files} how many files they are stored as (always more than {n}).
|
|
(n, files) => ngettext_f(
|
|
"Move {n} photo to Trash? They are stored as {files} files.",
|
|
"Move {n} photos to Trash? They are stored as {files} files.",
|
|
n as u64,
|
|
&[("files", &files.to_string())],
|
|
),
|
|
};
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading(gettext("Move to Trash?"))
|
|
.body(body)
|
|
.build();
|
|
dialog.add_response("cancel", &gettext("Cancel"));
|
|
dialog.add_response("trash", &gettext("Move to Trash"));
|
|
dialog.set_response_appearance("trash", adw::ResponseAppearance::Destructive);
|
|
dialog.set_default_response(Some("cancel"));
|
|
dialog.set_close_response("cancel");
|
|
|
|
let win = ui_window(ui);
|
|
let ui = ui.clone();
|
|
dialog.connect_response(None, move |_, response| {
|
|
if response == "trash" {
|
|
trash_photos(&ui, uids.clone(), false);
|
|
}
|
|
});
|
|
dialog.present(win.as_ref());
|
|
}
|
|
|
|
/// Move photos to Proton trash, taking them out of the grid straight away.
|
|
///
|
|
/// The removal is optimistic because the alternative — a grid that keeps showing
|
|
/// photos the user just deleted until a refresh lands — reads as a failure. What
|
|
/// the server refuses comes back, so the grid still ends up telling the truth,
|
|
/// and what succeeded is one Undo away for as long as the toast is up.
|
|
///
|
|
/// `files_only` trashes exactly `uids` and leaves the rest of their groups, for
|
|
/// the duplicate finder; otherwise each photo takes its whole group along.
|
|
pub(crate) fn trash_photos(ui: &Rc<Ui>, uids: Vec<String>, files_only: bool) {
|
|
let removed = remove_photos(ui, &uids);
|
|
set_selection_mode(ui, false);
|
|
ui.busy_begin();
|
|
let rx = spawn_request(
|
|
ui.dirs.control_socket(),
|
|
Request::TrashNodes {
|
|
uids: uids.clone(),
|
|
files_only,
|
|
},
|
|
);
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
let reply = rx.recv().await;
|
|
ui.busy_end();
|
|
match reply {
|
|
Ok(Ok(Response::Trashed { trashed, failed })) => {
|
|
// Put back whatever stayed on the server, in its timeline
|
|
// position rather than at the end.
|
|
if !failed.is_empty() {
|
|
let kept: Vec<PhotoItem> = removed
|
|
.iter()
|
|
.filter(|photo| failed.iter().any(|f| f.uid == photo.uid))
|
|
.cloned()
|
|
.collect();
|
|
restore_photos(&ui, kept);
|
|
let message = failed
|
|
.first()
|
|
.map(|f| f.message.clone())
|
|
.unwrap_or_else(|| gettext("The server refused."));
|
|
toast_error(
|
|
&ui,
|
|
&gettext("Some photos couldn't be moved to Trash"),
|
|
&message,
|
|
);
|
|
}
|
|
if trashed.is_empty() {
|
|
return;
|
|
}
|
|
let count = trashed.len();
|
|
let message = ngettext_f(
|
|
"Moved {n} photo to Trash",
|
|
"Moved {n} photos to Trash",
|
|
count as u64,
|
|
&[],
|
|
);
|
|
toast_action(&ui, &message, &gettext("Undo"), move |ui| {
|
|
restore_uids(ui, trashed.clone(), count);
|
|
load_gallery(ui, false);
|
|
});
|
|
}
|
|
Ok(Ok(Response::Error { message, kind })) => {
|
|
restore_photos(&ui, removed.clone());
|
|
toast_failure(&ui, &gettext("Couldn't move to Trash"), &message, kind);
|
|
}
|
|
_ => {
|
|
restore_photos(&ui, removed.clone());
|
|
toast_error(
|
|
&ui,
|
|
&gettext("Couldn't move to Trash"),
|
|
&gettext("The Proton Drive service didn't respond."),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Take photos out of the loaded model, returning them so a failure can put them
|
|
/// back.
|
|
pub(crate) fn remove_photos(ui: &Rc<Ui>, uids: &[String]) -> Vec<PhotoItem> {
|
|
let model = &ui.gallery.model;
|
|
let mut removed = Vec::new();
|
|
let mut index = 0;
|
|
while index < model.n_items() {
|
|
let item = model
|
|
.item(index)
|
|
.and_downcast::<BoxedAnyObject>()
|
|
.map(|obj| obj.borrow::<PhotoItem>().clone());
|
|
match item {
|
|
Some(photo) if uids.contains(&photo.uid) => {
|
|
model.remove(index);
|
|
removed.push(photo);
|
|
}
|
|
_ => index += 1,
|
|
}
|
|
}
|
|
repaint_gallery(ui);
|
|
removed
|
|
}
|
|
|
|
/// Put photos back into the loaded model, at their place in the timeline.
|
|
pub(crate) fn restore_photos(ui: &Rc<Ui>, photos: Vec<PhotoItem>) {
|
|
let model = &ui.gallery.model;
|
|
for photo in photos {
|
|
// The timeline is newest first, so a photo belongs before the first
|
|
// entry older than it.
|
|
let at = (0..model.n_items())
|
|
.find(|index| {
|
|
model
|
|
.item(*index)
|
|
.and_downcast::<BoxedAnyObject>()
|
|
.is_some_and(|obj| obj.borrow::<PhotoItem>().capture_time < photo.capture_time)
|
|
})
|
|
.unwrap_or(model.n_items());
|
|
model.insert(at, &BoxedAnyObject::new(photo));
|
|
}
|
|
repaint_gallery(ui);
|
|
}
|
|
|
|
/// Give `picture` its thumbnail: straight from the texture cache when it's there,
|
|
/// otherwise register the tile as waiting and get the thumbnail moving — decoding
|
|
/// it if the daemon already had it cached on disk, or asking the daemon for it.
|
|
///
|
|
/// This is what makes the gallery on-demand: only tiles the ListView actually
|
|
/// realises ever ask for an image.
|
|
pub(crate) fn want_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: >k4::Picture) {
|
|
let cached = ui.gallery.photo_tex.borrow().get(&photo.uid).cloned();
|
|
if let Some(texture) = cached {
|
|
picture.set_paintable(Some(&texture));
|
|
ui.touch_texture(&photo.uid);
|
|
return;
|
|
}
|
|
// No thumbnail will ever come for this one — not from the server, and not
|
|
// from the daemon's own scaling of the file. The tile keeps its placeholder
|
|
// glyph, and stays clickable: the full photo may still open fine.
|
|
if ui.gallery.photo_nothumb.borrow().contains(&photo.uid) {
|
|
return;
|
|
}
|
|
|
|
ui.gallery
|
|
.thumb_wanted
|
|
.borrow_mut()
|
|
.insert(photo.uid.clone(), picture.clone());
|
|
|
|
match photo.thumb_path.as_deref() {
|
|
Some(path) => {
|
|
ui.gallery
|
|
.decode_queue
|
|
.borrow_mut()
|
|
.push_back((photo.uid.clone(), path.to_string()));
|
|
schedule_decode(ui);
|
|
}
|
|
None => {
|
|
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
|
if !queue.contains(&photo.uid) {
|
|
queue.push_back(photo.uid.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Come back for thumbnails the daemon is still generating — it is downloading
|
|
/// each photo's full file to scale it, which takes far longer than a batch. The
|
|
/// tiles keep their placeholder until then, and a tile that has scrolled away is
|
|
/// dropped by [`flush_thumbs`] like any other queued uid.
|
|
pub(crate) fn retry_pending_thumbs(ui: &Rc<Ui>, uids: Vec<String>) {
|
|
let ui = ui.clone();
|
|
glib::timeout_add_local_once(THUMB_RETRY, move || {
|
|
{
|
|
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
|
for uid in uids {
|
|
if !queue.contains(&uid) {
|
|
queue.push_back(uid);
|
|
}
|
|
}
|
|
}
|
|
schedule_thumbs(&ui);
|
|
});
|
|
}
|
|
|
|
/// Ask the daemon for the queued thumbnails after a short pause, so a fast scroll
|
|
/// coalesces into one batch per settle rather than one per row it flew past.
|
|
pub(crate) fn schedule_thumbs(ui: &Rc<Ui>) {
|
|
if ui.gallery.thumb_queue.borrow().is_empty() || ui.gallery.thumb_inflight.get() {
|
|
return;
|
|
}
|
|
if let Some(id) = ui.gallery.thumb_source.borrow_mut().take() {
|
|
id.remove();
|
|
}
|
|
let ui_flush = ui.clone();
|
|
let source = glib::timeout_add_local_once(THUMB_DEBOUNCE, move || {
|
|
ui_flush.gallery.thumb_source.borrow_mut().take();
|
|
flush_thumbs(&ui_flush);
|
|
});
|
|
*ui.gallery.thumb_source.borrow_mut() = Some(source);
|
|
}
|
|
|
|
/// Send one [`Request::PhotoThumbs`] batch for the tiles on screen, topped up
|
|
/// with the tiles just beyond them.
|
|
///
|
|
/// A queued uid whose tile has scrolled away is dropped while the queue is long,
|
|
/// because during a fast scroll what the user is looking at *now* is the only
|
|
/// thing worth the round trip. Once the queue is short the scroll has settled,
|
|
/// and those near misses are the tiles one flick away — fetching them costs one
|
|
/// batch and saves a blank tile, and the reply lands in the texture cache
|
|
/// whether or not a widget still wants it.
|
|
pub(crate) fn flush_thumbs(ui: &Rc<Ui>) {
|
|
if ui.gallery.thumb_inflight.get() {
|
|
return;
|
|
}
|
|
let uids: Vec<String> = {
|
|
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
|
let wanted = ui.gallery.thumb_wanted.borrow();
|
|
let settled = queue.len() <= THUMB_BATCH * 2;
|
|
let mut batch = Vec::new();
|
|
let mut skipped = VecDeque::new();
|
|
while batch.len() < THUMB_BATCH {
|
|
let Some(uid) = queue.pop_front() else { break };
|
|
if wanted.contains_key(&uid) || settled {
|
|
batch.push(uid);
|
|
} else {
|
|
skipped.push_back(uid);
|
|
}
|
|
}
|
|
// Anything skipped over stays queued behind what was taken: the scroll
|
|
// may still come back to it.
|
|
for uid in skipped.into_iter().rev() {
|
|
queue.push_front(uid);
|
|
}
|
|
batch
|
|
};
|
|
let uids = prefetch_thumbs(ui, uids);
|
|
if uids.is_empty() {
|
|
return;
|
|
}
|
|
|
|
ui.gallery.thumb_inflight.set(true);
|
|
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoThumbs { uids });
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = rx.recv().await;
|
|
ui.gallery.thumb_inflight.set(false);
|
|
match result {
|
|
Ok(Ok(Response::Thumbs { items })) => {
|
|
let mut decode = ui.gallery.decode_queue.borrow_mut();
|
|
let mut nothumb = ui.gallery.photo_nothumb.borrow_mut();
|
|
let mut pending = Vec::new();
|
|
for item in items {
|
|
match item.path {
|
|
Some(path) => decode.push_back((item.uid, path)),
|
|
// The daemon is making this one itself, from the photo's
|
|
// full file — that takes a download, so come back for it.
|
|
None if item.pending => pending.push(item.uid),
|
|
// No thumbnail exists and none can be made: remember that,
|
|
// so scrolling past the tile doesn't re-ask forever.
|
|
None => {
|
|
nothumb.insert(item.uid);
|
|
}
|
|
}
|
|
}
|
|
drop((decode, nothumb));
|
|
schedule_decode(&ui);
|
|
if !pending.is_empty() {
|
|
retry_pending_thumbs(&ui, pending);
|
|
}
|
|
}
|
|
// A thumbnail that doesn't arrive is not worth a toast — the tile just
|
|
// stays a placeholder, and the next scroll past it tries again.
|
|
Ok(Ok(Response::Error { message, .. })) => {
|
|
tracing::debug!("photo thumbs failed: {message}")
|
|
}
|
|
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => tracing::debug!("photo thumbs: no reply"),
|
|
}
|
|
// Whatever the batch did, more tiles may have queued up behind it.
|
|
schedule_thumbs(&ui);
|
|
});
|
|
}
|
|
|
|
/// Top a batch up with the photos just past the rows on screen, in the direction
|
|
/// the timeline is being scrolled.
|
|
///
|
|
/// A tile that starts its fetch when it is bound is a tile that shows a
|
|
/// placeholder for as long as the round trip takes. Warming the next screen is
|
|
/// what makes a steady scroll look like it already has the photos.
|
|
fn prefetch_thumbs(ui: &Rc<Ui>, mut batch: Vec<String>) -> Vec<String> {
|
|
if batch.is_empty() || batch.len() >= THUMB_BATCH {
|
|
return batch;
|
|
}
|
|
let bound = ui.gallery.bound.borrow();
|
|
let (Some(first), Some(last)) = (bound.keys().next(), bound.keys().next_back()) else {
|
|
return batch;
|
|
};
|
|
let rows = &ui.gallery.groups;
|
|
let ahead: Box<dyn Iterator<Item = u32>> = if ui.gallery.scrolling_down.get() {
|
|
Box::new(last.saturating_add(1)..rows.n_items())
|
|
} else {
|
|
Box::new((0..*first).rev())
|
|
};
|
|
drop(bound);
|
|
|
|
let cached = ui.gallery.photo_tex.borrow();
|
|
let nothumb = ui.gallery.photo_nothumb.borrow();
|
|
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
|
let mut warmed = 0;
|
|
for index in ahead {
|
|
if warmed >= THUMB_PREFETCH || batch.len() >= THUMB_BATCH {
|
|
break;
|
|
}
|
|
let Some(obj) = rows.item(index).and_downcast::<BoxedAnyObject>() else {
|
|
continue;
|
|
};
|
|
let GalleryRow::Tiles { tiles, .. } = &*obj.borrow::<GalleryRow>() else {
|
|
continue;
|
|
};
|
|
for tile in tiles {
|
|
let uid = &tile.photo.uid;
|
|
// A photo the daemon already handed us a path for is decoded, not
|
|
// fetched, and one that can never have a thumbnail is neither.
|
|
if tile.photo.thumb_path.is_some() || cached.contains_key(uid) || nothumb.contains(uid)
|
|
{
|
|
continue;
|
|
}
|
|
if batch.contains(uid) || queue.contains(uid) {
|
|
continue;
|
|
}
|
|
warmed += 1;
|
|
if batch.len() < THUMB_BATCH {
|
|
batch.push(uid.clone());
|
|
} else {
|
|
queue.push_back(uid.clone());
|
|
}
|
|
}
|
|
}
|
|
batch
|
|
}
|
|
|
|
/// Decode queued thumbnails into textures on an idle callback, a few per pass, so
|
|
/// a big batch fills in progressively instead of freezing the scroll for the
|
|
/// length of the whole decode.
|
|
pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
|
|
if ui.gallery.decode_idle.get() || ui.gallery.decode_queue.borrow().is_empty() {
|
|
return;
|
|
}
|
|
ui.gallery.decode_idle.set(true);
|
|
|
|
let ui = ui.clone();
|
|
glib::idle_add_local(move || {
|
|
let batch: Vec<(String, String)> = {
|
|
let mut queue = ui.gallery.decode_queue.borrow_mut();
|
|
(0..4).filter_map(|_| queue.pop_front()).collect()
|
|
};
|
|
for (uid, path) in batch {
|
|
let texture = match gtk4::gdk::Texture::from_filename(&path) {
|
|
Ok(texture) => texture,
|
|
Err(e) => {
|
|
tracing::debug!("cannot decode thumbnail {path}: {e}");
|
|
ui.gallery.photo_nothumb.borrow_mut().insert(uid);
|
|
continue;
|
|
}
|
|
};
|
|
ui.store_texture(&uid, texture.clone());
|
|
if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) {
|
|
picture.set_paintable(Some(&texture));
|
|
}
|
|
}
|
|
|
|
if ui.gallery.decode_queue.borrow().is_empty() {
|
|
ui.gallery.decode_idle.set(false);
|
|
return glib::ControlFlow::Break;
|
|
}
|
|
glib::ControlFlow::Continue
|
|
});
|
|
}
|
|
|
|
/// Re-flow the sections on screen shortly. Debounced, because the triggers (a
|
|
/// window resize, a zoom step) arrive in floods and only the final state
|
|
/// matters.
|
|
pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
|
if let Some(id) = ui.gallery.relayout_source.borrow_mut().take() {
|
|
id.remove();
|
|
}
|
|
let ui_relayout = ui.clone();
|
|
let source = glib::timeout_add_local_once(RELAYOUT_DEBOUNCE, move || {
|
|
ui_relayout.gallery.relayout_source.borrow_mut().take();
|
|
relayout_gallery(&ui_relayout);
|
|
});
|
|
*ui.gallery.relayout_source.borrow_mut() = Some(source);
|
|
}
|
|
|
|
/// Re-flow the timeline at the current width and zoom.
|
|
///
|
|
/// A different column count means different rows, so unlike the old
|
|
/// section-per-day model there is nothing to patch in place — the row model is
|
|
/// rebuilt. What that would cost the user is their scroll position, so the
|
|
/// topmost visible row's first photo is remembered, and the row that holds it
|
|
/// afterwards is put back at the same height in the viewport.
|
|
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
|
// At the very top there is nothing to hold on to: the first photo row sits
|
|
// under a heading, and anchoring on it would scroll that heading away.
|
|
let at_top = ui
|
|
.gallery
|
|
.list
|
|
.vadjustment()
|
|
.is_none_or(|adj| adj.value() < 1.0);
|
|
let anchor = if at_top { None } else { top_anchor(ui) };
|
|
repaint_gallery(ui);
|
|
let Some((anchor, offset)) = anchor else {
|
|
return;
|
|
};
|
|
let Some(row) = row_of_photo(&ui.gallery.groups, &anchor) else {
|
|
return;
|
|
};
|
|
// Scrolling to a row only brings it into view, anywhere in it; the rows
|
|
// are laid out over the next frames, so the fine alignment follows them.
|
|
ui.gallery
|
|
.list
|
|
.scroll_to(row, gtk4::ListScrollFlags::empty(), None);
|
|
align_row(ui, row, offset);
|
|
}
|
|
|
|
/// How many frames [`align_row`] keeps correcting for: rows above the anchor
|
|
/// that the list only estimated get measured as they are realised.
|
|
const ALIGN_FRAMES: u32 = 4;
|
|
|
|
/// Scroll so the top of row `position` sits `offset` px below the top of the
|
|
/// viewport, re-checking for a few frames while the rows around it settle.
|
|
fn align_row(ui: &Rc<Ui>, position: u32, offset: f32) {
|
|
let ui = ui.clone();
|
|
let frames = Cell::new(0);
|
|
ui.gallery.list.clone().add_tick_callback(move |list, _| {
|
|
frames.set(frames.get() + 1);
|
|
let top = ui
|
|
.gallery
|
|
.bound
|
|
.borrow()
|
|
.get(&position)
|
|
.filter(|(_, row)| row.is_mapped())
|
|
.and_then(|(_, row)| row.compute_bounds(list))
|
|
.map(|rect| rect.y());
|
|
if let (Some(top), Some(adjustment)) = (top, list.vadjustment()) {
|
|
let delta = f64::from(top - offset);
|
|
if delta.abs() >= 1.0 {
|
|
adjustment.set_value(adjustment.value() + delta);
|
|
}
|
|
}
|
|
if frames.get() >= ALIGN_FRAMES {
|
|
glib::ControlFlow::Break
|
|
} else {
|
|
glib::ControlFlow::Continue
|
|
}
|
|
});
|
|
}
|
|
|
|
/// The first photo of the topmost photo row that reaches into the viewport,
|
|
/// and how far below the top of the viewport that row starts (negative when it
|
|
/// is partly scrolled off).
|
|
///
|
|
/// Not simply the first bound row: the ListView keeps rows bound well outside
|
|
/// the viewport (unmapped, with their last allocation), and scrolling one of
|
|
/// those "back" to the top is a jump. Falls back to the first bound photo row
|
|
/// while the list isn't on screen.
|
|
fn top_anchor(ui: &Rc<Ui>) -> Option<(String, f32)> {
|
|
let list = &ui.gallery.list;
|
|
let bound = ui.gallery.bound.borrow();
|
|
let mut photo_rows = bound
|
|
.values()
|
|
.filter_map(|(uid, row)| uid.as_ref().map(|uid| (uid, row)));
|
|
let first = photo_rows.clone().next().map(|(uid, _)| (uid.clone(), 0.0));
|
|
photo_rows
|
|
.find_map(|(uid, row)| {
|
|
let rect = row.compute_bounds(list)?;
|
|
(row.is_mapped() && rect.y() + rect.height() > 0.0).then(|| (uid.clone(), rect.y()))
|
|
})
|
|
.or(first)
|
|
}
|
|
|
|
/// Which row of the rendered model holds `uid`, if any.
|
|
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
|
|
(0..store.n_items()).find(|i| {
|
|
store
|
|
.item(*i)
|
|
.and_downcast::<BoxedAnyObject>()
|
|
.is_some_and(|obj| match &*obj.borrow::<GalleryRow>() {
|
|
GalleryRow::Memories | GalleryRow::Heading(_) => false,
|
|
GalleryRow::Tiles { tiles, .. } => tiles.iter().any(|t| t.photo.uid == uid),
|
|
})
|
|
})
|
|
}
|
|
|
|
/// Step the target row height by `delta` px and re-flow, clamped to the zoom
|
|
/// range.
|
|
pub(crate) fn zoom_gallery(ui: &Rc<Ui>, delta: i32) {
|
|
set_gallery_tile(ui, ui.gallery.row_height.get() + delta);
|
|
}
|
|
|
|
/// Set the target row height (clamped) and re-flow the visible sections at it.
|
|
pub(crate) fn set_gallery_tile(ui: &Rc<Ui>, tile: i32) {
|
|
let tile = tile.clamp(ROW_MIN, ROW_MAX);
|
|
if tile == ui.gallery.row_height.get() {
|
|
return;
|
|
}
|
|
ui.gallery.row_height.set(tile);
|
|
schedule_relayout(ui);
|
|
}
|
|
|
|
/// Rebuild the ListView's row model from the flat photo model: each day becomes
|
|
/// a heading row followed by its grid rows, laid out to the current width and
|
|
/// zoom.
|
|
///
|
|
/// The rows are diffed into the existing store rather than replacing it: a "load
|
|
/// more" only really changes the last day (the one the new page continues) and
|
|
/// appends after it, and clearing the store instead would scroll the user back
|
|
/// to the top of the timeline at the exact moment they asked for more.
|
|
pub(crate) fn repaint_gallery(ui: &Rc<Ui>) {
|
|
let rows = build_rows(ui);
|
|
let store = &ui.gallery.groups;
|
|
|
|
for (i, row) in rows.iter().enumerate() {
|
|
let i = i as u32;
|
|
let unchanged = store
|
|
.item(i)
|
|
.and_downcast::<BoxedAnyObject>()
|
|
.is_some_and(|old| row.same_as(&old.borrow::<GalleryRow>()));
|
|
if unchanged {
|
|
continue;
|
|
}
|
|
let boxed = BoxedAnyObject::new(clone_row(row));
|
|
if i < store.n_items() {
|
|
store.splice(i, 1, &[boxed]);
|
|
} else {
|
|
store.append(&boxed);
|
|
}
|
|
}
|
|
if store.n_items() > rows.len() as u32 {
|
|
let len = rows.len() as u32;
|
|
store.splice(len, store.n_items() - len, &[] as &[BoxedAnyObject]);
|
|
}
|
|
|
|
update_gallery_subtitle(ui);
|
|
}
|
|
|
|
/// Flatten the loaded photos into the rows the ListView renders.
|
|
fn build_rows(ui: &Rc<Ui>) -> Vec<GalleryRow> {
|
|
let width = gallery_width(ui);
|
|
let mut rows = Vec::new();
|
|
let grouping = grouping_for(ui.gallery.row_height.get());
|
|
if shows_memories(ui) {
|
|
rows.push(GalleryRow::Memories);
|
|
}
|
|
for group in group_photos(&ui.gallery.model, grouping) {
|
|
rows.push(GalleryRow::Heading(group.heading));
|
|
let grid = justify_rows(ui, &group.photos, width);
|
|
let last_index = grid.len().saturating_sub(1);
|
|
for (index, tiles) in grid.into_iter().enumerate() {
|
|
rows.push(GalleryRow::Tiles {
|
|
tiles,
|
|
last: index == last_index,
|
|
});
|
|
}
|
|
}
|
|
rows
|
|
}
|
|
|
|
fn clone_row(row: &GalleryRow) -> GalleryRow {
|
|
match row {
|
|
GalleryRow::Memories => GalleryRow::Memories,
|
|
GalleryRow::Heading(heading) => GalleryRow::Heading(heading.clone()),
|
|
GalleryRow::Tiles { tiles, last } => GalleryRow::Tiles {
|
|
tiles: tiles.clone(),
|
|
last: *last,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// "1,204 photos" under the page title.
|
|
pub(crate) fn update_gallery_subtitle(ui: &Rc<Ui>) {
|
|
let loaded = ui.gallery.model.n_items() as usize;
|
|
if loaded == 0 {
|
|
ui.gallery.title.set_subtitle("");
|
|
}
|
|
// An album or a place counts what it holds, not how much of it has been
|
|
// paged in — the subtitle would otherwise climb as the user scrolls.
|
|
if ui.gallery.in_collection() {
|
|
return;
|
|
}
|
|
// The noun tracks the active filter, so a Videos tab doesn't count "photos".
|
|
let kind = ui.gallery.kind.get();
|
|
// The whole library for this filter, not the page count — the subtitle sits
|
|
// next to tabs carrying the same totals, and the two disagreeing reads as a
|
|
// bug. A date jump or a filter is the exception: the counts are the
|
|
// library's, not the filter's, so there the loaded count is the honest one.
|
|
let narrowed = ui.gallery.range.get().is_some()
|
|
|| ui.gallery.favorites.get()
|
|
|| ui.gallery.not_in_album.get();
|
|
let total = match (narrowed, ui.gallery.counts.get()) {
|
|
(false, Some((photos, videos, raw))) => match kind {
|
|
Some(PhotoKind::Photo) => photos,
|
|
Some(PhotoKind::Video) => videos,
|
|
Some(PhotoKind::Raw) => raw,
|
|
None => photos + videos + raw,
|
|
},
|
|
_ => loaded,
|
|
};
|
|
if loaded == 0 {
|
|
return;
|
|
}
|
|
let count = thousands(total);
|
|
let args = [("count", count.as_str())];
|
|
let n = total as u64;
|
|
ui.gallery.title.set_subtitle(&match kind {
|
|
// Translators: the Photos page subtitle, such as "12 videos".
|
|
Some(PhotoKind::Video) => ngettext_f("{count} video", "{count} videos", n, &args),
|
|
// Translators: the Photos page subtitle, such as "12 raw photos".
|
|
Some(PhotoKind::Raw) => ngettext_f("{count} raw photo", "{count} raw photos", n, &args),
|
|
// Translators: the Photos page subtitle, such as "1,204 photos".
|
|
_ => ngettext_f("{count} photo", "{count} photos", n, &args),
|
|
});
|
|
}
|
|
|
|
pub(crate) fn group_photos(model: &gio::ListStore, grouping: Grouping) -> Vec<PhotoGroup> {
|
|
let mut groups: Vec<PhotoGroup> = Vec::new();
|
|
for i in 0..model.n_items() {
|
|
let Some(obj) = model.item(i) else { continue };
|
|
let Some(boxed) = obj.downcast_ref::<BoxedAnyObject>() else {
|
|
continue;
|
|
};
|
|
let photo = boxed.borrow::<PhotoItem>().clone();
|
|
let heading = section_heading(photo.capture_time, grouping);
|
|
match groups.last_mut() {
|
|
Some(group) if group.heading == heading => group.photos.push(photo),
|
|
_ => groups.push(PhotoGroup {
|
|
heading,
|
|
photos: vec![photo],
|
|
}),
|
|
}
|
|
}
|
|
groups
|
|
}
|
|
|
|
/// Section heading for a capture time: "Today", "Yesterday", or the local date
|
|
/// when grouping by day; "June 2026" by month; "2026" by year.
|
|
pub(crate) fn section_heading(secs: i64, grouping: Grouping) -> String {
|
|
let Ok(date) = glib::DateTime::from_unix_local(secs) else {
|
|
return gettext("Unknown date");
|
|
};
|
|
match grouping {
|
|
Grouping::Day => {}
|
|
Grouping::Month => return month_label(date.year(), date.month()),
|
|
Grouping::Year => return date.year().to_string(),
|
|
}
|
|
let same_day = |other: &glib::DateTime| {
|
|
other.year() == date.year()
|
|
&& other.month() == date.month()
|
|
&& other.day_of_month() == date.day_of_month()
|
|
};
|
|
if let Ok(now) = glib::DateTime::now_local() {
|
|
if same_day(&now) {
|
|
return gettext("Today");
|
|
}
|
|
if let Ok(yesterday) = glib::DateTime::from_unix_local(now.to_unix() - 86_400)
|
|
&& same_day(&yesterday)
|
|
{
|
|
return gettext("Yesterday");
|
|
}
|
|
}
|
|
// Translators: strftime format for a day heading in the photo timeline, such as "3 June 2026".
|
|
let format = gettext("%-d %B %Y");
|
|
date.format(&format)
|
|
.map(|s| s.to_string())
|
|
.unwrap_or_else(|_| gettext("Unknown date"))
|
|
}
|
|
|
|
pub(crate) fn find_photo_index(model: &gio::ListStore, uid: &str) -> Option<u32> {
|
|
for i in 0..model.n_items() {
|
|
if let Some(obj) = model.item(i)
|
|
&& let Some(boxed) = obj.downcast_ref::<BoxedAnyObject>()
|
|
&& boxed.borrow::<PhotoItem>().uid == uid
|
|
{
|
|
return Some(i);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub(crate) fn format_capture_time(secs: i64) -> String {
|
|
let date = glib::DateTime::from_unix_local(secs);
|
|
// Translators: strftime format for a photo's capture date and time, such as "2026-06-03 14:05:09".
|
|
let format = gettext("%Y-%m-%d %H:%M:%S");
|
|
match date {
|
|
Ok(d) => match d.format(&format) {
|
|
Ok(s) => s.to_string(),
|
|
Err(_) => gettext("Unknown Date"),
|
|
},
|
|
Err(_) => gettext("Unknown Date"),
|
|
}
|
|
}
|
|
|
|
/// The capture time as a tile caption: the clock time alone, since the day is
|
|
/// already the section heading right above it.
|
|
pub(crate) fn short_capture_time(secs: i64) -> String {
|
|
// Translators: strftime format for the time a photo was taken, shown on its tile, such as "14:05".
|
|
let format = gettext("%H:%M");
|
|
glib::DateTime::from_unix_local(secs)
|
|
.and_then(|d| d.format(&format))
|
|
.map(|s| s.to_string())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// The capture date as a tile caption, for a timeline headed by month or year.
|
|
pub(crate) fn short_capture_date(secs: i64) -> String {
|
|
// Translators: strftime format for the day a photo was taken, shown on its tile when the timeline is grouped by month or year, such as "3 Jun".
|
|
let format = gettext("%-d %b");
|
|
glib::DateTime::from_unix_local(secs)
|
|
.and_then(|d| d.format(&format))
|
|
.map(|s| s.to_string())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Fetch a timeline page from the daemon. When `append` is false the model is
|
|
/// cleared first (fresh load); otherwise the next page is tacked on.
|
|
/// The empty state for a timeline filtered to `kind`, favorites, the photos
|
|
/// in no album and/or a `month`: what is missing, in the filter's own words.
|
|
pub(crate) fn empty_timeline_text(
|
|
kind: Option<PhotoKind>,
|
|
favorites: bool,
|
|
not_in_album: bool,
|
|
month: Option<&str>,
|
|
) -> (String, String) {
|
|
let args = [("month", month.unwrap_or_default())];
|
|
let video = kind == Some(PhotoKind::Video);
|
|
let raw = kind == Some(PhotoKind::Raw);
|
|
if not_in_album && !favorites && kind.is_none() && month.is_none() {
|
|
return (
|
|
gettext("Every photo is in an album"),
|
|
gettext("Turn off the album filter to see everything."),
|
|
);
|
|
}
|
|
match (favorites, kind, month) {
|
|
(false, None, None) => (
|
|
gettext("No photos yet"),
|
|
gettext("Photos you upload to Proton Drive appear here."),
|
|
),
|
|
(true, None, None) => (
|
|
gettext("No favorites yet"),
|
|
gettext("Star a photo in the viewer or from its menu to find it here."),
|
|
),
|
|
(true, _, _) => (
|
|
match (video, raw, month.is_some()) {
|
|
// Translators: {month} is a month and year, such as "June 2024".
|
|
(true, _, true) => gettext_f("No favorite videos in {month}", &args),
|
|
(true, _, false) => gettext("No favorite videos"),
|
|
// Translators: {month} is a month and year, such as "June 2024".
|
|
(_, true, true) => gettext_f("No favorite raw files in {month}", &args),
|
|
(_, true, false) => gettext("No favorite raw files"),
|
|
// Translators: {month} is a month and year, such as "June 2024".
|
|
(_, _, true) => gettext_f("No favorite photos in {month}", &args),
|
|
(_, _, false) => gettext("No favorite photos"),
|
|
},
|
|
gettext("Turn off the favorites filter to see everything."),
|
|
),
|
|
(false, _, _) => (
|
|
match (video, raw, month.is_some()) {
|
|
// Translators: {month} is a month and year, such as "June 2024".
|
|
(true, _, true) => gettext_f("No videos in {month}", &args),
|
|
(true, _, false) => gettext("No videos"),
|
|
// Translators: {month} is a month and year, such as "June 2024".
|
|
(_, true, true) => gettext_f("No raw files in {month}", &args),
|
|
(_, true, false) => gettext("No raw files"),
|
|
// Translators: {month} is a month and year, such as "June 2024".
|
|
(_, _, true) => gettext_f("No photos in {month}", &args),
|
|
(_, _, false) => gettext("No photos"),
|
|
},
|
|
gettext("Try another filter or month."),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Point the scrubber at `months` (newest first): one step per month, with a
|
|
/// mark and a year label where each year starts. Shown only for the whole,
|
|
/// unfiltered timeline with more than one month to move between.
|
|
fn fill_scrubber(ui: &Rc<Ui>, months: &[PhotoMonth]) {
|
|
*ui.gallery.months.borrow_mut() = months.to_vec();
|
|
let years = months
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|&(index, month)| index == 0 || months[index - 1].year != month.year)
|
|
.map(|(index, month)| (index, month.year.to_string()))
|
|
.collect();
|
|
ui.gallery.scrubber.set_months(months.len(), years);
|
|
sync_scrubber(ui);
|
|
}
|
|
|
|
/// Show the scrubber only where a month jump makes sense: the whole timeline,
|
|
/// not an album, a date window, the favorites or the unfiled photos.
|
|
pub(crate) fn sync_scrubber(ui: &Rc<Ui>) {
|
|
let whole = !ui.gallery.in_collection()
|
|
&& ui.gallery.range.get().is_none()
|
|
&& !ui.gallery.favorites.get()
|
|
&& !ui.gallery.not_in_album.get();
|
|
ui.gallery
|
|
.scrubber
|
|
.set_available(whole && ui.gallery.months.borrow().len() > 1);
|
|
}
|
|
|
|
/// How many photos the "On this day" strip asks for. Enough for a card per year
|
|
/// of a big library; the cards only need one cover each.
|
|
const MEMORIES_LIMIT: usize = 200;
|
|
|
|
/// Size of an "On this day" card's cover, in px.
|
|
const MEMORY_CARD_WIDTH: i32 = 180;
|
|
const MEMORY_CARD_HEIGHT: i32 = 120;
|
|
|
|
/// Ask the daemon for photos taken on today's date in earlier years, and fill
|
|
/// the "On this day" strip with a card per year. A failure leaves the strip
|
|
/// hidden: it is a nicety, not worth a toast.
|
|
fn refresh_memories(ui: &Rc<Ui>) {
|
|
let rx = spawn_request(
|
|
ui.dirs.control_socket(),
|
|
Request::PhotosOnThisDay {
|
|
limit: MEMORIES_LIMIT,
|
|
},
|
|
);
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
let items = match rx.recv().await {
|
|
Ok(Ok(Response::Photos { items, .. })) => items,
|
|
_ => Vec::new(),
|
|
};
|
|
let this_year = glib::DateTime::now_local().map_or(0, |now| now.year());
|
|
let cards = &ui.gallery.memory_cards;
|
|
while let Some(child) = cards.first_child() {
|
|
cards.remove(&child);
|
|
}
|
|
let years = memory_years(&items, this_year);
|
|
ui.gallery.has_memories.set(!years.is_empty());
|
|
for (years_ago, photos) in years {
|
|
cards.append(&memory_card(&ui, years_ago, photos));
|
|
}
|
|
schedule_thumbs(&ui);
|
|
sync_memories(&ui);
|
|
});
|
|
}
|
|
|
|
/// Group "On this day" photos (newest first) by how many years ago they were
|
|
/// taken, nearest year first. Each group keeps the newest-first order, so its
|
|
/// first photo is the card's cover.
|
|
pub(crate) fn memory_years(items: &[PhotoItem], this_year: i32) -> Vec<(u32, Vec<PhotoItem>)> {
|
|
let mut years: Vec<(u32, Vec<PhotoItem>)> = Vec::new();
|
|
for item in items {
|
|
let Ok(date) = glib::DateTime::from_unix_local(item.capture_time) else {
|
|
continue;
|
|
};
|
|
let Ok(years_ago) = u32::try_from(this_year - date.year()) else {
|
|
continue;
|
|
};
|
|
if years_ago == 0 {
|
|
continue;
|
|
}
|
|
match years.iter_mut().find(|(y, _)| *y == years_ago) {
|
|
Some((_, photos)) => photos.push(item.clone()),
|
|
None => years.push((years_ago, vec![item.clone()])),
|
|
}
|
|
}
|
|
years.sort_by_key(|(years_ago, _)| *years_ago);
|
|
years
|
|
}
|
|
|
|
/// One "On this day" card: the year's newest photo as the cover, labelled with
|
|
/// how long ago it was. Clicking it scrolls the timeline to that day.
|
|
fn memory_card(ui: &Rc<Ui>, years_ago: u32, photos: Vec<PhotoItem>) -> gtk4::Button {
|
|
let cover = photos[0].clone();
|
|
let picture = gtk4::Picture::builder()
|
|
.content_fit(gtk4::ContentFit::Cover)
|
|
.can_shrink(true)
|
|
.build();
|
|
let placeholder = gtk4::Image::builder()
|
|
.icon_name("image-x-generic-symbolic")
|
|
.pixel_size(24)
|
|
.build();
|
|
placeholder.add_css_class("photo-placeholder");
|
|
let label = gtk4::Label::builder()
|
|
.label(ngettext_f(
|
|
"{n} year ago",
|
|
"{n} years ago",
|
|
u64::from(years_ago),
|
|
&[],
|
|
))
|
|
.halign(gtk4::Align::Fill)
|
|
.valign(gtk4::Align::End)
|
|
.xalign(0.0)
|
|
.build();
|
|
label.add_css_class("memory-label");
|
|
|
|
// Sized here rather than on the picture: an overlay takes its size from
|
|
// its main child, which is the placeholder glyph.
|
|
let overlay = gtk4::Overlay::new();
|
|
overlay.set_size_request(MEMORY_CARD_WIDTH, MEMORY_CARD_HEIGHT);
|
|
overlay.set_child(Some(&placeholder));
|
|
overlay.add_overlay(&picture);
|
|
overlay.add_overlay(&label);
|
|
overlay.set_overflow(gtk4::Overflow::Hidden);
|
|
overlay.add_css_class("memory-cover");
|
|
|
|
let day = section_heading(cover.capture_time, Grouping::Day);
|
|
let card = gtk4::Button::builder()
|
|
.child(&overlay)
|
|
.tooltip_text(ngettext_f(
|
|
"{date}, {n} photo",
|
|
"{date}, {n} photos",
|
|
photos.len() as u64,
|
|
&[("date", &day)],
|
|
))
|
|
.build();
|
|
card.add_css_class("flat");
|
|
card.add_css_class("memory-card");
|
|
|
|
want_thumb(ui, &cover, &picture);
|
|
let ui_click = ui.clone();
|
|
card.connect_clicked(move |_| {
|
|
// The timeline is newest first, so the day's newest photo is the first
|
|
// one older than a second past it.
|
|
ui_click.gallery.jump.set(Some(cover.capture_time + 1));
|
|
continue_jump(&ui_click);
|
|
});
|
|
card
|
|
}
|
|
|
|
/// Whether the "On this day" strip belongs in the timeline: only atop the
|
|
/// whole, unfiltered timeline, and only when it has cards.
|
|
fn shows_memories(ui: &Rc<Ui>) -> bool {
|
|
let whole = !ui.gallery.in_collection()
|
|
&& ui.gallery.kind.get().is_none()
|
|
&& ui.gallery.range.get().is_none()
|
|
&& !ui.gallery.favorites.get()
|
|
&& !ui.gallery.not_in_album.get();
|
|
whole && ui.gallery.has_memories.get() && ui.gallery.model.n_items() > 0
|
|
}
|
|
|
|
/// Add or drop the "On this day" row at the head of the timeline. Only that
|
|
/// one row changes, so the rest of the list keeps its widgets and position.
|
|
fn sync_memories(ui: &Rc<Ui>) {
|
|
let store = &ui.gallery.groups;
|
|
let shown = store
|
|
.item(0)
|
|
.and_downcast::<BoxedAnyObject>()
|
|
.is_some_and(|obj| matches!(*obj.borrow::<GalleryRow>(), GalleryRow::Memories));
|
|
match (shown, shows_memories(ui)) {
|
|
(false, true) => store.insert(0, &BoxedAnyObject::new(GalleryRow::Memories)),
|
|
(true, false) => store.remove(0),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// "March 2024" for the scrubber's month `index`.
|
|
fn scrubber_label(months: &[PhotoMonth], index: usize) -> String {
|
|
months
|
|
.get(index)
|
|
.map_or_else(String::new, |m| month_label(m.year, m.month))
|
|
}
|
|
|
|
/// Move the knob to the month at the top of the timeline. The scrubber itself
|
|
/// ignores this while the user has it in hand.
|
|
fn sync_scrubber_position(ui: &Rc<Ui>) {
|
|
if !ui.gallery.scrubber.is_available() {
|
|
return;
|
|
}
|
|
let Some((uid, _)) = top_anchor(ui) else {
|
|
return;
|
|
};
|
|
let Some(capture_time) = find_photo_index(&ui.gallery.model, &uid)
|
|
.and_then(|idx| ui.gallery.model.item(idx))
|
|
.and_downcast::<BoxedAnyObject>()
|
|
.map(|boxed| boxed.borrow::<PhotoItem>().capture_time)
|
|
else {
|
|
return;
|
|
};
|
|
let months = ui.gallery.months.borrow();
|
|
if let Some(index) = month_index(&months, capture_time) {
|
|
ui.gallery.scrubber.set_value(index);
|
|
}
|
|
}
|
|
|
|
/// Which of `months` (newest first) `capture_time` falls in; the nearest one
|
|
/// when it falls between them.
|
|
pub(crate) fn month_index(months: &[PhotoMonth], capture_time: i64) -> Option<usize> {
|
|
let position = months
|
|
.iter()
|
|
.position(|m| month_range(m.year, m.month).is_some_and(|(from, _)| capture_time >= from));
|
|
match position {
|
|
Some(index) => Some(index),
|
|
None => months.len().checked_sub(1),
|
|
}
|
|
}
|
|
|
|
/// Wire the scrubber: the month names in its bubble, and the jump once it
|
|
/// rests on a month.
|
|
fn wire_scrubber(ui: &Rc<Ui>) {
|
|
let ui_label = ui.clone();
|
|
ui.gallery
|
|
.scrubber
|
|
.set_label_func(move |index| scrubber_label(&ui_label.gallery.months.borrow(), index));
|
|
let ui_pick = ui.clone();
|
|
ui.gallery.scrubber.connect_pick(move |index| {
|
|
if let Some(source) = ui_pick.gallery.scrub_source.borrow_mut().take() {
|
|
source.remove();
|
|
}
|
|
let ui_jump = ui_pick.clone();
|
|
let source = glib::timeout_add_local_once(SCRUB_DEBOUNCE, move || {
|
|
ui_jump.gallery.scrub_source.borrow_mut().take();
|
|
jump_to_month(&ui_jump, index);
|
|
});
|
|
*ui_pick.gallery.scrub_source.borrow_mut() = Some(source);
|
|
});
|
|
}
|
|
|
|
/// Scroll the timeline to the scrubber's month `index`, loading pages until
|
|
/// its first photo is in.
|
|
fn jump_to_month(ui: &Rc<Ui>, index: usize) {
|
|
let end = {
|
|
let months = ui.gallery.months.borrow();
|
|
months
|
|
.get(index)
|
|
.and_then(|m| month_range(m.year, m.month))
|
|
.map(|(_, to)| to)
|
|
};
|
|
ui.gallery.jump.set(end);
|
|
continue_jump(ui);
|
|
}
|
|
|
|
/// One step of a scrubber jump: scroll there when the month is loaded, or load
|
|
/// a long page and come back when it lands.
|
|
fn continue_jump(ui: &Rc<Ui>) {
|
|
let Some(end) = ui.gallery.jump.get() else {
|
|
return;
|
|
};
|
|
let model = &ui.gallery.model;
|
|
// Newest first, so the month's first photo is the first one older than
|
|
// its end.
|
|
let landing = (0..model.n_items()).find(|idx| {
|
|
model
|
|
.item(*idx)
|
|
.and_downcast::<BoxedAnyObject>()
|
|
.is_some_and(|boxed| boxed.borrow::<PhotoItem>().capture_time < end)
|
|
});
|
|
if landing.is_none() && ui.gallery.has_more.get() {
|
|
let ui_page = ui.clone();
|
|
ui.gallery
|
|
.page_waiters
|
|
.borrow_mut()
|
|
.push(Box::new(move || continue_jump(&ui_page)));
|
|
ui.gallery.burst.set(true);
|
|
load_gallery(ui, true);
|
|
return;
|
|
}
|
|
ui.gallery.jump.set(None);
|
|
let target = landing.or_else(|| model.n_items().checked_sub(1));
|
|
let Some(uid) = target
|
|
.and_then(|idx| model.item(idx))
|
|
.and_downcast::<BoxedAnyObject>()
|
|
.map(|boxed| boxed.borrow::<PhotoItem>().uid.clone())
|
|
else {
|
|
return;
|
|
};
|
|
let Some(row) = row_of_photo(&ui.gallery.groups, &uid) else {
|
|
return;
|
|
};
|
|
// Land on the day heading above the photo when it opens a day.
|
|
let heading = row
|
|
.checked_sub(1)
|
|
.filter(|above| {
|
|
ui.gallery
|
|
.groups
|
|
.item(*above)
|
|
.and_downcast::<BoxedAnyObject>()
|
|
.is_some_and(|boxed| {
|
|
matches!(*boxed.borrow::<GalleryRow>(), GalleryRow::Heading(_))
|
|
})
|
|
})
|
|
.unwrap_or(row);
|
|
ui.gallery
|
|
.list
|
|
.scroll_to(heading, gtk4::ListScrollFlags::empty(), None);
|
|
}
|
|
|
|
/// Load the next page when the timeline does not fill the window yet: with no
|
|
/// scrollbar there is no scrolling to trigger it.
|
|
fn fill_viewport(ui: &Rc<Ui>) {
|
|
let ui = ui.clone();
|
|
glib::idle_add_local_once(move || {
|
|
let Some(adj) = ui.gallery.list.vadjustment() else {
|
|
return;
|
|
};
|
|
if ui.gallery.has_more.get() && adj.upper() <= adj.page_size() + 1.0 {
|
|
load_gallery(&ui, true);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Select every photo loaded so far (Ctrl+A).
|
|
pub(crate) fn select_all_photos(ui: &Rc<Ui>) {
|
|
set_selection_mode(ui, true);
|
|
{
|
|
let mut selected = ui.gallery.selected.borrow_mut();
|
|
for idx in 0..ui.gallery.model.n_items() {
|
|
if let Some(boxed) = ui.gallery.model.item(idx).and_downcast::<BoxedAnyObject>() {
|
|
selected.insert(boxed.borrow::<PhotoItem>().uid.clone());
|
|
}
|
|
}
|
|
}
|
|
sync_selection_bar(ui);
|
|
repaint_gallery(ui);
|
|
}
|
|
|
|
/// A tile's right-click menu.
|
|
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());
|
|
let label = if photo.kind == PhotoKind::Video {
|
|
pgettext("verb", "Play")
|
|
} else {
|
|
pgettext("verb", "Open")
|
|
};
|
|
menu.item(&label, move || open_photo_viewer(&ui_c, uid.clone()));
|
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
|
// Translators: a check item in a photo's menu, on while the photo is a favorite.
|
|
let favorite_label = pgettext("state", "Favorite");
|
|
menu.toggle(&favorite_label, photo.favorite, move |favorite| {
|
|
set_photo_favorite(&ui_c, uid.clone(), favorite)
|
|
});
|
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
|
menu.item(&pgettext("verb", "Select"), move || {
|
|
set_selection_mode(&ui_c, true);
|
|
toggle_selected(&ui_c, &uid);
|
|
});
|
|
menu.section();
|
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
|
menu.item(&gettext("Add to Album…"), move || {
|
|
prompt_add_to_album(&ui_c, vec![uid.clone()])
|
|
});
|
|
let open_album = ui.gallery.album.borrow().clone();
|
|
if let Some(album) = open_album.filter(|album| !album.shared) {
|
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
|
menu.item(&gettext("Remove from Album"), move || {
|
|
remove_from_album(&ui_c, &album, vec![uid.clone()])
|
|
});
|
|
}
|
|
menu.section();
|
|
let (ui_c, uid) = (ui.clone(), photo.uid.clone());
|
|
menu.item(&gettext("Move to Trash…"), move || {
|
|
confirm_trash_photos(&ui_c, vec![uid.clone()])
|
|
});
|
|
menu.popup_at(anchor, x, y);
|
|
}
|
|
|
|
/// Star or unstar a photo from the grid, and show the new state.
|
|
fn set_photo_favorite(ui: &Rc<Ui>, uid: String, favorite: bool) {
|
|
let rx = spawn_request(
|
|
ui.dirs.control_socket(),
|
|
Request::SetPhotoFavorite {
|
|
uid: uid.clone(),
|
|
favorite,
|
|
},
|
|
);
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
match rx.recv().await {
|
|
Ok(Ok(Response::Ok { .. })) => {
|
|
set_gallery_favorite(&ui, &uid, favorite);
|
|
repaint_gallery(&ui);
|
|
toast(
|
|
&ui,
|
|
&if favorite {
|
|
gettext("Added to Favorites")
|
|
} else {
|
|
gettext("Removed from Favorites")
|
|
},
|
|
);
|
|
}
|
|
Ok(Ok(Response::Error { message, .. })) => {
|
|
toast_error(&ui, &gettext("Couldn't change the favorite"), &message)
|
|
}
|
|
_ => toast_error(
|
|
&ui,
|
|
&gettext("Couldn't change the favorite"),
|
|
&gettext("The Proton Drive service didn't respond."),
|
|
),
|
|
}
|
|
});
|
|
}
|
|
|
|
pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|
sync_filter_badge(ui);
|
|
if ui.gallery.loading.get() {
|
|
// A filter flipped while a page is on its way: that page answers the
|
|
// old question, so fetch again once it lands. More pages can wait.
|
|
if !append {
|
|
ui.gallery.reload_pending.set(true);
|
|
}
|
|
return;
|
|
}
|
|
// An open album or place pages itself instead of the timeline; everything
|
|
// downstream — the model, the sections, the thumbnails, the lightbox — is
|
|
// the same.
|
|
let album = ui.gallery.album.borrow().as_ref().map(|a| a.uid.clone());
|
|
let place = ui.gallery.place.borrow().as_ref().map(|p| p.id);
|
|
let collection = album.is_some() || place.is_some();
|
|
if !append {
|
|
// Fresh load: clear the timeline and show Loading until the first page lands.
|
|
ui.gallery.model.remove_all();
|
|
gallery_status(
|
|
ui,
|
|
"image-x-generic-symbolic",
|
|
&gettext("Loading photos…"),
|
|
&gettext("Reading your Proton Drive timeline."),
|
|
false,
|
|
);
|
|
// Rebuild the date jump for the current kind, but only for a full-span
|
|
// load — a jump *to* a month sets a range and reloads, and refreshing the
|
|
// dropdown then would fight the selection the user just made. An album
|
|
// has no date jump at all.
|
|
if !collection && ui.gallery.range.get().is_none() {
|
|
refresh_photo_months(ui);
|
|
}
|
|
sync_scrubber(ui);
|
|
if !collection {
|
|
refresh_memories(ui);
|
|
}
|
|
sync_memories(ui);
|
|
}
|
|
let offset = ui.gallery.model.n_items() as usize;
|
|
let limit = if ui.gallery.burst.replace(false) {
|
|
JUMP_PAGE
|
|
} else {
|
|
PHOTOS_PAGE
|
|
};
|
|
ui.gallery.loading.set(true);
|
|
ui.gallery.pager.set_visible(append);
|
|
|
|
ui.busy_begin();
|
|
let request = match (album, place) {
|
|
(Some(uid), _) => Request::AlbumPhotos { uid, offset, limit },
|
|
(None, Some(id)) => Request::PlacePhotos { id, offset, limit },
|
|
(None, None) => Request::PhotosTimeline {
|
|
offset,
|
|
limit,
|
|
kind: ui.gallery.kind.get(),
|
|
range: ui.gallery.range.get(),
|
|
favorites: ui.gallery.favorites.get(),
|
|
not_in_album: ui.gallery.not_in_album.get(),
|
|
},
|
|
};
|
|
let rx = spawn_request(ui.dirs.control_socket(), request);
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = rx.recv().await;
|
|
ui.busy_end();
|
|
ui.gallery.loading.set(false);
|
|
ui.gallery.pager.set_visible(false);
|
|
if ui.gallery.reload_pending.take() {
|
|
load_gallery(&ui, false);
|
|
return;
|
|
}
|
|
// 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,
|
|
items,
|
|
counts,
|
|
})) => {
|
|
// Nothing can go into a library the account does not have; the
|
|
// SDK refuses the upload before sending it (issue #38).
|
|
ui.gallery.library.set(available);
|
|
ui.gallery
|
|
.upload
|
|
.set_sensitive(available && *ui.mounted.borrow());
|
|
if !available {
|
|
gallery_status(
|
|
&ui,
|
|
"image-missing-symbolic",
|
|
&gettext("No photo library"),
|
|
&gettext("This Proton account doesn't have Photos enabled."),
|
|
false,
|
|
);
|
|
return;
|
|
}
|
|
ui.gallery.has_more.set(items.len() == limit);
|
|
// Label the filter tabs with live per-kind counts.
|
|
if let Some(counts) = counts {
|
|
update_gallery_tabs(&ui, counts);
|
|
}
|
|
// Take the daemon's word on which photos can never have a
|
|
// thumbnail, so their tiles show a placeholder from the first
|
|
// frame instead of queueing a request that can only fail.
|
|
{
|
|
let mut nothumb = ui.gallery.photo_nothumb.borrow_mut();
|
|
for item in items.iter().filter(|item| item.no_thumb) {
|
|
nothumb.insert(item.uid.clone());
|
|
}
|
|
}
|
|
for item in &items {
|
|
ui.gallery.model.append(&BoxedAnyObject::new(item.clone()));
|
|
}
|
|
repaint_gallery(&ui);
|
|
if ui.gallery.model.n_items() == 0 {
|
|
let filtered = ui.gallery.kind.get().is_some()
|
|
|| ui.gallery.favorites.get()
|
|
|| ui.gallery.not_in_album.get()
|
|
|| ui.gallery.range.get().is_some();
|
|
let (title, description) = if ui.gallery.album.borrow().is_some() {
|
|
(
|
|
gettext("Empty album"),
|
|
gettext("This album has no photos in it."),
|
|
)
|
|
} else {
|
|
let month = ui
|
|
.gallery
|
|
.range
|
|
.get()
|
|
.and_then(|_| ui.gallery.dates.selected_item())
|
|
.and_downcast::<gtk4::StringObject>()
|
|
.map(|month| month.string().to_string());
|
|
empty_timeline_text(
|
|
ui.gallery.kind.get(),
|
|
ui.gallery.favorites.get(),
|
|
ui.gallery.not_in_album.get(),
|
|
month.as_deref(),
|
|
)
|
|
};
|
|
gallery_status(
|
|
&ui,
|
|
if ui.gallery.favorites.get() {
|
|
"starred-symbolic"
|
|
} else {
|
|
"image-x-generic-symbolic"
|
|
},
|
|
&title,
|
|
&description,
|
|
false,
|
|
);
|
|
// Only the whole, unfiltered timeline offers Upload: an
|
|
// empty *album* is filled by adding existing photos to it,
|
|
// and an empty filter is not a library that needs photos.
|
|
ui.gallery
|
|
.empty_actions
|
|
.set_visible(!ui.gallery.in_collection() && !filtered);
|
|
return;
|
|
}
|
|
ui.gallery.content.set_visible_child_name("timeline");
|
|
fill_viewport(&ui);
|
|
}
|
|
// A failed *next* page keeps the photos already on screen — the failure
|
|
// goes to a toast rather than wiping the timeline for a status page.
|
|
Ok(Ok(Response::Error { message, .. })) if append => {
|
|
toast_error(&ui, &gettext("Couldn't load more photos"), &message)
|
|
}
|
|
Ok(Ok(Response::Error { message, .. })) => gallery_status(
|
|
&ui,
|
|
"dialog-warning-symbolic",
|
|
&gettext("Couldn't load photos"),
|
|
&message,
|
|
false,
|
|
),
|
|
Ok(Ok(_)) => gallery_status(
|
|
&ui,
|
|
"dialog-warning-symbolic",
|
|
&gettext("Couldn't load photos"),
|
|
&gettext("Unexpected reply from the Proton Drive service."),
|
|
false,
|
|
),
|
|
Ok(Err(_)) | Err(_) if append => toast_error(
|
|
&ui,
|
|
&gettext("Couldn't load more photos"),
|
|
&gettext("The Proton Drive service didn't respond."),
|
|
),
|
|
Ok(Err(_)) | Err(_) => gallery_unreachable(&ui),
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Swap the Photos content area to the status page, hiding the pager. Retry is
|
|
/// offered only when restarting the mount service could actually fix it.
|
|
pub(crate) fn gallery_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
|
ui.gallery.status.set_icon_name(Some(icon));
|
|
ui.gallery.status.set_title(title);
|
|
ui.gallery
|
|
.status
|
|
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
|
ui.gallery.retry.set_visible(retry);
|
|
// Only the empty timeline offers a way to fill it; every other status turns
|
|
// those buttons back off.
|
|
ui.gallery.empty_actions.set_visible(false);
|
|
ui.gallery.has_more.set(false);
|
|
ui.gallery.content.set_visible_child_name("status");
|
|
}
|
|
|
|
/// Photos counterpart of [`browser_unreachable`]: auto-retry while the mount is
|
|
/// still starting, surface an actionable error + Retry once it's actually down.
|
|
pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) {
|
|
service_unreachable(ui, "gallery", gallery_status, |ui| load_gallery(ui, false));
|
|
}
|
|
|
|
/// Play a video with an external player. Prefers `mpv` — it sniffs the container
|
|
/// from the bytes, so the cache's extensionless blob plays fine, and it is the
|
|
/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the
|
|
/// user's default handler when mpv isn't installed.
|
|
pub(crate) fn play_external(path: &str) {
|
|
match Command::new("mpv").arg(path).spawn() {
|
|
Ok(child) => pdfs_core::opener::reap(child),
|
|
Err(_) => open_path(path),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
Grouping, ROW_DEFAULT, ROW_MAX, ROW_MIN, ROW_STEP, TILE_GAP, active_filters,
|
|
empty_timeline_text, grouping_for, memory_years, month_index, month_range, plan_grid,
|
|
section_heading,
|
|
};
|
|
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoMonth};
|
|
|
|
#[test]
|
|
fn the_scrubber_finds_the_month_a_photo_was_taken_in() {
|
|
let months = [(2026, 3), (2025, 12), (2025, 11)].map(|(year, month)| PhotoMonth {
|
|
year,
|
|
month,
|
|
count: 1,
|
|
});
|
|
let mid = |year, month| month_range(year, month).unwrap().0 + 86_400 * 10;
|
|
assert_eq!(month_index(&months, mid(2026, 3)), Some(0));
|
|
assert_eq!(month_index(&months, mid(2025, 12)), Some(1));
|
|
// A month with no photos of its own lands on the next older one.
|
|
assert_eq!(month_index(&months, mid(2026, 1)), Some(1));
|
|
assert_eq!(month_index(&months, mid(2020, 1)), Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn square_tiles_span_the_content_width() {
|
|
for width in [0, 40, 640, 900, 1440, 1920, 2560] {
|
|
for target in [ROW_MIN, ROW_DEFAULT, ROW_MAX] {
|
|
let (columns, size) = plan_grid(width, target);
|
|
let columns = columns as i32;
|
|
let row = columns * size + TILE_GAP * (columns - 1);
|
|
assert!(size > 0 && columns > 0);
|
|
assert!(
|
|
width < target || row <= width,
|
|
"{row}px overflows {width}px"
|
|
);
|
|
assert!(
|
|
width < target || width - row < columns,
|
|
"{row}px leaves a margin in {width}px"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn zooming_in_puts_fewer_tiles_in_a_row() {
|
|
assert!(plan_grid(1920, ROW_MIN).0 > plan_grid(1920, ROW_DEFAULT).0);
|
|
assert!(plan_grid(1920, ROW_DEFAULT).0 > plan_grid(1920, ROW_MAX).0);
|
|
let (_, size) = plan_grid(1920, ROW_DEFAULT);
|
|
assert!((size - ROW_DEFAULT).abs() < ROW_DEFAULT / 2);
|
|
}
|
|
|
|
#[test]
|
|
fn only_big_tiles_are_headed_by_day() {
|
|
assert_eq!(grouping_for(ROW_MAX), Grouping::Day);
|
|
assert_eq!(grouping_for(ROW_DEFAULT + 2 * ROW_STEP), Grouping::Day);
|
|
assert_eq!(grouping_for(ROW_DEFAULT), Grouping::Month);
|
|
assert_eq!(grouping_for(ROW_DEFAULT - 3 * ROW_STEP), Grouping::Month);
|
|
assert_eq!(grouping_for(ROW_MIN), Grouping::Year);
|
|
}
|
|
|
|
#[test]
|
|
fn month_and_year_sections_are_headed_by_the_month_and_the_year() {
|
|
let Some((from, _)) = month_range(2024, 6) else {
|
|
panic!("June 2024 is a month");
|
|
};
|
|
let noon = from + 14 * 86_400 + 12 * 3_600;
|
|
assert_eq!(section_heading(noon, Grouping::Year), "2024");
|
|
assert_eq!(
|
|
section_heading(noon, Grouping::Month),
|
|
section_heading(from, Grouping::Month),
|
|
"every day of a month shares its heading"
|
|
);
|
|
assert_ne!(
|
|
section_heading(noon, Grouping::Day),
|
|
section_heading(from, Grouping::Day)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_filter_badge_counts_each_active_filter_once() {
|
|
assert_eq!(active_filters(None, false, false, None), 0);
|
|
assert_eq!(active_filters(Some(PhotoKind::Raw), false, false, None), 1);
|
|
assert_eq!(
|
|
active_filters(Some(PhotoKind::Video), true, true, Some((0, 1))),
|
|
4
|
|
);
|
|
}
|
|
|
|
/// The strip shows the nearest year first, with each year's newest photo
|
|
/// as its cover, and never this year's own photos.
|
|
#[test]
|
|
fn on_this_day_cards_go_nearest_year_first() {
|
|
let photo = |uid: &str, year: i32, hour: i32| {
|
|
let Some((from, _)) = month_range(year, 6) else {
|
|
panic!("June {year} is a month");
|
|
};
|
|
PhotoItem {
|
|
uid: uid.into(),
|
|
capture_time: from + 14 * 86_400 + i64::from(hour) * 3_600,
|
|
thumb_path: None,
|
|
name: None,
|
|
ratio: None,
|
|
no_thumb: false,
|
|
kind: PhotoKind::Photo,
|
|
favorite: false,
|
|
group_size: 1,
|
|
has_raw: false,
|
|
}
|
|
};
|
|
// Newest first, the way the daemon sends them.
|
|
let items = [
|
|
photo("today", 2026, 9),
|
|
photo("late", 2025, 18),
|
|
photo("early", 2025, 8),
|
|
photo("old", 2021, 12),
|
|
];
|
|
let years = memory_years(&items, 2026);
|
|
let summary: Vec<(u32, Vec<&str>)> = years
|
|
.iter()
|
|
.map(|(ago, photos)| (*ago, photos.iter().map(|p| p.uid.as_str()).collect()))
|
|
.collect();
|
|
assert_eq!(summary, [(1, vec!["late", "early"]), (5, vec!["old"])]);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_filter_names_what_it_filtered() {
|
|
assert_eq!(
|
|
empty_timeline_text(None, false, false, None).0,
|
|
"No photos yet"
|
|
);
|
|
assert_eq!(
|
|
empty_timeline_text(None, true, false, None).0,
|
|
"No favorites yet"
|
|
);
|
|
assert_eq!(
|
|
empty_timeline_text(Some(PhotoKind::Video), false, false, Some("June 2024")).0,
|
|
"No videos in June 2024"
|
|
);
|
|
assert_eq!(
|
|
empty_timeline_text(Some(PhotoKind::Raw), true, false, None).0,
|
|
"No favorite raw files"
|
|
);
|
|
assert_eq!(
|
|
empty_timeline_text(None, false, true, None).0,
|
|
"Every photo is in an album"
|
|
);
|
|
}
|
|
}
|