From 7cbcacc02eab57b54030a6937164caa7fdcf8763 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 26 Sep 2026 13:06:19 -0400 Subject: [PATCH] Export to an album instead of a folder in the settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export took a path typed into the settings page, or a folder inside the library on the server. The first is how exports end up somewhere nobody looks; the second put JPEGs into the tree a scan catalogues, where they came back as photographs beside the RAWs they were made from. The destination is now an album (FR-EXP-10), chosen by name in the export sheet. Albums are listed under the collections in the sidebar; "+" there, or "New album…" in the sheet, opens a sheet for its name and its folder — on this device through the platform's dialogue, or on the server through the browser with "New folder". A server folder inside the library is refused, and the sheet says why. Selecting an album narrows the grid to the photographs behind its files: library::Scope is Collection or Album, and scope_clause is the one place the two are spelled, which also retires the two copies of the collection predicate total_images_scoped and read_cells_scoped had inlined. A batch resolves the album when it starts, and refuses in words when none is chosen, it has gone, or its folder is local to another device. Each item reports the image it came from, and the files written are recorded against the album in one transaction when the batch ends. A server album lives outside the library, so its queued uploads are relative to the account root. That is a third line in the outbox's .dest record rather than a leading slash, because a record written before albums may carry a stray slash and must keep the meaning it was written with. An export folder set before albums becomes an album called "Exports" on first open, so upgrading does not lose where exports were going. The old destination fields stay in ExportSettings so older settings files still read. --- core/dr-types/src/settings.rs | 13 + ui/dr-ui/src/albums_ui.rs | 763 ++++++++++++++++++++++ ui/dr-ui/src/collections_ui/controller.rs | 15 + ui/dr-ui/src/collections_ui/tree_sync.rs | 5 + ui/dr-ui/src/develop_ui.rs | 73 ++- ui/dr-ui/src/export.rs | 129 +++- ui/dr-ui/src/lib.rs | 165 ++--- ui/dr-ui/src/library/cells.rs | 36 +- ui/dr-ui/src/library/mod.rs | 12 + ui/dr-ui/src/library/spans.rs | 155 +++-- ui/dr-ui/src/library_ui/controller.rs | 61 +- ui/dr-ui/src/library_ui/timeline.rs | 6 +- ui/dr-ui/src/library_ui/window.rs | 20 +- ui/dr-ui/src/settings_ui.rs | 248 +------ ui/dr-ui/ui/albums.slint | 480 ++++++++++++++ ui/dr-ui/ui/app.slint | 19 +- ui/dr-ui/ui/collections.slint | 14 + ui/dr-ui/ui/export.slint | 208 +----- ui/dr-ui/ui/library.slint | 7 +- 19 files changed, 1757 insertions(+), 672 deletions(-) create mode 100644 ui/dr-ui/src/albums_ui.rs create mode 100644 ui/dr-ui/ui/albums.slint diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs index d57d795..156e3a1 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -727,6 +727,18 @@ pub struct ExportSettings { /// [`Self::destination`], where empty means "ask each time" because there /// is no sensible folder to assume on a filesystem. pub remote_destination: String, + + /// TRACES: FR-EXP-10 + /// The album exports go to, by its catalog uuid. Empty until one is + /// chosen, and an export with none is refused and says so. + /// + /// This replaced [`Self::target`] and the two destination fields as what + /// the export sheet chooses: a destination is now a named album in the + /// catalog, which records what was exported into it. The three older + /// fields stay so a settings file written before albums still reads, and + /// so the first album can be made from the folder they named; the batch + /// fills them from the album when it runs. + pub album: String, } impl Default for ExportSettings { @@ -748,6 +760,7 @@ impl Default for ExportSettings { target: ExportTarget::default(), destination: String::new(), remote_destination: String::new(), + album: String::new(), } } } diff --git a/ui/dr-ui/src/albums_ui.rs b/ui/dr-ui/src/albums_ui.rs new file mode 100644 index 0000000..03baea0 --- /dev/null +++ b/ui/dr-ui/src/albums_ui.rs @@ -0,0 +1,763 @@ +//! TRACES: FR-EXP-10 | FR-EXP-6 +//! Albums in the window: the sidebar section, the sheet that makes and edits +//! one, and the album the export sheet sends to. +//! +//! The catalog half is [`dr_catalog::albums`]. This half turns an album into +//! somewhere a batch can write — a folder on this device, or a folder on the +//! server reached through the outbox — and records what the batch wrote, so +//! the album can show the originals behind its files. +//! +//! # Why an album and not a destination field +//! +//! Export used to take a path typed into the settings page, or a folder +//! inside the library on the server. The first is how a destination silently +//! becomes a new folder nobody meant; the second put JPEGs inside the tree a +//! scan catalogues, where they came back as photographs of their own. An +//! album is chosen once, by pointing — the platform's folder dialogue, or the +//! server browser here, both able to make a folder — and outside the +//! library. After that, exporting is picking a name. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use dr_catalog::albums::{self, Album, AlbumId, Place}; +use dr_types::{ExportTarget, ImageId}; +use slint::{ComponentHandle, ModelRc, SharedString, VecModel}; + +use crate::library_ui::LibraryController; +use crate::settings_ui::SettingsController; +use crate::{AlbumRow, Albums, AppWindow, Collections, ExportOptions}; + +/// `collection-selected` while an album, rather than a collection, the whole +/// library or the trash, scopes the grid. The sidebar has one selection; the +/// album itself is in `Albums.selected`. +pub const ALBUM_SCOPE: i32 = -2; + +/// Where a sheet is putting the album's files. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Where { + Device, + Server, +} + +/// The sheet, while it is open. +struct Sheet { + /// `None` for a new album. + editing: Option, + name: String, + at: Where, + /// A folder chosen on this device: a path, or on Android a SAF tree. + device_folder: String, + /// The server folder being browsed, which is the one chosen. + browser: crate::launch::FolderBrowser, + error: String, + confirming_delete: bool, +} + +pub struct AlbumsController { + library: Rc, + settings: Rc, + /// The live albums, as last read, in the order they are drawn. + rows: RefCell>, + /// The album scoping the grid. + selected: Cell>, + sheet: RefCell>, +} + +impl AlbumsController { + pub fn new(library: Rc, settings: Rc) -> Rc { + Rc::new(Self { + library, + settings, + rows: RefCell::new(Vec::new()), + selected: Cell::new(None), + sheet: RefCell::new(None), + }) + } + + /// The album scoping the grid, if one is. + pub fn selected(&self) -> Option { + self.selected.get() + } + + /// Stop scoping the grid to an album — something else in the sidebar was + /// chosen. + pub fn deselect(&self, window: &AppWindow) { + if self.selected.replace(None).is_some() { + window.global::().set_selected(0); + } + } + + /// Where the export sheet's album writes, from this device: the album, + /// and the target and folder a batch understands. + /// + /// Refused, with a sentence to show, when no album is chosen, when it is + /// gone, or when its folder is local to another device. + pub fn export_destination(&self) -> Result<(AlbumId, ExportTarget, String), String> { + let uuid = self.settings.snapshot().export.album; + if uuid.is_empty() { + return Err("Choose an album to export to (Ctrl+E), or make one.".into()); + } + let catalog = self.library.catalog(); + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { + return Err("Open a library before exporting to an album.".into()); + }; + let conn = cat.connection(); + let album = albums::id_for_uuid(conn, &uuid) + .ok() + .flatten() + .and_then(|id| albums::get(conn, id).ok().flatten()) + .ok_or("The album exports went to is gone. Choose another (Ctrl+E).")?; + match album.place { + Some(Place::Local(folder)) => Ok((album.id, ExportTarget::Device, folder)), + // Spelled from `/`: relative to the account, not the library — + // see `export::Pending::remote_dir`. + Some(Place::Server(path)) => Ok((album.id, ExportTarget::Remote, format!("/{path}"))), + None => Err(format!( + "“{}” has no folder on this device yet. Choose one: open the album from the sidebar.", + album.name + )), + } + } + + /// Record what a batch wrote into an album, and redraw what counts it. + pub fn record(&self, window: &AppWindow, album: AlbumId, files: Vec<(ImageId, String)>) { + { + let catalog = self.library.catalog(); + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + if let Err(e) = albums::record_exports(cat.connection(), album, &files) { + log::warn!("recording {} exports in album {}: {e}", files.len(), album.0); + return; + } + } + refresh(window, self); + // The grid is showing this album: it has just gained photographs. + if self.selected.get() == Some(album) { + crate::library_ui::reload(window, &self.library); + } + } +} + +/// Re-read the albums and redraw everything that shows them: the sidebar +/// section and the export sheet's choice. +pub fn refresh(window: &AppWindow, ctl: &AlbumsController) { + let catalog = ctl.library.catalog(); + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + refresh_from(window, ctl, cat); +} + +/// [`refresh`], with the catalog already in hand — for the callers that hold +/// its borrow already. +pub fn refresh_from(window: &AppWindow, ctl: &AlbumsController, cat: &dr_catalog::Catalog) { + adopt_old_destination(ctl, cat); + match albums::list(cat.connection()) { + Ok(rows) => { + // An album deleted elsewhere, and merged away, cannot stay the + // scope: the grid would be narrowed to nothing under a name that + // is no longer in the list. + if let Some(id) = ctl.selected.get() { + if !rows.iter().any(|a| a.id == id) { + ctl.selected.set(None); + } + } + *ctl.rows.borrow_mut() = rows; + } + Err(e) => log::warn!("reading albums: {e}"), + } + render(window, ctl); +} + +/// A destination folder set before albums existed becomes the first album, +/// once, so upgrading does not lose where exports were going. +/// +/// Only a folder on this device. A server destination was inside the library, +/// which is exactly where an album may not be. +fn adopt_old_destination(ctl: &AlbumsController, cat: &dr_catalog::Catalog) { + let export = ctl.settings.snapshot().export; + if !export.album.is_empty() + || export.target != ExportTarget::Device + || export.destination.trim().is_empty() + { + return; + } + let conn = cat.connection(); + if !albums::list(conn).map(|a| a.is_empty()).unwrap_or(false) { + return; + } + let place = Place::Local(export.destination.trim().to_string()); + match albums::create(conn, "Exports", &place) + .and_then(|id| albums::get(conn, id)) + { + Ok(Some(album)) => { + log::info!("the export folder {} is now the album “Exports”", export.destination); + ctl.settings.edit(|s| s.export.album = album.uuid); + } + Ok(None) => {} + Err(e) => log::warn!("making an album of the old export folder: {e}"), + } +} + +fn render(window: &AppWindow, ctl: &AlbumsController) { + let rows = ctl.rows.borrow(); + + let sidebar: Vec = rows + .iter() + .map(|a| AlbumRow { + id: a.id.0 as i32, + name: a.name.as_str().into(), + count: a.sources as i32, + detail: describe(a.place.as_ref()).into(), + homeless: a.place.is_none(), + }) + .collect(); + let albums = window.global::(); + albums.set_rows(ModelRc::new(VecModel::from(sidebar))); + albums.set_selected(ctl.selected.get().map(|a| a.0 as i32).unwrap_or(0)); + + let chosen = ctl.settings.snapshot().export.album; + let index = rows.iter().position(|a| a.uuid == chosen); + let options = window.global::(); + options.set_album_labels(ModelRc::new(VecModel::from( + rows.iter() + .map(|a| SharedString::from(a.name.as_str())) + .collect::>(), + ))); + options.set_album_selected(index.map(|i| i as i32).unwrap_or(-1)); + options.set_album_detail( + match index { + Some(i) => describe(rows[i].place.as_ref()), + None if rows.is_empty() => String::new(), + None => "No album chosen: exports are refused until one is.".into(), + } + .into(), + ); + drop(rows); + crate::refresh_export_label(window); +} + +/// Where an album's files go, as the sidebar and the export sheet say it. +fn describe(place: Option<&Place>) -> String { + match place { + Some(Place::Local(folder)) => format!("On this device · {}", display_folder(folder)), + Some(Place::Server(path)) => format!("On the server · /{path}"), + None => "No folder on this device yet".into(), + } +} + +/// A local folder as a person reads it. A SAF tree URI is not a path, and +/// its last segment — `primary:Pictures/Web` — is the part worth showing. +fn display_folder(folder: &str) -> String { + if folder.starts_with("content://") { + let tail = folder.rsplit('/').next().unwrap_or(folder); + let decoded = tail.replace("%3A", ":").replace("%2F", "/").replace("%20", " "); + return decoded + .split_once(':') + .map(|(_, p)| p.to_string()) + .unwrap_or(decoded); + } + folder.to_string() +} + +fn render_sheet(window: &AppWindow, ctl: &AlbumsController) { + let albums = window.global::(); + let sheet = ctl.sheet.borrow(); + let Some(s) = sheet.as_ref() else { + albums.set_sheet_open(false); + return; + }; + let server = ctl.library.credentials().is_some_and(|c| !c.account.login.is_empty()); + let refusal = if s.at == Where::Server { + refusal(&s.browser.path, ctl) + } else { + String::new() + }; + let can_save = !s.name.trim().is_empty() + && match s.at { + Where::Device => !s.device_folder.is_empty(), + Where::Server => !s.browser.loading && refusal.is_empty(), + }; + + albums.set_sheet_open(true); + albums.set_sheet_editing(s.editing.is_some()); + albums.set_sheet_name(s.name.as_str().into()); + albums.set_server_available(server); + albums.set_sheet_where(if s.at == Where::Server { 1 } else { 0 }); + albums.set_sheet_device_folder(display_folder(&s.device_folder).into()); + albums.set_sheet_error(s.error.as_str().into()); + albums.set_sheet_confirming_delete(s.confirming_delete); + albums.set_sheet_can_save(can_save); + albums.set_browse_path(s.browser.path.as_str().into()); + albums.set_browse_entries(ModelRc::new(VecModel::from( + s.browser + .entries + .iter() + .map(|e| SharedString::from(e.as_str())) + .collect::>(), + ))); + albums.set_browse_loading(s.browser.loading); + albums.set_browse_at_root(s.browser.parent_path().is_none()); + albums.set_browse_refusal(refusal.into()); +} + +/// Why a server folder cannot hold an album, or empty when it can. +/// +/// Inside the library is refused: the next scan would catalogue every JPEG +/// exported there as a photograph, beside the RAW it was made from. +fn refusal(path: &str, ctl: &AlbumsController) -> String { + let Some(conn) = ctl.library.credentials() else { + return "Sign in to the library's server to choose a folder there.".into(); + }; + let root = conn.account.root.trim_matches('/'); + if inside(path, root) { + return if root.is_empty() { + "The whole account is the library here, so every folder on it is inside the library. Put this album on this device instead.".into() + } else { + format!("This is inside the library (/{root}), where exported files would be catalogued as photographs. Choose a folder outside it.") + }; + } + String::new() +} + +/// Whether `path` is `root` or beneath it. An empty root is the whole account. +fn inside(path: &str, root: &str) -> bool { + let path = path.trim_matches('/'); + root.is_empty() || path == root || path.starts_with(&format!("{root}/")) +} + +/// List the sheet's server folder, and redraw when the answer comes. +fn browse(weak: slint::Weak, ctl: Rc, path: String) { + let Some(conn) = ctl.library.credentials() else { return }; + if let Some(s) = ctl.sheet.borrow_mut().as_mut() { + s.browser.path = path.clone(); + s.browser.entries.clear(); + s.browser.loading = true; + s.error.clear(); + } + let arrived = { + let ctl = ctl.clone(); + let weak = weak.clone(); + let path = path.clone(); + move |result: Result, String>| settle(&weak, &ctl, &path, result) + }; + crate::remote_folders::list(conn, path, arrived); + if let Some(w) = weak.upgrade() { + render_sheet(&w, &ctl); + } +} + +/// A listing arrived for `path`. Ignored if the sheet has moved on: two quick +/// clicks must not leave the second folder showing the first one's children. +fn settle( + weak: &slint::Weak, + ctl: &Rc, + path: &str, + result: Result, String>, +) { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = ctl.sheet.borrow_mut().as_mut() { + if s.browser.path != path { + return; + } + s.browser.loading = false; + match result { + Ok(dirs) => s.browser.entries = dirs, + // The browser stays where it was: a listing that failed is not a + // reason to discard where the user had navigated to. + Err(e) => s.error = format!("Could not list folders: {e}"), + } + } + render_sheet(&w, ctl); +} + +/// Open the sheet — on a new album, or on `editing`. +fn open_sheet(window: &AppWindow, ctl: &Rc, editing: Option) { + let album = editing.and_then(|id| ctl.rows.borrow().iter().find(|a| a.id == id).cloned()); + let server = ctl.library.credentials().is_some_and(|c| !c.account.login.is_empty()); + let (at, device_folder, server_path) = match album.as_ref().and_then(|a| a.place.clone()) { + Some(Place::Local(f)) => (Where::Device, f, String::new()), + Some(Place::Server(p)) => (Where::Server, String::new(), p), + None => (Where::Device, String::new(), String::new()), + }; + *ctl.sheet.borrow_mut() = Some(Sheet { + editing, + name: album.as_ref().map(|a| a.name.clone()).unwrap_or_default(), + at: if server { at } else { Where::Device }, + device_folder, + browser: crate::launch::FolderBrowser { + path: server_path.clone(), + ..Default::default() + }, + error: String::new(), + confirming_delete: false, + }); + if server && at == Where::Server { + browse(window.as_weak(), ctl.clone(), server_path); + } + render_sheet(window, ctl); +} + +/// Make or update the album the sheet describes. +fn save(window: &AppWindow, ctl: &Rc) { + let (editing, name, place) = { + let sheet = ctl.sheet.borrow(); + let Some(s) = sheet.as_ref() else { return }; + let place = match s.at { + Where::Device => Place::Local(s.device_folder.clone()), + Where::Server => Place::Server(s.browser.path.trim_matches('/').to_string()), + }; + (s.editing, s.name.clone(), place) + }; + + let outcome = { + let catalog = ctl.library.catalog(); + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + let conn = cat.connection(); + match editing { + Some(id) => albums::rename(conn, id, &name) + .and_then(|()| { + let current = albums::get(conn, id)?.and_then(|a| a.place); + if current.as_ref() != Some(&place) { + albums::set_place(conn, id, &place)?; + } + Ok(id) + }), + None => albums::create(conn, &name, &place), + } + .and_then(|id| Ok((id, albums::get(conn, id)?))) + }; + + match outcome { + Ok((_, album)) => { + // A new album is the one the next export goes to: it was made to + // be exported into, often from the export sheet itself. + if editing.is_none() { + if let Some(album) = album.as_ref() { + ctl.settings.edit(|s| s.export.album = album.uuid.clone()); + } + } + *ctl.sheet.borrow_mut() = None; + refresh(window, ctl); + render_sheet(window, ctl); + // The grid's title is the album's name while it is open, and + // the name has just changed. + if let (Some(id), Some(album)) = (editing, album) { + if ctl.selected.get() == Some(id) { + window + .global::() + .set_collection_scope_label(album.name.into()); + } + } + } + Err(e) => { + if let Some(s) = ctl.sheet.borrow_mut().as_mut() { + s.error = e.to_string(); + } + render_sheet(window, ctl); + } + } +} + +/// Ask for a folder on this device, and put it in the sheet. +fn choose_device_folder(window: &AppWindow, ctl: &Rc) { + let start = ctl + .sheet + .borrow() + .as_ref() + .map(|s| s.device_folder.clone()) + .unwrap_or_default(); + let weak = window.as_weak(); + let ctl = ctl.clone(); + let chosen = move |folder: String| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = ctl.sheet.borrow_mut().as_mut() { + s.device_folder = folder; + s.error.clear(); + } + render_sheet(&w, &ctl); + }; + crate::folder_dialog::ask( + window, + "Folder for this album", + crate::folder_dialog::Pick::Folder, + Some(&start), + move |path| chosen(path.to_string_lossy().into_owned()), + ); +} + +pub fn wire( + window: &AppWindow, + ctl: Rc, + collections: Rc, +) { + let albums = window.global::(); + + // --- the sidebar -------------------------------------------------------- + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + albums.on_select(move |id| { + let Some(w) = weak.upgrade() else { return }; + let id = AlbumId(id.max(0) as u64); + let name = ctl + .rows + .borrow() + .iter() + .find(|a| a.id == id) + .map(|a| a.name.clone()) + .unwrap_or_default(); + ctl.selected.set(Some(id)); + collections.leave_for_album(); + w.set_collection_selected(ALBUM_SCOPE); + w.global::() + .set_collection_scope_label(name.into()); + w.global::().set_selected(id.0 as i32); + ctl.library.set_album(Some(id)); + crate::library_ui::reload(&w, &ctl.library); + }); + } + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + albums.on_create(move || { + let Some(w) = weak.upgrade() else { return }; + open_sheet(&w, &ctl, None); + }); + } + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + albums.on_edit(move |id| { + let Some(w) = weak.upgrade() else { return }; + open_sheet(&w, &ctl, Some(AlbumId(id.max(0) as u64))); + }); + } + + // --- the sheet ------------------------------------------------------------ + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + albums.on_sheet_name_edited(move |text| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = ctl.sheet.borrow_mut().as_mut() { + s.name = text.to_string(); + } + render_sheet(&w, &ctl); + }); + } + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + albums.on_sheet_where_picked(move |i| { + let Some(w) = weak.upgrade() else { return }; + let at = if i == 1 { Where::Server } else { Where::Device }; + let start = { + let mut sheet = ctl.sheet.borrow_mut(); + let Some(s) = sheet.as_mut() else { return }; + s.at = at; + s.error.clear(); + (at == Where::Server && s.browser.entries.is_empty() && !s.browser.loading) + .then(|| s.browser.path.clone()) + }; + if let Some(path) = start { + browse(w.as_weak(), ctl.clone(), path); + } + render_sheet(&w, &ctl); + }); + } + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + albums.on_sheet_choose_device_folder(move || { + let Some(w) = weak.upgrade() else { return }; + choose_device_folder(&w, &ctl); + }); + } + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + albums.on_sheet_save(move || { + let Some(w) = weak.upgrade() else { return }; + save(&w, &ctl); + }); + } + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + albums.on_sheet_cancel(move || { + let Some(w) = weak.upgrade() else { return }; + *ctl.sheet.borrow_mut() = None; + render_sheet(&w, &ctl); + }); + } + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + albums.on_sheet_delete(move || { + let Some(w) = weak.upgrade() else { return }; + let id = { + let mut sheet = ctl.sheet.borrow_mut(); + let Some(s) = sheet.as_mut() else { return }; + let Some(id) = s.editing else { return }; + // Asked twice: the second press is the one that deletes. + if !s.confirming_delete { + s.confirming_delete = true; + drop(sheet); + render_sheet(&w, &ctl); + return; + } + id + }; + let uuid = ctl + .rows + .borrow() + .iter() + .find(|a| a.id == id) + .map(|a| a.uuid.clone()); + let result = { + let catalog = ctl.library.catalog(); + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + albums::delete(cat.connection(), id) + }; + if let Err(e) = result { + if let Some(s) = ctl.sheet.borrow_mut().as_mut() { + s.error = e.to_string(); + } + render_sheet(&w, &ctl); + return; + } + if uuid.is_some_and(|u| u == ctl.settings.snapshot().export.album) { + ctl.settings.edit(|s| s.export.album.clear()); + } + *ctl.sheet.borrow_mut() = None; + render_sheet(&w, &ctl); + // The grid was showing the album just deleted: back to the whole + // library, the way deleting the scoped collection does. + if ctl.selected.get() == Some(id) { + w.invoke_collection_select(0); + } + refresh(&w, &ctl); + }); + } + + // --- the server browser --------------------------------------------------- + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + albums.on_browse_into(move |name| { + let path = ctl + .sheet + .borrow() + .as_ref() + .map(|s| s.browser.child_path(&name)); + if let Some(path) = path { + browse(weak.clone(), ctl.clone(), path); + } + }); + } + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + albums.on_browse_up(move || { + let path = ctl + .sheet + .borrow() + .as_ref() + .and_then(|s| s.browser.parent_path()); + if let Some(path) = path { + browse(weak.clone(), ctl.clone(), path); + } + }); + } + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + albums.on_browse_make(move |name| { + let Some(w) = weak.upgrade() else { return }; + let Some(conn) = ctl.library.credentials() else { return }; + let parent = { + let mut sheet = ctl.sheet.borrow_mut(); + let Some(s) = sheet.as_mut() else { return }; + s.browser.loading = true; + s.error.clear(); + s.browser.path.clone() + }; + render_sheet(&w, &ctl); + // Made, then walked into: a folder somebody has just named is the + // one they mean to use. + let name = name.trim().to_string(); + let (weak, ctl2) = (weak.clone(), ctl.clone()); + let child = name.clone(); + crate::remote_folders::make(conn, parent.clone(), name, move |result| { + match result { + Ok(_) => { + let path = if parent.is_empty() { + child + } else { + format!("{parent}/{child}") + }; + browse(weak, ctl2, path); + } + Err(e) => settle(&weak, &ctl2, &parent, Err(e)), + } + }); + }); + } + + // --- the export sheet's choice -------------------------------------------- + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window + .global::() + .on_album_picked(move |i| { + let Some(w) = weak.upgrade() else { return }; + let uuid = ctl + .rows + .borrow() + .get(i.max(0) as usize) + .map(|a| a.uuid.clone()); + if let Some(uuid) = uuid { + ctl.settings.edit(|s| s.export.album = uuid); + } + render(&w, &ctl); + }); + } + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.global::().on_album_new(move || { + let Some(w) = weak.upgrade() else { return }; + open_sheet(&w, &ctl, None); + }); + } +} + +#[cfg(test)] +mod tests { + use super::{display_folder, inside}; + + #[test] + fn a_folder_inside_the_library_is_refused_and_one_beside_it_is_not() { + assert!(inside("Photos", "Photos")); + assert!(inside("Photos/2026/Web", "Photos")); + assert!(!inside("PhotosWeb", "Photos"), "a sibling sharing a prefix"); + assert!(!inside("Shared/Web", "Photos")); + assert!(inside("Anything", ""), "an empty root is the whole account"); + } + + #[test] + fn a_saf_tree_reads_as_its_folder() { + assert_eq!( + display_folder( + "content://com.android.externalstorage.documents/tree/primary%3APictures%2FWeb" + ), + "Pictures/Web" + ); + assert_eq!(display_folder("/home/me/Web"), "/home/me/Web"); + } +} diff --git a/ui/dr-ui/src/collections_ui/controller.rs b/ui/dr-ui/src/collections_ui/controller.rs index 1f197cd..68517b2 100644 --- a/ui/dr-ui/src/collections_ui/controller.rs +++ b/ui/dr-ui/src/collections_ui/controller.rs @@ -126,6 +126,11 @@ pub struct CollectionsController { /// `scope` as a sentinel id would put that inversion inside a type that /// means "a collection". pub(super) viewing_trash: std::cell::Cell, + /// TRACES: FR-EXP-10 + /// The albums section below the tree, refreshed whenever the tree is: + /// every open, scan, sync and merge that can change collections can + /// change albums too. `None` until `lib.rs` wires it. + pub(crate) albums: RefCell>>, /// The live drag: what it carries. Empty means no drag. pub(super) dragging: RefCell>, /// The collection being dragged, when the drag is a tree rearrangement @@ -424,6 +429,16 @@ impl CollectionsController { /// Selection is by id and survives a window change, but a selection the /// user cannot see is a selection they will act on by accident. Clearing on /// a deliberate navigation is the safer of the two behaviours. + /// TRACES: FR-EXP-10 + /// An album was chosen in the sidebar: nothing of this panel scopes the + /// grid any more. The selection goes too, as it does on any scope change — + /// a selection the user cannot see is one they will act on by accident. + pub(crate) fn leave_for_album(&self) { + self.viewing_trash.set(false); + *self.scope.borrow_mut() = None; + self.clear_selection(); + } + pub fn clear_selection(&self) { self.selection.borrow_mut().clear(); *self.anchor.borrow_mut() = None; diff --git a/ui/dr-ui/src/collections_ui/tree_sync.rs b/ui/dr-ui/src/collections_ui/tree_sync.rs index e37f769..97f3138 100644 --- a/ui/dr-ui/src/collections_ui/tree_sync.rs +++ b/ui/dr-ui/src/collections_ui/tree_sync.rs @@ -24,6 +24,11 @@ use super::controller::CollectionsController; /// every ancestor — diffing that against the model would be more code than the /// query costs, and the tree is tens of rows, not thousands. pub fn refresh_tree(window: &AppWindow, ctl: &Rc, catalog: &Catalog) { + // The albums share the sidebar, and every caller of this is a moment + // they may have changed too — a merge brings both. + if let Some(albums) = ctl.albums.borrow().as_ref() { + crate::albums_ui::refresh_from(window, albums, catalog); + } let rows = match coll::tree(catalog.connection()) { Ok(r) => r, Err(e) => { diff --git a/ui/dr-ui/src/develop_ui.rs b/ui/dr-ui/src/develop_ui.rs index c901730..0af4ad9 100644 --- a/ui/dr-ui/src/develop_ui.rs +++ b/ui/dr-ui/src/develop_ui.rs @@ -211,6 +211,21 @@ fn wire_presets(window: &AppWindow, w: &DevelopWiring) { presets::render(window, &clipboard, &settings); } +/// An export that cannot start, said where the button that asked for it is, +/// and the button given back. +fn refuse(window: &AppWindow, to: export::Reporting, message: &str) { + match to { + export::Reporting::Develop => { + window.set_export_status(message.into()); + window.set_export_busy(false); + } + export::Reporting::Library => { + window.global::().set_library_status(message.into()); + window.global::().set_library_exporting(false); + } + } +} + /// --- export (FR-EXP-6, FR-EXP-7, FR-EXP-9) --------------------------- /// /// Two buttons, one worker. The develop view exports the image on screen and @@ -229,6 +244,7 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) { let collections = w.collections.clone(); let activity = w.activity.clone(); let gpu = w.gpu.clone(); + let open_image = w.open_image.clone(); // Replaced at each start, so the button always cancels the run it is // sitting on and a stale token cancels nothing. @@ -244,10 +260,37 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) { let gpu = gpu.clone(); let cancel = cancel.clone(); let drain = drain.clone(); + let collections = collections.clone(); Rc::new( - move |window: &AppWindow, sources: Vec, to: export::Reporting| { + move |window: &AppWindow, + sources: Vec, + images: Vec>, + to: export::Reporting| { let total = sources.len(); - let request = batch_request(sources, &settings, &library, gpu.as_ref()); + + // TRACES: FR-EXP-10 + // Where it goes is an album, resolved now rather than when + // the sheet was last touched: the album may have been + // deleted, merged away, or have no folder on this device. + // Refused before anything is rendered, in the words the + // photographer needs to fix it. + let albums = collections.albums.borrow().clone(); + let resolved = match albums.as_ref() { + Some(a) => a.export_destination(), + None => Err("Albums are not ready yet.".into()), + }; + let (album, target, folder) = match resolved { + Ok(found) => found, + Err(e) => { + refuse(window, to, &e); + return; + } + }; + + let mut request = batch_request(sources, &settings, &library, gpu.as_ref()); + request.settings.target = target; + request.settings.destination = folder; + request.images = images; let token = export::Cancel::default(); *cancel.borrow_mut() = token.clone(); @@ -261,6 +304,7 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) { // "Queued for Exports" sitting unchanged until somebody // presses Sync reads as an export that did not upload. let library_for_drain = library.clone(); + let weak = window.as_weak(); export::drain_batch( window.as_weak(), &activity, @@ -268,7 +312,14 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) { rx, total, to, - move || drain_outbox(&library_for_drain), + move |written| { + drain_outbox(&library_for_drain); + // What the album now holds, and which photograph + // each file came from. + if let (Some(albums), Some(w)) = (albums.as_ref(), weak.upgrade()) { + albums.record(&w, album, written); + } + }, ); }, ) @@ -278,6 +329,8 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) { let weak = window.as_weak(); let session = session.clone(); let settings = settings.clone(); + let library = library.clone(); + let open_image = open_image.clone(); let start = start.clone(); window.on_export_image(move || { let Some(w) = weak.upgrade() else { return }; @@ -295,14 +348,20 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) { // would never be drawn in the "Rendering…" state at all. w.set_export_status("Rendering…".into()); + // The photograph in the catalog, so the album can link its file + // back to it. A file opened from the command line has none. + let image = match &*open_image.borrow() { + presets::Stored::Remote { path, .. } => library.image_id_for_path(path), + _ => None, + }; match render_open_frame(&w, &session, settings.snapshot().export.colour_space) { - Ok(source) => start(&w, vec![source], export::Reporting::Develop), + Ok(source) => start(&w, vec![source], vec![image], export::Reporting::Develop), Err(e) => { w.set_export_status(format!("Export failed: {e}").into()); w.set_export_busy(false); } } - refresh_export_label(&w, &settings); + refresh_export_label(&w); }); } @@ -329,7 +388,7 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) { return; } - let sources = library.export_sources(&collections.selected()); + let (sources, images) = library.export_sources_with_ids(&collections.selected()); if sources.is_empty() { // Said out loud rather than ignored, matching what a paste // or a judgement keystroke does with an empty selection. @@ -341,7 +400,7 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) { w.global::().set_library_exporting(true); w.global::() .set_library_status(format!("Exporting {} images…", sources.len()).into()); - start(&w, sources, export::Reporting::Library); + start(&w, sources, images, export::Reporting::Library); }); } diff --git a/ui/dr-ui/src/export.rs b/ui/dr-ui/src/export.rs index 2032269..700fa07 100644 --- a/ui/dr-ui/src/export.rs +++ b/ui/dr-ui/src/export.rs @@ -96,6 +96,13 @@ pub struct Pending { pub local: PathBuf, /// Remote folder, relative to the library root. Empty means the root. pub remote_dir: String, + /// TRACES: FR-EXP-10 + /// `remote_dir` is relative to the *account* root instead: an album on + /// the server lives outside the library, where a scan would not + /// catalogue its JPEGs as photographs. A third line in the record, so a + /// record written before albums — two lines, a stray `/` and all — keeps + /// the meaning it was written with. + pub account: bool, /// The filename to give it there. pub name: String, } @@ -104,7 +111,7 @@ impl Pending { /// Full remote path for this entry, under `root`. fn remote_path(&self, root: &str) -> RemotePath { let mut parts: Vec<&str> = Vec::new(); - for segment in [root, self.remote_dir.as_str()] { + for segment in [self.base(root), self.remote_dir.as_str()] { for part in segment.split('/') { if !part.is_empty() { parts.push(part); @@ -118,7 +125,7 @@ impl Pending { /// The folder this entry's file belongs in, as a remote path. fn remote_folder(&self, root: &str) -> RemotePath { let mut parts: Vec<&str> = Vec::new(); - for segment in [root, self.remote_dir.as_str()] { + for segment in [self.base(root), self.remote_dir.as_str()] { for part in segment.split('/') { if !part.is_empty() { parts.push(part); @@ -127,6 +134,15 @@ impl Pending { } RemotePath::new(parts.join("/")) } + + /// What `remote_dir` is relative to: the library root, or the account's. + fn base<'a>(&self, root: &'a str) -> &'a str { + if self.account { + "" + } else { + root + } + } } /// Where an export was put, for the interface to report. @@ -134,11 +150,27 @@ impl Pending { pub enum Placed { /// Written straight to a folder on this device. Device(PathBuf), - /// Staged locally, awaiting upload to the named remote folder. - Queued { local: PathBuf, remote_dir: String }, + /// Staged locally, awaiting upload to the named remote folder as `name`. + /// The staged copy may be called something else — see [`stage`]. + Queued { + local: PathBuf, + remote_dir: String, + name: String, + }, } impl Placed { + /// The file's name where it was asked to go — what an album records. + pub fn file_name(&self) -> String { + match self { + Placed::Device(path) => path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(), + Placed::Queued { name, .. } => name.clone(), + } + } + /// A sentence for the status line. pub fn describe(&self) -> String { match self { @@ -156,7 +188,7 @@ impl Placed { let dir = if remote_dir.is_empty() { "the library root".to_string() } else { - remote_dir.clone() + remote_dir.trim_start_matches('/').to_string() }; format!("Queued for {dir}") } @@ -179,7 +211,7 @@ pub fn place( match target { ExportTarget::Device => { if destination.trim().is_empty() { - return Err("No export folder is set. Choose one in Settings.".into()); + return Err("No export folder is set. Choose an album to export to.".into()); } let dir = PathBuf::from(destination); std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; @@ -193,6 +225,7 @@ pub fn place( Ok(Placed::Queued { local, remote_dir: destination.to_string(), + name: encoded.name.clone(), }) } } @@ -235,7 +268,13 @@ fn stage(encoded: &Encoded, remote_dir: &str, outbox: &Path) -> Result format!("{dir}\n{}\naccount\n", encoded.name), + None => format!("{remote_dir}\n{}\n", encoded.name), + }; + std::fs::write(&record, text) .map_err(|e| format!("{}: {e}", record.display()))?; Ok(candidate) @@ -280,6 +319,7 @@ pub fn pending(outbox: &Path) -> Vec { let mut lines = text.lines(); let remote_dir = lines.next().unwrap_or("").to_string(); let name = lines.next().unwrap_or("").to_string(); + let account = lines.next() == Some("account"); if name.is_empty() { continue; } @@ -287,6 +327,7 @@ pub fn pending(outbox: &Path) -> Vec { local, remote_dir, name, + account, }); } // Stable order so a drain is reproducible and a stuck entry is obvious @@ -505,6 +546,11 @@ impl Source { /// catalog. Passing the finished answers means the worker borrows nothing. pub struct BatchRequest { pub sources: Vec, + /// TRACES: FR-EXP-10 + /// The catalog image behind each source, by position, where there is one + /// — what an album records its files against. Shorter than `sources`, or + /// empty, where the caller does not know. + pub images: Vec>, /// Credentials for the account the library is open on. `None` where no /// library is open, which is fine for a [`Source::Rendered`] and fatal for /// anything that has to be fetched. @@ -567,6 +613,8 @@ pub enum BatchMessage { /// One image is finished, for better or worse. Item { name: String, + /// The catalog image it came from, where the request said. + image: Option, outcome: Result, }, Finished { @@ -625,7 +673,12 @@ fn run(mut request: BatchRequest, cancel: &Cancel, tx: &Sender) { Ok(_) => exported += 1, Err(_) => failed += 1, } - let _ = tx.send(BatchMessage::Item { name, outcome }); + let image = request.images.get(i).copied().flatten(); + let _ = tx.send(BatchMessage::Item { + name, + image, + outcome, + }); } let _ = tx.send(BatchMessage::Finished { @@ -1034,11 +1087,12 @@ pub fn drain_batch( rx: Receiver, total: usize, reporting: Reporting, - // Run when the batch finishes having written something. A queued export - // is finished on disk but not where the user asked for it, and waiting - // for the next sync pass to notice reads — correctly — as an export that - // did not upload. - on_exported: impl Fn() + 'static, + // Run when the batch finishes having written something, with each file + // it wrote and the image that file came from. A queued export is finished + // on disk but not where the user asked for it, and waiting for the next + // sync pass to notice reads — correctly — as an export that did not + // upload; the files are what an album records (FR-EXP-10). + on_exported: impl Fn(Vec<(dr_types::ImageId, String)>) + 'static, ) { let job = activity.begin( crate::activity::Kind::Export, @@ -1054,6 +1108,7 @@ pub fn drain_batch( // every image failed for the same reason should say that reason once, and // the rest are in the log. let mut first_failure: Option = None; + let mut written: Vec<(dr_types::ImageId, String)> = Vec::new(); let timer = slint::Timer::default(); let held = slot.clone(); @@ -1085,8 +1140,17 @@ pub fn drain_batch( &format!("Exporting {name} ({}/{total})", done + 1), ); } - BatchMessage::Item { name, outcome } => match outcome { - Ok(placed) => log::info!("{name}: {}", placed.describe()), + BatchMessage::Item { + name, + image, + outcome, + } => match outcome { + Ok(placed) => { + log::info!("{name}: {}", placed.describe()); + if let Some(image) = image { + written.push((image, placed.file_name())); + } + } Err(e) => { // Every failure is logged, not only the first: the // summary is a sentence and this is the record of which @@ -1109,7 +1173,7 @@ pub fn drain_batch( } settle(&w, reporting, &text); if exported > 0 { - on_exported(); + on_exported(std::mem::take(&mut written)); } stop_timer(&held); return; @@ -1231,7 +1295,8 @@ mod tests { // wherever the app happened to be launched from. let dir = tmp(); let err = place(&encoded("a.jpg", b"x"), ExportTarget::Device, " ", &dir).unwrap_err(); - assert!(err.contains("Settings"), "unhelpful message: {err}"); + // Says what to do about it: the destination is an album now. + assert!(err.contains("album"), "unhelpful message: {err}"); } #[test] @@ -1249,7 +1314,9 @@ mod tests { .unwrap(); match placed { - Placed::Queued { local, remote_dir } => { + Placed::Queued { + local, remote_dir, .. + } => { assert_eq!(std::fs::read(&local).unwrap(), b"hello"); assert_eq!(remote_dir, "Exports/2026"); } @@ -1340,6 +1407,7 @@ mod tests { local: PathBuf::from("/tmp/a.jpg"), remote_dir: "Exports/2026".into(), name: "a.jpg".into(), + account: false, }; assert_eq!( entry.remote_path("Photos").as_str(), @@ -1359,6 +1427,7 @@ mod tests { local: PathBuf::from("/tmp/a.jpg"), remote_dir: String::new(), name: "a.jpg".into(), + account: false, }; assert_eq!(entry.remote_path("Photos").as_str(), "Photos/a.jpg"); } @@ -1371,6 +1440,7 @@ mod tests { local: PathBuf::from("/tmp/a.jpg"), remote_dir: "/Exports/".into(), name: "a.jpg".into(), + account: false, }; assert_eq!( entry.remote_path("/Photos/").as_str(), @@ -1378,6 +1448,27 @@ mod tests { ); } + #[test] + fn an_album_on_the_server_is_reached_from_the_account_not_the_library() { + // FR-EXP-10: an album lives outside the library, so its folder is + // spelled from the account root and the library root is not put in + // front of it — through the record on disk, as a drain after a + // restart would read it. + let dir = std::env::temp_dir().join(format!( + "dr-export-album-outbox-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + place(&encoded("a.jpg", b"x"), ExportTarget::Remote, "/Shared/Web", &dir).unwrap(); + + let entry = pending(&dir).pop().expect("one staged export"); + assert!(entry.account); + assert_eq!(entry.remote_path("Photos").as_str(), "Shared/Web/a.jpg"); + assert_eq!(entry.remote_folder("Photos").as_str(), "Shared/Web"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn the_status_line_never_claims_an_upload_that_has_not_happened() { // A queued export is real and finished, but it is not on the server, @@ -1385,6 +1476,7 @@ mod tests { let queued = Placed::Queued { local: PathBuf::from("/tmp/a.jpg"), remote_dir: "Exports".into(), + name: "a.jpg".into(), }; let text = queued.describe(); assert!(text.contains("Queued"), "{text}"); @@ -1412,6 +1504,7 @@ mod tests { fn request(settings: ExportSettings, sources: Vec) -> BatchRequest { BatchRequest { sources, + images: Vec::new(), conn: None, settings, outbox: std::env::temp_dir().join("dr-batch-test-outbox"), diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index d04459a..4e701f8 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -20,6 +20,7 @@ //! in `ui/` names an operation or knows a shader exists (FR-DEV-3a). mod activity; +mod albums_ui; #[cfg(all(feature = "automation", unix))] mod automation; mod bursts; @@ -703,6 +704,7 @@ fn batch_request( export::BatchRequest { sources, + images: Vec::new(), conn: library.credentials(), settings: stored.export, outbox: match library.session() { @@ -766,23 +768,28 @@ fn drain_outbox(library: &Rc) { /// The label carries the destination because the button is the only place the /// distinction is visible from: "Export" alone gives no hint whether the file /// lands on this device or is queued for a server that may be unreachable. -fn refresh_export_label(window: &AppWindow, settings: &Rc) { - let stored = settings.snapshot(); - let remote = stored.export.target == dr_types::ExportTarget::Remote; +pub(crate) fn refresh_export_label(window: &AppWindow) { + use slint::Model as _; + // The album the choice in `ExportOptions` points at, as `albums_ui` last + // drew it — the one place that knows both the settings and the catalog. + let options = window.global::(); + let album = usize::try_from(options.get_album_selected()) + .ok() + .and_then(|i| options.get_album_labels().row_data(i)) + .map(|s| s.to_string()) + .unwrap_or_default(); window.set_export_label( - if remote { - // Not the connector's name: this is a Nextcloud account for some - // libraries and a folder on a mount for others. - "Export to the library" + if album.is_empty() { + "Export".to_string() } else { - "Export" + format!("Export to {album}") } .into(), ); // The grid's button says the same thing about a selection, but it composes - // its own label around a count — so it is given the fact rather than the + // its own label around a count — so it is given the name rather than the // sentence (FR-EXP-7). - window.set_export_to_server(remote); + window.set_export_album(album.into()); } /// Push current parameter values back to the interface. @@ -1610,6 +1617,14 @@ fn construct_screens( // each save over the other. let settings = settings_ui::SettingsController::new(); + // TRACES: FR-EXP-10 + // Albums: the export folders under the collections. Held by the + // collections controller, which refreshes the sidebar they share, and + // reached through it by the export button. + let albums = albums_ui::AlbumsController::new(library.clone(), settings.clone()); + *collections.albums.borrow_mut() = Some(albums.clone()); + albums_ui::wire(window, albums.clone(), collections.clone()); + // TRACES: FR-PLAT-AND-5 // The thumbnail tier. Registered here, beside the thing it frees, so that // a controller which grows another cache is one line from offering it up. @@ -1697,6 +1712,16 @@ fn construct_screens( // survive the call. lib.set_scope(coll.scope()); lib.set_viewing_trash(coll.viewing_trash()); + // An album is a third kind of scope, beside the two + // above, and the sidebar still has one selection: + // anything but an album there leaves the album. + if let Some(albums) = coll.albums.borrow().as_ref() { + if w.get_collection_selected() == albums_ui::ALBUM_SCOPE { + lib.set_album(albums.selected()); + } else { + albums.deselect(&w); + } + } library_ui::reload(&w, &lib); }, move || lib_ids.visible_ids(), @@ -1946,7 +1971,7 @@ fn wire_settings_screen( settings_ui::render(window, &settings); // The export button carries its destination, so it has to be correct // before the first click rather than after the first settings edit. - refresh_export_label(window, &settings); + refresh_export_label(window); // Apply what is on disk before anything can use it. Without this the // controller's defaults stand until the user happens to open the @@ -1962,122 +1987,6 @@ fn wire_settings_screen( library.set_face_model_id(inference::model_id(stored.faces.detector)); } - // --- the export folder picker ---------------------------------------- - // - // Wired here rather than inside `settings_ui::wire` because listing a - // remote folder needs credentials, and the settings page deliberately - // holds no session — it is reachable before a library is opened and must - // not depend on one existing. - { - let weak = window.as_weak(); - let ctl = settings.clone(); - let library = library.clone(); - - // Every entry point needs the same three things, so they are fetched - // once here rather than at four call sites. - let start = move |ctl: &Rc, - weak: &slint::Weak, - library: &Rc, - path: String| { - match library.session() { - Some(conn) => { - settings_ui::spawn_folder_list(weak.clone(), ctl.clone(), conn, path); - } - None => { - // No account, so nothing to browse. Said plainly rather - // than left as an empty list, which would read as a server - // with no folders on it. - ctl.set_error("Sign in to a library before choosing a folder on it."); - ctl.browser.replace(None); - } - } - }; - - { - let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start); - window - .global::() - .on_browse_open_picker(move || { - let Some(w) = weak.upgrade() else { return }; - // Opens on the library root rather than on whatever the - // destination field happens to contain: a half-typed path - // would list nothing and look like a broken picker. - ctl.browser.replace(Some(launch::FolderBrowser { - path: String::new(), - entries: Vec::new(), - loading: true, - })); - start(&ctl, &weak, &library, String::new()); - settings_ui::render(&w, &ctl); - }); - } - - { - let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start); - window - .global::() - .on_browse_into(move |name| { - let Some(w) = weak.upgrade() else { return }; - let path = { - let mut browser = ctl.browser.borrow_mut(); - let Some(b) = browser.as_mut() else { return }; - let path = b.child_path(&name); - b.path = path.clone(); - b.entries.clear(); - b.loading = true; - path - }; - start(&ctl, &weak, &library, path); - settings_ui::render(&w, &ctl); - }); - } - - { - let (weak, ctl, library, start) = (weak.clone(), ctl.clone(), library.clone(), start); - window.global::().on_browse_up(move || { - let Some(w) = weak.upgrade() else { return }; - let path = { - let mut browser = ctl.browser.borrow_mut(); - let Some(b) = browser.as_mut() else { return }; - let Some(path) = b.parent_path() else { return }; - b.path = path.clone(); - b.entries.clear(); - b.loading = true; - path - }; - start(&ctl, &weak, &library, path); - settings_ui::render(&w, &ctl); - }); - } - - { - let (weak, ctl) = (weak.clone(), ctl.clone()); - window.global::().on_browse_confirm(move || { - let Some(w) = weak.upgrade() else { return }; - // The folder being *shown* is the one chosen, matching the - // library picker — so "use this one" means the same thing in - // both places rather than depending on a selection the list - // does not have. - let chosen = ctl.browser.borrow().as_ref().map(|b| b.path.clone()); - if let Some(path) = chosen { - ctl.set_destination(path); - } - ctl.browser.replace(None); - settings_ui::render(&w, &ctl); - refresh_export_label(&w, &ctl); - }); - } - - { - let (weak, ctl) = (weak.clone(), ctl.clone()); - window.global::().on_browse_cancel(move || { - let Some(w) = weak.upgrade() else { return }; - ctl.browser.replace(None); - settings_ui::render(&w, &ctl); - }); - } - } - let lib = library.clone(); let ctl = settings.clone(); let weak = window.as_weak(); @@ -2093,7 +2002,7 @@ fn wire_settings_screen( lib.set_fetch_ahead(s.cache.fetch_ahead); lib.set_write_xmp_sidecars(s.library.write_xmp_sidecars); if let Some(w) = weak.upgrade() { - refresh_export_label(&w, &ctl); + refresh_export_label(&w); } // The axis is cut into bars when the window is loaded, so a new diff --git a/ui/dr-ui/src/library/cells.rs b/ui/dr-ui/src/library/cells.rs index beffb01..4926811 100644 --- a/ui/dr-ui/src/library/cells.rs +++ b/ui/dr-ui/src/library/cells.rs @@ -2,6 +2,8 @@ //! for, trash listing, and the ordinal a single photograph sits at. use dr_catalog::Catalog; + +use super::Scope; #[cfg(test)] use dr_sync::RemotePath; #[cfg(test)] @@ -82,39 +84,29 @@ pub fn read_cells( /// positions are unrelated integers. pub fn read_cells_scoped( catalog: &Catalog, - scope: Option, + scope: Option, filter: &RatingFilter, offset: usize, limit: usize, ) -> Result, dr_catalog::CatalogError> { - let Some(scope) = scope else { + if scope.is_none() { return read_cells_all(catalog, filter, offset, limit); - }; + } - let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?; - // Placeholders are generated from the *count* of ids, never from user text. - let placeholders = std::iter::repeat_n("?", ids.len()) - .collect::>() - .join(","); + let (clause, mut params) = scope_clause(catalog, scope)?; let rated = filter.sql(); let folded = uncollapsed("i"); - let (order, order_params) = grid_order_for(catalog, Some(scope)); + let (order, order_params) = grid_order_for(catalog, scope); let sql = format!( "SELECT {CELL_COLUMNS} FROM images i - WHERE {VISIBLE}{rated}{folded} - AND i.id IN (SELECT image_id FROM collection_members - WHERE collection_id IN ({placeholders})) + WHERE {VISIBLE}{rated}{folded}{clause} {order} LIMIT ? OFFSET ?" ); - // Bound in the order the `?`s appear: the scope's ids in the WHERE, then + // Bound in the order the `?`s appear: the scope's in the WHERE, then // whatever the ORDER BY needs, then the window. - let mut params: Vec = ids - .iter() - .map(|c| rusqlite::types::Value::Integer(c.0 as i64)) - .collect(); params.extend(order_params); params.push(rusqlite::types::Value::Integer(limit as i64)); params.push(rusqlite::types::Value::Integer(offset as i64)); @@ -225,7 +217,7 @@ pub fn total_trashed(catalog: &Catalog) -> Result, + scope: Option, filter: &RatingFilter, trash: bool, first: usize, @@ -310,7 +302,7 @@ pub fn read_ids_span( /// narrows. Swapping the two silently looks up a collection by an image id. pub fn ordinal_of_path( catalog: &Catalog, - scope: Option, + scope: Option, filter: &RatingFilter, trash: bool, path: &str, @@ -669,18 +661,18 @@ mod tests { .unwrap(); let filter = RatingFilter::default(); - let cells = read_cells_scoped(&catalog, Some(coll), &filter, 0, 100).unwrap(); + let cells = read_cells_scoped(&catalog, Some(Scope::Collection(coll)), &filter, 0, 100).unwrap(); assert_eq!(cells.len(), 2); for (i, cell) in cells.iter().enumerate() { assert_eq!( - ordinal_of_path(&catalog, Some(coll), &filter, false, &cell.remote_path).unwrap(), + ordinal_of_path(&catalog, Some(Scope::Collection(coll)), &filter, false, &cell.remote_path).unwrap(), Some(i), "{} is drawn at row {i} of the collection", cell.remote_path ); } assert_eq!( - ordinal_of_path(&catalog, Some(coll), &filter, false, "2019/a.CR2").unwrap(), + ordinal_of_path(&catalog, Some(Scope::Collection(coll)), &filter, false, "2019/a.CR2").unwrap(), None, "not a member" ); diff --git a/ui/dr-ui/src/library/mod.rs b/ui/dr-ui/src/library/mod.rs index c876a80..0d43b8e 100644 --- a/ui/dr-ui/src/library/mod.rs +++ b/ui/dr-ui/src/library/mod.rs @@ -49,6 +49,18 @@ pub use thumbnails_fetch::*; pub use thumbnails_gen::*; pub use xmp::*; +/// What narrows the grid, when something does. +/// +/// A collection is a set of originals the photographer files; an album is the +/// photographs whose exports went into one folder. The grid reads either the +/// same way — see `spans::scope_clause`, the one place the difference is +/// spelled — and the whole library is `None`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scope { + Collection(dr_types::CollectionId), + Album(dr_catalog::AlbumId), +} + #[cfg(test)] pub(crate) mod test_support { //! Fixtures shared with the modules that build on this one's catalog diff --git a/ui/dr-ui/src/library/spans.rs b/ui/dr-ui/src/library/spans.rs index 38e104e..b00f624 100644 --- a/ui/dr-ui/src/library/spans.rs +++ b/ui/dr-ui/src/library/spans.rs @@ -3,6 +3,8 @@ use dr_catalog::Catalog; +use super::Scope; + use super::cells::uncollapsed; #[cfg(test)] use super::cells::{read_cells_all, read_cells_scoped, read_ids_span}; @@ -16,36 +18,26 @@ use super::filters::{RatingFilter, GRID_ORDER, VISIBLE}; /// the user scrolling through empty rows. pub fn total_images_scoped( catalog: &Catalog, - scope: Option, + scope: Option, filter: &RatingFilter, ) -> Result { - let Some(scope) = scope else { + if scope.is_none() { return total_images_filtered(catalog, filter); - }; - - let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?; - let placeholders = std::iter::repeat_n("?", ids.len()) - .collect::>() - .join(","); + } + let (clause, params) = scope_clause(catalog, scope)?; let rated = filter.sql(); // DISTINCT: an image in both a parent and a child is one photograph, and a // count that disagrees with the number of cells drawn is worse than either // number alone. // - // Counted through `images` rather than over `collection_members` alone, so + // Counted through `images` rather than over the membership table alone, so // `VISIBLE` applies — a trashed photograph is still a member row, and // counting it made the header claim images the grid would not draw. let folded = uncollapsed("i"); let sql = format!( "SELECT count(DISTINCT i.id) FROM images i - WHERE {VISIBLE}{rated}{folded} - AND i.id IN (SELECT image_id FROM collection_members - WHERE collection_id IN ({placeholders}))" + WHERE {VISIBLE}{rated}{folded}{clause}" ); - let params: Vec = ids - .iter() - .map(|c| rusqlite::types::Value::Integer(c.0 as i64)) - .collect(); let n: i64 = catalog .connection() @@ -162,9 +154,11 @@ pub fn reordered( /// `collection_members`' primary key, which is `(collection_id, image_id)`. pub(super) fn grid_order_for( catalog: &Catalog, - scope: Option, + scope: Option, ) -> (String, Vec) { - let Some(id) = scope else { + // An album has no order of its own to keep: it is a record of what was + // exported, and capture time is how its photographs read best. + let Some(Scope::Collection(id)) = scope else { return (GRID_ORDER.to_string(), Vec::new()); }; @@ -195,32 +189,47 @@ pub(super) fn grid_order_for( ) } -/// The SQL restricting a query to `scope` and its descendants, with the bound -/// parameters to go with it. +/// The SQL restricting a query to `scope`, with the bound parameters to go +/// with it: a collection and its descendants, or the photographs an album's +/// files were exported from. /// -/// Shared by the span and the histogram so the two cannot drift: an axis drawn -/// over one set of images and bars counted over another puts the bars in the -/// wrong place. +/// Every scoped reader goes through this, so the count, the cells, the span +/// and the histogram cannot drift: an axis drawn over one set of images and +/// bars counted over another puts the bars in the wrong place, and a header +/// count that disagrees with the cells drawn leaves empty rows to scroll. pub(super) fn scope_clause( catalog: &Catalog, - scope: Option, + scope: Option, ) -> Result<(String, Vec), dr_catalog::CatalogError> { - let Some(scope) = scope else { - return Ok((String::new(), Vec::new())); - }; - let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?; - let placeholders = std::iter::repeat_n("?", ids.len()) - .collect::>() - .join(","); - Ok(( - format!( - " AND i.id IN (SELECT image_id FROM collection_members - WHERE collection_id IN ({placeholders}))" - ), - ids.iter() - .map(|c| rusqlite::types::Value::Integer(c.0 as i64)) - .collect(), - )) + match scope { + None => Ok((String::new(), Vec::new())), + Some(Scope::Collection(scope)) => { + let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?; + // Placeholders are generated from the *count* of ids, never from + // user text. + let placeholders = std::iter::repeat_n("?", ids.len()) + .collect::>() + .join(","); + Ok(( + format!( + " AND i.id IN (SELECT image_id FROM collection_members + WHERE collection_id IN ({placeholders}))" + ), + ids.iter() + .map(|c| rusqlite::types::Value::Integer(c.0 as i64)) + .collect(), + )) + } + Some(Scope::Album(album)) => { + // The table exists only once an album has; an album scope with no + // table behind it has nothing in it, and the query must still run. + dr_catalog::albums::ensure_tables(catalog.connection())?; + Ok(( + " AND i.id IN (SELECT image_id FROM album_exports WHERE album_id = ?)".to_string(), + vec![rusqlite::types::Value::Integer(album.0 as i64)], + )) + } + } } /// Earliest and latest capture time within `scope`, honouring the filter. @@ -231,7 +240,7 @@ pub(super) fn scope_clause( /// collection's bars crushed into a sliver of it. pub fn span_scoped( catalog: &Catalog, - scope: Option, + scope: Option, filter: &RatingFilter, ) -> Option<(i64, i64)> { let (clause, params) = scope_clause(catalog, scope).ok()?; @@ -281,7 +290,7 @@ pub fn span_scoped( /// SQL, and the caller's number comes from a hand-editable settings file. pub fn timeline_uniform( catalog: &Catalog, - scope: Option, + scope: Option, filter: &RatingFilter, from: i64, to: i64, @@ -400,6 +409,42 @@ mod tests { use super::*; use crate::library::test_support::*; + #[test] + fn an_album_scopes_the_grid_to_what_was_exported_into_it() { + use dr_catalog::albums::{self, Place}; + + let catalog = with_images(10); + let ids = image_ids(&catalog); + let album = albums::create(catalog.connection(), "Web", &Place::Local("/out".into())) + .unwrap(); + // Two files from one photograph, one from another: two cells. + albums::record_exports( + catalog.connection(), + album, + &[ + (ids[1], "a.jpg".into()), + (ids[1], "a-crop.jpg".into()), + (ids[7], "b.jpg".into()), + ], + ) + .unwrap(); + + let scope = Some(Scope::Album(album)); + let cells = read_cells_scoped(&catalog, scope, &RatingFilter::default(), 0, 120).unwrap(); + assert_eq!(cells.len(), 2); + assert_eq!( + total_images_scoped(&catalog, scope, &RatingFilter::default()).unwrap(), + 2 + ); + assert_eq!( + read_ids_span(&catalog, scope, &RatingFilter::default(), false, 0, 1) + .unwrap() + .len(), + 2, + "a shift-click over the album selects what the grid drew" + ); + } + #[test] fn a_scoped_grid_shows_only_that_collections_images() { use dr_catalog::collections::{self as coll, CollectionKind}; @@ -415,10 +460,10 @@ mod tests { .unwrap(); coll::add_images(catalog.connection(), c, &ids[2..5]).unwrap(); - let cells = read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120).unwrap(); + let cells = read_cells_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default(), 0, 120).unwrap(); assert_eq!(cells.len(), 3); assert_eq!( - total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(), + total_images_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default()).unwrap(), 3 ); // Unscoped is still the whole library. @@ -449,10 +494,10 @@ mod tests { // The parent itself has no direct members at all. let cells = - read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap(); + read_cells_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default(), 0, 120).unwrap(); assert_eq!(cells.len(), 4, "the set shows what its children hold"); assert_eq!( - total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(), + total_images_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default()).unwrap(), 4 ); } @@ -478,10 +523,10 @@ mod tests { coll::add_images(catalog.connection(), iceland, &ids[0..3]).unwrap(); let cells = - read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap(); + read_cells_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default(), 0, 120).unwrap(); assert_eq!(cells.len(), 3, "images 0..3, each once"); assert_eq!( - total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(), + total_images_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default()).unwrap(), 3 ); } @@ -497,7 +542,7 @@ mod tests { let c = coll::create(catalog.connection(), "Big", None, CollectionKind::Manual).unwrap(); coll::add_images(catalog.connection(), c, &ids).unwrap(); - let page = read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 10, 5).unwrap(); + let page = read_cells_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default(), 10, 5).unwrap(); assert_eq!(page.len(), 5); assert_eq!(page[0].name, "img010.CR2"); } @@ -512,12 +557,12 @@ mod tests { let c = coll::create(catalog.connection(), "Empty", None, CollectionKind::Manual).unwrap(); assert!( - read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120) + read_cells_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default(), 0, 120) .unwrap() .is_empty() ); assert_eq!( - total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(), + total_images_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default()).unwrap(), 0 ); } @@ -626,7 +671,7 @@ mod tests { let wanted: Vec<_> = all.iter().rev().copied().collect(); dr_catalog::collections::set_order(catalog.connection(), id, &wanted).unwrap(); - let cells = read_cells_scoped(&catalog, Some(id), &RatingFilter::default(), 0, 50).unwrap(); + let cells = read_cells_scoped(&catalog, Some(Scope::Collection(id)), &RatingFilter::default(), 0, 50).unwrap(); let drawn: Vec<_> = cells .iter() .map(|c| dr_types::ImageId(c.image_id as u64)) @@ -634,7 +679,7 @@ mod tests { assert_eq!(drawn, wanted, "the grid draws the order that was written"); let spanned = - read_ids_span(&catalog, Some(id), &RatingFilter::default(), false, 0, 4).unwrap(); + read_ids_span(&catalog, Some(Scope::Collection(id)), &RatingFilter::default(), false, 0, 4).unwrap(); assert_eq!(spanned, wanted, "and a range resolves through the same one"); assert_eq!( @@ -671,7 +716,7 @@ mod tests { dr_catalog::collections::set_order(catalog.connection(), parent, &reversed).unwrap(); let cells = - read_cells_scoped(&catalog, Some(parent), &RatingFilter::default(), 0, 50).unwrap(); + read_cells_scoped(&catalog, Some(Scope::Collection(parent)), &RatingFilter::default(), 0, 50).unwrap(); let drawn: Vec<_> = cells .iter() .map(|c| dr_types::ImageId(c.image_id as u64)) @@ -691,7 +736,7 @@ mod tests { dr_catalog::collections::CollectionKind::Smart, ) .unwrap(); - let (order, params) = grid_order_for(&catalog, Some(id)); + let (order, params) = grid_order_for(&catalog, Some(Scope::Collection(id))); assert_eq!(order, GRID_ORDER); assert!(params.is_empty()); } diff --git a/ui/dr-ui/src/library_ui/controller.rs b/ui/dr-ui/src/library_ui/controller.rs index 1d5a5d0..0946e6b 100644 --- a/ui/dr-ui/src/library_ui/controller.rs +++ b/ui/dr-ui/src/library_ui/controller.rs @@ -74,7 +74,7 @@ pub(super) const MAX_CELL_SIZE: f32 = 420.0; /// Not `Copy` since `RatingFilter` stopped being — it holds a set of people. #[derive(Clone, PartialEq, Eq)] pub(super) struct LibraryFacts { - pub(super) scope: Option, + pub(super) scope: Option, pub(super) filter: library::RatingFilter, pub(super) trash: bool, pub(super) total: usize, @@ -202,6 +202,11 @@ pub struct LibraryController { /// and read here. Shared rather than passed per call because a rescan, a /// scrub and a drop all reload the window and must all honour it. pub(super) scope: RefCell>, + /// TRACES: FR-EXP-10 + /// Which album narrows the grid instead, owned by + /// [`crate::collections_ui`] beside `scope`. At most one of the two is set: + /// the sidebar has one selection. + pub(super) album: std::cell::Cell>, /// TRACES: FR-UI-8 /// How to open a photograph in develop. /// @@ -460,6 +465,7 @@ impl LibraryController { sync_timer: RefCell::new(None), session: RefCell::new(None), scope: RefCell::new(None), + album: std::cell::Cell::new(None), open_image: RefCell::new(None), place_store: RefCell::new(None), place_device: RefCell::new(String::new()), @@ -847,7 +853,7 @@ impl LibraryController { }; library::read_ids_span( catalog, - *self.scope.borrow(), + self.grid_scope(), &self.filter.borrow(), self.viewing_trash.get(), first, @@ -922,13 +928,28 @@ impl LibraryController { /// Assembled here because a worker thread can reach neither the catalog nor /// the session, and both are needed to say where a cached original lives. pub fn export_sources(&self, images: &[dr_types::ImageId]) -> Vec { + self.export_sources_with_ids(images).0 + } + + /// TRACES: FR-EXP-10 + /// [`Self::export_sources`], and the image each source is, in the same + /// order — what an album records its files against. + pub fn export_sources_with_ids( + &self, + images: &[dr_types::ImageId], + ) -> (Vec, Vec>) { self.selected_image_paths(images) .into_iter() - .map(|(id, path)| crate::export::Source::Library { - path, - cache: self.cache_context_for(id), + .map(|(id, path)| { + ( + crate::export::Source::Library { + path, + cache: self.cache_context_for(id), + }, + Some(id), + ) }) - .collect() + .unzip() } /// The open library's connection, for a full-file fetch. @@ -939,9 +960,36 @@ impl LibraryController { self.session.borrow().as_ref().map(|(c, _)| c.clone()) } + /// What the grid is narrowed to, collection or album. Every reader of the + /// grid's images goes through this rather than `scope`, so an album is + /// honoured by the count, the cells, the timeline and a shift-click alike. + pub(crate) fn grid_scope(&self) -> Option { + match (self.album.get(), *self.scope.borrow()) { + (Some(album), _) => Some(library::Scope::Album(album)), + (None, Some(c)) => Some(library::Scope::Collection(c)), + (None, None) => None, + } + } + + /// TRACES: FR-EXP-10 + /// Narrow the grid to the photographs behind an album's files. + /// + /// Clears the collection scope and the trash: the sidebar has one + /// selection, and an album is not inside either. + pub fn set_album(&self, album: Option) { + self.album.set(album); + if album.is_some() { + *self.scope.borrow_mut() = None; + self.viewing_trash.set(false); + } + *self.offset.borrow_mut() = 0; + self.requested.borrow_mut().clear(); + } + /// Narrow the grid to a collection, or to the whole library with `None`. pub fn set_scope(&self, scope: Option) { *self.scope.borrow_mut() = scope; + self.album.set(None); // Selecting a collection is leaving the trash. Without this, picking a // collection while the trash was open would keep listing trashed images // under that collection's name. @@ -961,6 +1009,7 @@ impl LibraryController { self.viewing_trash.set(viewing); if viewing { *self.scope.borrow_mut() = None; + self.album.set(None); } *self.offset.borrow_mut() = 0; self.requested.borrow_mut().clear(); diff --git a/ui/dr-ui/src/library_ui/timeline.rs b/ui/dr-ui/src/library_ui/timeline.rs index d40c932..ddcf001 100644 --- a/ui/dr-ui/src/library_ui/timeline.rs +++ b/ui/dr-ui/src/library_ui/timeline.rs @@ -82,7 +82,7 @@ pub(super) fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc) -> Op // through the range, narrowing it would move the ground under the very // handles doing the narrowing — each drag re-scaling the axis, so the next // one meant something else. - library::span_scoped(catalog, *ctl.scope.borrow(), &ctl.filter.borrow()) + library::span_scoped(catalog, ctl.grid_scope(), &ctl.filter.borrow()) } /// TRACES: NFR-P5 @@ -848,7 +848,7 @@ fn resolve_place( if !place.path.is_empty() { match library::ordinal_of_path( catalog, - *ctl.scope.borrow(), + ctl.grid_scope(), &ctl.filter.borrow(), ctl.viewing_trash.get(), &place.path, diff --git a/ui/dr-ui/src/library_ui/window.rs b/ui/dr-ui/src/library_ui/window.rs index 8790b8d..430b951 100644 --- a/ui/dr-ui/src/library_ui/window.rs +++ b/ui/dr-ui/src/library_ui/window.rs @@ -306,7 +306,7 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc) { // and filtering later would fetch thumbnails for images the user is not // looking at, which on a remote library is the cost FR-NC-3 exists to // avoid. - let scope = *ctl.scope.borrow(); + let scope = ctl.grid_scope(); let filter = ctl.filter.borrow().clone(); // The trash lists what every other view excludes, so it takes its own // query rather than another predicate threaded through the scoped one. @@ -340,11 +340,15 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc) { window .global::() .set_library_scope_pinned(match scope { - Some(id) => dr_catalog::collections::descendants(catalog.connection(), id) + Some(library::Scope::Collection(id)) => { + dr_catalog::collections::descendants(catalog.connection(), id) .map(|ids| collection_images(catalog, &ids)) - .map(|images| scope_is_pinned(catalog, &images)) - .unwrap_or(false), - None => false, + .map(|images| scope_is_pinned(catalog, &images)) + .unwrap_or(false) + } + // An album is not pinned as a unit; its photographs are + // pinned where they are filed. + Some(library::Scope::Album(_)) | None => false, }); } // Read before it is overwritten: the property still holds what the library @@ -385,9 +389,9 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc) { // by when, and it is not a collection at all. window.global::().set_library_reorderable( !trash - && scope.is_some_and(|c| { - dr_catalog::collections::orders_manually(catalog.connection(), c).unwrap_or(false) - }), + && matches!(scope, Some(library::Scope::Collection(c)) + if dr_catalog::collections::orders_manually(catalog.connection(), c) + .unwrap_or(false)), ); let cells = if trash { diff --git a/ui/dr-ui/src/settings_ui.rs b/ui/dr-ui/src/settings_ui.rs index 383de1e..6edc351 100644 --- a/ui/dr-ui/src/settings_ui.rs +++ b/ui/dr-ui/src/settings_ui.rs @@ -26,14 +26,13 @@ use std::rc::Rc; use dr_types::settings::budget; use dr_types::{ - CacheSettings, CollisionPolicy, ColourSpace, ExportFormat, ExportTarget, FaceDetector, + CacheSettings, CollisionPolicy, ColourSpace, ExportFormat, FaceDetector, GroupNavigation, LibrarySettings, OutputSharpening, ScreenSize, Settings, SizingMode, }; use slint::ComponentHandle; use crate::settings_store::SettingsStore; use crate::{Adjustments, AppWindow, ExportOptions, Page}; -use dr_sync::Connection; /// Shared settings state for the running window. pub struct SettingsController { @@ -46,24 +45,6 @@ pub struct SettingsController { /// How much disk the cache is currently using, as a label. Supplied by /// whoever owns the catalog — this module has no connection to query. usage_label: RefCell, - /// TRACES: FR-EXP-6 - /// The remote folder picker, while it is open. - /// - /// The same [`FolderBrowser`](crate::launch::FolderBrowser) the launch - /// screen uses to choose a library root, reused rather than reimplemented: - /// it browses a remote tree and nothing about it is specific to what the - /// chosen folder is *for*. `None` means the picker is closed, which is - /// also the only state a device destination ever has — a path on this - /// machine is typed or chosen by the platform, not walked through a - /// backend. - pub browser: RefCell>, - /// Polls the folder listing while one is in flight. - /// - /// Held here rather than in the function that starts it: a `slint::Timer` - /// stops the moment it is dropped, so a timer local to `spawn_folder_list` - /// would be collected before the listing it is waiting on ever arrived. - /// The same place `LaunchController` keeps its own poll timer. - poll_timer: RefCell>, } impl SettingsController { @@ -75,8 +56,6 @@ impl SettingsController { store, error: RefCell::new(None), usage_label: RefCell::new(String::new()), - browser: RefCell::new(None), - poll_timer: RefCell::new(None), }) } @@ -85,21 +64,6 @@ impl SettingsController { self.settings.borrow().clone() } - /// Adopt a folder chosen in the picker as the export destination. - /// - /// Goes through `edit` like every other change, so it is saved the moment - /// it is chosen — the page has no Save button and a destination that - /// survived only until the window closed would be the one setting that - /// behaved differently from all the others. - pub fn set_destination(&self, path: String) { - self.edit(|s| s.export.set_active_destination(path)); - } - - /// Report a failure onto the page's error line. - pub fn set_error(&self, message: impl Into) { - *self.error.borrow_mut() = Some(message.into()); - } - /// Show what the cache is holding. Empty hides the line. pub fn set_usage_label(&self, label: String) { *self.usage_label.borrow_mut() = label; @@ -362,78 +326,6 @@ pub fn render(window: &AppWindow, controller: &SettingsController) { .global::() .set_strip_location(s.export.strip_location); - // `available()`, not `ALL`: Android cannot write to a device folder, and - // offering a target that fails at the last step is what made export look - // broken there rather than absent. - let targets = ExportTarget::available(); - window - .global::() - .set_target_labels(labels(targets.iter().map(|t| t.label()))); - window - .global::() - .set_target_selected(index_of(targets, &s.export.target)); - window - .global::() - .set_destination(s.export.active_destination().into()); - // Derived from the target itself, never from its position in a list whose - // length differs by platform. - window - .global::() - .set_browse_available(s.export.target.is_remote()); - // The field means different things either side of the choice, and a - // placeholder saying which is cheaper than a paragraph under it. - // The placeholder names what an empty field *means*, which differs by - // target: on a filesystem it is a question, on the server it is the - // library root. - window - .global::() - .set_destination_hint(s.export.destination_label().into()); - - // --- the remote folder picker -------------------------------------- - { - let browser = controller.browser.borrow(); - window - .global::() - .set_browse_open(browser.is_some()); - match browser.as_ref() { - Some(b) => { - // The root is shown as a word rather than as an empty string, - // which would read as a control that had lost its value. - window.global::().set_browse_path( - if b.path.is_empty() { - "Library root".to_string() - } else { - b.path.clone() - } - .into(), - ); - window - .global::() - .set_browse_loading(b.loading); - window - .global::() - .set_browse_at_root(b.parent_path().is_none()); - window - .global::() - .set_browse_entries(slint::ModelRc::new(slint::VecModel::from( - b.entries - .iter() - .map(|e| slint::SharedString::from(e.as_str())) - .collect::>(), - ))); - } - None => { - window - .global::() - .set_browse_entries(slint::ModelRc::new(slint::VecModel::from(Vec::< - slint::SharedString, - >::new( - )))); - window.global::().set_browse_loading(false); - } - } - } - window.set_settings_error(controller.error.borrow().clone().unwrap_or_default().into()); } @@ -889,34 +781,8 @@ fn wire_export(window: &AppWindow, controller: &Rc) { }); } - { - let weak = window.as_weak(); - let ctl = controller.clone(); - window - .global::() - .on_destination_changed(move |text| { - let Some(w) = weak.upgrade() else { return }; - ctl.edit(|s| s.export.set_active_destination(text.to_string())); - render(&w, &ctl); - }); - } - - { - let weak = window.as_weak(); - let ctl = controller.clone(); - window.global::().on_target_picked(move |i| { - let Some(w) = weak.upgrade() else { return }; - if let Some(&t) = ExportTarget::available().get(i.max(0) as usize) { - // Nothing is cleared. Each target keeps its own destination - // (see `ExportSettings::remote_destination`), so switching to - // look at the other option no longer throws away the one - // already set — which is what made this setting appear not to - // stick. - ctl.edit(|s| s.export.target = t); - } - render(&w, &ctl); - }); - } + // The export destination is an album, chosen and rendered by + // `albums_ui`, which has the catalog this page does not. } /// --- reset --------------------------------------------------------- @@ -938,110 +804,6 @@ fn wire_reset( } } -/// TRACES: FR-EXP-6 -/// List the folders under `path`, for the export destination picker. -/// -/// A near-twin of `launch_ui::spawn_folder_list` and deliberately not shared -/// with it. That one reaches into the `LaunchController` for its account and -/// reports failures onto the launch screen's error line; this one is handed a -/// connection and writes to the settings page. Factoring them together would -/// mean a function taking both controllers, or a trait implemented twice to -/// abstract two call sites — more machinery than the twenty lines it saves. -/// -/// The *model* is shared, which is the part that matters: both drive a -/// [`FolderBrowser`](crate::launch::FolderBrowser), so navigation behaves -/// identically in both places. -pub fn spawn_folder_list( - weak: slint::Weak, - ctl: Rc, - conn: Connection, - path: String, -) { - use dr_sync::RemotePath; - - let (tx, rx) = std::sync::mpsc::channel::, String>>(); - - std::thread::spawn(move || { - // Multi-thread, for the reason the login worker records: a - // current-thread runtime left reqwest's connection future unpolled on - // Android, and the await never resolved. - let rt = tokio::runtime::Builder::new_multi_thread() - .worker_threads(1) - .enable_io() - .enable_time() - .build(); - let Ok(rt) = rt else { - let _ = tx.send(Err("runtime".into())); - return; - }; - rt.block_on(async { - match crate::remote::connect(&conn) { - Ok(b) => match b.list(&RemotePath::new(&path), None).await { - Ok(entries) => { - let mut dirs: Vec = entries - .iter() - .filter(|e| e.kind == dr_sync::EntryKind::Directory) - .map(|e| e.path.name().to_string()) - .collect(); - dirs.sort_by_key(|d| d.to_ascii_lowercase()); - let _ = tx.send(Ok(dirs)); - } - Err(e) => { - let _ = tx.send(Err(e.to_string())); - } - }, - Err(e) => { - let _ = tx.send(Err(e.to_string())); - } - } - }); - }); - - let timer = slint::Timer::default(); - let ctl_cb = ctl.clone(); - timer.start( - slint::TimerMode::Repeated, - std::time::Duration::from_millis(150), - move || { - let Some(w) = weak.upgrade() else { return }; - match rx.try_recv() { - Ok(Ok(dirs)) => { - if let Some(b) = ctl_cb.browser.borrow_mut().as_mut() { - b.entries = dirs; - b.loading = false; - } - render(&w, &ctl_cb); - } - Ok(Err(e)) => { - // The picker stays open showing the folder it was on. A - // listing that failed is not a reason to discard where the - // user had navigated to. - if let Some(b) = ctl_cb.browser.borrow_mut().as_mut() { - b.loading = false; - } - *ctl_cb.error.borrow_mut() = Some(format!("Could not list folders: {e}")); - render(&w, &ctl_cb); - } - Err(std::sync::mpsc::TryRecvError::Empty) => return, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - if let Some(b) = ctl_cb.browser.borrow_mut().as_mut() { - b.loading = false; - } - render(&w, &ctl_cb); - } - } - // The channel has delivered, so there is nothing left to poll - // for. Stopping it here rather than leaving it running is what - // keeps a page opened and closed twenty times from accumulating - // twenty timers. - if let Some(t) = ctl_cb.poll_timer.borrow().as_ref() { - t.stop(); - } - }, - ); - *ctl.poll_timer.borrow_mut() = Some(timer); -} - #[cfg(test)] mod tests { use super::*; @@ -1066,8 +828,6 @@ mod tests { store, error: RefCell::new(None), usage_label: RefCell::new(String::new()), - browser: RefCell::new(None), - poll_timer: RefCell::new(None), }) } @@ -1138,8 +898,6 @@ mod tests { store: SettingsStore::open_at(blocker.join("settings.json")), error: RefCell::new(None), usage_label: RefCell::new(String::new()), - browser: RefCell::new(None), - poll_timer: RefCell::new(None), }; broken.edit(|s| s.export.quality = 50); diff --git a/ui/dr-ui/ui/albums.slint b/ui/dr-ui/ui/albums.slint new file mode 100644 index 0000000..4045ac6 --- /dev/null +++ b/ui/dr-ui/ui/albums.slint @@ -0,0 +1,480 @@ +// TRACES: FR-EXP-10 | FR-EXP-6 +// Albums: named export folders, in the sidebar under the collections. +// +// An album is where finished pictures go. Its folder holds only the exported +// files; the catalog remembers which photograph each came from, so selecting +// an album shows the originals behind it, and the export sheet sends a +// selection to one by name rather than to a path typed into a field. +// +// The folder is on this device (chosen in the platform's dialogue, or on +// Android the system's folder picker) or on the library's server (chosen in +// the browser below, which can make a folder as well as open one). Not inside +// the library itself: a JPEG exported there would be catalogued by the next +// scan as a photograph of its own. + +import { Theme } from "theme.slint"; +import { Button, Caption, Field, Icon, PanelHeading, Value } from "widgets.slint"; +import { Segmented } from "controls.slint"; + +export struct AlbumRow { + id: int, + name: string, + // Photographs exported into it — what the grid shows when it is opened. + count: int, + // Where its files go, in words: "On this device · ~/Exports/Web", + // "On the server · Shared/Web", or that this device has no folder for it. + detail: string, + // The album has no folder on this device yet: made elsewhere with a + // folder local to that device. + homeless: bool, +} + +export global Albums { + in property <[AlbumRow]> rows; + /// The album scoping the grid, 0 for none. + in property selected: 0; + callback select(int); + /// Open the sheet on a new album. + callback create(); + /// Open the sheet on an existing one. + callback edit(int); + + // --- the sheet ------------------------------------------------------ + in property sheet-open: false; + /// Editing an existing album rather than making one. + in property sheet-editing: false; + in property sheet-name; + /// Whether the library has a server to put a folder on. A folder library + /// has only this device. + in property server-available: false; + /// 0: this device. 1: the server. + in property sheet-where: 0; + /// The folder chosen on this device, for display. Empty until chosen. + in property sheet-device-folder; + /// Something to say — a refusal, a failed request. + in property sheet-error; + /// Delete has been pressed once and waits to be confirmed. + in property sheet-confirming-delete: false; + /// What saving would do, when the button alone would not say. + in property sheet-can-save: false; + + callback sheet-where-picked(int); + callback sheet-choose-device-folder(); + callback sheet-name-edited(string); + callback sheet-save(); + callback sheet-cancel(); + callback sheet-delete(); + + // --- the server folder, while the sheet is choosing one --------------- + in property browse-path; + in property <[string]> browse-entries; + in property browse-loading: false; + in property browse-at-root: true; + /// Why the folder shown cannot be used, or empty when it can. + in property browse-refusal; + callback browse-into(string); + callback browse-up(); + callback browse-make(string); +} + +// A folder on the server: where it is, what is in it, and a way to make one. +// +// Confirms the folder *shown*, not one selected in the list — the rule the +// launch screen's picker already follows — so walking into a folder is +// choosing it, and there is no second selection that could disagree. +export component ServerFolderBrowser inherits VerticalLayout { + in property path; + in property <[string]> entries; + in property loading; + in property at-root: true; + + callback into(string); + callback up(); + callback make(string); + + property naming: false; + + spacing: Theme.gap-sm; + + HorizontalLayout { + spacing: Theme.gap-sm; + + Button { + text: "↑ Up"; + // Disabled rather than hidden at the root: a control that + // vanishes moves everything beside it. + enabled: !root.at-root && !root.loading; + clicked => { root.up(); } + } + + Value { + text: root.path == "" ? "/" : "/" + root.path; + overflow: elide; + horizontal-stretch: 1; + min-width: 0px; + vertical-alignment: center; + } + + Button { + text: "New folder"; + enabled: !root.loading && !root.naming; + clicked => { root.naming = true; } + } + } + + if root.naming: HorizontalLayout { + spacing: Theme.gap-sm; + name := Field { + horizontal-stretch: 1; + label: "New folder name"; + placeholder: "Folder name"; + accepted(t) => { + if (t != "") { + root.make(t); + root.naming = false; + } + } + init => { self.take-focus(); } + } + Button { + text: "Make"; + enabled: name.text != ""; + clicked => { + root.make(name.text); + root.naming = false; + } + } + Button { + text: "Cancel"; + clicked => { root.naming = false; } + } + } + + // A fixed height rather than one that grows with the listing: a folder + // with sixty children would otherwise push the buttons off the sheet. + Rectangle { + height: 200px; + background: Theme.ground; + border-radius: Theme.radius; + border-width: 1px; + border-color: Theme.rule; + + if root.loading: Caption { + text: "Listing…"; + horizontal-alignment: center; + vertical-alignment: center; + width: 100%; + height: 100%; + } + + if !root.loading: Flickable { + x: 4px; + y: 4px; + width: parent.width - 8px; + height: parent.height - 8px; + viewport-height: list.preferred-height; + + list := VerticalLayout { + width: 100%; + spacing: 2px; + alignment: start; + + if root.entries.length == 0: Caption { + text: "No folders here. Use this one, make one, or go up."; + wrap: word-wrap; + } + + for folder in root.entries: Rectangle { + height: 32px; + background: row-touch.has-hover ? Theme.hover : transparent; + border-radius: Theme.radius-sm; + + accessible-role: button; + accessible-label: folder; + accessible-action-default => { root.into(folder); } + + HorizontalLayout { + padding-left: Theme.gap-sm; + padding-right: Theme.gap-sm; + spacing: Theme.gap-sm; + Icon { + name: "chevron-right"; + ink: Theme.ink-faint; + size: 10px; + y: (parent.height - self.height) / 2; + } + Value { + text: folder; + overflow: elide; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + row-touch := TouchArea { clicked => { root.into(folder); } } + } + } + } + } +} + +// One album in the sidebar. +component AlbumListRow inherits Rectangle { + in property entry; + in property selected; + callback clicked(); + callback edit(); + + height: Theme.row-height; + background: root.selected ? Theme.surface-raised + : (touch.has-hover ? Theme.hover : transparent); + border-radius: Theme.radius-sm; + + accessible-role: button; + accessible-label: root.entry.name + ", " + root.entry.detail; + accessible-action-default => { root.clicked(); } + + touch := TouchArea { + clicked => { root.clicked(); } + double-clicked => { root.edit(); } + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down && ev.button == PointerEventButton.right) { + root.edit(); + } + } + } + + HorizontalLayout { + padding-left: Theme.gap-sm; + padding-right: Theme.gap-sm; + spacing: Theme.gap-sm; + + Text { + text: root.entry.name; + color: root.entry.homeless ? Theme.ink-faint + : (root.selected ? Theme.ink : Theme.ink-dim); + font-size: Theme.text; + font-weight: root.selected ? 600 : 400; + overflow: elide; + vertical-alignment: center; + horizontal-stretch: 1; + } + Text { + text: root.entry.count > 0 ? root.entry.count : ""; + color: Theme.ink-faint; + font-size: Theme.text-sm; + vertical-alignment: center; + } + // The way in to rename, move or delete, where there is no right + // button and no double click: a finger. + Rectangle { + width: 22px; + accessible-role: button; + accessible-label: "Edit " + root.entry.name; + accessible-action-default => { root.edit(); } + Icon { + name: "chevron-right"; + ink: Theme.ink-faint; + size: 10px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + } + TouchArea { clicked => { root.edit(); } } + } + } +} + +// The sidebar's albums: a heading with "+", and the list. +export component AlbumsSection inherits VerticalLayout { + spacing: 2px; + + HorizontalLayout { + height: 26px; + spacing: Theme.gap-sm; + + Text { + text: "ALBUMS"; + color: Theme.ink-faint; + font-size: Theme.text-sm; + font-weight: 700; + letter-spacing: 1.2px; + vertical-alignment: center; + horizontal-stretch: 1; + } + + Rectangle { + width: 22px; + height: 22px; + y: (parent.height - self.height) / 2; + background: add-touch.pressed ? Theme.pressed + : (add-touch.has-hover ? Theme.hover : transparent); + border-radius: Theme.radius-sm; + + accessible-role: button; + accessible-label: "New album"; + accessible-action-default => { Albums.create(); } + + Icon { + name: "plus"; + ink: Theme.ink-dim; + size: 12px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + } + add-touch := TouchArea { clicked => { Albums.create(); } } + } + } + + if Albums.rows.length == 0: Text { + text: "Press + to make an export folder. Exports sent to it are listed here."; + color: Theme.ink-faint; + font-size: Theme.text-sm; + wrap: word-wrap; + } + + // At most five rows tall, then it scrolls: the albums share the column + // with the collection tree, which is the thing that should grow. + if Albums.rows.length > 0: Flickable { + height: min(Albums.rows.length, 5) * (Theme.row-height + 2px); + viewport-height: Albums.rows.length * (Theme.row-height + 2px); + + for row[i] in Albums.rows: AlbumListRow { + y: i * (Theme.row-height + 2px); + width: parent.width; + entry: row; + selected: row.id == Albums.selected; + clicked => { Albums.select(row.id); } + edit => { Albums.edit(row.id); } + } + } +} + +// Making an album, or changing one: its name and where its files go. +export component AlbumSheet inherits Rectangle { + background: #000000CC; + + TouchArea { clicked => { Albums.sheet-cancel(); } } + + Rectangle { + width: min(560px, parent.width - 2 * Theme.gap-lg); + height: min(body.preferred-height + 2 * Theme.gap-lg, parent.height - 2 * Theme.gap-lg); + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + background: Theme.surface; + border-radius: Theme.radius; + border-width: 1px; + border-color: Theme.rule; + + TouchArea { } + + Flickable { + viewport-height: body.preferred-height + 2 * Theme.gap-lg; + + body := VerticalLayout { + x: Theme.gap-lg; + y: Theme.gap-lg; + width: parent.width - 2 * Theme.gap-lg; + spacing: Theme.gap; + alignment: start; + + Text { + text: Albums.sheet-editing ? "Album" : "New album"; + color: Theme.ink; + font-size: Theme.text-lg; + font-weight: 600; + } + + PanelHeading { text: "NAME"; } + Field { + label: "Album name"; + text: Albums.sheet-name; + placeholder: "Web, Prints, For Anna…"; + edited(t) => { Albums.sheet-name-edited(t); } + accepted(t) => { Albums.sheet-name-edited(t); } + // The keyboard comes here when the sheet opens. Left on + // the grid, a name typed at once is read as shortcuts — + // P picks, R rejects, digits rate. + init => { self.take-focus(); } + } + + PanelHeading { text: "FILES GO TO"; } + + if Albums.server-available: Segmented { + label: "Where"; + options: ["This device", "The server"]; + selected: Albums.sheet-where; + picked(i) => { Albums.sheet-where-picked(i); } + } + + if Albums.sheet-where == 0: HorizontalLayout { + spacing: Theme.gap-sm; + Value { + text: Albums.sheet-device-folder != "" ? Albums.sheet-device-folder : "No folder chosen"; + placeholder: Albums.sheet-device-folder == ""; + overflow: elide; + vertical-alignment: center; + horizontal-stretch: 1; + min-width: 0px; + } + Button { + text: Albums.sheet-device-folder != "" ? "Change…" : "Choose folder…"; + clicked => { Albums.sheet-choose-device-folder(); } + } + } + + if Albums.sheet-where == 0: Caption { + text: "The dialogue can make a new folder too. Only the exported files are written there."; + wrap: word-wrap; + } + + if Albums.sheet-where == 1: ServerFolderBrowser { + path: Albums.browse-path; + entries: Albums.browse-entries; + loading: Albums.browse-loading; + at-root: Albums.browse-at-root; + into(name) => { Albums.browse-into(name); } + up => { Albums.browse-up(); } + make(name) => { Albums.browse-make(name); } + } + + if Albums.sheet-where == 1 && Albums.browse-refusal != "": Caption { + text: Albums.browse-refusal; + warn: true; + wrap: word-wrap; + } + + if Albums.sheet-error != "": Caption { + text: Albums.sheet-error; + warn: true; + wrap: word-wrap; + } + + if Albums.sheet-confirming-delete: Caption { + text: "The album goes; the files already in its folder stay where they are."; + wrap: word-wrap; + } + + HorizontalLayout { + spacing: Theme.gap-sm; + + if Albums.sheet-editing: Button { + text: Albums.sheet-confirming-delete ? "Delete album" : "Delete…"; + clicked => { Albums.sheet-delete(); } + } + + Rectangle { horizontal-stretch: 1; } + + Button { + text: "Cancel"; + clicked => { Albums.sheet-cancel(); } + } + Button { + text: Albums.sheet-editing ? "Save" : "Make album"; + primary: true; + enabled: Albums.sheet-can-save; + clicked => { Albums.sheet-save(); } + } + } + } + } + } +} diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index b089e03..560aa1c 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -16,6 +16,7 @@ import { HistogramPanel, HistogramView, Levels } from "histogram.slint"; import { RecoveryPrompt } from "recovery.slint"; import { CopySheet, PresetOrigin, PresetRow, PresetSheet, ScopeChips, ScopeKind } from "presets.slint"; import { ExportOptions, ExportSheet } from "export.slint"; +import { Albums, AlbumSheet, AlbumRow } from "albums.slint"; import { FocusMarks, FocusPanel, Peaking } from "peaking.slint"; import { SettingsPage } from "settings.slint"; import { ImportPage } from "import.slint"; @@ -37,6 +38,7 @@ export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Mask export { Collections } export { Library } export { ExportOptions } +export { Albums, AlbumRow } export { Pickers } from "pickers.slint"; export { Duplicates, DuplicateRow, DuplicateCopy } export { Scrolling } @@ -939,7 +941,7 @@ in property panel-visible: true; /// this device. The grid says the same thing as the develop button, but it /// composes its own label around a selection count — so it is given the /// fact and not the sentence. - in property export-to-server: false; + in property export-album; /// Show or hide the develop column. callback toggle-panel(); /// Export the image on screen, using the settings as they stand. @@ -1516,7 +1518,7 @@ in property panel-visible: true; // TRACES: FR-EXP-7 exporting: Library.library-exporting; - export-to-server: root.export-to-server; + export-album: root.export-album; export-selection => { Library.library-export-selection(); } cancel-export => { Library.library-cancel-export(); } // TRACES: FR-MRG-1 @@ -3452,8 +3454,8 @@ in property panel-visible: true; error: root.settings-error; action-label: root.active-view == View.develop ? root.export-label - : (root.export-to-server - ? "Export " + Library.library-selected-count + " to the library" + : (root.export-album != "" + ? "Export " + Library.library-selected-count + " to " + root.export-album : "Export " + Library.library-selected-count); can-export: root.export-sheet-can-export; export => { root.export-from-sheet(); } @@ -3541,6 +3543,15 @@ in property panel-visible: true; dismiss() => { Collections.collection-menu-dismiss(); } } + // TRACES: FR-EXP-10 + // Above the export sheet, which opens it for "New album…", and above + // the sidebar that opens it otherwise — which the compact layout + // hides, so it cannot be drawn inside it. + if Albums.sheet-open: AlbumSheet { + width: 100%; + height: 100%; + } + // Last, and therefore over everything including the settings page and // the offline prompt. Not a preference about layering: this is asked // before the grid exists, and nothing else in the window is about a diff --git a/ui/dr-ui/ui/collections.slint b/ui/dr-ui/ui/collections.slint index 7fec202..434e6e8 100644 --- a/ui/dr-ui/ui/collections.slint +++ b/ui/dr-ui/ui/collections.slint @@ -35,6 +35,7 @@ import { Theme } from "theme.slint"; import { Duplicates } from "duplicates.slint"; import { Keys } from "keys.slint"; import { Button, Icon, ScrollBar } from "widgets.slint"; +import { AlbumsSection } from "albums.slint"; // One row of the collection tree. export struct CollectionRow { @@ -783,6 +784,19 @@ export component CollectionsPanel inherits Rectangle { clicked => { root.toggle-pin-scope(); } } + // --- albums ------------------------------------------------------- + // + // TRACES: FR-EXP-10 + // Under the collections and above the trash: a selection target like + // a collection, but not a drop target — photographs reach an album by + // being exported to it, never by being filed. + Rectangle { + height: 1px; + background: Theme.rule; + } + + AlbumsSection {} + // --- trash -------------------------------------------------------- // // TRACES: FR-CAT-15 diff --git a/ui/dr-ui/ui/export.slint b/ui/dr-ui/ui/export.slint index 5a47c1c..b8e8ee8 100644 --- a/ui/dr-ui/ui/export.slint +++ b/ui/dr-ui/ui/export.slint @@ -43,33 +43,16 @@ export global ExportOptions { in property <[string]> collision-labels; in property collision-selected: 0; in property strip-location: true; - in-out property destination; - // What the destination field means depends on this, so the placeholder - // comes from Rust alongside it rather than being written twice here. - in property destination-hint; - in property <[string]> target-labels; - in property target-selected: 0; - - // --- the remote folder picker ------------------------------------ + // --- where it goes (FR-EXP-10) ----------------------------------- // - // The same navigation the launch screen uses to choose a library root, - // driven by the same `FolderBrowser` model in Rust. A folder on the - // server is not something anyone can be expected to type from memory. - in property browse-open: false; - in property browse-path; - in property <[string]> browse-entries; - in property browse-loading: false; - /// At the library root, so there is nowhere up to go. - in property browse-at-root: true; - /// Whether the destination is one that can be walked. - /// - /// A boolean from Rust rather than a test on `target-selected`. The index - /// was hardcoded to 1, which was Remote's position while both targets were - /// offered — and the moment Android's list narrowed to Remote alone, that - /// index became 0 and the button vanished on the one platform where it is - /// the *only* way to set a destination. An index into a list whose length - /// varies is not a fact about the target. - in property browse-available: false; + // An album, by name. The folder behind it — on this device or on the + // server — is chosen once, when the album is made, in the platform's + // dialogue or the server browser; exporting only says which album. + in property <[string]> album-labels; + /// -1 while none is chosen, and an export is refused until one is. + in property album-selected: -1; + /// Where the chosen album's files go, in words. + in property album-detail; callback format-picked(int); callback quality-changed(int); @@ -83,13 +66,9 @@ export global ExportOptions { callback template-changed(string); callback collision-picked(int); callback strip-location-toggled(bool); - callback destination-changed(string); - callback target-picked(int); - callback browse-open-picker(); - callback browse-into(string); - callback browse-up(); - callback browse-confirm(); - callback browse-cancel(); + callback album-picked(int); + /// Make a new album, from here rather than from the sidebar. + callback album-new(); } // What the image becomes: format, quality, colour, size, sharpening. The @@ -222,152 +201,35 @@ export component ExportFileOptions inherits VerticalLayout { picked(i) => { ExportOptions.collision-picked(i); } } - // Where the file lands, before what it is called: on - // Android the answer decides whether an export needs - // the Storage Access Framework at all, and on any - // platform a server destination is reached over a - // network that may not be there. - Segmented { - label: "Export to"; - options: ExportOptions.target-labels; - selected: ExportOptions.target-selected; - picked(i) => { ExportOptions.target-picked(i); } + // TRACES: FR-EXP-10 + // Where the file lands, before what it is called: an album, which + // names a folder on this device or on the server and remembers + // what was sent to it. Not the library — a JPEG written into the + // library's own tree would be catalogued by the next scan as a + // photograph of its own. + if ExportOptions.album-labels.length > 0: Segmented { + label: "Album"; + options: ExportOptions.album-labels; + selected: ExportOptions.album-selected; + columns: 3; + picked(i) => { ExportOptions.album-picked(i); } } - TextRow { - label: "Destination"; - // What an empty field does, not what it was once - // going to do: nothing asks, and an export with - // no folder is refused and says so in the header. - hint: "a folder on this device; exports are refused until one is set"; - text <=> ExportOptions.destination; - field-width: 320px; - placeholder: ExportOptions.destination-hint; - accepted(t) => { ExportOptions.destination-changed(t); } + if ExportOptions.album-labels.length == 0: Caption { + text: "No album yet. An album is a folder exports go to — on this device or on the server — and the photographs sent to it are listed under Albums in the library."; + wrap: word-wrap; } - // Offered only for a server destination. A folder on - // this device is chosen by the platform's own dialogue - // or typed; a folder on the server can only be found - // by walking it, and expecting anyone to recall the - // exact spelling of a path three levels down is how a - // destination silently becomes a new folder at the - // root. - if ExportOptions.browse-available && !ExportOptions.browse-open: HorizontalLayout { + if ExportOptions.album-detail != "": Caption { + text: ExportOptions.album-detail; + wrap: word-wrap; + } + + HorizontalLayout { alignment: start; Button { - text: "Choose folder…"; - clicked => { ExportOptions.browse-open-picker(); } - } - } - - if ExportOptions.browse-open: Rectangle { - background: Theme.ground; - border-radius: Theme.radius; - height: picker.preferred-height + 2 * Theme.gap; - - picker := VerticalLayout { - x: Theme.gap; - y: Theme.gap; - width: parent.width - 2 * Theme.gap; - spacing: Theme.gap-sm; - - HorizontalLayout { - spacing: Theme.gap-sm; - - Button { - text: "↑ Up"; - // Disabled rather than hidden at the - // root: a control that vanishes moves - // everything beside it, and the row - // would jump as the user navigates. - enabled: !ExportOptions.browse-at-root; - clicked => { ExportOptions.browse-up(); } - } - - Value { - text: ExportOptions.browse-path; - overflow: elide; - horizontal-stretch: 1; - vertical-alignment: center; - } - - Caption { - text: ExportOptions.browse-loading ? "Listing…" : ""; - vertical-alignment: center; - } - } - - // A fixed height rather than one that grows - // with the listing: a folder with sixty - // children would otherwise push the rest of - // the settings page off the bottom. - Rectangle { - height: 180px; - background: Theme.surface; - border-radius: Theme.radius; - - Flickable { - x: 4px; - y: 4px; - width: parent.width - 8px; - height: parent.height - 8px; - viewport-height: folders.preferred-height; - - folders := VerticalLayout { - width: 100%; - spacing: 2px; - alignment: start; - - if ExportOptions.browse-entries.length == 0 - && !ExportOptions.browse-loading: Caption { - text: "No folders here. " - + "Use this one, or go up."; - } - - for name in ExportOptions.browse-entries: Rectangle { - height: 32px; - background: touch.has-hover - ? Theme.surface-raised - : transparent; - border-radius: Theme.radius; - - Label { - x: Theme.gap-sm; - text: name; - vertical-alignment: center; - overflow: elide; - width: parent.width - 2 * Theme.gap-sm; - } - - touch := TouchArea { - clicked => { ExportOptions.browse-into(name); } - } - } - } - } - } - - HorizontalLayout { - spacing: Theme.gap-sm; - alignment: end; - - Button { - text: "Cancel"; - clicked => { ExportOptions.browse-cancel(); } - } - - // Confirms the folder currently *shown*, - // not one selected in the list — the same - // rule the library picker follows, so - // "use this one" means the same thing in - // both places. - Button { - text: "Use this folder"; - active: true; - clicked => { ExportOptions.browse-confirm(); } - } - } + text: "New album…"; + clicked => { ExportOptions.album-new(); } } } diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index 855a76b..3867f6d 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -2006,7 +2006,8 @@ export component LibraryGrid inherits Rectangle { // where the files go nor the worker that writes them — it reports what is // selected and asks, exactly as it does for a paste. in property exporting: false; - in property export-to-server: false; + /// The album an export goes to, by name; empty when none is chosen. + in property export-album; callback export-selection(); callback cancel-export(); /// TRACES: FR-EXP-7 | FR-UI-5 @@ -5217,8 +5218,8 @@ export component LibraryGrid inherits Rectangle { if !root.ranging: Button { text: root.exporting ? "Cancel export" - : (root.export-to-server - ? "Export " + root.selected-count + " to the library" + : (root.export-album != "" + ? "Export " + root.selected-count + " to " + root.export-album : "Export " + root.selected-count); active: root.exporting; y: (parent.height - self.height) / 2;