diff --git a/core/dr-catalog/src/collections.rs b/core/dr-catalog/src/collections.rs index 30d642a..0f4a5e0 100644 --- a/core/dr-catalog/src/collections.rs +++ b/core/dr-catalog/src/collections.rs @@ -682,7 +682,7 @@ fn require_exists(conn: &Connection, id: CollectionId) -> Result<(), CatalogErro /// same reasoning as the connector's date parsing. Version 4 layout, seeded /// from the OS via `getrandom` through `rusqlite`'s existing dependency-free /// path — see below. -fn new_uuid() -> String { +pub(crate) fn new_uuid() -> String { let b = random_bytes(); // Version 4, variant 1, per RFC 4122 §4.4. let v6 = (b[6] & 0x0F) | 0x40; diff --git a/core/dr-catalog/src/error.rs b/core/dr-catalog/src/error.rs index b6d6f08..0a8f0da 100644 --- a/core/dr-catalog/src/error.rs +++ b/core/dr-catalog/src/error.rs @@ -42,6 +42,15 @@ pub enum CatalogError { #[error("no such collection: {0}")] NoSuchCollection(u64), + /// A keyword the caller named is gone — deleted, or fused into another by a + /// merge while its id sat in a UI model. + /// + /// Its own variant rather than a silent no-op because the two are different + /// answers to the user: a rename that quietly did nothing looks exactly like + /// a rename that did not take. + #[error("no such keyword: {0}")] + NoSuchKeyword(u64), + /// Images were dropped onto a smart collection. /// /// A smart collection's membership *is* its selector, so member rows would diff --git a/core/dr-catalog/src/keywords.rs b/core/dr-catalog/src/keywords.rs new file mode 100644 index 0000000..c3676ba --- /dev/null +++ b/core/dr-catalog/src/keywords.rs @@ -0,0 +1,1220 @@ +//! TRACES: FR-CAT-5 | FR-CAT-6 | FR-CAT-13 | NFR-R5 +//! Keywords: the vocabulary, the assignments, and the edits the UI performs. +//! +//! The read half of this shipped with the catalog and the write half did not. +//! [`crate::query`] has compiled [`dr_types::Selector::Keyword`] against the +//! `keywords` table since v1, and [`dr_types::Selector::Text`] substring-matches +//! it, so the library could always be searched by keyword — but nothing could +//! ever *put* one there. A user could filter to a word they had no way to +//! apply. This module is the missing half. +//! +//! # Two tables, and which one is the fact +//! +//! **`keywords`** is the assignment: `(version_id, keyword)`, where the keyword +//! is the *word itself* as text. This is the durable fact. It is what +//! [`crate::query`] matches, what a sidecar carries, and what XMP `dc:subject` +//! interoperates on (FR-CAT-13) — every one of which speaks in strings. +//! +//! **`keyword_terms`** is the identity: uuid, revision, tombstone. It exists so +//! that a *rename* and a *deletion* have something a cross-device merge can key +//! on, and so that a keyword can exist in the vocabulary before any photograph +//! carries it. It is not referenced by the assignment rows; see the V6 +//! migration in [`crate::schema`] for why pointing at it would be a mistake. +//! +//! A rename therefore writes both: the term rows (so the edit merges) and every +//! assignment carrying the old text (so the search, the sidecar and the XMP all +//! agree). Those writes are one transaction, because a catalog holding half a +//! rename would show the keyword under one name and find it under the other. +//! [`rename`] explains why the old identity is retired rather than relabelled. +//! +//! # Which version an assignment lands on +//! +//! The schema hangs keywords off `versions`, not `images`, and that is right: +//! FR-NC-8 makes the sidecar a keyed set of versions, so everything durable +//! about a photograph is stored per version. But a *write* from the UI has to +//! land somewhere definite, and it lands on the **default** version — the same +//! choice [`crate::rating`] makes, for the same reason. +//! +//! Reading is deliberately asymmetric: [`crate::query`] matches a keyword on +//! *any* version of an image (`EXISTS ... WHERE kv.image_id = images.id`), and +//! [`for_images`] does the same. A keyword names what is in the frame, so a +//! word applied to a virtual copy must still find the photograph — but there +//! must be exactly one place a UI write goes, or two copies of one frame come +//! to disagree about their own subject. +//! +//! # Uniqueness is converged upon, not constrained +//! +//! `keyword_terms.name` carries no unique index. Two devices that each type +//! "Iceland" both create a term, with different uuids, and neither is wrong +//! until they meet. A constraint would abort the merge transaction at that +//! moment — which is the ordinary case, not a corner one. +//! +//! Instead: [`create`] resolves an existing name rather than adding a second +//! row, and [`fuse_duplicates`] collapses a cross-device pair onto the +//! lexicographically smaller uuid. That rule is deterministic and symmetric, so +//! both devices reach the same answer without a round trip. +//! +//! # Keywords are user text, and they only ever bind +//! +//! Every keyword here reaches SQLite as a bound parameter. `crate::query` has +//! the same rule and a test that asserts it; this module is the write side of +//! the same guarantee, and the reason it matters more here is that this is +//! where the text arrives from the user in the first place. + +use rusqlite::{Connection, OptionalExtension}; + +use dr_types::ImageId; + +use crate::error::CatalogError; + +/// A keyword's local row id in `keyword_terms`. +/// +/// Local to this catalog and meaningless on another device, exactly as +/// [`dr_types::CollectionId`] is — [`Keyword::uuid`] is what a merge keys on. +/// +/// Defined here rather than in `dr-types` because nothing outside the catalog +/// and the panel that draws it has any use for it: an id that names a row in a +/// rebuildable index is not a term of the shared vocabulary the way an +/// `ImageId` is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct KeywordId(pub u64); + +/// One keyword, as the vocabulary list draws it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Keyword { + pub id: KeywordId, + /// Device-independent identity. The integer `id` is local and collides + /// across devices; this is what a merge keys on. + pub uuid: String, + pub name: String, + /// How many images in the whole library carry it. Shown beside the word so + /// the user can tell a keyword they use from one they typed once by + /// mistake. + pub image_count: usize, +} + +/// A keyword's standing across a *selection*, for the panel. +/// +/// The three-way distinction is the whole reason this type exists: applying a +/// keyword to forty photographs where thirty already carry it must not look +/// the same as applying it to forty that do not, and removing one that only +/// some of them carry must not silently claim to have removed it from all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Coverage { + /// No image in the selection carries it. + None, + /// Some do and some do not — Lightroom's dash rather than a tick. + Some, + /// Every image in the selection carries it. + All, +} + +/// A keyword plus how much of the current selection it covers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectionKeyword { + pub keyword: Keyword, + pub coverage: Coverage, + /// How many of the *selected* images carry it. + pub selected_count: usize, +} + +/// Longest keyword accepted. +/// +/// Not a storage limit — SQLite would take a megabyte — but a paste guard. The +/// field that feeds this is one line in a sheet, and a keyword the width of a +/// paragraph is a mis-paste rather than an intention. Truncating rather than +/// refusing, so the paste is recoverable by editing rather than lost. +pub const MAX_KEYWORD_LEN: usize = 128; + +/// Create a keyword, or return the one that already carries this name. +/// +/// **Resolving rather than refusing** is what separates this from +/// [`crate::collections::create`], which happily makes a second "Iceland". A +/// keyword *is* its text — that is what the assignment rows store and what XMP +/// carries — so two term rows with one name are two identities for one thing, +/// and the second would be a duplicate in the vocabulary list that no amount +/// of user care could distinguish from the first. +/// +/// The UUID is generated here rather than taken from the caller: it is the +/// merge identity, and a caller that reuses one fuses two keywords at the next +/// sync. +pub fn create(conn: &Connection, name: &str) -> Result { + let name = normalise(name)?; + + // A tombstoned row is deliberately *not* resurrected here: it carries a + // revision that says "deleted", and reusing it would make the new keyword + // inherit an argument it was never part of. A fresh uuid starts the + // conversation again, which is what the user asked for by typing the word. + if let Some(id) = live_id_for_name(conn, &name)? { + return Ok(id); + } + + let now = now_secs(); + conn.execute( + "INSERT INTO keyword_terms(uuid, name, created, revision, modified) + VALUES (?1, ?2, ?3, 1, ?3)", + rusqlite::params![new_uuid(), name, now], + )?; + Ok(KeywordId(conn.last_insert_rowid() as u64)) +} + +/// Rename a keyword, everywhere it appears. +/// +/// Returns the identity the word now has, which is **not** the one passed in. +/// +/// # A rename retires one word and raises another +/// +/// The obvious implementation — change `name` on the term row and keep its +/// uuid — is wrong here, and the reason is that assignments store *text*. Take +/// two keywords, "Icland" and "Iceland", and rename the first onto the second. +/// Something has to give, because two live rows may not both be "Iceland", and +/// every way of choosing between them by uuid or by revision loses the rename +/// on the device that chose the other way. The rename then silently fails to +/// propagate, which is the worst of the outcomes available. +/// +/// So a rename is modelled as what it actually is to a body of *text*: the old +/// word stops existing and the new one exists. The old term row is tombstoned +/// **keeping its old name**, the new word gets (or already has) a live row, and +/// every assignment is rewritten between them. That composes with the merge +/// rules already in place — the tombstone travels as a deletion and takes the +/// old word's assignments with it on every device, and the new word travels as +/// an ordinary keyword — rather than needing a rule of its own. +/// +/// One transaction over both tables. A catalog holding half a rename would show +/// a photograph under the new word and fail to find it under either. +/// +/// Renaming onto a name another keyword already holds therefore **fuses the +/// two**, which is almost always what was meant: the user is correcting +/// "Icland" to "Iceland" and there is already an "Iceland". Refusing would +/// leave them to do it by hand, image by image, with no bulk gesture to do it +/// with. +pub fn rename(conn: &Connection, id: KeywordId, name: &str) -> Result { + let name = normalise(name)?; + let old = name_of(conn, id)?; + if old == name { + // Not an error, and not an edit either: bumping the revision for a + // no-op rename would let an idle device win a merge against one that + // did real work. + return Ok(id); + } + + let tx = conn.unchecked_transaction()?; + + // Assignments first. If this fails, the term rows are untouched and the + // catalog is merely unchanged rather than internally inconsistent. + rewrite_assignments(&tx, &old, &name)?; + retire(&tx, id)?; + let now = create(&tx, &name)?; + + tx.commit()?; + Ok(now) +} + +/// Delete a keyword, leaving a tombstone. +/// +/// The term row survives with `deleted = 1` because a merge against a device +/// that still holds the keyword would otherwise resurrect it — the same rule +/// [`crate::collections::delete`] follows. +/// +/// The assignment rows go outright. They carry no independent identity: the +/// tombstone is what merges, and a photograph keyworded with a word that no +/// longer exists would be searchable by a term absent from every list. +/// +/// Returns how many assignments went. +pub fn delete(conn: &Connection, id: KeywordId) -> Result { + let name = name_of(conn, id)?; + + let tx = conn.unchecked_transaction()?; + let removed = tx.execute("DELETE FROM keywords WHERE keyword = ?1", [&name])?; + retire(&tx, id)?; + tx.commit()?; + Ok(removed) +} + +/// The whole vocabulary, most-used first and then alphabetical. +/// +/// Used-first because the list is a target for a thumb: the words a +/// photographer reaches for are the ones they already use, and burying them +/// under a one-off typo sorted to the top is what makes a keyword list stop +/// being used. Alphabetical *within* a count so the order is stable across +/// launches and across devices — row-id order would differ per device, which +/// is disorienting on the same library seen from two machines. +/// +/// One grouped aggregate rather than a count per row: the sheet redraws on +/// every assignment, and a query per keyword would be one statement per word +/// in the library. +pub fn list(conn: &Connection) -> Result, CatalogError> { + let mut stmt = conn.prepare( + // DISTINCT image, not row: a word on two versions of one frame is one + // photograph, and reporting two is the kind of small lie that makes a + // user stop trusting the counts. + "SELECT t.id, t.uuid, t.name, + (SELECT count(DISTINCT v.image_id) + FROM keywords k JOIN versions v ON v.id = k.version_id + WHERE k.keyword = t.name) + FROM keyword_terms t + WHERE t.deleted = 0 + ORDER BY 4 DESC, t.name COLLATE NOCASE, t.id", + )?; + let rows = stmt + .query_map([], |r| { + Ok(Keyword { + id: KeywordId(r.get::<_, i64>(0)? as u64), + uuid: r.get(1)?, + name: r.get(2)?, + image_count: r.get::<_, i64>(3)? as usize, + }) + })? + .collect::, _>>()?; + Ok(rows) +} + +/// Assign a keyword to images, creating the keyword if it is new. +/// +/// The bulk form is the *only* form, because keywording a selection is the +/// common case rather than the exception: a photographer picks out the frames +/// with the puffin in them and applies "puffin" once. One transaction, so a +/// crash partway through cannot leave half a selection keyworded — the same +/// reasoning as [`crate::rating::set_rating_many`]. +/// +/// Additive and idempotent. An image that already carries the word is left +/// alone rather than rewritten, because re-applying a keyword to a selection +/// that overlaps what is already tagged is a normal thing to do. +/// +/// Returns how many images genuinely gained it, which is what the UI reports — +/// "added to 3 of 12" is the honest message when nine already had it. +pub fn assign(conn: &Connection, images: &[ImageId], name: &str) -> Result { + let name = normalise(name)?; + let tx = conn.unchecked_transaction()?; + + // The term row first, so the word appears in the vocabulary even when the + // selection turns out to be empty — typing a keyword into the field and + // pressing return is a legitimate way to build a vocabulary ahead of the + // photographs it will be used on. + create(&tx, &name)?; + + let mut added = 0; + { + let mut stmt = tx.prepare( + "INSERT INTO keywords(version_id, keyword) VALUES (?1, ?2) + ON CONFLICT(version_id, keyword) DO NOTHING", + )?; + for image in images { + // Creates the version if the image has none — a library scanned + // before versions existed, or a scan interrupted between the image + // insert and the commit. Failing the keyword because of either + // would be the wrong answer: the user typed a word and expects it + // to stick. + let version = crate::rating::default_version_id(&tx, *image)?; + added += stmt.execute(rusqlite::params![version, name])?; + } + } + + tx.commit()?; + Ok(added) +} + +/// Take a keyword off images. +/// +/// Removes the assignment only. The keyword itself survives in the vocabulary +/// and on every other photograph that carries it — that asymmetry is the point +/// of a join table, and it is why this is "remove from these images" and never +/// "delete the keyword". [`delete`] is the other gesture, and it says so. +/// +/// Removes it from **every** version of each image, not only the default. A +/// user who unticks a word is saying the photograph is not of that thing, and +/// leaving it on a virtual copy would keep the frame in the search results +/// with nothing in the panel to explain why. +pub fn unassign(conn: &Connection, images: &[ImageId], name: &str) -> Result { + let name = normalise(name)?; + if images.is_empty() { + return Ok(0); + } + + let tx = conn.unchecked_transaction()?; + let mut removed = 0; + { + let mut stmt = tx.prepare( + "DELETE FROM keywords + WHERE keyword = ?2 + AND version_id IN (SELECT id FROM versions WHERE image_id = ?1)", + )?; + for image in images { + if stmt.execute(rusqlite::params![image.0 as i64, name])? > 0 { + removed += 1; + } + } + } + tx.commit()?; + Ok(removed) +} + +/// Every keyword on one image, alphabetically. +/// +/// Across all its versions, and de-duplicated: the panel shows what the +/// photograph is of, and a word on two virtual copies is still one subject. +pub fn for_image(conn: &Connection, image: ImageId) -> Result, CatalogError> { + let mut stmt = conn.prepare( + "SELECT DISTINCT k.keyword + FROM keywords k JOIN versions v ON v.id = k.version_id + WHERE v.image_id = ?1 + ORDER BY k.keyword COLLATE NOCASE", + )?; + let rows = stmt + .query_map([image.0 as i64], |r| r.get(0))? + .collect::, _>>()?; + Ok(rows) +} + +/// The vocabulary, annotated with how much of `images` each keyword covers. +/// +/// This is what the keywording panel draws: one list, in which a word the whole +/// selection already carries, a word only some of it carries, and a word none +/// of it carries are three visibly different things. +/// +/// Two statements regardless of the selection size — the vocabulary, and one +/// grouped count over the selection. A count per keyword would be one query +/// per word on every redraw, which is the cost `crate::rating::judgements` +/// exists to avoid for stars. +/// +/// A word that is on an image but has somehow lost its term row still appears, +/// synthesised with no identity. That happens to a catalog rebuilt from +/// sidecars between the rebuild and the next [`adopt_orphan_terms`], and a +/// panel that omitted the keywords the photograph visibly has would read as the +/// keywords having been lost. +pub fn for_images( + conn: &Connection, + images: &[ImageId], +) -> Result, CatalogError> { + let vocabulary = list(conn)?; + if images.is_empty() { + return Ok(vocabulary + .into_iter() + .map(|keyword| SelectionKeyword { + keyword, + coverage: Coverage::None, + selected_count: 0, + }) + .collect()); + } + + // Placeholders are generated from the *count* of ids, never from any text + // that came from outside — the same rule `rating::judgements` follows, and + // the reason a keyword can never reach SQL as anything but a parameter. + let placeholders = std::iter::repeat_n("?", images.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT k.keyword, count(DISTINCT v.image_id) + FROM keywords k JOIN versions v ON v.id = k.version_id + WHERE v.image_id IN ({placeholders}) + GROUP BY k.keyword" + ); + let params: Vec = images + .iter() + .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) + .collect(); + + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); + { + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)) + })?; + for (word, n) in rows.flatten() { + counts.insert(word, n as usize); + } + } + + let mut out: Vec = Vec::with_capacity(vocabulary.len()); + let mut seen = std::collections::HashSet::new(); + for keyword in vocabulary { + let n = counts.get(&keyword.name).copied().unwrap_or(0); + seen.insert(keyword.name.clone()); + out.push(SelectionKeyword { + coverage: coverage_of(n, images.len()), + selected_count: n, + keyword, + }); + } + + // Words the selection carries that the vocabulary has no row for. Sorted + // and appended rather than interleaved, so the list the user has been + // looking at does not reshuffle around them. + let mut orphans: Vec<(String, usize)> = counts + .into_iter() + .filter(|(word, _)| !seen.contains(word)) + .collect(); + orphans.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); + for (name, n) in orphans { + out.push(SelectionKeyword { + keyword: Keyword { + // Zero, because there is no row. The panel treats it as a word + // it can assign and unassign but not rename — which is exactly + // true until the next backfill gives it an identity. + id: KeywordId(0), + uuid: String::new(), + image_count: n, + name, + }, + coverage: coverage_of(n, images.len()), + selected_count: n, + }); + } + + Ok(out) +} + +fn coverage_of(carrying: usize, selected: usize) -> Coverage { + if carrying == 0 { + Coverage::None + } else if carrying >= selected { + Coverage::All + } else { + Coverage::Some + } +} + +/// Give a term row to every word some image carries without one. +/// +/// Runs from [`crate::schema::backfill`] on every open. Cheap on the common +/// path: one anti-joined scan of an indexed column that inserts nothing once +/// the vocabulary is complete. +/// +/// Returns how many terms were adopted, so an import or a rebuild can log it +/// rather than silently writing thousands of rows. +pub fn adopt_orphan_terms(conn: &Connection) -> Result { + let orphans: Vec = { + let mut stmt = conn.prepare( + // A tombstone counts as "has a term row". A word the user deleted + // whose assignment somehow outlived the deletion must not be + // quietly readmitted to the vocabulary; it stays visible as an + // orphan in [`for_images`] instead, which is a state someone can + // see and act on rather than one that silently undoes a deletion. + "SELECT DISTINCT k.keyword FROM keywords k + WHERE NOT EXISTS (SELECT 1 FROM keyword_terms t + WHERE t.name = k.keyword)", + )?; + let found = stmt + .query_map([], |r| r.get(0))? + .collect::, _>>()?; + found + }; + if orphans.is_empty() { + return Ok(0); + } + + // One transaction for the batch. An import from Lightroom can bring in + // hundreds of keywords, and per-statement commits would be hundreds of + // fsyncs for what is conceptually one adoption. + let tx = conn.unchecked_transaction()?; + let now = now_secs(); + { + let mut stmt = tx.prepare( + "INSERT INTO keyword_terms(uuid, name, created, revision, modified) + VALUES (?1, ?2, ?3, 1, ?3)", + )?; + for name in &orphans { + stmt.execute(rusqlite::params![new_uuid(), name, now])?; + } + } + tx.commit()?; + Ok(orphans.len()) +} + +/// Collapse term rows that describe the same word onto one identity. +/// +/// The keyword *is* its text, so two live rows named "Iceland" are two +/// identities for one thing. That happens for one ordinary reason: two devices +/// each typed the word before they had ever synced, and each minted a uuid. +/// +/// The survivor is the **lexicographically smallest uuid**, and the losers are +/// deleted outright rather than tombstoned. Both halves of that matter: +/// +/// - *Smallest uuid* is a rule both devices apply to the same pair and reach +/// the same answer from, with no round trip and no ordering dependency. Any +/// rule that consulted a revision or a timestamp would let two devices pick +/// different survivors and never converge. +/// - *Deleted, not tombstoned*, because the word itself has not been deleted — +/// the redundant row is being retired, and a tombstone would propagate as +/// "the user removed this keyword" and take the assignments with it. +/// +/// Assignment rows need no repair: they store the text, which is the same on +/// both sides, which is precisely why fusing is possible at all. +/// +/// Returns how many rows were retired. +pub fn fuse_duplicates(conn: &Connection) -> Result { + let n = conn.execute( + "DELETE FROM keyword_terms + WHERE deleted = 0 + AND uuid > (SELECT min(o.uuid) FROM keyword_terms o + WHERE o.deleted = 0 AND o.name = keyword_terms.name)", + [], + )?; + Ok(n) +} + +/// Point every assignment at a new spelling of the same word. +/// +/// `INSERT OR IGNORE` then `DELETE` rather than a bare `UPDATE`, because a +/// version may already carry the destination word — renaming "Icland" to +/// "Iceland" on a frame that has both — and the primary key would reject the +/// update for that row and abort the rename for every other. +fn rewrite_assignments(conn: &Connection, from: &str, to: &str) -> Result<(), CatalogError> { + conn.execute( + "INSERT OR IGNORE INTO keywords(version_id, keyword) + SELECT version_id, ?2 FROM keywords WHERE keyword = ?1", + rusqlite::params![from, to], + )?; + conn.execute("DELETE FROM keywords WHERE keyword = ?1", [from])?; + Ok(()) +} + +/// The stored form of a keyword the user typed. +/// +/// Trimmed, inner whitespace collapsed, and length-capped. Trimming matters +/// more than it looks: `keywords.keyword` is compared with `=` by +/// [`crate::query`], so " puffin" and "puffin" would be two keywords that look +/// identical in every list and never match each other's searches. +/// +/// Case is deliberately **preserved**. "Iceland" is a place and "iceland" is a +/// typo of it, and a photographer who capitalises their proper nouns should +/// find them capitalised. The lists sort `COLLATE NOCASE` so the two still land +/// beside each other where both exist. +/// +/// Public because a caller that is about to *tell the user* what it did needs +/// the word as it will be stored, not as it was typed. Reporting `Added " +/// puffin "` for something the list then shows as `puffin` is a small +/// inconsistency, but it is the kind that makes a user wonder whether the +/// leading space mattered — and the only way to answer that from the outside +/// is to duplicate this rule, which is how the two come to disagree. +pub fn normalise(name: &str) -> Result { + let collapsed = name.split_whitespace().collect::>().join(" "); + if collapsed.is_empty() { + return Err(CatalogError::BadName( + "A keyword needs a word in it.".into(), + )); + } + // Truncated on a *character* boundary: `String::truncate` panics mid-code + // point, and a keyword is as likely to be "Þingvellir" as "puffin". + Ok(collapsed.chars().take(MAX_KEYWORD_LEN).collect()) +} + +/// The live term row holding this exact name, if there is one. +fn live_id_for_name(conn: &Connection, name: &str) -> Result, CatalogError> { + let id: Option = conn + .query_row( + "SELECT id FROM keyword_terms WHERE name = ?1 AND deleted = 0 ORDER BY uuid LIMIT 1", + [name], + |r| r.get(0), + ) + .optional()?; + Ok(id.map(|v| KeywordId(v as u64))) +} + +fn name_of(conn: &Connection, id: KeywordId) -> Result { + conn.query_row( + "SELECT name FROM keyword_terms WHERE id = ?1 AND deleted = 0", + [id.0 as i64], + |r| r.get(0), + ) + .optional()? + .ok_or(CatalogError::NoSuchKeyword(id.0)) +} + +/// Tombstone a term row, bumping its revision. +/// +/// **The name is deliberately left as it was.** A tombstone is read by the +/// merge as "the user removed *this word*", and the other device acts on it by +/// deleting the assignments carrying that text — so a tombstone renamed to the +/// word it was merged into would delete the assignments of the surviving +/// keyword instead of the retired one. See [`rename`]. +/// +/// The revision bump is not optional. [`crate::merge`] compares revisions, so a +/// deletion that updated `modified` alone is invisible to it — the other device +/// keeps its live row and the keyword comes back at the next sync. +fn retire(conn: &Connection, id: KeywordId) -> Result<(), CatalogError> { + conn.execute( + "UPDATE keyword_terms + SET deleted = 1, revision = revision + 1, modified = ?2 + WHERE id = ?1", + rusqlite::params![id.0 as i64, now_secs()], + )?; + Ok(()) +} + +/// A random UUID, formatted as the canonical 8-4-4-4-12. +/// +/// Shares [`crate::collections`]'s implementation rather than repeating it: a +/// second generator is a second thing to get wrong, and the merge identity is +/// the one place a weak one is unrecoverable. +fn new_uuid() -> String { + crate::collections::new_uuid() +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Catalog; + use dr_types::Selector; + + /// A catalog with six images, each with a default version. + fn seeded() -> Catalog { + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + c.execute( + "INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')", + [], + ) + .unwrap(); + for i in 1..=6i64 { + c.execute( + "INSERT INTO images(id, root_id, source_ref, added_at) + VALUES (?1, 1, ?2, 0)", + rusqlite::params![i, format!("IMG_{i}.CR3")], + ) + .unwrap(); + } + crate::rating::ensure_default_versions(c).unwrap(); + cat + } + + fn img(n: u64) -> ImageId { + ImageId(n) + } + + fn names(cat: &Catalog) -> Vec { + list(cat.connection()) + .unwrap() + .into_iter() + .map(|k| k.name) + .collect() + } + + #[test] + fn a_keyword_can_be_applied_and_then_found() { + // The whole point: the search path existed and nothing could feed it. + let cat = seeded(); + assign(cat.connection(), &[img(1), img(2)], "puffin").unwrap(); + + let q = crate::Query { + filter: Selector::Keyword("puffin".into()), + ..Default::default() + }; + assert_eq!(cat.count(&q, 0).unwrap(), 2); + } + + #[test] + fn assigning_to_a_selection_is_one_gesture() { + let cat = seeded(); + let all: Vec = (1..=6).map(img).collect(); + assert_eq!(assign(cat.connection(), &all, "iceland").unwrap(), 6); + } + + #[test] + fn reapplying_to_an_overlapping_selection_reports_only_the_new_ones() { + // "Added to 1 of 3" is the honest message, and the UI shows it. + let cat = seeded(); + assign(cat.connection(), &[img(1), img(2)], "puffin").unwrap(); + let added = assign(cat.connection(), &[img(1), img(2), img(3)], "puffin").unwrap(); + assert_eq!(added, 1); + } + + #[test] + fn assignment_is_idempotent_and_stores_one_row() { + let cat = seeded(); + assign(cat.connection(), &[img(1)], "puffin").unwrap(); + assign(cat.connection(), &[img(1)], "puffin").unwrap(); + assert_eq!(for_image(cat.connection(), img(1)).unwrap(), ["puffin"]); + } + + #[test] + fn unassigning_leaves_the_keyword_on_everything_else() { + // The join-table asymmetry: removing a word from one photograph is not + // deleting the word. + let cat = seeded(); + assign(cat.connection(), &[img(1), img(2)], "puffin").unwrap(); + assert_eq!(unassign(cat.connection(), &[img(1)], "puffin").unwrap(), 1); + + assert!(for_image(cat.connection(), img(1)).unwrap().is_empty()); + assert_eq!(for_image(cat.connection(), img(2)).unwrap(), ["puffin"]); + assert_eq!(names(&cat), ["puffin"], "the vocabulary still has it"); + } + + #[test] + fn a_word_on_a_virtual_copy_is_removed_with_the_frame() { + // Unticking says "this photograph is not of that", and a word left on + // a virtual copy would keep the frame in the results with nothing in + // the panel to explain it. + let cat = seeded(); + let c = cat.connection(); + c.execute( + "INSERT INTO versions(image_id, uuid, name, is_default) VALUES (1, 'copy', 'Crop', 0)", + [], + ) + .unwrap(); + let copy: i64 = c.last_insert_rowid(); + assign(c, &[img(1)], "puffin").unwrap(); + c.execute( + "INSERT INTO keywords(version_id, keyword) VALUES (?1, 'puffin')", + [copy], + ) + .unwrap(); + + unassign(c, &[img(1)], "puffin").unwrap(); + let n: i64 = c + .query_row("SELECT count(*) FROM keywords", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 0); + } + + #[test] + fn a_keyword_on_a_virtual_copy_still_finds_the_photograph() { + // The read side is deliberately asymmetric with the write side: a word + // names what is in the frame, whichever copy carries it. + let cat = seeded(); + let c = cat.connection(); + c.execute( + "INSERT INTO versions(image_id, uuid, name, is_default) VALUES (3, 'copy', 'Crop', 0)", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO keywords(version_id, keyword) VALUES (?1, 'gannet')", + [c.last_insert_rowid()], + ) + .unwrap(); + + let q = crate::Query { + filter: Selector::Keyword("gannet".into()), + ..Default::default() + }; + assert_eq!(cat.count(&q, 0).unwrap(), 1); + } + + #[test] + fn creating_the_same_word_twice_is_one_keyword() { + // A keyword is its text. Two rows with one name would be a duplicate + // in the list that no user could tell apart. + let cat = seeded(); + let a = create(cat.connection(), "iceland").unwrap(); + let b = create(cat.connection(), "iceland").unwrap(); + assert_eq!(a, b); + assert_eq!(names(&cat), ["iceland"]); + } + + #[test] + fn a_keyword_can_exist_before_any_photograph_carries_it() { + // Building the vocabulary ahead of the shoot is a real workflow, and + // it is the reason the term table exists at all. + let cat = seeded(); + create(cat.connection(), "puffin").unwrap(); + assert_eq!(names(&cat), ["puffin"]); + assert_eq!(list(cat.connection()).unwrap()[0].image_count, 0); + } + + #[test] + fn whitespace_around_a_keyword_is_not_part_of_it() { + // `query` compares with `=`, so " puffin" would be a second keyword + // that looked identical in every list and matched nothing the first + // matched. + let cat = seeded(); + assign(cat.connection(), &[img(1)], " puffin ").unwrap(); + assert_eq!(for_image(cat.connection(), img(1)).unwrap(), ["puffin"]); + } + + #[test] + fn inner_whitespace_collapses() { + let cat = seeded(); + assign(cat.connection(), &[img(1)], "black\tguillemot").unwrap(); + assert_eq!( + for_image(cat.connection(), img(1)).unwrap(), + ["black guillemot"] + ); + } + + #[test] + fn normalise_is_what_a_caller_can_show_the_user() { + // Public so a status line can quote the word as stored rather than as + // typed. If these two ever disagree, the UI is reporting a keyword the + // list will not show. + let cat = seeded(); + assign(cat.connection(), &[img(1)], " black \t guillemot ").unwrap(); + assert_eq!( + for_image(cat.connection(), img(1)).unwrap(), + [normalise(" black \t guillemot ").unwrap()] + ); + } + + #[test] + fn a_blank_keyword_is_refused_rather_than_stored() { + let cat = seeded(); + assert!(matches!( + assign(cat.connection(), &[img(1)], " "), + Err(CatalogError::BadName(_)) + )); + } + + #[test] + fn an_overlong_keyword_is_cut_on_a_character_boundary() { + // A mis-paste, not an intention. Truncating on a byte would panic on + // the multi-byte characters an Icelandic place name is full of. + let cat = seeded(); + let long = "Þingvellir".repeat(40); + assign(cat.connection(), &[img(1)], &long).unwrap(); + let stored = &for_image(cat.connection(), img(1)).unwrap()[0]; + assert_eq!(stored.chars().count(), MAX_KEYWORD_LEN); + } + + #[test] + fn case_is_preserved() { + // "Iceland" is a place; "iceland" is a typo of it. + let cat = seeded(); + assign(cat.connection(), &[img(1)], "Iceland").unwrap(); + assert_eq!(for_image(cat.connection(), img(1)).unwrap(), ["Iceland"]); + } + + #[test] + fn renaming_moves_every_assignment_with_it() { + // The failure this guards against: the vocabulary shows the new word + // and the search only finds the old one. + let cat = seeded(); + assign(cat.connection(), &[img(1), img(2)], "Icland").unwrap(); + let id = list(cat.connection()).unwrap()[0].id; + + rename(cat.connection(), id, "Iceland").unwrap(); + + assert_eq!(names(&cat), ["Iceland"]); + assert_eq!(for_image(cat.connection(), img(1)).unwrap(), ["Iceland"]); + let q = crate::Query { + filter: Selector::Keyword("Iceland".into()), + ..Default::default() + }; + assert_eq!(cat.count(&q, 0).unwrap(), 2); + } + + #[test] + fn renaming_onto_an_existing_keyword_fuses_the_two() { + // Correcting a typo when the correct spelling already exists. Refusing + // would leave the user to fix it photograph by photograph. + let cat = seeded(); + assign(cat.connection(), &[img(1)], "Icland").unwrap(); + assign(cat.connection(), &[img(2)], "Iceland").unwrap(); + let typo = list(cat.connection()) + .unwrap() + .into_iter() + .find(|k| k.name == "Icland") + .unwrap() + .id; + + rename(cat.connection(), typo, "Iceland").unwrap(); + + assert_eq!(names(&cat), ["Iceland"]); + assert_eq!(list(cat.connection()).unwrap()[0].image_count, 2); + } + + #[test] + fn renaming_a_frame_that_carries_both_spellings_does_not_abort() { + // The primary key would reject a bare UPDATE for that one row and take + // the whole rename down with it. + let cat = seeded(); + assign(cat.connection(), &[img(1)], "Icland").unwrap(); + assign(cat.connection(), &[img(1)], "Iceland").unwrap(); + let typo = list(cat.connection()) + .unwrap() + .into_iter() + .find(|k| k.name == "Icland") + .unwrap() + .id; + + rename(cat.connection(), typo, "Iceland").unwrap(); + assert_eq!(for_image(cat.connection(), img(1)).unwrap(), ["Iceland"]); + } + + #[test] + fn a_no_op_rename_is_not_an_edit() { + // A revision bumped for nothing lets an idle device win a merge + // against one that did real work. + let cat = seeded(); + let id = create(cat.connection(), "puffin").unwrap(); + let before = revision_of(&cat, id); + + assert_eq!(rename(cat.connection(), id, " puffin ").unwrap(), id); + assert_eq!(revision_of(&cat, id), before); + assert_eq!(deleted_of(&cat, id), 0, "and it does not retire the word"); + } + + #[test] + fn a_rename_retires_the_old_word_under_its_old_name() { + // The tombstone is what carries the rename to the other device, and it + // is read as "delete the assignments spelt this way" — so it has to + // keep the *old* spelling or it would delete the new word's. + let cat = seeded(); + assign(cat.connection(), &[img(1)], "Icland").unwrap(); + let old = list(cat.connection()).unwrap()[0].id; + + let new = rename(cat.connection(), old, "Iceland").unwrap(); + assert_ne!(new, old); + assert_eq!(deleted_of(&cat, old), 1); + assert_eq!(name_row(&cat, old), "Icland"); + assert!( + revision_of(&cat, old) > 1, + "the tombstone must out-revision the live row the other device holds" + ); + } + + fn revision_of(cat: &Catalog, id: KeywordId) -> i64 { + scalar(cat, "revision", id) + } + + fn deleted_of(cat: &Catalog, id: KeywordId) -> i64 { + scalar(cat, "deleted", id) + } + + /// One integer column of a term row. The column name is a literal from this + /// file and never user text — the same rule the module follows. + fn scalar(cat: &Catalog, column: &str, id: KeywordId) -> i64 { + cat.connection() + .query_row( + &format!("SELECT {column} FROM keyword_terms WHERE id = ?1"), + [id.0 as i64], + |r| r.get(0), + ) + .unwrap() + } + + fn name_row(cat: &Catalog, id: KeywordId) -> String { + cat.connection() + .query_row( + "SELECT name FROM keyword_terms WHERE id = ?1", + [id.0 as i64], + |r| r.get(0), + ) + .unwrap() + } + + #[test] + fn deleting_a_keyword_leaves_a_tombstone_and_takes_its_assignments() { + let cat = seeded(); + assign(cat.connection(), &[img(1), img(2)], "blurry").unwrap(); + let id = list(cat.connection()).unwrap()[0].id; + + assert_eq!(delete(cat.connection(), id).unwrap(), 2); + assert!(names(&cat).is_empty()); + assert!(for_image(cat.connection(), img(1)).unwrap().is_empty()); + + // The row survives, or a merge with a device that still holds the + // keyword would bring it straight back. + let deleted: i64 = cat + .connection() + .query_row( + "SELECT deleted FROM keyword_terms WHERE id = ?1", + [id.0 as i64], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(deleted, 1); + } + + #[test] + fn retyping_a_deleted_keyword_starts_a_fresh_identity() { + // Reusing the tombstoned row would make the new keyword inherit a + // revision that says "deleted" and lose an argument it was never in. + let cat = seeded(); + let first = create(cat.connection(), "puffin").unwrap(); + delete(cat.connection(), first).unwrap(); + + let second = create(cat.connection(), "puffin").unwrap(); + assert_ne!(first, second); + assert_eq!(names(&cat), ["puffin"]); + } + + #[test] + fn coverage_distinguishes_all_from_some() { + // The dash rather than the tick. Applying a word to forty frames where + // thirty already have it must not look like applying it to forty that + // do not. + let cat = seeded(); + assign(cat.connection(), &[img(1), img(2)], "puffin").unwrap(); + assign(cat.connection(), &[img(1)], "nest").unwrap(); + + let rows = for_images(cat.connection(), &[img(1), img(2)]).unwrap(); + let puffin = rows.iter().find(|r| r.keyword.name == "puffin").unwrap(); + let nest = rows.iter().find(|r| r.keyword.name == "nest").unwrap(); + + assert_eq!(puffin.coverage, Coverage::All); + assert_eq!(nest.coverage, Coverage::Some); + assert_eq!(nest.selected_count, 1); + } + + #[test] + fn a_keyword_no_one_in_the_selection_has_still_appears() { + // It is a target, not a report: the list is what the user assigns + // from, so hiding the unused words would hide the point of it. + let cat = seeded(); + create(cat.connection(), "puffin").unwrap(); + let rows = for_images(cat.connection(), &[img(1)]).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].coverage, Coverage::None); + } + + #[test] + fn the_vocabulary_leads_with_the_words_actually_used() { + let cat = seeded(); + create(cat.connection(), "unused").unwrap(); + assign(cat.connection(), &[img(1), img(2)], "puffin").unwrap(); + assert_eq!(names(&cat), ["puffin", "unused"]); + } + + #[test] + fn a_word_on_two_versions_counts_as_one_photograph() { + // A count that double-counts virtual copies is the kind of small lie + // that makes a user stop trusting the numbers. + let cat = seeded(); + let c = cat.connection(); + assign(c, &[img(1)], "puffin").unwrap(); + c.execute( + "INSERT INTO versions(image_id, uuid, name, is_default) VALUES (1, 'copy', 'Crop', 0)", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO keywords(version_id, keyword) VALUES (?1, 'puffin')", + [c.last_insert_rowid()], + ) + .unwrap(); + + assert_eq!(list(c).unwrap()[0].image_count, 1); + } + + #[test] + fn an_image_with_no_version_gains_one_rather_than_losing_the_keyword() { + // A library scanned before versions existed. The user typed a word and + // expects it to stick. + let cat = seeded(); + let c = cat.connection(); + c.execute( + "INSERT INTO images(id, root_id, source_ref, added_at) + VALUES (99, 1, 'IMG_99.CR3', 0)", + [], + ) + .unwrap(); + + assign(c, &[img(99)], "puffin").unwrap(); + assert_eq!(for_image(c, img(99)).unwrap(), ["puffin"]); + } + + #[test] + fn a_hostile_keyword_is_stored_rather_than_executed() { + // The write side of `query`'s injection guard. It goes in as a + // parameter, comes back out unchanged, and the table is still there. + let cat = seeded(); + let evil = "'; DROP TABLE images; --"; + assign(cat.connection(), &[img(1)], evil).unwrap(); + + assert_eq!(for_image(cat.connection(), img(1)).unwrap(), [evil]); + let n: i64 = cat + .connection() + .query_row("SELECT count(*) FROM images", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 6); + } + + #[test] + fn words_that_predate_the_vocabulary_are_adopted() { + // A catalog rebuilt from sidecars, or keyworded by an older build. + let cat = seeded(); + let c = cat.connection(); + let version = crate::rating::default_version_id(c, img(1)).unwrap(); + c.execute( + "INSERT INTO keywords(version_id, keyword) VALUES (?1, 'seabird')", + [version], + ) + .unwrap(); + + assert_eq!(adopt_orphan_terms(c).unwrap(), 1); + assert_eq!(names(&cat), ["seabird"]); + // It runs on every open, so a second pass must find nothing to do. + assert_eq!(adopt_orphan_terms(c).unwrap(), 0); + } + + #[test] + fn an_orphaned_word_is_still_shown_before_it_is_adopted() { + let cat = seeded(); + let c = cat.connection(); + let version = crate::rating::default_version_id(c, img(1)).unwrap(); + c.execute( + "INSERT INTO keywords(version_id, keyword) VALUES (?1, 'seabird')", + [version], + ) + .unwrap(); + + let rows = for_images(c, &[img(1)]).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].keyword.name, "seabird"); + assert_eq!(rows[0].keyword.id, KeywordId(0), "no identity yet"); + } + + #[test] + fn duplicate_identities_for_one_word_fuse_onto_the_smaller_uuid() { + // Two devices each typed "Iceland" before they had ever synced. + let cat = seeded(); + let c = cat.connection(); + c.execute( + "INSERT INTO keyword_terms(uuid, name, created, revision, modified) + VALUES ('aaaa', 'Iceland', 0, 1, 1), ('zzzz', 'Iceland', 0, 9, 9)", + [], + ) + .unwrap(); + + assert_eq!(fuse_duplicates(c).unwrap(), 1); + let survivor: String = c + .query_row("SELECT uuid FROM keyword_terms", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + survivor, "aaaa", + "the rule must not consult the revision, or two devices pick differently" + ); + } + + #[test] + fn fusing_leaves_a_tombstone_alone() { + // A tombstone is the user's deletion. Retiring it as a duplicate would + // quietly undo it. + let cat = seeded(); + let c = cat.connection(); + c.execute( + "INSERT INTO keyword_terms(uuid, name, created, revision, modified, deleted) + VALUES ('aaaa', 'Iceland', 0, 1, 1, 0), ('zzzz', 'Iceland', 0, 9, 9, 1)", + [], + ) + .unwrap(); + + assert_eq!(fuse_duplicates(c).unwrap(), 0); + } + + #[test] + fn an_empty_selection_still_creates_the_keyword() { + // Typing a word into the field with nothing selected builds the + // vocabulary, which is a legitimate thing to do ahead of a shoot. + let cat = seeded(); + assert_eq!(assign(cat.connection(), &[], "puffin").unwrap(), 0); + assert_eq!(names(&cat), ["puffin"]); + } + + #[test] + fn renaming_a_keyword_that_is_gone_says_so() { + let cat = seeded(); + assert!(matches!( + rename(cat.connection(), KeywordId(404), "x"), + Err(CatalogError::NoSuchKeyword(404)) + )); + } +} diff --git a/core/dr-catalog/src/lib.rs b/core/dr-catalog/src/lib.rs index aca35d4..8a0994a 100644 --- a/core/dr-catalog/src/lib.rs +++ b/core/dr-catalog/src/lib.rs @@ -14,9 +14,10 @@ //! - [`walk`] — those decisions driven against real storage, local or SAF //! - [`query`] — selectors compiled to indexed SQL, windowed for the grid //! - [`collections`] — the collection tree and membership the UI edits +//! - [`keywords`] — the keyword vocabulary and what it is assigned to //! - [`jobs`] — the durable background work queue //! - [`trash`] — soft delete to a folder, then permanent delete -//! - [`merge`] / [`sync`] — cross-device collection merging +//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords //! //! # The one thing everything is designed around //! @@ -35,6 +36,7 @@ pub mod cache; pub mod collections; pub mod error; pub mod jobs; +pub mod keywords; pub mod merge; pub mod query; pub mod rating; @@ -48,6 +50,7 @@ pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES}; pub use collections::{Collection, CollectionKind, TreeRow}; pub use error::CatalogError; pub use jobs::{Job, JobKind, Priority}; +pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword}; pub use merge::MergeReport; pub use query::{Query, Sort}; pub use rating::{Judgement, MAX_RATING}; diff --git a/core/dr-catalog/src/merge.rs b/core/dr-catalog/src/merge.rs index 710553b..7a1e50c 100644 --- a/core/dr-catalog/src/merge.rs +++ b/core/dr-catalog/src/merge.rs @@ -1,5 +1,5 @@ -//! TRACES: FR-CAT-7 | FR-NC-9 -//! Merging a remote catalog's collections into the local one. +//! TRACES: FR-CAT-7 | FR-CAT-5 | FR-NC-9 +//! Merging a remote catalog's collections and keywords into the local one. //! //! # Why this is a merge and not a copy //! @@ -33,6 +33,35 @@ //! against a device that still holds it would otherwise resurrect it. The //! tombstone carries a revision like any other edit, so deletion competes on //! the same footing as a rename. +//! +//! # Keywords merge on the same three rules +//! +//! [`merge_keywords`] reuses all of the above rather than inventing a second +//! set of rules, because a keyword is the same shape of problem as a +//! collection: a named thing with a device-independent identity, and a +//! many-to-many join to images. +//! +//! - The **vocabulary** (`keyword_terms`) is decided per row by [`verdict`], +//! exactly as collections are. +//! - The **assignments** (`keywords`) are a set union, exactly as membership +//! is: two devices each keywording different photographs "puffin" keep both +//! sets, and two devices each keywording the *same* photograph converge on +//! one row rather than one of them winning. +//! - **Deletion** tombstones, and takes the assignments with it. +//! +//! Two things are genuinely different, and both are consequences of assignments +//! storing the *word* rather than a row id: +//! +//! 1. A tombstone deletes assignments **by name**, so a deletion still lands on +//! a device that had minted its own identity for the same word. The union +//! then refuses to readmit a word a winning tombstone has just removed — +//! without that filter, the other device's live assignments would resurrect +//! it on the very same pass. +//! 2. Two devices that independently typed the same word arrive with two uuids +//! for one keyword. [`crate::keywords::fuse_duplicates`] collapses them onto +//! the lexicographically smaller one, which both devices compute identically. +//! A unique index on the name would instead abort the merge transaction at +//! that moment, which is the ordinary case rather than a corner one. use rusqlite::Connection; @@ -59,18 +88,44 @@ pub struct MergeReport { pub kept_local: usize, pub deleted: usize, pub members_added: usize, + + // Keywords are counted separately from collections rather than summed into + // the same fields. The report is shown to the user — "3 collections, 11 + // keywords" is a sentence; "14 things" is not — and a merge that went wrong + // is far easier to place when the counts say which half it went wrong in. + /// Keywords the remote had and this device did not. + pub keywords_inserted: usize, + /// Keywords the remote had renamed, or brought back from a tombstone. + pub keywords_updated: usize, + /// Keywords the remote deleted, and this device has now deleted too. + pub keywords_deleted: usize, + /// Keywords where this device's revision was at least as high. + pub keywords_kept_local: usize, + /// Redundant identities for one word, retired by + /// [`crate::keywords::fuse_duplicates`]. + pub keywords_fused: usize, + /// Keyword assignments taken from the remote. + pub keywords_assigned: usize, } impl MergeReport { /// Whether the local catalog changed, and so needs re-uploading. pub fn local_changed(&self) -> bool { - self.inserted > 0 || self.updated > 0 || self.deleted > 0 || self.members_added > 0 + self.inserted > 0 + || self.updated > 0 + || self.deleted > 0 + || self.members_added > 0 + || self.keywords_inserted > 0 + || self.keywords_updated > 0 + || self.keywords_deleted > 0 + || self.keywords_fused > 0 + || self.keywords_assigned > 0 } /// Whether the local catalog holds anything the remote did not, and so /// must be uploaded even if nothing was taken from the remote. pub fn should_upload(&self) -> bool { - self.kept_local > 0 || self.local_changed() + self.kept_local > 0 || self.keywords_kept_local > 0 || self.local_changed() } } @@ -108,16 +163,55 @@ pub fn verdict( } } -/// Merge collections and membership from an attached catalog. +/// Merge everything that syncs, from an attached catalog. /// /// The remote catalog must already be attached under the schema name -/// `remote_cat`; [`crate::Catalog::merge_attached_collections`] handles that. +/// `remote_cat`; [`crate::sync::merge_remote`] handles that. +/// +/// **One transaction over both halves.** Keywords and collections are +/// independent as data, but a merge that landed the collections and then failed +/// on the keywords would leave a catalog that has already taken the remote's +/// revisions for half of itself — and the next attempt, seeing those revisions, +/// would decline to take them again. Half a merge is not a state that can be +/// resumed, so it is not a state that can be reached. +pub fn merge_all(conn: &Connection) -> Result { + let tx = conn.unchecked_transaction()?; + let mut report = MergeReport::default(); + merge_collections_within(&tx, &mut report)?; + merge_keywords_within(&tx, &mut report)?; + tx.commit()?; + Ok(report) +} + +/// Merge collections and membership from an attached catalog. +/// +/// The collections half of [`merge_all`], on its own. Kept as a public entry +/// point because the two halves are genuinely independent, and because the +/// rules for this one are worth being able to exercise without a keyword in +/// sight. /// /// Runs in one transaction: a merge either lands whole or not at all. pub fn merge_collections(conn: &Connection) -> Result { let tx = conn.unchecked_transaction()?; let mut report = MergeReport::default(); + merge_collections_within(&tx, &mut report)?; + tx.commit()?; + Ok(report) +} +/// Merge the keyword vocabulary and its assignments from an attached catalog. +/// +/// The keywords half of [`merge_all`], on its own. See the module header for +/// the three rules and the two places keywords differ from collections. +pub fn merge_keywords(conn: &Connection) -> Result { + let tx = conn.unchecked_transaction()?; + let mut report = MergeReport::default(); + merge_keywords_within(&tx, &mut report)?; + tx.commit()?; + Ok(report) +} + +fn merge_collections_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> { // ---- collections ------------------------------------------------------ { let mut stmt = tx.prepare( @@ -310,8 +404,245 @@ pub fn merge_collections(conn: &Connection) -> Result )?; report.members_added = added; - tx.commit()?; - Ok(report) + Ok(()) +} + +/// Schema name the downloaded remote catalog is attached under. +/// +/// Repeated from [`crate::sync`] rather than shared, because the SQL below +/// spells it inline and a constant that only half the file used would be worse +/// than no constant at all. +const REMOTE: &str = "remote_cat"; + +/// The keyword half. See the module header. +fn merge_keywords_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> { + // ---- the vocabulary --------------------------------------------------- + // + // A remote written before schema v6 has no `keyword_terms` at all, and + // `remote_is_mergeable` deliberately admits it: the check is that the + // remote is not *newer* than us. So the table's absence is a normal state + // and not an error. Its assignments still merge below — those have been in + // the schema since v1 — and its words gain identities on that device the + // next time it opens the catalog and backfills. + if attached_has_table(tx, REMOTE, "keyword_terms")? { + struct Incoming { + uuid: String, + name: String, + /// What this device currently calls the same identity, if it has + /// it. A rename is applied to the assignment rows by rewriting this + /// text, so it has to be read before the term row is overwritten. + local_name: Option, + created: i64, + revision: i64, + modified: i64, + verdict: MergeVerdict, + } + + let rows: Vec = { + let mut stmt = tx.prepare( + "SELECT r.uuid, r.name, r.created, r.revision, r.modified, r.deleted, + l.name, l.revision, l.modified + FROM remote_cat.keyword_terms r + LEFT JOIN main.keyword_terms l ON l.uuid = r.uuid", + )?; + let found = stmt + .query_map([], |r| { + let deleted: i64 = r.get(5)?; + let local_rev: Option = r.get(7)?; + let local_mod: Option = r.get(8)?; + let revision: i64 = r.get(3)?; + let modified: i64 = r.get(4)?; + Ok(Incoming { + uuid: r.get(0)?, + name: r.get(1)?, + local_name: r.get(6)?, + created: r.get(2)?, + revision, + modified, + verdict: verdict( + local_rev.zip(local_mod), + (revision, modified), + deleted != 0, + ), + }) + })? + .collect::, _>>()?; + found + }; + + for row in rows { + match row.verdict { + MergeVerdict::KeptLocal => { + report.keywords_kept_local += 1; + } + MergeVerdict::InsertedFromRemote => { + tx.execute( + "INSERT INTO main.keyword_terms + (uuid, name, created, revision, modified, deleted) + VALUES (?1, ?2, ?3, ?4, ?5, 0)", + rusqlite::params![ + row.uuid, + row.name, + row.created, + row.revision, + row.modified, + ], + )?; + report.keywords_inserted += 1; + } + MergeVerdict::UpdatedFromRemote => { + // The assignments carry the *word*, so taking a new name + // for an identity we already hold means rewriting every row + // spelt the old way. Without this the vocabulary would show + // the new spelling and the search would only find the old. + if let Some(old) = row.local_name.filter(|n| *n != row.name) { + tx.execute( + "INSERT OR IGNORE INTO main.keywords(version_id, keyword) + SELECT version_id, ?2 FROM main.keywords WHERE keyword = ?1", + rusqlite::params![old, row.name], + )?; + tx.execute("DELETE FROM main.keywords WHERE keyword = ?1", [&old])?; + } + tx.execute( + "UPDATE main.keyword_terms + SET name = ?2, revision = ?3, modified = ?4, deleted = 0 + WHERE uuid = ?1", + rusqlite::params![row.uuid, row.name, row.revision, row.modified], + )?; + report.keywords_updated += 1; + } + MergeVerdict::DeletedByRemote => { + // Tombstone rather than DELETE, or a third device + // reintroduces the keyword through us. + tx.execute( + "INSERT INTO main.keyword_terms + (uuid, name, created, revision, modified, deleted) + VALUES (?1, ?2, ?3, ?4, ?5, 1) + ON CONFLICT(uuid) DO UPDATE SET + deleted = 1, revision = ?4, modified = ?5", + rusqlite::params![ + row.uuid, + row.name, + row.created, + row.revision, + row.modified, + ], + )?; + // **By name, not by identity.** This device may well have + // minted its own uuid for the same word before the two ever + // synced, in which case deleting by uuid would tombstone a + // row that nothing is assigned to and leave every + // photograph still carrying the word. + tx.execute("DELETE FROM main.keywords WHERE keyword = ?1", [&row.name])?; + report.keywords_deleted += 1; + } + } + } + + // Two devices that each typed "Iceland" now hold two identities for one + // word. Collapse them before the assignments arrive, so the vocabulary + // the user sees after a sync has one row per word. + report.keywords_fused = crate::keywords::fuse_duplicates(tx)?; + } + + // ---- assignments ------------------------------------------------------ + // + // Set union, and the union is the whole point: FR-NC-9's principle applied + // to metadata rather than to edit nodes. Two devices that keyworded + // different frames "puffin" both keep their work, and neither loses it to + // whichever synced second. + // + // A removal therefore does not propagate — the remote's assignment simply + // reappears. That is the same trade-off collection membership makes above, + // and for the same reason: an unwanted keyword is removed again in a + // second, and a silently lost afternoon of keywording is not recoverable at + // all. Making removal propagate needs a tombstone per assignment, which is + // a schema change and a merge rule of its own. + // + // An incoming keyword lands on the local default version, so an image that + // has not got one yet would silently drop it. That is not a rare state: the + // invariant is maintained by a backfill on open, and a scan that ran since + // has added rows it has not covered. Losing a word the user typed on + // another device, for a bookkeeping reason, would be the wrong answer — + // this is idempotent and writes nothing once the invariant holds. + crate::rating::ensure_default_versions_within(tx)?; + + // Two passes rather than one statement with an `OR`, because they resolve + // *different identities* for the same photograph and each wants its own + // index. See [`ASSIGN_BY_FILE_ID`] for why there are two at all. + for sql in [ASSIGN_BY_FILE_ID, ASSIGN_BY_CONTENT_HASH] { + report.keywords_assigned += tx.execute(sql, [])?; + } + + Ok(()) +} + +/// Take assignments for images both devices know by the server's file id. +/// +/// **Preferred over the content hash**, and the reason is that `content_hash` +/// is expensive — the schema says so, and it is computed only when import +/// dedup or a reconnect asks for it, which for most libraries is never. Keying +/// keywords on it alone would mean the union quietly did nothing for the +/// ordinary image, which is the exact failure this merge exists to prevent. +/// +/// `oc:fileid` is the opposite: it is recorded for every image the moment a +/// remote scan sees it, it is stable across server-side renames and moves, and +/// it is the same integer on every device pointed at the same Nextcloud — which +/// is precisely the situation where two devices are keywording one library. +/// +/// The keyword lands on the local image's **default version**, not on the +/// version it came from. Version uuids do not reconcile across devices in the +/// catalog: [`crate::rating::ensure_default_versions`] mints a fresh one per +/// device, so the same photograph's default versions have different uuids on +/// two machines and a uuid-keyed join would union nothing at all. Version +/// identity is reconciled in the *sidecar* (FR-NC-8), and until a merged +/// version arrives through there, the default version is both where +/// [`crate::keywords::assign`] writes and where the panel reads — so it is the +/// one place the word can land and be seen. +const ASSIGN_BY_FILE_ID: &str = " + INSERT OR IGNORE INTO main.keywords(version_id, keyword) + SELECT lv.id, rk.keyword + FROM remote_cat.keywords rk + JOIN remote_cat.versions rv ON rv.id = rk.version_id + JOIN remote_cat.remote rr ON rr.image_id = rv.image_id + JOIN main.remote lr ON lr.file_id = rr.file_id + JOIN main.versions lv ON lv.image_id = lr.image_id AND lv.is_default = 1 + WHERE NOT EXISTS (SELECT 1 FROM main.keyword_terms t + WHERE t.name = rk.keyword AND t.deleted = 1) + OR EXISTS (SELECT 1 FROM main.keyword_terms t + WHERE t.name = rk.keyword AND t.deleted = 0)"; + +/// The same union for a library with no server behind it. +/// +/// A local-only library has no `remote` rows at all, so [`ASSIGN_BY_FILE_ID`] +/// matches nothing and this is the only identity available — and it is the one +/// collection membership already uses, so a library where membership merges +/// has keywords that merge too. +const ASSIGN_BY_CONTENT_HASH: &str = " + INSERT OR IGNORE INTO main.keywords(version_id, keyword) + SELECT lv.id, rk.keyword + FROM remote_cat.keywords rk + JOIN remote_cat.versions rv ON rv.id = rk.version_id + JOIN remote_cat.images ri ON ri.id = rv.image_id + JOIN main.images li ON li.content_hash = ri.content_hash + JOIN main.versions lv ON lv.image_id = li.id AND lv.is_default = 1 + WHERE ri.content_hash IS NOT NULL + AND (NOT EXISTS (SELECT 1 FROM main.keyword_terms t + WHERE t.name = rk.keyword AND t.deleted = 1) + OR EXISTS (SELECT 1 FROM main.keyword_terms t + WHERE t.name = rk.keyword AND t.deleted = 0))"; + +/// Whether an attached database holds a table of this name. +/// +/// The schema name and the table name are both literals from this file, never +/// user text — but they are still bound rather than formatted where SQLite +/// allows it, because the habit is what keeps the one that eventually is user +/// text from being formatted by accident. +fn attached_has_table(conn: &Connection, schema: &str, table: &str) -> Result { + let sql = + format!("SELECT count(*) FROM {schema}.sqlite_master WHERE type = 'table' AND name = ?1"); + let n: i64 = conn.query_row(&sql, [table], |r| r.get(0))?; + Ok(n > 0) } #[cfg(test)] @@ -391,12 +722,21 @@ mod tests { // ---- integration over two real catalogs ------------------------------ fn two_catalogs() -> Connection { + attached_remote(schema::for_attached("remote_cat")) + } + + /// The same pair, but with the remote stopped at v1 — a device running a + /// build from before keywords had identities. + fn two_catalogs_with_a_v1_remote() -> Connection { + attached_remote(schema::v1_for_attached("remote_cat")) + } + + fn attached_remote(remote_schema: String) -> Connection { let c = Connection::open_in_memory().unwrap(); schema::configure(&c).unwrap(); schema::migrate(&c).unwrap(); // A second in-memory database standing in for the downloaded remote. c.execute_batch("ATTACH ':memory:' AS remote_cat").unwrap(); - let remote_schema = super::super::schema::v1_for_attached("remote_cat"); c.execute_batch(&remote_schema).unwrap(); c } @@ -656,6 +996,330 @@ mod tests { assert!(!second.local_changed(), "merge must be idempotent"); } + // ---- keywords -------------------------------------------------------- + + /// Give an image a default version, as every write path assumes it has. + fn add_version(c: &Connection, db: &str, image: i64, uuid: &str) -> i64 { + c.execute( + &format!( + "INSERT INTO {db}.versions(image_id, uuid, name, is_default) + VALUES (?1, ?2, 'Default', 1)" + ), + rusqlite::params![image, uuid], + ) + .unwrap(); + c.last_insert_rowid() + } + + /// Put a word on an image's default version, creating the version. + /// + /// The version uuid is derived from the database *and* the image, so the + /// two catalogs never accidentally agree on one — which is the real + /// situation, and the reason the assignment union cannot key on it. + fn keyword(c: &Connection, db: &str, image: i64, word: &str) { + let existing: Option = c + .query_row( + &format!("SELECT id FROM {db}.versions WHERE image_id = ?1 AND is_default = 1"), + [image], + |r| r.get(0), + ) + .ok(); + let version = + existing.unwrap_or_else(|| add_version(c, db, image, &format!("v-{db}-{image}"))); + c.execute( + &format!("INSERT OR IGNORE INTO {db}.keywords(version_id, keyword) VALUES (?1, ?2)"), + rusqlite::params![version, word], + ) + .unwrap(); + } + + fn add_term(c: &Connection, db: &str, uuid: &str, name: &str, rev: i64, deleted: i64) { + c.execute( + &format!( + "INSERT INTO {db}.keyword_terms(uuid, name, created, revision, modified, deleted) + VALUES (?1, ?2, 0, ?3, ?3, ?4)" + ), + rusqlite::params![uuid, name, rev, deleted], + ) + .unwrap(); + } + + /// Map an image to a server file id, as a remote scan does. + fn add_file_id(c: &Connection, db: &str, image: i64, file_id: i64) { + c.execute( + &format!("INSERT INTO {db}.remote(image_id, file_id) VALUES (?1, ?2)"), + rusqlite::params![image, file_id], + ) + .unwrap(); + } + + /// Every word on an image locally, sorted. + fn words_on(c: &Connection, image: i64) -> Vec { + let mut stmt = c + .prepare( + "SELECT DISTINCT k.keyword FROM main.keywords k + JOIN main.versions v ON v.id = k.version_id + WHERE v.image_id = ?1 ORDER BY k.keyword", + ) + .unwrap(); + let rows = stmt.query_map([image], |r| r.get(0)).unwrap(); + rows.collect::, _>>().unwrap() + } + + fn live_terms(c: &Connection) -> Vec { + let mut stmt = c + .prepare("SELECT name FROM main.keyword_terms WHERE deleted = 0 ORDER BY name") + .unwrap(); + let rows = stmt.query_map([], |r| r.get(0)).unwrap(); + rows.collect::, _>>().unwrap() + } + + #[test] + fn two_devices_keywording_different_photographs_both_survive() { + // FR-NC-9's principle applied to metadata: disjoint work merges to the + // union, and neither device loses an afternoon to whoever synced last. + let c = two_catalogs(); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + add_image(&c, db, 2, "hash-b"); + } + keyword(&c, "main", 1, "puffin"); + keyword(&c, "remote_cat", 2, "gannet"); + + merge_keywords(&c).unwrap(); + + assert_eq!(words_on(&c, 1), ["puffin"]); + assert_eq!(words_on(&c, 2), ["gannet"]); + } + + #[test] + fn two_devices_keywording_one_photograph_keep_both_words() { + // The case the union is really for: the same frame, two different + // words, and last-writer-wins would silently drop one of them. + let c = two_catalogs(); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + } + keyword(&c, "main", 1, "puffin"); + keyword(&c, "remote_cat", 1, "Iceland"); + + let report = merge_keywords(&c).unwrap(); + assert_eq!(report.keywords_assigned, 1); + assert_eq!(words_on(&c, 1), ["Iceland", "puffin"]); + } + + #[test] + fn a_word_both_devices_already_had_is_not_duplicated() { + let c = two_catalogs(); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + keyword(&c, db, 1, "puffin"); + } + + let report = merge_keywords(&c).unwrap(); + assert_eq!(report.keywords_assigned, 0); + assert_eq!(words_on(&c, 1), ["puffin"]); + } + + #[test] + fn keywords_reach_an_image_the_server_names_but_no_one_has_hashed() { + // `content_hash` is computed only when import dedup or a reconnect asks + // for it, so for most images it is NULL — and a union keyed on it alone + // would quietly do nothing for the ordinary photograph. The file id is + // recorded by every remote scan, which is exactly the situation where + // two devices are keywording one library. + let c = two_catalogs(); + c.execute( + "INSERT INTO main.roots(id, kind, label) VALUES (1, 'remote', 'r')", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO remote_cat.roots(id, kind, label) VALUES (1, 'remote', 'r')", + [], + ) + .unwrap(); + // Different row ids for one photograph, and no hash on either side. + c.execute( + "INSERT INTO main.images(id, root_id, source_ref, added_at) + VALUES (77, 1, 'IMG_1.CR3', 0)", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO remote_cat.images(id, root_id, source_ref, added_at) + VALUES (3, 1, 'IMG_1.CR3', 0)", + [], + ) + .unwrap(); + add_file_id(&c, "main", 77, 9001); + add_file_id(&c, "remote_cat", 3, 9001); + add_version(&c, "main", 77, "v-main"); + keyword(&c, "remote_cat", 3, "puffin"); + + merge_keywords(&c).unwrap(); + assert_eq!(words_on(&c, 77), ["puffin"]); + } + + #[test] + fn keywords_map_across_devices_by_content_hash_where_there_is_no_server() { + // A local-only library has no `remote` rows at all, so the hash is the + // only identity available — and it is the one membership already uses. + let c = two_catalogs(); + add_image(&c, "main", 77, "same-photo"); + add_image(&c, "remote_cat", 3, "same-photo"); + add_version(&c, "main", 77, "v-main"); + keyword(&c, "remote_cat", 3, "puffin"); + + merge_keywords(&c).unwrap(); + assert_eq!(words_on(&c, 77), ["puffin"]); + } + + #[test] + fn the_vocabulary_merges_by_uuid_and_a_skewed_clock_cannot_win() { + let c = two_catalogs(); + add_term(&c, "main", "u-1", "Iceland", 9, 0); + add_term(&c, "remote_cat", "u-1", "iceland", 2, 0); + add_term(&c, "remote_cat", "u-2", "puffin", 1, 0); + + let report = merge_keywords(&c).unwrap(); + assert_eq!(report.keywords_kept_local, 1); + assert_eq!(report.keywords_inserted, 1); + assert_eq!(live_terms(&c), ["Iceland", "puffin"]); + } + + #[test] + fn a_remote_rename_moves_this_device_s_assignments_too() { + // The failure this exists to stop: the vocabulary shows the corrected + // spelling and the search still only finds the old one. + let c = two_catalogs(); + add_image(&c, "main", 1, "hash-a"); + add_term(&c, "main", "u-1", "Icland", 1, 0); + keyword(&c, "main", 1, "Icland"); + add_term(&c, "remote_cat", "u-1", "Iceland", 4, 0); + + let report = merge_keywords(&c).unwrap(); + assert_eq!(report.keywords_updated, 1); + assert_eq!(live_terms(&c), ["Iceland"]); + assert_eq!(words_on(&c, 1), ["Iceland"]); + } + + #[test] + fn a_remote_deletion_takes_the_word_off_every_photograph() { + let c = two_catalogs(); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + } + add_term(&c, "main", "u-1", "blurry", 1, 0); + keyword(&c, "main", 1, "blurry"); + add_term(&c, "remote_cat", "u-1", "blurry", 5, 1); + + let report = merge_keywords(&c).unwrap(); + assert_eq!(report.keywords_deleted, 1); + assert!(live_terms(&c).is_empty()); + assert!(words_on(&c, 1).is_empty()); + } + + #[test] + fn a_deletion_is_not_undone_by_the_union_on_the_same_pass() { + // The remote deleted the word *and* still carries assignments for it — + // it has not yet had the chance to sweep them, or a third device put + // them there. Without the tombstone filter the union would put the word + // straight back on the photograph the deletion had just cleared. + let c = two_catalogs(); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + } + add_term(&c, "main", "u-1", "blurry", 1, 0); + keyword(&c, "main", 1, "blurry"); + add_term(&c, "remote_cat", "u-1", "blurry", 5, 1); + keyword(&c, "remote_cat", 1, "blurry"); + + merge_keywords(&c).unwrap(); + assert!(words_on(&c, 1).is_empty(), "a deleted keyword came back"); + } + + #[test] + fn a_deletion_lands_even_when_the_two_devices_minted_different_uuids() { + // Both typed "blurry" before they ever synced, so this device's row has + // a uuid the remote has never heard of. Deleting by identity would + // tombstone nothing and leave every photograph still carrying the word. + let c = two_catalogs(); + add_image(&c, "main", 1, "hash-a"); + add_term(&c, "main", "mine", "blurry", 1, 0); + keyword(&c, "main", 1, "blurry"); + add_term(&c, "remote_cat", "theirs", "blurry", 5, 1); + + merge_keywords(&c).unwrap(); + assert!(words_on(&c, 1).is_empty()); + } + + #[test] + fn two_devices_that_typed_one_word_end_up_with_one_keyword() { + // Neither is wrong until they meet, which is why the name carries no + // unique index — a constraint would abort the merge at this moment. + let c = two_catalogs(); + add_term(&c, "main", "zzzz", "Iceland", 3, 0); + add_term(&c, "remote_cat", "aaaa", "Iceland", 1, 0); + + let report = merge_keywords(&c).unwrap(); + assert_eq!(report.keywords_fused, 1); + assert_eq!(live_terms(&c), ["Iceland"]); + + let survivor: String = c + .query_row("SELECT uuid FROM main.keyword_terms", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + survivor, "aaaa", + "both devices must pick the same survivor without asking each other" + ); + } + + #[test] + fn merging_keywords_twice_changes_nothing_the_second_time() { + let c = two_catalogs(); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + } + add_term(&c, "remote_cat", "u-1", "puffin", 1, 0); + keyword(&c, "remote_cat", 1, "puffin"); + + let first = merge_keywords(&c).unwrap(); + assert!(first.local_changed()); + let second = merge_keywords(&c).unwrap(); + assert!(!second.local_changed(), "merge must be idempotent"); + } + + #[test] + fn a_remote_from_before_keyword_identities_still_contributes_its_words() { + // `remote_is_mergeable` admits an older remote on purpose — the check + // is that it is not *newer* than us. A missing table is therefore a + // normal state and must not fail the merge. + let c = two_catalogs_with_a_v1_remote(); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + } + keyword(&c, "remote_cat", 1, "puffin"); + + let report = merge_keywords(&c).unwrap(); + assert_eq!(report.keywords_assigned, 1); + assert_eq!(words_on(&c, 1), ["puffin"]); + } + + #[test] + fn merge_all_lands_both_halves() { + let c = two_catalogs(); + add_collection(&c, "remote_cat", 1, "u-coll", "Portugal", 1); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + } + keyword(&c, "remote_cat", 1, "puffin"); + + let report = merge_all(&c).unwrap(); + assert_eq!(report.inserted, 1); + assert_eq!(report.keywords_assigned, 1); + } + #[test] fn keeping_local_still_marks_the_catalog_for_upload() { // We hold something the remote does not, so the remote is stale even diff --git a/core/dr-catalog/src/rating.rs b/core/dr-catalog/src/rating.rs index 1c90595..139acd9 100644 --- a/core/dr-catalog/src/rating.rs +++ b/core/dr-catalog/src/rating.rs @@ -75,6 +75,24 @@ impl Judgement { /// The UUID is per row and generated here — it is the merge identity across /// devices (FR-NC-8), so two images must never share one. pub fn ensure_default_versions(conn: &Connection) -> Result { + // One transaction for the batch. A backfill over a 24k-image library is + // 24k inserts, and per-statement commits would make it minutes rather + // than seconds. + let tx = conn.unchecked_transaction()?; + let n = ensure_default_versions_within(&tx)?; + tx.commit()?; + Ok(n) +} + +/// [`ensure_default_versions`] without opening a transaction. +/// +/// Separate because SQLite has no nested `BEGIN`: [`crate::merge`] needs the +/// invariant restored *inside* the merge transaction — an incoming keyword +/// lands on a default version, so an image without one would silently drop it — +/// and calling the public form there fails at runtime with "cannot start a +/// transaction within a transaction". The same split, for the same reason, as +/// `collections::add_within`. +pub fn ensure_default_versions_within(conn: &Connection) -> Result { let ids: Vec = { let mut stmt = conn.prepare( "SELECT i.id FROM images i @@ -89,12 +107,8 @@ pub fn ensure_default_versions(conn: &Connection) -> Result return Ok(0); } - // One transaction for the batch. A backfill over a 24k-image library is - // 24k inserts, and per-statement commits would make it minutes rather - // than seconds. - let tx = conn.unchecked_transaction()?; { - let mut insert = tx.prepare( + let mut insert = conn.prepare( "INSERT INTO versions(image_id, uuid, name, is_default, rating, flag) VALUES (?1, ?2, ?3, 1, 0, 0)", )?; @@ -102,7 +116,6 @@ pub fn ensure_default_versions(conn: &Connection) -> Result insert.execute(rusqlite::params![id, new_uuid(), DEFAULT_VERSION_NAME])?; } } - tx.commit()?; Ok(ids.len()) } diff --git a/core/dr-catalog/src/schema.rs b/core/dr-catalog/src/schema.rs index da13480..271f772 100644 --- a/core/dr-catalog/src/schema.rs +++ b/core/dr-catalog/src/schema.rs @@ -15,7 +15,7 @@ use rusqlite::Connection; use crate::error::CatalogError; /// Schema version this build writes and understands. -pub const SCHEMA_VERSION: i64 = 5; +pub const SCHEMA_VERSION: i64 = 6; /// Apply migrations up to [`SCHEMA_VERSION`]. /// @@ -66,6 +66,12 @@ pub fn migrate(conn: &Connection) -> Result { tx.pragma_update(None, "user_version", 5)?; tx.commit()?; } + if from < 6 { + let tx = conn.unchecked_transaction()?; + tx.execute_batch(V6)?; + tx.pragma_update(None, "user_version", 6)?; + tx.commit()?; + } Ok(from) } @@ -103,6 +109,20 @@ pub fn backfill(conn: &Connection) -> Result, Catalog out.push(("default_versions", n)); } + // v6: a vocabulary row for every word some image already carries. + // + // Three ways a catalog arrives holding assignments with no term behind + // them, and all three are normal rather than exceptional: a library + // keyworded by a build that predates this table, a catalog rebuilt from + // sidecars (which carry the word and not the identity), and an import from + // Lightroom or darktable (FR-CAT-14). Without this the words are + // searchable but absent from the vocabulary list, which reads as the + // keywords having been lost. + let n = crate::keywords::adopt_orphan_terms(conn)?; + if n > 0 { + out.push(("keyword_terms", n)); + } + Ok(out) } @@ -134,15 +154,45 @@ pub fn configure(conn: &Connection) -> Result<(), CatalogError> { /// in [`V1`]: every `CREATE TABLE`/`CREATE INDEX` must name its object /// unqualified, which they do. pub fn v1_for_attached(schema_name: &str) -> String { - V1.replace("CREATE TABLE ", &format!("CREATE TABLE {schema_name}.")) + rewrite_for_attached(V1, schema_name) + // REFERENCES within an attached schema resolve to that schema already, + // so foreign keys need no rewriting — but the ON clause of an index + // does, and `CREATE INDEX x.name ON table` is the correct form. +} + +/// Every table this build knows about, rewritten to target an attached +/// database. +/// +/// [`v1_for_attached`] is kept alongside this rather than replaced by it: a +/// remote catalog written by an older build genuinely has only the v1 tables, +/// and the merge has to keep working against one (see +/// [`crate::merge::merge_keywords`]). Building that case in a test needs a way +/// to say "v1 and no more". +/// +/// Only the migrations that *create* objects appear here. V2 through V5 are +/// `ALTER TABLE ... ADD COLUMN`, and the columns they add are local index +/// state — shadowing, trashing, cache pinning — that a merge never reads +/// across the attachment. +pub fn for_attached(schema_name: &str) -> String { + format!( + "{}\n{}", + rewrite_for_attached(V1, schema_name), + rewrite_for_attached(V6, schema_name) + ) +} + +/// Qualify every object a `CREATE` statement names with `schema_name`. +/// +/// The rewrite is textual and therefore only as good as the naming discipline +/// in the batches it is given: every `CREATE TABLE`/`CREATE INDEX` must name +/// its object unqualified, which they do. +fn rewrite_for_attached(sql: &str, schema_name: &str) -> String { + sql.replace("CREATE TABLE ", &format!("CREATE TABLE {schema_name}.")) .replace("CREATE INDEX ", &format!("CREATE INDEX {schema_name}.")) .replace( "CREATE UNIQUE INDEX ", &format!("CREATE UNIQUE INDEX {schema_name}."), ) - // REFERENCES within an attached schema resolve to that schema already, - // so foreign keys need no rewriting — but the ON clause of an index - // does, and `CREATE INDEX x.name ON table` is the correct form. } /// Mark each JPEG that sits beside a RAW of the same name. @@ -226,6 +276,63 @@ fn stem_of(path: &str) -> &str { } } +const V6: &str = r#" +-- TRACES: FR-CAT-5 | FR-CAT-6 | FR-NC-9 +-- Keywords gain an identity, so that renaming and deleting one can cross +-- between devices. +-- +-- The v1 `keywords` table is the *assignment*: one row per (version, word), +-- and the word is stored as text. That stays exactly as it is, and this +-- migration adds nothing to it, for a reason that is easy to get backwards. +-- +-- # Why assignments keep the text rather than pointing at a row here +-- +-- The catalog is a rebuildable index (ARCH §6.12). What an image is keyworded +-- with is authoritative in the sidecar and in XMP `dc:subject` (FR-CAT-13), +-- and both of those carry a *string*. Rewriting the join to reference +-- `keyword_terms(id)` would mean a catalog rebuilt from sidecars had to invent +-- term rows before it could record a single assignment, and an integer that +-- means nothing on the other device would sit where the durable fact belongs. +-- It would also break `crate::query`, which matches `kw.keyword` directly and +-- must keep hitting `keywords_term` on a 50k library (FR-CAT-6). +-- +-- So the text is the fact and this table is the *identity*: it exists to give +-- a rename and a deletion something a merge can key on, and to let a keyword +-- exist in the vocabulary before any photograph carries it. +CREATE TABLE keyword_terms ( + id INTEGER PRIMARY KEY, + -- Device-independent identity, as `collections.uuid` is. The integer id is + -- local and collides across devices. + uuid TEXT NOT NULL UNIQUE, + -- The word itself, and the value written into every assignment row. + name TEXT NOT NULL, + created INTEGER NOT NULL, + -- Monotonic, bumped on every local edit. `crate::merge` compares these + -- rather than timestamps, so a clock-skewed device cannot silently win. + revision INTEGER NOT NULL DEFAULT 1, + modified INTEGER NOT NULL, + -- Tombstone, so a merge against a device that still holds the keyword does + -- not resurrect it. + deleted INTEGER NOT NULL DEFAULT 0 +); + +-- Deliberately **not** UNIQUE. +-- +-- Two devices that each type "Iceland" create two rows with two uuids, and +-- both are correct until they meet. A unique constraint would abort the merge +-- transaction at exactly that moment — the ordinary case, not a corner one. +-- Uniqueness is instead reached by convergence: `crate::keywords::create` +-- resolves an existing name locally, and `crate::keywords::fuse_duplicates` +-- collapses a cross-device pair onto the lexicographically smaller uuid, which +-- both devices compute identically without talking to each other. +-- +-- Partial on `deleted = 0` because every lookup here is a live one: the +-- vocabulary list, the resolve-by-name in `create`, and the fuse pass all +-- exclude tombstones, and including them would grow the index with every +-- keyword the library has ever had rather than with the ones it has. +CREATE INDEX keyword_terms_name ON keyword_terms(name) WHERE deleted = 0; +"#; + const V5: &str = r#" -- TRACES: FR-NC-6a | FR-CAT-9 | NFR-RES-4 -- Offline availability: what is kept, why it is kept, and where it lives. @@ -671,6 +778,77 @@ mod tests { assert_eq!(bytes, 100, "the existing row is untouched"); } + #[test] + fn a_v5_catalog_keeps_its_keywords_and_gains_their_identities() { + // TRACES: FR-CAT-5 + // The migration case that matters here: a library keyworded by an + // import or an older build already has assignment rows, and they must + // survive into the vocabulary rather than being left searchable but + // invisible. + let c = mem(); + for step in [V1, V2, V3, V4, V5] { + c.execute_batch(step).unwrap(); + } + c.pragma_update(None, "user_version", 5).unwrap(); + c.execute( + "INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO images(id, root_id, source_ref, added_at) VALUES (7, 1, 'IMG_7.CR3', 0)", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO versions(id, image_id, uuid, name, is_default) + VALUES (1, 7, 'v-7', 'Default', 1)", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO keywords(version_id, keyword) VALUES (1, 'puffin')", + [], + ) + .unwrap(); + + assert_eq!(migrate(&c).unwrap(), 5, "migrated from v5"); + assert_eq!(backfilled(&c, "keyword_terms"), 1); + + let name: String = c + .query_row("SELECT name FROM keyword_terms", [], |r| r.get(0)) + .unwrap(); + assert_eq!(name, "puffin"); + // The assignment is untouched — it is the durable fact, and the term + // row is only its identity. + let n: i64 = c + .query_row("SELECT count(*) FROM keywords", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 1); + + // It runs on every open, so a second pass must find nothing to do. + assert_eq!(backfilled(&c, "keyword_terms"), 0); + } + + #[test] + fn two_devices_may_both_hold_a_term_of_the_same_name() { + // Deliberately not a unique index. Two devices each typing "Iceland" + // is the ordinary case, and a constraint would abort the merge + // transaction at exactly the moment they first sync. + let c = mem(); + migrate(&c).unwrap(); + c.execute( + "INSERT INTO keyword_terms(uuid, name, created, revision, modified) + VALUES ('a', 'Iceland', 0, 1, 1), ('b', 'Iceland', 0, 1, 1)", + [], + ) + .unwrap(); + let n: i64 = c + .query_row("SELECT count(*) FROM keyword_terms", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 2); + } + #[test] fn stems_ignore_directories_containing_dots() { assert_eq!(stem_of("2026.08/IMG_1.CR2"), "IMG_1"); diff --git a/core/dr-catalog/src/sync.rs b/core/dr-catalog/src/sync.rs index 7cabf36..01d302d 100644 --- a/core/dr-catalog/src/sync.rs +++ b/core/dr-catalog/src/sync.rs @@ -16,10 +16,11 @@ //! //! # What is actually synced //! -//! Only collections merge (see [`crate::merge`]). The rest of the catalog is a -//! *local index* of *local* storage — folder mtimes, cache paths, job rows — -//! and copying another device's version of those in would be actively wrong. -//! The remote file is read for its collections and then discarded. +//! Only the *user's judgements about their library* merge: collections, and the +//! keyword vocabulary with its assignments (see [`crate::merge`]). The rest of +//! the catalog is a *local index* of *local* storage — folder mtimes, cache +//! paths, job rows — and copying another device's version of those in would be +//! actively wrong. The remote file is read for those two and then discarded. //! //! This is why the catalog remains disposable in the ARCH §6.12 sense: nothing //! here makes the local database authoritative for anything a rebuild could @@ -96,7 +97,7 @@ pub fn merge_remote(conn: &Connection, remote: &Path) -> Result Option { + pollster::block_on(GpuContext::new_headless()).ok() +} + +/// The centre pixel's red, green and blue, after `graph` has run over flat +/// mid-grey. +fn rendered(ctx: &GpuContext, graph: &EditGraph) -> (u8, u8, u8) { + let data: Vec = (0..SIZE * SIZE).flat_map(|_| [128, 128, 128, 255]).collect(); + let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload"); + + // Composed the way the display path composes it. A curve that generates + // invalid WGSL fails at `render` below, which is the point of running this + // on a device at all. + let shader = graph.compose(); + + let mut adjust = AdjustPass::new(ctx); + adjust.render(&source, &shader, SIZE, SIZE).expect("render"); + let pixels = adjust.export_pixels().expect("readback").0; + + let at = ((SIZE / 2 * SIZE + SIZE / 2) * 4) as usize; + (pixels[at], pixels[at + 1], pixels[at + 2]) +} + +/// A curve with its mid-point lifted — the simplest edit that is unmistakably +/// an edit. +fn lifted(channel: Channel) -> EditGraph { + let mut graph = EditGraph::default_chain(); + graph.set_param( + curve::ID, + curve::coordinate(channel, 2, Axis::Y), + 0.75, + ); + graph +} + +#[test] +fn the_master_curve_lifts_every_component_together() { + let Some(ctx) = ctx() else { + eprintln!("no adapter; skipping"); + return; + }; + + let (r0, g0, b0) = rendered(&ctx, &EditGraph::default_chain()); + let (r, g, b) = rendered(&ctx, &lifted(Channel::Master)); + + assert!(r > r0, "the master curve did not lift the image: {r} vs {r0}"); + // Grey in, grey out: the master curve is applied as a ratio over + // luminance, so it changes tone and not hue. A tolerance of one code + // value, because the components travel through the ratio separately and + // the result is quantised to eight bits. + assert!( + r.abs_diff(g) <= 1 && g.abs_diff(b) <= 1, + "the master curve tinted a neutral pixel: {r},{g},{b}" + ); + assert_eq!((g0, b0), (r0, r0), "the unedited image is neutral"); +} + +#[test] +fn a_channel_curve_lifts_only_its_own_component() { + let Some(ctx) = ctx() else { + eprintln!("no adapter; skipping"); + return; + }; + + let (r0, g0, b0) = rendered(&ctx, &EditGraph::default_chain()); + for (channel, name) in [ + (Channel::Red, "red"), + (Channel::Green, "green"), + (Channel::Blue, "blue"), + ] { + let (r, g, b) = rendered(&ctx, &lifted(channel)); + // The component the curve names moves; the other two stay exactly + // where they were. This is what catches a fragment whose uniforms are + // wired to the wrong curve — it would still lift *something*. + let (moved, still) = match channel { + Channel::Red => (r > r0, g == g0 && b == b0), + Channel::Green => (g > g0, r == r0 && b == b0), + _ => (b > b0, r == r0 && g == g0), + }; + assert!(moved, "the {name} curve changed nothing: {r},{g},{b}"); + assert!( + still, + "the {name} curve moved a component that was not its own: \ + {r},{g},{b} from {r0},{g0},{b0}" + ); + } +} + +#[test] +fn the_master_and_the_channels_compose_in_one_pass() { + // All four curves at once: the case where the generated fragment is + // longest, every helper is present, and forty uniforms are in the block. + // Mostly a compile check, which is why the assertion is only that the + // result is a colour and not the one we started with. + let Some(ctx) = ctx() else { + eprintln!("no adapter; skipping"); + return; + }; + + let mut graph = EditGraph::default_chain(); + graph.set_param(curve::ID, curve::P1_Y, 0.15); + graph.set_param(curve::ID, curve::P3_Y, 0.85); + for (channel, y) in [ + (Channel::Red, 0.55), + (Channel::Green, 0.5), + (Channel::Blue, 0.62), + ] { + graph.set_param(curve::ID, curve::coordinate(channel, 2, Axis::Y), y); + } + + let (r0, _, _) = rendered(&ctx, &EditGraph::default_chain()); + let (r, g, b) = rendered(&ctx, &graph); + assert!( + (r, g, b) != (r0, r0, r0), + "four active curves left the image untouched" + ); + // Red and blue were pushed apart from green, which is the chromatic half + // doing its work on top of the tonal one. + assert!(b > g, "blue was lifted above green: {r},{g},{b}"); +} diff --git a/core/dr-pipeline/ops/README.md b/core/dr-pipeline/ops/README.md index dfb8ec8..aa27c1a 100644 --- a/core/dr-pipeline/ops/README.md +++ b/core/dr-pipeline/ops/README.md @@ -209,9 +209,10 @@ They still belong in this directory, because the pipeline's **order** is the one thing a reader comes here to learn, and an order written half in YAML and half in Rust would be worse than either alone. -Currently hand-written: `tone_curve` (a curve widget over five interpolated -points), `colour_mixer` (thirty-six faceted parameters from twelve computed hue -bands). `vignetting` is hand-written too but is not in the develop chain — it +Currently hand-written: `tone_curve` (one widget over four curves of five +interpolated points — master, red, green, blue — each reaching the shader only +when it has been moved), `colour_mixer` (thirty-six faceted parameters from +twelve computed hue bands). `vignetting` is hand-written too but is not in the develop chain — it carries lens-profile coefficients that are not parameters. `distortion` and `aberration` are `Warp`s rather than operations: they rewrite coordinates before sampling rather than transforming a colour after it. diff --git a/core/dr-pipeline/ops/tone_curve.yaml b/core/dr-pipeline/ops/tone_curve.yaml index 91f9e3b..8f14477 100644 --- a/core/dr-pipeline/ops/tone_curve.yaml +++ b/core/dr-pipeline/ops/tone_curve.yaml @@ -16,13 +16,26 @@ attributes: [tone, colour] rust: ToneCurve why_rust: | - Five control points presented as one curve widget, with an interpolator and - a monotonicity guarantee behind it. Its neutral is a *relationship* between - parameters rather than a set of values — the identity diagonal — which is - not something the declarative `active:` rule can express, and its - `presentation()` spans parameters rather than describing one. + Four curves — master, red, green, blue — of five control points each, + presented as one widget, with an interpolator and a monotonicity guarantee + behind them. Its neutral is a *relationship* between parameters rather than + a set of values — the identity diagonal, on every channel — which is not + something the declarative `active:` rule can express; its `presentation()` + spans forty parameters rather than describing one; and its fragment is + *assembled* rather than written, because each curve reaches the shader only + when it has been moved off the diagonal. A declared node's `wgsl:` is one + fixed block of text, which is the right shape for nearly everything here and + the wrong one for a node whose cost has to follow what the photographer + actually touched. placement: | After the fixed-weight region controls, so the curve is the final word on tone: a photographer reaches for it to fix what those controls could not place exactly. + + Within the node, the master curve runs before the per-channel ones. Both + orders are visibly different images and the reasons for this one are written + out in `src/ops/curve.rs`: the master is tonal and hue-preserving, the + per-channel curves are the chromatic grade over the tones it produced, and a + point placed on a channel curve should act on the tone the photographer can + see rather than on the one the master is about to move. diff --git a/core/dr-pipeline/src/ops/curve.rs b/core/dr-pipeline/src/ops/curve.rs index 6bc0bd3..c2c6782 100644 --- a/core/dr-pipeline/src/ops/curve.rs +++ b/core/dr-pipeline/src/ops/curve.rs @@ -1,9 +1,13 @@ -//! The tone curve — a monotonic spline through five movable points. +//! TRACES: FR-DEV-3 +//! The tone curve — four monotonic splines through five movable points each: +//! a master curve over tone, and one per colour channel. //! //! The control every other tonal adjustment is a preset of. Highlights, //! shadows, blacks and whites each shape one region with a fixed weight; the //! curve lets the photographer put the inflection exactly where the image -//! needs it. +//! needs it. The per-channel curves are the same instrument pointed at colour: +//! a lifted blue black point is the faded shadow every film emulation is built +//! out of, and there is no way to ask for it with a saturation slider. //! //! # Why the points are ordinary scalars //! @@ -14,7 +18,7 @@ //! //! - The parameter API stays `f32`-only, so nothing else in the pipeline, //! the graph or the sidecar had to change to accommodate a curve. -//! - A UI that has not implemented the curve widget renders ten sliders and +//! - A UI that has not implemented the curve widget renders forty sliders and //! remains completely functional. //! - Undo, clamping and sidecar serialisation work already, because the //! points are the same kind of thing as every other parameter. @@ -23,28 +27,223 @@ //! need a variable-length value type, which is a much larger change for a //! control that rarely needs more than five. //! +//! [`ParamKind::Scalar`]: crate::descriptor::ParamKind::Scalar +//! //! # Why monotonic //! //! A plain cubic spline through user-placed points overshoots: drag one point //! and the curve can dip *below* its neighbour, which inverts tones locally //! and shows up as a dark halo in a smooth gradient. The Fritsch-Carlson //! filter constrains the tangents so the interpolant is monotone wherever the -//! data is, which is exactly the guarantee a tone curve needs. +//! data is, which is exactly the guarantee a tone curve needs. It is enforced +//! per curve, because "the master's points are in order" says nothing whatever +//! about the blue one's. +//! +//! # Four curves, and the order they run in +//! +//! The master curve runs **first**, and the per-channel curves run on the +//! colour it produced. The two orders are not cosmetically different — an +//! S-curve followed by a lifted blue black point is a visibly different image +//! from the blue lift followed by the S-curve — so the choice has to be made +//! here and stated, rather than left to whichever loop was written first. +//! +//! It is made this way for two reasons. +//! +//! **A control point's x coordinate should mean the tone the photographer can +//! see.** The channel curves are the finishing grade — warm the shadows, cool +//! the highlights — and the tones being graded are the ones on screen, which +//! are the master curve's output. Running the channels first would anchor them +//! to the tones the master is *about to move*: place a warm shadow, then reach +//! for contrast, and the warmth migrates up into the midtones as the master +//! lifts the region the channel curve was pinned to. In this order the master +//! reshapes what reaches the grade, and the grade stays where it was put on +//! the axis the widget draws. +//! +//! **Tone before colour is the order the rest of the chain already runs in.** +//! The master curve is hue-preserving by construction: it curves *luminance* +//! and reapplies the result as a ratio, exactly as contrast does, so it is a +//! tonal operation and nothing else. The per-channel curves deliberately break +//! that ratio — they are the only part of this operation that can change a +//! hue. Putting the chromatic half last keeps this node in step with the chain +//! around it, where the colour mixer is the finishing control and acts on the +//! tones the tonal operations have already settled (`ops/README.md`). +//! +//! # What four curves cost when three of them are untouched +//! +//! Nothing. Each curve is emitted into the fragment and into the uniform block +//! only when it differs from the identity, so the overwhelmingly common edit — +//! an S-curve on the master and no per-channel work at all — generates exactly +//! the shader it generated when this file held one curve, down to the uniform +//! names. An operation whose four curves are all identity is inactive and +//! contributes no code, no uniform and no branch, which is the property the +//! whole composition scheme rests on (ARCH §5.6). -use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale, Unit, - WidgetDemand, WidgetKind,}; +use std::fmt::Write as _; + +use crate::descriptor::{ + Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, + Scale, Unit, WidgetDemand, WidgetKind, +}; use crate::operation::{Helper, Operation, Uniform}; use crate::ops::helpers; pub const ID: OpId = OpId("tone_curve"); -/// How many movable points the curve has. +/// How many movable points a curve has. /// /// Five: the two endpoints, a mid-tone, and one either side. Enough for the /// S-curves and shoulder rolls that make up nearly every tonal edit, few /// enough that the shader can evaluate them without a loop over storage. pub const POINTS: usize = 5; +/// TRACES: FR-DEV-3 +/// Which of the four curves a point belongs to. +/// +/// `Master` is the curve that existed before the other three, and it keeps +/// that position in every list here: it is the one a photographer reaches for +/// first, it is the one that runs first, and — see [`Channel::prefix`] — it is +/// the one whose parameter ids may not change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Channel { + /// Tone, applied to all three components through a luminance ratio. + Master, + Red, + Green, + Blue, +} + +/// How many curves the operation carries. +pub const CHANNELS: usize = Channel::ALL.len(); + +impl Channel { + /// Every curve, in the order they are applied and presented. + /// + /// Master first because it runs first — see the module documentation for + /// why that is the composition order — and because a list showing the + /// grade before the tone would be describing a different operation. + pub const ALL: [Channel; 4] = [ + Channel::Master, + Channel::Red, + Channel::Green, + Channel::Blue, + ]; + + /// Position in [`Self::ALL`], and so in every array keyed by channel. + pub const fn index(self) -> usize { + match self { + Channel::Master => 0, + Channel::Red => 1, + Channel::Green => 2, + Channel::Blue => 3, + } + } + + /// TRACES: FR-CAT-8 + /// What this channel's parameter ids are prefixed with. + /// + /// **The master's prefix is empty, and that is a compatibility guarantee + /// rather than a saving of two characters.** A sidecar is the + /// authoritative store of an edit (ARCH §6.12) and it keys parameters by + /// `op.param` text, so `tone_curve.p2_y`, written by a build that had only + /// one curve, has to keep meaning the master's third point for as long as + /// those files exist. Every id that existed before the channels did is + /// therefore still spelled exactly as it was, and the new ones are spelled + /// differently — rather than the old ones being renamed into a scheme that + /// reads more evenly and loses every edit in the field. + /// + /// It is also why the channel is a *prefix*. A suffix would collide with + /// the axis — `p2_y_r` is one underscore from a point called `y_r` — and + /// the parse in [`ToneCurve::index_of`] would have to read the end of the + /// string to know how to read the beginning of it. + pub const fn prefix(self) -> &'static str { + match self { + Channel::Master => "", + Channel::Red => "r_", + Channel::Green => "g_", + Channel::Blue => "b_", + } + } + + /// The WGSL vector component this curve is applied to, or `None` for the + /// master, which acts on all three through luminance. + const fn component(self) -> Option<&'static str> { + match self { + Channel::Master => None, + Channel::Red => Some("r"), + Channel::Green => Some("g"), + Channel::Blue => Some("b"), + } + } + + /// The localisation key naming this curve. + /// + /// A key, not a word: resolving one needs a localiser and `core/` must not + /// depend on one (NFR-A11Y-1). It reaches the interface as a + /// [`Facet::subject`] — see [`facet_of`] — which is how a panel comes to + /// draw a channel selector without this file knowing that selectors exist. + pub const fn subject(self) -> LocalizedKey { + LocalizedKey(match self { + Channel::Master => "channel.rgb", + Channel::Red => "channel.red", + Channel::Green => "channel.green", + Channel::Blue => "channel.blue", + }) + } + + /// Where this channel sits on the hue wheel, in degrees. + /// + /// Data about the operation, not a decision about appearance: the red + /// curve genuinely acts on the primary at 0°. Whether a frontend draws a + /// swatch from it, and in what shade, is the frontend's to decide + /// (ARCH §4.3a) — which is why this is a number and not a colour. `None` + /// for the master, whose subject is tone rather than a colour. + pub const fn hue(self) -> Option { + match self { + Channel::Master => None, + Channel::Red => Some(0.0), + Channel::Green => Some(120.0), + Channel::Blue => Some(240.0), + } + } + + /// This channel's ten point parameters, x and y interleaved. + pub fn params(self) -> &'static [ParamId] { + let base = self.index() * POINTS * 2; + &CURVE_PARAMS[base..base + POINTS * 2] + } +} + +/// Which coordinate of a point, for [`coordinate`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Axis { + X, + Y, +} + +/// TRACES: FR-DEV-3 +/// The parameter id for one coordinate of one channel's curve. +/// +/// How a caller outside this module addresses a point. The alternative — forty +/// public constants — would write the layout of the parameter list into every +/// call site, where it would then have to agree forever; a caller that wants +/// point 2 of the blue curve says so. +/// +/// Panics if `point` is out of range, which is a programming error rather than +/// anything a file or a user can cause: ids arriving from a sidecar go through +/// [`ToneCurve::index_of`], which returns an option. +pub fn coordinate(channel: Channel, point: usize, axis: Axis) -> ParamId { + assert!(point < POINTS, "the curve has {POINTS} points"); + let axis = match axis { + Axis::X => 0, + Axis::Y => 1, + }; + CURVE_PARAMS[channel.index() * POINTS * 2 + point * 2 + axis] +} + +// The master curve's parameter ids, named because they were named before the +// channels existed: the tests, the develop example and the history's +// coalescing test all address points through them, and a sidecar in the field +// spells them exactly like this. pub const P0_X: ParamId = ParamId("p0_x"); pub const P0_Y: ParamId = ParamId("p0_y"); pub const P1_X: ParamId = ParamId("p1_x"); @@ -56,9 +255,53 @@ pub const P3_Y: ParamId = ParamId("p3_y"); pub const P4_X: ParamId = ParamId("p4_x"); pub const P4_Y: ParamId = ParamId("p4_y"); -/// The parameters the curve widget owns, in point order. -static CURVE_PARAMS: [ParamId; POINTS * 2] = - [P0_X, P0_Y, P1_X, P1_Y, P2_X, P2_Y, P3_X, P3_Y, P4_X, P4_Y]; +/// The ten point ids of one channel, in point order, x before y. +/// +/// A macro because `concat!` needs literals — the same reason the colour +/// mixer's band parameters are macro-generated — and because writing forty ids +/// out by hand is forty chances to transpose two characters in a way that +/// compiles and silently drives the wrong point. +macro_rules! channel_ids { + ($($prefix:literal),* $(,)?) => { + [$( + ParamId(concat!($prefix, "p0_x")), ParamId(concat!($prefix, "p0_y")), + ParamId(concat!($prefix, "p1_x")), ParamId(concat!($prefix, "p1_y")), + ParamId(concat!($prefix, "p2_x")), ParamId(concat!($prefix, "p2_y")), + ParamId(concat!($prefix, "p3_x")), ParamId(concat!($prefix, "p3_y")), + ParamId(concat!($prefix, "p4_x")), ParamId(concat!($prefix, "p4_y")), + )*] + }; +} + +/// The parameters the curve widget owns: every channel, in point order. +/// +/// The widget claims all forty, so a frontend that draws the curve draws all +/// four of them and no point appears a second time as a stray slider beneath +/// it. The prefixes are the ones [`Channel::prefix`] declares, spelled out +/// again here because `concat!` cannot call a function; `the_ids_match_the_ +/// channel_prefixes` is what stops the two drifting. +static CURVE_PARAMS: [ParamId; CHANNELS * POINTS * 2] = channel_ids!["", "r_", "g_", "b_"]; + +/// The uniform names each channel's fragment reads, x and y interleaved. +/// +/// Parallel to [`CURVE_PARAMS`] and deliberately its own table: uniform names +/// are the shader's business and parameter ids are the sidecar's, and tying +/// the two together would make a rename in one file change the meaning of the +/// other. The master's are unprefixed for the same reason its parameters are — +/// a master-only edit generates the shader it always generated, so nothing +/// that keyed on that source has to notice the channels arriving. +static UNIFORM_NAMES: [[&str; POINTS * 2]; CHANNELS] = [ + ["x0", "y0", "x1", "y1", "x2", "y2", "x3", "y3", "x4", "y4"], + [ + "r_x0", "r_y0", "r_x1", "r_y1", "r_x2", "r_y2", "r_x3", "r_y3", "r_x4", "r_y4", + ], + [ + "g_x0", "g_y0", "g_x1", "g_y1", "g_x2", "g_y2", "g_x3", "g_y3", "g_x4", "g_y4", + ], + [ + "b_x0", "b_y0", "b_x1", "b_y1", "b_x2", "b_y2", "b_x3", "b_y3", "b_x4", "b_y4", + ], +]; /// A coordinate parameter: 0…1 with enough precision to place a point /// exactly, and a default putting the curve on the identity diagonal. @@ -77,35 +320,86 @@ const fn coord(id: &'static str, label: &'static str, default: f32) -> ParamDesc ) } +/// TRACES: FR-DEV-3a +/// Where a coordinate sits in the operation's grid. +/// +/// **This is how the channel dimension reaches the interface without the +/// interface learning what a channel is.** The forty parameters are one +/// control — a point coordinate — applied to four subjects, which is exactly +/// what a [`Facet`] describes and the same shape the colour mixer uses for its +/// twelve hue bands. A panel that groups a widget's parameters by their +/// subject gets a four-way selector over the curves for free, names each entry +/// from the key the channel published, and never contains the word "red". +/// +/// A frontend is free to ignore all of it and render forty sliders; nothing +/// becomes unreachable, it merely reads as forty anonymous coordinates. +const fn facet_of(aspect: &'static str, channel: Channel) -> Facet { + Facet { + // What this parameter adjusts: one coordinate of one point. Four + // parameters share it — the same point on each of the four curves. + aspect: LocalizedKey(aspect), + // What it adjusts it on. + subject: channel.subject(), + subject_hue: channel.hue(), + } +} + +/// One channel's ten descriptors, defaulted onto the identity diagonal. +/// +/// Written out per point rather than looped because a `ParamDescriptor` has to +/// be `const` to live in a `static`, and a const loop cannot build a slice. +macro_rules! channel_params { + ($(($prefix:literal, $channel:expr)),* $(,)?) => { + &[$( + coord(concat!($prefix, "p0_x"), "param.curve.p0_x", 0.0) + .faceted(facet_of("param.curve.p0_x", $channel)), + coord(concat!($prefix, "p0_y"), "param.curve.p0_y", 0.0) + .faceted(facet_of("param.curve.p0_y", $channel)), + coord(concat!($prefix, "p1_x"), "param.curve.p1_x", 0.25) + .faceted(facet_of("param.curve.p1_x", $channel)), + coord(concat!($prefix, "p1_y"), "param.curve.p1_y", 0.25) + .faceted(facet_of("param.curve.p1_y", $channel)), + coord(concat!($prefix, "p2_x"), "param.curve.p2_x", 0.5) + .faceted(facet_of("param.curve.p2_x", $channel)), + coord(concat!($prefix, "p2_y"), "param.curve.p2_y", 0.5) + .faceted(facet_of("param.curve.p2_y", $channel)), + coord(concat!($prefix, "p3_x"), "param.curve.p3_x", 0.75) + .faceted(facet_of("param.curve.p3_x", $channel)), + coord(concat!($prefix, "p3_y"), "param.curve.p3_y", 0.75) + .faceted(facet_of("param.curve.p3_y", $channel)), + coord(concat!($prefix, "p4_x"), "param.curve.p4_x", 1.0) + .faceted(facet_of("param.curve.p4_x", $channel)), + coord(concat!($prefix, "p4_y"), "param.curve.p4_y", 1.0) + .faceted(facet_of("param.curve.p4_y", $channel)), + )*] + }; +} + static DESCRIPTOR: OpDescriptor = OpDescriptor { - // Both, and this is the case the plural exists for: an RGB curve is + // Both, and this is the case the plural exists for: the master curve is // tonal and the per-channel curves are chromatic. Filing it under one // would hide it from half the people looking for it. attributes: &[Attribute::Tone, Attribute::Colour], id: ID, label: LocalizedKey("op.tone_curve"), // Defaults lie on y = x, so a fresh curve is the identity and the - // operation reports itself inactive. - params: &[ - coord("p0_x", "param.curve.p0_x", 0.0), - coord("p0_y", "param.curve.p0_y", 0.0), - coord("p1_x", "param.curve.p1_x", 0.25), - coord("p1_y", "param.curve.p1_y", 0.25), - coord("p2_x", "param.curve.p2_x", 0.5), - coord("p2_y", "param.curve.p2_y", 0.5), - coord("p3_x", "param.curve.p3_x", 0.75), - coord("p3_y", "param.curve.p3_y", 0.75), - coord("p4_x", "param.curve.p4_x", 1.0), - coord("p4_y", "param.curve.p4_y", 1.0), + // operation reports itself inactive — on every channel. + // + // The master's ten come first, and stay first: a frontend addresses a + // point by its offset from the first parameter of the run it is drawing, + // and this is also the order one falling back to sliders reads them in. + params: channel_params![ + ("", Channel::Master), + ("r_", Channel::Red), + ("g_", Channel::Green), + ("b_", Channel::Blue), ], }; -static CURVE_HELPERS: &[Helper] = &[ - helpers::LUMINANCE, - helpers::APPLY_TONE_GAIN, - Helper { - name: "curve_span", - source: "\ +/// One span of a monotone cubic Hermite spline. Shared by all four curves. +const CURVE_SPAN: Helper = Helper { + name: "curve_span", + source: "\ // One span of a monotone cubic Hermite spline. // // Takes the span's endpoints and the secants either side of it, rather than @@ -158,15 +452,20 @@ fn curve_span( return h00 * y0 + h10 * h * m0 + h01 * y1 + h11 * h * m1; }", - }, - Helper { - name: "curve_eval", - source: "\ -// Evaluate the five-point tone curve at `x`. +}; + +/// The five-point evaluation. Shared by all four curves — one function, called +/// with whichever curve's points the caller holds, rather than four copies +/// that could be improved one at a time. +const CURVE_EVAL: Helper = Helper { + name: "curve_eval", + source: "\ +// Evaluate a five-point curve at `x`. // // Spans are unrolled and secants passed explicitly; see `curve_span` for why // there is no array indexing here. Points arrive pre-sorted with a minimum -// separation enforced on the CPU, so no division can be by zero. +// separation enforced on the CPU — per curve, so one channel's points cannot +// be rescued by another's — so no division can be by zero. fn curve_eval( x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, x3: f32, y3: f32, x4: f32, y4: f32, x: f32, @@ -188,19 +487,102 @@ fn curve_eval( if (x < x3) { return curve_span(x2, y2, x3, y3, s1, s3, x); } return curve_span(x3, y3, x4, y4, s2, s3, x); }", - }, +}; + +/// One colour component through its own curve. +const CHANNEL_CURVE: Helper = Helper { + name: "channel_curve", + source: "\ +// One colour component through its own curve, on the display-referred axis. +// +// The same encode-curve-decode as the master's, and for the same reason: the +// widget draws a 0..1 grid, so a point placed at the middle of it has to mean +// the middle of the visible range rather than the middle of an unbounded +// scene-referred one. +// +// What differs is that this is applied to the component *directly* rather than +// as a ratio over luminance. That is the whole point of a per-channel curve — +// it changes the proportions between the components, which is what makes it +// chromatic where the master is tonal. +// +// The clamp is the curve's promise rather than an oversight: its last point +// *is* white, so a component arriving above the axis takes the value the curve +// gives at 1. The master does the same to a luminance above 1, through the +// gain it applies; a channel curve that instead let highlights past unchanged +// would tint them differently from every tone below them, which reads as a +// coloured fringe along a blown edge. +fn channel_curve( + v: f32, + x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, + x3: f32, y3: f32, x4: f32, y4: f32, +) -> f32 { + let encoded = pow(clamp(v, 0.0, 1.0), 1.0 / 2.2); + let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded); + return pow(clamp(curved, 0.0, 1.0), 2.2); +}", +}; + +// The three helper sets, one per shape of edit. +// +// Chosen rather than assembled because [`Operation::helpers`] hands back a +// `&'static [Helper]` and there is nowhere to build a list at call time. Three +// statics rather than one union so a master-only edit — the common case — +// declares no function it does not call, and a grade with no tonal work does +// not drag in the luminance machinery it has no use for. + +/// The master curve alone. +static MASTER_HELPERS: &[Helper] = &[ + helpers::LUMINANCE, + helpers::APPLY_TONE_GAIN, + CURVE_SPAN, + CURVE_EVAL, ]; -/// A tone curve through [`POINTS`] movable points. -#[derive(Debug, Clone)] -pub struct ToneCurve { +/// The per-channel curves alone. +static CHANNEL_HELPERS: &[Helper] = &[CURVE_SPAN, CURVE_EVAL, CHANNEL_CURVE]; + +/// Both. +static ALL_HELPERS: &[Helper] = &[ + helpers::LUMINANCE, + helpers::APPLY_TONE_GAIN, + CURVE_SPAN, + CURVE_EVAL, + CHANNEL_CURVE, +]; + +/// The master curve's fragment: tone, applied as a ratio so hue survives it. +const MASTER_BODY: &str = "\ +let luma = luminance(c); +if (luma > 0.0001) { + // The curve is authored on a display-referred 0..1 axis, which is where + // the eye reads tone and where the widget's grid lives. Scene-referred + // luminance is unbounded, so it is encoded to that axis, curved, and + // decoded back — otherwise a point placed at the middle of the grid + // would not correspond to the middle of the visible range. + let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2); + + let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded); + + let decoded = pow(clamp(curved, 0.0, 1.0), 2.2); + // Applied as a ratio so hue is preserved, exactly as contrast does. + c = apply_tone_gain(c, decoded / luma); +}"; + +/// A five-point monotone spline. +/// +/// One of these per channel. The point *values* live here and the parameter +/// *names* live in [`CURVE_PARAMS`], which is what lets the master keep the +/// ids it was born with while the code below stops caring which curve it is +/// holding. +#[derive(Debug, Clone, Copy)] +struct Curve { xs: [f32; POINTS], ys: [f32; POINTS], } -impl Default for ToneCurve { - fn default() -> Self { - // The identity diagonal. +impl Curve { + /// The identity diagonal. + const fn identity() -> Self { let mut xs = [0.0f32; POINTS]; let mut ys = [0.0f32; POINTS]; let mut i = 0; @@ -212,32 +594,15 @@ impl Default for ToneCurve { } Self { xs, ys } } -} - -impl ToneCurve { - pub fn new() -> Self { - Self::default() - } - - /// Map a parameter id to `(point index, is_y)`. - fn index_of(id: ParamId) -> Option<(usize, bool)> { - let (point, axis) = id.0.split_once('_')?; - let index: usize = point.strip_prefix('p')?.parse().ok()?; - if index >= POINTS { - return None; - } - match axis { - "x" => Some((index, false)), - "y" => Some((index, true)), - _ => None, - } - } /// The x coordinates, sorted and separated. /// /// The widget cannot reorder points, but a sidecar can carry anything and /// a spline through unordered or coincident x values divides by zero. - /// Enforced here so the shader never has to check. + /// Enforced here so the shader never has to check — and enforced on each + /// curve independently, because a NaN on the blue channel blanks the image + /// exactly as thoroughly as one on the master, and it is the one nobody + /// thinks to try. fn sorted_xs(&self) -> [f32; POINTS] { const MIN_GAP: f32 = 0.001; let mut xs = self.xs; @@ -259,7 +624,7 @@ impl ToneCurve { xs } - /// Whether the curve differs from the identity. + /// Whether this curve differs from the identity. fn differs_from_identity(&self) -> bool { self.xs .iter() @@ -268,6 +633,76 @@ impl ToneCurve { } } +/// TRACES: FR-DEV-3 +/// A master tone curve and one curve per colour channel. +#[derive(Debug, Clone)] +pub struct ToneCurve { + /// Indexed by [`Channel::index`]. + curves: [Curve; CHANNELS], +} + +impl Default for ToneCurve { + fn default() -> Self { + Self { + curves: [Curve::identity(); CHANNELS], + } + } +} + +impl ToneCurve { + pub fn new() -> Self { + Self::default() + } + + /// TRACES: FR-CAT-8 + /// Map a parameter id to `(channel, point index, is_y)`. + /// + /// **An id with no channel prefix is the master curve**, which is what + /// makes a sidecar written before the per-channel curves existed load and + /// mean what it meant: `p2_y` was the master's third point then and parses + /// to the master's third point now. Nothing needs a version check, because + /// nothing was renamed — the new curves took new names instead. + /// + /// Only the three known prefixes are recognised, so an id from a *newer* + /// build naming a curve this one does not have falls out as `None` and is + /// warned about, rather than being read as some other point. The sidecar + /// preserves the line either way (see [`crate::sidecar`]), so the edit + /// survives the round trip through a build that cannot apply it. + fn index_of(id: ParamId) -> Option<(Channel, usize, bool)> { + let (channel, rest) = match id.0.split_once('_') { + Some(("r", rest)) => (Channel::Red, rest), + Some(("g", rest)) => (Channel::Green, rest), + Some(("b", rest)) => (Channel::Blue, rest), + // No recognised prefix: the id names the master's own point, in + // the spelling it has always had. + _ => (Channel::Master, id.0), + }; + + let (point, axis) = rest.split_once('_')?; + let index: usize = point.strip_prefix('p')?.parse().ok()?; + if index >= POINTS { + return None; + } + match axis { + "x" => Some((channel, index, false)), + "y" => Some((channel, index, true)), + _ => None, + } + } + + fn curve(&self, channel: Channel) -> &Curve { + &self.curves[channel.index()] + } + + /// Whether any per-channel curve contributes anything. + fn channels_active(&self) -> bool { + Channel::ALL + .iter() + .filter(|c| c.component().is_some()) + .any(|c| self.curve(*c).differs_from_identity()) + } +} + impl Operation for ToneCurve { fn descriptor(&self) -> &'static OpDescriptor { &DESCRIPTOR @@ -275,22 +710,22 @@ impl Operation for ToneCurve { fn set_param(&mut self, id: ParamId, value: f32) { match Self::index_of(id) { - Some((i, true)) => self.ys[i] = value, - Some((i, false)) => self.xs[i] = value, + Some((c, i, true)) => self.curves[c.index()].ys[i] = value, + Some((c, i, false)) => self.curves[c.index()].xs[i] = value, None => log::warn!("tone_curve: unknown parameter {id}"), } } fn param(&self, id: ParamId) -> f32 { match Self::index_of(id) { - Some((i, true)) => self.ys[i], - Some((i, false)) => self.xs[i], + Some((c, i, true)) => self.curve(c).ys[i], + Some((c, i, false)) => self.curve(c).xs[i], None => 0.0, } } fn is_active(&self) -> bool { - self.differs_from_identity() + self.curves.iter().any(Curve::differs_from_identity) } fn presentation(&self) -> Option { @@ -305,88 +740,96 @@ impl Operation for ToneCurve { two_dimensional: true, precise_pointing: true, }, + // All four curves. A widget claiming only the master's ten would + // leave the other thirty stranded as sliders beneath the plot; + // which of the four it draws at a time is its own affair, and the + // facets are what let it decide without naming a channel. params: &CURVE_PARAMS, }) } fn wgsl_body(&self) -> String { - "\ -let luma = luminance(c); -if (luma > 0.0001) { - // The curve is authored on a display-referred 0..1 axis, which is where - // the eye reads tone and where the widget's grid lives. Scene-referred - // luminance is unbounded, so it is encoded to that axis, curved, and - // decoded back — otherwise a point placed at the middle of the grid - // would not correspond to the middle of the visible range. - let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2); + let mut body = String::new(); - let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded); + // Tone first, then colour on top of it — see the module documentation + // for why this order and not the other one. + if self.curve(Channel::Master).differs_from_identity() { + body.push_str(MASTER_BODY); + body.push('\n'); + } - let decoded = pow(clamp(curved, 0.0, 1.0), 2.2); - // Applied as a ratio so hue is preserved, exactly as contrast does. - c = apply_tone_gain(c, decoded / luma); -} -c = max(c, vec3(0.0));" - .into() + for channel in Channel::ALL { + let Some(component) = channel.component() else { + continue; + }; + // An untouched channel is not a curve evaluated to the identity; + // it is nothing at all in the generated source. + if !self.curve(channel).differs_from_identity() { + continue; + } + // The arguments come out of the same table the uniforms are + // declared from, so a call and its uniform block cannot disagree + // about a name. + let args = UNIFORM_NAMES[channel.index()].join(", "); + let _ = writeln!(body, "c.{component} = channel_curve(c.{component}, {args});"); + } + + // Whichever curves ran, the result has to be a colour: the spline's + // tangents can carry a point at the floor a very small distance below + // zero, and a negative component poisons every operation after this + // one. + body.push_str("c = max(c, vec3(0.0));"); + body } fn uniforms(&self) -> Vec { - let xs = self.sorted_xs(); - vec![ - Uniform { - name: "x0", - value: xs[0], - }, - Uniform { - name: "x1", - value: xs[1], - }, - Uniform { - name: "x2", - value: xs[2], - }, - Uniform { - name: "x3", - value: xs[3], - }, - Uniform { - name: "x4", - value: xs[4], - }, - Uniform { - name: "y0", - value: self.ys[0], - }, - Uniform { - name: "y1", - value: self.ys[1], - }, - Uniform { - name: "y2", - value: self.ys[2], - }, - Uniform { - name: "y3", - value: self.ys[3], - }, - Uniform { - name: "y4", - value: self.ys[4], - }, - ] + let mut out = Vec::new(); + for channel in Channel::ALL { + let curve = self.curve(channel); + // An untouched curve declares nothing, which is what makes three + // unused curves cost nothing rather than thirty uniform slots. + if !curve.differs_from_identity() { + continue; + } + let names = &UNIFORM_NAMES[channel.index()]; + let xs = curve.sorted_xs(); + for i in 0..POINTS { + out.push(Uniform { + name: names[i * 2], + value: xs[i], + }); + out.push(Uniform { + name: names[i * 2 + 1], + value: curve.ys[i], + }); + } + } + out } fn helpers(&self) -> &'static [Helper] { - CURVE_HELPERS + match ( + self.curve(Channel::Master).differs_from_identity(), + self.channels_active(), + ) { + (true, false) => MASTER_HELPERS, + (false, true) => CHANNEL_HELPERS, + // Both — and the fourth case, neither, which the composer never + // asks because an inactive operation is skipped whole. + _ => ALL_HELPERS, + } } } -/// Evaluate the curve on the CPU. +/// Evaluate a curve on the CPU. /// /// The same maths as the shader, used by the widget to draw the line it is /// editing. Duplicating it is deliberate: the alternative is a GPU readback /// per frame to draw a 200-pixel polyline (ARCH §6.1), and the shared tests /// below pin the two implementations to the same values. +/// +/// Takes points rather than a channel because it has no idea which curve it is +/// drawing and does not need one — four curves are four calls. pub fn evaluate(xs: &[f32; POINTS], ys: &[f32; POINTS], x: f32) -> f32 { if x <= xs[0] { return ys[0]; @@ -457,10 +900,13 @@ mod tests { use super::*; fn identity() -> ([f32; POINTS], [f32; POINTS]) { - let c = ToneCurve::new(); + let c = Curve::identity(); (c.xs, c.ys) } + /// The three curves that are not the master. + const COLOURS: [Channel; 3] = [Channel::Red, Channel::Green, Channel::Blue]; + #[test] fn a_fresh_curve_is_the_identity_and_inactive() { // Opening an unedited image must show the image. @@ -490,7 +936,83 @@ mod tests { p.id ); } - assert_eq!(DESCRIPTOR.params.len(), POINTS * 2); + assert_eq!(DESCRIPTOR.params.len(), CHANNELS * POINTS * 2); + } + + #[test] + fn the_ids_match_the_channel_prefixes() { + // `concat!` cannot call `Channel::prefix`, so the prefixes are written + // twice. This is what stops the two spellings drifting apart — which + // would produce a parameter the descriptor declares and `index_of` + // routes somewhere else. + for channel in Channel::ALL { + for (i, id) in channel.params().iter().enumerate() { + assert!( + id.0.starts_with(channel.prefix()), + "{id} is not on {channel:?}" + ); + let point = i / 2; + let is_y = i % 2 == 1; + assert_eq!(ToneCurve::index_of(*id), Some((channel, point, is_y))); + } + } + } + + #[test] + fn the_master_curves_parameters_are_spelled_as_they_always_were() { + // **The sidecar compatibility test.** These ten ids are written into + // every file produced before the per-channel curves existed, and a + // sidecar is the authoritative store of an edit (ARCH §6.12). + // Renaming one — to `m_p2_y`, say, for symmetry with `r_p2_y` — would + // silently drop that point from every edit in the field. + for (i, (x, y)) in [ + (P0_X, P0_Y), + (P1_X, P1_Y), + (P2_X, P2_Y), + (P3_X, P3_Y), + (P4_X, P4_Y), + ] + .into_iter() + .enumerate() + { + assert_eq!(ToneCurve::index_of(x), Some((Channel::Master, i, false))); + assert_eq!(ToneCurve::index_of(y), Some((Channel::Master, i, true))); + assert_eq!(coordinate(Channel::Master, i, Axis::X), x); + assert_eq!(coordinate(Channel::Master, i, Axis::Y), y); + assert!( + DESCRIPTOR.params.iter().any(|p| p.id == x), + "{x} left the descriptor" + ); + } + } + + #[test] + fn a_master_point_from_an_older_sidecar_still_moves_the_master_curve() { + // The same claim from the other end: the *value* arrives where it used + // to, not merely the name. + let mut c = ToneCurve::new(); + c.set_param(ParamId("p2_y"), 0.65); + assert_eq!(c.curve(Channel::Master).ys[2], 0.65); + for channel in COLOURS { + assert!( + !c.curve(channel).differs_from_identity(), + "{channel:?} moved when only the master was set" + ); + } + } + + #[test] + fn each_channel_owns_its_own_points() { + // The failure this guards is one array behind four names: set red, + // read blue, and see red's value. + let mut c = ToneCurve::new(); + for (channel, value) in COLOURS.into_iter().zip([0.6, 0.7, 0.8]) { + c.set_param(coordinate(channel, 2, Axis::Y), value); + } + assert_eq!(c.param(coordinate(Channel::Red, 2, Axis::Y)), 0.6); + assert_eq!(c.param(coordinate(Channel::Green, 2, Axis::Y)), 0.7); + assert_eq!(c.param(coordinate(Channel::Blue, 2, Axis::Y)), 0.8); + assert_eq!(c.param(P2_Y), 0.5, "the master must not have moved"); } #[test] @@ -501,6 +1023,18 @@ mod tests { assert_eq!(c.param(P2_Y), 0.65); } + #[test] + fn moving_a_channel_point_activates_the_operation() { + // An edit that touches only the blue curve is still an edit; an + // `is_active` that looked at the master alone would drop it from the + // shader and show the untouched image. + for channel in COLOURS { + let mut c = ToneCurve::new(); + c.set_param(coordinate(channel, 1, Axis::Y), 0.4); + assert!(c.is_active(), "{channel:?} did not activate the operation"); + } + } + #[test] fn the_curve_passes_through_its_control_points() { // The property that makes the widget honest: the line drawn through @@ -509,14 +1043,15 @@ mod tests { c.set_param(P1_Y, 0.15); c.set_param(P3_Y, 0.85); - let xs = c.sorted_xs(); + let master = c.curve(Channel::Master); + let xs = master.sorted_xs(); for i in 0..POINTS { - let y = evaluate(&xs, &c.ys, xs[i]); + let y = evaluate(&xs, &master.ys, xs[i]); assert!( - (y - c.ys[i]).abs() < 1e-4, + (y - master.ys[i]).abs() < 1e-4, "point {i} at x={} evaluated to {y}, expected {}", xs[i], - c.ys[i] + master.ys[i] ); } } @@ -530,11 +1065,12 @@ mod tests { c.set_param(P1_Y, 0.10); c.set_param(P3_Y, 0.90); - let xs = c.sorted_xs(); + let master = c.curve(Channel::Master); + let xs = master.sorted_xs(); let mut previous = f32::NEG_INFINITY; for i in 0..=200 { let x = i as f32 / 200.0; - let y = evaluate(&xs, &c.ys, x); + let y = evaluate(&xs, &master.ys, x); assert!( y >= previous - 1e-5, "curve decreased at x={x}: {y} after {previous}" @@ -554,16 +1090,37 @@ mod tests { c.set_param(P3_Y, 0.97); c.set_param(P4_Y, 1.0); - let xs = c.sorted_xs(); + let master = c.curve(Channel::Master); + let xs = master.sorted_xs(); let mut previous = f32::NEG_INFINITY; for i in 0..=200 { - let y = evaluate(&xs, &c.ys, i as f32 / 200.0); + let y = evaluate(&xs, &master.ys, i as f32 / 200.0); assert!(y >= previous - 1e-5, "decreased at {i}"); assert!(y.is_finite(), "non-finite at {i}"); previous = y; } } + #[test] + fn a_channel_curve_stays_monotonic() { + // The same guarantee the master carries, and it matters more here: a + // non-monotone blue curve inverts blue locally, which is a hue + // reversal rather than a dark halo — harder to see and much harder to + // attribute to the control that caused it. + let mut c = ToneCurve::new(); + c.set_param(coordinate(Channel::Blue, 1, Axis::Y), 0.05); + c.set_param(coordinate(Channel::Blue, 3, Axis::Y), 0.95); + + let blue = c.curve(Channel::Blue); + let xs = blue.sorted_xs(); + let mut previous = f32::NEG_INFINITY; + for i in 0..=200 { + let y = evaluate(&xs, &blue.ys, i as f32 / 200.0); + assert!(y >= previous - 1e-5, "blue decreased at {i}"); + previous = y; + } + } + #[test] fn a_flat_span_stays_flat() { // Two points at the same height must not bow between them. @@ -572,10 +1129,11 @@ mod tests { c.set_param(P2_Y, 0.5); c.set_param(P3_Y, 0.5); - let xs = c.sorted_xs(); + let master = c.curve(Channel::Master); + let xs = master.sorted_xs(); for i in 0..=20 { let x = 0.25 + (i as f32 / 20.0) * 0.5; - let y = evaluate(&xs, &c.ys, x); + let y = evaluate(&xs, &master.ys, x); assert!((y - 0.5).abs() < 1e-4, "at {x} the flat span gave {y}"); } } @@ -588,35 +1146,41 @@ mod tests { } #[test] - fn coincident_x_values_are_separated() { + fn coincident_x_values_are_separated_on_every_channel() { // A sidecar can carry anything; a spline through two points at the - // same x divides by zero and produces NaN across the image. - let mut c = ToneCurve::new(); - c.set_param(P1_X, 0.5); - c.set_param(P2_X, 0.5); - c.set_param(P3_X, 0.5); + // same x divides by zero and produces NaN across the image. The + // guarantee has to hold per curve, because the sort is per curve. + for channel in Channel::ALL { + let mut c = ToneCurve::new(); + for point in 1..4 { + c.set_param(coordinate(channel, point, Axis::X), 0.5); + } - let xs = c.sorted_xs(); - for i in 1..POINTS { - assert!( - xs[i] > xs[i - 1], - "x values must be strictly increasing, got {xs:?}" - ); - } - // And the result must be usable, not merely non-crashing. - for i in 0..=50 { - assert!(evaluate(&xs, &c.ys, i as f32 / 50.0).is_finite()); + let curve = c.curve(channel); + let xs = curve.sorted_xs(); + for i in 1..POINTS { + assert!( + xs[i] > xs[i - 1], + "{channel:?}: x values must be strictly increasing, got {xs:?}" + ); + } + // And the result must be usable, not merely non-crashing. + for i in 0..=50 { + assert!(evaluate(&xs, &curve.ys, i as f32 / 50.0).is_finite()); + } } } #[test] - fn out_of_order_x_values_are_sorted() { - let mut c = ToneCurve::new(); - c.set_param(P1_X, 0.9); - c.set_param(P3_X, 0.1); - let xs = c.sorted_xs(); - for i in 1..POINTS { - assert!(xs[i] > xs[i - 1], "not sorted: {xs:?}"); + fn out_of_order_x_values_are_sorted_on_every_channel() { + for channel in Channel::ALL { + let mut c = ToneCurve::new(); + c.set_param(coordinate(channel, 1, Axis::X), 0.9); + c.set_param(coordinate(channel, 3, Axis::X), 0.1); + let xs = c.curve(channel).sorted_xs(); + for i in 1..POINTS { + assert!(xs[i] > xs[i - 1], "{channel:?} not sorted: {xs:?}"); + } } } @@ -643,10 +1207,65 @@ mod tests { } } + #[test] + fn the_widgets_parameters_are_grouped_by_the_curve_they_belong_to() { + // What a channel selector is built out of. A panel groups the widget's + // parameters by their facet's subject and gets four curves, in this + // order, without knowing that a colour channel is a thing — so each + // channel's run has to be contiguous, complete, and labelled. + let presentation = ToneCurve::new().presentation().expect("declares a widget"); + for channel in Channel::ALL { + let base = channel.index() * POINTS * 2; + assert_eq!( + &presentation.params[base..base + POINTS * 2], + channel.params(), + "{channel:?}'s points are not contiguous in the widget's list" + ); + for id in channel.params() { + let facet = DESCRIPTOR + .param(*id) + .expect("declared") + .facet + .expect("a curve point says which curve it is on"); + assert_eq!(facet.subject, channel.subject()); + assert_eq!(facet.subject_hue, channel.hue()); + } + } + } + + #[test] + fn the_four_curves_share_one_aspect_per_coordinate() { + // The other half of the grid: the same point on all four curves is one + // control applied to four subjects, which is what makes a panel able to + // draw one plot and change its subject. + for point in 0..POINTS { + for axis in [Axis::X, Axis::Y] { + let aspects: Vec<_> = Channel::ALL + .iter() + .map(|c| { + DESCRIPTOR + .param(coordinate(*c, point, axis)) + .expect("declared") + .facet + .expect("faceted") + .aspect + }) + .collect(); + assert!( + aspects.windows(2).all(|w| w[0] == w[1]), + "point {point} {axis:?} does not share an aspect across the curves" + ); + } + } + } + #[test] fn the_fragment_reads_every_declared_uniform() { let mut c = ToneCurve::new(); c.set_param(P2_Y, 0.7); + for channel in COLOURS { + c.set_param(coordinate(channel, 2, Axis::Y), 0.6); + } let body = c.wgsl_body(); for u in c.uniforms() { assert!( @@ -657,12 +1276,119 @@ mod tests { } } + #[test] + fn an_untouched_channel_costs_nothing() { + // **The property that makes four curves affordable.** A photograph + // edited with the master curve alone must generate what it generated + // when this operation held one curve: the same ten uniforms, the same + // fragment, and not one line about red, green or blue. + let mut c = ToneCurve::new(); + c.set_param(P2_Y, 0.7); + + let body = c.wgsl_body(); + assert!(body.contains("curve_eval("), "the master curve is missing"); + assert!( + !body.contains("channel_curve("), + "an untouched channel reached the shader:\n{body}" + ); + let names: Vec<&str> = c.uniforms().iter().map(|u| u.name).collect(); + assert_eq!(names.len(), POINTS * 2, "only the master declares uniforms"); + assert!( + !names.iter().any(|n| n.contains('_')), + "an untouched channel declared uniforms: {names:?}" + ); + assert_eq!(c.helpers(), MASTER_HELPERS); + } + + #[test] + fn an_untouched_master_costs_nothing() { + // The mirror image, and the case a naive implementation gets wrong: a + // grade with no tonal work should not pay for a luminance evaluation + // that maps every pixel to itself. + let mut c = ToneCurve::new(); + c.set_param(coordinate(Channel::Blue, 0, Axis::Y), 0.08); + + let body = c.wgsl_body(); + assert!( + !body.contains("luminance("), + "the identity master curve reached the shader:\n{body}" + ); + assert!(body.contains("c.b = channel_curve(c.b,")); + assert!(!body.contains("c.r = "), "red was untouched:\n{body}"); + let names: Vec<&str> = c.uniforms().iter().map(|u| u.name).collect(); + assert_eq!(names.len(), POINTS * 2); + assert!(names.iter().all(|n| n.starts_with("b_")), "{names:?}"); + assert_eq!(c.helpers(), CHANNEL_HELPERS); + } + + #[test] + fn a_neutral_curve_contributes_no_uniforms_at_all() { + // Belt and braces around `is_active`: the composer skips an inactive + // operation, but one that declared uniforms while claiming to be + // neutral would push the whole uniform block out of step the day that + // changed. + let c = ToneCurve::new(); + assert!(!c.is_active()); + assert!(c.uniforms().is_empty()); + assert_eq!(c.wgsl_body(), "c = max(c, vec3(0.0));"); + } + + #[test] + fn the_master_curve_runs_before_the_channel_curves() { + // **The composition order, asserted rather than described.** The + // channels grade the tones the master produced; the other order is a + // visibly different image, and it is the kind of change that arrives + // by accident when someone reorders a loop. + let mut c = ToneCurve::new(); + c.set_param(P2_Y, 0.7); + c.set_param(coordinate(Channel::Red, 1, Axis::Y), 0.3); + + let body = c.wgsl_body(); + let master = body.find("apply_tone_gain").expect("the master curve runs"); + let red = body.find("c.r = channel_curve").expect("the red curve runs"); + assert!(master < red, "the master curve must run first:\n{body}"); + } + + #[test] + fn the_channels_run_in_the_order_they_are_listed() { + // Not because the result depends on it — the three act on separate + // components — but because a reader comparing the generated shader + // with this file should not have to wonder whether it does. + let mut c = ToneCurve::new(); + for channel in COLOURS { + c.set_param(coordinate(channel, 2, Axis::Y), 0.6); + } + let body = c.wgsl_body(); + let at = |s: &str| body.find(s).unwrap_or_else(|| panic!("{s} missing")); + assert!(at("c.r = ") < at("c.g = ")); + assert!(at("c.g = ") < at("c.b = ")); + } + #[test] fn unknown_parameters_are_ignored() { let mut c = ToneCurve::new(); c.set_param(ParamId("p9_x"), 0.5); c.set_param(ParamId("nonsense"), 0.5); c.set_param(ParamId("p1_z"), 0.5); + // A curve from a build that has more of them than this one does. + c.set_param(ParamId("k_p1_y"), 0.5); + c.set_param(ParamId("r_p9_y"), 0.5); assert!(!c.is_active()); } + + #[test] + fn every_channel_is_nameable_and_distinct() { + // The selector is built from these, so two channels sharing a key + // would draw two entries with one name and no way to tell which is + // which. + let mut keys: Vec<&str> = Channel::ALL.iter().map(|c| c.subject().0).collect(); + let before = keys.len(); + keys.sort_unstable(); + keys.dedup(); + assert_eq!(before, keys.len(), "two channels share a name"); + assert_eq!(Channel::ALL.len(), CHANNELS); + for c in Channel::ALL { + assert!(!c.subject().0.is_empty()); + } + } } diff --git a/core/dr-pipeline/src/sidecar.rs b/core/dr-pipeline/src/sidecar.rs index 3b28fd8..a7b8d8f 100644 --- a/core/dr-pipeline/src/sidecar.rs +++ b/core/dr-pipeline/src/sidecar.rs @@ -1088,7 +1088,7 @@ impl std::error::Error for ParseError {} mod tests { use super::*; use crate::framing; - use crate::ops::{exposure, saturation, white_balance}; + use crate::ops::{curve, exposure, saturation, white_balance}; fn edited() -> EditGraph { let mut g = EditGraph::default_chain(); @@ -1273,6 +1273,90 @@ mod tests { assert_eq!(once, twice); } + /// TRACES: FR-DEV-3 | FR-CAT-8 + /// A file written before the tone curve had per-channel curves. + /// + /// Spelled out as literal text rather than produced by `to_text`, because + /// the claim is about *those bytes*: a sidecar generated by this build + /// would agree with this build by construction, and would go on agreeing + /// with it through a rename that broke every file on disk. + #[test] + fn a_sidecar_from_before_the_channel_curves_still_names_the_master() { + let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 4\nmodified = 9\n\ + tone_curve.p1_y = 0.15\ntone_curve.p3_y = 0.85\n"; + let parsed = Sidecar::parse(text).expect("valid"); + let mut g = EditGraph::default_chain(); + parsed.default_version().expect("a version").apply(&mut g); + + // The S-curve the file describes, on the master curve and nowhere + // else. + assert_eq!(g.param(curve::ID, curve::P1_Y), Some(0.15)); + assert_eq!(g.param(curve::ID, curve::P3_Y), Some(0.85)); + for channel in [curve::Channel::Red, curve::Channel::Green, curve::Channel::Blue] { + for point in 0..curve::POINTS { + for axis in [curve::Axis::X, curve::Axis::Y] { + let id = curve::coordinate(channel, point, axis); + let expected = g + .capabilities() + .iter() + .find(|c| c.id == curve::ID) + .and_then(|c| c.params.iter().find(|p| p.id == id)) + .map(|p| p.default); + assert_eq!( + g.param(curve::ID, id), + expected, + "{id} moved, and no line in the file mentions it" + ); + } + } + } + + // And writing it back produces the same two lines: the curves the file + // never mentioned are still at their defaults, so they are still + // absent (`only_non_default_values_are_written`). + let written = Sidecar::parse(&Sidecar::parse(text).expect("valid").to_text()) + .expect("valid") + .to_text(); + assert!(written.contains("tone_curve.p1_y = 0.15"), "{written}"); + assert!(written.contains("tone_curve.p3_y = 0.85"), "{written}"); + assert!( + !written.contains("tone_curve.r_"), + "an untouched channel curve was written out:\n{written}" + ); + } + + /// TRACES: FR-DEV-3 + /// The other direction: the new curves persist like any other parameter. + #[test] + fn a_per_channel_curve_survives_the_round_trip() { + let mut g = EditGraph::default_chain(); + // A faded shadow: blue lifted at the black point, red pulled down. + let blue = curve::coordinate(curve::Channel::Blue, 0, curve::Axis::Y); + let red = curve::coordinate(curve::Channel::Red, 4, curve::Axis::Y); + g.set_param(curve::ID, blue, 0.08); + g.set_param(curve::ID, red, 0.92); + g.set_param(curve::ID, curve::P2_Y, 0.55); + + let mut sidecar = Sidecar::new(); + sidecar.put(version_of(&g)); + let text = sidecar.to_text(); + // Keyed by the channel-prefixed id, which is what makes the master's + // unprefixed ones safe to leave alone. + assert!(text.contains("tone_curve.b_p0_y = 0.08"), "{text}"); + + let parsed = Sidecar::parse(&text).expect("valid"); + let mut restored = EditGraph::default_chain(); + parsed + .default_version() + .expect("a version") + .apply(&mut restored); + + assert_eq!(restored.param(curve::ID, blue), Some(0.08)); + assert_eq!(restored.param(curve::ID, red), Some(0.92)); + assert_eq!(restored.param(curve::ID, curve::P2_Y), Some(0.55)); + assert!(!restored.is_neutral()); + } + #[test] fn an_unknown_operation_survives_a_round_trip() { // The data-loss case that matters: a device running an older build diff --git a/core/dr-pipeline/tests/tone_curve.rs b/core/dr-pipeline/tests/tone_curve.rs new file mode 100644 index 0000000..d54abb4 --- /dev/null +++ b/core/dr-pipeline/tests/tone_curve.rs @@ -0,0 +1,202 @@ +//! TRACES: FR-DEV-3 +//! The tone curve's four curves, seen from outside the crate. +//! +//! The unit tests beside the operation assert what one `ToneCurve` does. These +//! assert the two properties that only show up once it is a node in a chain +//! with a sidecar under it: that a file written before the per-channel curves +//! existed still describes the edit it described, and that the three new +//! curves cost a photograph that does not use them precisely nothing. + +use dr_pipeline::ops::curve::{self, Axis, Channel}; +use dr_pipeline::{EditGraph, Sidecar}; + +/// A version block carrying `params`, in the on-disk spelling. +fn sidecar_with(params: &[(&str, f32)]) -> String { + let mut text = String::from("drsc 1\n\n[version u1]\nname = Default\nrevision = 2\nmodified = 0\n"); + for (key, value) in params { + text.push_str(&format!("{key} = {value}\n")); + } + text +} + +fn apply(text: &str) -> EditGraph { + let sidecar = Sidecar::parse(text).expect("a valid sidecar"); + let mut graph = EditGraph::default_chain(); + sidecar + .default_version() + .expect("a default version") + .apply(&mut graph); + graph +} + +/// TRACES: FR-CAT-8 +/// **The compatibility guarantee, end to end.** +/// +/// An edit made before this build existed has to produce the same image now. +/// Asserted on the generated shader rather than on the parameter values, +/// because that is what the photograph is actually made of: same source, same +/// uniforms, same picture. +#[test] +fn an_edit_written_before_the_channel_curves_renders_as_it_did() { + let from_file = apply(&sidecar_with(&[ + ("tone_curve.p1_y", 0.15), + ("tone_curve.p3_y", 0.85), + ])); + + let mut by_hand = EditGraph::default_chain(); + by_hand.set_param(curve::ID, curve::P1_Y, 0.15); + by_hand.set_param(curve::ID, curve::P3_Y, 0.85); + + let restored = from_file.compose(); + let expected = by_hand.compose(); + assert_eq!(restored.source, expected.source); + assert_eq!(restored.uniforms, expected.uniforms); + assert_eq!(restored.structure_hash, expected.structure_hash); +} + +/// **Neutral means absent, and stays absent with four times as much to be +/// neutral about.** +/// +/// The curve carries forty parameters now. An image edited with an S-curve and +/// nothing else must generate the shader it generated when it carried ten: +/// three untouched curves are not three identity evaluations, they are nothing +/// at all. +#[test] +fn three_untouched_curves_cost_nothing() { + let mut graph = EditGraph::default_chain(); + graph.set_param(curve::ID, curve::P2_Y, 0.62); + let shader = graph.compose(); + + assert!( + shader.source.contains("---- tone_curve ----"), + "the master curve must reach the shader" + ); + assert!( + !shader.source.contains("channel_curve"), + "an untouched channel curve reached the shader:\n{}", + shader.source + ); + for prefix in ["r_", "g_", "b_"] { + assert!( + !shader.source.contains(&format!("tone_curve_{prefix}")), + "an untouched channel curve declared uniforms:\n{}", + shader.source + ); + } +} + +/// And with none of them touched, the operation is not there at all — the +/// property `a_fresh_graph_is_neutral` asserts for the chain, restated for the +/// node that just quadrupled in size. +#[test] +fn a_curve_at_its_defaults_is_absent_from_the_shader() { + let graph = EditGraph::default_chain(); + assert!(graph.is_neutral()); + assert!(!graph.compose().source.contains("tone_curve")); + + // Including when every one of the forty parameters has been explicitly + // written to its own default, which is what a sidecar round trip through + // a build with a different idea of "default" would produce. + let mut written = EditGraph::default_chain(); + for cap in written.capabilities() { + if cap.id != curve::ID { + continue; + } + for p in &cap.params { + written.set_param(curve::ID, p.id, p.default); + } + } + assert!(written.is_neutral()); +} + +/// A grade with no tonal work is a real edit, and it must not drag the +/// luminance path in behind it. +#[test] +fn a_channel_curve_reaches_the_shader_on_its_own() { + let mut graph = EditGraph::default_chain(); + graph.set_param( + curve::ID, + curve::coordinate(Channel::Blue, 0, Axis::Y), + 0.08, + ); + + let shader = graph.compose(); + assert!(shader.source.contains("c.b = channel_curve(c.b,")); + assert!( + !shader.source.contains("apply_tone_gain"), + "the identity master curve reached the shader:\n{}", + shader.source + ); + // Blue's ten points, and nothing else: the other two curves are at the + // identity and contribute no slot to the uniform block. + for prefix in ["r_", "g_"] { + assert!( + !shader.source.contains(&format!("tone_curve_{prefix}")), + "an untouched channel curve declared uniforms:\n{}", + shader.source + ); + } + for point in 0..curve::POINTS { + assert!( + shader.source.contains(&format!("tone_curve_b_x{point}")), + "blue's point {point} is missing from the uniform block" + ); + } +} + +/// Each curve is its own shader, so the pipeline cache cannot hand the red +/// curve's compiled program to an edit that moved the green one. +#[test] +fn every_curve_generates_a_distinct_shader() { + let mut hashes: Vec = Vec::new(); + for channel in Channel::ALL { + let mut graph = EditGraph::default_chain(); + graph.set_param(curve::ID, curve::coordinate(channel, 2, Axis::Y), 0.62); + hashes.push(graph.compose().structure_hash); + } + let before = hashes.len(); + hashes.sort_unstable(); + hashes.dedup(); + assert_eq!(before, hashes.len(), "two curves share a compiled shader"); +} + +/// The forty parameters all persist, and the file names each one once. +#[test] +fn every_point_of_every_curve_round_trips_through_a_sidecar() { + let mut graph = EditGraph::default_chain(); + // A different y per coordinate, so a point wired to the wrong channel + // cannot pass by holding the value it was supposed to hold anyway. + // Thirty-secondths because they survive both the decimal the file is + // written in and the binary32 it is read back into exactly — this test is + // about which parameter a value lands in, and a rounding difference here + // would fail it for an unrelated reason. + let mut step = 1; + for channel in Channel::ALL { + for point in 0..curve::POINTS { + graph.set_param( + curve::ID, + curve::coordinate(channel, point, Axis::Y), + step as f32 / 32.0, + ); + step += 1; + } + } + + let mut sidecar = Sidecar::new(); + sidecar.put(dr_pipeline::sidecar::Version::from_graph( + "u1", "Default", &graph, + )); + let restored = apply(&sidecar.to_text()); + + for channel in Channel::ALL { + for point in 0..curve::POINTS { + let id = curve::coordinate(channel, point, Axis::Y); + assert_eq!( + restored.param(curve::ID, id), + graph.param(curve::ID, id), + "{id} did not survive the sidecar" + ); + } + } +} + diff --git a/docs/traceability.md b/docs/traceability.md index dc5910a..669bad6 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 148 | -| TRACES tags found | 344 | +| Source files scanned | 160 | +| TRACES tags found | 436 | | Requirements defined | 175 | -| Requirements covered | 83 | -| **Coverage** | **47.4%** (83/175) | +| Requirements covered | 86 | +| **Coverage** | **49.1%** (86/175) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 65 | 120 | +| FR | 68 | 120 | | NFR | 16 | 49 | | R | 2 | 6 | @@ -35,71 +35,74 @@ _None._ |---|---| | FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:196`](../core/dr-types/src/lib.rs#L196), [`core/dr-types/src/lib.rs:265`](../core/dr-types/src/lib.rs#L265), [`core/dr-types/src/lib.rs:298`](../core/dr-types/src/lib.rs#L298), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) | | FR-CAT-11 | [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152) | -| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109) | -| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:258`](../core/dr-catalog/src/schema.rs#L258), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:122`](../core/dr-sync/src/lib.rs#L122), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1171`](../ui/dr-ui/src/collections_ui.rs#L1171), [`ui/dr-ui/src/collections_ui.rs:1846`](../ui/dr-ui/src/collections_ui.rs#L1846), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2576`](../ui/dr-ui/src/library.rs#L2576), [`ui/dr-ui/src/library.rs:2608`](../ui/dr-ui/src/library.rs#L2608), [`ui/dr-ui/src/library_ui.rs:123`](../ui/dr-ui/src/library_ui.rs#L123), [`ui/dr-ui/src/library_ui.rs:637`](../ui/dr-ui/src/library_ui.rs#L637), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) | +| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111) | +| FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) | +| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:365`](../core/dr-catalog/src/schema.rs#L365), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:122`](../core/dr-sync/src/lib.rs#L122), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1171`](../ui/dr-ui/src/collections_ui.rs#L1171), [`ui/dr-ui/src/collections_ui.rs:1846`](../ui/dr-ui/src/collections_ui.rs#L1846), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2576`](../ui/dr-ui/src/library.rs#L2576), [`ui/dr-ui/src/library.rs:2608`](../ui/dr-ui/src/library.rs#L2608), [`ui/dr-ui/src/library_ui.rs:123`](../ui/dr-ui/src/library_ui.rs#L123), [`ui/dr-ui/src/library_ui.rs:637`](../ui/dr-ui/src/library_ui.rs#L637), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) | | FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:49`](../core/dr-types/src/lib.rs#L49) | | FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | | FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1) | | FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | -| FR-CAT-5 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-decode/src/lib.rs:310`](../core/dr-decode/src/lib.rs#L310), [`core/dr-pipeline/src/sidecar.rs:126`](../core/dr-pipeline/src/sidecar.rs#L126) | -| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179) | -| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/ui/app.slint:570`](../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:668`](../ui/dr-ui/ui/library.slint#L668), [`ui/dr-ui/ui/library.slint:687`](../ui/dr-ui/ui/library.slint#L687) | -| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/develop.rs:1770`](../ui/dr-ui/src/develop.rs#L1770), [`ui/dr-ui/src/export.rs:697`](../ui/dr-ui/src/export.rs#L697), [`ui/dr-ui/src/lib.rs:1065`](../ui/dr-ui/src/lib.rs#L1065), [`ui/dr-ui/src/lib.rs:1405`](../ui/dr-ui/src/lib.rs#L1405), [`ui/dr-ui/src/lib.rs:1510`](../ui/dr-ui/src/lib.rs#L1510), [`ui/dr-ui/src/lib.rs:460`](../ui/dr-ui/src/lib.rs#L460), [`ui/dr-ui/src/lib.rs:748`](../ui/dr-ui/src/lib.rs#L748), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library_ui.rs:4071`](../ui/dr-ui/src/library_ui.rs#L4071), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | -| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:115`](../core/dr-types/src/lib.rs#L115), [`ui/dr-ui/src/develop.rs:1478`](../ui/dr-ui/src/develop.rs#L1478), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:2791`](../ui/dr-ui/src/library.rs#L2791), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:610`](../ui/dr-ui/src/library.rs#L610), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library_ui.rs:1443`](../ui/dr-ui/src/library_ui.rs#L1443), [`ui/dr-ui/src/library_ui.rs:1469`](../ui/dr-ui/src/library_ui.rs#L1469), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:1579`](../ui/dr-ui/src/library_ui.rs#L1579), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:198`](../ui/dr-ui/src/library_ui.rs#L198), [`ui/dr-ui/src/library_ui.rs:2112`](../ui/dr-ui/src/library_ui.rs#L2112), [`ui/dr-ui/src/library_ui.rs:2377`](../ui/dr-ui/src/library_ui.rs#L2377), [`ui/dr-ui/src/library_ui.rs:2559`](../ui/dr-ui/src/library_ui.rs#L2559), [`ui/dr-ui/src/library_ui.rs:2840`](../ui/dr-ui/src/library_ui.rs#L2840), [`ui/dr-ui/src/library_ui.rs:2912`](../ui/dr-ui/src/library_ui.rs#L2912), [`ui/dr-ui/src/library_ui.rs:3077`](../ui/dr-ui/src/library_ui.rs#L3077), [`ui/dr-ui/src/library_ui.rs:352`](../ui/dr-ui/src/library_ui.rs#L352), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:4267`](../ui/dr-ui/src/library_ui.rs#L4267), [`ui/dr-ui/src/library_ui.rs:4382`](../ui/dr-ui/src/library_ui.rs#L4382), [`ui/dr-ui/src/presets.rs:284`](../ui/dr-ui/src/presets.rs#L284), [`ui/dr-ui/src/presets.rs:296`](../ui/dr-ui/src/presets.rs#L296), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/schema.rs:783`](../core/dr-catalog/src/schema.rs#L783), [`core/dr-decode/src/lib.rs:264`](../core/dr-decode/src/lib.rs#L264), [`core/dr-decode/src/lib.rs:331`](../core/dr-decode/src/lib.rs#L331), [`core/dr-pipeline/src/sidecar.rs:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library_ui.rs:5224`](../ui/dr-ui/src/library_ui.rs#L5224), [`ui/dr-ui/src/library_ui.rs:5235`](../ui/dr-ui/src/library_ui.rs#L5235), [`ui/dr-ui/src/library_ui.rs:5248`](../ui/dr-ui/src/library_ui.rs#L5248), [`ui/dr-ui/src/library_ui.rs:5263`](../ui/dr-ui/src/library_ui.rs#L5263), [`ui/dr-ui/src/library_ui.rs:5272`](../ui/dr-ui/src/library_ui.rs#L5272), [`ui/dr-ui/ui/app.slint:592`](../ui/dr-ui/ui/app.slint#L592), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:685`](../ui/dr-ui/ui/library.slint#L685), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) | +| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/ui/app.slint:592`](../ui/dr-ui/ui/app.slint#L592), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) | +| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/ui/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/library.slint:716`](../ui/dr-ui/ui/library.slint#L716) | +| FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:141`](../core/dr-pipeline/src/ops/curve.rs#L141), [`core/dr-pipeline/src/ops/curve.rs:657`](../core/dr-pipeline/src/ops/curve.rs#L657), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/tests/tone_curve.rs:32`](../core/dr-pipeline/tests/tone_curve.rs#L32), [`ui/dr-ui/src/develop.rs:2194`](../ui/dr-ui/src/develop.rs#L2194), [`ui/dr-ui/src/export.rs:697`](../ui/dr-ui/src/export.rs#L697), [`ui/dr-ui/src/lib.rs:1083`](../ui/dr-ui/src/lib.rs#L1083), [`ui/dr-ui/src/lib.rs:1423`](../ui/dr-ui/src/lib.rs#L1423), [`ui/dr-ui/src/lib.rs:1528`](../ui/dr-ui/src/lib.rs#L1528), [`ui/dr-ui/src/lib.rs:462`](../ui/dr-ui/src/lib.rs#L462), [`ui/dr-ui/src/lib.rs:766`](../ui/dr-ui/src/lib.rs#L766), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library_ui.rs:4235`](../ui/dr-ui/src/library_ui.rs#L4235), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:115`](../core/dr-types/src/lib.rs#L115), [`ui/dr-ui/src/develop.rs:1902`](../ui/dr-ui/src/develop.rs#L1902), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:2791`](../ui/dr-ui/src/library.rs#L2791), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:610`](../ui/dr-ui/src/library.rs#L610), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library_ui.rs:1443`](../ui/dr-ui/src/library_ui.rs#L1443), [`ui/dr-ui/src/library_ui.rs:1469`](../ui/dr-ui/src/library_ui.rs#L1469), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:1579`](../ui/dr-ui/src/library_ui.rs#L1579), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:198`](../ui/dr-ui/src/library_ui.rs#L198), [`ui/dr-ui/src/library_ui.rs:2112`](../ui/dr-ui/src/library_ui.rs#L2112), [`ui/dr-ui/src/library_ui.rs:2541`](../ui/dr-ui/src/library_ui.rs#L2541), [`ui/dr-ui/src/library_ui.rs:2723`](../ui/dr-ui/src/library_ui.rs#L2723), [`ui/dr-ui/src/library_ui.rs:3004`](../ui/dr-ui/src/library_ui.rs#L3004), [`ui/dr-ui/src/library_ui.rs:3076`](../ui/dr-ui/src/library_ui.rs#L3076), [`ui/dr-ui/src/library_ui.rs:3241`](../ui/dr-ui/src/library_ui.rs#L3241), [`ui/dr-ui/src/library_ui.rs:352`](../ui/dr-ui/src/library_ui.rs#L352), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:4465`](../ui/dr-ui/src/library_ui.rs#L4465), [`ui/dr-ui/src/library_ui.rs:4580`](../ui/dr-ui/src/library_ui.rs#L4580), [`ui/dr-ui/src/presets.rs:284`](../ui/dr-ui/src/presets.rs#L284), [`ui/dr-ui/src/presets.rs:296`](../ui/dr-ui/src/presets.rs#L296), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161) | -| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:126`](../core/dr-pipeline/src/sidecar.rs#L126), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1388`](../core/dr-gpu/src/adjust.rs#L1388), [`core/dr-gpu/src/adjust.rs:297`](../core/dr-gpu/src/adjust.rs#L297), [`core/dr-pipeline/src/framing.rs:186`](../core/dr-pipeline/src/framing.rs#L186), [`core/dr-pipeline/src/operation.rs:196`](../core/dr-pipeline/src/operation.rs#L196), [`core/dr-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`ui/dr-ui/src/develop.rs:1016`](../ui/dr-ui/src/develop.rs#L1016), [`ui/dr-ui/src/develop.rs:62`](../ui/dr-ui/src/develop.rs#L62), [`ui/dr-ui/src/develop.rs:867`](../ui/dr-ui/src/develop.rs#L867), [`ui/dr-ui/src/lib.rs:1101`](../ui/dr-ui/src/lib.rs#L1101), [`ui/dr-ui/src/lib.rs:1753`](../ui/dr-ui/src/lib.rs#L1753), [`ui/dr-ui/src/lib.rs:286`](../ui/dr-ui/src/lib.rs#L286) | -| FR-DEV-3a | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:257`](../core/dr-pipeline/src/framing.rs#L257), [`core/dr-pipeline/src/graph.rs:163`](../core/dr-pipeline/src/graph.rs#L163), [`core/dr-pipeline/src/graph.rs:19`](../core/dr-pipeline/src/graph.rs#L19), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/mask.rs:490`](../core/dr-pipeline/src/mask.rs#L490), [`core/dr-pipeline/src/operation.rs:107`](../core/dr-pipeline/src/operation.rs#L107), [`ui/dr-ui/src/lib.rs:522`](../ui/dr-ui/src/lib.rs#L522) | -| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:257`](../core/dr-pipeline/src/framing.rs#L257), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/operation.rs:107`](../core/dr-pipeline/src/operation.rs#L107) | -| FR-DEV-3c | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:163`](../core/dr-pipeline/src/graph.rs#L163), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/mask.rs:490`](../core/dr-pipeline/src/mask.rs#L490), [`ui/dr-ui/src/develop.rs:2579`](../ui/dr-ui/src/develop.rs#L2579) | -| FR-DEV-3d | [`core/dr-pipeline/src/framing.rs:186`](../core/dr-pipeline/src/framing.rs#L186) | -| FR-DEV-3e | [`core/dr-decode/src/lib.rs:504`](../core/dr-decode/src/lib.rs#L504), [`core/dr-decode/src/lib.rs:646`](../core/dr-decode/src/lib.rs#L646), [`core/dr-decode/src/lib.rs:686`](../core/dr-decode/src/lib.rs#L686) | -| FR-DEV-3h | [`core/dr-decode/src/lib.rs:310`](../core/dr-decode/src/lib.rs#L310), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:200`](../core/dr-pipeline/src/framing.rs#L200), [`core/dr-types/src/lib.rs:332`](../core/dr-types/src/lib.rs#L332) | -| FR-DEV-4 | [`core/dr-gpu/src/lib.rs:211`](../core/dr-gpu/src/lib.rs#L211) | -| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:1786`](../ui/dr-ui/src/develop.rs#L1786), [`ui/dr-ui/src/develop.rs:1796`](../ui/dr-ui/src/develop.rs#L1796), [`ui/dr-ui/src/develop.rs:44`](../ui/dr-ui/src/develop.rs#L44), [`ui/dr-ui/src/lib.rs:1093`](../ui/dr-ui/src/lib.rs#L1093) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:1747`](../ui/dr-ui/src/develop.rs#L1747), [`ui/dr-ui/src/develop.rs:1757`](../ui/dr-ui/src/develop.rs#L1757), [`ui/dr-ui/src/lib.rs:1065`](../ui/dr-ui/src/lib.rs#L1065), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2208`](../ui/dr-ui/src/library_ui.rs#L2208), [`ui/dr-ui/src/library_ui.rs:2559`](../ui/dr-ui/src/library_ui.rs#L2559), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:569`](../ui/dr-ui/ui/adjust.slint#L569), [`ui/dr-ui/ui/library.slint:1037`](../ui/dr-ui/ui/library.slint#L1037), [`ui/dr-ui/ui/library.slint:640`](../ui/dr-ui/ui/library.slint#L640), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1311`](../core/dr-gpu/src/adjust.rs#L1311), [`core/dr-gpu/src/adjust.rs:1388`](../core/dr-gpu/src/adjust.rs#L1388), [`core/dr-gpu/src/adjust.rs:1473`](../core/dr-gpu/src/adjust.rs#L1473), [`core/dr-gpu/src/adjust.rs:42`](../core/dr-gpu/src/adjust.rs#L42), [`core/dr-gpu/src/lib.rs:48`](../core/dr-gpu/src/lib.rs#L48), [`core/dr-gpu/src/lib.rs:88`](../core/dr-gpu/src/lib.rs#L88), [`ui/dr-ui/src/develop.rs:1314`](../ui/dr-ui/src/develop.rs#L1314), [`ui/dr-ui/src/develop.rs:1934`](../ui/dr-ui/src/develop.rs#L1934), [`ui/dr-ui/src/develop.rs:2124`](../ui/dr-ui/src/develop.rs#L2124), [`ui/dr-ui/src/develop.rs:2158`](../ui/dr-ui/src/develop.rs#L2158), [`ui/dr-ui/src/lib.rs:56`](../ui/dr-ui/src/lib.rs#L56), [`ui/dr-ui/src/lib.rs:647`](../ui/dr-ui/src/lib.rs#L647) | -| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:169`](../core/dr-pipeline/src/operation.rs#L169), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) | -| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:1359`](../ui/dr-ui/src/develop.rs#L1359), [`ui/dr-ui/src/develop.rs:3084`](../ui/dr-ui/src/develop.rs#L3084), [`ui/dr-ui/src/develop.rs:3116`](../ui/dr-ui/src/develop.rs#L3116), [`ui/dr-ui/src/develop.rs:55`](../ui/dr-ui/src/develop.rs#L55), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1152`](../ui/dr-ui/src/lib.rs#L1152), [`ui/dr-ui/src/lib.rs:281`](../ui/dr-ui/src/lib.rs#L281), [`ui/dr-ui/ui/app.slint:303`](../ui/dr-ui/ui/app.slint#L303), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | +| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340) | +| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1768`](../core/dr-gpu/src/adjust.rs#L1768), [`core/dr-gpu/src/adjust.rs:395`](../core/dr-gpu/src/adjust.rs#L395), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:333`](../core/dr-pipeline/src/detail.rs#L333), [`core/dr-pipeline/src/detail.rs:408`](../core/dr-pipeline/src/detail.rs#L408), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:121`](../core/dr-pipeline/src/graph.rs#L121), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:439`](../core/dr-pipeline/src/operation.rs#L439), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:223`](../core/dr-pipeline/src/ops/curve.rs#L223), [`core/dr-pipeline/src/ops/curve.rs:636`](../core/dr-pipeline/src/ops/curve.rs#L636), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:1328`](../core/dr-pipeline/src/sidecar.rs#L1328), [`core/dr-pipeline/src/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1255`](../ui/dr-ui/src/develop.rs#L1255), [`ui/dr-ui/src/develop.rs:1270`](../ui/dr-ui/src/develop.rs#L1270), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1375`](../ui/dr-ui/src/develop.rs#L1375), [`ui/dr-ui/src/develop.rs:1449`](../ui/dr-ui/src/develop.rs#L1449), [`ui/dr-ui/src/develop.rs:243`](../ui/dr-ui/src/develop.rs#L243), [`ui/dr-ui/src/develop.rs:2552`](../ui/dr-ui/src/develop.rs#L2552), [`ui/dr-ui/src/develop.rs:2606`](../ui/dr-ui/src/develop.rs#L2606), [`ui/dr-ui/src/develop.rs:276`](../ui/dr-ui/src/develop.rs#L276), [`ui/dr-ui/src/develop.rs:830`](../ui/dr-ui/src/develop.rs#L830), [`ui/dr-ui/src/lib.rs:1119`](../ui/dr-ui/src/lib.rs#L1119), [`ui/dr-ui/src/lib.rs:1771`](../ui/dr-ui/src/lib.rs#L1771), [`ui/dr-ui/src/lib.rs:288`](../ui/dr-ui/src/lib.rs#L288), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1772`](../ui/dr-ui/ui/app.slint#L1772) | +| FR-DEV-3a | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:19`](../core/dr-pipeline/src/graph.rs#L19), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/mask.rs:925`](../core/dr-pipeline/src/mask.rs#L925), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294), [`core/dr-pipeline/src/ops/curve.rs:323`](../core/dr-pipeline/src/ops/curve.rs#L323), [`ui/dr-ui/src/develop.rs:727`](../ui/dr-ui/src/develop.rs#L727), [`ui/dr-ui/src/lib.rs:524`](../ui/dr-ui/src/lib.rs#L524) | +| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294) | +| FR-DEV-3c | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/mask.rs:925`](../core/dr-pipeline/src/mask.rs#L925), [`ui/dr-ui/src/develop.rs:3185`](../ui/dr-ui/src/develop.rs#L3185) | +| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:709`](../core/dr-gpu/src/adjust.rs#L709), [`core/dr-gpu/src/adjust.rs:764`](../core/dr-gpu/src/adjust.rs#L764), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/src/adjust.rs:97`](../core/dr-gpu/src/adjust.rs#L97), [`core/dr-gpu/tests/detail_stage.rs:240`](../core/dr-gpu/tests/detail_stage.rs#L240), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:420`](../core/dr-pipeline/src/graph.rs#L420), [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) | +| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:100`](../core/dr-decode/src/lib.rs#L100), [`core/dr-decode/src/lib.rs:632`](../core/dr-decode/src/lib.rs#L632), [`core/dr-decode/src/lib.rs:672`](../core/dr-decode/src/lib.rs#L672), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:690`](../core/dr-gpu/src/adjust.rs#L690), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1344`](../core/dr-pipeline/src/operation.rs#L1344), [`core/dr-pipeline/src/operation.rs:1369`](../core/dr-pipeline/src/operation.rs#L1369), [`core/dr-pipeline/src/operation.rs:1384`](../core/dr-pipeline/src/operation.rs#L1384), [`core/dr-pipeline/src/operation.rs:1405`](../core/dr-pipeline/src/operation.rs#L1405), [`core/dr-pipeline/src/operation.rs:369`](../core/dr-pipeline/src/operation.rs#L369), [`core/dr-pipeline/src/operation.rs:379`](../core/dr-pipeline/src/operation.rs#L379), [`core/dr-pipeline/src/operation.rs:513`](../core/dr-pipeline/src/operation.rs#L513) | +| FR-DEV-3h | [`core/dr-decode/src/lib.rs:331`](../core/dr-decode/src/lib.rs#L331), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:332`](../core/dr-types/src/lib.rs#L332) | +| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | +| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2210`](../ui/dr-ui/src/develop.rs#L2210), [`ui/dr-ui/src/develop.rs:2220`](../ui/dr-ui/src/develop.rs#L2220), [`ui/dr-ui/src/develop.rs:225`](../ui/dr-ui/src/develop.rs#L225), [`ui/dr-ui/src/lib.rs:1111`](../ui/dr-ui/src/lib.rs#L1111) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:2171`](../ui/dr-ui/src/develop.rs#L2171), [`ui/dr-ui/src/develop.rs:2181`](../ui/dr-ui/src/develop.rs#L2181), [`ui/dr-ui/src/lib.rs:1083`](../ui/dr-ui/src/lib.rs#L1083), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2372`](../ui/dr-ui/src/library_ui.rs#L2372), [`ui/dr-ui/src/library_ui.rs:2723`](../ui/dr-ui/src/library_ui.rs#L2723), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1077`](../ui/dr-ui/ui/library.slint#L1077), [`ui/dr-ui/ui/library.slint:666`](../ui/dr-ui/ui/library.slint#L666), [`ui/dr-ui/ui/library.slint:737`](../ui/dr-ui/ui/library.slint#L737), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) | +| FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:333`](../core/dr-pipeline/src/detail.rs#L333), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1691`](../core/dr-gpu/src/adjust.rs#L1691), [`core/dr-gpu/src/adjust.rs:1768`](../core/dr-gpu/src/adjust.rs#L1768), [`core/dr-gpu/src/adjust.rs:1853`](../core/dr-gpu/src/adjust.rs#L1853), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/detail_stage.rs:322`](../core/dr-gpu/tests/detail_stage.rs#L322), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:408`](../core/dr-pipeline/src/detail.rs#L408), [`core/dr-pipeline/src/graph.rs:368`](../core/dr-pipeline/src/graph.rs#L368), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`ui/dr-ui/src/develop.rs:1738`](../ui/dr-ui/src/develop.rs#L1738), [`ui/dr-ui/src/develop.rs:2358`](../ui/dr-ui/src/develop.rs#L2358), [`ui/dr-ui/src/develop.rs:2730`](../ui/dr-ui/src/develop.rs#L2730), [`ui/dr-ui/src/develop.rs:2764`](../ui/dr-ui/src/develop.rs#L2764), [`ui/dr-ui/src/lib.rs:57`](../ui/dr-ui/src/lib.rs#L57), [`ui/dr-ui/src/lib.rs:665`](../ui/dr-ui/src/lib.rs#L665) | +| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) | +| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:1783`](../ui/dr-ui/src/develop.rs#L1783), [`ui/dr-ui/src/develop.rs:236`](../ui/dr-ui/src/develop.rs#L236), [`ui/dr-ui/src/develop.rs:3832`](../ui/dr-ui/src/develop.rs#L3832), [`ui/dr-ui/src/develop.rs:3864`](../ui/dr-ui/src/develop.rs#L3864), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1170`](../ui/dr-ui/src/lib.rs#L1170), [`ui/dr-ui/src/lib.rs:283`](../ui/dr-ui/src/lib.rs#L283), [`ui/dr-ui/ui/app.slint:304`](../ui/dr-ui/ui/app.slint#L304), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:137`](../core/dr-export/src/lib.rs#L137), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:50`](../core/dr-export/src/lib.rs#L50), [`core/dr-gpu/src/adjust.rs:1621`](../core/dr-gpu/src/adjust.rs#L1621), [`core/dr-pipeline/src/graph.rs:336`](../core/dr-pipeline/src/graph.rs#L336), [`core/dr-pipeline/src/operation.rs:169`](../core/dr-pipeline/src/operation.rs#L169), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:454`](../core/dr-types/src/settings.rs#L454), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:137`](../core/dr-export/src/lib.rs#L137), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:50`](../core/dr-export/src/lib.rs#L50), [`core/dr-gpu/src/adjust.rs:2001`](../core/dr-gpu/src/adjust.rs#L2001), [`core/dr-pipeline/src/graph.rs:358`](../core/dr-pipeline/src/graph.rs#L358), [`core/dr-pipeline/src/graph.rs:404`](../core/dr-pipeline/src/graph.rs#L404), [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:454`](../core/dr-types/src/settings.rs#L454), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | -| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:561`](../ui/dr-ui/src/settings_ui.rs#L561) | -| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:879`](../ui/dr-ui/src/export.rs#L879), [`ui/dr-ui/src/lib.rs:1676`](../ui/dr-ui/src/lib.rs#L1676), [`ui/dr-ui/src/lib.rs:172`](../ui/dr-ui/src/lib.rs#L172), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/lib.rs:359`](../ui/dr-ui/src/lib.rs#L359), [`ui/dr-ui/src/lib.rs:386`](../ui/dr-ui/src/lib.rs#L386), [`ui/dr-ui/src/library_ui.rs:2929`](../ui/dr-ui/src/library_ui.rs#L2929), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:5008`](../ui/dr-ui/src/library_ui.rs#L5008), [`ui/dr-ui/src/library_ui.rs:5020`](../ui/dr-ui/src/library_ui.rs#L5020), [`ui/dr-ui/src/library_ui.rs:5032`](../ui/dr-ui/src/library_ui.rs#L5032), [`ui/dr-ui/src/library_ui.rs:540`](../ui/dr-ui/src/library_ui.rs#L540), [`ui/dr-ui/src/library_ui.rs:597`](../ui/dr-ui/src/library_ui.rs#L597), [`ui/dr-ui/ui/app.slint:1180`](../ui/dr-ui/ui/app.slint#L1180), [`ui/dr-ui/ui/app.slint:869`](../ui/dr-ui/ui/app.slint#L869), [`ui/dr-ui/ui/library.slint:1045`](../ui/dr-ui/ui/library.slint#L1045), [`ui/dr-ui/ui/library.slint:644`](../ui/dr-ui/ui/library.slint#L644), [`ui/dr-ui/ui/library.slint:712`](../ui/dr-ui/ui/library.slint#L712) | +| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:326`](../ui/dr-ui/src/lib.rs#L326), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:561`](../ui/dr-ui/src/settings_ui.rs#L561) | +| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:879`](../ui/dr-ui/src/export.rs#L879), [`ui/dr-ui/src/lib.rs:1694`](../ui/dr-ui/src/lib.rs#L1694), [`ui/dr-ui/src/lib.rs:173`](../ui/dr-ui/src/lib.rs#L173), [`ui/dr-ui/src/lib.rs:326`](../ui/dr-ui/src/lib.rs#L326), [`ui/dr-ui/src/lib.rs:361`](../ui/dr-ui/src/lib.rs#L361), [`ui/dr-ui/src/lib.rs:388`](../ui/dr-ui/src/lib.rs#L388), [`ui/dr-ui/src/library_ui.rs:3093`](../ui/dr-ui/src/library_ui.rs#L3093), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:5206`](../ui/dr-ui/src/library_ui.rs#L5206), [`ui/dr-ui/src/library_ui.rs:5283`](../ui/dr-ui/src/library_ui.rs#L5283), [`ui/dr-ui/src/library_ui.rs:5295`](../ui/dr-ui/src/library_ui.rs#L5295), [`ui/dr-ui/src/library_ui.rs:540`](../ui/dr-ui/src/library_ui.rs#L540), [`ui/dr-ui/src/library_ui.rs:597`](../ui/dr-ui/src/library_ui.rs#L597), [`ui/dr-ui/ui/app.slint:1243`](../ui/dr-ui/ui/app.slint#L1243), [`ui/dr-ui/ui/app.slint:932`](../ui/dr-ui/ui/app.slint#L932), [`ui/dr-ui/ui/library.slint:1085`](../ui/dr-ui/ui/library.slint#L1085), [`ui/dr-ui/ui/library.slint:670`](../ui/dr-ui/ui/library.slint#L670), [`ui/dr-ui/ui/library.slint:752`](../ui/dr-ui/ui/library.slint#L752) | | FR-EXP-8 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-9 | [`core/dr-decode/src/lib.rs:412`](../core/dr-decode/src/lib.rs#L412), [`core/dr-export/src/lib.rs:125`](../core/dr-export/src/lib.rs#L125), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:415`](../core/dr-gpu/src/adjust.rs#L415), [`ui/dr-ui/src/develop.rs:1437`](../ui/dr-ui/src/develop.rs#L1437), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324) | +| FR-EXP-9 | [`core/dr-decode/src/lib.rs:433`](../core/dr-decode/src/lib.rs#L433), [`core/dr-export/src/lib.rs:125`](../core/dr-export/src/lib.rs#L125), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:791`](../core/dr-gpu/src/adjust.rs#L791), [`ui/dr-ui/src/develop.rs:1861`](../ui/dr-ui/src/develop.rs#L1861), [`ui/dr-ui/src/lib.rs:326`](../ui/dr-ui/src/lib.rs#L326) | | FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) | -| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:386`](../ui/dr-ui/src/lib.rs#L386), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:2929`](../ui/dr-ui/src/library_ui.rs#L2929), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:388`](../ui/dr-ui/src/lib.rs#L388), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:3093`](../ui/dr-ui/src/library_ui.rs#L3093), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:892`](../core/dr-sync-nextcloud/src/lib.rs#L892), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/lib.rs:38`](../core/dr-sync/src/lib.rs#L38), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) | | FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34) | | FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | | FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) | | FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51) | | FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) | -| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/schema.rs:631`](../core/dr-catalog/src/schema.rs#L631), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/lib.rs:1436`](../ui/dr-ui/src/lib.rs#L1436), [`ui/dr-ui/src/lib.rs:2131`](../ui/dr-ui/src/lib.rs#L2131), [`ui/dr-ui/src/library.rs:1254`](../ui/dr-ui/src/library.rs#L1254), [`ui/dr-ui/src/library.rs:1277`](../ui/dr-ui/src/library.rs#L1277), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library_ui.rs:1017`](../ui/dr-ui/src/library_ui.rs#L1017), [`ui/dr-ui/src/library_ui.rs:1118`](../ui/dr-ui/src/library_ui.rs#L1118), [`ui/dr-ui/src/library_ui.rs:1173`](../ui/dr-ui/src/library_ui.rs#L1173), [`ui/dr-ui/src/library_ui.rs:1291`](../ui/dr-ui/src/library_ui.rs#L1291), [`ui/dr-ui/src/library_ui.rs:1410`](../ui/dr-ui/src/library_ui.rs#L1410), [`ui/dr-ui/src/library_ui.rs:1888`](../ui/dr-ui/src/library_ui.rs#L1888), [`ui/dr-ui/src/library_ui.rs:206`](../ui/dr-ui/src/library_ui.rs#L206), [`ui/dr-ui/src/library_ui.rs:217`](../ui/dr-ui/src/library_ui.rs#L217), [`ui/dr-ui/src/library_ui.rs:225`](../ui/dr-ui/src/library_ui.rs#L225), [`ui/dr-ui/src/library_ui.rs:237`](../ui/dr-ui/src/library_ui.rs#L237), [`ui/dr-ui/src/library_ui.rs:246`](../ui/dr-ui/src/library_ui.rs#L246), [`ui/dr-ui/src/library_ui.rs:311`](../ui/dr-ui/src/library_ui.rs#L311), [`ui/dr-ui/src/library_ui.rs:321`](../ui/dr-ui/src/library_ui.rs#L321), [`ui/dr-ui/src/library_ui.rs:363`](../ui/dr-ui/src/library_ui.rs#L363), [`ui/dr-ui/src/library_ui.rs:426`](../ui/dr-ui/src/library_ui.rs#L426), [`ui/dr-ui/src/library_ui.rs:4284`](../ui/dr-ui/src/library_ui.rs#L4284), [`ui/dr-ui/src/library_ui.rs:4302`](../ui/dr-ui/src/library_ui.rs#L4302), [`ui/dr-ui/src/library_ui.rs:4314`](../ui/dr-ui/src/library_ui.rs#L4314), [`ui/dr-ui/src/library_ui.rs:457`](../ui/dr-ui/src/library_ui.rs#L457), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:944`](../ui/dr-ui/src/library_ui.rs#L944), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2026`](../ui/dr-ui/ui/app.slint#L2026), [`ui/dr-ui/ui/app.slint:564`](../ui/dr-ui/ui/app.slint#L564), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:758`](../ui/dr-ui/ui/library.slint#L758) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/schema.rs:738`](../core/dr-catalog/src/schema.rs#L738), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/lib.rs:1454`](../ui/dr-ui/src/lib.rs#L1454), [`ui/dr-ui/src/lib.rs:2194`](../ui/dr-ui/src/lib.rs#L2194), [`ui/dr-ui/src/library.rs:1254`](../ui/dr-ui/src/library.rs#L1254), [`ui/dr-ui/src/library.rs:1277`](../ui/dr-ui/src/library.rs#L1277), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library_ui.rs:1017`](../ui/dr-ui/src/library_ui.rs#L1017), [`ui/dr-ui/src/library_ui.rs:1118`](../ui/dr-ui/src/library_ui.rs#L1118), [`ui/dr-ui/src/library_ui.rs:1173`](../ui/dr-ui/src/library_ui.rs#L1173), [`ui/dr-ui/src/library_ui.rs:1291`](../ui/dr-ui/src/library_ui.rs#L1291), [`ui/dr-ui/src/library_ui.rs:1410`](../ui/dr-ui/src/library_ui.rs#L1410), [`ui/dr-ui/src/library_ui.rs:1888`](../ui/dr-ui/src/library_ui.rs#L1888), [`ui/dr-ui/src/library_ui.rs:206`](../ui/dr-ui/src/library_ui.rs#L206), [`ui/dr-ui/src/library_ui.rs:217`](../ui/dr-ui/src/library_ui.rs#L217), [`ui/dr-ui/src/library_ui.rs:225`](../ui/dr-ui/src/library_ui.rs#L225), [`ui/dr-ui/src/library_ui.rs:237`](../ui/dr-ui/src/library_ui.rs#L237), [`ui/dr-ui/src/library_ui.rs:246`](../ui/dr-ui/src/library_ui.rs#L246), [`ui/dr-ui/src/library_ui.rs:311`](../ui/dr-ui/src/library_ui.rs#L311), [`ui/dr-ui/src/library_ui.rs:321`](../ui/dr-ui/src/library_ui.rs#L321), [`ui/dr-ui/src/library_ui.rs:363`](../ui/dr-ui/src/library_ui.rs#L363), [`ui/dr-ui/src/library_ui.rs:426`](../ui/dr-ui/src/library_ui.rs#L426), [`ui/dr-ui/src/library_ui.rs:4482`](../ui/dr-ui/src/library_ui.rs#L4482), [`ui/dr-ui/src/library_ui.rs:4500`](../ui/dr-ui/src/library_ui.rs#L4500), [`ui/dr-ui/src/library_ui.rs:4512`](../ui/dr-ui/src/library_ui.rs#L4512), [`ui/dr-ui/src/library_ui.rs:457`](../ui/dr-ui/src/library_ui.rs#L457), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:944`](../ui/dr-ui/src/library_ui.rs#L944), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2247`](../ui/dr-ui/ui/app.slint#L2247), [`ui/dr-ui/ui/app.slint:581`](../ui/dr-ui/ui/app.slint#L581), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:798`](../ui/dr-ui/ui/library.slint#L798) | | FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1173`](../ui/dr-ui/src/library_ui.rs#L1173) | | FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:115`](../core/dr-types/src/lib.rs#L115), [`core/dr-types/src/lib.rs:197`](../core/dr-types/src/lib.rs#L197), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/library_ui.rs:1017`](../ui/dr-ui/src/library_ui.rs#L1017), [`ui/dr-ui/src/library_ui.rs:944`](../ui/dr-ui/src/library_ui.rs#L944), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) | | FR-NC-7 | [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1) | -| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/lib.rs:1405`](../ui/dr-ui/src/lib.rs#L1405), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378) | -| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`core/dr-pipeline/src/sidecar.rs:301`](../core/dr-pipeline/src/sidecar.rs#L301), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:810`](../ui/dr-ui/src/library.rs#L810) | +| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1423`](../ui/dr-ui/src/lib.rs#L1423), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378) | +| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`core/dr-pipeline/src/sidecar.rs:303`](../core/dr-pipeline/src/sidecar.rs#L303), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:810`](../ui/dr-ui/src/library.rs#L810) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:49`](../core/dr-types/src/lib.rs#L49) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | | FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | -| FR-RAW-1 | [`core/dr-decode/src/lib.rs:201`](../core/dr-decode/src/lib.rs#L201), [`core/dr-types/src/lib.rs:125`](../core/dr-types/src/lib.rs#L125), [`core/dr-types/src/lib.rs:196`](../core/dr-types/src/lib.rs#L196) | -| FR-RAW-3 | [`core/dr-decode/src/lib.rs:412`](../core/dr-decode/src/lib.rs#L412), [`core/dr-decode/src/lib.rs:97`](../core/dr-decode/src/lib.rs#L97), [`core/dr-decode/src/locate.rs:1045`](../core/dr-decode/src/locate.rs#L1045) | -| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:172`](../ui/dr-ui/src/lib.rs#L172) | -| FR-RAW-5 | [`core/dr-decode/src/lib.rs:125`](../core/dr-decode/src/lib.rs#L125), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:573`](../core/dr-gpu/src/demosaic.rs#L573), [`core/dr-gpu/src/demosaic.rs:652`](../core/dr-gpu/src/demosaic.rs#L652), [`core/dr-gpu/src/demosaic.rs:776`](../core/dr-gpu/src/demosaic.rs#L776) | -| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2213`](../ui/dr-ui/src/lib.rs#L2213), [`ui/dr-ui/src/lib.rs:64`](../ui/dr-ui/src/lib.rs#L64), [`ui/dr-ui/ui/library.slint:804`](../ui/dr-ui/ui/library.slint#L804) | -| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/lib.rs:64`](../ui/dr-ui/src/lib.rs#L64), [`ui/dr-ui/src/library_ui.rs:225`](../ui/dr-ui/src/library_ui.rs#L225), [`ui/dr-ui/src/library_ui.rs:4314`](../ui/dr-ui/src/library_ui.rs#L4314), [`ui/dr-ui/ui/app.slint:575`](../ui/dr-ui/ui/app.slint#L575), [`ui/dr-ui/ui/app.slint:582`](../ui/dr-ui/ui/app.slint#L582), [`ui/dr-ui/ui/library.slint:1784`](../ui/dr-ui/ui/library.slint#L1784), [`ui/dr-ui/ui/library.slint:632`](../ui/dr-ui/ui/library.slint#L632), [`ui/dr-ui/ui/library.slint:668`](../ui/dr-ui/ui/library.slint#L668), [`ui/dr-ui/ui/library.slint:947`](../ui/dr-ui/ui/library.slint#L947), [`ui/dr-ui/ui/library.slint:954`](../ui/dr-ui/ui/library.slint#L954), [`ui/dr-ui/ui/library.slint:960`](../ui/dr-ui/ui/library.slint#L960) | -| FR-UI-3 | [`ui/dr-ui/src/library_ui.rs:3590`](../ui/dr-ui/src/library_ui.rs#L3590), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) | -| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:124`](../ui/dr-ui/src/collections_ui.rs#L124), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:537`](../ui/dr-ui/src/collections_ui.rs#L537), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/library_ui.rs:3590`](../ui/dr-ui/src/library_ui.rs#L3590), [`ui/dr-ui/src/library_ui.rs:3635`](../ui/dr-ui/src/library_ui.rs#L3635), [`ui/dr-ui/src/library_ui.rs:3738`](../ui/dr-ui/src/library_ui.rs#L3738), [`ui/dr-ui/src/library_ui.rs:3766`](../ui/dr-ui/src/library_ui.rs#L3766), [`ui/dr-ui/src/library_ui.rs:4302`](../ui/dr-ui/src/library_ui.rs#L4302), [`ui/dr-ui/src/library_ui.rs:4314`](../ui/dr-ui/src/library_ui.rs#L4314), [`ui/dr-ui/ui/app.slint:1401`](../ui/dr-ui/ui/app.slint#L1401), [`ui/dr-ui/ui/app.slint:570`](../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/app.slint:582`](../ui/dr-ui/ui/app.slint#L582), [`ui/dr-ui/ui/library.slint:1784`](../ui/dr-ui/ui/library.slint#L1784), [`ui/dr-ui/ui/library.slint:632`](../ui/dr-ui/ui/library.slint#L632), [`ui/dr-ui/ui/library.slint:668`](../ui/dr-ui/ui/library.slint#L668), [`ui/dr-ui/ui/library.slint:687`](../ui/dr-ui/ui/library.slint#L687), [`ui/dr-ui/ui/library.slint:947`](../ui/dr-ui/ui/library.slint#L947), [`ui/dr-ui/ui/library.slint:954`](../ui/dr-ui/ui/library.slint#L954), [`ui/dr-ui/ui/library.slint:960`](../ui/dr-ui/ui/library.slint#L960) | -| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2247`](../ui/dr-ui/src/lib.rs#L2247), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | -| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:257`](../core/dr-pipeline/src/framing.rs#L257) | +| FR-RAW-1 | [`core/dr-decode/src/lib.rs:222`](../core/dr-decode/src/lib.rs#L222), [`core/dr-types/src/lib.rs:125`](../core/dr-types/src/lib.rs#L125), [`core/dr-types/src/lib.rs:196`](../core/dr-types/src/lib.rs#L196) | +| FR-RAW-3 | [`core/dr-decode/src/lib.rs:118`](../core/dr-decode/src/lib.rs#L118), [`core/dr-decode/src/lib.rs:433`](../core/dr-decode/src/lib.rs#L433), [`core/dr-decode/src/locate.rs:1045`](../core/dr-decode/src/locate.rs#L1045) | +| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:173`](../ui/dr-ui/src/lib.rs#L173) | +| FR-RAW-5 | [`core/dr-decode/src/lib.rs:146`](../core/dr-decode/src/lib.rs#L146), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) | +| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2276`](../ui/dr-ui/src/lib.rs#L2276), [`ui/dr-ui/src/lib.rs:65`](../ui/dr-ui/src/lib.rs#L65), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/ui/library.slint:844`](../ui/dr-ui/ui/library.slint#L844) | +| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/lib.rs:65`](../ui/dr-ui/src/lib.rs#L65), [`ui/dr-ui/src/library_ui.rs:225`](../ui/dr-ui/src/library_ui.rs#L225), [`ui/dr-ui/src/library_ui.rs:4512`](../ui/dr-ui/src/library_ui.rs#L4512), [`ui/dr-ui/ui/app.slint:611`](../ui/dr-ui/ui/app.slint#L611), [`ui/dr-ui/ui/app.slint:618`](../ui/dr-ui/ui/app.slint#L618), [`ui/dr-ui/ui/library.slint:1000`](../ui/dr-ui/ui/library.slint#L1000), [`ui/dr-ui/ui/library.slint:1868`](../ui/dr-ui/ui/library.slint#L1868), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/library.slint:987`](../ui/dr-ui/ui/library.slint#L987), [`ui/dr-ui/ui/library.slint:994`](../ui/dr-ui/ui/library.slint#L994) | +| FR-UI-3 | [`ui/dr-ui/src/develop.rs:1449`](../ui/dr-ui/src/develop.rs#L1449), [`ui/dr-ui/src/library_ui.rs:3754`](../ui/dr-ui/src/library_ui.rs#L3754), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:792`](../ui/dr-ui/src/masks_ui.rs#L792), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1772`](../ui/dr-ui/ui/app.slint#L1772), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) | +| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:124`](../ui/dr-ui/src/collections_ui.rs#L124), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:537`](../ui/dr-ui/src/collections_ui.rs#L537), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/library_ui.rs:3754`](../ui/dr-ui/src/library_ui.rs#L3754), [`ui/dr-ui/src/library_ui.rs:3799`](../ui/dr-ui/src/library_ui.rs#L3799), [`ui/dr-ui/src/library_ui.rs:3902`](../ui/dr-ui/src/library_ui.rs#L3902), [`ui/dr-ui/src/library_ui.rs:3930`](../ui/dr-ui/src/library_ui.rs#L3930), [`ui/dr-ui/src/library_ui.rs:4500`](../ui/dr-ui/src/library_ui.rs#L4500), [`ui/dr-ui/src/library_ui.rs:4512`](../ui/dr-ui/src/library_ui.rs#L4512), [`ui/dr-ui/ui/app.slint:1468`](../ui/dr-ui/ui/app.slint#L1468), [`ui/dr-ui/ui/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/app.slint:618`](../ui/dr-ui/ui/app.slint#L618), [`ui/dr-ui/ui/library.slint:1000`](../ui/dr-ui/ui/library.slint#L1000), [`ui/dr-ui/ui/library.slint:1868`](../ui/dr-ui/ui/library.slint#L1868), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/library.slint:716`](../ui/dr-ui/ui/library.slint#L716), [`ui/dr-ui/ui/library.slint:987`](../ui/dr-ui/ui/library.slint#L987), [`ui/dr-ui/ui/library.slint:994`](../ui/dr-ui/ui/library.slint#L994) | +| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1921`](../ui/dr-ui/src/lib.rs#L1921), [`ui/dr-ui/src/lib.rs:2310`](../ui/dr-ui/src/lib.rs#L2310), [`ui/dr-ui/src/lib.rs:2495`](../ui/dr-ui/src/lib.rs#L2495), [`ui/dr-ui/src/masks_ui.rs:747`](../ui/dr-ui/src/masks_ui.rs#L747), [`ui/dr-ui/ui/app.slint:325`](../ui/dr-ui/ui/app.slint#L325), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | -| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1490`](../ui/dr-ui/src/export.rs#L1490), [`ui/dr-ui/src/export.rs:1516`](../ui/dr-ui/src/export.rs#L1516), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:1710`](../ui/dr-ui/src/lib.rs#L1710), [`ui/dr-ui/ui/app.slint:880`](../ui/dr-ui/ui/app.slint#L880), [`ui/dr-ui/ui/library.slint:712`](../ui/dr-ui/ui/library.slint#L712) | +| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1490`](../ui/dr-ui/src/export.rs#L1490), [`ui/dr-ui/src/export.rs:1516`](../ui/dr-ui/src/export.rs#L1516), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:1728`](../ui/dr-ui/src/lib.rs#L1728), [`ui/dr-ui/ui/app.slint:943`](../ui/dr-ui/ui/app.slint#L943), [`ui/dr-ui/ui/library.slint:752`](../ui/dr-ui/ui/library.slint#L752) | | NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) | | NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) | | NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | @@ -108,23 +111,22 @@ _None._ | NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:265`](../core/dr-types/src/lib.rs#L265), [`core/dr-types/src/lib.rs:298`](../core/dr-types/src/lib.rs#L298) | | NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877) | | NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) | -| NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | +| NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | -| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:56`](../ui/dr-ui/src/lib.rs#L56) | -| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376) | +| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:57`](../ui/dr-ui/src/lib.rs#L57) | +| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | | R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) | -| R4 | [`core/dr-gpu/src/lib.rs:211`](../core/dr-gpu/src/lib.rs#L211) | +| R4 | [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | ## Not yet tagged -92 of 175 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +89 of 175 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements - FR-CAT-10 -- FR-CAT-13 - FR-CAT-14 - FR-CULL-10 - FR-CULL-11 @@ -136,11 +138,9 @@ _None._ - FR-CULL-8 - FR-CULL-9 - FR-DEV-1 -- FR-DEV-2 - FR-DEV-3f - FR-DEV-3g - FR-DEV-7 -- FR-DEV-8 - FR-DSP-2 - FR-DSP-3 - FR-DSP-4 diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 6863a65..8820974 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -273,6 +273,20 @@ pub struct DevelopSession { /// attributes leaves it at — the tabs are the interface's idea, not the /// core's, and nothing breaks without them (ARCH §4.3a). active_tab: Option, + /// TRACES: FR-DEV-3 + /// Which of the curve widget's subjects the panel is plotting. + /// + /// The tone curve is four curves — one over tone and one per colour + /// channel — and one square plot draws one of them at a time. The index + /// is into the subjects the operation's parameters are faceted on, in the + /// order it declares them, so nothing here knows that "red" exists. + /// + /// **Interface state, not part of the edit.** It changes no pixel, so it + /// is not a parameter, it is not in the graph, it is not in the sidecar + /// and it is not on the undo stack — the same standing as which tab is + /// open. One value rather than one per operation, for the same reason + /// `curve_samples` is one polyline: the panel draws one curve. + curve_channel: usize, } impl DevelopSession { @@ -339,6 +353,7 @@ impl DevelopSession { active_mask: None, show_overlay: false, active_tab: None, + curve_channel: 0, } } @@ -349,8 +364,12 @@ impl DevelopSession { pub fn rows(&self) -> Vec { let caps = self.scoped_capabilities(); match self.active_tab { - Some(attribute) => rows_filtered(&caps, |op| op.attributes.contains(&attribute)), - None => rows_from(&caps), + Some(attribute) => rows_filtered( + &caps, + |op| op.attributes.contains(&attribute), + self.curve_channel, + ), + None => rows_filtered(&caps, |_| true, self.curve_channel), } } @@ -384,8 +403,10 @@ impl DevelopSession { .into_iter() .filter(|a| *a != Attribute::Geometry) .filter(|a| { - caps.iter() - .any(|c| c.attributes.contains(a) && !rows_filtered(&caps, |o| o.attributes.contains(a)).is_empty()) + caps.iter().any(|c| { + c.attributes.contains(a) + && !rows_filtered(&caps, |o| o.attributes.contains(a), 0).is_empty() + }) }) .map(|a| (a, crate::labels::resolve(a.label().0))) .collect() @@ -494,8 +515,13 @@ pub(crate) fn supported(widget: WidgetKind) -> bool { /// FR-DEV-3c acceptance test asks for — an operation the frontend has never /// heard of appearing in a generated panel — and it cannot be asserted at all /// if generating a row requires a device. +/// +/// `#[cfg(test)]` since the panel began passing the selected curve down: the +/// session always has one to pass, and a wrapper that quietly picked the first +/// would be a second answer to a question the session already answers. +#[cfg(test)] pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec { - rows_filtered(caps, |_| true) + rows_filtered(caps, |_| true, 0) } /// The panel model for the capabilities `keep` accepts. @@ -508,9 +534,16 @@ pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec { /// /// Getting that backwards is how a slider ends up driving a different /// operation, which is the kind of fault that looks like a rendering bug. +/// +/// `curve_channel` is which subject a multi-subject widget is showing — the +/// tone curve's four curves are one plot with a selector over it. It is passed +/// in rather than read from anywhere because this function is deliberately +/// free-standing: the descriptor-to-panel path has to be exercisable against a +/// hand-built capability list with no session behind it. pub(crate) fn rows_filtered( caps: &[OpCapability], keep: impl Fn(&OpCapability) -> bool, + curve_channel: usize, ) -> Vec { let mut rows = Vec::new(); for (op_index, op) in caps.iter().enumerate() { @@ -558,7 +591,9 @@ pub(crate) fn rows_filtered( // to the core stops this compiling until someone has decided, // here, whether the panel draws it. let row = match widget { - WidgetKind::ToneCurve => curve_row(op_index, group_head, op, presentation), + WidgetKind::ToneCurve => { + curve_row(op_index, group_head, op, presentation, curve_channel) + } // Canvas-hosted kinds returned above; the rest are not // implemented and reached sliders via `choose`. WidgetKind::ColourWheel @@ -671,8 +706,76 @@ pub(crate) fn rows_filtered( rows } +/// One run of a curve widget's parameters: the points of a single curve. +/// +/// A widget may span several curves — the tone curve is one plot over a master +/// curve and three colour channels — and it says so the way the colour mixer +/// says it has twelve bands: by faceting each parameter with the *subject* it +/// acts on. Consecutive parameters sharing a subject are one curve. +struct CurveRun { + /// The subject's localisation key, or `None` where the widget's parameters + /// carry no facet at all and are therefore a single unnamed curve. + subject: Option<&'static str>, + /// Where this run's points begin in the operation's parameter list. What + /// a drag routes back through, so it must be a position in `op.params` + /// and not in the presentation's list. + base: usize, + /// How many coordinates it holds. + len: usize, +} + +/// TRACES: FR-DEV-3a +/// The curves a curve widget spans, in the order the operation declares them. +/// +/// **This is the whole of the panel's knowledge of colour channels: none.** It +/// groups by whatever subject the parameters carry, so an operation offering a +/// master curve and three channels gets a four-way selector, one offering a +/// single unfaceted curve gets no selector at all, and one that grows a fifth +/// curve tomorrow needs no change here. +/// +/// Returns `None` where the parameters do not look like point coordinates — +/// an odd count, a run that is not contiguous in the capability list — in +/// which case the caller falls back to sliders rather than drawing a widget +/// over a layout it has guessed at. +fn curve_runs(op: &OpCapability, presentation: &Presentation) -> Option> { + // Points are x/y pairs, so an odd count means the operation and this code + // disagree about the layout. + if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) { + log::warn!("{}: curve widget needs an even parameter count", op.id); + return None; + } + + let mut runs: Vec = Vec::new(); + for id in presentation.params { + // The widget addresses points by offset from the first of its run, so + // a run has to be contiguous in the capability list. + let at = op.params.iter().position(|p| p.id == *id)?; + let subject = op.params[at].facet.as_ref().map(|f| f.subject.0); + + match runs.last_mut() { + Some(run) if run.subject == subject && run.base + run.len == at => run.len += 1, + _ => runs.push(CurveRun { + subject, + base: at, + len: 1, + }), + } + } + + if runs.iter().any(|r| !r.len.is_multiple_of(2)) { + log::warn!("{}: a curve's points are not contiguous", op.id); + return None; + } + Some(runs) +} + /// One row standing for a whole curve. /// +/// `channel` picks which of the widget's curves is plotted; it is clamped +/// rather than validated, because the selection is interface state that +/// outlives a change of photograph and the new image's operation may have +/// fewer curves than the old one's. +/// /// Returns `None` if the operation's parameters do not look like point /// coordinates, in which case the caller falls back to sliders rather than /// rendering a broken widget. @@ -681,38 +784,21 @@ fn curve_row( group_head: usize, op: &OpCapability, presentation: &Presentation, + channel: usize, ) -> Option { - // Points are x/y pairs, so an odd count means the operation and this - // code disagree about the layout. - if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) { - log::warn!("{}: curve widget needs an even parameter count", op.id); - return None; - } + let runs = curve_runs(op, presentation)?; + let run = runs.get(channel.min(runs.len().saturating_sub(1)))?; - // The widget addresses points by offset from the first, so they must - // be contiguous in the capability list. - let base = op - .params + let points: Vec = op.params[run.base..run.base + run.len] .iter() - .position(|p| p.id == presentation.params[0])?; - for (i, id) in presentation.params.iter().enumerate() { - if op.params.get(base + i).map(|p| p.id) != Some(*id) { - log::warn!("{}: curve parameters are not contiguous", op.id); - return None; - } - } - - let points: Vec = presentation - .params - .iter() - .filter_map(|id| op.params.iter().find(|p| p.id == *id)) .map(|p| p.value) .collect(); Some(ParamRow { op_index: op_index as i32, - // The first point parameter; the widget offsets from here. - param_index: base as i32, + // The first point parameter *of the curve on show*; the widget offsets + // from here, so switching curve is what re-points the drag. + param_index: run.base as i32, op_label: labels::resolve(op.label.0).into(), param_label: String::new().into(), // A widget spanning a whole operation is not a row in anyone's @@ -733,21 +819,97 @@ fn curve_row( precision: 4, unit: String::new().into(), points: slint::ModelRc::new(slint::VecModel::from(points)), - // A curve is not a choice between named alternatives. + // A curve is not a choice between named alternatives. The curves it + // can switch between are named on the panel rather than on the row — + // see `DevelopSession::curve_channels` for why they cannot ride here. choices: no_choices(), }) } impl DevelopSession { - /// The curve's shape, sampled for drawing. + /// TRACES: FR-DEV-3 + /// The names of the curves the widget can switch between. + /// + /// Empty where there is only one, which is also the answer for a frontend + /// with no curve at all: a selector over a single choice is a row of + /// nothing. + /// + /// **Derived from the facets, so nothing here names a colour channel.** + /// The operation says its forty points are one control applied to four + /// subjects and publishes a localisation key for each; this resolves the + /// keys and hands over four words. An operation that grew a fifth curve + /// would appear here on its own. + /// + /// A panel property rather than a field on the curve's `ParamRow`, and the + /// reason is Slint's: a row's models are compared by identity, so a fresh + /// list of names built on every parameter event would make the row look + /// changed every time, and rewriting a row rebuilds the repeater item + /// underneath it — destroying the `TouchArea` holding the drag in + /// progress. The same hazard `rows`'s in-place point update exists to + /// avoid. Nothing in this list is a drag target, so up here it is safe to + /// replace wholesale, exactly as [`Self::curve_samples`] is. + pub fn curve_channels(&self) -> Vec { + for op in &self.scoped_capabilities() { + let Some(presentation) = &op.presentation else { + continue; + }; + if presentation.choose(supported) != Some(WidgetKind::ToneCurve) { + continue; + } + let Some(runs) = curve_runs(op, presentation) else { + continue; + }; + if runs.len() < 2 { + continue; + } + return runs + .iter() + .map(|r| r.subject.map(labels::resolve).unwrap_or_default()) + .collect(); + } + Vec::new() + } + + /// Which curve the widget is plotting, as an index into + /// [`Self::curve_channels`]. + pub fn curve_channel(&self) -> i32 { + self.curve_channel as i32 + } + + /// Plot a different one of the operation's curves. + /// + /// Out-of-range indices are ignored rather than clamped: the only thing + /// that can send one is a stale interface event, and quietly moving the + /// selection somewhere the user did not point is worse than doing nothing. + pub fn set_curve_channel(&mut self, index: i32) { + let Ok(index) = usize::try_from(index) else { + return; + }; + if index < self.curve_channels().len() { + self.curve_channel = index; + } + } + + /// The plotted curve's shape, sampled for drawing. /// /// Evaluated with `dr_pipeline`'s own spline, so the line the user drags /// is the line the shader applies. The alternative — reading the curve /// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a /// polyline. + /// + /// The line drawn is the *selected* curve's own shape, not the composition + /// of it with the master. Two curves overlaid on one grid is a plot of two + /// things, and the one being dragged has to be the one whose points are + /// under the pointer. pub fn curve_samples(&self) -> Vec { const SAMPLES: usize = 96; + // The selection is an index over the subjects the panel found, which + // for this operation is its channel order. Clamped rather than + // trusted: a selection made on one photograph outlives the change to + // the next. + let channel = curve::Channel::ALL[self.curve_channel.min(curve::CHANNELS - 1)]; + let mut xs = [0.0f32; curve::POINTS]; let mut ys = [0.0f32; curve::POINTS]; let mut found = false; @@ -757,16 +919,18 @@ impl DevelopSession { continue; } found = true; - for (i, p) in cap.params.iter().enumerate() { - let point = i / 2; - if point >= curve::POINTS { - break; - } - if i % 2 == 0 { - xs[point] = p.value; - } else { - ys[point] = p.value; - } + // By id rather than by position, so which curve is plotted is + // decided by naming it and not by arithmetic over the parameter + // list. + let value = |id| { + cap.params + .iter() + .find(|p| p.id == id) + .map_or(0.0, |p| p.value) + }; + for i in 0..curve::POINTS { + xs[i] = value(curve::coordinate(channel, i, curve::Axis::X)); + ys[i] = value(curve::coordinate(channel, i, curve::Axis::Y)); } } if !found { @@ -3419,9 +3583,9 @@ mod tests { #[test] fn the_curve_collapses_to_a_single_row() { - // Ten point parameters must appear as one curve control, not ten - // sliders — otherwise the widget and the sliders both render and the - // panel shows the same values twice. + // Every point parameter — all four curves' worth — must appear as one + // curve control, not as forty sliders. Otherwise the widget and the + // sliders both render and the panel shows the same values twice. let graph = EditGraph::default_chain(); let curve_cap = graph .capabilities() @@ -3429,7 +3593,11 @@ mod tests { .find(|c| c.id == curve::ID) .expect("the chain includes a tone curve"); - assert_eq!(curve_cap.params.len(), curve::POINTS * 2); + assert_eq!( + curve_cap.params.len(), + curve::CHANNELS * curve::POINTS * 2, + "a master curve and one per colour channel" + ); let presentation = curve_cap .presentation .as_ref() @@ -3469,6 +3637,144 @@ mod tests { } } + /// The panel's whole knowledge of colour channels, asserted to be none. + /// + /// It groups the widget's parameters by the subject the *operation* put on + /// them and finds four curves; nothing below says "red", and an operation + /// that grew a fifth curve would arrive here on its own. + #[test] + fn a_curve_widget_offers_one_run_per_subject() { + let graph = EditGraph::default_chain(); + let cap = graph + .capabilities() + .into_iter() + .find(|c| c.id == curve::ID) + .expect("tone curve present"); + let presentation = cap.presentation.as_ref().expect("declares a widget"); + + let runs = curve_runs(&cap, presentation).expect("a curve-shaped operation"); + assert_eq!(runs.len(), curve::CHANNELS); + for (i, run) in runs.iter().enumerate() { + assert_eq!(run.len, curve::POINTS * 2, "run {i} is not five points"); + assert_eq!(run.base, i * curve::POINTS * 2); + assert!(run.subject.is_some(), "run {i} is unnamed"); + } + } + + #[test] + fn switching_curve_repoints_the_row() { + use slint::Model as _; + + // What a drag routes through. The row's `param_index` is the base of + // the curve *on show*, so picking a different one must move it — if it + // did not, dragging a point on the red curve would write to the + // master's. + let graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + let curve_at = caps + .iter() + .position(|c| c.id == curve::ID) + .expect("tone curve present"); + + let mut bases = Vec::new(); + for channel in 0..curve::CHANNELS { + let rows = rows_filtered(&caps, |_| true, channel); + let row = rows + .iter() + .find(|r| r.op_index as usize == curve_at) + .expect("the curve has a row"); + assert_eq!(row.kind, "curve"); + assert_eq!( + row.points.row_count(), + curve::POINTS * 2, + "one curve's points, not all four curves'" + ); + bases.push(row.param_index); + } + + assert_eq!( + bases, + (0..curve::CHANNELS) + .map(|i| (i * curve::POINTS * 2) as i32) + .collect::>() + ); + } + + #[test] + fn a_selection_the_operation_cannot_honour_falls_back_to_its_last_curve() { + // The selection outlives the photograph it was made on, and the next + // image's operation may offer fewer curves. Clamping keeps a plot on + // the grid; the alternative is a curve row that vanishes, which reads + // as the tone curve having disappeared from the panel. + let graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + let rows = rows_filtered(&caps, |_| true, 99); + let row = rows + .iter() + .find(|r| r.kind == "curve") + .expect("the curve still has a row"); + assert_eq!( + row.param_index, + ((curve::CHANNELS - 1) * curve::POINTS * 2) as i32 + ); + } + + #[test] + fn an_operation_whose_points_are_unfaceted_is_one_curve() { + // A curve widget that spans a single unnamed curve — which is what + // this operation was before the channels arrived, and what any other + // node declaring a `tone_curve` widget over ten scalars would be. + // It must draw, and it must offer no choice. + use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand}; + use slint::Model as _; + + static IDS: [ParamId; 4] = [ + ParamId("p0_x"), + ParamId("p0_y"), + ParamId("p1_x"), + ParamId("p1_y"), + ]; + let param = |id: ParamId| ParamCapability { + id, + label: LocalizedKey("param.point"), + kind: ParamKind::Scalar { + min: 0.0, + max: 1.0, + scale: dr_pipeline::Scale::Linear, + unit: Unit::None, + precision: 4, + }, + default: 0.0, + value: 0.0, + facet: None, + }; + let plain = OpCapability { + id: OpId("invented_curve"), + label: LocalizedKey("op.invented_curve"), + active: false, + presentation: Some(Presentation { + widgets: &[WidgetKind::ToneCurve], + demand: WidgetDemand { + two_dimensional: true, + precise_pointing: true, + }, + params: &IDS, + }), + params: IDS.iter().map(|id| param(*id)).collect(), + attributes: &[dr_pipeline::Attribute::Tone], + }; + + let presentation = plain.presentation.as_ref().expect("declares a widget"); + let runs = curve_runs(&plain, presentation).expect("curve-shaped"); + assert_eq!(runs.len(), 1, "one unnamed curve"); + assert_eq!(runs[0].subject, None); + + let rows = rows_from(&[plain]); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].kind, "curve"); + assert_eq!(rows[0].points.row_count(), IDS.len()); + } + #[test] fn curve_samples_start_on_the_diagonal() { // A fresh curve is the identity, so the drawn line must be the 45° diff --git a/ui/dr-ui/src/labels.rs b/ui/dr-ui/src/labels.rs index 4d2f242..f40c076 100644 --- a/ui/dr-ui/src/labels.rs +++ b/ui/dr-ui/src/labels.rs @@ -54,6 +54,19 @@ pub fn resolve(key: &str) -> String { "param.channel.sat" => "Saturation".into(), "param.channel.lum" => "Luminance".into(), + // The tone curve's four curves, which its points are *subject* to. + // + // Catalogued rather than derived because the master curve's key would + // otherwise read "Rgb": these are the terms of a four-way choice, and + // one of them miscapitalised is the one the eye goes to. The three + // colours would derive correctly and are written out beside it anyway, + // since a list where one entry is translated and three are guessed is + // the shape a half-finished translation takes. + "channel.rgb" => "RGB".into(), + "channel.red" => "Red".into(), + "channel.green" => "Green".into(), + "channel.blue" => "Blue".into(), + // The hue bands, which a faceted row is *subject* to. // // Catalogued even where `derive` would produce the same word, because diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index acc62aa..82cea65 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -583,8 +583,24 @@ pub(crate) fn sync_rows( // curve must be drawn whatever shape it is in. rows.set_vec(current); curve_moved = true; + + // The curves the widget can switch between, named. They can only + // change with the operation set, which is what this branch means, so + // the walk that derives them is not on the parameter-event path. + let channels: Vec = match session.borrow().as_ref() { + Some(s) => s.curve_channels().into_iter().map(Into::into).collect(), + None => Vec::new(), + }; + window.set_curve_channels(slint::ModelRc::new(slint::VecModel::from(channels))); } + // Which curve is plotted, on every pass. Picking one that happens to be + // shaped like the last — two untouched curves are both the diagonal — + // moves no point, so this cannot ride on the resample below: the chips + // would go on highlighting the curve the user just navigated away from. + let channel = session.borrow().as_ref().map_or(0, |s| s.curve_channel()); + window.set_curve_channel(channel); + if !curve_moved { return; } @@ -1804,6 +1820,24 @@ pub fn run(paths: Vec) -> Result<()> { redraw(&w); }); } + { + // Which of the curve's curves the plot is showing. **No redraw**, and + // that is the whole character of this control: it changes no + // parameter, so the photograph is already correct on screen and + // recomputing it would be a frame spent to produce the same pixels. + // For the same reason it records no history step — there is nothing + // to undo — and the sidecar never hears about it. + let weak = window.as_weak(); + let session = session.clone(); + let rows = rows.clone(); + window.on_curve_channel_picked(move |index| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.set_curve_channel(index); + } + sync_rows(&w, &rows, &session); + }); + } // ---- undo and redo (FR-DEV-5) --------------------------------------- // diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index 38a6451..e8b2dc8 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -22,7 +22,7 @@ use dr_types::FormatFilter; use slint::{ComponentHandle, Model as _}; use crate::library::{self, ScanMessage, ThumbnailMessage}; -use crate::{AppWindow, LibraryCell, TimelineBar}; +use crate::{AppWindow, KeywordRow, LibraryCell, TimelineBar}; /// Window size before the grid has reported its geometry. /// @@ -2117,6 +2117,170 @@ fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) { window.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.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.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.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.set_library_error(format!("{e}").into()); + return; + } + }; + + window.set_library_error(slint::SharedString::new()); + window.set_library_status(keyword_summary(&word, n, images.len(), assigning).into()); + refresh_keywords(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 @@ -4152,6 +4316,40 @@ pub fn wire( }); } + // --- 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. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll_for_keywords = coll_ctl.clone(); + window.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.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.on_library_unassign_keyword(move |word| { + let Some(w) = weak.upgrade() else { return }; + apply_keyword(&w, &ctl, word.as_str(), false); + }); + } + // --- the filter bar --------------------------------------------------- // // Each of these narrows what the grid *queries*, so all three reset the @@ -5017,6 +5215,71 @@ mod tests { assert_eq!(paths, vec!["c.CR2", "a.CR2"]); } + // --- 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" + ); + } + /// TRACES: FR-EXP-7 #[test] fn a_selection_outside_the_loaded_window_still_resolves() { diff --git a/ui/dr-ui/ui/adjust.slint b/ui/dr-ui/ui/adjust.slint index b73adb1..3e0fe78 100644 --- a/ui/dr-ui/ui/adjust.slint +++ b/ui/dr-ui/ui/adjust.slint @@ -365,10 +365,17 @@ component ParamControl inherits Rectangle { in property data; /// Curve rows only; ignored by every other kind. in property <[float]> curve-samples; + /// The curves this widget can plot, named. Empty, or one entry, where + /// there is nothing to choose between — see `curve-channel-picked`. + in property <[string]> curve-channels; + /// Which of `curve-channels` is on the grid. + in property curve-channel; callback param-changed(int, int, float); callback param-reset(int, int); callback curve-reset(int); + /// Plot a different one of the operation's curves. + callback curve-channel-picked(int); callback drag-changed(bool); height: layout.preferred-height; @@ -420,20 +427,49 @@ component ParamControl inherits Rectangle { } } - if root.data.kind == "curve": CurveEditor { - points: root.data.points; - samples: root.curve-samples; - drag-changed(on) => { root.drag-changed(on); } - // A point carries two parameters, so the parameter index is the - // row's base plus the point's offset. This component still knows - // nothing about which operation it belongs to. - point-moved(point, x, y) => { - root.param-changed( - root.data.op-index, root.data.param-index + point * 2, x); - root.param-changed( - root.data.op-index, root.data.param-index + point * 2 + 1, y); + // A curve, and — where the operation offers more than one — the choice + // of which curve is on the grid. + // + // One plot rather than four stacked ones: the curves are read against + // the diagonal and against each other, which needs the grid large, and + // four grids at a quarter of the width would each be too small to + // place a point in. So the selector switches the subject of a single + // plot, and the names in it come from the core — this file does not + // know that a colour channel is what is being chosen between, only + // that the widget said it spans several named things. + if root.data.kind == "curve": VerticalLayout { + spacing: Theme.gap-sm; + + if root.curve-channels.length > 1: Segmented { + // The operation's own name, which nothing else in this row + // draws: a curve row heads no group, so without this the plot + // would sit in the panel unlabelled. + label: root.data.op-label; + options: root.curve-channels; + selected: root.curve-channel; + picked(i) => { root.curve-channel-picked(i); } + } + + CurveEditor { + points: root.data.points; + samples: root.curve-samples; + drag-changed(on) => { root.drag-changed(on); } + // A point carries two parameters, so the parameter index is + // the row's base plus the point's offset. The base is the + // first point of the curve *on show*, so switching curve + // re-points the drag and this component still knows nothing + // about which operation — or which curve — it is drawing. + point-moved(point, x, y) => { + root.param-changed( + root.data.op-index, root.data.param-index + point * 2, x); + root.param-changed( + root.data.op-index, root.data.param-index + point * 2 + 1, y); + } + // Resetting a curve resets the operation, which is all four of + // them — a photographer who double-clicks to start again means + // the control, not the curve that happens to be on show. + reset => { root.curve-reset(root.data.op-index); } } - reset => { root.curve-reset(root.data.op-index); } } } } @@ -825,9 +861,16 @@ export component AdjustPanel inherits Rectangle { /// The tone curve's sampled shape, evaluated in Rust by the same spline /// the shader runs so the drawn line cannot disagree with the applied one. in property <[float]> curve-samples; + /// The curves the tone curve widget can plot, named by the core. Fewer + /// than two of them means there is nothing to choose and no selector. + in property <[string]> curve-channels; + /// Which of them `curve-samples` and the row's points describe. + in property curve-channel; callback param-changed(int, int, float); callback param-reset(int, int); callback curve-reset(int); + /// Plot a different one of the curve's curves. + callback curve-channel-picked(int); /// Return every parameter of one operation to its default — the reset on /// a section's own header, beside the panel-wide one. callback op-reset(int); @@ -986,12 +1029,15 @@ export component AdjustPanel inherits Rectangle { ParamControl { data: row; curve-samples: root.curve-samples; + curve-channels: root.curve-channels; + curve-channel: root.curve-channel; drag-changed(on) => { root.slider-dragging = on; } param-changed(op, param, v) => { root.param-changed(op, param, v); } param-reset(op, param) => { root.param-reset(op, param); } curve-reset(op) => { root.curve-reset(op); } + curve-channel-picked(i) => { root.curve-channel-picked(i); } } } } diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index ce62ec6..c055ecd 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -2,7 +2,7 @@ import { Theme } from "theme.slint"; import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint"; import { GradientHandle, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint"; import { LaunchScreen } from "launch.slint"; -import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll } from "library.slint"; +import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow } from "library.slint"; import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint"; import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint"; import { HistogramPanel, HistogramView } from "histogram.slint"; @@ -589,6 +589,25 @@ export component AppWindow inherits Window { /// the target's id, and whether to take the images out of the collection /// currently being shown. callback library-file-in-collection(int, bool); + /// TRACES: FR-CAT-5 | FR-CAT-6 + /// Keywording the grid's selection. The catalog has been searchable by + /// keyword since it existed and there was nowhere to type one; this is it. + /// + /// The vocabulary arrives already answered against the selection — each row + /// says how many of the selected photographs carry that word — because only + /// Rust knows what is selected, and a `.slint` file counting it would need + /// the selection as a second model that could disagree with the first. + in property <[KeywordRow]> library-keywords; + /// The sheet is opening: recompute the rows against the selection as it + /// stands now. Pulled rather than pushed, because the selection changes on + /// every arrow key and the sheet is shut for almost all of them. + callback library-keywords-opened(); + /// Put a keyword on the selection, creating it if it is new. By name, so a + /// word typed into the field and a word tapped in the list are one path. + callback library-assign-keyword(string); + /// Take a keyword off the selection. Never deletes the keyword itself — + /// it stays in the vocabulary and on every other photograph that carries it. + callback library-unassign-keyword(string); /// TRACES: FR-UI-2 /// Whether a tap in the grid selects rather than opens, and the button /// that turns it on. The long press does the same thing without it. @@ -697,9 +716,14 @@ export component AppWindow inherits Window { // The tone curve's sampled shape, evaluated by the core so the drawn // line and the applied one cannot disagree. in property <[float]> curve-samples; + // The curves that widget can plot, named by the core, and which of them + // `curve-samples` describes. Fewer than two means nothing to choose. + in property <[string]> curve-channels; + in property curve-channel; callback param-changed(int, int, float); callback param-reset(int, int); callback curve-reset(int); + callback curve-channel-picked(int); callback reset-all(); // --- copying settings between photographs (FR-DEV-6) --- @@ -1268,6 +1292,10 @@ in property panel-visible: true; file-in-collection(id, moves) => { root.library-file-in-collection(id, moves); } + keywords: root.library-keywords; + keywords-opened() => { root.library-keywords-opened(); } + assign-keyword(word) => { root.library-assign-keyword(word); } + unassign-keyword(word) => { root.library-unassign-keyword(word); } cursor: root.library-cursor; move-cursor(delta, extend) => { root.library-move-cursor(delta, extend); @@ -2154,6 +2182,11 @@ in property panel-visible: true; enabled: root.adjust-enabled; scope: root.adjust-scope; curve-samples: root.curve-samples; + curve-channels: root.curve-channels; + curve-channel: root.curve-channel; + curve-channel-picked(i) => { + root.curve-channel-picked(i); + } param-changed(op, param, value) => { root.param-changed(op, param, value); } diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index 5089a45..4e1a50f 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -9,12 +9,38 @@ // must not look identical (FR-NC-6c). import { Theme } from "theme.slint"; -import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon } from "widgets.slint"; +import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon, Field } from "widgets.slint"; // The filing sheet lists the same rows the sidebar draws, from the same model: // two lists of collections that could disagree about what exists is one list // too many. import { CollectionRow } from "collections.slint"; +// TRACES: FR-CAT-5 +// One keyword in the keywording sheet, already answered against the selection. +// +// The three-way `coverage` is the whole reason this is a struct rather than a +// list of strings. Applying a word to forty photographs where thirty already +// carry it must not look like applying it to forty that carry none, and +// removing one that only some of them carry must not silently claim to have +// taken it off all forty. Rust computes it, because only Rust knows how big the +// selection is and how many of it each word covers. +export struct KeywordRow { + // Row id in `keyword_terms`, or 0 for a word an image carries that the + // vocabulary has no identity for yet. The sheet acts on `name`, never on + // this, so a 0 costs nothing — it is here so a future rename gesture has + // something to name. + id: int, + name: string, + // 0 none of the selection, 1 some of it, 2 all of it. + coverage: int, + // How many of the selected photographs carry it, for the "3 of 12" that + // makes `coverage: 1` a number rather than a shrug. + selected-count: int, + // How many photographs in the whole library carry it. Lets a word in + // regular use be told from one typed once by mistake. + image-count: int, +} + // One bar of the capture-time histogram. export struct TimelineBar { // 0..1, relative to the tallest bucket. Square-rooted in Rust so a quiet @@ -656,6 +682,9 @@ component HeaderActions inherits HorizontalLayout { callback remove-from-collection(); /// Open the sheet that files the selection in a collection. callback add-to-collection(); + /// TRACES: FR-CAT-5 + /// Open the sheet that keywords the selection. + callback add-keyword(); callback toggle-select-mode(); callback change-library(); callback toggle-pin-scope(); @@ -694,6 +723,17 @@ component HeaderActions inherits HorizontalLayout { clicked => { root.add-to-collection(); } } + // TRACES: FR-CAT-5 | FR-CAT-6 + // Keyword the selection. Beside "Add to collection" because they are the + // same thought — these photographs are *of* something, and they belong + // *with* something — and appearing under the same condition, because + // neither means anything without a selection to act on. + if root.selected-count > 0: Button { + text: "Keywords"; + y: root.centred ? (root.row-height - self.height) / 2 : 0; + clicked => { root.add-keyword(); } + } + // TRACES: FR-DEV-6 // Batch-apply the copied settings. Shown only with both a selection and a // clipboard, because it is meaningless without either — and because a @@ -1105,6 +1145,34 @@ export component LibraryGrid inherits Rectangle { /// out of the one currently being shown. callback file-in-collection(int, bool); + // --- keywording the selection (FR-CAT-5, FR-CAT-6) ---------------------- + // + // The catalog has been searchable by keyword since it existed and there was + // never anywhere to type one. This sheet is that place, and it sits beside + // the filing sheet above because the two are the same gesture applied to + // two different kinds of label — pick the photographs, then say what they + // are — and a user who has learnt one should not have to learn the other. + // + // Assign and unassign travel by **name**, not by id. A word typed into the + // field and a word tapped in the list are then one path through Rust rather + // than two, and the sheet does not have to invent an id for a keyword that + // does not exist yet. + /// The vocabulary, already answered against the current selection. + in property <[KeywordRow]> keywords; + /// The sheet is opening: Rust answers by refreshing `keywords` against + /// whatever is selected *now*. + /// + /// Pulled on open rather than pushed on every selection change, because the + /// selection changes on every arrow key and the sheet is shut for almost + /// all of them — recomputing coverage over a forty-image selection for a + /// panel nobody is looking at is work the grid cannot afford. + callback keywords-opened(); + callback assign-keyword(string); + callback unassign-keyword(string); + /// Whether the sheet is up. Local, for the same reason `filing` is: it is a + /// disclosure rather than a preference, and what closes it is dismissing it. + property keywording: false; + // Cell geometry. Columns are derived from the available width so the grid // reflows with the window rather than fixing a count (FR-UI-1). // Zoomable, so the grid serves both jobs: fewer, larger images for @@ -1355,6 +1423,14 @@ export component LibraryGrid inherits Rectangle { // to is still there when the sheet closes. root.actions-open = false; } + add-keyword => { + // Ask for the vocabulary before showing the sheet, so + // it is answered against the selection as it stands now + // rather than as it stood when the grid last loaded. + root.keywords-opened(); + root.keywording = true; + root.actions-open = false; + } change-library => { root.change-library(); } toggle-pin-scope => { root.toggle-pin-scope(); } sync-now => { root.sync-now(); } @@ -1429,6 +1505,14 @@ export component LibraryGrid inherits Rectangle { // to is still there when the sheet closes. root.actions-open = false; } + add-keyword => { + // Ask for the vocabulary before showing the sheet, so + // it is answered against the selection as it stands now + // rather than as it stood when the grid last loaded. + root.keywords-opened(); + root.keywording = true; + root.actions-open = false; + } change-library => { root.change-library(); } toggle-pin-scope => { root.toggle-pin-scope(); } sync-now => { root.sync-now(); } @@ -1788,6 +1872,10 @@ export component LibraryGrid inherits Rectangle { // button, and a sheet it walked straight past would leave // the user out of the grid with their selection gone. if (event.text == Key.Back || event.text == Key.Escape) { + if (root.keywording) { + root.keywording = false; + return accept; + } if (root.filing) { root.filing = false; return accept; @@ -2486,4 +2574,184 @@ export component LibraryGrid inherits Rectangle { } } } + + // --- the keywording sheet (FR-CAT-5, FR-CAT-6) -------------------------- + // + // "These are of…". Deliberately the same card, scrim and dismissal as the + // filing sheet above: a user who has filed a selection already knows how + // this works, and a second idiom for the same gesture would be a second + // thing to learn for no gain. + // + // It stays open after each word, where the filing sheet closes. Filing is + // one choice; keywording is usually several — "puffin", "Látrabjarg", + // "2026" — and a sheet that shut after each one would have to be reopened, + // and the selection re-confirmed, three times over. + if root.keywording: Rectangle { + background: #000000CC; + + // Swallows the taps that miss the card, and closes. First, so the + // card's own controls sit above it. + TouchArea { + clicked => { root.keywording = false; } + } + + Rectangle { + width: min(420px, parent.width - 2 * Theme.gap-lg); + height: min(kw-sheet.preferred-height, 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; + + // Stops a press on the card reaching the scrim behind it. + TouchArea { } + + kw-sheet := VerticalLayout { + padding: Theme.gap-lg; + spacing: Theme.gap; + + Text { + text: root.selected-count == 1 + ? "Keywords for 1 photograph" + : "Keywords for " + root.selected-count + " photographs"; + color: Theme.ink; + font-size: Theme.text-lg; + font-weight: 600; + wrap: word-wrap; + } + + // Typing a word applies it, whether or not it already exists. + // One field for both, because "is this keyword new?" is a + // question about the catalog and not about what the user meant, + // and Rust can answer it without being asked. + // + // The field clears itself on accept so the next word can be + // typed straight after — keywording a shoot is a run of them. + new-keyword := Field { + placeholder: "Type a keyword and press return"; + accepted(text) => { + root.assign-keyword(text); + self.text = ""; + } + } + + Rectangle { height: 1px; background: Theme.rule; } + + Flickable { + vertical-stretch: 1; + // A floor, so the list is not squeezed out of existence by + // the field and the button around it on a short window. + min-height: 120px; + viewport-height: root.keywords.length * (Theme.touch-target + 2px); + + for word[i] in root.keywords: Rectangle { + y: i * (Theme.touch-target + 2px); + width: parent.width; + // A full touch target per row, for the same reason the + // filing sheet uses one: this is a place to hit once, + // with a thumb, holding a selection that took a minute + // to build (FR-UI-3). + height: Theme.touch-target; + background: kw-touch.pressed ? Theme.pressed + : (kw-touch.has-hover ? Theme.hover : transparent); + border-radius: Theme.radius-sm; + + HorizontalLayout { + padding-left: Theme.gap-sm; + padding-right: Theme.gap-sm; + spacing: Theme.gap-sm; + + // Tick, dash, or nothing — the three states of + // `coverage`, drawn as three different marks rather + // than as two. A half-applied keyword shown as + // applied is a lie about photographs the user + // cannot see from here. + Rectangle { + width: 16px; + y: (parent.height - self.height) / 2; + height: 16px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: word.coverage == 0 ? Theme.rule : Theme.active; + background: word.coverage == 2 ? Theme.active : transparent; + + // The dash for "some of them". A bar rather + // than a tick, because a tick at half strength + // reads as a rendering artefact. + if word.coverage == 1: Rectangle { + width: 8px; + height: 2px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + background: Theme.active; + } + if word.coverage == 2: Icon { + name: "check"; + ink: Theme.surface; + size: 12px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + } + } + + Text { + text: word.name; + color: Theme.ink; + font-size: Theme.text; + vertical-alignment: center; + overflow: elide; + horizontal-stretch: 1; + } + + // "3 of 12" only where it says something the mark + // does not. For a word the whole selection carries, + // or none of it, the mark has already said it and + // the number would be noise on every row. + Text { + text: word.coverage == 1 + ? word.selected-count + " of " + root.selected-count + : (word.image-count > 0 ? word.image-count + "" : ""); + color: Theme.ink-faint; + font-size: Theme.text-sm; + vertical-alignment: center; + } + } + + // One target for both directions. A word the selection + // fully carries comes off; anything else goes on — so a + // partly-applied keyword is completed rather than + // removed, which is what a user tapping a dash means + // nine times in ten, and the tenth is one more tap + // away. + kw-touch := TouchArea { + clicked => { + if (word.coverage == 2) { + root.unassign-keyword(word.name); + } else { + root.assign-keyword(word.name); + } + } + } + } + + if root.keywords.length == 0: Text { + text: "No keywords yet. Type one above to make the first."; + color: Theme.ink-faint; + font-size: Theme.text-sm; + wrap: word-wrap; + width: parent.width; + } + } + + Rectangle { height: 1px; background: Theme.rule; } + + Button { + text: "Done"; + clicked => { root.keywording = false; } + } + } + } + } }