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;