//! Ratings, flags, bursts and keywords: applying a judgement to a selection, //! keeping the grid and the filter chips honest about what landed, and the //! sidecar and XMP writes a judgement or a paste of develop settings queues //! behind it. //! //! `dr_catalog::rating` and `dr_catalog::keywords` own the data rules; this //! owns which photographs a keystroke or a sheet acts on, and reporting what //! actually changed rather than what was asked for. See //! `docs/dev/code-health.md` CH-1. use std::cell::RefCell; use std::rc::Rc; use dr_catalog::Catalog; use slint::{ComponentHandle, Model as _}; use crate::library; use crate::{AppWindow, KeywordRow, Library}; use super::controller::{stop, LibraryController}; use super::window::load_window; /// Push each visible image's stars and flag into the grid model. /// /// One query for the window, mirroring `collections_ui::sync_badges` — 120 /// cells is 120 round trips otherwise, on every scroll and after every /// keystroke. pub fn sync_ratings(window: &AppWindow, catalog: &Catalog, ids: &[dr_types::ImageId]) { if ids.is_empty() { return; } let found = match dr_catalog::rating::judgements(catalog.connection(), ids) { Ok(j) => j, Err(e) => { // The grid is still usable without stars, so this is logged rather // than surfaced — a failure here must not blank the library. log::debug!("reading ratings: {e}"); return; } }; let model = window.global::().get_library_cells(); for (row, id) in ids.iter().enumerate() { // Absent means unrated, which is a real state rather than missing data. let j = found.get(id).copied().unwrap_or_default(); let (rating, flag) = (j.rating as i32, flag_code(j.flag)); if let Some(mut cell) = model.row_data(row) { if cell.rating != rating || cell.flag != flag { cell.rating = rating; cell.flag = flag; model.set_row_data(row, cell); } } } } /// Refresh the filter chips' per-star counts. /// /// Whole-library figures, deliberately: they say what narrowing to a filter /// would show, so computing them over the current window would make each chip /// describe the view it is meant to change. pub(super) fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) { let counts = dr_catalog::rating::rating_histogram(catalog.connection()).unwrap_or_default(); let as_i32: Vec = counts.iter().map(|n| *n as i32).collect(); window .global::() .set_library_rating_counts(slint::ModelRc::new(slint::VecModel::from(as_i32))); // TRACES: FR-CAT-9 // How many originals are actually here, for the "On this device" chip. // Shown for the same reason the star counts are: a filter that silently // empties the grid reads as broken, and this one will legitimately be zero // on a library nothing has been downloaded from yet. window .global::() .set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32); } // --- keywords (FR-CAT-5, FR-CAT-6) --------------------------------------- // // `dr_catalog::keywords` owns the data rules — the vocabulary, the many-to-many // join, what a rename does to the assignments. This part owns the *interaction*: // which photographs the sheet is acting on, and keeping what it draws honest // about what actually landed. /// Redraw the keywording sheet against whatever is selected now. /// /// Called when the sheet opens and after every assignment, rather than on every /// selection change: the selection moves on each arrow key and the sheet is shut /// for almost all of them, so computing coverage over a forty-image selection /// on each one would be work nobody is looking at. /// /// Re-read from the catalog rather than patched in place after a write. A word /// applied to a selection that partly already had it moves from "3 of 12" to /// "12 of 12", and a model updated by hand would have to reproduce the rule /// that decides that — which is exactly the rule the catalog has just applied. fn refresh_keywords(window: &AppWindow, ctl: &Rc, images: &[dr_types::ImageId]) { let borrow = ctl.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return; }; let rows = match dr_catalog::keywords::for_images(catalog.connection(), images) { Ok(rows) => rows, Err(e) => { // The grid is entirely usable without the sheet, so this is logged // rather than surfaced: a keyword read that failed must not put an // error banner over a library the user is browsing. log::debug!("reading keywords: {e}"); return; } }; let model: Vec = rows .into_iter() .map(|row| KeywordRow { id: row.keyword.id.0 as i32, name: row.keyword.name.into(), coverage: match row.coverage { dr_catalog::Coverage::None => 0, dr_catalog::Coverage::Some => 1, dr_catalog::Coverage::All => 2, }, selected_count: row.selected_count as i32, image_count: row.keyword.image_count as i32, }) .collect(); window .global::() .set_library_keywords(slint::ModelRc::new(slint::VecModel::from(model))); } /// Put a keyword on the selection, or take it off. /// /// # Why this does not write a sidecar /// /// Every other judgement in this file — a star, a flag — is written to the /// catalog and then queued to the image's sidecar, because the sidecar is what /// makes it survive a catalog rebuild (ARCH §6.12). A keyword has no place in /// the sidecar format yet: `dr_pipeline::sidecar::Version` carries `rating` and /// `flag` and nothing else that is not an edit-graph parameter. /// /// So a keyword is, for now, catalog state that reaches the user's other /// devices through the *catalog* merge ([`dr_catalog::merge`]) rather than /// through the sidecar. That is a real limitation and not a silent one: a /// deleted catalog loses keywords where it would keep ratings, until the /// sidecar gains a `dc:subject` field (FR-CAT-13) and this grows the same /// queued write the stars have. fn apply_keyword(window: &AppWindow, ctl: &Rc, word: &str, assigning: bool) { let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|c| c.upgrade()) else { return; }; let images = coll.selected(); // The word as it will be *stored*, resolved before anything is written. // The status line below quotes it back, and quoting what was typed would // report a leading space the catalog is about to drop — leaving the user to // wonder whether it mattered. // // This is also where a blank keyword is caught, which is why it happens // before the selection check: "you typed nothing" is a better answer than // "select an image first" to someone who pressed return on an empty field. let word = match dr_catalog::keywords::normalise(word) { Ok(word) => word, Err(e) => { // `BadName` carries text written to be read by the user rather than // by a developer, so it is shown as it is. window .global::() .set_library_error(format!("{e}").into()); return; } }; // Assigning with nothing selected still means something — it puts the word // in the vocabulary, ready for the photographs it was typed for — so only // the removal half needs a selection to act on. if images.is_empty() && !assigning { window .global::() .set_library_status("Select an image first".into()); return; } let outcome = { let borrow = ctl.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return; }; let conn = catalog.connection(); if assigning { dr_catalog::keywords::assign(conn, &images, &word) } else { dr_catalog::keywords::unassign(conn, &images, &word) } }; let n = match outcome { Ok(n) => n, Err(e) => { window .global::() .set_library_error(format!("{e}").into()); return; } }; window .global::() .set_library_error(slint::SharedString::new()); window .global::() .set_library_status(keyword_summary(&word, n, images.len(), assigning).into()); refresh_keywords(window, ctl, &images); start_xmp_writes(window, ctl, &images); // A filtered grid may no longer hold what was just keyworded — taking // "puffin" off an image while showing only puffins means it belongs // elsewhere now. The same reasoning as a rating that falls below the star // filter. if !ctl.filter.borrow().is_unfiltered() { load_window(window, ctl); } } /// What the status line says about a keyword that just landed. /// /// The honest count, not the requested one: "added to 3 of 12" is what /// happened when nine of them already carried the word, and a message that /// claimed twelve would be teaching the user that the counts are decorative. fn keyword_summary(word: &str, changed: usize, selected: usize, assigning: bool) -> String { if selected == 0 { return format!("Added “{word}” to the keyword list"); } let verb = if assigning { "Added" } else { "Removed" }; let preposition = if assigning { "to" } else { "from" }; if changed == 0 { return if assigning { format!("Every selected photograph already had “{word}”") } else { format!("None of the selected photographs had “{word}”") }; } if changed == selected { let what = if selected == 1 { "1 photograph".to_string() } else { format!("{selected} photographs") }; return format!("{verb} “{word}” {preposition} {what}"); } format!("{verb} “{word}” {preposition} {changed} of {selected}") } /// Apply a judgement to a set of images: catalog first, then sidecars. /// /// # Order matters /// /// The catalog is written **synchronously and first**, so the star appears /// immediately and survives a restart even if the network is down. The sidecar /// write is queued behind it on a worker thread — it is what makes the /// judgement survive a *catalog rebuild* (ARCH §6.12), which is a slower and /// rarer concern than the user seeing their keystroke take effect. /// /// Doing it the other way round would mean a cull that stalls on every /// keypress waiting for a round trip, on a workflow whose entire premise is /// speed (FR-CULL-1). fn apply_judgement( window: &AppWindow, ctl: &Rc, images: &[dr_types::ImageId], rating: Option, flag: Option, ) { if images.is_empty() { // Nothing selected. Said out loud rather than ignored: a keystroke // that silently does nothing reads as a broken key. window .global::() .set_library_status("Select an image first".into()); return; } let writes = { let borrow = ctl.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return; }; let conn = catalog.connection(); let wrote = match (rating, flag) { (Some(r), _) => dr_catalog::rating::set_rating_many(conn, images, r), (_, Some(f)) => dr_catalog::rating::set_flag_many(conn, images, f), // Neither axis named: nothing to do, and not an error. (None, None) => return, }; if let Err(e) = wrote { window .global::() .set_library_error(format!("recording rating: {e}").into()); return; } // Report what happened, in the user's terms rather than as a count of // rows. A bulk judgement on a selection is easy to trigger by accident // and the status line is the only confirmation of its extent. window .global::() .set_library_status(judgement_summary(images.len(), rating, flag).into()); // Refresh the grid and the chips from what actually landed, rather // than assuming the write took: a clamped or coalesced value must show // as what is stored. let visible = ctl.visible_ids(); sync_ratings(window, catalog, &visible); refresh_rating_counts(window, catalog); collect_sidecar_writes(catalog, images) }; // A filtered grid may no longer contain what was just judged — rating an // image 2 while showing "★4+" means it belongs elsewhere now. Reloading // keeps the cells and the header count honest. if !ctl.filter.borrow().is_unfiltered() { load_window(window, ctl); } start_sidecar_writes(window, ctl, writes); start_xmp_writes(window, ctl, images); } /// What the status line says about a judgement that just landed. fn judgement_summary(n: usize, rating: Option, flag: Option) -> String { let what = match (rating, flag) { (Some(0), _) => "unrated".to_string(), (Some(r), _) => format!("{r} star{}", if r == 1 { "" } else { "s" }), (_, Some(dr_types::FlagState::Pick)) => "picked".to_string(), (_, Some(dr_types::FlagState::Reject)) => "rejected".to_string(), (_, Some(dr_types::FlagState::Unflagged)) => "unflagged".to_string(), (None, None) => return String::new(), }; if n == 1 { what } else { format!("{n} images · {what}") } } /// TRACES: FR-DEV-6 /// Apply copied develop settings to a selection of images. /// /// # Why this goes straight to the sidecars /// /// The sidecar is the authoritative store for an edit (ARCH §6.12) and the /// catalog holds no parameters at all — only a `graph_hash` — so there is /// nothing here for the catalog to record. Nor is any image opened: applying /// to forty frames by loading forty develop sessions would mean forty RAW /// downloads and forty demosaics to move some numbers between two maps, which /// is not a thing to ask of a phone. See [`crate::presets`]. /// /// # What the user sees /// /// Nothing in the grid changes — a thumbnail is rendered from the server's /// preview and does not reflect an edit — so the status line is the only /// confirmation, exactly as it is for a bulk judgement. The applied settings /// appear when a target is next opened in develop, which is what reads the /// sidecar back. pub fn paste_settings_to_selection( window: &AppWindow, ctl: &Rc, images: &[dr_types::ImageId], preset: &dr_pipeline::Preset, scope: dr_pipeline::Scope, ) { if images.is_empty() { // Said out loud rather than ignored, matching what a judgement // keystroke does with an empty selection. window .global::() .set_library_status("Select an image first".into()); return; } let writes = { let borrow = ctl.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return; }; // A never-judged image may have no version row yet, and the query // below joins on one. Ratings create them as a side effect; a paste // is the first write path that can reach an image which has never // been rated, so it has to ask for them itself. if let Err(e) = dr_catalog::rating::ensure_default_versions(catalog.connection()) { log::debug!("ensuring versions before a paste: {e}"); } collect_settings_writes(catalog, images, preset, scope) }; if writes.is_empty() { window .global::() .set_library_error("Could not find those images in the catalog.".into()); return; } let count = writes.len(); window.global::().set_library_status( format!( "Applied settings to {count} image{}.", if count == 1 { "" } else { "s" } ) .into(), ); start_sidecar_writes(window, ctl, writes); } /// Gather one settings write per image, addressed by remote path and version. /// /// The uuid comes from the catalog for the same reason a judgement's does: it /// is the identity a cross-device merge keys on, and a generated one would /// write a second version beside the one the image already has (FR-NC-8). fn collect_settings_writes( catalog: &Catalog, images: &[dr_types::ImageId], preset: &dr_pipeline::Preset, scope: dr_pipeline::Scope, ) -> Vec { let placeholders = std::iter::repeat_n("?", images.len()) .collect::>() .join(","); let sql = format!( "SELECT i.source_ref, v.uuid FROM images i JOIN versions v ON v.image_id = i.id AND v.is_default = 1 WHERE i.id IN ({placeholders})" ); let params: Vec = images .iter() .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) .collect(); let Ok(mut stmt) = catalog.connection().prepare(&sql) else { return Vec::new(); }; let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { Ok(library::SidecarWrite { image_path: r.get(0)?, version_uuid: r.get(1)?, amendment: library::Amendment::Settings { preset: preset.clone(), scope, // A paste carries no masks, and must not: a mask is drawn // against one photograph and describes nothing on another. // The target keeps whatever local adjustments it already had. masks: None, // A paste carries no film either, and for a plainer reason // than the masks: a preset is a parameter map, and a stock is // not a parameter. Pasting one would be pasting a choice the // clipboard never captured. film: None, // Nor snapshots: they are states of the photograph they were // taken on, and mean nothing on another. snapshots: None, }, }) }); match rows { Ok(rows) => rows.flatten().collect(), Err(e) => { log::debug!("collecting settings writes: {e}"); Vec::new() } } } /// Gather what the sidecar writer needs for each judged image. /// /// The version uuid comes from the catalog rather than being generated here: /// it is the identity a cross-device merge keys on, so the sidecar and the /// catalog must name the same version or a sync would treat one photograph's /// judgement as two (FR-NC-8). fn collect_sidecar_writes( catalog: &Catalog, images: &[dr_types::ImageId], ) -> Vec { let placeholders = std::iter::repeat_n("?", images.len()) .collect::>() .join(","); let sql = format!( "SELECT i.source_ref, v.uuid, v.rating, v.flag FROM images i JOIN versions v ON v.image_id = i.id AND v.is_default = 1 WHERE i.id IN ({placeholders})" ); let params: Vec = images .iter() .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) .collect(); let Ok(mut stmt) = catalog.connection().prepare(&sql) else { return Vec::new(); }; let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { Ok(library::SidecarWrite { image_path: r.get(0)?, version_uuid: r.get(1)?, amendment: library::Amendment::Judgement { rating: r.get::<_, i64>(2)? as u8, flag: r.get::<_, i64>(3)? as u8, }, }) }); match rows { Ok(rows) => rows.flatten().collect(), Err(e) => { log::debug!("collecting sidecar writes: {e}"); Vec::new() } } } /// Push judgements out to sidecars on a worker, reporting once at the end. /// TRACES: FR-CAT-13 | NFR-R4 /// Write these images' ratings, labels and keywords to the `.xmp` beside /// each, where the user has switched that on. /// /// The record is read from the catalog *now*, whole, rather than carried /// from the gesture: a rating and a keyword typed a second apart are two /// writes of the same file, and the second must not carry a copy of the /// first taken before it landed. pub(crate) fn start_xmp_writes( window: &AppWindow, ctl: &Rc, images: &[dr_types::ImageId], ) { if !ctl.write_xmp.get() || images.is_empty() || ctl.is_offline() { return; } let Some((conn, _)) = ctl.session.borrow().clone() else { return; }; let writes: Vec = { let borrow = ctl.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return; }; let c = catalog.connection(); images .iter() .filter_map(|&image| { let version = dr_catalog::rating::default_version_id(c, image).ok()?; let image_path: String = c .query_row( "SELECT source_ref FROM images WHERE id = ?1", [image.0 as i64], |r| r.get(0), ) .ok()?; Some(library::XmpWrite { image_path, record: crate::xmp_sync::record_of(c, image, version), }) }) .collect() }; if writes.is_empty() { return; } let count = writes.len(); let rx = library::spawn_xmp_writes(conn, writes); let job = ctl.activity.begin( crate::activity::Kind::Upload, format!("Writing {count} XMP sidecar(s)"), ); drain_xmp(window.as_weak(), ctl.clone(), rx, job, false); } /// TRACES: FR-CAT-13 /// The offered reload: take the sidecars' values for every photograph the /// last pull found disagreeing with the catalog. pub(crate) fn start_xmp_reload(window: &AppWindow, ctl: &Rc) { let Some((conn, _)) = ctl.session.borrow().clone() else { return; }; let paths: Vec = { let borrow = ctl.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return; }; let Some(root_id) = root_id_of(catalog, &conn.account.root) else { return; }; crate::xmp_sync::conflicts(catalog, root_id) .into_iter() .map(|c| c.path) .collect() }; if paths.is_empty() { return; } let count = paths.len(); let rx = library::spawn_xmp_reload( conn.clone(), conn.account.root.clone(), library::catalog_path(&conn.account), paths, ); let job = ctl.activity.begin( crate::activity::Kind::Download, format!("Reloading {count} XMP sidecar(s)"), ); drain_xmp(window.as_weak(), ctl.clone(), rx, job, true); } /// Wait for a batch of XMP work to report, then say what it did. fn drain_xmp( weak: slint::Weak, ctl: Rc, rx: std::sync::mpsc::Receiver, job: crate::activity::Activity, reload: bool, ) { let timer = slint::Timer::default(); let job = RefCell::new(Some(job)); let ctl_cb = ctl.clone(); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(200), move || { let ctl = &ctl_cb; let message = match rx.try_recv() { Ok(m) => m, Err(std::sync::mpsc::TryRecvError::Empty) => return, Err(std::sync::mpsc::TryRecvError::Disconnected) => { stop(&ctl.xmp_timer); return; } }; stop(&ctl.xmp_timer); let library::XmpMessage::Finished { written, failed, last_error, } = message; let status = match (reload, failed, last_error) { (false, 0, _) => format!("{written} XMP sidecar(s) written"), (true, 0, _) => format!("{written} photograph(s) reloaded from XMP"), (_, n, Some(e)) => format!("{n} XMP sidecar(s) failed: {e}"), (_, n, None) => format!("{n} XMP sidecar(s) failed"), }; if let Some(job) = job.borrow_mut().take() { if failed > 0 { job.fail(status.clone()); } else { job.finish(status.clone()); } } if let Some(w) = weak.upgrade() { w.global::().set_library_status(status.into()); if reload { // The grid draws what the reload changed, and the // settings page stops offering what is settled. let visible = ctl.visible_ids(); if let Some(catalog) = ctl.catalog.borrow().as_ref() { sync_ratings(&w, catalog, &visible); refresh_rating_counts(&w, catalog); } refresh_xmp_conflicts(&w, ctl); } } }, ); *ctl.xmp_timer.borrow_mut() = Some(timer); } /// TRACES: FR-CAT-13 /// How many sidecars the last pull found disagreeing with the catalog, for /// the settings page to offer the reload against. pub(crate) fn refresh_xmp_conflicts(window: &AppWindow, ctl: &Rc) { let count = (|| { let (conn, _) = ctl.session.borrow().clone()?; let borrow = ctl.catalog.borrow(); let catalog = borrow.as_ref()?; let root_id = root_id_of(catalog, &conn.account.root)?; Some(crate::xmp_sync::conflicts(catalog, root_id).len()) })() .unwrap_or(0); window.set_settings_xmp_conflicts(count as i32); } fn root_id_of(catalog: &Catalog, root: &str) -> Option { catalog .connection() .query_row( "SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'", [root], |r| r.get(0), ) .ok() } pub(crate) fn start_sidecar_writes( window: &AppWindow, ctl: &Rc, writes: Vec, ) { if writes.is_empty() { return; } // TRACES: FR-CAT-9 // Offline is passed down rather than used to skip. // // It used to skip, and the reasoning was that a cull stays responsive // because the rating is safe in the catalog. That held for judgements and // not for edits: the catalog stores no parameters, so a pasted edit made // offline survived nowhere at all. Every write now commits to the local // sidecar cache first and the upload is best-effort, which keeps the // keystroke path off the network — the original concern — without the // write being conditional on it. let offline = ctl.is_offline(); let Some((conn, _)) = ctl.session.borrow().clone() else { return; }; let Some(cache_dir) = ctl.sidecar_cache_dir() else { return; }; let count = writes.len(); let rx = library::spawn_sidecar_writes(conn.clone(), writes, cache_dir, offline); let timer = slint::Timer::default(); let weak = window.as_weak(); let ctl_cb = ctl.clone(); // The writer reports once at the end, so there is no per-file progress to // show — but a cull that has just rated forty frames has forty uploads in // flight, and "is that saved yet" deserves an answer somewhere. let job = ctl.activity.begin( crate::activity::Kind::Upload, format!("Saving {count} judgement(s)"), ); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(200), move || { let Some(w) = weak.upgrade() else { return }; // One message is all this channel ever carries — the writer reports // `Finished` once and hangs up — so this drains a single item rather // than looping like the scan and thumbnail drains do. match rx.try_recv() { Ok(library::SidecarMessage::Finished { written, queued, failed, last_error, }) => { if failed == 0 && queued > 0 { // Recorded locally, waiting for the server. Said out // loud because the user has just made an edit with no // connection and deserves to know it is safe — the // old behaviour here was to drop it silently. log::debug!("{queued} sidecar(s) queued for upload"); ctl_cb.outbox_maybe_dirty.set(true); job.finish_quietly(); w.global::().set_library_status( format!("{queued} edit(s) saved · will upload when back online").into(), ); stop(&ctl_cb.sidecar_timer); return; } if failed > 0 { // A failed upload left the edit in the outbox. ctl_cb.outbox_maybe_dirty.set(true); log::warn!( "{failed} sidecar write(s) failed: {}", last_error.clone().unwrap_or_default() ); job.fail(format!( "{written} saved · {failed} failed: {}", last_error.clone().unwrap_or_default() )); // Said plainly, because the consequence is specific: // the rating is safe in the catalog but will not // survive deleting it. w.global::().set_library_status( format!( "{written} saved · {failed} could not be written \ to the library folder" ) .into(), ); } else { log::debug!("{written} sidecar(s) written"); // Quietly: a cull produces one of these every few // seconds and none of them is news. job.finish_quietly(); } stop(&ctl_cb.sidecar_timer); } Err(std::sync::mpsc::TryRecvError::Empty) => {} Err(std::sync::mpsc::TryRecvError::Disconnected) => { stop(&ctl_cb.sidecar_timer); } } }, ); *ctl.sidecar_timer.borrow_mut() = Some(timer); } /// Slint carries the flag as an integer, matching the catalog's encoding. fn flag_code(f: dr_types::FlagState) -> i32 { match f { dr_types::FlagState::Unflagged => 0, dr_types::FlagState::Pick => 1, dr_types::FlagState::Reject => 2, } } /// The flag an integer from Slint stands for. fn flag_from_code(v: i32) -> dr_types::FlagState { match v { 1 => dr_types::FlagState::Pick, 2 => dr_types::FlagState::Reject, _ => dr_types::FlagState::Unflagged, } } /// --- ratings and flags (FR-CAT-5, FR-CULL-4) -------------------------- pub(super) fn wire_ratings_and_flags( window: &AppWindow, ctl: &Rc, coll_ctl: &Rc, ) { // Clicking a star rates *that cell*, not the selection. The pointer names // one photograph unambiguously, and a click that silently rated forty // others would be a trap — the keyboard is the bulk gesture. { let weak = window.as_weak(); let ctl = ctl.clone(); window .global::() .on_library_cell_rated(move |row, stars| { let Some(w) = weak.upgrade() else { return }; let id = ctl .image_ids .borrow() .get(row as usize) .map(|id| dr_types::ImageId(*id as u64)); let Some(id) = id else { return }; apply_judgement(&w, &ctl, &[id], Some(stars.clamp(0, 5) as u8), None); }); } // Fold a burst up, or open it out (FR-CULL-5). A reload rather than a repaint, because // it changes what the grid's query returns — see [`crate::bursts::toggle`]. { let weak = window.as_weak(); let ctl = ctl.clone(); window .global::() .on_library_burst_toggled(move |row| { let Some(w) = weak.upgrade() else { return }; let id = ctl .image_ids .borrow() .get(row as usize) .map(|id| dr_types::ImageId(*id as u64)); let Some(id) = id else { return }; let changed = { let borrow = ctl.catalog.borrow(); match borrow.as_ref() { Some(catalog) => crate::bursts::toggle(catalog, id), None => false, } }; if changed { ctl.requested.borrow_mut().clear(); load_window(&w, &ctl); } }); } // Name the frame an open burst folds to (FR-CULL-5). The default is the // earliest of them, chosen because it is a fact about the clock and not a // judgement about the photograph; this is the photographer, who is the only // one who knows which of the twelve is the keeper, saying otherwise. // // A repaint of the badges rather than a reload, which is what separates it // from folding a burst up: the grid's query returns the same rows either // way while the group is open, and only the mark on them moves. Refilled // for the whole window in one statement — the tick has to leave the frame // that was carrying it, and that frame is a different cell. { let weak = window.as_weak(); let ctl = ctl.clone(); window .global::() .on_library_burst_representative_chosen(move |row| { let Some(w) = weak.upgrade() else { return }; let ids: Vec = ctl .image_ids .borrow() .iter() .map(|id| dr_types::ImageId(*id as u64)) .collect(); let Some(id) = ids.get(row as usize).copied() else { return; }; let borrow = ctl.catalog.borrow(); let Some(catalog) = borrow.as_ref() else { return; }; if crate::bursts::choose(catalog, id) { crate::bursts::sync_badges(&w, catalog, &ids); } }); } // A rating or flag key. Applies to the whole selection, which is what // makes judging a run of frames one keystroke rather than forty. { let weak = window.as_weak(); let ctl = ctl.clone(); let coll_for_keys = coll_ctl.clone(); window .global::() .on_library_judged(move |rating, flag| { let Some(w) = weak.upgrade() else { return }; let chosen = coll_for_keys.selected(); // Exactly one axis is meant per keystroke; the other arrives as // -1 so a star press cannot disturb a flag or the reverse. if rating >= 0 { apply_judgement(&w, &ctl, &chosen, Some(rating.clamp(0, 5) as u8), None); } else if flag >= 0 { apply_judgement(&w, &ctl, &chosen, None, Some(flag_from_code(flag))); } }); } } /// --- keywords (FR-CAT-5, FR-CAT-6) ------------------------------------ /// /// Three callbacks and no state of their own: the sheet's open/shut is local /// to the `.slint` file, and what a keyword applies to is the grid selection /// the collections controller already owns. A second copy of either here is /// a second thing that can disagree with the first. pub(super) fn wire_keywords( window: &AppWindow, ctl: &Rc, coll_ctl: &Rc, ) { { let weak = window.as_weak(); let ctl = ctl.clone(); let coll_for_keywords = coll_ctl.clone(); window .global::() .on_library_keywords_opened(move || { let Some(w) = weak.upgrade() else { return }; refresh_keywords(&w, &ctl, &coll_for_keywords.selected()); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window .global::() .on_library_assign_keyword(move |word| { let Some(w) = weak.upgrade() else { return }; apply_keyword(&w, &ctl, word.as_str(), true); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window .global::() .on_library_unassign_keyword(move |word| { let Some(w) = weak.upgrade() else { return }; apply_keyword(&w, &ctl, word.as_str(), false); }); } } #[cfg(test)] mod tests { use super::*; // --- what the status line says about a keyword (FR-CAT-5) ------------- // // Split out from the callback for the same reason `decide_drop` is: the // sheet cannot be driven from a test, and this is the part that can // actually mislead someone. /// TRACES: FR-CAT-5 #[test] fn a_partly_applied_keyword_reports_the_honest_count() { // Nine of the twelve already had it. Claiming twelve is how a user // learns that the counts are decorative. assert_eq!( keyword_summary("puffin", 3, 12, true), "Added “puffin” to 3 of 12" ); } /// TRACES: FR-CAT-5 #[test] fn a_keyword_that_changed_nothing_says_so_rather_than_claiming_success() { assert_eq!( keyword_summary("puffin", 0, 12, true), "Every selected photograph already had “puffin”" ); assert_eq!( keyword_summary("puffin", 0, 12, false), "None of the selected photographs had “puffin”" ); } /// TRACES: FR-CAT-5 #[test] fn one_photograph_is_singular() { // "Added to 1 photographs" is the kind of small wrongness that makes // the rest of the interface look unfinished. assert_eq!( keyword_summary("puffin", 1, 1, true), "Added “puffin” to 1 photograph" ); assert_eq!( keyword_summary("puffin", 2, 2, true), "Added “puffin” to 2 photographs" ); } /// TRACES: FR-CAT-5 #[test] fn removing_a_keyword_reads_as_removal() { assert_eq!( keyword_summary("blurry", 4, 4, false), "Removed “blurry” from 4 photographs" ); } /// TRACES: FR-CAT-5 #[test] fn typing_a_word_with_nothing_selected_says_what_it_did_do() { // It builds the vocabulary, which is a legitimate thing to do ahead of // a shoot — so it must not report itself as having keyworded nothing. assert_eq!( keyword_summary("puffin", 0, 0, true), "Added “puffin” to the keyword list" ); } }