mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-10 17:17:03 +01:00
The grouping landed in the daemon; this is what it looks like. A tile whose shot was stored as more than one file badges that in the corner — "RAW" when one of the members is a raw file, since that is the word the person is looking for, and a plain count otherwise (a live photo, a burst). The lightbox gains a control that steps through the group's files, which is the only way to reach the RAW now that the grid shows the shot once. Deleting says what it will do: the daemon takes the whole group, so the confirmation names the file count when a selection stands for more files than tiles.
2320 lines
92 KiB
Rust
2320 lines
92 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>,
|
|
/// Aspect ratios learned by decoding a thumbnail, for photos the daemon had
|
|
/// not recorded one for. Consulted by the layout ahead of
|
|
/// [`PhotoItem::ratio`]: a row laid out at the assumed 1.0 re-flows once the
|
|
/// real shape is known.
|
|
pub(crate) learned_ratios: RefCell<HashMap<String, f64>>,
|
|
/// Photos the layout had to guess a ratio for. Decoding one of these is what
|
|
/// makes a re-flow worth doing; decoding a photo whose ratio was already
|
|
/// known would re-flow the timeline for no visible change.
|
|
pub(crate) assumed_ratios: RefCell<HashSet<String>>,
|
|
/// 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,
|
|
pub(crate) more: gtk4::Button,
|
|
pub(crate) upload: gtk4::Button,
|
|
/// Opens the Google Photos Takeout import chooser.
|
|
pub(crate) import: gtk4::Button,
|
|
/// 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.
|
|
pub(crate) title: gtk4::Label,
|
|
/// "1,204 photos" under the page title.
|
|
pub(crate) subtitle: gtk4::Label,
|
|
/// 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 Photos/Albums switcher, and the box holding it — hidden while an
|
|
/// album is open, where the back button leads instead.
|
|
pub(crate) photos_btn: gtk4::ToggleButton,
|
|
pub(crate) albums_btn: gtk4::ToggleButton,
|
|
pub(crate) view_switch: gtk4::Box,
|
|
/// 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 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,
|
|
/// 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: [gtk4::ToggleButton; 4],
|
|
/// 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 date-jump dropdown ("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: gtk4::DropDown,
|
|
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 favourites toggle, and whether it is on. When on, the timeline is
|
|
/// restricted to photos carrying Proton's `Favorite` tag.
|
|
pub(crate) favorites_btn: gtk4::ToggleButton,
|
|
pub(crate) favorites: 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>,
|
|
/// 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>,
|
|
/// 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). 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.
|
|
pub(crate) bound: RefCell<BTreeMap<u32, Option<String>>>,
|
|
/// 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 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,
|
|
}
|
|
|
|
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
|
pub(crate) const PHOTOS_PAGE: usize = 60;
|
|
|
|
/// Gallery row height in px: the zoom range, its default, and the step one
|
|
/// Ctrl+scroll notch (or Ctrl+±) moves it by. A justified row is scaled to the
|
|
/// content width once it is full, so this is the *target* a row lands near
|
|
/// rather than the height it ends up with (see [`justify_rows`]).
|
|
pub(crate) const ROW_MIN: i32 = 90;
|
|
|
|
pub(crate) const ROW_MAX: i32 = 340;
|
|
|
|
pub(crate) const ROW_DEFAULT: i32 = 180;
|
|
|
|
pub(crate) const ROW_STEP: i32 = 30;
|
|
|
|
/// 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;
|
|
|
|
/// The aspect ratios the layout will lay out. A 12:1 panorama laid out honestly
|
|
/// is a row of one photo two hundred px tall, and a scan of a strip of film is
|
|
/// worse; clamping keeps one odd frame from deciding what a whole row looks
|
|
/// like, at the cost of cropping that frame's extremes in its tile.
|
|
pub(crate) const RATIO_MIN: f64 = 0.4;
|
|
|
|
pub(crate) const RATIO_MAX: f64 = 3.0;
|
|
|
|
/// The ratio a photo gets before anything has seen its pixels. Square is the
|
|
/// least wrong guess: it is between the two orientations, so the re-flow when
|
|
/// the real ratio lands moves the row as little as possible.
|
|
pub(crate) const RATIO_UNKNOWN: f64 = 1.0;
|
|
|
|
/// 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;
|
|
|
|
/// Pause after a resize/zoom before the visible sections are re-flowed.
|
|
pub(crate) const RELAYOUT_DEBOUNCE: Duration = Duration::from_millis(80);
|
|
|
|
/// One day-section of the photos timeline: a heading plus the photos captured
|
|
/// that day, 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 {
|
|
/// 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::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::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
|
|
})
|
|
}
|
|
_ => 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: gtk4::Label,
|
|
pub(crate) subtitle: gtk4::Label,
|
|
pub(crate) more: gtk4::Button,
|
|
pub(crate) list: gtk4::ListView,
|
|
pub(crate) scroll: gtk4::ScrolledWindow,
|
|
pub(crate) retry: gtk4::Button,
|
|
pub(crate) upload: gtk4::Button,
|
|
pub(crate) import: gtk4::Button,
|
|
pub(crate) empty_actions: gtk4::Box,
|
|
pub(crate) empty_upload: gtk4::Button,
|
|
pub(crate) empty_import: gtk4::Button,
|
|
pub(crate) refresh: 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_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) photos_btn: gtk4::ToggleButton,
|
|
pub(crate) albums_btn: gtk4::ToggleButton,
|
|
pub(crate) view_switch: gtk4::Box,
|
|
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: [gtk4::ToggleButton; 4],
|
|
pub(crate) favorites_btn: gtk4::ToggleButton,
|
|
/// The date-jump dropdown, populated with the timeline's months.
|
|
pub(crate) dates: gtk4::DropDown,
|
|
}
|
|
|
|
/// 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("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("Upload photos").build();
|
|
empty_upload.add_css_class("pill");
|
|
empty_upload.add_css_class("suggested-action");
|
|
let empty_import = gtk4::Button::builder()
|
|
.label("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");
|
|
|
|
// Kept as an explicit fallback: the timeline also pages itself in as the
|
|
// scroll nears the bottom (see [`wire_gallery`]), so reaching this button at
|
|
// all is unusual.
|
|
let more = gtk4::Button::builder()
|
|
.label("Load more")
|
|
.halign(gtk4::Align::Center)
|
|
.build();
|
|
more.add_css_class("pill");
|
|
more.set_visible(false);
|
|
|
|
// Horizontal scrolling is never wanted: the grid is sized to the viewport
|
|
// width, and a stray hscrollbar would fight the layout.
|
|
let scroll = gtk4::ScrolledWindow::builder()
|
|
.vexpand(true)
|
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
|
.child(&list)
|
|
.build();
|
|
|
|
let title_label = gtk4::Label::builder()
|
|
.label("Gallery")
|
|
.halign(gtk4::Align::Start)
|
|
.build();
|
|
title_label.add_css_class("title-2");
|
|
|
|
let subtitle = gtk4::Label::builder()
|
|
.halign(gtk4::Align::Start)
|
|
.visible(false)
|
|
.build();
|
|
subtitle.add_css_class("dim-label");
|
|
subtitle.add_css_class("caption");
|
|
|
|
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
|
titles.set_hexpand(true);
|
|
titles.append(&title_label);
|
|
titles.append(&subtitle);
|
|
|
|
// 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("Back to albums")
|
|
.valign(gtk4::Align::Center)
|
|
.visible(false)
|
|
.build();
|
|
back.add_css_class("flat");
|
|
back.add_css_class("circular");
|
|
|
|
let upload = gtk4::Button::builder()
|
|
.label("Upload")
|
|
.icon_name("list-add-symbolic")
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
upload.add_css_class("pill");
|
|
upload.add_css_class("suggested-action");
|
|
|
|
// Importing an export is a rarer, heavier action than adding one photo, so
|
|
// it sits beside Upload as a plain button rather than a second accent one.
|
|
let import = gtk4::Button::builder()
|
|
.icon_name("folder-download-symbolic")
|
|
.tooltip_text("Import a Google Photos Takeout export")
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
import.add_css_class("flat");
|
|
import.add_css_class("circular");
|
|
let refresh = refresh_button();
|
|
|
|
// Picking photos is a mode, so its control is a toggle rather than a button.
|
|
let select_btn = gtk4::ToggleButton::builder()
|
|
.icon_name("selection-mode-symbolic")
|
|
.tooltip_text("Select photos")
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
select_btn.add_css_class("flat");
|
|
select_btn.add_css_class("circular");
|
|
|
|
// 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("Select photos")
|
|
.hexpand(true)
|
|
.xalign(0.0)
|
|
.build();
|
|
let select_trash = gtk4::Button::builder()
|
|
.label("Move to Trash")
|
|
.valign(gtk4::Align::Center)
|
|
.sensitive(false)
|
|
.build();
|
|
select_trash.add_css_class("destructive-action");
|
|
let select_done = gtk4::Button::builder()
|
|
.icon_name("window-close-symbolic")
|
|
.tooltip_text("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_trash);
|
|
select_box.append(&select_done);
|
|
let select_bar = gtk4::Revealer::builder()
|
|
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
|
.child(&select_box)
|
|
.build();
|
|
|
|
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
header_box.append(&back);
|
|
header_box.append(&titles);
|
|
header_box.append(&select_btn);
|
|
header_box.append(&refresh);
|
|
header_box.append(&import);
|
|
header_box.append(&upload);
|
|
|
|
// 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 tab_labels = ["All", "Photos", "Videos", "Raw"];
|
|
let tabs: [gtk4::ToggleButton; 4] = std::array::from_fn(|i| {
|
|
gtk4::ToggleButton::builder()
|
|
.label(tab_labels[i])
|
|
.active(i == 0)
|
|
.build()
|
|
});
|
|
// Group the toggles so they behave as a radio set: chaining each to the first
|
|
// is what GTK turns into mutual exclusion.
|
|
for btn in &tabs[1..] {
|
|
btn.set_group(Some(&tabs[0]));
|
|
}
|
|
let tab_group = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
|
tab_group.add_css_class("linked");
|
|
for btn in &tabs {
|
|
btn.add_css_class("pill");
|
|
tab_group.append(btn);
|
|
}
|
|
|
|
// Favourites: a filter, not a tab — it cuts across Photos / Videos / Raw, so
|
|
// it stays outside the segmented control rather than becoming a fifth option
|
|
// that would silently drop the kind the user picked.
|
|
let favorites_btn = gtk4::ToggleButton::builder()
|
|
.icon_name("starred-symbolic")
|
|
.tooltip_text("Show only favourites")
|
|
.build();
|
|
favorites_btn.add_css_class("pill");
|
|
|
|
// Date jump: "All dates" plus a row per month, filled in once the timeline's
|
|
// months are known (see [`refresh_photo_months`]). Pushed to the far end of
|
|
// the filter row, opposite the kind toggles.
|
|
let dates = gtk4::DropDown::from_strings(&["All dates"]);
|
|
dates.add_css_class("pill");
|
|
dates.set_tooltip_text(Some("Jump to a month"));
|
|
|
|
// The kind toggles and the date jump 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(&tab_group);
|
|
filters.append(&favorites_btn);
|
|
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
|
spacer.set_hexpand(true);
|
|
filters.append(&spacer);
|
|
filters.append(&dates);
|
|
|
|
// 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.
|
|
let photos_btn = gtk4::ToggleButton::builder()
|
|
.label("Photos")
|
|
.active(true)
|
|
.build();
|
|
let albums_btn = gtk4::ToggleButton::builder().label("Albums").build();
|
|
albums_btn.set_group(Some(&photos_btn));
|
|
let view_switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
|
view_switch.add_css_class("linked");
|
|
view_switch.add_css_class("view-switch");
|
|
view_switch.set_halign(gtk4::Align::Start);
|
|
for btn in [&photos_btn, &albums_btn] {
|
|
btn.add_css_class("pill");
|
|
view_switch.append(btn);
|
|
}
|
|
|
|
let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
filter_bar.append(&filters);
|
|
|
|
// The timeline (plus its pager) or the status page, never both.
|
|
let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
|
timeline.append(&select_bar);
|
|
timeline.append(&scroll);
|
|
timeline.append(&more);
|
|
|
|
// 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 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_stack, Some("albums"));
|
|
|
|
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(&header_box);
|
|
inner.append(&view_switch);
|
|
inner.append(&filter_bar);
|
|
inner.append(&content);
|
|
|
|
(
|
|
inner.upcast(),
|
|
GalleryWidgets {
|
|
model,
|
|
groups,
|
|
content,
|
|
status,
|
|
title: title_label,
|
|
subtitle,
|
|
more,
|
|
list,
|
|
scroll,
|
|
retry,
|
|
upload,
|
|
import,
|
|
empty_actions,
|
|
empty_upload,
|
|
empty_import,
|
|
refresh,
|
|
select_btn,
|
|
select_bar,
|
|
select_label,
|
|
select_trash,
|
|
select_done,
|
|
tabs,
|
|
favorites_btn,
|
|
dates,
|
|
albums,
|
|
albums_stack,
|
|
albums_status,
|
|
photos_btn,
|
|
albums_btn,
|
|
view_switch,
|
|
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()))
|
|
}
|
|
|
|
/// English month names, indexed 1..=12.
|
|
pub(crate) const MONTH_NAMES: [&str; 12] = [
|
|
"January",
|
|
"February",
|
|
"March",
|
|
"April",
|
|
"May",
|
|
"June",
|
|
"July",
|
|
"August",
|
|
"September",
|
|
"October",
|
|
"November",
|
|
"December",
|
|
];
|
|
|
|
/// 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!["All dates".to_string()];
|
|
let mut ranges: Vec<Option<(i64, i64)>> = vec![None];
|
|
for m in months {
|
|
let name = MONTH_NAMES
|
|
.get((m.month - 1) as usize)
|
|
.copied()
|
|
.unwrap_or("?");
|
|
labels.push(format!("{name} {} ({})", m.year, m.count));
|
|
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];
|
|
for (index, tab) in ui.gallery.tabs.iter().enumerate() {
|
|
let name = ["All", "Photos", "Videos", "Raw"][index];
|
|
let n = totals[index];
|
|
tab.set_label(&format!("{name} {}", thousands(n)));
|
|
tab.set_sensitive(n > 0 || tab.is_active());
|
|
}
|
|
}
|
|
|
|
/// `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. The alternative — two factories, or
|
|
// a Stack per row — buys nothing, since a row's children are rebuilt on
|
|
// bind either way.
|
|
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);
|
|
}
|
|
match &*row {
|
|
GalleryRow::Heading(heading) => {
|
|
row_box.set_margin_top(10);
|
|
row_box.set_margin_bottom(4);
|
|
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 });
|
|
for tile in tiles {
|
|
row_box.append(&photo_tile(&ui_bind, tile.clone()));
|
|
}
|
|
schedule_thumbs(&ui_bind);
|
|
}
|
|
}
|
|
ui_bind
|
|
.gallery
|
|
.bound
|
|
.borrow_mut()
|
|
.insert(item.position(), row.anchor());
|
|
});
|
|
|
|
// 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.
|
|
let ui_unbind = ui.clone();
|
|
factory.connect_unbind(move |_, item| {
|
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
|
ui_unbind
|
|
.gallery
|
|
.bound
|
|
.borrow_mut()
|
|
.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 {
|
|
wanted.remove(&tile.photo.uid);
|
|
}
|
|
}
|
|
});
|
|
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).
|
|
let ui_width = ui.clone();
|
|
list.connect_notify_local(Some("width"), move |list, _| {
|
|
let width = list.width();
|
|
if width > 0 && width != ui_width.gallery.width.get() {
|
|
ui_width.gallery.width.set(width);
|
|
schedule_relayout(&ui_width);
|
|
}
|
|
});
|
|
|
|
// Page the timeline in as the scroll nears the end, so "load more" is a
|
|
// fallback button rather than something the user has to hunt for.
|
|
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() * 0.5;
|
|
if near_end && ui_scroll.gallery.more.is_visible() && ui_scroll.gallery.more.is_sensitive()
|
|
{
|
|
load_gallery(&ui_scroll, true);
|
|
}
|
|
});
|
|
|
|
// 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),
|
|
_ => return glib::Propagation::Proceed,
|
|
}
|
|
glib::Propagation::Stop
|
|
});
|
|
list.add_controller(zoom_keys);
|
|
|
|
let ui_more = ui.clone();
|
|
ui.gallery.more.clone().connect_clicked(move |_| {
|
|
load_gallery(&ui_more, true);
|
|
});
|
|
|
|
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();
|
|
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. Only the button being switched *on* acts — the group also fires a
|
|
// `toggled` for the one switching off, which this skips — and a redundant
|
|
// toggle to the already-current kind is a no-op.
|
|
for (index, tab) in ui.gallery.tabs.iter().enumerate() {
|
|
let ui_tab = ui.clone();
|
|
tab.connect_toggled(move |btn| {
|
|
if !btn.is_active() {
|
|
return;
|
|
}
|
|
let kind = kind_for_tab(index);
|
|
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);
|
|
});
|
|
}
|
|
|
|
// Favourites: reload the timeline restricted to favourites (or back to all).
|
|
// Independent of the kind tabs and the date jump, both of which keep their
|
|
// current value across the toggle.
|
|
let ui_fav = ui.clone();
|
|
ui.gallery.favorites_btn.connect_toggled(move |btn| {
|
|
let on = btn.is_active();
|
|
if ui_fav.gallery.favorites.get() == on {
|
|
return;
|
|
}
|
|
ui_fav.gallery.favorites.set(on);
|
|
load_gallery(&ui_fav, 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);
|
|
});
|
|
|
|
// The import is a staged, hours-long migration, so the button hands over to
|
|
// the Import page rather than opening a file chooser here: the archives need
|
|
// reviewing before anything is sent, and the run needs somewhere to report.
|
|
let ui_import = ui.clone();
|
|
ui.gallery
|
|
.import
|
|
.connect_clicked(move |_| ui_import.stack.set_visible_child_name("takeout"));
|
|
|
|
let ui_upload = ui.clone();
|
|
ui.gallery.upload.connect_clicked(move |_| {
|
|
let dialog = gtk4::FileDialog::builder()
|
|
.title("Select Photo to Upload")
|
|
.build();
|
|
|
|
let filter = gtk4::FileFilter::new();
|
|
filter.set_name(Some("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",
|
|
};
|
|
let capture_time = std::fs::metadata(&path)
|
|
.and_then(|m| m.modified())
|
|
.ok()
|
|
.and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
|
|
.map(|d| d.as_secs() as i64);
|
|
|
|
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, "Photo uploaded");
|
|
}
|
|
Ok(Ok(Response::Error { message, kind })) => {
|
|
toast_failure(&ui_clone, "Couldn't upload photo", &message, kind);
|
|
}
|
|
_ => {
|
|
toast_error(
|
|
&ui_clone,
|
|
"Couldn't upload photo",
|
|
"The mount 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,
|
|
}
|
|
|
|
/// The aspect ratio to lay `photo` out at: what a decode has learned, else what
|
|
/// the daemon remembered, else square.
|
|
fn tile_ratio(ui: &Rc<Ui>, photo: &PhotoItem) -> f64 {
|
|
let known = ui
|
|
.gallery
|
|
.learned_ratios
|
|
.borrow()
|
|
.get(&photo.uid)
|
|
.copied()
|
|
.or(photo.ratio)
|
|
.filter(|ratio| ratio.is_finite() && *ratio > 0.0);
|
|
match known {
|
|
Some(ratio) => ratio.clamp(RATIO_MIN, RATIO_MAX),
|
|
None => {
|
|
ui.gallery
|
|
.assumed_ratios
|
|
.borrow_mut()
|
|
.insert(photo.uid.clone());
|
|
RATIO_UNKNOWN
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Break one day's photos into justified rows spanning `width` — each photo at
|
|
/// its own aspect ratio, each row scaled so it ends exactly on the right margin.
|
|
///
|
|
/// This is what removes the black bars: a square tile had to either crop the
|
|
/// photo or letterbox it, and with a box of the right shape there is nothing to
|
|
/// do either way.
|
|
pub(crate) fn justify_rows(ui: &Rc<Ui>, photos: &[PhotoItem], width: i32) -> Vec<Vec<Tile>> {
|
|
let ratios: Vec<f64> = photos.iter().map(|photo| tile_ratio(ui, photo)).collect();
|
|
let selecting = ui.gallery.selecting.get();
|
|
let selected = ui.gallery.selected.borrow();
|
|
let mut photos = photos.iter();
|
|
plan_rows(&ratios, width, ui.gallery.row_height.get())
|
|
.into_iter()
|
|
.map(|(height, widths)| {
|
|
widths
|
|
.into_iter()
|
|
.filter_map(|width| {
|
|
photos.next().map(|photo| Tile {
|
|
selecting,
|
|
selected: selecting && selected.contains(&photo.uid),
|
|
photo: photo.clone(),
|
|
width,
|
|
height,
|
|
})
|
|
})
|
|
.collect()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The layout math, over aspect ratios alone: each row's height, and the width
|
|
/// of every tile in it.
|
|
///
|
|
/// Photos are taken in order until their summed ratio no longer leaves them
|
|
/// `target` px tall, and that row is then scaled to land on `width` exactly. The
|
|
/// *last* row of a day is left at the target height instead of being stretched:
|
|
/// a day holding two photos would otherwise be two enormous tiles, which reads
|
|
/// as a layout bug rather than as a short day.
|
|
pub(crate) fn plan_rows(ratios: &[f64], width: i32, target: i32) -> Vec<(i32, Vec<i32>)> {
|
|
let width = width.max(ROW_MIN);
|
|
let target = target.clamp(ROW_MIN, ROW_MAX);
|
|
let mut rows = Vec::new();
|
|
let mut start = 0;
|
|
let mut sum = 0.0;
|
|
|
|
for (index, ratio) in ratios.iter().enumerate() {
|
|
sum += ratio;
|
|
let count = (index + 1 - start) as i32;
|
|
// The gaps are fixed, so only the pixels left over from them scale.
|
|
let usable = (width - TILE_GAP * (count - 1)).max(1) as f64;
|
|
let height = usable / sum;
|
|
if height <= target as f64 {
|
|
let height = (height.round() as i32).max(1);
|
|
rows.push((height, fit_row(&ratios[start..=index], width, height)));
|
|
start = index + 1;
|
|
sum = 0.0;
|
|
}
|
|
}
|
|
if start < ratios.len() {
|
|
let tail = &ratios[start..];
|
|
let widths = tail
|
|
.iter()
|
|
.map(|ratio| ((ratio * target as f64).round() as i32).max(1))
|
|
.collect();
|
|
rows.push((target, widths));
|
|
}
|
|
rows
|
|
}
|
|
|
|
/// The tile widths of one full row at `height`, adjusted so the row spans
|
|
/// `width` to the pixel.
|
|
///
|
|
/// Rounding each tile independently leaves a few px of slack, which at a tight
|
|
/// gap is visible as a ragged right margin; the last tile absorbs it.
|
|
pub(crate) fn fit_row(ratios: &[f64], width: i32, height: i32) -> Vec<i32> {
|
|
let mut widths: Vec<i32> = ratios
|
|
.iter()
|
|
.map(|ratio| ((ratio * height as f64).round() as i32).max(1))
|
|
.collect();
|
|
let gaps = TILE_GAP * (widths.len() as i32 - 1);
|
|
let used: i32 = widths.iter().sum::<i32>() + gaps;
|
|
if let Some(last) = widths.last_mut() {
|
|
*last = (*last + (width - used)).max(1);
|
|
}
|
|
widths
|
|
}
|
|
|
|
/// The width the grid is laid out to: the ListView's own 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.
|
|
pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
|
let picture = gtk4::Picture::builder()
|
|
// The tile is already the photo's shape, so Contain shows the whole
|
|
// frame with nothing cropped and nothing letterboxed. Cover would still
|
|
// crop here: a thumbnail's ratio is not exactly the ratio the row was
|
|
// laid out at, and the rounding is enough to shave an edge. The expands
|
|
// are what make the picture take the whole overlay.
|
|
.content_fit(gtk4::ContentFit::Contain)
|
|
.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)
|
|
.label(short_capture_time(tile.photo.capture_time))
|
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
|
.build();
|
|
caption.add_css_class("photo-caption");
|
|
|
|
let overlay = gtk4::Overlay::new();
|
|
overlay.set_child(Some(&placeholder));
|
|
overlay.add_overlay(&picture);
|
|
overlay.add_overlay(&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 is_video = tile.photo.kind == PhotoKind::Video;
|
|
if is_video {
|
|
let badge = gtk4::Image::builder()
|
|
.icon_name("media-playback-start-symbolic")
|
|
.pixel_size(28)
|
|
.halign(gtk4::Align::Center)
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
badge.add_css_class("photo-video-badge");
|
|
overlay.add_overlay(&badge);
|
|
}
|
|
|
|
// A shot stored as more than one file says so, in the corner the caption
|
|
// does not use. "RAW" is the useful word when one of the members is a raw
|
|
// file — that is what the person wants to find — and a plain count covers
|
|
// the rest (a live photo, a burst).
|
|
if tile.photo.has_raw || tile.photo.group_size > 1 {
|
|
let badge = gtk4::Label::builder()
|
|
.label(if tile.photo.has_raw {
|
|
"RAW".to_string()
|
|
} else {
|
|
format!("{}", tile.photo.group_size)
|
|
})
|
|
.halign(gtk4::Align::End)
|
|
.valign(gtk4::Align::Start)
|
|
.build();
|
|
badge.add_css_class("photo-group-badge");
|
|
overlay.add_overlay(&badge);
|
|
}
|
|
|
|
// 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.
|
|
if tile.selecting {
|
|
let check = gtk4::Image::builder()
|
|
.icon_name(if tile.selected {
|
|
"checkbox-checked-symbolic"
|
|
} else {
|
|
"checkbox-symbolic"
|
|
})
|
|
.pixel_size(16)
|
|
.halign(gtk4::Align::Start)
|
|
.valign(gtk4::Align::Start)
|
|
.margin_start(6)
|
|
.margin_top(6)
|
|
.build();
|
|
check.add_css_class("photo-check");
|
|
if tile.selected {
|
|
check.add_css_class("photo-check-on");
|
|
}
|
|
overlay.add_overlay(&check);
|
|
}
|
|
|
|
let button = gtk4::Button::builder()
|
|
.child(&overlay)
|
|
.width_request(tile.width)
|
|
.height_request(tile.height)
|
|
.tooltip_text(format_capture_time(tile.photo.capture_time))
|
|
.build();
|
|
button.add_css_class("photo-tile");
|
|
button.add_css_class("flat");
|
|
if tile.selected {
|
|
button.add_css_class("photo-tile-selected");
|
|
}
|
|
// Clip the thumbnail to the tile's rounded corners.
|
|
button.set_overflow(gtk4::Overflow::Hidden);
|
|
|
|
want_thumb(ui, &tile.photo, &picture);
|
|
|
|
// 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. 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 = tile.photo.uid.clone();
|
|
modifier.connect_pressed(move |gesture, _, _, _| {
|
|
let state = gesture.current_event_state().intersects(
|
|
gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::SHIFT_MASK,
|
|
);
|
|
if !state || ui_modifier.gallery.selecting.get() {
|
|
return;
|
|
}
|
|
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
|
set_selection_mode(&ui_modifier, true);
|
|
toggle_selected(&ui_modifier, &modifier_uid);
|
|
});
|
|
button.add_controller(modifier);
|
|
|
|
// A still opens in the in-app lightbox; a video can't render there, so it
|
|
// downloads and hands off to an external player instead. In selection mode
|
|
// a tile picks instead of opening: the lightbox is one Escape away.
|
|
let ui_open = ui.clone();
|
|
let uid = tile.photo.uid.clone();
|
|
button.connect_clicked(move |_| {
|
|
if ui_open.gallery.selecting.get() {
|
|
toggle_selected(&ui_open, &uid);
|
|
} else if is_video {
|
|
play_video(&ui_open, uid.clone());
|
|
} else {
|
|
open_photo_viewer(&ui_open, uid.clone());
|
|
}
|
|
});
|
|
button
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
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());
|
|
}
|
|
}
|
|
sync_selection_bar(ui);
|
|
repaint_gallery(ui);
|
|
}
|
|
|
|
/// 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 => "Select photos".to_string(),
|
|
1 => "1 selected".to_string(),
|
|
n => format!("{n} selected"),
|
|
});
|
|
ui.gallery.select_trash.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();
|
|
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) => "Move this photo to Trash?".to_string(),
|
|
(1, files) => format!("Move this photo to Trash? It is stored as {files} files."),
|
|
(n, files) if files == n => format!("Move {n} photos to Trash?"),
|
|
(n, files) => format!("Move {n} photos to Trash? They are stored as {files} files."),
|
|
};
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading("Move to Trash")
|
|
.body(body)
|
|
.build();
|
|
dialog.add_response("cancel", "Cancel");
|
|
dialog.add_response("trash", "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());
|
|
}
|
|
});
|
|
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.
|
|
pub(crate) fn trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
|
|
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() },
|
|
);
|
|
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(|| "The server refused.".to_string());
|
|
toast_error(&ui, "Some photos couldn't be moved to Trash", &message);
|
|
}
|
|
if trashed.is_empty() {
|
|
return;
|
|
}
|
|
let count = trashed.len();
|
|
let message = match count {
|
|
1 => "Moved 1 photo to Trash".to_string(),
|
|
n => format!("Moved {n} photos to Trash"),
|
|
};
|
|
toast_action(&ui, &message, "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, "Couldn't move to Trash", &message, kind);
|
|
}
|
|
_ => {
|
|
restore_photos(&ui, removed.clone());
|
|
toast_error(
|
|
&ui,
|
|
"Couldn't move to Trash",
|
|
"The mount service didn't respond.",
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Take photos out of the loaded model, returning them so a failure can put them
|
|
/// back.
|
|
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.
|
|
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;
|
|
}
|
|
};
|
|
// A thumbnail is the first look anyone gets at the photo's shape.
|
|
// If the row was laid out without it, remember it and re-flow — the
|
|
// debounce means a batch of decodes costs one re-flow, not one
|
|
// each.
|
|
learn_ratio(&ui, &uid, &texture);
|
|
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
|
|
});
|
|
}
|
|
|
|
/// Record the aspect ratio a decoded thumbnail proves, and re-flow if the layout
|
|
/// had been guessing.
|
|
///
|
|
/// Only a photo the layout had to guess about is worth a re-flow: re-recording
|
|
/// what the daemon already told us would re-flow the timeline on every scroll.
|
|
fn learn_ratio(ui: &Rc<Ui>, uid: &str, texture: >k4::gdk::Texture) {
|
|
let (width, height) = (texture.width(), texture.height());
|
|
if width <= 0 || height <= 0 {
|
|
return;
|
|
}
|
|
ui.gallery
|
|
.learned_ratios
|
|
.borrow_mut()
|
|
.insert(uid.to_string(), width as f64 / height as f64);
|
|
if ui.gallery.assumed_ratios.borrow_mut().remove(uid) {
|
|
schedule_relayout(ui);
|
|
}
|
|
}
|
|
|
|
/// 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 realised row's first photo is remembered and scrolled back to.
|
|
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
|
let anchor: Option<String> = ui.gallery.bound.borrow().values().flatten().next().cloned();
|
|
repaint_gallery(ui);
|
|
let Some(anchor) = anchor else { return };
|
|
let Some(row) = row_of_photo(&ui.gallery.groups, &anchor) else {
|
|
return;
|
|
};
|
|
ui.gallery
|
|
.list
|
|
.scroll_to(row, gtk4::ListScrollFlags::empty(), None);
|
|
}
|
|
|
|
/// 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::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();
|
|
for group in group_photos(&ui.gallery.model) {
|
|
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::Heading(heading) => GalleryRow::Heading(heading.clone()),
|
|
GalleryRow::Tiles { tiles, last } => GalleryRow::Tiles {
|
|
tiles: tiles.clone(),
|
|
last: *last,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// "1,204 photos" under the page title.
|
|
fn update_gallery_subtitle(ui: &Rc<Ui>) {
|
|
let loaded = ui.gallery.model.n_items() as usize;
|
|
ui.gallery.subtitle.set_visible(loaded > 0);
|
|
// An album counts what the server says it holds, not how much of it has been
|
|
// paged in — the subtitle would otherwise climb as the user scrolls.
|
|
if ui.gallery.album.borrow().is_some() {
|
|
return;
|
|
}
|
|
// The noun tracks the active filter, so a Videos tab doesn't count "photos".
|
|
let kind = ui.gallery.kind.get();
|
|
let (one, many) = match kind {
|
|
Some(PhotoKind::Video) => ("video", "videos"),
|
|
Some(PhotoKind::Raw) => ("raw photo", "raw photos"),
|
|
_ => ("photo", "photos"),
|
|
};
|
|
// 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 is the exception: there the window is the subject.
|
|
let total = match (ui.gallery.range.get(), ui.gallery.counts.get()) {
|
|
(None, Some((photos, videos, raw))) => match kind {
|
|
Some(PhotoKind::Photo) => photos,
|
|
Some(PhotoKind::Video) => videos,
|
|
Some(PhotoKind::Raw) => raw,
|
|
None => photos + videos + raw,
|
|
},
|
|
_ => loaded,
|
|
};
|
|
ui.gallery.subtitle.set_label(&match total {
|
|
1 => format!("1 {one}"),
|
|
n => format!("{} {many}", thousands(n)),
|
|
});
|
|
}
|
|
|
|
pub(crate) fn group_photos(model: &gio::ListStore) -> 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 = day_heading(photo.capture_time);
|
|
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.
|
|
pub(crate) fn day_heading(secs: i64) -> String {
|
|
let Ok(date) = glib::DateTime::from_unix_local(secs) else {
|
|
return "Unknown date".into();
|
|
};
|
|
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 "Today".into();
|
|
}
|
|
if let Ok(yesterday) = glib::DateTime::from_unix_local(now.to_unix() - 86_400)
|
|
&& same_day(&yesterday)
|
|
{
|
|
return "Yesterday".into();
|
|
}
|
|
}
|
|
date.format("%-d %B %Y")
|
|
.map(|s| s.to_string())
|
|
.unwrap_or_else(|_| "Unknown date".into())
|
|
}
|
|
|
|
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);
|
|
match date {
|
|
Ok(d) => match d.format("%Y-%m-%d %H:%M:%S") {
|
|
Ok(s) => s.to_string(),
|
|
Err(_) => "Unknown Date".to_string(),
|
|
},
|
|
Err(_) => "Unknown Date".to_string(),
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
glib::DateTime::from_unix_local(secs)
|
|
.and_then(|d| d.format("%H:%M"))
|
|
.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.
|
|
pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|
if ui.gallery.loading.get() {
|
|
return;
|
|
}
|
|
// An open album 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());
|
|
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",
|
|
"Loading photos…",
|
|
"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 album.is_none() && ui.gallery.range.get().is_none() {
|
|
refresh_photo_months(ui);
|
|
}
|
|
}
|
|
let offset = ui.gallery.model.n_items() as usize;
|
|
ui.gallery.loading.set(true);
|
|
ui.gallery.more.set_sensitive(false);
|
|
|
|
ui.busy_begin();
|
|
let request = match album {
|
|
Some(uid) => Request::AlbumPhotos {
|
|
uid,
|
|
offset,
|
|
limit: PHOTOS_PAGE,
|
|
},
|
|
None => Request::PhotosTimeline {
|
|
offset,
|
|
limit: PHOTOS_PAGE,
|
|
kind: ui.gallery.kind.get(),
|
|
range: ui.gallery.range.get(),
|
|
favorites: ui.gallery.favorites.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.more.set_sensitive(true);
|
|
match result {
|
|
Ok(Ok(Response::Photos {
|
|
available,
|
|
items,
|
|
counts,
|
|
})) => {
|
|
if !available {
|
|
gallery_status(
|
|
&ui,
|
|
"image-missing-symbolic",
|
|
"No photo library",
|
|
"This Proton account doesn't have Photos enabled.",
|
|
false,
|
|
);
|
|
return;
|
|
}
|
|
// 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 (title, description) = if ui.gallery.album.borrow().is_some() {
|
|
("Empty album", "This album has no photos in it.")
|
|
} else {
|
|
(
|
|
"No photos yet",
|
|
"Photos you upload to Proton Drive appear here.",
|
|
)
|
|
};
|
|
gallery_status(&ui, "image-x-generic-symbolic", title, description, false);
|
|
// Only the whole-timeline empty state: an empty *album* is
|
|
// filled by adding existing photos to it, not by uploading
|
|
// into it, so the buttons would point the wrong way.
|
|
ui.gallery
|
|
.empty_actions
|
|
.set_visible(ui.gallery.album.borrow().is_none());
|
|
return;
|
|
}
|
|
ui.gallery.content.set_visible_child_name("timeline");
|
|
// Offer "Load more" only when the page came back full.
|
|
ui.gallery.more.set_visible(items.len() == PHOTOS_PAGE);
|
|
}
|
|
// 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, "Couldn't load more photos", &message)
|
|
}
|
|
Ok(Ok(Response::Error { message, .. })) => gallery_status(
|
|
&ui,
|
|
"dialog-warning-symbolic",
|
|
"Couldn't load photos",
|
|
&message,
|
|
false,
|
|
),
|
|
Ok(Ok(_)) => gallery_status(
|
|
&ui,
|
|
"dialog-warning-symbolic",
|
|
"Couldn't load photos",
|
|
"Unexpected reply from the mount service.",
|
|
false,
|
|
),
|
|
Ok(Err(_)) | Err(_) if append => toast_error(
|
|
&ui,
|
|
"Couldn't load more photos",
|
|
"The mount 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(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.more.set_visible(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>) {
|
|
if service::is_failed() || !service::is_active() {
|
|
gallery_status(
|
|
ui,
|
|
"network-offline-symbolic",
|
|
"Not connected",
|
|
"The Proton Drive mount service isn't running.",
|
|
true,
|
|
);
|
|
return;
|
|
}
|
|
gallery_status(
|
|
ui,
|
|
"folder-remote-symbolic",
|
|
"Connecting…",
|
|
"Waiting for the Proton Drive mount service to come up.",
|
|
false,
|
|
);
|
|
let ui = ui.clone();
|
|
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
|
if ui.stack.visible_child_name().as_deref() == Some("gallery") {
|
|
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) {
|
|
if Command::new("mpv").arg(path).spawn().is_ok() {
|
|
return;
|
|
}
|
|
open_path(path);
|
|
}
|
|
|
|
/// Download a Photos-library video, then hand it to an external player. Unlike a
|
|
/// still photo — which the in-app lightbox can render — a video needs a real
|
|
/// player, and the photos volume isn't part of the FUSE mount, so there is no
|
|
/// path to stream it from: [`Request::OpenPhoto`] fetches the whole file into the
|
|
/// cache (served straight from there on a repeat) and we launch the player on it.
|
|
///
|
|
/// For large videos kept in an on-demand *drive* folder, streaming through the
|
|
/// mount is the better route — that is the file-browser "Play" action, not this.
|
|
pub(crate) fn play_video(ui: &Rc<Ui>, uid: String) {
|
|
toast(ui, "Preparing video…");
|
|
let rx = spawn_request(ui.dirs.control_socket(), Request::OpenPhoto { uid });
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
match rx.recv().await {
|
|
Ok(Ok(Response::FilePath { path })) => play_external(&path),
|
|
Ok(Ok(Response::Error { message, kind })) => {
|
|
toast_failure(&ui, "Couldn't open this video", &message, kind)
|
|
}
|
|
Ok(Ok(_)) => toast_error(
|
|
&ui,
|
|
"Couldn't open this video",
|
|
"Unexpected reply from the mount service.",
|
|
),
|
|
Ok(Err(_)) | Err(_) => toast_error(
|
|
&ui,
|
|
"Couldn't open this video",
|
|
"Couldn't reach Proton Drive.",
|
|
),
|
|
}
|
|
});
|
|
}
|