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, /// 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>, /// 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>, /// 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, /// 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>, /// 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>, /// 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>, /// 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>>, /// 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>, /// 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, /// 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, /// 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, /// 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, /// 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>, pub(crate) photo_tex_order: RefCell>, /// 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>, /// 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>, /// 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>, /// 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, /// The scroll offset the direction was last decided at. pub(crate) scroll_offset: Cell, pub(crate) thumb_inflight: Cell, /// 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>, pub(crate) decode_idle: Cell, /// 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>, pub(crate) relayout_source: RefCell>, /// 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>>, /// 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, /// The photos picked so far, by uid. pub(crate) selected: RefCell>, /// 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, } /// 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, /// 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 { 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::(); let groups = gio::ListStore::new::(); 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 { 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) { 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> = 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, 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, 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, list: >k4::ListView, scroll: >k4::ScrolledWindow, select_done: >k4::Button, ) { let factory = gtk4::SignalListItemFactory::new(); factory.connect_setup(|_, item| { let item = item.downcast_ref::().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::().unwrap(); let row_box = item.child().and_downcast::().unwrap(); let obj = item.item().and_downcast::().unwrap(); let row = obj.borrow::(); 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::().unwrap(); ui_unbind .gallery .bound .borrow_mut() .remove(&item.position()); if let Some(obj) = item.item().and_downcast::() && let GalleryRow::Tiles { tiles, .. } = &*obj.borrow::() { 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::(); filters.append(&filter); dialog.set_filters(Some(&filters)); let ui = ui_upload.clone(); let parent_win = ui.stack.root().and_downcast::(); 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, 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, photos: &[PhotoItem], width: i32) -> Vec> { let ratios: Vec = 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)> { 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 { let mut widths: Vec = 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::() + 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) -> 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, 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, 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, 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) { 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, 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::() else { continue; }; let photo = boxed.borrow::(); 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) { let uids: Vec = 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, uids: Vec) { 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 = 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, uids: &[String]) -> Vec { 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::() .map(|obj| obj.borrow::().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, photos: Vec) { 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::() .is_some_and(|obj| obj.borrow::().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, 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, uids: Vec) { 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) { 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) { if ui.gallery.thumb_inflight.get() { return; } let uids: Vec = { 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, mut batch: Vec) -> Vec { 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> = 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::() else { continue; }; let GalleryRow::Tiles { tiles, .. } = &*obj.borrow::() 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) { 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, 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) { 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) { let anchor: Option = 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 { (0..store.n_items()).find(|i| { store .item(*i) .and_downcast::() .is_some_and(|obj| match &*obj.borrow::() { 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, 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, 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) { 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::() .is_some_and(|old| row.same_as(&old.borrow::())); 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) -> Vec { 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) { 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 { let mut groups: Vec = Vec::new(); for i in 0..model.n_items() { let Some(obj) = model.item(i) else { continue }; let Some(boxed) = obj.downcast_ref::() else { continue; }; let photo = boxed.borrow::().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 { for i in 0..model.n_items() { if let Some(obj) = model.item(i) && let Some(boxed) = obj.downcast_ref::() && boxed.borrow::().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, 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, 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) { 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, 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.", ), } }); }