From 2147eaa6a5588dcc16d88489bcf52085e5c4d448 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 15:50:49 +0200 Subject: [PATCH 1/6] Put a keyword on a photograph, not only search for one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog has been able to *find* by keyword since v1 — query.rs joins the keywords table, matches it exactly, and substring-matches it for free text — and nothing anywhere could ever put a word there. A user could filter to a keyword they had no way to apply. This is the missing half: create, rename, delete, list, assign, unassign, and the two reads a panel needs. Bulk-only for assignment, because keywording a selection is the common case rather than the exception — the photographer picks out the frames with the puffin in them and applies "puffin" once, in one transaction. Schema v6 adds `keyword_terms`, and deliberately does *not* touch the v1 join. The assignment keeps the word as text because the catalog is a rebuildable index and the durable copies of that fact — the sidecar, XMP dc:subject — both carry a string; a foreign key would mean a catalog rebuilt from sidecars had to invent identity rows before it could record anything, and would break the query path that already works. So the text is the fact, and the new table is only the identity a rename and a deletion can be keyed on. `keyword_terms.name` carries no unique index, which looks like an oversight and is not: two devices that each type "Iceland" are both right until they meet, and a constraint would abort the merge at that moment. Uniqueness is converged upon instead — create resolves an existing name, fuse_duplicates collapses a cross-device pair onto the smaller uuid. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-catalog/src/collections.rs | 2 +- core/dr-catalog/src/error.rs | 9 + core/dr-catalog/src/keywords.rs | 1220 ++++++++++++++++++++++++++++ core/dr-catalog/src/lib.rs | 5 +- core/dr-catalog/src/schema.rs | 188 ++++- 5 files changed, 1417 insertions(+), 7 deletions(-) create mode 100644 core/dr-catalog/src/keywords.rs 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/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"); From 62188ec7400d5e2002ff31b42fd2597809c2f1b9 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 15:51:00 +0200 Subject: [PATCH 2/6] Keep both devices' keywords when the catalogs meet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keywords are catalog state, and the catalog syncs. Without this, two devices keywording the same library would resolve to whichever synced last, and an afternoon of work would vanish with no sign it had ever happened. The vocabulary merges per row on the rule collections already use: revision first, timestamp only to break a tie, so a device with a skewed clock cannot win by having the wrong idea of the time. Assignments merge as a set union, which is FR-NC-9's principle applied to metadata instead of edit nodes — disjoint work survives on both sides. Three things needed care and are commented where they happen: A deletion travels *by name*, not by identity. Both devices may have minted their own uuid for one word before they ever synced, so deleting by uuid would tombstone a row nothing was assigned to and leave every photograph still carrying the word. The union then refuses to readmit a word a winning tombstone has just removed — without that filter the remote's live assignments would resurrect it on the very same pass. Images are resolved by the server's file id first and the content hash second. Membership has always used the hash alone, but the hash is computed only when import dedup or a reconnect asks for it, which for most libraries is never — so a hash-only union would have quietly done nothing for the ordinary photograph. A word lands on the local default version. Version uuids do not reconcile in the catalog at all: ensure_default_versions mints a fresh one per device, so a uuid-keyed join would have unioned nothing. Removal still does not propagate. That is the trade collection membership already makes, for the same reason — an unwanted keyword is removed again in a second, and a silently lost afternoon is not recoverable at all. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-catalog/src/merge.rs | 682 +++++++++++++++++++++++++++++++++- core/dr-catalog/src/rating.rs | 25 +- core/dr-catalog/src/sync.rs | 11 +- 3 files changed, 698 insertions(+), 20 deletions(-) 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/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 Date: Sat, 22 Aug 2026 15:51:11 +0200 Subject: [PATCH 3/6] Give the library somewhere to type a keyword MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sheet over the grid, opened from the header beside "Add to collection" — deliberately the same card, scrim and dismissal as the filing sheet, because they are the same gesture applied to two kinds of label: pick the photographs, then say what they are. A user who has filed a selection already knows how this works. A word the whole selection carries, a word only some of it carries, and a word none of it carries are three visibly different marks. Half-applied shown as applied would be a lie about photographs the user cannot see from here, so a partial keyword draws a dash and says "3 of 12" beside it. Tapping a dash completes the keyword rather than removing it, which is what it means nine times in ten, and the tenth is one more tap away. The vocabulary is answered against the selection in Rust and pulled when the sheet opens rather than pushed on every selection change — the selection moves on each arrow key and the sheet is shut for almost all of them. Assign and unassign travel by name, so a word typed into the field and a word tapped in the list are one path rather than two, and the sheet never has to invent an identity for a keyword that does not exist yet. One gap, commented at the call site: unlike a star or a flag, a keyword is not queued to the image's sidecar, because the sidecar format has no field for one. So it reaches the user's other devices through the catalog merge, and a deleted catalog loses keywords where it would keep ratings. Co-Authored-By: Claude Opus 5 (1M context) --- ui/dr-ui/src/library_ui.rs | 265 +++++++++++++++++++++++++++++++++++- ui/dr-ui/ui/app.slint | 25 +++- ui/dr-ui/ui/library.slint | 270 ++++++++++++++++++++++++++++++++++++- 3 files changed, 557 insertions(+), 3 deletions(-) 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/app.slint b/ui/dr-ui/ui/app.slint index 8460f8e..fff578a 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, GroupStrip, ParamRow, TransferPanel } from "adjust.slint"; import { 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"; @@ -572,6 +572,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. @@ -1229,6 +1248,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); 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; } + } + } + } + } } From d64a61d6774ccf71929a212bcda09772c07fafa9 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 16:06:13 +0200 Subject: [PATCH 4/6] Give the tone curve a curve for each colour channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declaration in ops/tone_curve.yaml has claimed per-channel curves since it was written — it is the justification for the operation carrying both `tone` and `colour`. Only the master curve existed. This is the other three. The master runs first and the channels grade its result. Both orders are real images and they differ visibly, so the choice is made and written down rather than left to the loop: a point placed on the blue curve should act on the tone the photographer can see, which is what the master has already produced. The other order anchors the grade to tones the master is about to move, so adjusting contrast slides a warm shadow up into the midtones. Every id that existed before today is spelled exactly as it was. The master curve keeps `p2_y` and the new curves take `r_`, `g_` and `b_` prefixes, so a sidecar written when there was one curve loads, means what it meant, and renders the same shader — asserted on the generated source, not on the parameter values. Nothing needed a version check because nothing was renamed. Each curve reaches the shader only when it has been moved off the diagonal, so an S-curve and no colour work generates what it generated when this operation held ten parameters instead of forty, down to the uniform names. The monotonicity guarantee is enforced per curve: a coincident pair on blue divides by zero exactly as thoroughly as one on the master. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-gpu/tests/tone_curve.rs | 139 ++++ core/dr-pipeline/ops/README.md | 7 +- core/dr-pipeline/ops/tone_curve.yaml | 23 +- core/dr-pipeline/src/ops/curve.rs | 1056 ++++++++++++++++++++++---- core/dr-pipeline/src/sidecar.rs | 86 ++- core/dr-pipeline/tests/tone_curve.rs | 202 +++++ 6 files changed, 1339 insertions(+), 174 deletions(-) create mode 100644 core/dr-gpu/tests/tone_curve.rs create mode 100644 core/dr-pipeline/tests/tone_curve.rs diff --git a/core/dr-gpu/tests/tone_curve.rs b/core/dr-gpu/tests/tone_curve.rs new file mode 100644 index 0000000..b12eea0 --- /dev/null +++ b/core/dr-gpu/tests/tone_curve.rs @@ -0,0 +1,139 @@ +//! TRACES: FR-DEV-3 +//! The tone curve's four curves, on a device. +//! +//! `dr-pipeline` asserts that the right WGSL is generated and `dr-gpu`'s other +//! tests assert that a shader runs; neither notices a fragment that says +//! exactly what it should and does not compile, or one that compiles and puts +//! the red curve's uniforms into the blue slot. So this renders flat grey +//! through each curve and looks at what came out. +//! +//! Flat grey because it makes every assertion a comparison between the three +//! components of one pixel: a curve that is meant to be chromatic must move +//! them apart, and one that is meant to be tonal must not. + +use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext}; +use dr_pipeline::ops::curve::{self, Axis, Channel}; +use dr_pipeline::EditGraph; + +const SIZE: u32 = 8; + +fn ctx() -> 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 29344fa..0fe79c9 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 2f21750..9dffe6b 100644 --- a/core/dr-pipeline/src/sidecar.rs +++ b/core/dr-pipeline/src/sidecar.rs @@ -1004,7 +1004,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(); @@ -1189,6 +1189,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" + ); + } + } +} + From 13d003b89db4eb8a8bad652023f825979495954e Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 16:06:21 +0200 Subject: [PATCH 5/6] Let the curve widget plot whichever curve is asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An operation with four curves and a panel that draws one plot needs a way to say which. The panel finds out the way it finds out everything else: the points are faceted with the subject they act on, consecutive parameters sharing a subject are one curve, and a widget spanning several of them gets a selector over their names. Nothing in ui/ contains the word "red", and an operation that grows a fifth curve arrives with a fifth chip. The names ride on the panel rather than on the curve's row, because a Slint model is compared by identity: a fresh list built on every parameter event would make the row look changed every time, and rewriting a row rebuilds the element holding the drag in progress. That is the hazard the in-place point update already exists to avoid. Which curve is on show is interface state, not an edit. It changes no pixel, so it takes no history step, reaches no sidecar, and redraws nothing — the photograph on screen is already right. Co-Authored-By: Claude Opus 5 (1M context) --- ui/dr-ui/src/develop.rs | 398 ++++++++++++++++++++++++++++++++++----- ui/dr-ui/src/labels.rs | 13 ++ ui/dr-ui/src/lib.rs | 34 ++++ ui/dr-ui/ui/adjust.slint | 72 +++++-- ui/dr-ui/ui/app.slint | 10 + 5 files changed, 468 insertions(+), 59 deletions(-) diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 7367f3c..f90d10e 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -92,6 +92,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 { @@ -157,6 +171,7 @@ impl DevelopSession { active_mask: None, show_overlay: false, active_tab: None, + curve_channel: 0, } } @@ -167,8 +182,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), } } @@ -202,8 +221,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() @@ -312,8 +333,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. @@ -326,9 +352,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() { @@ -376,7 +409,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 @@ -489,8 +524,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. @@ -499,38 +602,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 @@ -551,21 +637,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; @@ -575,16 +737,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 { @@ -2977,9 +3141,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() @@ -2987,7 +3151,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() @@ -3027,6 +3195,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 c7d45ae..55b3d8e 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -581,8 +581,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; } @@ -1802,6 +1818,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/ui/adjust.slint b/ui/dr-ui/ui/adjust.slint index 8d81154..49f8f8a 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); } } } } @@ -710,9 +746,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); @@ -861,12 +904,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 8460f8e..6e1512b 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -680,9 +680,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) --- @@ -1978,6 +1983,11 @@ in property panel-visible: true; rows: root.adjust-rows; enabled: root.adjust-enabled; 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); } From 45214d3ca8c33f0de8606e7864a03ebcb97cbfea Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:01:39 +0200 Subject: [PATCH 6/6] Regenerate the traceability matrix Four branches merged, each of which had regenerated this file against its own tree. Those versions all disagreed and none was right for the union, which is why the merges took whichever side was to hand and deferred to this: the matrix is generated, so the only correct version is the one produced once, here, from the merged source. --- docs/traceability.md | 106 +++++++++++++++++++++---------------------- 1 file changed, 53 insertions(+), 53 deletions(-) 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