diff --git a/core/dr-catalog/src/collections.rs b/core/dr-catalog/src/collections.rs index 0f4a5e0..bdcbe69 100644 --- a/core/dr-catalog/src/collections.rs +++ b/core/dr-catalog/src/collections.rs @@ -477,6 +477,23 @@ pub fn collections_for_image( Ok(rows) } +/// What kind of collection `id` is, or `None` if there is no such collection. +/// +/// Cheaper than reading the whole [`Collection`] where the caller only needs to +/// know whether member rows exist — the grid asks this to decide whether manual +/// position is a thing it can order by, and a smart collection has no +/// `collection_members` rows to carry one. +pub fn kind(conn: &Connection, id: CollectionId) -> Result, CatalogError> { + let found = conn + .query_row( + "SELECT kind FROM collections WHERE id = ?1 AND deleted = 0", + [id.0 as i64], + |r| r.get::<_, i64>(0), + ) + .optional()?; + Ok(found.map(CollectionKind::from_i64)) +} + /// A collection and everything beneath it, including itself. /// /// Used for cycle checks and for scoping the grid to a parent: selecting a diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs index a6b596c..ee5eb80 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -54,6 +54,7 @@ pub struct Settings { pub develop: DevelopSettings, pub import: ImportSettings, pub library: LibrarySettings, + pub faces: FaceSettings, } // --------------------------------------------------------------------------- @@ -109,6 +110,95 @@ impl Default for LibrarySettings { } } +// --------------------------------------------------------------------------- +// Faces +// --------------------------------------------------------------------------- + +/// TRACES: FR-CULL-9 | FR-CULL-10 +/// How hard the grouping pass tries to put two faces together, and how small a +/// group it will still call a person. +/// +/// # Why these are settings at all +/// +/// FR-CULL-10 is built on the clustering being wrong, and the two ways it is +/// wrong pull in opposite directions. Too loose and it welds siblings into one +/// person — the error the user cannot undo by hand. Too tight and a real person +/// arrives as nine fragments to be merged one at a time. The balance point is a +/// property of *the library*: how many people are in it, how closely related +/// they are, how far apart in years the photographs run. `dr_face`'s default was +/// measured on one 1,813-face reference library, and its own documentation says +/// so. +/// +/// So the numbers the tuning harness prints are put on the screen instead of +/// staying in a doc comment. Regrouping is re-runnable by construction — +/// suggestions are the pass's own output and confirmations are never touched — +/// which is what makes a value the user can move safe to offer. +/// +/// **Per device, not per library, like everything else in this file.** These +/// only decide what a *local* regrouping pass does; the people it produces are +/// catalog data and sync normally. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct FaceSettings { + /// Probability above which two groups are judged to be one person. + /// + /// A calibrated probability and never a bare similarity, which is + /// FR-CULL-9's standing rule for this subsystem — so the control the user + /// moves is in the same units as the confidence printed under every face. + pub merge_probability: f32, + /// The smallest group the pass will make a person out of. + /// + /// A group of one is a stray, and naming every stray fills the People rail + /// with noise that has to be dismissed one entry at a time before the real + /// clusters are visible. Raising it is how a user with a crowded library + /// says "only show me people I have actually photographed more than once". + /// + /// Groups that already carry a confirmation, a name, or an ignore are never + /// dropped by this, whatever their size: those are the user's judgements and + /// a display preference does not overrule them (FR-CULL-12). + pub min_group_size: u32, +} + +impl FaceSettings { + /// What `dr_face` was tuned to, restated here because `dr-types` sits below + /// the face engine and must not depend on it. + /// + /// `dr_ui::faces` holds the test that keeps the two numbers equal; a + /// default that drifted from the engine's would silently mean the settings + /// page's "default" marker pointed at a value the engine had abandoned. + pub const DEFAULT_MERGE_PROBABILITY: f32 = 0.80; + + /// The range the settings control offers. + /// + /// Not 0..1. Below about a half the pass stops building people and starts + /// melting them together — `dr_face::cluster`'s own measurements show the + /// group count *falling* while the grouped-face count rises, which is the + /// shape of over-merging — and above 0.95 almost nothing merges at all. A + /// slider whose ends are both useless spends most of its travel on answers + /// no one wants. + pub const PROBABILITY_RANGE: (f32, f32) = (0.50, 0.95); + + /// The range the smallest-group control offers. + /// + /// One means "show me every stray", which is a real thing to want while + /// hunting for a face the grouping missed. The top end is a judgement about + /// crowded libraries rather than a limit of the algorithm. + pub const GROUP_SIZE_RANGE: (u32, u32) = (1, 12); +} + +impl Default for FaceSettings { + fn default() -> Self { + Self { + merge_probability: Self::DEFAULT_MERGE_PROBABILITY, + // Two, because a group of one is not evidence of anything. This is + // the number the clustering pass carried as a literal before it was + // a setting, so an existing library regroups identically until the + // user moves it. + min_group_size: 2, + } + } +} + // --------------------------------------------------------------------------- // Import // --------------------------------------------------------------------------- @@ -884,6 +974,21 @@ impl Settings { pub fn sanitise(&mut self) { self.export.quality = self.export.quality.clamp(1, 100); + // Clamped to the range the slider offers rather than to 0..1. A + // probability of 0.02 is not a looser setting, it is a pass that welds + // the whole library into one person, and the file is hand-editable. + // NaN reaches here as a `f32` from JSON and survives every comparison, + // so it is answered explicitly instead of by `clamp`, which panics on + // it. + let (lo, hi) = FaceSettings::PROBABILITY_RANGE; + if !self.faces.merge_probability.is_finite() { + self.faces.merge_probability = FaceSettings::default().merge_probability; + } + self.faces.merge_probability = self.faces.merge_probability.clamp(lo, hi); + + let (lo, hi) = FaceSettings::GROUP_SIZE_RANGE; + self.faces.min_group_size = self.faces.min_group_size.clamp(lo, hi); + // Snapped to an offered count rather than clamped to a range. The // settings page lights the chip whose value matches, so a // hand-edited 40 would leave every chip dark and the page unable to @@ -1269,6 +1374,56 @@ mod tests { assert!(s.export.destination.is_empty()); } + #[test] + fn sanitise_pulls_a_hand_edited_merge_probability_into_range() { + let mut s = Settings::default(); + // The file is plain JSON in a config directory and a user is entitled + // to edit it. 0.02 is not a looser grouping, it is one person. + s.faces.merge_probability = 0.02; + s.sanitise(); + assert_eq!(s.faces.merge_probability, FaceSettings::PROBABILITY_RANGE.0); + + s.faces.merge_probability = 4.0; + s.sanitise(); + assert_eq!(s.faces.merge_probability, FaceSettings::PROBABILITY_RANGE.1); + } + + /// `f32::clamp` panics on a NaN bound and returns NaN for a NaN input, and + /// a NaN threshold silently groups nothing at all — every comparison + /// against it is false. JSON can carry one in. + #[test] + fn sanitise_answers_a_merge_probability_that_is_not_a_number() { + let mut s = Settings::default(); + s.faces.merge_probability = f32::NAN; + s.sanitise(); + assert_eq!( + s.faces.merge_probability, + FaceSettings::default().merge_probability + ); + } + + #[test] + fn sanitise_keeps_the_smallest_group_at_one_or_more() { + let mut s = Settings::default(); + // Zero would be a pass that made a person out of nothing. + s.faces.min_group_size = 0; + s.sanitise(); + assert_eq!(s.faces.min_group_size, FaceSettings::GROUP_SIZE_RANGE.0); + + s.faces.min_group_size = 9_000; + s.sanitise(); + assert_eq!(s.faces.min_group_size, FaceSettings::GROUP_SIZE_RANGE.1); + } + + /// A settings file written before the dials existed is missing the whole + /// section, and has to load as the defaults rather than as a refusal. + #[test] + fn a_file_from_before_the_grouping_dials_still_loads() { + let older = r#"{"export":{"quality":90}}"#; + let s: Settings = serde_json::from_str(older).expect("older file should parse"); + assert_eq!(s.faces, FaceSettings::default()); + } + #[test] fn sanitise_leaves_a_real_destination_alone() { let mut s = Settings::default(); diff --git a/docs/faces.md b/docs/faces.md index 6abfcfd..5fef824 100644 --- a/docs/faces.md +++ b/docs/faces.md @@ -788,6 +788,40 @@ It is **not** a merge threshold and must not become one. Uniqueness is relative, one named person would hand every stray face a 1. "Is this the same person at all" stays §8's question, and coherence is the half of the product that carries it. +### 9.2 The two numbers the user is allowed to move · 2026-08-29 + +The merge probability and the smallest group the pass will call a person are `FaceSettings` in +`dr-types`, edited from the People screen and saved per device beside the cache budgets. They were +constants: `dr_face::DEFAULT_MERGE_PROBABILITY` and a bare `< 2` in `dr_ui::faces::recluster`. + +**Why they had to become settings.** The default was tuned on one library — the table in +`dr_face::cluster`'s doc comment is 1,813 faces of one photographer's family — and the quantity it +optimises is a property of the population, not of the model. A library of one household at close +family resemblance and a library of two thousand strangers at a wedding want different answers, and +neither of them is the reference library. The doc comment already conceded the point ("this is a +*default*, not a constant of nature") and pointed at `face_index --tune` as the way to find a better +one; a photographer does not have a terminal. + +**Why moving them is safe, and why that is the reason there is no confirmation on it.** A regroup +writes only the *suggested* half. Confirmations, names and ignores enter as anchors and come back +unchanged (FR-CULL-10), so the pass is re-runnable by construction and a dial the user can move is +just that property being used. The smallest-group rule is applied only to groups the system invented: +a group carrying a person — confirmed, named or set aside — survives it whatever its size, because a +display preference does not overrule a judgement (FR-CULL-12). + +**Withdrawal, which the setting does not work without.** Raising the smallest group stops the pass +*creating* small groups; it does not by itself remove the ones a previous pass made, because those +still hold their suggestions, so they are not empty, so `prune_empty_unnamed` leaves them. The pass +therefore now releases every unanchored face it did not place — `faces::unassign` — before pruning. +Without that step the control appears to do nothing until the library is reindexed. + +**The preview.** `dr_ui::faces::preview_grouping` runs the same population through +`dr_face::cluster` and reports groups, faces grouped and largest group without opening a +transaction. It is `face_index --tune`'s row for one setting, on the user's own library, on a worker +thread. The line leads with the **group count** because that is the number that says which side of +the right setting you are on: it climbs as fragments are gathered into people and falls as separate +people start being welded together, while the grouped-face count rises straight through both. + --- ## 10. Catalog and jobs diff --git a/docs/traceability.md b/docs/traceability.md index c7afe7b..bdb31eb 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -10,7 +10,7 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| | Source files scanned | 299 | -| TRACES tags found | 961 | +| TRACES tags found | 977 | | Requirements defined | 179 | | Requirements covered | 120 | | **Coverage** | **67.0%** (120/179) | @@ -34,82 +34,82 @@ _None._ | ID | Tagged in | |---|---| | 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:201`](../core/dr-types/src/lib.rs#L201), [`core/dr-types/src/lib.rs:270`](../core/dr-types/src/lib.rs#L270), [`core/dr-types/src/lib.rs:303`](../core/dr-types/src/lib.rs#L303), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`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-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1201`](../ui/dr-ui/src/lib.rs#L1201), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1041`](../ui/dr-ui/ui/library.slint#L1041), [`ui/dr-ui/ui/library.slint:1208`](../ui/dr-ui/ui/library.slint#L1208), [`ui/dr-ui/ui/library.slint:876`](../ui/dr-ui/ui/library.slint#L876) | -| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1201`](../ui/dr-ui/src/lib.rs#L1201), [`ui/dr-ui/src/library.rs:175`](../ui/dr-ui/src/library.rs#L175), [`ui/dr-ui/src/library.rs:2491`](../ui/dr-ui/src/library.rs#L2491), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | +| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:206`](../core/dr-types/src/settings.rs#L206), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1205`](../ui/dr-ui/src/lib.rs#L1205), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1051`](../ui/dr-ui/ui/library.slint#L1051), [`ui/dr-ui/ui/library.slint:1218`](../ui/dr-ui/ui/library.slint#L1218), [`ui/dr-ui/ui/library.slint:886`](../ui/dr-ui/ui/library.slint#L886) | +| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1205`](../ui/dr-ui/src/lib.rs#L1205), [`ui/dr-ui/src/library.rs:175`](../ui/dr-ui/src/library.rs#L175), [`ui/dr-ui/src/library.rs:2491`](../ui/dr-ui/src/library.rs#L2491), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:119`](../core/dr-pipeline/src/sidecar.rs#L119) | | 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:720`](../core/dr-catalog/src/schema.rs#L720), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:457`](../core/dr-sync-nextcloud/src/lib.rs#L457), [`core/dr-sync/src/lib.rs:135`](../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:563`](../core/dr-sync/src/scan.rs#L563), [`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:1219`](../ui/dr-ui/src/collections_ui.rs#L1219), [`ui/dr-ui/src/collections_ui.rs:1975`](../ui/dr-ui/src/collections_ui.rs#L1975), [`ui/dr-ui/src/library.rs:175`](../ui/dr-ui/src/library.rs#L175), [`ui/dr-ui/src/library.rs:192`](../ui/dr-ui/src/library.rs#L192), [`ui/dr-ui/src/library.rs:241`](../ui/dr-ui/src/library.rs#L241), [`ui/dr-ui/src/library.rs:3987`](../ui/dr-ui/src/library.rs#L3987), [`ui/dr-ui/src/library.rs:4021`](../ui/dr-ui/src/library.rs#L4021), [`ui/dr-ui/src/library_ui.rs:190`](../ui/dr-ui/src/library_ui.rs#L190), [`ui/dr-ui/src/library_ui.rs:778`](../ui/dr-ui/src/library_ui.rs#L778), [`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-15 | [`core/dr-catalog/src/schema.rs:720`](../core/dr-catalog/src/schema.rs#L720), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:457`](../core/dr-sync-nextcloud/src/lib.rs#L457), [`core/dr-sync/src/lib.rs:135`](../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:563`](../core/dr-sync/src/scan.rs#L563), [`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:1307`](../ui/dr-ui/src/collections_ui.rs#L1307), [`ui/dr-ui/src/collections_ui.rs:2221`](../ui/dr-ui/src/collections_ui.rs#L2221), [`ui/dr-ui/src/library.rs:175`](../ui/dr-ui/src/library.rs#L175), [`ui/dr-ui/src/library.rs:192`](../ui/dr-ui/src/library.rs#L192), [`ui/dr-ui/src/library.rs:241`](../ui/dr-ui/src/library.rs#L241), [`ui/dr-ui/src/library.rs:3992`](../ui/dr-ui/src/library.rs#L3992), [`ui/dr-ui/src/library.rs:4026`](../ui/dr-ui/src/library.rs#L4026), [`ui/dr-ui/src/library_ui.rs:190`](../ui/dr-ui/src/library_ui.rs#L190), [`ui/dr-ui/src/library_ui.rs:778`](../ui/dr-ui/src/library_ui.rs#L778), [`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:54`](../core/dr-types/src/lib.rs#L54), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:46`](../platform/dr-plat/src/storage.rs#L46) | | 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), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library.rs:2964`](../ui/dr-ui/src/library.rs#L2964), [`ui/dr-ui/src/library.rs:3472`](../ui/dr-ui/src/library.rs#L3472), [`ui/dr-ui/src/library_ui.rs:172`](../ui/dr-ui/src/library_ui.rs#L172), [`ui/dr-ui/src/library_ui.rs:3709`](../ui/dr-ui/src/library_ui.rs#L3709), [`ui/dr-ui/src/library_ui.rs:4708`](../ui/dr-ui/src/library_ui.rs#L4708), [`ui/dr-ui/ui/app.slint:365`](../ui/dr-ui/ui/app.slint#L365), [`ui/dr-ui/ui/settings.slint:384`](../ui/dr-ui/ui/settings.slint#L384), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | +| 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), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library.rs:2964`](../ui/dr-ui/src/library.rs#L2964), [`ui/dr-ui/src/library.rs:3472`](../ui/dr-ui/src/library.rs#L3472), [`ui/dr-ui/src/library_ui.rs:172`](../ui/dr-ui/src/library_ui.rs#L172), [`ui/dr-ui/src/library_ui.rs:3709`](../ui/dr-ui/src/library_ui.rs#L3709), [`ui/dr-ui/src/library_ui.rs:4715`](../ui/dr-ui/src/library_ui.rs#L4715), [`ui/dr-ui/ui/app.slint:374`](../ui/dr-ui/ui/app.slint#L374), [`ui/dr-ui/ui/settings.slint:384`](../ui/dr-ui/ui/settings.slint#L384), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | | 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), [`core/dr-catalog/src/schema.rs:330`](../core/dr-catalog/src/schema.rs#L330), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:222`](../ui/dr-ui/src/library.rs#L222), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.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:1138`](../core/dr-catalog/src/schema.rs#L1138), [`core/dr-catalog/src/schema.rs:635`](../core/dr-catalog/src/schema.rs#L635), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:136`](../core/dr-pipeline/src/sidecar.rs#L136), [`ui/dr-ui/src/collections_ui.rs:1558`](../ui/dr-ui/src/collections_ui.rs#L1558), [`ui/dr-ui/src/collections_ui.rs:1577`](../ui/dr-ui/src/collections_ui.rs#L1577), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:4035`](../ui/dr-ui/src/library.rs#L4035), [`ui/dr-ui/src/library_ui.rs:6533`](../ui/dr-ui/src/library_ui.rs#L6533), [`ui/dr-ui/src/library_ui.rs:653`](../ui/dr-ui/src/library_ui.rs#L653), [`ui/dr-ui/src/library_ui.rs:6544`](../ui/dr-ui/src/library_ui.rs#L6544), [`ui/dr-ui/src/library_ui.rs:6557`](../ui/dr-ui/src/library_ui.rs#L6557), [`ui/dr-ui/src/library_ui.rs:6572`](../ui/dr-ui/src/library_ui.rs#L6572), [`ui/dr-ui/src/library_ui.rs:6581`](../ui/dr-ui/src/library_ui.rs#L6581), [`ui/dr-ui/ui/app.slint:310`](../ui/dr-ui/ui/app.slint#L310), [`ui/dr-ui/ui/app.slint:491`](../ui/dr-ui/ui/app.slint#L491), [`ui/dr-ui/ui/library.slint:1257`](../ui/dr-ui/ui/library.slint#L1257), [`ui/dr-ui/ui/library.slint:1260`](../ui/dr-ui/ui/library.slint#L1260), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:869`](../ui/dr-ui/ui/library.slint#L869), [`ui/dr-ui/ui/library.slint:921`](../ui/dr-ui/ui/library.slint#L921) | -| 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:635`](../core/dr-catalog/src/schema.rs#L635), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:246`](../ui/dr-ui/src/library.rs#L246), [`ui/dr-ui/src/library.rs:4283`](../ui/dr-ui/src/library.rs#L4283), [`ui/dr-ui/src/library_ui.rs:337`](../ui/dr-ui/src/library_ui.rs#L337), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:5347`](../ui/dr-ui/src/library_ui.rs#L5347), [`ui/dr-ui/src/library_ui.rs:5400`](../ui/dr-ui/src/library_ui.rs#L5400), [`ui/dr-ui/src/library_ui.rs:6673`](../ui/dr-ui/src/library_ui.rs#L6673), [`ui/dr-ui/ui/app.slint:313`](../ui/dr-ui/ui/app.slint#L313), [`ui/dr-ui/ui/app.slint:491`](../ui/dr-ui/ui/app.slint#L491), [`ui/dr-ui/ui/app.slint:767`](../ui/dr-ui/ui/app.slint#L767), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1333`](../ui/dr-ui/ui/library.slint#L1333), [`ui/dr-ui/ui/library.slint:921`](../ui/dr-ui/ui/library.slint#L921), [`ui/dr-ui/ui/settings.slint:147`](../ui/dr-ui/ui/settings.slint#L147) | -| 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:1673`](../ui/dr-ui/src/collections_ui.rs#L1673), [`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/src/library_ui.rs:3571`](../ui/dr-ui/src/library_ui.rs#L3571), [`ui/dr-ui/src/library_ui.rs:4298`](../ui/dr-ui/src/library_ui.rs#L4298), [`ui/dr-ui/ui/app.slint:486`](../ui/dr-ui/ui/app.slint#L486), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2600`](../ui/dr-ui/ui/library.slint#L2600), [`ui/dr-ui/ui/library.slint:892`](../ui/dr-ui/ui/library.slint#L892), [`ui/dr-ui/ui/library.slint:911`](../ui/dr-ui/ui/library.slint#L911) | -| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:346`](../core/dr-pipeline/src/graph.rs#L346), [`core/dr-pipeline/src/graph.rs:385`](../core/dr-pipeline/src/graph.rs#L385), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1637`](../core/dr-pipeline/src/sidecar.rs#L1637), [`core/dr-pipeline/src/sidecar.rs:93`](../core/dr-pipeline/src/sidecar.rs#L93), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3720`](../ui/dr-ui/src/develop.rs#L3720), [`ui/dr-ui/src/develop.rs:3749`](../ui/dr-ui/src/develop.rs#L3749), [`ui/dr-ui/src/export.rs:750`](../ui/dr-ui/src/export.rs#L750), [`ui/dr-ui/src/lib.rs:1480`](../ui/dr-ui/src/lib.rs#L1480), [`ui/dr-ui/src/lib.rs:1937`](../ui/dr-ui/src/lib.rs#L1937), [`ui/dr-ui/src/lib.rs:2072`](../ui/dr-ui/src/lib.rs#L2072), [`ui/dr-ui/src/lib.rs:563`](../ui/dr-ui/src/lib.rs#L563), [`ui/dr-ui/src/lib.rs:988`](../ui/dr-ui/src/lib.rs#L988), [`ui/dr-ui/src/library.rs:1903`](../ui/dr-ui/src/library.rs#L1903), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library.rs:539`](../ui/dr-ui/src/library.rs#L539), [`ui/dr-ui/src/library.rs:576`](../ui/dr-ui/src/library.rs#L576), [`ui/dr-ui/src/library.rs:823`](../ui/dr-ui/src/library.rs#L823), [`ui/dr-ui/src/library_ui.rs:5000`](../ui/dr-ui/src/library_ui.rs#L5000), [`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:692`](../core/dr-catalog/src/schema.rs#L692), [`core/dr-catalog/src/walk.rs:1022`](../core/dr-catalog/src/walk.rs#L1022), [`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:728`](../core/dr-catalog/src/walk.rs#L728), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/error.rs:113`](../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-sync/src/scan.rs:175`](../core/dr-sync/src/scan.rs#L175), [`core/dr-sync/src/scan.rs:464`](../core/dr-sync/src/scan.rs#L464), [`core/dr-types/src/lib.rs:120`](../core/dr-types/src/lib.rs#L120), [`ui/dr-ui/src/develop.rs:3147`](../ui/dr-ui/src/develop.rs#L3147), [`ui/dr-ui/src/library.rs:1222`](../ui/dr-ui/src/library.rs#L1222), [`ui/dr-ui/src/library.rs:1275`](../ui/dr-ui/src/library.rs#L1275), [`ui/dr-ui/src/library.rs:1306`](../ui/dr-ui/src/library.rs#L1306), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library.rs:161`](../ui/dr-ui/src/library.rs#L161), [`ui/dr-ui/src/library.rs:1863`](../ui/dr-ui/src/library.rs#L1863), [`ui/dr-ui/src/library.rs:1939`](../ui/dr-ui/src/library.rs#L1939), [`ui/dr-ui/src/library.rs:266`](../ui/dr-ui/src/library.rs#L266), [`ui/dr-ui/src/library.rs:4391`](../ui/dr-ui/src/library.rs#L4391), [`ui/dr-ui/src/library.rs:576`](../ui/dr-ui/src/library.rs#L576), [`ui/dr-ui/src/library.rs:807`](../ui/dr-ui/src/library.rs#L807), [`ui/dr-ui/src/library.rs:823`](../ui/dr-ui/src/library.rs#L823), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1045`](../ui/dr-ui/src/library_ui.rs#L1045), [`ui/dr-ui/src/library_ui.rs:1636`](../ui/dr-ui/src/library_ui.rs#L1636), [`ui/dr-ui/src/library_ui.rs:1680`](../ui/dr-ui/src/library_ui.rs#L1680), [`ui/dr-ui/src/library_ui.rs:1696`](../ui/dr-ui/src/library_ui.rs#L1696), [`ui/dr-ui/src/library_ui.rs:1790`](../ui/dr-ui/src/library_ui.rs#L1790), [`ui/dr-ui/src/library_ui.rs:232`](../ui/dr-ui/src/library_ui.rs#L232), [`ui/dr-ui/src/library_ui.rs:2414`](../ui/dr-ui/src/library_ui.rs#L2414), [`ui/dr-ui/src/library_ui.rs:265`](../ui/dr-ui/src/library_ui.rs#L265), [`ui/dr-ui/src/library_ui.rs:273`](../ui/dr-ui/src/library_ui.rs#L273), [`ui/dr-ui/src/library_ui.rs:2852`](../ui/dr-ui/src/library_ui.rs#L2852), [`ui/dr-ui/src/library_ui.rs:3074`](../ui/dr-ui/src/library_ui.rs#L3074), [`ui/dr-ui/src/library_ui.rs:3352`](../ui/dr-ui/src/library_ui.rs#L3352), [`ui/dr-ui/src/library_ui.rs:3440`](../ui/dr-ui/src/library_ui.rs#L3440), [`ui/dr-ui/src/library_ui.rs:3622`](../ui/dr-ui/src/library_ui.rs#L3622), [`ui/dr-ui/src/library_ui.rs:3736`](../ui/dr-ui/src/library_ui.rs#L3736), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:526`](../ui/dr-ui/src/library_ui.rs#L526), [`ui/dr-ui/src/library_ui.rs:5445`](../ui/dr-ui/src/library_ui.rs#L5445), [`ui/dr-ui/src/library_ui.rs:5560`](../ui/dr-ui/src/library_ui.rs#L5560), [`ui/dr-ui/src/presets.rs:337`](../ui/dr-ui/src/presets.rs#L337), [`ui/dr-ui/src/presets.rs:349`](../ui/dr-ui/src/presets.rs#L349), [`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:1138`](../core/dr-catalog/src/schema.rs#L1138), [`core/dr-catalog/src/schema.rs:635`](../core/dr-catalog/src/schema.rs#L635), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:136`](../core/dr-pipeline/src/sidecar.rs#L136), [`ui/dr-ui/src/collections_ui.rs:1650`](../ui/dr-ui/src/collections_ui.rs#L1650), [`ui/dr-ui/src/collections_ui.rs:1756`](../ui/dr-ui/src/collections_ui.rs#L1756), [`ui/dr-ui/src/collections_ui.rs:1805`](../ui/dr-ui/src/collections_ui.rs#L1805), [`ui/dr-ui/src/collections_ui.rs:254`](../ui/dr-ui/src/collections_ui.rs#L254), [`ui/dr-ui/src/collections_ui.rs:383`](../ui/dr-ui/src/collections_ui.rs#L383), [`ui/dr-ui/src/collections_ui.rs:90`](../ui/dr-ui/src/collections_ui.rs#L90), [`ui/dr-ui/src/library.rs:4040`](../ui/dr-ui/src/library.rs#L4040), [`ui/dr-ui/src/library_ui.rs:653`](../ui/dr-ui/src/library_ui.rs#L653), [`ui/dr-ui/src/library_ui.rs:6642`](../ui/dr-ui/src/library_ui.rs#L6642), [`ui/dr-ui/src/library_ui.rs:6653`](../ui/dr-ui/src/library_ui.rs#L6653), [`ui/dr-ui/src/library_ui.rs:6666`](../ui/dr-ui/src/library_ui.rs#L6666), [`ui/dr-ui/src/library_ui.rs:6681`](../ui/dr-ui/src/library_ui.rs#L6681), [`ui/dr-ui/src/library_ui.rs:6690`](../ui/dr-ui/src/library_ui.rs#L6690), [`ui/dr-ui/ui/app.slint:310`](../ui/dr-ui/ui/app.slint#L310), [`ui/dr-ui/ui/app.slint:509`](../ui/dr-ui/ui/app.slint#L509), [`ui/dr-ui/ui/library.slint:1275`](../ui/dr-ui/ui/library.slint#L1275), [`ui/dr-ui/ui/library.slint:1299`](../ui/dr-ui/ui/library.slint#L1299), [`ui/dr-ui/ui/library.slint:1341`](../ui/dr-ui/ui/library.slint#L1341), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:931`](../ui/dr-ui/ui/library.slint#L931) | +| 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:635`](../core/dr-catalog/src/schema.rs#L635), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:64`](../core/dr-types/src/settings.rs#L64), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:246`](../ui/dr-ui/src/library.rs#L246), [`ui/dr-ui/src/library.rs:4437`](../ui/dr-ui/src/library.rs#L4437), [`ui/dr-ui/src/library_ui.rs:337`](../ui/dr-ui/src/library_ui.rs#L337), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:5382`](../ui/dr-ui/src/library_ui.rs#L5382), [`ui/dr-ui/src/library_ui.rs:5435`](../ui/dr-ui/src/library_ui.rs#L5435), [`ui/dr-ui/src/library_ui.rs:6782`](../ui/dr-ui/src/library_ui.rs#L6782), [`ui/dr-ui/ui/app.slint:322`](../ui/dr-ui/ui/app.slint#L322), [`ui/dr-ui/ui/app.slint:509`](../ui/dr-ui/ui/app.slint#L509), [`ui/dr-ui/ui/app.slint:789`](../ui/dr-ui/ui/app.slint#L789), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1434`](../ui/dr-ui/ui/library.slint#L1434), [`ui/dr-ui/ui/library.slint:931`](../ui/dr-ui/ui/library.slint#L931), [`ui/dr-ui/ui/settings.slint:147`](../ui/dr-ui/ui/settings.slint#L147) | +| 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:1669`](../ui/dr-ui/src/collections_ui.rs#L1669), [`ui/dr-ui/src/collections_ui.rs:1919`](../ui/dr-ui/src/collections_ui.rs#L1919), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/collections_ui.rs:987`](../ui/dr-ui/src/collections_ui.rs#L987), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:4242`](../ui/dr-ui/src/library.rs#L4242), [`ui/dr-ui/src/library.rs:4268`](../ui/dr-ui/src/library.rs#L4268), [`ui/dr-ui/src/library.rs:4323`](../ui/dr-ui/src/library.rs#L4323), [`ui/dr-ui/src/library_ui.rs:3571`](../ui/dr-ui/src/library_ui.rs#L3571), [`ui/dr-ui/src/library_ui.rs:4298`](../ui/dr-ui/src/library_ui.rs#L4298), [`ui/dr-ui/ui/app.slint:313`](../ui/dr-ui/ui/app.slint#L313), [`ui/dr-ui/ui/app.slint:504`](../ui/dr-ui/ui/app.slint#L504), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2871`](../ui/dr-ui/ui/library.slint#L2871), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902), [`ui/dr-ui/ui/library.slint:921`](../ui/dr-ui/ui/library.slint#L921) | +| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:346`](../core/dr-pipeline/src/graph.rs#L346), [`core/dr-pipeline/src/graph.rs:385`](../core/dr-pipeline/src/graph.rs#L385), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1637`](../core/dr-pipeline/src/sidecar.rs#L1637), [`core/dr-pipeline/src/sidecar.rs:93`](../core/dr-pipeline/src/sidecar.rs#L93), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3720`](../ui/dr-ui/src/develop.rs#L3720), [`ui/dr-ui/src/develop.rs:3749`](../ui/dr-ui/src/develop.rs#L3749), [`ui/dr-ui/src/export.rs:750`](../ui/dr-ui/src/export.rs#L750), [`ui/dr-ui/src/lib.rs:1484`](../ui/dr-ui/src/lib.rs#L1484), [`ui/dr-ui/src/lib.rs:1941`](../ui/dr-ui/src/lib.rs#L1941), [`ui/dr-ui/src/lib.rs:2076`](../ui/dr-ui/src/lib.rs#L2076), [`ui/dr-ui/src/lib.rs:563`](../ui/dr-ui/src/lib.rs#L563), [`ui/dr-ui/src/lib.rs:988`](../ui/dr-ui/src/lib.rs#L988), [`ui/dr-ui/src/library.rs:1903`](../ui/dr-ui/src/library.rs#L1903), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library.rs:539`](../ui/dr-ui/src/library.rs#L539), [`ui/dr-ui/src/library.rs:576`](../ui/dr-ui/src/library.rs#L576), [`ui/dr-ui/src/library.rs:823`](../ui/dr-ui/src/library.rs#L823), [`ui/dr-ui/src/library_ui.rs:5007`](../ui/dr-ui/src/library_ui.rs#L5007), [`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:692`](../core/dr-catalog/src/schema.rs#L692), [`core/dr-catalog/src/walk.rs:1022`](../core/dr-catalog/src/walk.rs#L1022), [`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:728`](../core/dr-catalog/src/walk.rs#L728), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/error.rs:113`](../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-sync/src/scan.rs:175`](../core/dr-sync/src/scan.rs#L175), [`core/dr-sync/src/scan.rs:464`](../core/dr-sync/src/scan.rs#L464), [`core/dr-types/src/lib.rs:120`](../core/dr-types/src/lib.rs#L120), [`ui/dr-ui/src/develop.rs:3147`](../ui/dr-ui/src/develop.rs#L3147), [`ui/dr-ui/src/library.rs:1222`](../ui/dr-ui/src/library.rs#L1222), [`ui/dr-ui/src/library.rs:1275`](../ui/dr-ui/src/library.rs#L1275), [`ui/dr-ui/src/library.rs:1306`](../ui/dr-ui/src/library.rs#L1306), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library.rs:161`](../ui/dr-ui/src/library.rs#L161), [`ui/dr-ui/src/library.rs:1863`](../ui/dr-ui/src/library.rs#L1863), [`ui/dr-ui/src/library.rs:1939`](../ui/dr-ui/src/library.rs#L1939), [`ui/dr-ui/src/library.rs:266`](../ui/dr-ui/src/library.rs#L266), [`ui/dr-ui/src/library.rs:4545`](../ui/dr-ui/src/library.rs#L4545), [`ui/dr-ui/src/library.rs:576`](../ui/dr-ui/src/library.rs#L576), [`ui/dr-ui/src/library.rs:807`](../ui/dr-ui/src/library.rs#L807), [`ui/dr-ui/src/library.rs:823`](../ui/dr-ui/src/library.rs#L823), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1045`](../ui/dr-ui/src/library_ui.rs#L1045), [`ui/dr-ui/src/library_ui.rs:1636`](../ui/dr-ui/src/library_ui.rs#L1636), [`ui/dr-ui/src/library_ui.rs:1680`](../ui/dr-ui/src/library_ui.rs#L1680), [`ui/dr-ui/src/library_ui.rs:1696`](../ui/dr-ui/src/library_ui.rs#L1696), [`ui/dr-ui/src/library_ui.rs:1790`](../ui/dr-ui/src/library_ui.rs#L1790), [`ui/dr-ui/src/library_ui.rs:232`](../ui/dr-ui/src/library_ui.rs#L232), [`ui/dr-ui/src/library_ui.rs:2414`](../ui/dr-ui/src/library_ui.rs#L2414), [`ui/dr-ui/src/library_ui.rs:265`](../ui/dr-ui/src/library_ui.rs#L265), [`ui/dr-ui/src/library_ui.rs:273`](../ui/dr-ui/src/library_ui.rs#L273), [`ui/dr-ui/src/library_ui.rs:2852`](../ui/dr-ui/src/library_ui.rs#L2852), [`ui/dr-ui/src/library_ui.rs:3074`](../ui/dr-ui/src/library_ui.rs#L3074), [`ui/dr-ui/src/library_ui.rs:3352`](../ui/dr-ui/src/library_ui.rs#L3352), [`ui/dr-ui/src/library_ui.rs:3440`](../ui/dr-ui/src/library_ui.rs#L3440), [`ui/dr-ui/src/library_ui.rs:3622`](../ui/dr-ui/src/library_ui.rs#L3622), [`ui/dr-ui/src/library_ui.rs:3736`](../ui/dr-ui/src/library_ui.rs#L3736), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:526`](../ui/dr-ui/src/library_ui.rs#L526), [`ui/dr-ui/src/library_ui.rs:5480`](../ui/dr-ui/src/library_ui.rs#L5480), [`ui/dr-ui/src/library_ui.rs:5595`](../ui/dr-ui/src/library_ui.rs#L5595), [`ui/dr-ui/src/presets.rs:337`](../ui/dr-ui/src/presets.rs#L337), [`ui/dr-ui/src/presets.rs:349`](../ui/dr-ui/src/presets.rs#L349), [`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:121`](../core/dr-decode/src/preview.rs#L121) | -| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:369`](../core/dr-catalog/src/schema.rs#L369), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/develop.rs:120`](../ui/dr-ui/src/develop.rs#L120), [`ui/dr-ui/src/develop.rs:129`](../ui/dr-ui/src/develop.rs#L129), [`ui/dr-ui/src/develop.rs:1944`](../ui/dr-ui/src/develop.rs#L1944), [`ui/dr-ui/src/develop.rs:195`](../ui/dr-ui/src/develop.rs#L195), [`ui/dr-ui/src/develop.rs:688`](../ui/dr-ui/src/develop.rs#L688), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2044`](../ui/dr-ui/src/lib.rs#L2044), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | +| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:369`](../core/dr-catalog/src/schema.rs#L369), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/develop.rs:120`](../ui/dr-ui/src/develop.rs#L120), [`ui/dr-ui/src/develop.rs:129`](../ui/dr-ui/src/develop.rs#L129), [`ui/dr-ui/src/develop.rs:1944`](../ui/dr-ui/src/develop.rs#L1944), [`ui/dr-ui/src/develop.rs:195`](../ui/dr-ui/src/develop.rs#L195), [`ui/dr-ui/src/develop.rs:688`](../ui/dr-ui/src/develop.rs#L688), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2048`](../ui/dr-ui/src/lib.rs#L2048), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:287`](../ui/dr-ui/src/library.rs#L287), [`ui/dr-ui/src/library.rs:317`](../ui/dr-ui/src/library.rs#L317), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:369`](../core/dr-catalog/src/schema.rs#L369), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463) | -| FR-CULL-3 | [`core/dr-gpu/src/focus.rs:154`](../core/dr-gpu/src/focus.rs#L154), [`core/dr-gpu/src/focus.rs:186`](../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`ui/dr-ui/src/develop.rs:2930`](../ui/dr-ui/src/develop.rs#L2930), [`ui/dr-ui/src/develop.rs:2941`](../ui/dr-ui/src/develop.rs#L2941), [`ui/dr-ui/src/develop.rs:2947`](../ui/dr-ui/src/develop.rs#L2947), [`ui/dr-ui/src/develop.rs:2964`](../ui/dr-ui/src/develop.rs#L2964), [`ui/dr-ui/src/develop.rs:726`](../ui/dr-ui/src/develop.rs#L726), [`ui/dr-ui/src/develop.rs:732`](../ui/dr-ui/src/develop.rs#L732), [`ui/dr-ui/src/lib.rs:1519`](../ui/dr-ui/src/lib.rs#L1519), [`ui/dr-ui/src/lib.rs:1597`](../ui/dr-ui/src/lib.rs#L1597), [`ui/dr-ui/src/lib.rs:1670`](../ui/dr-ui/src/lib.rs#L1670), [`ui/dr-ui/src/lib.rs:1704`](../ui/dr-ui/src/lib.rs#L1704), [`ui/dr-ui/src/lib.rs:2947`](../ui/dr-ui/src/lib.rs#L2947), [`ui/dr-ui/src/lib.rs:323`](../ui/dr-ui/src/lib.rs#L323), [`ui/dr-ui/src/peaking.rs:1`](../ui/dr-ui/src/peaking.rs#L1), [`ui/dr-ui/ui/app.slint:1679`](../ui/dr-ui/ui/app.slint#L1679), [`ui/dr-ui/ui/app.slint:2224`](../ui/dr-ui/ui/app.slint#L2224), [`ui/dr-ui/ui/app.slint:75`](../ui/dr-ui/ui/app.slint#L75), [`ui/dr-ui/ui/peaking.slint:1`](../ui/dr-ui/ui/peaking.slint#L1), [`ui/dr-ui/ui/peaking.slint:25`](../ui/dr-ui/ui/peaking.slint#L25), [`ui/dr-ui/ui/peaking.slint:56`](../ui/dr-ui/ui/peaking.slint#L56) | +| FR-CULL-3 | [`core/dr-gpu/src/focus.rs:154`](../core/dr-gpu/src/focus.rs#L154), [`core/dr-gpu/src/focus.rs:186`](../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`ui/dr-ui/src/develop.rs:2930`](../ui/dr-ui/src/develop.rs#L2930), [`ui/dr-ui/src/develop.rs:2941`](../ui/dr-ui/src/develop.rs#L2941), [`ui/dr-ui/src/develop.rs:2947`](../ui/dr-ui/src/develop.rs#L2947), [`ui/dr-ui/src/develop.rs:2964`](../ui/dr-ui/src/develop.rs#L2964), [`ui/dr-ui/src/develop.rs:726`](../ui/dr-ui/src/develop.rs#L726), [`ui/dr-ui/src/develop.rs:732`](../ui/dr-ui/src/develop.rs#L732), [`ui/dr-ui/src/lib.rs:1523`](../ui/dr-ui/src/lib.rs#L1523), [`ui/dr-ui/src/lib.rs:1601`](../ui/dr-ui/src/lib.rs#L1601), [`ui/dr-ui/src/lib.rs:1674`](../ui/dr-ui/src/lib.rs#L1674), [`ui/dr-ui/src/lib.rs:1708`](../ui/dr-ui/src/lib.rs#L1708), [`ui/dr-ui/src/lib.rs:2951`](../ui/dr-ui/src/lib.rs#L2951), [`ui/dr-ui/src/lib.rs:323`](../ui/dr-ui/src/lib.rs#L323), [`ui/dr-ui/src/peaking.rs:1`](../ui/dr-ui/src/peaking.rs#L1), [`ui/dr-ui/ui/app.slint:1716`](../ui/dr-ui/ui/app.slint#L1716), [`ui/dr-ui/ui/app.slint:2261`](../ui/dr-ui/ui/app.slint#L2261), [`ui/dr-ui/ui/app.slint:75`](../ui/dr-ui/ui/app.slint#L75), [`ui/dr-ui/ui/peaking.slint:1`](../ui/dr-ui/ui/peaking.slint#L1), [`ui/dr-ui/ui/peaking.slint:25`](../ui/dr-ui/ui/peaking.slint#L25), [`ui/dr-ui/ui/peaking.slint:56`](../ui/dr-ui/ui/peaking.slint#L56) | | FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:136`](../core/dr-pipeline/src/sidecar.rs#L136), [`ui/dr-ui/src/library.rs:246`](../ui/dr-ui/src/library.rs#L246), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492) | -| FR-CULL-5 | [`core/dr-catalog/src/bursts.rs:1`](../core/dr-catalog/src/bursts.rs#L1), [`core/dr-catalog/src/schema.rs:412`](../core/dr-catalog/src/schema.rs#L412), [`ui/dr-ui/src/bursts.rs:1`](../ui/dr-ui/src/bursts.rs#L1), [`ui/dr-ui/src/library.rs:202`](../ui/dr-ui/src/library.rs#L202), [`ui/dr-ui/src/library.rs:4992`](../ui/dr-ui/src/library.rs#L4992) | +| FR-CULL-5 | [`core/dr-catalog/src/bursts.rs:1`](../core/dr-catalog/src/bursts.rs#L1), [`core/dr-catalog/src/schema.rs:412`](../core/dr-catalog/src/schema.rs#L412), [`ui/dr-ui/src/bursts.rs:1`](../ui/dr-ui/src/bursts.rs#L1), [`ui/dr-ui/src/library.rs:202`](../ui/dr-ui/src/library.rs#L202), [`ui/dr-ui/src/library.rs:5146`](../ui/dr-ui/src/library.rs#L5146) | | FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:480`](../core/dr-catalog/src/schema.rs#L480), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2965`](../ui/dr-ui/src/library.rs#L2965), [`ui/dr-ui/src/library.rs:3069`](../ui/dr-ui/src/library.rs#L3069), [`ui/dr-ui/ui/settings.slint:416`](../ui/dr-ui/ui/settings.slint#L416), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) | -| FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1) | +| FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:260`](../ui/dr-ui/ui/identity.slint#L260) | | FR-DEV-1 | [`core/dr-pipeline/src/graph.rs:1`](../core/dr-pipeline/src/graph.rs#L1), [`core/dr-pipeline/src/sidecar.rs:1`](../core/dr-pipeline/src/sidecar.rs#L1) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2203`](../core/dr-gpu/src/adjust.rs#L2203), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:177`](../core/dr-pipeline/src/framing.rs#L177), [`core/dr-pipeline/src/framing.rs:234`](../core/dr-pipeline/src/framing.rs#L234), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-pipeline/src/framing.rs:743`](../core/dr-pipeline/src/framing.rs#L743), [`core/dr-pipeline/src/graph.rs:170`](../core/dr-pipeline/src/graph.rs#L170), [`core/dr-pipeline/src/graph.rs:578`](../core/dr-pipeline/src/graph.rs#L578), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:157`](../core/dr-pipeline/src/sidecar.rs#L157), [`core/dr-pipeline/src/sidecar.rs:1637`](../core/dr-pipeline/src/sidecar.rs#L1637), [`core/dr-pipeline/src/sidecar.rs:1697`](../core/dr-pipeline/src/sidecar.rs#L1697), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:102`](../ui/dr-ui/src/develop.rs#L102), [`ui/dr-ui/src/develop.rs:1448`](../ui/dr-ui/src/develop.rs#L1448), [`ui/dr-ui/src/develop.rs:164`](../ui/dr-ui/src/develop.rs#L164), [`ui/dr-ui/src/develop.rs:1926`](../ui/dr-ui/src/develop.rs#L1926), [`ui/dr-ui/src/develop.rs:1944`](../ui/dr-ui/src/develop.rs#L1944), [`ui/dr-ui/src/develop.rs:1958`](../ui/dr-ui/src/develop.rs#L1958), [`ui/dr-ui/src/develop.rs:1980`](../ui/dr-ui/src/develop.rs#L1980), [`ui/dr-ui/src/develop.rs:2126`](../ui/dr-ui/src/develop.rs#L2126), [`ui/dr-ui/src/develop.rs:2224`](../ui/dr-ui/src/develop.rs#L2224), [`ui/dr-ui/src/develop.rs:327`](../ui/dr-ui/src/develop.rs#L327), [`ui/dr-ui/src/develop.rs:3418`](../ui/dr-ui/src/develop.rs#L3418), [`ui/dr-ui/src/develop.rs:3448`](../ui/dr-ui/src/develop.rs#L3448), [`ui/dr-ui/src/develop.rs:3514`](../ui/dr-ui/src/develop.rs#L3514), [`ui/dr-ui/src/develop.rs:3528`](../ui/dr-ui/src/develop.rs#L3528), [`ui/dr-ui/src/develop.rs:364`](../ui/dr-ui/src/develop.rs#L364), [`ui/dr-ui/src/develop.rs:3720`](../ui/dr-ui/src/develop.rs#L3720), [`ui/dr-ui/src/develop.rs:4380`](../ui/dr-ui/src/develop.rs#L4380), [`ui/dr-ui/src/develop.rs:4434`](../ui/dr-ui/src/develop.rs#L4434), [`ui/dr-ui/src/develop.rs:4478`](../ui/dr-ui/src/develop.rs#L4478), [`ui/dr-ui/src/develop.rs:4528`](../ui/dr-ui/src/develop.rs#L4528), [`ui/dr-ui/src/develop.rs:587`](../ui/dr-ui/src/develop.rs#L587), [`ui/dr-ui/src/develop.rs:648`](../ui/dr-ui/src/develop.rs#L648), [`ui/dr-ui/src/develop.rs:746`](../ui/dr-ui/src/develop.rs#L746), [`ui/dr-ui/src/develop.rs:793`](../ui/dr-ui/src/develop.rs#L793), [`ui/dr-ui/src/develop.rs:822`](../ui/dr-ui/src/develop.rs#L822), [`ui/dr-ui/src/lib.rs:1578`](../ui/dr-ui/src/lib.rs#L1578), [`ui/dr-ui/src/lib.rs:2340`](../ui/dr-ui/src/lib.rs#L2340), [`ui/dr-ui/src/lib.rs:2536`](../ui/dr-ui/src/lib.rs#L2536), [`ui/dr-ui/src/lib.rs:2708`](../ui/dr-ui/src/lib.rs#L2708), [`ui/dr-ui/src/lib.rs:2772`](../ui/dr-ui/src/lib.rs#L2772), [`ui/dr-ui/src/lib.rs:2826`](../ui/dr-ui/src/lib.rs#L2826), [`ui/dr-ui/src/lib.rs:329`](../ui/dr-ui/src/lib.rs#L329), [`ui/dr-ui/src/lib.rs:372`](../ui/dr-ui/src/lib.rs#L372), [`ui/dr-ui/src/lib.rs:395`](../ui/dr-ui/src/lib.rs#L395), [`ui/dr-ui/src/library.rs:539`](../ui/dr-ui/src/library.rs#L539), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/adjust.slint:517`](../ui/dr-ui/ui/adjust.slint#L517), [`ui/dr-ui/ui/adjust.slint:635`](../ui/dr-ui/ui/adjust.slint#L635), [`ui/dr-ui/ui/app.slint:136`](../ui/dr-ui/ui/app.slint#L136), [`ui/dr-ui/ui/app.slint:182`](../ui/dr-ui/ui/app.slint#L182), [`ui/dr-ui/ui/app.slint:1881`](../ui/dr-ui/ui/app.slint#L1881), [`ui/dr-ui/ui/app.slint:877`](../ui/dr-ui/ui/app.slint#L877), [`ui/dr-ui/ui/masks.slint:524`](../ui/dr-ui/ui/masks.slint#L524) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2203`](../core/dr-gpu/src/adjust.rs#L2203), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:177`](../core/dr-pipeline/src/framing.rs#L177), [`core/dr-pipeline/src/framing.rs:234`](../core/dr-pipeline/src/framing.rs#L234), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-pipeline/src/framing.rs:743`](../core/dr-pipeline/src/framing.rs#L743), [`core/dr-pipeline/src/graph.rs:170`](../core/dr-pipeline/src/graph.rs#L170), [`core/dr-pipeline/src/graph.rs:578`](../core/dr-pipeline/src/graph.rs#L578), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:157`](../core/dr-pipeline/src/sidecar.rs#L157), [`core/dr-pipeline/src/sidecar.rs:1637`](../core/dr-pipeline/src/sidecar.rs#L1637), [`core/dr-pipeline/src/sidecar.rs:1697`](../core/dr-pipeline/src/sidecar.rs#L1697), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:102`](../ui/dr-ui/src/develop.rs#L102), [`ui/dr-ui/src/develop.rs:1448`](../ui/dr-ui/src/develop.rs#L1448), [`ui/dr-ui/src/develop.rs:164`](../ui/dr-ui/src/develop.rs#L164), [`ui/dr-ui/src/develop.rs:1926`](../ui/dr-ui/src/develop.rs#L1926), [`ui/dr-ui/src/develop.rs:1944`](../ui/dr-ui/src/develop.rs#L1944), [`ui/dr-ui/src/develop.rs:1958`](../ui/dr-ui/src/develop.rs#L1958), [`ui/dr-ui/src/develop.rs:1980`](../ui/dr-ui/src/develop.rs#L1980), [`ui/dr-ui/src/develop.rs:2126`](../ui/dr-ui/src/develop.rs#L2126), [`ui/dr-ui/src/develop.rs:2224`](../ui/dr-ui/src/develop.rs#L2224), [`ui/dr-ui/src/develop.rs:327`](../ui/dr-ui/src/develop.rs#L327), [`ui/dr-ui/src/develop.rs:3418`](../ui/dr-ui/src/develop.rs#L3418), [`ui/dr-ui/src/develop.rs:3448`](../ui/dr-ui/src/develop.rs#L3448), [`ui/dr-ui/src/develop.rs:3514`](../ui/dr-ui/src/develop.rs#L3514), [`ui/dr-ui/src/develop.rs:3528`](../ui/dr-ui/src/develop.rs#L3528), [`ui/dr-ui/src/develop.rs:364`](../ui/dr-ui/src/develop.rs#L364), [`ui/dr-ui/src/develop.rs:3720`](../ui/dr-ui/src/develop.rs#L3720), [`ui/dr-ui/src/develop.rs:4380`](../ui/dr-ui/src/develop.rs#L4380), [`ui/dr-ui/src/develop.rs:4434`](../ui/dr-ui/src/develop.rs#L4434), [`ui/dr-ui/src/develop.rs:4478`](../ui/dr-ui/src/develop.rs#L4478), [`ui/dr-ui/src/develop.rs:4528`](../ui/dr-ui/src/develop.rs#L4528), [`ui/dr-ui/src/develop.rs:587`](../ui/dr-ui/src/develop.rs#L587), [`ui/dr-ui/src/develop.rs:648`](../ui/dr-ui/src/develop.rs#L648), [`ui/dr-ui/src/develop.rs:746`](../ui/dr-ui/src/develop.rs#L746), [`ui/dr-ui/src/develop.rs:793`](../ui/dr-ui/src/develop.rs#L793), [`ui/dr-ui/src/develop.rs:822`](../ui/dr-ui/src/develop.rs#L822), [`ui/dr-ui/src/lib.rs:1582`](../ui/dr-ui/src/lib.rs#L1582), [`ui/dr-ui/src/lib.rs:2344`](../ui/dr-ui/src/lib.rs#L2344), [`ui/dr-ui/src/lib.rs:2540`](../ui/dr-ui/src/lib.rs#L2540), [`ui/dr-ui/src/lib.rs:2712`](../ui/dr-ui/src/lib.rs#L2712), [`ui/dr-ui/src/lib.rs:2776`](../ui/dr-ui/src/lib.rs#L2776), [`ui/dr-ui/src/lib.rs:2830`](../ui/dr-ui/src/lib.rs#L2830), [`ui/dr-ui/src/lib.rs:329`](../ui/dr-ui/src/lib.rs#L329), [`ui/dr-ui/src/lib.rs:372`](../ui/dr-ui/src/lib.rs#L372), [`ui/dr-ui/src/lib.rs:395`](../ui/dr-ui/src/lib.rs#L395), [`ui/dr-ui/src/library.rs:539`](../ui/dr-ui/src/library.rs#L539), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/adjust.slint:517`](../ui/dr-ui/ui/adjust.slint#L517), [`ui/dr-ui/ui/adjust.slint:635`](../ui/dr-ui/ui/adjust.slint#L635), [`ui/dr-ui/ui/app.slint:136`](../ui/dr-ui/ui/app.slint#L136), [`ui/dr-ui/ui/app.slint:182`](../ui/dr-ui/ui/app.slint#L182), [`ui/dr-ui/ui/app.slint:1918`](../ui/dr-ui/ui/app.slint#L1918), [`ui/dr-ui/ui/app.slint:899`](../ui/dr-ui/ui/app.slint#L899), [`ui/dr-ui/ui/masks.slint:524`](../ui/dr-ui/ui/masks.slint#L524) | | FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:24`](../core/dr-pipeline/src/graph.rs#L24), [`core/dr-pipeline/src/graph.rs:251`](../core/dr-pipeline/src/graph.rs#L251), [`core/dr-pipeline/src/graph.rs:46`](../core/dr-pipeline/src/graph.rs#L46), [`core/dr-pipeline/src/graph.rs:59`](../core/dr-pipeline/src/graph.rs#L59), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1345`](../ui/dr-ui/src/develop.rs#L1345), [`ui/dr-ui/src/lib.rs:678`](../ui/dr-ui/src/lib.rs#L678), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:59`](../core/dr-pipeline/src/graph.rs#L59), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) | | FR-DEV-3c | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:251`](../core/dr-pipeline/src/graph.rs#L251), [`core/dr-pipeline/src/graph.rs:46`](../core/dr-pipeline/src/graph.rs#L46), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:5107`](../ui/dr-ui/src/develop.rs#L5107) | | FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:1079`](../core/dr-gpu/src/adjust.rs#L1079), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:558`](../core/dr-gpu/tests/local_contrast.rs#L558), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/graph.rs:617`](../core/dr-pipeline/src/graph.rs#L617), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) | | 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:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`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:967`](../core/dr-gpu/src/adjust.rs#L967), [`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:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) | -| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:102`](../core/dr-pipeline/src/graph.rs#L102), [`core/dr-pipeline/src/graph.rs:125`](../core/dr-pipeline/src/graph.rs#L125), [`core/dr-pipeline/src/graph.rs:325`](../core/dr-pipeline/src/graph.rs#L325), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:112`](../core/dr-pipeline/src/sidecar.rs#L112), [`core/dr-pipeline/src/sidecar.rs:168`](../core/dr-pipeline/src/sidecar.rs#L168), [`core/dr-pipeline/src/sidecar.rs:1958`](../core/dr-pipeline/src/sidecar.rs#L1958), [`core/dr-pipeline/src/sidecar.rs:2034`](../core/dr-pipeline/src/sidecar.rs#L2034), [`core/dr-pipeline/src/sidecar.rs:534`](../core/dr-pipeline/src/sidecar.rs#L534), [`core/dr-pipeline/src/sidecar.rs:661`](../core/dr-pipeline/src/sidecar.rs#L661), [`core/dr-pipeline/src/sidecar.rs:793`](../core/dr-pipeline/src/sidecar.rs#L793), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:3162`](../ui/dr-ui/src/develop.rs#L3162), [`ui/dr-ui/src/develop.rs:3179`](../ui/dr-ui/src/develop.rs#L3179), [`ui/dr-ui/src/develop.rs:3191`](../ui/dr-ui/src/develop.rs#L3191), [`ui/dr-ui/src/develop.rs:3229`](../ui/dr-ui/src/develop.rs#L3229), [`ui/dr-ui/src/develop.rs:3238`](../ui/dr-ui/src/develop.rs#L3238), [`ui/dr-ui/src/develop.rs:3341`](../ui/dr-ui/src/develop.rs#L3341), [`ui/dr-ui/src/develop.rs:3757`](../ui/dr-ui/src/develop.rs#L3757), [`ui/dr-ui/src/develop.rs:3772`](../ui/dr-ui/src/develop.rs#L3772), [`ui/dr-ui/src/lib.rs:2314`](../ui/dr-ui/src/lib.rs#L2314), [`ui/dr-ui/src/lib.rs:611`](../ui/dr-ui/src/lib.rs#L611), [`ui/dr-ui/src/lib.rs:669`](../ui/dr-ui/src/lib.rs#L669), [`ui/dr-ui/src/library.rs:530`](../ui/dr-ui/src/library.rs#L530), [`ui/dr-ui/src/library.rs:778`](../ui/dr-ui/src/library.rs#L778), [`ui/dr-ui/src/presets.rs:286`](../ui/dr-ui/src/presets.rs#L286), [`ui/dr-ui/ui/adjust.slint:1034`](../ui/dr-ui/ui/adjust.slint#L1034), [`ui/dr-ui/ui/adjust.slint:1116`](../ui/dr-ui/ui/adjust.slint#L1116), [`ui/dr-ui/ui/app.slint:2416`](../ui/dr-ui/ui/app.slint#L2416), [`ui/dr-ui/ui/app.slint:619`](../ui/dr-ui/ui/app.slint#L619) | +| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:102`](../core/dr-pipeline/src/graph.rs#L102), [`core/dr-pipeline/src/graph.rs:125`](../core/dr-pipeline/src/graph.rs#L125), [`core/dr-pipeline/src/graph.rs:325`](../core/dr-pipeline/src/graph.rs#L325), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:112`](../core/dr-pipeline/src/sidecar.rs#L112), [`core/dr-pipeline/src/sidecar.rs:168`](../core/dr-pipeline/src/sidecar.rs#L168), [`core/dr-pipeline/src/sidecar.rs:1958`](../core/dr-pipeline/src/sidecar.rs#L1958), [`core/dr-pipeline/src/sidecar.rs:2034`](../core/dr-pipeline/src/sidecar.rs#L2034), [`core/dr-pipeline/src/sidecar.rs:534`](../core/dr-pipeline/src/sidecar.rs#L534), [`core/dr-pipeline/src/sidecar.rs:661`](../core/dr-pipeline/src/sidecar.rs#L661), [`core/dr-pipeline/src/sidecar.rs:793`](../core/dr-pipeline/src/sidecar.rs#L793), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:3162`](../ui/dr-ui/src/develop.rs#L3162), [`ui/dr-ui/src/develop.rs:3179`](../ui/dr-ui/src/develop.rs#L3179), [`ui/dr-ui/src/develop.rs:3191`](../ui/dr-ui/src/develop.rs#L3191), [`ui/dr-ui/src/develop.rs:3229`](../ui/dr-ui/src/develop.rs#L3229), [`ui/dr-ui/src/develop.rs:3238`](../ui/dr-ui/src/develop.rs#L3238), [`ui/dr-ui/src/develop.rs:3341`](../ui/dr-ui/src/develop.rs#L3341), [`ui/dr-ui/src/develop.rs:3757`](../ui/dr-ui/src/develop.rs#L3757), [`ui/dr-ui/src/develop.rs:3772`](../ui/dr-ui/src/develop.rs#L3772), [`ui/dr-ui/src/lib.rs:2318`](../ui/dr-ui/src/lib.rs#L2318), [`ui/dr-ui/src/lib.rs:611`](../ui/dr-ui/src/lib.rs#L611), [`ui/dr-ui/src/lib.rs:669`](../ui/dr-ui/src/lib.rs#L669), [`ui/dr-ui/src/library.rs:530`](../ui/dr-ui/src/library.rs#L530), [`ui/dr-ui/src/library.rs:778`](../ui/dr-ui/src/library.rs#L778), [`ui/dr-ui/src/presets.rs:286`](../ui/dr-ui/src/presets.rs#L286), [`ui/dr-ui/ui/adjust.slint:1034`](../ui/dr-ui/ui/adjust.slint#L1034), [`ui/dr-ui/ui/adjust.slint:1116`](../ui/dr-ui/ui/adjust.slint#L1116), [`ui/dr-ui/ui/app.slint:2453`](../ui/dr-ui/ui/app.slint#L2453), [`ui/dr-ui/ui/app.slint:641`](../ui/dr-ui/ui/app.slint#L641) | | FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:1050`](../core/dr-pipeline/src/framing.rs#L1050), [`core/dr-pipeline/src/framing.rs:328`](../core/dr-pipeline/src/framing.rs#L328), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-types/src/lib.rs:337`](../core/dr-types/src/lib.rs#L337), [`core/dr-types/src/lib.rs:445`](../core/dr-types/src/lib.rs#L445), [`core/dr-types/src/lib.rs:457`](../core/dr-types/src/lib.rs#L457), [`core/dr-types/src/lib.rs:473`](../core/dr-types/src/lib.rs#L473), [`ui/dr-ui/src/develop.rs:139`](../ui/dr-ui/src/develop.rs#L139), [`ui/dr-ui/src/develop.rs:2143`](../ui/dr-ui/src/develop.rs#L2143), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:338`](../core/dr-gpu/src/lib.rs#L338), [`ui/dr-ui/ui/crop.slint:1`](../ui/dr-ui/ui/crop.slint#L1) | -| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:346`](../core/dr-pipeline/src/graph.rs#L346), [`core/dr-pipeline/src/graph.rs:385`](../core/dr-pipeline/src/graph.rs#L385), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:3191`](../ui/dr-ui/src/develop.rs#L3191), [`ui/dr-ui/src/develop.rs:3772`](../ui/dr-ui/src/develop.rs#L3772), [`ui/dr-ui/src/develop.rs:3802`](../ui/dr-ui/src/develop.rs#L3802), [`ui/dr-ui/src/develop.rs:3815`](../ui/dr-ui/src/develop.rs#L3815), [`ui/dr-ui/src/develop.rs:3827`](../ui/dr-ui/src/develop.rs#L3827), [`ui/dr-ui/src/develop.rs:3843`](../ui/dr-ui/src/develop.rs#L3843), [`ui/dr-ui/src/develop.rs:3875`](../ui/dr-ui/src/develop.rs#L3875), [`ui/dr-ui/src/develop.rs:3879`](../ui/dr-ui/src/develop.rs#L3879), [`ui/dr-ui/src/develop.rs:3898`](../ui/dr-ui/src/develop.rs#L3898), [`ui/dr-ui/src/develop.rs:3914`](../ui/dr-ui/src/develop.rs#L3914), [`ui/dr-ui/src/develop.rs:709`](../ui/dr-ui/src/develop.rs#L709), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1513`](../ui/dr-ui/src/lib.rs#L1513), [`ui/dr-ui/src/lib.rs:1556`](../ui/dr-ui/src/lib.rs#L1556), [`ui/dr-ui/src/lib.rs:1564`](../ui/dr-ui/src/lib.rs#L1564), [`ui/dr-ui/src/lib.rs:2510`](../ui/dr-ui/src/lib.rs#L2510), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:249`](../core/dr-pipeline/src/preset.rs#L249), [`core/dr-pipeline/src/preset.rs:282`](../core/dr-pipeline/src/preset.rs#L282), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:3714`](../ui/dr-ui/src/develop.rs#L3714), [`ui/dr-ui/src/develop.rs:3735`](../ui/dr-ui/src/develop.rs#L3735), [`ui/dr-ui/src/lib.rs:1480`](../ui/dr-ui/src/lib.rs#L1480), [`ui/dr-ui/src/lib.rs:2132`](../ui/dr-ui/src/lib.rs#L2132), [`ui/dr-ui/src/library.rs:1903`](../ui/dr-ui/src/library.rs#L1903), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library.rs:520`](../ui/dr-ui/src/library.rs#L520), [`ui/dr-ui/src/library_ui.rs:2674`](../ui/dr-ui/src/library_ui.rs#L2674), [`ui/dr-ui/src/library_ui.rs:3074`](../ui/dr-ui/src/library_ui.rs#L3074), [`ui/dr-ui/src/library_ui.rs:494`](../ui/dr-ui/src/library_ui.rs#L494), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:546`](../ui/dr-ui/src/presets.rs#L546), [`ui/dr-ui/src/settings_ui.rs:630`](../ui/dr-ui/src/settings_ui.rs#L630), [`ui/dr-ui/ui/adjust.slint:709`](../ui/dr-ui/ui/adjust.slint#L709), [`ui/dr-ui/ui/adjust.slint:736`](../ui/dr-ui/ui/adjust.slint#L736), [`ui/dr-ui/ui/adjust.slint:779`](../ui/dr-ui/ui/adjust.slint#L779), [`ui/dr-ui/ui/app.slint:1462`](../ui/dr-ui/ui/app.slint#L1462), [`ui/dr-ui/ui/app.slint:2505`](../ui/dr-ui/ui/app.slint#L2505), [`ui/dr-ui/ui/library.slint:1360`](../ui/dr-ui/ui/library.slint#L1360), [`ui/dr-ui/ui/library.slint:1367`](../ui/dr-ui/ui/library.slint#L1367), [`ui/dr-ui/ui/library.slint:847`](../ui/dr-ui/ui/library.slint#L847), [`ui/dr-ui/ui/library.slint:863`](../ui/dr-ui/ui/library.slint#L863), [`ui/dr-ui/ui/library.slint:932`](../ui/dr-ui/ui/library.slint#L932), [`ui/dr-ui/ui/library.slint:947`](../ui/dr-ui/ui/library.slint#L947), [`ui/dr-ui/ui/presets.slint:4`](../ui/dr-ui/ui/presets.slint#L4), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) | -| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3843`](../ui/dr-ui/src/develop.rs#L3843), [`ui/dr-ui/src/develop.rs:3875`](../ui/dr-ui/src/develop.rs#L3875), [`ui/dr-ui/src/lib.rs:1564`](../ui/dr-ui/src/lib.rs#L1564), [`ui/dr-ui/src/lib.rs:2510`](../ui/dr-ui/src/lib.rs#L2510), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | -| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:405`](../core/dr-gpu/src/detail.rs#L405), [`core/dr-gpu/src/detail.rs:623`](../core/dr-gpu/src/detail.rs#L623), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:410`](../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:114`](../core/dr-pipeline/src/graph.rs#L114), [`core/dr-pipeline/src/graph.rs:192`](../core/dr-pipeline/src/graph.rs#L192), [`core/dr-pipeline/src/graph.rs:686`](../core/dr-pipeline/src/graph.rs#L686), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:184`](../core/dr-pipeline/src/sidecar.rs#L184), [`core/dr-pipeline/src/sidecar.rs:353`](../core/dr-pipeline/src/sidecar.rs#L353), [`core/dr-pipeline/src/sidecar.rs:673`](../core/dr-pipeline/src/sidecar.rs#L673), [`core/dr-pipeline/src/sidecar.rs:809`](../core/dr-pipeline/src/sidecar.rs#L809), [`core/dr-pipeline/src/sidecar.rs:863`](../core/dr-pipeline/src/sidecar.rs#L863), [`core/dr-pipeline/src/sidecar.rs:893`](../core/dr-pipeline/src/sidecar.rs#L893), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2295`](../ui/dr-ui/src/develop.rs#L2295), [`ui/dr-ui/src/develop.rs:2333`](../ui/dr-ui/src/develop.rs#L2333), [`ui/dr-ui/src/develop.rs:2398`](../ui/dr-ui/src/develop.rs#L2398), [`ui/dr-ui/src/develop.rs:2481`](../ui/dr-ui/src/develop.rs#L2481), [`ui/dr-ui/src/develop.rs:2495`](../ui/dr-ui/src/develop.rs#L2495), [`ui/dr-ui/src/develop.rs:776`](../ui/dr-ui/src/develop.rs#L776), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1585`](../ui/dr-ui/src/lib.rs#L1585), [`ui/dr-ui/src/lib.rs:2628`](../ui/dr-ui/src/lib.rs#L2628), [`ui/dr-ui/src/lib.rs:334`](../ui/dr-ui/src/lib.rs#L334), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:810`](../ui/dr-ui/ui/adjust.slint#L810), [`ui/dr-ui/ui/app.slint:126`](../ui/dr-ui/ui/app.slint#L126), [`ui/dr-ui/ui/app.slint:1784`](../ui/dr-ui/ui/app.slint#L1784), [`ui/dr-ui/ui/app.slint:1959`](../ui/dr-ui/ui/app.slint#L1959), [`ui/dr-ui/ui/app.slint:2322`](../ui/dr-ui/ui/app.slint#L2322), [`ui/dr-ui/ui/spots.slint:180`](../ui/dr-ui/ui/spots.slint#L180), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | +| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:346`](../core/dr-pipeline/src/graph.rs#L346), [`core/dr-pipeline/src/graph.rs:385`](../core/dr-pipeline/src/graph.rs#L385), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:3191`](../ui/dr-ui/src/develop.rs#L3191), [`ui/dr-ui/src/develop.rs:3772`](../ui/dr-ui/src/develop.rs#L3772), [`ui/dr-ui/src/develop.rs:3802`](../ui/dr-ui/src/develop.rs#L3802), [`ui/dr-ui/src/develop.rs:3815`](../ui/dr-ui/src/develop.rs#L3815), [`ui/dr-ui/src/develop.rs:3827`](../ui/dr-ui/src/develop.rs#L3827), [`ui/dr-ui/src/develop.rs:3843`](../ui/dr-ui/src/develop.rs#L3843), [`ui/dr-ui/src/develop.rs:3875`](../ui/dr-ui/src/develop.rs#L3875), [`ui/dr-ui/src/develop.rs:3879`](../ui/dr-ui/src/develop.rs#L3879), [`ui/dr-ui/src/develop.rs:3898`](../ui/dr-ui/src/develop.rs#L3898), [`ui/dr-ui/src/develop.rs:3914`](../ui/dr-ui/src/develop.rs#L3914), [`ui/dr-ui/src/develop.rs:709`](../ui/dr-ui/src/develop.rs#L709), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1517`](../ui/dr-ui/src/lib.rs#L1517), [`ui/dr-ui/src/lib.rs:1560`](../ui/dr-ui/src/lib.rs#L1560), [`ui/dr-ui/src/lib.rs:1568`](../ui/dr-ui/src/lib.rs#L1568), [`ui/dr-ui/src/lib.rs:2514`](../ui/dr-ui/src/lib.rs#L2514), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:249`](../core/dr-pipeline/src/preset.rs#L249), [`core/dr-pipeline/src/preset.rs:282`](../core/dr-pipeline/src/preset.rs#L282), [`core/dr-types/src/settings.rs:272`](../core/dr-types/src/settings.rs#L272), [`ui/dr-ui/src/develop.rs:3714`](../ui/dr-ui/src/develop.rs#L3714), [`ui/dr-ui/src/develop.rs:3735`](../ui/dr-ui/src/develop.rs#L3735), [`ui/dr-ui/src/lib.rs:1484`](../ui/dr-ui/src/lib.rs#L1484), [`ui/dr-ui/src/lib.rs:2136`](../ui/dr-ui/src/lib.rs#L2136), [`ui/dr-ui/src/library.rs:1903`](../ui/dr-ui/src/library.rs#L1903), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library.rs:520`](../ui/dr-ui/src/library.rs#L520), [`ui/dr-ui/src/library_ui.rs:2674`](../ui/dr-ui/src/library_ui.rs#L2674), [`ui/dr-ui/src/library_ui.rs:3074`](../ui/dr-ui/src/library_ui.rs#L3074), [`ui/dr-ui/src/library_ui.rs:494`](../ui/dr-ui/src/library_ui.rs#L494), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:546`](../ui/dr-ui/src/presets.rs#L546), [`ui/dr-ui/src/settings_ui.rs:636`](../ui/dr-ui/src/settings_ui.rs#L636), [`ui/dr-ui/ui/adjust.slint:709`](../ui/dr-ui/ui/adjust.slint#L709), [`ui/dr-ui/ui/adjust.slint:736`](../ui/dr-ui/ui/adjust.slint#L736), [`ui/dr-ui/ui/adjust.slint:779`](../ui/dr-ui/ui/adjust.slint#L779), [`ui/dr-ui/ui/app.slint:1496`](../ui/dr-ui/ui/app.slint#L1496), [`ui/dr-ui/ui/app.slint:2542`](../ui/dr-ui/ui/app.slint#L2542), [`ui/dr-ui/ui/library.slint:1461`](../ui/dr-ui/ui/library.slint#L1461), [`ui/dr-ui/ui/library.slint:1468`](../ui/dr-ui/ui/library.slint#L1468), [`ui/dr-ui/ui/library.slint:857`](../ui/dr-ui/ui/library.slint#L857), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/library.slint:942`](../ui/dr-ui/ui/library.slint#L942), [`ui/dr-ui/ui/library.slint:957`](../ui/dr-ui/ui/library.slint#L957), [`ui/dr-ui/ui/presets.slint:4`](../ui/dr-ui/ui/presets.slint#L4), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) | +| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3843`](../ui/dr-ui/src/develop.rs#L3843), [`ui/dr-ui/src/develop.rs:3875`](../ui/dr-ui/src/develop.rs#L3875), [`ui/dr-ui/src/lib.rs:1568`](../ui/dr-ui/src/lib.rs#L1568), [`ui/dr-ui/src/lib.rs:2514`](../ui/dr-ui/src/lib.rs#L2514), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | +| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:405`](../core/dr-gpu/src/detail.rs#L405), [`core/dr-gpu/src/detail.rs:623`](../core/dr-gpu/src/detail.rs#L623), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:410`](../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:114`](../core/dr-pipeline/src/graph.rs#L114), [`core/dr-pipeline/src/graph.rs:192`](../core/dr-pipeline/src/graph.rs#L192), [`core/dr-pipeline/src/graph.rs:686`](../core/dr-pipeline/src/graph.rs#L686), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:184`](../core/dr-pipeline/src/sidecar.rs#L184), [`core/dr-pipeline/src/sidecar.rs:353`](../core/dr-pipeline/src/sidecar.rs#L353), [`core/dr-pipeline/src/sidecar.rs:673`](../core/dr-pipeline/src/sidecar.rs#L673), [`core/dr-pipeline/src/sidecar.rs:809`](../core/dr-pipeline/src/sidecar.rs#L809), [`core/dr-pipeline/src/sidecar.rs:863`](../core/dr-pipeline/src/sidecar.rs#L863), [`core/dr-pipeline/src/sidecar.rs:893`](../core/dr-pipeline/src/sidecar.rs#L893), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2295`](../ui/dr-ui/src/develop.rs#L2295), [`ui/dr-ui/src/develop.rs:2333`](../ui/dr-ui/src/develop.rs#L2333), [`ui/dr-ui/src/develop.rs:2398`](../ui/dr-ui/src/develop.rs#L2398), [`ui/dr-ui/src/develop.rs:2481`](../ui/dr-ui/src/develop.rs#L2481), [`ui/dr-ui/src/develop.rs:2495`](../ui/dr-ui/src/develop.rs#L2495), [`ui/dr-ui/src/develop.rs:776`](../ui/dr-ui/src/develop.rs#L776), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1589`](../ui/dr-ui/src/lib.rs#L1589), [`ui/dr-ui/src/lib.rs:2632`](../ui/dr-ui/src/lib.rs#L2632), [`ui/dr-ui/src/lib.rs:334`](../ui/dr-ui/src/lib.rs#L334), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:810`](../ui/dr-ui/ui/adjust.slint#L810), [`ui/dr-ui/ui/app.slint:126`](../ui/dr-ui/ui/app.slint#L126), [`ui/dr-ui/ui/app.slint:1821`](../ui/dr-ui/ui/app.slint#L1821), [`ui/dr-ui/ui/app.slint:1996`](../ui/dr-ui/ui/app.slint#L1996), [`ui/dr-ui/ui/app.slint:2359`](../ui/dr-ui/ui/app.slint#L2359), [`ui/dr-ui/ui/spots.slint:180`](../ui/dr-ui/ui/spots.slint#L180), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | | FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2126`](../core/dr-gpu/src/adjust.rs#L2126), [`core/dr-gpu/src/adjust.rs:2203`](../core/dr-gpu/src/adjust.rs#L2203), [`core/dr-gpu/src/adjust.rs:2288`](../core/dr-gpu/src/adjust.rs#L2288), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:159`](../core/dr-gpu/src/lib.rs#L159), [`core/dr-gpu/src/lib.rs:179`](../core/dr-gpu/src/lib.rs#L179), [`core/dr-gpu/src/lib.rs:57`](../core/dr-gpu/src/lib.rs#L57), [`core/dr-gpu/src/lib.rs:80`](../core/dr-gpu/src/lib.rs#L80), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:264`](../core/dr-gpu/tests/local_contrast.rs#L264), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:548`](../core/dr-pipeline/src/graph.rs#L548), [`core/dr-pipeline/src/graph.rs:578`](../core/dr-pipeline/src/graph.rs#L578), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:659`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:971`](../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2819`](../ui/dr-ui/src/develop.rs#L2819), [`ui/dr-ui/src/develop.rs:4169`](../ui/dr-ui/src/develop.rs#L4169), [`ui/dr-ui/src/develop.rs:4652`](../ui/dr-ui/src/develop.rs#L4652), [`ui/dr-ui/src/develop.rs:4686`](../ui/dr-ui/src/develop.rs#L4686), [`ui/dr-ui/src/lib.rs:76`](../ui/dr-ui/src/lib.rs#L76), [`ui/dr-ui/src/lib.rs:819`](../ui/dr-ui/src/lib.rs#L819), [`ui/dr-ui/src/lib.rs:878`](../ui/dr-ui/src/lib.rs#L878) | | FR-DSP-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/local_contrast.rs:329`](../core/dr-gpu/tests/local_contrast.rs#L329), [`core/dr-pipeline/src/ops/local_contrast.rs:447`](../core/dr-pipeline/src/ops/local_contrast.rs#L447) | | FR-DSP-5 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/zoom_resolution.rs:135`](../core/dr-gpu/tests/zoom_resolution.rs#L135), [`core/dr-gpu/tests/zoom_resolution.rs:166`](../core/dr-gpu/tests/zoom_resolution.rs#L166), [`core/dr-gpu/tests/zoom_resolution.rs:1`](../core/dr-gpu/tests/zoom_resolution.rs#L1), [`core/dr-gpu/tests/zoom_resolution.rs:216`](../core/dr-gpu/tests/zoom_resolution.rs#L216) | -| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2849`](../ui/dr-ui/src/develop.rs#L2849), [`ui/dr-ui/src/develop.rs:5944`](../ui/dr-ui/src/develop.rs#L5944), [`ui/dr-ui/src/lib.rs:1541`](../ui/dr-ui/src/lib.rs#L1541), [`ui/dr-ui/src/lib.rs:3011`](../ui/dr-ui/src/lib.rs#L3011) | -| 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:2898`](../ui/dr-ui/src/develop.rs#L2898), [`ui/dr-ui/src/develop.rs:5754`](../ui/dr-ui/src/develop.rs#L5754), [`ui/dr-ui/src/develop.rs:5786`](../ui/dr-ui/src/develop.rs#L5786), [`ui/dr-ui/src/develop.rs:720`](../ui/dr-ui/src/develop.rs#L720), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1651`](../ui/dr-ui/src/lib.rs#L1651), [`ui/dr-ui/src/lib.rs:318`](../ui/dr-ui/src/lib.rs#L318), [`ui/dr-ui/ui/app.slint:70`](../ui/dr-ui/ui/app.slint#L70), [`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-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2795`](../ui/dr-ui/src/develop.rs#L2795), [`ui/dr-ui/src/develop.rs:2849`](../ui/dr-ui/src/develop.rs#L2849), [`ui/dr-ui/src/develop.rs:5944`](../ui/dr-ui/src/develop.rs#L5944), [`ui/dr-ui/src/develop.rs:5990`](../ui/dr-ui/src/develop.rs#L5990), [`ui/dr-ui/src/develop.rs:6009`](../ui/dr-ui/src/develop.rs#L6009), [`ui/dr-ui/src/develop.rs:807`](../ui/dr-ui/src/develop.rs#L807), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1502`](../ui/dr-ui/src/lib.rs#L1502), [`ui/dr-ui/src/lib.rs:1541`](../ui/dr-ui/src/lib.rs#L1541), [`ui/dr-ui/src/lib.rs:2924`](../ui/dr-ui/src/lib.rs#L2924), [`ui/dr-ui/src/lib.rs:3011`](../ui/dr-ui/src/lib.rs#L3011), [`ui/dr-ui/ui/app.slint:1632`](../ui/dr-ui/ui/app.slint#L1632), [`ui/dr-ui/ui/app.slint:49`](../ui/dr-ui/ui/app.slint#L49), [`ui/dr-ui/ui/settings.slint:120`](../ui/dr-ui/ui/settings.slint#L120), [`ui/dr-ui/ui/settings.slint:772`](../ui/dr-ui/ui/settings.slint#L772) | +| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2849`](../ui/dr-ui/src/develop.rs#L2849), [`ui/dr-ui/src/develop.rs:5944`](../ui/dr-ui/src/develop.rs#L5944), [`ui/dr-ui/src/lib.rs:1545`](../ui/dr-ui/src/lib.rs#L1545), [`ui/dr-ui/src/lib.rs:3015`](../ui/dr-ui/src/lib.rs#L3015) | +| 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:2898`](../ui/dr-ui/src/develop.rs#L2898), [`ui/dr-ui/src/develop.rs:5754`](../ui/dr-ui/src/develop.rs#L5754), [`ui/dr-ui/src/develop.rs:5786`](../ui/dr-ui/src/develop.rs#L5786), [`ui/dr-ui/src/develop.rs:720`](../ui/dr-ui/src/develop.rs#L720), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1655`](../ui/dr-ui/src/lib.rs#L1655), [`ui/dr-ui/src/lib.rs:318`](../ui/dr-ui/src/lib.rs#L318), [`ui/dr-ui/ui/app.slint:70`](../ui/dr-ui/ui/app.slint#L70), [`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-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2795`](../ui/dr-ui/src/develop.rs#L2795), [`ui/dr-ui/src/develop.rs:2849`](../ui/dr-ui/src/develop.rs#L2849), [`ui/dr-ui/src/develop.rs:5944`](../ui/dr-ui/src/develop.rs#L5944), [`ui/dr-ui/src/develop.rs:5990`](../ui/dr-ui/src/develop.rs#L5990), [`ui/dr-ui/src/develop.rs:6009`](../ui/dr-ui/src/develop.rs#L6009), [`ui/dr-ui/src/develop.rs:807`](../ui/dr-ui/src/develop.rs#L807), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1506`](../ui/dr-ui/src/lib.rs#L1506), [`ui/dr-ui/src/lib.rs:1545`](../ui/dr-ui/src/lib.rs#L1545), [`ui/dr-ui/src/lib.rs:2928`](../ui/dr-ui/src/lib.rs#L2928), [`ui/dr-ui/src/lib.rs:3015`](../ui/dr-ui/src/lib.rs#L3015), [`ui/dr-ui/ui/app.slint:1669`](../ui/dr-ui/ui/app.slint#L1669), [`ui/dr-ui/ui/app.slint:49`](../ui/dr-ui/ui/app.slint#L49), [`ui/dr-ui/ui/settings.slint:120`](../ui/dr-ui/ui/settings.slint#L120), [`ui/dr-ui/ui/settings.slint:772`](../ui/dr-ui/ui/settings.slint#L772) | | 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:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2436`](../core/dr-gpu/src/adjust.rs#L2436), [`core/dr-pipeline/src/graph.rs:538`](../core/dr-pipeline/src/graph.rs#L538), [`core/dr-pipeline/src/graph.rs:592`](../core/dr-pipeline/src/graph.rs#L592), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`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:600`](../core/dr-types/src/settings.rs#L600), [`ui/dr-ui/src/develop.rs:6009`](../ui/dr-ui/src/develop.rs#L6009), [`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:182`](../core/dr-export/src/lib.rs#L182), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:136`](../core/dr-export/src/size.rs#L136), [`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-export/src/size.rs:84`](../core/dr-export/src/size.rs#L84), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:645`](../core/dr-types/src/settings.rs#L645), [`core/dr-types/src/settings.rs:653`](../core/dr-types/src/settings.rs#L653), [`core/dr-types/src/settings.rs:669`](../core/dr-types/src/settings.rs#L669), [`core/dr-types/src/settings.rs:738`](../core/dr-types/src/settings.rs#L738), [`core/dr-types/src/settings.rs:766`](../core/dr-types/src/settings.rs#L766), [`core/dr-types/src/settings.rs:777`](../core/dr-types/src/settings.rs#L777), [`core/dr-types/src/settings.rs:783`](../core/dr-types/src/settings.rs#L783), [`ui/dr-ui/src/settings_ui.rs:199`](../ui/dr-ui/src/settings_ui.rs#L199), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:212`](../ui/dr-ui/src/settings_ui.rs#L212), [`ui/dr-ui/src/settings_ui.rs:227`](../ui/dr-ui/src/settings_ui.rs#L227), [`ui/dr-ui/src/settings_ui.rs:557`](../ui/dr-ui/src/settings_ui.rs#L557), [`ui/dr-ui/ui/settings.slint:675`](../ui/dr-ui/ui/settings.slint#L675), [`ui/dr-ui/ui/settings.slint:685`](../ui/dr-ui/ui/settings.slint#L685) | +| 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:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2436`](../core/dr-gpu/src/adjust.rs#L2436), [`core/dr-pipeline/src/graph.rs:538`](../core/dr-pipeline/src/graph.rs#L538), [`core/dr-pipeline/src/graph.rs:592`](../core/dr-pipeline/src/graph.rs#L592), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`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:690`](../core/dr-types/src/settings.rs#L690), [`ui/dr-ui/src/develop.rs:6009`](../ui/dr-ui/src/develop.rs#L6009), [`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:182`](../core/dr-export/src/lib.rs#L182), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:136`](../core/dr-export/src/size.rs#L136), [`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-export/src/size.rs:84`](../core/dr-export/src/size.rs#L84), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:735`](../core/dr-types/src/settings.rs#L735), [`core/dr-types/src/settings.rs:743`](../core/dr-types/src/settings.rs#L743), [`core/dr-types/src/settings.rs:759`](../core/dr-types/src/settings.rs#L759), [`core/dr-types/src/settings.rs:828`](../core/dr-types/src/settings.rs#L828), [`core/dr-types/src/settings.rs:856`](../core/dr-types/src/settings.rs#L856), [`core/dr-types/src/settings.rs:867`](../core/dr-types/src/settings.rs#L867), [`core/dr-types/src/settings.rs:873`](../core/dr-types/src/settings.rs#L873), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:205`](../ui/dr-ui/src/settings_ui.rs#L205), [`ui/dr-ui/src/settings_ui.rs:218`](../ui/dr-ui/src/settings_ui.rs#L218), [`ui/dr-ui/src/settings_ui.rs:233`](../ui/dr-ui/src/settings_ui.rs#L233), [`ui/dr-ui/src/settings_ui.rs:563`](../ui/dr-ui/src/settings_ui.rs#L563), [`ui/dr-ui/ui/settings.slint:675`](../ui/dr-ui/ui/settings.slint#L675), [`ui/dr-ui/ui/settings.slint:685`](../ui/dr-ui/ui/settings.slint#L685) | | 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:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:49`](../ui/dr-ui/src/settings_ui.rs#L49), [`ui/dr-ui/src/settings_ui.rs:685`](../ui/dr-ui/src/settings_ui.rs#L685) | -| 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:942`](../ui/dr-ui/src/export.rs#L942), [`ui/dr-ui/src/lib.rs:208`](../ui/dr-ui/src/lib.rs#L208), [`ui/dr-ui/src/lib.rs:2256`](../ui/dr-ui/src/lib.rs#L2256), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/lib.rs:459`](../ui/dr-ui/src/lib.rs#L459), [`ui/dr-ui/src/lib.rs:486`](../ui/dr-ui/src/lib.rs#L486), [`ui/dr-ui/src/library_ui.rs:3457`](../ui/dr-ui/src/library_ui.rs#L3457), [`ui/dr-ui/src/library_ui.rs:585`](../ui/dr-ui/src/library_ui.rs#L585), [`ui/dr-ui/src/library_ui.rs:6515`](../ui/dr-ui/src/library_ui.rs#L6515), [`ui/dr-ui/src/library_ui.rs:6592`](../ui/dr-ui/src/library_ui.rs#L6592), [`ui/dr-ui/src/library_ui.rs:6604`](../ui/dr-ui/src/library_ui.rs#L6604), [`ui/dr-ui/src/library_ui.rs:685`](../ui/dr-ui/src/library_ui.rs#L685), [`ui/dr-ui/src/library_ui.rs:742`](../ui/dr-ui/src/library_ui.rs#L742), [`ui/dr-ui/ui/app.slint:1013`](../ui/dr-ui/ui/app.slint#L1013), [`ui/dr-ui/ui/app.slint:1472`](../ui/dr-ui/ui/app.slint#L1472), [`ui/dr-ui/ui/library.slint:1370`](../ui/dr-ui/ui/library.slint#L1370), [`ui/dr-ui/ui/library.slint:851`](../ui/dr-ui/ui/library.slint#L851), [`ui/dr-ui/ui/library.slint:961`](../ui/dr-ui/ui/library.slint#L961) | -| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:653`](../core/dr-types/src/lib.rs#L653), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:619`](../ui/dr-ui/src/export.rs#L619), [`ui/dr-ui/src/export.rs:647`](../ui/dr-ui/src/export.rs#L647), [`ui/dr-ui/src/export.rs:777`](../ui/dr-ui/src/export.rs#L777), [`ui/dr-ui/src/export.rs:794`](../ui/dr-ui/src/export.rs#L794), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.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:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:49`](../ui/dr-ui/src/settings_ui.rs#L49), [`ui/dr-ui/src/settings_ui.rs:691`](../ui/dr-ui/src/settings_ui.rs#L691) | +| 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:942`](../ui/dr-ui/src/export.rs#L942), [`ui/dr-ui/src/lib.rs:208`](../ui/dr-ui/src/lib.rs#L208), [`ui/dr-ui/src/lib.rs:2260`](../ui/dr-ui/src/lib.rs#L2260), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/lib.rs:459`](../ui/dr-ui/src/lib.rs#L459), [`ui/dr-ui/src/lib.rs:486`](../ui/dr-ui/src/lib.rs#L486), [`ui/dr-ui/src/library_ui.rs:3457`](../ui/dr-ui/src/library_ui.rs#L3457), [`ui/dr-ui/src/library_ui.rs:585`](../ui/dr-ui/src/library_ui.rs#L585), [`ui/dr-ui/src/library_ui.rs:6624`](../ui/dr-ui/src/library_ui.rs#L6624), [`ui/dr-ui/src/library_ui.rs:6701`](../ui/dr-ui/src/library_ui.rs#L6701), [`ui/dr-ui/src/library_ui.rs:6713`](../ui/dr-ui/src/library_ui.rs#L6713), [`ui/dr-ui/src/library_ui.rs:685`](../ui/dr-ui/src/library_ui.rs#L685), [`ui/dr-ui/src/library_ui.rs:742`](../ui/dr-ui/src/library_ui.rs#L742), [`ui/dr-ui/ui/app.slint:1035`](../ui/dr-ui/ui/app.slint#L1035), [`ui/dr-ui/ui/app.slint:1506`](../ui/dr-ui/ui/app.slint#L1506), [`ui/dr-ui/ui/library.slint:1471`](../ui/dr-ui/ui/library.slint#L1471), [`ui/dr-ui/ui/library.slint:861`](../ui/dr-ui/ui/library.slint#L861), [`ui/dr-ui/ui/library.slint:971`](../ui/dr-ui/ui/library.slint#L971) | +| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:653`](../core/dr-types/src/lib.rs#L653), [`core/dr-types/src/settings.rs:403`](../core/dr-types/src/settings.rs#L403), [`ui/dr-ui/src/export.rs:619`](../ui/dr-ui/src/export.rs#L619), [`ui/dr-ui/src/export.rs:647`](../ui/dr-ui/src/export.rs#L647), [`ui/dr-ui/src/export.rs:777`](../ui/dr-ui/src/export.rs#L777), [`ui/dr-ui/src/export.rs:794`](../ui/dr-ui/src/export.rs#L794), [`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:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1106`](../core/dr-gpu/src/adjust.rs#L1106), [`ui/dr-ui/src/develop.rs:3106`](../ui/dr-ui/src/develop.rs#L3106), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424) | | 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/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`ui/dr-ui/src/launch.rs:277`](../ui/dr-ui/src/launch.rs#L277), [`ui/dr-ui/src/launch.rs:61`](../ui/dr-ui/src/launch.rs#L61), [`ui/dr-ui/src/launch_ui.rs:417`](../ui/dr-ui/src/launch_ui.rs#L417) | | FR-NC-10 | [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:486`](../ui/dr-ui/src/lib.rs#L486), [`ui/dr-ui/src/library.rs:1101`](../ui/dr-ui/src/library.rs#L1101), [`ui/dr-ui/src/library.rs:1939`](../ui/dr-ui/src/library.rs#L1939), [`ui/dr-ui/src/library.rs:576`](../ui/dr-ui/src/library.rs#L576), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1696`](../ui/dr-ui/src/library_ui.rs#L1696), [`ui/dr-ui/src/library_ui.rs:3457`](../ui/dr-ui/src/library_ui.rs#L3457), [`ui/dr-ui/src/library_ui.rs:526`](../ui/dr-ui/src/library_ui.rs#L526), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-NC-12 | [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:40`](../core/dr-sync-nextcloud/src/lib.rs#L40), [`core/dr-sync-nextcloud/src/lib.rs:914`](../core/dr-sync-nextcloud/src/lib.rs#L914), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:81`](../core/dr-sync/src/account.rs#L81), [`core/dr-sync/src/lib.rs:218`](../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/lib.rs:51`](../core/dr-sync/src/lib.rs#L51), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`core/dr-sync/src/provider.rs:1`](../core/dr-sync/src/provider.rs#L1), [`core/dr-sync/src/provider.rs:53`](../core/dr-sync/src/provider.rs#L53), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | | FR-NC-13 | [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`ui/dr-ui/src/launch_ui.rs:316`](../ui/dr-ui/src/launch_ui.rs#L316), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | | FR-NC-2 | [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`core/dr-sync/src/account.rs:59`](../core/dr-sync/src/account.rs#L59), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | -| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:85`](../core/dr-sync/src/capability.rs#L85), [`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.rs:2964`](../ui/dr-ui/src/library.rs#L2964), [`ui/dr-ui/src/library.rs:3472`](../ui/dr-ui/src/library.rs#L3472), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3709`](../ui/dr-ui/src/library_ui.rs#L3709), [`ui/dr-ui/src/library_ui.rs:4708`](../ui/dr-ui/src/library_ui.rs#L4708), [`ui/dr-ui/ui/app.slint:365`](../ui/dr-ui/ui/app.slint#L365), [`ui/dr-ui/ui/settings.slint:384`](../ui/dr-ui/ui/settings.slint#L384), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | +| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:85`](../core/dr-sync/src/capability.rs#L85), [`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.rs:2964`](../ui/dr-ui/src/library.rs#L2964), [`ui/dr-ui/src/library.rs:3472`](../ui/dr-ui/src/library.rs#L3472), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3709`](../ui/dr-ui/src/library_ui.rs#L3709), [`ui/dr-ui/src/library_ui.rs:4715`](../ui/dr-ui/src/library_ui.rs#L4715), [`ui/dr-ui/ui/app.slint:374`](../ui/dr-ui/ui/app.slint#L374), [`ui/dr-ui/ui/settings.slint:384`](../ui/dr-ui/ui/settings.slint#L384), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | | FR-NC-4 | [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-nextcloud/src/propfind.rs:103`](../core/dr-sync-nextcloud/src/propfind.rs#L103), [`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:218`](../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:61`](../ui/dr-ui/src/launch.rs#L61) | | FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488) | | 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/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1093`](../core/dr-catalog/src/schema.rs#L1093), [`core/dr-catalog/src/schema.rs:692`](../core/dr-catalog/src/schema.rs#L692), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`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:3174`](../ui/dr-ui/src/collections_ui.rs#L3174), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1967`](../ui/dr-ui/src/lib.rs#L1967), [`ui/dr-ui/src/lib.rs:3074`](../ui/dr-ui/src/lib.rs#L3074), [`ui/dr-ui/src/library.rs:1565`](../ui/dr-ui/src/library.rs#L1565), [`ui/dr-ui/src/library.rs:1588`](../ui/dr-ui/src/library.rs#L1588), [`ui/dr-ui/src/library.rs:1863`](../ui/dr-ui/src/library.rs#L1863), [`ui/dr-ui/src/library_ui.rs:1121`](../ui/dr-ui/src/library_ui.rs#L1121), [`ui/dr-ui/src/library_ui.rs:1194`](../ui/dr-ui/src/library_ui.rs#L1194), [`ui/dr-ui/src/library_ui.rs:1295`](../ui/dr-ui/src/library_ui.rs#L1295), [`ui/dr-ui/src/library_ui.rs:1350`](../ui/dr-ui/src/library_ui.rs#L1350), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:1603`](../ui/dr-ui/src/library_ui.rs#L1603), [`ui/dr-ui/src/library_ui.rs:2148`](../ui/dr-ui/src/library_ui.rs#L2148), [`ui/dr-ui/src/library_ui.rs:289`](../ui/dr-ui/src/library_ui.rs#L289), [`ui/dr-ui/src/library_ui.rs:300`](../ui/dr-ui/src/library_ui.rs#L300), [`ui/dr-ui/src/library_ui.rs:308`](../ui/dr-ui/src/library_ui.rs#L308), [`ui/dr-ui/src/library_ui.rs:320`](../ui/dr-ui/src/library_ui.rs#L320), [`ui/dr-ui/src/library_ui.rs:329`](../ui/dr-ui/src/library_ui.rs#L329), [`ui/dr-ui/src/library_ui.rs:422`](../ui/dr-ui/src/library_ui.rs#L422), [`ui/dr-ui/src/library_ui.rs:432`](../ui/dr-ui/src/library_ui.rs#L432), [`ui/dr-ui/src/library_ui.rs:479`](../ui/dr-ui/src/library_ui.rs#L479), [`ui/dr-ui/src/library_ui.rs:542`](../ui/dr-ui/src/library_ui.rs#L542), [`ui/dr-ui/src/library_ui.rs:5462`](../ui/dr-ui/src/library_ui.rs#L5462), [`ui/dr-ui/src/library_ui.rs:5480`](../ui/dr-ui/src/library_ui.rs#L5480), [`ui/dr-ui/src/library_ui.rs:5492`](../ui/dr-ui/src/library_ui.rs#L5492), [`ui/dr-ui/src/library_ui.rs:573`](../ui/dr-ui/src/library_ui.rs#L573), [`ui/dr-ui/src/library_ui.rs:585`](../ui/dr-ui/src/library_ui.rs#L585), [`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:2495`](../ui/dr-ui/ui/app.slint#L2495), [`ui/dr-ui/ui/app.slint:480`](../ui/dr-ui/ui/app.slint#L480), [`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:1007`](../ui/dr-ui/ui/library.slint#L1007) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1093`](../core/dr-catalog/src/schema.rs#L1093), [`core/dr-catalog/src/schema.rs:692`](../core/dr-catalog/src/schema.rs#L692), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`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:3467`](../ui/dr-ui/src/collections_ui.rs#L3467), [`ui/dr-ui/src/collections_ui.rs:664`](../ui/dr-ui/src/collections_ui.rs#L664), [`ui/dr-ui/src/collections_ui.rs:732`](../ui/dr-ui/src/collections_ui.rs#L732), [`ui/dr-ui/src/lib.rs:1971`](../ui/dr-ui/src/lib.rs#L1971), [`ui/dr-ui/src/lib.rs:3078`](../ui/dr-ui/src/lib.rs#L3078), [`ui/dr-ui/src/library.rs:1565`](../ui/dr-ui/src/library.rs#L1565), [`ui/dr-ui/src/library.rs:1588`](../ui/dr-ui/src/library.rs#L1588), [`ui/dr-ui/src/library.rs:1863`](../ui/dr-ui/src/library.rs#L1863), [`ui/dr-ui/src/library_ui.rs:1121`](../ui/dr-ui/src/library_ui.rs#L1121), [`ui/dr-ui/src/library_ui.rs:1194`](../ui/dr-ui/src/library_ui.rs#L1194), [`ui/dr-ui/src/library_ui.rs:1295`](../ui/dr-ui/src/library_ui.rs#L1295), [`ui/dr-ui/src/library_ui.rs:1350`](../ui/dr-ui/src/library_ui.rs#L1350), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:1603`](../ui/dr-ui/src/library_ui.rs#L1603), [`ui/dr-ui/src/library_ui.rs:2148`](../ui/dr-ui/src/library_ui.rs#L2148), [`ui/dr-ui/src/library_ui.rs:289`](../ui/dr-ui/src/library_ui.rs#L289), [`ui/dr-ui/src/library_ui.rs:300`](../ui/dr-ui/src/library_ui.rs#L300), [`ui/dr-ui/src/library_ui.rs:308`](../ui/dr-ui/src/library_ui.rs#L308), [`ui/dr-ui/src/library_ui.rs:320`](../ui/dr-ui/src/library_ui.rs#L320), [`ui/dr-ui/src/library_ui.rs:329`](../ui/dr-ui/src/library_ui.rs#L329), [`ui/dr-ui/src/library_ui.rs:422`](../ui/dr-ui/src/library_ui.rs#L422), [`ui/dr-ui/src/library_ui.rs:432`](../ui/dr-ui/src/library_ui.rs#L432), [`ui/dr-ui/src/library_ui.rs:479`](../ui/dr-ui/src/library_ui.rs#L479), [`ui/dr-ui/src/library_ui.rs:542`](../ui/dr-ui/src/library_ui.rs#L542), [`ui/dr-ui/src/library_ui.rs:5497`](../ui/dr-ui/src/library_ui.rs#L5497), [`ui/dr-ui/src/library_ui.rs:5515`](../ui/dr-ui/src/library_ui.rs#L5515), [`ui/dr-ui/src/library_ui.rs:5527`](../ui/dr-ui/src/library_ui.rs#L5527), [`ui/dr-ui/src/library_ui.rs:573`](../ui/dr-ui/src/library_ui.rs#L573), [`ui/dr-ui/src/library_ui.rs:585`](../ui/dr-ui/src/library_ui.rs#L585), [`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:2532`](../ui/dr-ui/ui/app.slint#L2532), [`ui/dr-ui/ui/app.slint:498`](../ui/dr-ui/ui/app.slint#L498), [`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:1017`](../ui/dr-ui/ui/library.slint#L1017) | | FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1350`](../ui/dr-ui/src/library_ui.rs#L1350), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | -| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:818`](../core/dr-sync-folder/src/lib.rs#L818), [`core/dr-sync-folder/src/lib.rs:857`](../core/dr-sync-folder/src/lib.rs#L857), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:167`](../core/dr-sync-nextcloud/src/desktop_client.rs#L167), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:120`](../core/dr-types/src/lib.rs#L120), [`core/dr-types/src/lib.rs:202`](../core/dr-types/src/lib.rs#L202), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3174`](../ui/dr-ui/src/collections_ui.rs#L3174), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/derived_sync.rs:627`](../ui/dr-ui/src/derived_sync.rs#L627), [`ui/dr-ui/src/library.rs:1685`](../ui/dr-ui/src/library.rs#L1685), [`ui/dr-ui/src/library.rs:1775`](../ui/dr-ui/src/library.rs#L1775), [`ui/dr-ui/src/library.rs:3254`](../ui/dr-ui/src/library.rs#L3254), [`ui/dr-ui/src/library.rs:3592`](../ui/dr-ui/src/library.rs#L3592), [`ui/dr-ui/src/library.rs:981`](../ui/dr-ui/src/library.rs#L981), [`ui/dr-ui/src/library_ui.rs:1121`](../ui/dr-ui/src/library_ui.rs#L1121), [`ui/dr-ui/src/library_ui.rs:1194`](../ui/dr-ui/src/library_ui.rs#L1194), [`ui/dr-ui/src/library_ui.rs:1393`](../ui/dr-ui/src/library_ui.rs#L1393), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`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-6c | [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:818`](../core/dr-sync-folder/src/lib.rs#L818), [`core/dr-sync-folder/src/lib.rs:857`](../core/dr-sync-folder/src/lib.rs#L857), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:167`](../core/dr-sync-nextcloud/src/desktop_client.rs#L167), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:120`](../core/dr-types/src/lib.rs#L120), [`core/dr-types/src/lib.rs:202`](../core/dr-types/src/lib.rs#L202), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3467`](../ui/dr-ui/src/collections_ui.rs#L3467), [`ui/dr-ui/src/collections_ui.rs:664`](../ui/dr-ui/src/collections_ui.rs#L664), [`ui/dr-ui/src/collections_ui.rs:732`](../ui/dr-ui/src/collections_ui.rs#L732), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/derived_sync.rs:627`](../ui/dr-ui/src/derived_sync.rs#L627), [`ui/dr-ui/src/library.rs:1685`](../ui/dr-ui/src/library.rs#L1685), [`ui/dr-ui/src/library.rs:1775`](../ui/dr-ui/src/library.rs#L1775), [`ui/dr-ui/src/library.rs:3254`](../ui/dr-ui/src/library.rs#L3254), [`ui/dr-ui/src/library.rs:3592`](../ui/dr-ui/src/library.rs#L3592), [`ui/dr-ui/src/library.rs:981`](../ui/dr-ui/src/library.rs#L981), [`ui/dr-ui/src/library_ui.rs:1121`](../ui/dr-ui/src/library_ui.rs#L1121), [`ui/dr-ui/src/library_ui.rs:1194`](../ui/dr-ui/src/library_ui.rs#L1194), [`ui/dr-ui/src/library_ui.rs:1393`](../ui/dr-ui/src/library_ui.rs#L1393), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`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-6d | [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1) | | FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:105`](../core/dr-sync-nextcloud/src/lib.rs#L105), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3472`](../ui/dr-ui/src/library.rs#L3472), [`ui/dr-ui/src/library_ui.rs:3709`](../ui/dr-ui/src/library_ui.rs#L3709), [`ui/dr-ui/ui/settings.slint:384`](../ui/dr-ui/ui/settings.slint#L384) | -| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1201`](../ui/dr-ui/src/lib.rs#L1201), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | -| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:122`](../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../ui/dr-ui/src/import.rs#L584), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1201`](../ui/dr-ui/src/lib.rs#L1201) | -| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:119`](../core/dr-pipeline/src/sidecar.rs#L119), [`core/dr-pipeline/src/sidecar.rs:93`](../core/dr-pipeline/src/sidecar.rs#L93), [`ui/dr-ui/src/lib.rs:1937`](../ui/dr-ui/src/lib.rs#L1937), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library_ui.rs:494`](../ui/dr-ui/src/library_ui.rs#L494) | +| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:206`](../core/dr-types/src/settings.rs#L206), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1205`](../ui/dr-ui/src/lib.rs#L1205), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | +| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:122`](../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../ui/dr-ui/src/import.rs#L584), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1205`](../ui/dr-ui/src/lib.rs#L1205) | +| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:119`](../core/dr-pipeline/src/sidecar.rs#L119), [`core/dr-pipeline/src/sidecar.rs:93`](../core/dr-pipeline/src/sidecar.rs#L93), [`ui/dr-ui/src/lib.rs:1941`](../ui/dr-ui/src/lib.rs#L1941), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library_ui.rs:494`](../ui/dr-ui/src/library_ui.rs#L494) | | FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:635`](../core/dr-catalog/src/schema.rs#L635), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:157`](../core/dr-pipeline/src/sidecar.rs#L157), [`core/dr-pipeline/src/sidecar.rs:184`](../core/dr-pipeline/src/sidecar.rs#L184), [`core/dr-pipeline/src/sidecar.rs:2034`](../core/dr-pipeline/src/sidecar.rs#L2034), [`core/dr-pipeline/src/sidecar.rs:353`](../core/dr-pipeline/src/sidecar.rs#L353), [`core/dr-pipeline/src/sidecar.rs:451`](../core/dr-pipeline/src/sidecar.rs#L451), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/library.rs:1031`](../ui/dr-ui/src/library.rs#L1031), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:54`](../core/dr-types/src/lib.rs#L54), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) | | FR-PLAT-AND-2 | [`core/dr-catalog/src/walk.rs:1022`](../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-sync-folder/src/lib.rs:242`](../core/dr-sync-folder/src/lib.rs#L242), [`core/dr-sync-folder/src/tests.rs:51`](../core/dr-sync-folder/src/tests.rs#L51), [`core/dr-sync/src/error.rs:113`](../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/error.rs:195`](../core/dr-sync/src/error.rs#L195), [`core/dr-sync/src/scan.rs:175`](../core/dr-sync/src/scan.rs#L175), [`core/dr-sync/src/scan.rs:464`](../core/dr-sync/src/scan.rs#L464), [`core/dr-sync/src/scan.rs:490`](../core/dr-sync/src/scan.rs#L490), [`core/dr-sync/src/scan.rs:519`](../core/dr-sync/src/scan.rs#L519), [`ui/dr-ui/src/library.rs:1222`](../ui/dr-ui/src/library.rs#L1222), [`ui/dr-ui/src/library.rs:1275`](../ui/dr-ui/src/library.rs#L1275), [`ui/dr-ui/src/library.rs:1306`](../ui/dr-ui/src/library.rs#L1306), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library_ui.rs:1045`](../ui/dr-ui/src/library_ui.rs#L1045), [`ui/dr-ui/src/library_ui.rs:1644`](../ui/dr-ui/src/library_ui.rs#L1644), [`ui/dr-ui/src/library_ui.rs:273`](../ui/dr-ui/src/library_ui.rs#L273), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:966`](../ui/dr-ui/src/library_ui.rs#L966) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | -| FR-PLAT-AND-5 | [`apps/darkroom-android/src/lib.rs:54`](../apps/darkroom-android/src/lib.rs#L54), [`core/dr-gpu/src/adjust.rs:1029`](../core/dr-gpu/src/adjust.rs#L1029), [`core/dr-gpu/src/detail.rs:161`](../core/dr-gpu/src/detail.rs#L161), [`core/dr-gpu/src/detail.rs:547`](../core/dr-gpu/src/detail.rs#L547), [`core/dr-gpu/tests/detail_stage.rs:417`](../core/dr-gpu/tests/detail_stage.rs#L417), [`ui/dr-ui/src/develop.rs:3069`](../ui/dr-ui/src/develop.rs#L3069), [`ui/dr-ui/src/identity_ui.rs:109`](../ui/dr-ui/src/identity_ui.rs#L109), [`ui/dr-ui/src/lib.rs:1016`](../ui/dr-ui/src/lib.rs#L1016), [`ui/dr-ui/src/lib.rs:1454`](../ui/dr-ui/src/lib.rs#L1454), [`ui/dr-ui/src/memory.rs:163`](../ui/dr-ui/src/memory.rs#L163), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | +| FR-PLAT-AND-5 | [`apps/darkroom-android/src/lib.rs:54`](../apps/darkroom-android/src/lib.rs#L54), [`core/dr-gpu/src/adjust.rs:1029`](../core/dr-gpu/src/adjust.rs#L1029), [`core/dr-gpu/src/detail.rs:161`](../core/dr-gpu/src/detail.rs#L161), [`core/dr-gpu/src/detail.rs:547`](../core/dr-gpu/src/detail.rs#L547), [`core/dr-gpu/tests/detail_stage.rs:417`](../core/dr-gpu/tests/detail_stage.rs#L417), [`ui/dr-ui/src/develop.rs:3069`](../ui/dr-ui/src/develop.rs#L3069), [`ui/dr-ui/src/identity_ui.rs:115`](../ui/dr-ui/src/identity_ui.rs#L115), [`ui/dr-ui/src/lib.rs:1029`](../ui/dr-ui/src/lib.rs#L1029), [`ui/dr-ui/src/lib.rs:1458`](../ui/dr-ui/src/lib.rs#L1458), [`ui/dr-ui/src/memory.rs:163`](../ui/dr-ui/src/memory.rs#L163), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | | FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:928`](../ui/dr-ui/src/lib.rs#L928), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | | FR-PLAT-LIN-2 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1) | | FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:635`](../core/dr-pipeline/src/descriptor.rs#L635), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/tests/declared_parity.rs:1`](../core/dr-pipeline/tests/declared_parity.rs#L1), [`core/dr-pipeline/tests/declared_parity.rs:240`](../core/dr-pipeline/tests/declared_parity.rs#L240), [`core/dr-pipeline/tests/declared_parity.rs:305`](../core/dr-pipeline/tests/declared_parity.rs#L305), [`core/dr-pipeline/tests/declared_parity.rs:358`](../core/dr-pipeline/tests/declared_parity.rs#L358), [`core/dr-pipeline/tests/declared_parity.rs:416`](../core/dr-pipeline/tests/declared_parity.rs#L416) | @@ -118,22 +118,22 @@ _None._ | FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) | | FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:208`](../ui/dr-ui/src/lib.rs#L208) | | FR-RAW-5 | [`core/dr-decode/src/lib.rs:167`](../core/dr-decode/src/lib.rs#L167), [`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:3156`](../ui/dr-ui/src/lib.rs#L3156), [`ui/dr-ui/src/lib.rs:84`](../ui/dr-ui/src/lib.rs#L84), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/identity.slint:164`](../ui/dr-ui/ui/identity.slint#L164), [`ui/dr-ui/ui/library.slint:1073`](../ui/dr-ui/ui/library.slint#L1073) | -| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:1488`](../ui/dr-ui/src/collections_ui.rs#L1488), [`ui/dr-ui/src/collections_ui.rs:1502`](../ui/dr-ui/src/collections_ui.rs#L1502), [`ui/dr-ui/src/collections_ui.rs:1548`](../ui/dr-ui/src/collections_ui.rs#L1548), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1646`](../ui/dr-ui/src/collections_ui.rs#L1646), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/lib.rs:84`](../ui/dr-ui/src/lib.rs#L84), [`ui/dr-ui/src/lib.rs:91`](../ui/dr-ui/src/lib.rs#L91), [`ui/dr-ui/src/library_ui.rs:308`](../ui/dr-ui/src/library_ui.rs#L308), [`ui/dr-ui/src/library_ui.rs:5347`](../ui/dr-ui/src/library_ui.rs#L5347), [`ui/dr-ui/src/library_ui.rs:5492`](../ui/dr-ui/src/library_ui.rs#L5492), [`ui/dr-ui/src/library_ui.rs:6623`](../ui/dr-ui/src/library_ui.rs#L6623), [`ui/dr-ui/ui/adjust.slint:1072`](../ui/dr-ui/ui/adjust.slint#L1072), [`ui/dr-ui/ui/adjust.slint:544`](../ui/dr-ui/ui/adjust.slint#L544), [`ui/dr-ui/ui/adjust.slint:741`](../ui/dr-ui/ui/adjust.slint#L741), [`ui/dr-ui/ui/app.slint:2065`](../ui/dr-ui/ui/app.slint#L2065), [`ui/dr-ui/ui/app.slint:510`](../ui/dr-ui/ui/app.slint#L510), [`ui/dr-ui/ui/app.slint:517`](../ui/dr-ui/ui/app.slint#L517), [`ui/dr-ui/ui/app.slint:56`](../ui/dr-ui/ui/app.slint#L56), [`ui/dr-ui/ui/app.slint:848`](../ui/dr-ui/ui/app.slint#L848), [`ui/dr-ui/ui/controls.slint:474`](../ui/dr-ui/ui/controls.slint#L474), [`ui/dr-ui/ui/develop.slint:223`](../ui/dr-ui/ui/develop.slint#L223), [`ui/dr-ui/ui/histogram.slint:127`](../ui/dr-ui/ui/histogram.slint#L127), [`ui/dr-ui/ui/history.slint:118`](../ui/dr-ui/ui/history.slint#L118), [`ui/dr-ui/ui/library.slint:1234`](../ui/dr-ui/ui/library.slint#L1234), [`ui/dr-ui/ui/library.slint:1241`](../ui/dr-ui/ui/library.slint#L1241), [`ui/dr-ui/ui/library.slint:1247`](../ui/dr-ui/ui/library.slint#L1247), [`ui/dr-ui/ui/library.slint:2350`](../ui/dr-ui/ui/library.slint#L2350), [`ui/dr-ui/ui/library.slint:839`](../ui/dr-ui/ui/library.slint#L839), [`ui/dr-ui/ui/library.slint:892`](../ui/dr-ui/ui/library.slint#L892), [`ui/dr-ui/ui/masks.slint:314`](../ui/dr-ui/ui/masks.slint#L314), [`ui/dr-ui/ui/peaking.slint:82`](../ui/dr-ui/ui/peaking.slint#L82), [`ui/dr-ui/ui/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106), [`ui/dr-ui/ui/spots.slint:88`](../ui/dr-ui/ui/spots.slint#L88) | -| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2224`](../ui/dr-ui/src/develop.rs#L2224), [`ui/dr-ui/src/develop.rs:2333`](../ui/dr-ui/src/develop.rs#L2333), [`ui/dr-ui/src/library_ui.rs:4503`](../ui/dr-ui/src/library_ui.rs#L4503), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:1881`](../ui/dr-ui/ui/app.slint#L1881), [`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), [`ui/dr-ui/ui/masks.slint:524`](../ui/dr-ui/ui/masks.slint#L524) | -| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:131`](../ui/dr-ui/src/collections_ui.rs#L131), [`ui/dr-ui/src/collections_ui.rs:1488`](../ui/dr-ui/src/collections_ui.rs#L1488), [`ui/dr-ui/src/collections_ui.rs:1502`](../ui/dr-ui/src/collections_ui.rs#L1502), [`ui/dr-ui/src/collections_ui.rs:1548`](../ui/dr-ui/src/collections_ui.rs#L1548), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1646`](../ui/dr-ui/src/collections_ui.rs#L1646), [`ui/dr-ui/src/collections_ui.rs:1673`](../ui/dr-ui/src/collections_ui.rs#L1673), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:582`](../ui/dr-ui/src/collections_ui.rs#L582), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/library_ui.rs:4503`](../ui/dr-ui/src/library_ui.rs#L4503), [`ui/dr-ui/src/library_ui.rs:4548`](../ui/dr-ui/src/library_ui.rs#L4548), [`ui/dr-ui/src/library_ui.rs:4651`](../ui/dr-ui/src/library_ui.rs#L4651), [`ui/dr-ui/src/library_ui.rs:4679`](../ui/dr-ui/src/library_ui.rs#L4679), [`ui/dr-ui/src/library_ui.rs:5480`](../ui/dr-ui/src/library_ui.rs#L5480), [`ui/dr-ui/src/library_ui.rs:5492`](../ui/dr-ui/src/library_ui.rs#L5492), [`ui/dr-ui/ui/app.slint:1721`](../ui/dr-ui/ui/app.slint#L1721), [`ui/dr-ui/ui/app.slint:486`](../ui/dr-ui/ui/app.slint#L486), [`ui/dr-ui/ui/app.slint:517`](../ui/dr-ui/ui/app.slint#L517), [`ui/dr-ui/ui/library.slint:1234`](../ui/dr-ui/ui/library.slint#L1234), [`ui/dr-ui/ui/library.slint:1241`](../ui/dr-ui/ui/library.slint#L1241), [`ui/dr-ui/ui/library.slint:1247`](../ui/dr-ui/ui/library.slint#L1247), [`ui/dr-ui/ui/library.slint:2350`](../ui/dr-ui/ui/library.slint#L2350), [`ui/dr-ui/ui/library.slint:839`](../ui/dr-ui/ui/library.slint#L839), [`ui/dr-ui/ui/library.slint:892`](../ui/dr-ui/ui/library.slint#L892), [`ui/dr-ui/ui/library.slint:911`](../ui/dr-ui/ui/library.slint#L911) | -| 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:2593`](../ui/dr-ui/src/lib.rs#L2593), [`ui/dr-ui/src/lib.rs:3195`](../ui/dr-ui/src/lib.rs#L3195), [`ui/dr-ui/src/lib.rs:3380`](../ui/dr-ui/src/lib.rs#L3380), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:107`](../ui/dr-ui/ui/app.slint#L107), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| FR-UI-1 | [`ui/dr-ui/src/lib.rs:3160`](../ui/dr-ui/src/lib.rs#L3160), [`ui/dr-ui/src/lib.rs:84`](../ui/dr-ui/src/lib.rs#L84), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/identity.slint:165`](../ui/dr-ui/ui/identity.slint#L165), [`ui/dr-ui/ui/library.slint:1083`](../ui/dr-ui/ui/library.slint#L1083) | +| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1067`](../ui/dr-ui/src/collections_ui.rs#L1067), [`ui/dr-ui/src/collections_ui.rs:1093`](../ui/dr-ui/src/collections_ui.rs#L1093), [`ui/dr-ui/src/collections_ui.rs:119`](../ui/dr-ui/src/collections_ui.rs#L119), [`ui/dr-ui/src/collections_ui.rs:1580`](../ui/dr-ui/src/collections_ui.rs#L1580), [`ui/dr-ui/src/collections_ui.rs:1594`](../ui/dr-ui/src/collections_ui.rs#L1594), [`ui/dr-ui/src/collections_ui.rs:1640`](../ui/dr-ui/src/collections_ui.rs#L1640), [`ui/dr-ui/src/collections_ui.rs:170`](../ui/dr-ui/src/collections_ui.rs#L170), [`ui/dr-ui/src/collections_ui.rs:1892`](../ui/dr-ui/src/collections_ui.rs#L1892), [`ui/dr-ui/src/collections_ui.rs:528`](../ui/dr-ui/src/collections_ui.rs#L528), [`ui/dr-ui/src/collections_ui.rs:557`](../ui/dr-ui/src/collections_ui.rs#L557), [`ui/dr-ui/src/lib.rs:84`](../ui/dr-ui/src/lib.rs#L84), [`ui/dr-ui/src/lib.rs:91`](../ui/dr-ui/src/lib.rs#L91), [`ui/dr-ui/src/library_ui.rs:308`](../ui/dr-ui/src/library_ui.rs#L308), [`ui/dr-ui/src/library_ui.rs:5382`](../ui/dr-ui/src/library_ui.rs#L5382), [`ui/dr-ui/src/library_ui.rs:5527`](../ui/dr-ui/src/library_ui.rs#L5527), [`ui/dr-ui/src/library_ui.rs:6732`](../ui/dr-ui/src/library_ui.rs#L6732), [`ui/dr-ui/ui/adjust.slint:1072`](../ui/dr-ui/ui/adjust.slint#L1072), [`ui/dr-ui/ui/adjust.slint:544`](../ui/dr-ui/ui/adjust.slint#L544), [`ui/dr-ui/ui/adjust.slint:741`](../ui/dr-ui/ui/adjust.slint#L741), [`ui/dr-ui/ui/app.slint:2102`](../ui/dr-ui/ui/app.slint#L2102), [`ui/dr-ui/ui/app.slint:528`](../ui/dr-ui/ui/app.slint#L528), [`ui/dr-ui/ui/app.slint:535`](../ui/dr-ui/ui/app.slint#L535), [`ui/dr-ui/ui/app.slint:56`](../ui/dr-ui/ui/app.slint#L56), [`ui/dr-ui/ui/app.slint:870`](../ui/dr-ui/ui/app.slint#L870), [`ui/dr-ui/ui/controls.slint:474`](../ui/dr-ui/ui/controls.slint#L474), [`ui/dr-ui/ui/develop.slint:223`](../ui/dr-ui/ui/develop.slint#L223), [`ui/dr-ui/ui/histogram.slint:127`](../ui/dr-ui/ui/histogram.slint#L127), [`ui/dr-ui/ui/history.slint:118`](../ui/dr-ui/ui/history.slint#L118), [`ui/dr-ui/ui/library.slint:1252`](../ui/dr-ui/ui/library.slint#L1252), [`ui/dr-ui/ui/library.slint:1259`](../ui/dr-ui/ui/library.slint#L1259), [`ui/dr-ui/ui/library.slint:1265`](../ui/dr-ui/ui/library.slint#L1265), [`ui/dr-ui/ui/library.slint:1306`](../ui/dr-ui/ui/library.slint#L1306), [`ui/dr-ui/ui/library.slint:2621`](../ui/dr-ui/ui/library.slint#L2621), [`ui/dr-ui/ui/library.slint:849`](../ui/dr-ui/ui/library.slint#L849), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902), [`ui/dr-ui/ui/masks.slint:314`](../ui/dr-ui/ui/masks.slint#L314), [`ui/dr-ui/ui/peaking.slint:82`](../ui/dr-ui/ui/peaking.slint#L82), [`ui/dr-ui/ui/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106), [`ui/dr-ui/ui/spots.slint:88`](../ui/dr-ui/ui/spots.slint#L88) | +| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2224`](../ui/dr-ui/src/develop.rs#L2224), [`ui/dr-ui/src/develop.rs:2333`](../ui/dr-ui/src/develop.rs#L2333), [`ui/dr-ui/src/library_ui.rs:4503`](../ui/dr-ui/src/library_ui.rs#L4503), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:1918`](../ui/dr-ui/ui/app.slint#L1918), [`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), [`ui/dr-ui/ui/masks.slint:524`](../ui/dr-ui/ui/masks.slint#L524) | +| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1067`](../ui/dr-ui/src/collections_ui.rs#L1067), [`ui/dr-ui/src/collections_ui.rs:1077`](../ui/dr-ui/src/collections_ui.rs#L1077), [`ui/dr-ui/src/collections_ui.rs:1093`](../ui/dr-ui/src/collections_ui.rs#L1093), [`ui/dr-ui/src/collections_ui.rs:119`](../ui/dr-ui/src/collections_ui.rs#L119), [`ui/dr-ui/src/collections_ui.rs:132`](../ui/dr-ui/src/collections_ui.rs#L132), [`ui/dr-ui/src/collections_ui.rs:1580`](../ui/dr-ui/src/collections_ui.rs#L1580), [`ui/dr-ui/src/collections_ui.rs:1594`](../ui/dr-ui/src/collections_ui.rs#L1594), [`ui/dr-ui/src/collections_ui.rs:160`](../ui/dr-ui/src/collections_ui.rs#L160), [`ui/dr-ui/src/collections_ui.rs:1640`](../ui/dr-ui/src/collections_ui.rs#L1640), [`ui/dr-ui/src/collections_ui.rs:170`](../ui/dr-ui/src/collections_ui.rs#L170), [`ui/dr-ui/src/collections_ui.rs:1756`](../ui/dr-ui/src/collections_ui.rs#L1756), [`ui/dr-ui/src/collections_ui.rs:1892`](../ui/dr-ui/src/collections_ui.rs#L1892), [`ui/dr-ui/src/collections_ui.rs:1919`](../ui/dr-ui/src/collections_ui.rs#L1919), [`ui/dr-ui/src/collections_ui.rs:2804`](../ui/dr-ui/src/collections_ui.rs#L2804), [`ui/dr-ui/src/collections_ui.rs:346`](../ui/dr-ui/src/collections_ui.rs#L346), [`ui/dr-ui/src/collections_ui.rs:528`](../ui/dr-ui/src/collections_ui.rs#L528), [`ui/dr-ui/src/collections_ui.rs:557`](../ui/dr-ui/src/collections_ui.rs#L557), [`ui/dr-ui/src/collections_ui.rs:625`](../ui/dr-ui/src/collections_ui.rs#L625), [`ui/dr-ui/src/library_ui.rs:4503`](../ui/dr-ui/src/library_ui.rs#L4503), [`ui/dr-ui/src/library_ui.rs:4530`](../ui/dr-ui/src/library_ui.rs#L4530), [`ui/dr-ui/src/library_ui.rs:4555`](../ui/dr-ui/src/library_ui.rs#L4555), [`ui/dr-ui/src/library_ui.rs:4658`](../ui/dr-ui/src/library_ui.rs#L4658), [`ui/dr-ui/src/library_ui.rs:4686`](../ui/dr-ui/src/library_ui.rs#L4686), [`ui/dr-ui/src/library_ui.rs:5515`](../ui/dr-ui/src/library_ui.rs#L5515), [`ui/dr-ui/src/library_ui.rs:5527`](../ui/dr-ui/src/library_ui.rs#L5527), [`ui/dr-ui/ui/app.slint:1758`](../ui/dr-ui/ui/app.slint#L1758), [`ui/dr-ui/ui/app.slint:504`](../ui/dr-ui/ui/app.slint#L504), [`ui/dr-ui/ui/app.slint:535`](../ui/dr-ui/ui/app.slint#L535), [`ui/dr-ui/ui/library.slint:1252`](../ui/dr-ui/ui/library.slint#L1252), [`ui/dr-ui/ui/library.slint:1259`](../ui/dr-ui/ui/library.slint#L1259), [`ui/dr-ui/ui/library.slint:1265`](../ui/dr-ui/ui/library.slint#L1265), [`ui/dr-ui/ui/library.slint:1299`](../ui/dr-ui/ui/library.slint#L1299), [`ui/dr-ui/ui/library.slint:1306`](../ui/dr-ui/ui/library.slint#L1306), [`ui/dr-ui/ui/library.slint:2621`](../ui/dr-ui/ui/library.slint#L2621), [`ui/dr-ui/ui/library.slint:849`](../ui/dr-ui/ui/library.slint#L849), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902), [`ui/dr-ui/ui/library.slint:921`](../ui/dr-ui/ui/library.slint#L921) | +| 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:2597`](../ui/dr-ui/src/lib.rs#L2597), [`ui/dr-ui/src/lib.rs:3199`](../ui/dr-ui/src/lib.rs#L3199), [`ui/dr-ui/src/lib.rs:3384`](../ui/dr-ui/src/lib.rs#L3384), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:107`](../ui/dr-ui/ui/app.slint#L107), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | FR-UI-6 | [`ui/dr-ui/ui/widgets.slint:1`](../ui/dr-ui/ui/widgets.slint#L1) | | FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:3069`](../ui/dr-ui/src/library.rs#L3069) | -| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1553`](../ui/dr-ui/src/export.rs#L1553), [`ui/dr-ui/src/export.rs:1579`](../ui/dr-ui/src/export.rs#L1579), [`ui/dr-ui/src/export.rs:409`](../ui/dr-ui/src/export.rs#L409), [`ui/dr-ui/src/export.rs:435`](../ui/dr-ui/src/export.rs#L435), [`ui/dr-ui/src/lib.rs:2290`](../ui/dr-ui/src/lib.rs#L2290), [`ui/dr-ui/ui/app.slint:1024`](../ui/dr-ui/ui/app.slint#L1024), [`ui/dr-ui/ui/library.slint:961`](../ui/dr-ui/ui/library.slint#L961) | +| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1553`](../ui/dr-ui/src/export.rs#L1553), [`ui/dr-ui/src/export.rs:1579`](../ui/dr-ui/src/export.rs#L1579), [`ui/dr-ui/src/export.rs:409`](../ui/dr-ui/src/export.rs#L409), [`ui/dr-ui/src/export.rs:435`](../ui/dr-ui/src/export.rs#L435), [`ui/dr-ui/src/lib.rs:2294`](../ui/dr-ui/src/lib.rs#L2294), [`ui/dr-ui/ui/app.slint:1046`](../ui/dr-ui/ui/app.slint#L1046), [`ui/dr-ui/ui/library.slint:971`](../ui/dr-ui/ui/library.slint#L971) | | 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), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`ui/dr-ui/src/export.rs:499`](../ui/dr-ui/src/export.rs#L499) | | NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) | | NFR-OPS-3 | [`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) | | 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) | | NFR-P13 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) | -| NFR-P14 | [`core/dr-gpu/src/focus.rs:186`](../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`ui/dr-ui/src/develop.rs:2964`](../ui/dr-ui/src/develop.rs#L2964), [`ui/dr-ui/src/lib.rs:1670`](../ui/dr-ui/src/lib.rs#L1670) | -| NFR-P5 | [`core/dr-catalog/src/schema.rs:330`](../core/dr-catalog/src/schema.rs#L330), [`ui/dr-ui/src/library.rs:4122`](../ui/dr-ui/src/library.rs#L4122), [`ui/dr-ui/src/library.rs:4916`](../ui/dr-ui/src/library.rs#L4916), [`ui/dr-ui/src/library_ui.rs:4176`](../ui/dr-ui/src/library_ui.rs#L4176), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) | +| NFR-P14 | [`core/dr-gpu/src/focus.rs:186`](../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`ui/dr-ui/src/develop.rs:2964`](../ui/dr-ui/src/develop.rs#L2964), [`ui/dr-ui/src/lib.rs:1674`](../ui/dr-ui/src/lib.rs#L1674) | +| NFR-P5 | [`core/dr-catalog/src/schema.rs:330`](../core/dr-catalog/src/schema.rs#L330), [`ui/dr-ui/src/library.rs:4136`](../ui/dr-ui/src/library.rs#L4136), [`ui/dr-ui/src/library.rs:5070`](../ui/dr-ui/src/library.rs#L5070), [`ui/dr-ui/src/library_ui.rs:4176`](../ui/dr-ui/src/library_ui.rs#L4176), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) | | NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:942`](../ui/dr-ui/src/export.rs#L942), [`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), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) | | NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:270`](../core/dr-types/src/lib.rs#L270), [`core/dr-types/src/lib.rs:303`](../core/dr-types/src/lib.rs#L303), [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) | | NFR-PORT-2 | [`core/dr-gpu/src/lib.rs:1`](../core/dr-gpu/src/lib.rs#L1) | @@ -143,7 +143,7 @@ _None._ | 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-gpu/src/adjust.rs:1029`](../core/dr-gpu/src/adjust.rs#L1029), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/develop.rs:3069`](../ui/dr-ui/src/develop.rs#L3069), [`ui/dr-ui/src/identity_ui.rs:109`](../ui/dr-ui/src/identity_ui.rs#L109), [`ui/dr-ui/src/lib.rs:76`](../ui/dr-ui/src/lib.rs#L76), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | +| NFR-RES-1 | [`core/dr-gpu/src/adjust.rs:1029`](../core/dr-gpu/src/adjust.rs#L1029), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/develop.rs:3069`](../ui/dr-ui/src/develop.rs#L3069), [`ui/dr-ui/src/identity_ui.rs:115`](../ui/dr-ui/src/identity_ui.rs#L115), [`ui/dr-ui/src/lib.rs:76`](../ui/dr-ui/src/lib.rs#L76), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | | NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:692`](../core/dr-catalog/src/schema.rs#L692), [`core/dr-gpu/src/lib.rs:80`](../core/dr-gpu/src/lib.rs#L80), [`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), [`ui/dr-ui/src/library.rs:2964`](../ui/dr-ui/src/library.rs#L2964) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | | NFR-SEC-2 | [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | diff --git a/ui/dr-ui/examples/face_index.rs b/ui/dr-ui/examples/face_index.rs index 7c1f4a9..f8d09c3 100644 --- a/ui/dr-ui/examples/face_index.rs +++ b/ui/dr-ui/examples/face_index.rs @@ -88,7 +88,11 @@ fn main() { // whole-library operation over the embeddings detection produced, and it is // worth running *after* a sweep rather than during one (catalog.md §10.2). if args.iter().any(|a| a == "--cluster") { - match dr_ui::faces::recluster(&catalog, MODEL_ID, dr_face::DEFAULT_MERGE_PROBABILITY) { + // The engine's own defaults, not this device's settings file: a batch + // job run over a library on a server has no business inheriting the + // dials somebody moved on their laptop. + let grouping = dr_types::settings::FaceSettings::default(); + match dr_ui::faces::recluster(&catalog, MODEL_ID, &grouping) { Ok((suggested, created)) => { println!("\nclustering: {suggested} suggestion(s), {created} new group(s)"); report_people(&catalog); diff --git a/ui/dr-ui/src/collections_ui.rs b/ui/dr-ui/src/collections_ui.rs index f07717e..2031fe7 100644 --- a/ui/dr-ui/src/collections_ui.rs +++ b/ui/dr-ui/src/collections_ui.rs @@ -43,6 +43,7 @@ use dr_types::{CollectionId, ImageId}; use rusqlite::OptionalExtension as _; use slint::{ComponentHandle, Model as _}; +use crate::library; use crate::{AppWindow, CollectionRow}; /// The selection as it stood before a press, for [`cancel_press`]. @@ -156,6 +157,16 @@ pub struct CollectionsController { /// develop view on the second ctrl-click. Slint does not report modifiers on /// `clicked`, so the press records them and the click consults this. modified_press: std::cell::Cell, + /// TRACES: FR-UI-4 + /// When the press that began the current gesture landed, and whether a + /// finger did it. + /// + /// Read by the click that follows, to tell a tap from a graze — see + /// [`CollectionsController::press_was_a_graze`]. `None` between gestures, + /// which a click with no press before it is treated as: it cannot have been + /// deliberate if nothing pressed. + pressed_at: std::cell::Cell>, + touch_press: std::cell::Cell, /// TRACES: FR-UI-2 | FR-UI-4 /// Whether a tap in the grid selects rather than opens. /// @@ -332,6 +343,38 @@ impl CollectionsController { self.modified_press.get() } + /// TRACES: FR-UI-4 + /// Whether the contact that is ending was too brief to have meant anything. + /// + /// A hand crossing a tablet on its way to the scroll it intended produces a + /// press and a release a few tens of milliseconds apart, in the same place — + /// indistinguishable, to a `TouchArea`, from a tap, and it was opening + /// whichever photograph happened to be under the knuckle. Travel is already + /// answered (the Flickable claims the pointer and the press is cancelled); + /// what was left is the contact that does not travel and does not last. + /// + /// **Only a finger is held to this.** A mouse click is a discrete decision + /// made by a button and is routinely over in thirty milliseconds; applying + /// a dwell to it would make the desktop feel broken to fix a problem the + /// desktop does not have. + /// + /// And it only withholds the *open*. The press has already selected the + /// cell under the finger, which is the right failure mode: a graze leaves + /// something visible and reversible on screen rather than silently doing + /// nothing, and rather than throwing the user into develop. + pub fn press_was_a_graze(&self) -> bool { + if !self.touch_press.get() { + return false; + } + match self.pressed_at.get() { + Some(at) => at.elapsed() < std::time::Duration::from_millis(TAP_MIN_MS), + // A click with no press recorded before it. Not a graze — there is + // nothing to say it was one — and refusing it would be the one way + // this rule could make a photograph unopenable. + None => false, + } + } + /// Selected image ids, in a stable order. pub fn selected(&self) -> Vec { self.selection.borrow().iter().copied().collect() @@ -678,6 +721,12 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc, catalog *ctl.row_smart.borrow_mut() = smart; *ctl.row_has_children.borrow_mut() = has_kids; window.set_collection_rows(slint::ModelRc::new(slint::VecModel::from(out))); + + // The rows are what `sync_reorderable` reads, so it has to be told they + // changed: a collection that gains a child, or one that is deleted out from + // under the scope, changes whether the grid can be reordered without the + // scope itself moving. + sync_reorderable(window); } /// TRACES: FR-NC-6a | FR-NC-6c @@ -935,6 +984,29 @@ pub fn sync_selection(window: &AppWindow, ctl: &Rc, ids: window.set_library_selected_count(selection.len() as i32); } +/// TRACES: FR-CAT-7 +/// Whether what the grid is showing has an order the user can change. +/// +/// A single manual collection: not the whole library, not a saved filter whose +/// membership is a rule, and not a set — a set draws its descendants' images +/// too, and two children's `position` columns are unrelated integers that would +/// interleave arbitrarily. +/// +/// Read off the tree rows the sidebar already draws rather than asked of the +/// catalog. `smart` and `has_children` are the only two facts it needs and both +/// are in the model already, so this cannot disagree with what the user is +/// looking at, and a scope change costs no extra query. +fn sync_reorderable(window: &AppWindow) { + let id = window.get_collection_selected(); + let rows = window.get_collection_rows(); + let manual = id > 0 + && (0..rows.row_count()) + .filter_map(|i| rows.row_data(i)) + .find(|r| r.id == id) + .is_some_and(|r| !r.smart && !r.has_children); + window.set_library_reorderable(manual); +} + /// Refresh the per-cell "in this many collections" badges. /// /// One query for the whole window rather than one per cell: 120 cells is 120 @@ -1002,6 +1074,22 @@ const SPRING_DELAY_MS: u64 = 500; /// the hand lets go first, having concluded nothing was going to happen. pub(crate) const HOLD_DELAY_MS: u64 = 450; +/// TRACES: FR-UI-4 +/// How long a finger must stay down before letting go counts as opening a +/// photograph. +/// +/// **The floor under a tap, where `HOLD_DELAY_MS` is the ceiling.** Between the +/// two is a tap; below is a graze that only selects; above is a hold that +/// starts a selection. The three have to be one scale or the gesture set stops +/// being learnable. +/// +/// 120 ms is a tenth of the hold and about twice a brush. It costs nothing in +/// felt latency because it does not *delay* anything — the open still happens +/// on release, and this only decides whether that release counted — so the +/// error it can make is one-sided: an unusually quick deliberate tap selects +/// instead of opening, and the photograph is one further tap away. +pub(crate) const TAP_MIN_MS: u64 = 120; + /// TRACES: FR-UI-2 | FR-UI-4 /// Start the timer that turns a held cell into a selection. /// @@ -1471,13 +1559,17 @@ pub fn wire( let weak = window.as_weak(); let ctl = ctl.clone(); let visible = visible_ids.clone(); - window.on_library_cell_pressed(move |row, ctrl_held, shift_held| { + window.on_library_cell_pressed(move |row, ctrl_held, shift_held, touch| { let Some(w) = weak.upgrade() else { return }; let ids = visible(); // Consulted by the click that follows: a modified press is building // a selection and must not also navigate to develop. ctl.modified_press.set(ctrl_held || shift_held); + // And so is this: how long the contact lasts is what separates a + // tap from a graze, and only the press knows when it started. + ctl.pressed_at.set(Some(std::time::Instant::now())); + ctl.touch_press.set(touch); // Where the window starts, so the press is recorded as the ordinal // it is rather than as a row that stops meaning this photograph on @@ -1574,6 +1666,142 @@ pub fn wire( }); } + // TRACES: FR-CAT-7 + // Drag a photograph, or a whole selection of them, to a new place in the + // collection being shown. + // + // `collection_members.position` and `Sort::CollectionPosition` have been in + // the catalog since collections were, and until now nothing above it ever + // wrote or read them — the grid ordered by capture time whatever it was + // scoped to. `library::grid_order_for` reads them; this writes them. + // + // The membership is rewritten whole rather than patched. `set_order` sets + // the positions it is given and leaves the rest, so a partial write would + // interleave the moved run with rows whose positions nobody touched — and + // it is read unfiltered for the same reason: the user reorders what they + // can see, and what the filter is hiding keeps its place relative to it. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let catalog = catalog.clone(); + let visible = visible_ids.clone(); + let reload = on_scope_changed.clone(); + window.on_library_reorder_to(move |row, after| { + let Some(w) = weak.upgrade() else { return }; + // Both guards belong here rather than only in `library.slint`: the + // scope can change between the drag starting and the drop landing. + if !w.get_library_reorderable() { + return; + } + let Some(scope) = *ctl.scope.borrow() else { + return; + }; + + let moving = ctl.selected(); + if moving.is_empty() { + return; + } + + let ids = visible(); + let Some(&target) = usize::try_from(row).ok().and_then(|r| ids.get(r)) else { + return; + }; + + // Dropped on one of its own. There is no gap between a run and + // itself to land in, and rewriting the whole membership to say so + // would be a revision bump for no change. + if moving.contains(&target) { + return; + } + + let borrow = catalog.borrow(); + let Some(cat) = borrow.as_ref() else { return }; + + let current = match library::read_member_order(cat, scope) { + Ok(current) => current, + Err(e) => { + w.set_collection_error(format!("reading the collection's order: {e}").into()); + return; + } + }; + let wanted = library::reordered(¤t, &moving, target, after); + + match coll::set_order(cat.connection(), scope, &wanted) { + Ok(()) => { + w.set_collection_error(slint::SharedString::new()); + w.set_library_status( + format!( + "{} photograph{} moved", + moving.len(), + if moving.len() == 1 { "" } else { "s" } + ) + .into(), + ); + drop(borrow); + + // The selection survives. It is what was just moved, and + // dropping it would make a second nudge — which is how a + // drag-to-reorder is usually corrected — start over. + reload(); + sync_selection(&w, &ctl, &visible()); + } + Err(e) => { + drop(borrow); + w.set_collection_error(format!("reordering: {e}").into()); + } + } + }); + } + + // TRACES: FR-CAT-5 | FR-UI-4 + // Everything the grid is showing. + // + // Asked of the catalog through `span`, not read off the loaded window, for + // the reason spelled out in `apply_press`: the window is a hundred cells + // over a library of thousands, and a "select all" that quietly meant + // "select the hundred that happen to be loaded" is a lie the user cannot + // see until the export runs. `library_total` is the count the same scope + // and filter produced, so the run is the whole of what the grid claims. + // + // Replaces rather than adds. "All" is a statement about the result, not an + // increment, and a user who wanted the rest kept would not have reached for + // this. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let visible = visible_ids.clone(); + window.on_library_select_all(move || { + let Some(w) = weak.upgrade() else { return }; + let ids = visible(); + let total = w.get_library_total().max(0) as usize; + if total == 0 { + return; + } + + // The loaded window is the fallback, not the answer — the same + // trade the shift-click path makes. An empty span means no library + // or a failed query, and selecting what is on screen beats + // selecting nothing. + let all = match ctl.span(0, total - 1) { + run if run.is_empty() => ids.clone(), + run => run, + }; + + { + let mut selection = ctl.selection.borrow_mut(); + selection.clear(); + selection.extend(all); + } + + // The first frame becomes the anchor, so a "Select to…" straight + // afterwards describes a range from the top rather than from + // wherever the last individual tap left it — which, after taking + // everything, is not a place the user is still thinking about. + *ctl.anchor.borrow_mut() = Some(0); + sync_selection(&w, &ctl, &ids); + }); + } + // TRACES: FR-CAT-5 // A collection holding exactly what is selected. // @@ -1586,13 +1814,20 @@ pub fn wire( // the tree's "+". A selection can be gathered from anywhere, including // across collections, so filing it under whichever one happens to be open // would put it somewhere its contents did not come from. + // + // The name arrives already chosen. It used to be a placeholder, with the + // sidebar's rename field opened straight afterwards to correct it — which + // on a tablet meant opening a field inside a panel that is instantiated but + // not drawn, so it took the on-screen keyboard and could never give it + // back. `library.slint`'s naming sheet asks first, and nothing reaches the + // catalog until it is answered. { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let visible = visible_ids.clone(); let reload = on_scope_changed.clone(); - window.on_library_collection_from_selection(move || { + window.on_library_collection_from_selection(move |name| { let Some(w) = weak.upgrade() else { return }; let images = ctl.selected(); if images.is_empty() { @@ -1605,12 +1840,24 @@ pub fn wire( return; }; - let name = unique_name(cat.connection(), None); + // The sheet refuses an empty name in two places, so this is + // belt-and-braces rather than a path the UI can reach — but a + // collection with no name at all is unfindable in the tree, and + // falling back to the placeholder is recoverable where an empty + // row is not. + let name = match name.trim() { + "" => unique_name(cat.connection(), None), + typed => typed.to_string(), + }; + + // The id is not carried out. It used to be, to open the rename + // field on the row it names; now the name is already right, and + // `refresh_tree` redraws the tree from the catalog either way. let made = coll::create(cat.connection(), &name, None, CollectionKind::Manual) - .and_then(|id| coll::add_images(cat.connection(), id, &images).map(|n| (id, n))); + .and_then(|id| coll::add_images(cat.connection(), id, &images)); match made { - Ok((id, added)) => { + Ok(added) => { w.set_collection_error(slint::SharedString::new()); w.set_library_status( format!( @@ -1628,11 +1875,10 @@ pub fn wire( ctl.clear_selection(); sync_selection(&w, &ctl, &visible()); - // Into the name field, for the same reason `collection_new` - // does it: "New collection" is a placeholder nobody wants - // to keep, and making them find the rename afterwards is - // asking them to finish a job we started. - w.set_collection_renaming(id.0 as i32); + // No rename opened here. The name was chosen before the + // collection existed, so there is nothing left to correct — + // and opening the sidebar's field is what stranded the + // keyboard on a tablet. reload(); } Err(e) => { @@ -2164,6 +2410,7 @@ pub fn wire( }; w.set_collection_selected(id); w.set_collection_scope_label(label.into()); + sync_reorderable(&w); reload(); }); } @@ -2554,6 +2801,52 @@ mod tests { Vec::new() } + /// TRACES: FR-UI-4 + /// A hand brushing the tablet on its way to a scroll must not open a + /// photograph. It still *selects* one — the press did that, and something + /// visible and reversible is the right thing to be left with. + #[test] + fn a_graze_does_not_open_a_photograph() { + let ctl = CollectionsController::new(crate::activity::ActivityLog::new()); + ctl.touch_press.set(true); + ctl.pressed_at.set(Some(std::time::Instant::now())); + assert!( + ctl.press_was_a_graze(), + "a release in the same instant as the press is not a tap" + ); + } + + /// And a finger that stayed down is a tap, not a graze. + #[test] + fn a_deliberate_tap_opens_a_photograph() { + let ctl = CollectionsController::new(crate::activity::ActivityLog::new()); + ctl.touch_press.set(true); + ctl.pressed_at.set(Some( + std::time::Instant::now() - std::time::Duration::from_millis(TAP_MIN_MS + 10), + )); + assert!(!ctl.press_was_a_graze()); + } + + /// The desktop is not held to the dwell. A mouse button is a discrete + /// decision and is routinely down for thirty milliseconds. + #[test] + fn a_mouse_click_is_never_a_graze() { + let ctl = CollectionsController::new(crate::activity::ActivityLog::new()); + ctl.touch_press.set(false); + ctl.pressed_at.set(Some(std::time::Instant::now())); + assert!(!ctl.press_was_a_graze()); + } + + /// The floor has to sit under the ceiling, or there is no tap between them: + /// every press would be either a graze or a hold. + #[test] + fn a_tap_has_room_between_a_graze_and_a_hold() { + // A `const` block, so this is a compile error rather than a test + // failure: the two numbers are constants, and a gesture set with no + // room for a tap in it should not get as far as being run. + const { assert!(TAP_MIN_MS < HOLD_DELAY_MS) }; + } + #[test] fn dragging_a_collection_onto_another_moves_it() { // The gesture the tree rearrangement exists for: no images carried, a diff --git a/ui/dr-ui/src/faces.rs b/ui/dr-ui/src/faces.rs index 95e6f24..4e6675e 100644 --- a/ui/dr-ui/src/faces.rs +++ b/ui/dr-ui/src/faces.rs @@ -30,6 +30,7 @@ use dr_catalog::faces::{self, DetectedFace}; use dr_catalog::Catalog; use dr_face::{align, Calibration, DetectOptions, Detection, Detector, Embedder, ModelId}; use dr_thumbs::{ThumbSize, ThumbStore}; +use dr_types::settings::FaceSettings; use dr_types::ImageId; /// The tier faces are found on. See the module note. @@ -476,6 +477,148 @@ pub fn spawn_store_face_sweep( rx } +/// Everything a grouping pass reads before it decides anything. +/// +/// Split out because two callers need exactly this and must agree on it: the +/// pass that writes, and the preview that reports what the pass *would* do. A +/// preview built from a second, subtly different reading — anchors omitted, +/// say — would answer a question about a library nobody has. +struct Population { + cal: Calibration, + candidates: Vec, + /// Catalog ids, parallel to `candidates`. + ids: Vec, + /// Faces the user has ruled on, and who they are. See [`Population::read`]. + anchors: std::collections::HashMap, +} + +impl Population { + /// Takes the catalog and not its connection: `rusqlite` is `dr-catalog`'s + /// dependency and not this crate's, and reaching for the connection type by + /// name here would drag it across a layer that has kept clear of it. + fn read(catalog: &Catalog, model_id: &str) -> Result { + let conn = catalog.connection(); + // No valid calibration is not a reason to refuse to cluster — it is a + // reason not to *display* a confidence (FR-CULL-9). `Calibration:: + // default` is the reference implementation's fitted curve with `valid` + // false, which is a documented operating point rather than an invented + // one. + let cal = faces::calibration(conn, model_id)? + .map(|(c, _)| c) + .unwrap_or_else(Calibration::default); + + // Which faces the user has already ruled on, so they enter as anchors. + // + // Anything the user has ruled on anchors, and there are three ways of + // ruling — only the first of which is obvious. + // + // A **confirmation** is the plain case. **Setting a group aside** is one + // too, and the faces it covers are only ever suggestions, so anchoring + // confirmations alone let every ignored group scatter into fresh unnamed + // groups that were not ignored, and the strangers came straight back. + // + // And so is **giving a group a name**. That was the omission that did + // the most damage, because it is silent. Naming a cluster does not + // confirm its faces — they stay suggestions — so the next Regroup cut + // them loose, regrouped them into a brand new person, and left the named + // one holding nothing. `prune_empty_unnamed` will not remove it, because + // it has a name. Name the new group the same thing and it happens again. + // That is how one library came to hold sixteen people called Catherine, + // fourteen of them empty, with her faces split across the two that were + // not. + // + // A name is a judgement about *this group* (FR-CULL-12), exactly as an + // ignore is. Anchoring them all also does one better: a newly indexed + // face that matches a named person now merges *into* them rather than + // arriving as a stranger. + let mut anchors = std::collections::HashMap::new(); + for p in faces::people(conn)? { + let ruled_on = p.ignored || !p.name.trim().is_empty(); + for f in faces::for_person(conn, p.id, ruled_on)? { + anchors.insert(f.id, p.id); + } + } + + let stored = faces::embeddings(conn, model_id)?; + let model = ModelId::new(model_id.to_string()); + let mut candidates = Vec::with_capacity(stored.len()); + let mut ids = Vec::with_capacity(stored.len()); + for (face_id, image_id, blob, crop_px) in stored { + let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), &blob) else { + log::warn!("face {face_id:?} has a malformed embedding, skipped"); + continue; + }; + candidates.push(dr_face::Candidate { + face: face_id.0, + image: image_id.0, + embedding: emb.v.to_vec(), + crop_px, + confirmed_person: anchors.get(&face_id).map(|p| p.0), + }); + ids.push(face_id); + } + + Ok(Self { + cal, + candidates, + ids, + anchors, + }) + } +} + +/// What a grouping pass would produce, without producing it. +/// +/// The numbers `dr_face::cluster`'s own tuning table is built from, for one +/// setting rather than ten — because the question a photographer is actually +/// asking of the dials is "what does *my* library look like at this value", and +/// the doc-comment table answers it for a library that is not theirs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct GroupingPreview { + /// Faces that went in. + pub faces: usize, + /// Groups that would survive the smallest-group rule. + pub groups: usize, + /// Faces those groups would hold. + pub grouped: usize, + /// The biggest group. The tell for over-merging: it is the number that runs + /// away when the confidence is set too low. + pub largest: usize, +} + +/// Report what a grouping pass would do, writing nothing. +/// +/// Read-only by construction — it never opens a transaction — which is what +/// makes it safe to run repeatedly while the user moves a slider. Comparing two +/// settings by *applying* both would leave the second one's answer polluted by +/// the first one's suggestions. +pub fn preview_grouping( + catalog: &Catalog, + model_id: &str, + grouping: &FaceSettings, +) -> Result { + let min_group = grouping.min_group_size.max(1) as usize; + let pop = Population::read(catalog, model_id)?; + if pop.candidates.is_empty() { + return Ok(GroupingPreview::default()); + } + + let clusters = dr_face::cluster(&pop.candidates, &pop.cal, grouping.merge_probability); + // The same rule the writing pass applies, so the preview and the result + // cannot disagree about what counts as a person. + let kept: Vec<_> = clusters + .iter() + .filter(|c| c.members.len() >= min_group || c.person.is_some()) + .collect(); + + Ok(GroupingPreview { + faces: pop.candidates.len(), + groups: kept.len(), + grouped: kept.iter().map(|c| c.members.len()).sum(), + largest: kept.iter().map(|c| c.members.len()).max().unwrap_or(0), + }) +} + /// Group the library's faces into people, writing suggestions. /// /// Confirmations are never touched: they enter the clusterer as anchors and @@ -487,72 +630,22 @@ pub fn spawn_store_face_sweep( pub fn recluster( catalog: &Catalog, model_id: &str, - min_probability: f32, + grouping: &FaceSettings, ) -> Result<(usize, usize), dr_catalog::CatalogError> { + let min_probability = grouping.merge_probability; + let min_group = grouping.min_group_size.max(1) as usize; let conn = catalog.connection(); - // No valid calibration is not a reason to refuse to cluster — it is a - // reason not to *display* a confidence (FR-CULL-9). `Calibration::default` - // is the reference implementation's fitted curve with `valid` false, which - // is a documented operating point rather than an invented one. - let cal = faces::calibration(conn, model_id)? - .map(|(c, _)| c) - .unwrap_or_else(Calibration::default); - - let stored = faces::embeddings(conn, model_id)?; - if stored.is_empty() { + let Population { + cal, + candidates, + ids, + anchors: confirmed, + } = Population::read(catalog, model_id)?; + if candidates.is_empty() { return Ok((0, 0)); } - // Which faces the user has already ruled on, so they enter as anchors. - // - // Anything the user has ruled on anchors, and there are three ways of - // ruling — only the first of which is obvious. - // - // A **confirmation** is the plain case. **Setting a group aside** is one - // too, and the faces it covers are only ever suggestions, so anchoring - // confirmations alone let every ignored group scatter into fresh unnamed - // groups that were not ignored, and the strangers came straight back. - // - // And so is **giving a group a name**. That was the omission that did the - // most damage, because it is silent. Naming a cluster does not confirm its - // faces — they stay suggestions — so the next Regroup cut them loose, - // regrouped them into a brand new person, and left the named one holding - // nothing. `prune_empty_unnamed` will not remove it, because it has a name. - // Name the new group the same thing and it happens again. That is how one - // library came to hold sixteen people called Catherine, fourteen of them - // empty, with her faces split across the two that were not. - // - // A name is a judgement about *this group* (FR-CULL-12), exactly as an - // ignore is. Anchoring them all also does one better: a newly indexed face - // that matches a named person now merges *into* them rather than arriving - // as a stranger. - let mut confirmed = std::collections::HashMap::new(); - for p in faces::people(conn)? { - let ruled_on = p.ignored || !p.name.trim().is_empty(); - for f in faces::for_person(conn, p.id, ruled_on)? { - confirmed.insert(f.id, p.id); - } - } - - let model = ModelId::new(model_id.to_string()); - let mut candidates = Vec::with_capacity(stored.len()); - let mut ids = Vec::with_capacity(stored.len()); - for (face_id, image_id, blob, crop_px) in stored { - let Some(emb) = dr_face::Embedding::from_f16_bytes(model.clone(), &blob) else { - log::warn!("face {face_id:?} has a malformed embedding, skipped"); - continue; - }; - candidates.push(dr_face::Candidate { - face: face_id.0, - image: image_id.0, - embedding: emb.v.to_vec(), - crop_px, - confirmed_person: confirmed.get(&face_id).map(|p| p.0), - }); - ids.push(face_id); - } - let dr_face::Grouping { clusters, confidence, @@ -560,10 +653,20 @@ pub fn recluster( let mut suggested = 0usize; let mut created = 0usize; + // Every face this pass actually placed. What is *not* in here at the end is + // a face the previous pass had an opinion about and this one does not, and + // it has to be let go — see below. + let mut placed = std::collections::HashSet::with_capacity(ids.len()); for c in &clusters { - // A group of one is not a person. Naming every stray face would fill - // the People view with noise the user then has to dismiss. - if c.members.len() < 2 && c.person.is_none() { + // A group of one is not a person, and the user says how much bigger + // than one it has to be (`FaceSettings::min_group_size`). Naming every + // stray face would fill the People view with noise they then have to + // dismiss one entry at a time. + // + // Only ever applied to a group the system invented. A group with a + // `person` is one the user has already confirmed, named or set aside, + // and a display preference does not overrule a judgement (FR-CULL-12). + if c.members.len() < min_group && c.person.is_none() { continue; } @@ -579,6 +682,7 @@ pub fn recluster( for &m in &c.members { let face = ids[m]; + placed.insert(face); if confirmed.contains_key(&face) { continue; } @@ -594,6 +698,31 @@ pub fn recluster( } } + // Faces the previous pass placed and this one did not. + // + // Without this the parameters above are only half connected to the screen. + // Raise the smallest group to three and the pass stops *creating* groups of + // two — but last pass's group of two still holds its two suggestions, so it + // is not empty, so the prune below leaves it, and the rail does not change. + // The setting would appear to do nothing until the library was reindexed. + // + // Suggestions are this pass's own output (FR-CULL-10), so withdrawing one it + // no longer stands behind is exactly what it is entitled to do. Anchors are + // skipped by construction: a confirmed, named or ignored face is in + // `confirmed`, enters as an anchor, and comes back out inside a group that + // is never dropped. + let mut released = 0usize; + for face in &ids { + if placed.contains(face) || confirmed.contains_key(face) { + continue; + } + faces::unassign(conn, *face)?; + released += 1; + } + if released > 0 { + log::info!("reclustering released {released} face(s) it no longer groups"); + } + // Groups the *previous* pass created that this one left empty. Without // this, every press of Regroup adds a rail entry per group it no longer // believes in, and the screen fills with "Unnamed (0 faces)" — which is @@ -612,6 +741,43 @@ pub fn recluster( Ok((suggested, created)) } +/// The answer from a preview pass. +#[derive(Debug, Clone, PartialEq)] +pub enum PreviewMessage { + Ready(GroupingPreview), + Failed(String), +} + +/// Report what a grouping pass would do, **on a worker thread**. +/// +/// Same reasoning as [`spawn_recluster`], and the same cost: a preview is a +/// clustering pass that throws its answer away, so it is exactly as unbounded +/// as the pass it is previewing and exactly as unwelcome on the UI thread. +/// +/// Cancellation is dropping the receiver — so moving the slider again while one +/// is in flight abandons it, which is the behaviour a dial with a preview +/// button needs. +pub fn spawn_grouping_preview( + catalog_path: PathBuf, + model_id: String, + grouping: FaceSettings, +) -> Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let msg = match Catalog::open(&catalog_path) { + Ok(catalog) => match preview_grouping(&catalog, &model_id, &grouping) { + Ok(p) => PreviewMessage::Ready(p), + Err(e) => PreviewMessage::Failed(e.to_string()), + }, + Err(e) => PreviewMessage::Failed(format!("cannot open catalog: {e}")), + }; + let _ = tx.send(msg); + }); + + rx +} + /// Progress from a regrouping pass. #[derive(Debug, Clone, PartialEq)] pub enum ReclusterMessage { @@ -643,7 +809,7 @@ pub enum ReclusterMessage { pub fn spawn_recluster( catalog_path: PathBuf, model_id: String, - min_probability: f32, + grouping: FaceSettings, ) -> Receiver { let (tx, rx) = std::sync::mpsc::channel(); @@ -667,7 +833,7 @@ pub fn spawn_recluster( return; } - let msg = match recluster(&catalog, &model_id, min_probability) { + let msg = match recluster(&catalog, &model_id, &grouping) { Ok((suggested, created)) => ReclusterMessage::Finished { suggested, created }, Err(e) => ReclusterMessage::Failed(e.to_string()), }; @@ -1125,7 +1291,7 @@ mod tests { put_face(&catalog, 1, 0, 1.0); put_face(&catalog, 2, 0, 0.99); - recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); + recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap(); let people = faces::people(catalog.connection()).unwrap(); assert_eq!(people.len(), 1, "the two faces should have grouped"); let stranger = people[0].id; @@ -1133,7 +1299,7 @@ mod tests { faces::set_ignored(catalog.connection(), stranger, true).unwrap(); - recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); + recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap(); let after = faces::people(catalog.connection()).unwrap(); assert_eq!( @@ -1157,13 +1323,13 @@ mod tests { put_face(&catalog, 1, 0, 1.0); put_face(&catalog, 2, 0, 0.99); - recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); + recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap(); let stranger = faces::people(catalog.connection()).unwrap()[0].id; faces::set_ignored(catalog.connection(), stranger, true).unwrap(); // The same person turns up in a third photograph. put_face(&catalog, 3, 0, 0.98); - recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); + recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap(); let after = faces::people(catalog.connection()).unwrap(); assert_eq!(after.len(), 1, "a new face made a second group: {after:?}"); @@ -1177,13 +1343,13 @@ mod tests { let catalog = catalog_with(3); put_face(&catalog, 1, 0, 1.0); put_face(&catalog, 2, 0, 0.99); - recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); + recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap(); let id = faces::people(catalog.connection()).unwrap()[0].id; faces::set_ignored(catalog.connection(), id, true).unwrap(); faces::set_ignored(catalog.connection(), id, false).unwrap(); - recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); + recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap(); let after = faces::people(catalog.connection()).unwrap(); assert_eq!(after.len(), 1); assert!(!after[0].ignored); @@ -1200,7 +1366,7 @@ mod tests { put_face(&catalog, 1, 0, 1.0); put_face(&catalog, 2, 0, 0.99); - recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); + recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap(); let people = faces::people(catalog.connection()).unwrap(); assert_eq!(people.len(), 1); let her = people[0].id; @@ -1210,7 +1376,7 @@ mod tests { // types a name and moves on has done. faces::rename_person(catalog.connection(), her, "Catherine").unwrap(); - recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); + recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap(); let after = faces::people(catalog.connection()).unwrap(); assert_eq!( @@ -1233,15 +1399,121 @@ mod tests { let catalog = catalog_with(3); put_face(&catalog, 1, 0, 1.0); put_face(&catalog, 2, 0, 0.99); - recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); + recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap(); let her = faces::people(catalog.connection()).unwrap()[0].id; faces::rename_person(catalog.connection(), her, "Catherine").unwrap(); put_face(&catalog, 3, 0, 0.98); - recluster(&catalog, TEST_MODEL, dr_face::DEFAULT_MERGE_PROBABILITY).unwrap(); + recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap(); let after = faces::people(catalog.connection()).unwrap(); assert_eq!(after.len(), 1, "a second Catherine appeared: {after:?}"); assert_eq!(after[0].suggested_faces, 3); } + + /// `dr-types` sits below the face engine and cannot name its constant, so + /// it restates the number. This is the thing that stops the two drifting: + /// a settings page marking 0.80 as "default" while the engine had moved to + /// 0.75 would put the reset dot on a value nothing else agreed with. + #[test] + fn the_settings_default_is_the_engines_own_tuned_value() { + assert_eq!( + FaceSettings::default().merge_probability, + dr_face::DEFAULT_MERGE_PROBABILITY + ); + } + + /// The smallest-group setting has to change what is on the rail, not just + /// what the *next* pass would build. Before the release step in + /// [`recluster`], raising it left the previous pass's small groups sitting + /// there full of suggestions — not empty, so not pruned — and the control + /// looked broken. + #[test] + fn raising_the_smallest_group_takes_the_small_groups_off_the_rail() { + let catalog = catalog_with(5); + // One pair, and one trio: two groups at the default of two. + put_face(&catalog, 1, 0, 1.0); + put_face(&catalog, 2, 0, 0.99); + put_face(&catalog, 3, 1, 1.0); + put_face(&catalog, 4, 1, 0.99); + put_face(&catalog, 5, 1, 0.98); + + recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap(); + assert_eq!( + faces::people(catalog.connection()).unwrap().len(), + 2, + "the two groups should have formed at the default" + ); + + recluster( + &catalog, + TEST_MODEL, + &FaceSettings { + min_group_size: 3, + ..FaceSettings::default() + }, + ) + .unwrap(); + + let after = faces::people(catalog.connection()).unwrap(); + assert_eq!( + after.len(), + 1, + "the pair survived a smallest-group of three: {after:?}" + ); + assert_eq!(after[0].suggested_faces, 3, "the trio lost members"); + } + + /// And the setting does not overrule the user. A group they named is theirs + /// (FR-CULL-12), however few faces it holds. + #[test] + fn a_named_group_survives_a_smallest_group_it_is_under() { + let catalog = catalog_with(3); + put_face(&catalog, 1, 0, 1.0); + put_face(&catalog, 2, 0, 0.99); + recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap(); + let her = faces::people(catalog.connection()).unwrap()[0].id; + faces::rename_person(catalog.connection(), her, "Catherine").unwrap(); + + recluster( + &catalog, + TEST_MODEL, + &FaceSettings { + min_group_size: 6, + ..FaceSettings::default() + }, + ) + .unwrap(); + + let after = faces::people(catalog.connection()).unwrap(); + assert_eq!(after.len(), 1, "Catherine was dropped: {after:?}"); + assert_eq!(after[0].name, "Catherine"); + assert_eq!(after[0].suggested_faces, 2); + } + + /// Down at one, every stray becomes a group of its own — which is what a + /// user hunting for a face the grouping missed has asked for. + #[test] + fn a_smallest_group_of_one_shows_the_strays() { + let catalog = catalog_with(2); + put_face(&catalog, 1, 0, 1.0); + put_face(&catalog, 2, 1, 1.0); + + recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap(); + assert!( + faces::people(catalog.connection()).unwrap().is_empty(), + "two unrelated faces made a person at the default" + ); + + recluster( + &catalog, + TEST_MODEL, + &FaceSettings { + min_group_size: 1, + ..FaceSettings::default() + }, + ) + .unwrap(); + assert_eq!(faces::people(catalog.connection()).unwrap().len(), 2); + } } diff --git a/ui/dr-ui/src/identity.rs b/ui/dr-ui/src/identity.rs index 1d21b98..29c4c25 100644 --- a/ui/dr-ui/src/identity.rs +++ b/ui/dr-ui/src/identity.rs @@ -117,6 +117,29 @@ impl FaceCell { } } +/// Put a grouping preview into words. +/// +/// **The group count leads, not the grouped-face count.** They move in opposite +/// directions on either side of the right setting, and only one of them says +/// which side you are on: loosening gathers fragments into people, so the group +/// count climbs — until it starts welding separate people together, at which +/// point it *falls* while the grouped faces keep rising. `dr_face::cluster` +/// records that measurement at length. A line that led with "1,340 of 1,813 +/// faces grouped" would make the over-merged setting look like the best one. +/// +/// The largest group is here for the same reason: it is where over-merging +/// shows up first and most legibly, because a user who knows their own library +/// knows whether anyone in it has been photographed six hundred times. +pub fn preview_label(p: &crate::faces::GroupingPreview) -> String { + if p.faces == 0 { + return "No faces indexed yet, so there is nothing to group.".into(); + } + format!( + "{} group(s), holding {} of {} faces. Largest: {}.", + p.groups, p.grouped, p.faces, p.largest + ) +} + /// Everything the Identity screen draws. #[derive(Debug, Clone, Default, PartialEq)] pub struct IdentityView { @@ -646,8 +669,33 @@ pub fn delete_all(catalog: &Catalog) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::faces::GroupingPreview; use dr_catalog::faces::DetectedFace; + #[test] + fn a_preview_leads_with_the_group_count() { + let line = preview_label(&GroupingPreview { + faces: 1813, + groups: 328, + grouped: 1341, + largest: 69, + }); + // The group count is the number that says which side of the right + // setting you are on, so it is the number the sentence starts with. + assert!(line.starts_with("328 group"), "{line}"); + assert!(line.contains("1341 of 1813"), "{line}"); + assert!(line.contains("69"), "{line}"); + } + + /// The ordinary state of a library nobody has run the indexer over — and + /// "0 group(s), holding 0 of 0 faces" would read as a failure of the dials + /// rather than as an absence of input. + #[test] + fn a_preview_of_nothing_says_there_is_nothing() { + let line = preview_label(&GroupingPreview::default()); + assert!(line.contains("No faces indexed"), "{line}"); + } + fn catalog() -> Catalog { let dir = std::env::temp_dir().join(format!( "dr-identity-{}-{:?}", diff --git a/ui/dr-ui/src/identity_ui.rs b/ui/dr-ui/src/identity_ui.rs index 32ae950..68b0803 100644 --- a/ui/dr-ui/src/identity_ui.rs +++ b/ui/dr-ui/src/identity_ui.rs @@ -81,6 +81,12 @@ pub struct IdentityController { /// the handle, and clustering a real library is not something a Slint /// callback may do on the UI thread. regroup: RefCell>>, + /// The running read-only preview of the grouping dials, if any. + /// + /// Separate from `regroup` because the two are allowed to be about + /// different things at once and neither blocks the other: a preview writes + /// nothing, so there is nothing for a concurrent pass to corrupt. + preview: RefCell>>, /// Whether the rail is showing the people the user has set aside. show_ignored: std::cell::Cell, /// Rail portraits, kept between refreshes. @@ -378,12 +384,33 @@ pub type SweepPaths = (dr_sync::Connection, std::path::PathBuf, std::path::PathB /// The detector and embedder files, when both are present. pub type ModelPaths = (std::path::PathBuf, std::path::PathBuf); +/// Put the grouping dials on the screen from the settings record. +/// +/// Read back out of the controller rather than echoed from the callback's +/// argument, because `Settings::sanitise` may have moved the number: a slider +/// showing 40% while the file held the clamped 50% would be a control that +/// silently disagreed with what the next Regroup was going to do. +fn push_grouping(window: &AppWindow, settings: &crate::settings_ui::SettingsController) { + let s = settings.snapshot(); + window.set_identity_merge_probability(s.faces.merge_probability * 100.0); + window.set_identity_min_group_size(s.faces.min_group_size as i32); +} + /// Attach every Identity callback. +/// +/// Eight arguments because the screen has eight distinct dependencies and no +/// two of them belong together: three ways of reaching the library, two +/// controllers, the window, the activity log and the settings record. Bundling +/// them into a parameter struct would name a thing that does not exist — the +/// same reason every other `wire` in this file's neighbourhood carries the +/// allow. +#[allow(clippy::too_many_arguments)] pub fn wire( window: &AppWindow, ctl: Rc, catalog: Rc>>, activity: Rc, + settings: Rc, store: S, models: M, paths: P, @@ -396,6 +423,36 @@ pub fn wire( let models: Rc Option> = Rc::new(models); let paths: Rc Option> = Rc::new(paths); + // The dials start where the settings file left them, once, rather than on + // every open: the screen writes them back through the two callbacks below, + // and re-pushing them mid-drag would fight the slider's own live value. + push_grouping(window, &settings); + + { + let weak = window.as_weak(); + let settings = settings.clone(); + window.on_identity_merge_probability_changed(move |percent| { + let Some(w) = weak.upgrade() else { return }; + settings.edit(|s| s.faces.merge_probability = percent / 100.0); + push_grouping(&w, &settings); + // The answer on screen was about the old value. Left there it would + // be read as a description of the new one, which is worse than + // having no preview at all. + w.set_identity_grouping_preview(Default::default()); + }); + } + + { + let weak = window.as_weak(); + let settings = settings.clone(); + window.on_identity_min_group_size_changed(move |n| { + let Some(w) = weak.upgrade() else { return }; + settings.edit(|s| s.faces.min_group_size = n.max(0) as u32); + push_grouping(&w, &settings); + w.set_identity_grouping_preview(Default::default()); + }); + } + // Re-read everything and redraw. Every mutating callback ends in this // rather than patching the model in place: the operations here have // second-order effects — a merge empties a person, a split creates one, @@ -685,12 +742,76 @@ pub fn wire( }); } + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let paths = paths.clone(); + let settings = settings.clone(); + window.on_identity_preview_grouping(move || { + let Some(w) = weak.upgrade() else { return }; + // One at a time, like every other pass here. Two previews would + // race to write the same line and the loser's answer would win. + if ctl.preview.borrow().is_some() { + return; + } + let Some((_, catalog_path, _)) = paths() else { + return; + }; + + w.set_identity_previewing(true); + *ctl.preview.borrow_mut() = Some(crate::faces::spawn_grouping_preview( + catalog_path, + MODEL_ID.to_string(), + settings.snapshot().faces, + )); + + let timer = slint::Timer::default(); + let weak_tick = w.as_weak(); + let ctl_tick = ctl.clone(); + timer.start( + slint::TimerMode::Repeated, + Duration::from_millis(100), + move || { + let Some(w) = weak_tick.upgrade() else { return }; + let mut done = false; + { + let borrow = ctl_tick.preview.borrow(); + let Some(rx) = borrow.as_ref() else { return }; + while let Ok(msg) = rx.try_recv() { + match msg { + crate::faces::PreviewMessage::Ready(p) => { + w.set_identity_grouping_preview( + identity::preview_label(&p).into(), + ); + done = true; + } + crate::faces::PreviewMessage::Failed(e) => { + log::warn!("identity: preview: {e}"); + w.set_identity_grouping_preview( + format!("could not work it out: {e}").into(), + ); + done = true; + } + } + } + } + if done { + *ctl_tick.preview.borrow_mut() = None; + w.set_identity_previewing(false); + } + }, + ); + park_preview_timer(timer); + }); + } + { let weak = window.as_weak(); let ctl = ctl.clone(); let catalog = catalog.clone(); let store = store.clone(); let paths = paths.clone(); + let settings_for_regroup = settings.clone(); window.on_identity_recluster(move || { let Some(w) = weak.upgrade() else { return }; // One at a time. Two passes over the same faces would each create @@ -708,7 +829,7 @@ pub fn wire( *ctl.regroup.borrow_mut() = Some(crate::faces::spawn_recluster( catalog_path, MODEL_ID.to_string(), - dr_face::DEFAULT_MERGE_PROBABILITY, + settings_for_regroup.snapshot().faces, )); // Polled from the UI thread, like the indexing sweep: the worker @@ -984,6 +1105,21 @@ fn park_timer(timer: slint::Timer) { SWEEP_TIMER.with(|slot| *slot.borrow_mut() = Some(timer)); } +/// Keep the grouping preview's poll timer alive. +/// +/// **A slot of its own, and that is the whole point.** A preview and a +/// regrouping pass are allowed to be in flight together — the preview writes +/// nothing — so parking both in [`park_timer`]'s single slot would have the +/// second to start drop the first's timer. The visible symptom would be a +/// Regroup that finished on its worker and never told the screen: the button +/// stuck on "Regrouping…" for the life of the window. +fn park_preview_timer(timer: slint::Timer) { + thread_local! { + static PREVIEW_TIMER: RefCell> = const { RefCell::new(None) }; + } + PREVIEW_TIMER.with(|slot| *slot.borrow_mut() = Some(timer)); +} + #[cfg(test)] mod tests { use super::*; diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 9bbb80b..e5355d2 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -1013,6 +1013,19 @@ pub fn run(paths: Vec) -> Result<()> { // faces are ticked for a split. let identity = std::rc::Rc::new(identity_ui::IdentityController::new()); + // Settings: cache ceilings, export defaults and the face grouping dials, in + // their own config file. + // + // Wired independently of every view below. It reads no library and holds no + // session, so it has nothing to be sequenced against — which is the reason + // it is a page reachable from anywhere rather than a panel inside one view. + // Hoisted this far up because three separate things need the same record: + // the export action, the importer, and the People screen's grouping dials. + // A controller scoped to any one wiring block would be gone by the time the + // others were built, and two controllers each holding their own copy would + // each save over the other. + let settings = settings_ui::SettingsController::new(); + // TRACES: FR-PLAT-AND-5 // The thumbnail tier. Registered here, beside the thing it frees, so that // a controller which grows another cache is one line from offering it up. @@ -1116,6 +1129,7 @@ pub fn run(paths: Vec) -> Result<()> { identity.clone(), library.catalog(), activity.clone(), + settings.clone(), move || { let conn = lib_store.session()?; dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account)) @@ -1188,16 +1202,6 @@ pub fn run(paths: Vec) -> Result<()> { } } - // Settings: cache ceilings and export defaults, in their own config file. - // - // Wired independently of every view above. It reads no library and holds no - // session, so it has nothing to be sequenced against — which is the reason - // it is a page reachable from anywhere rather than a panel inside one view. - // Hoisted out of the block below: the export action needs the same record - // the settings page edits, and a controller scoped to the wiring block - // would be gone by the time that callback is built. - let settings = settings_ui::SettingsController::new(); - // TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a | FR-NC-7b // Import: a card into the library, and on to the server. // diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index bf06e3b..5f1b657 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -3910,9 +3910,10 @@ pub fn read_cells( /// look broken. The id list comes from /// [`dr_catalog::collections::descendants`], which is depth-guarded. /// -/// Ordering matches the unscoped grid (capture time, then name) rather than -/// manual position: position is only meaningful inside one collection and this -/// query also serves sets, where two children's positions are unrelated. +/// Ordering comes from [`grid_order_for`]: manual position for a single manual +/// collection, capture time for a set — because position is only meaningful +/// inside one collection, and this query also serves sets, where two children's +/// positions are unrelated integers. pub fn read_cells_scoped( catalog: &Catalog, scope: Option, @@ -3930,6 +3931,7 @@ pub fn read_cells_scoped( .collect::>() .join(","); let rated = filter.sql(); + let (order, order_params) = grid_order_for(catalog, Some(scope)); let folded = uncollapsed("i"); let sql = format!( "SELECT {CELL_COLUMNS} @@ -3937,14 +3939,17 @@ pub fn read_cells_scoped( WHERE {VISIBLE}{rated}{folded} AND i.id IN (SELECT image_id FROM collection_members WHERE collection_id IN ({placeholders})) - {GRID_ORDER} + {order} LIMIT ? OFFSET ?" ); + // Bound in the order the `?`s appear: the scope's ids in the WHERE, then + // whatever the ORDER BY needs, then the window. let mut params: Vec = ids .iter() .map(|c| rusqlite::types::Value::Integer(c.0 as i64)) .collect(); + params.extend(order_params); params.push(rusqlite::types::Value::Integer(limit as i64)); params.push(rusqlite::types::Value::Integer(offset as i64)); @@ -4070,14 +4075,23 @@ pub fn read_ids_span( Vec::new(), ) } else { - let (clause, params) = scope_clause(catalog, scope)?; + let (clause, mut params) = scope_clause(catalog, scope)?; let rated = filter.sql(); + // The same ordering the cells were drawn with, from the same place. + // A range is a pair of ordinals, and an ordinal read through a + // different ORDER BY names a different photograph. + let (order, order_params) = grid_order_for(catalog, scope); + params.extend(order_params); + // And the same folding, for the same reason one step further on: a + // collapsed burst is one cell in the grid, so an ordinal counted over + // a list that still held every frame of it would name a photograph + // several places away from the one the user pointed at. let folded = uncollapsed("i"); ( format!( "SELECT i.id FROM images i WHERE {VISIBLE}{rated}{folded}{clause} - {GRID_ORDER} + {order} LIMIT ? OFFSET ?" ), params, @@ -4225,6 +4239,146 @@ pub fn total_images_scoped( Ok(n as usize) } +/// TRACES: FR-CAT-7 +/// Every member of `scope`, in the order its positions put them. +/// +/// The *whole* membership, not the window and not the filtered view. A reorder +/// rewrites positions, and [`dr_catalog::collections::set_order`] only touches +/// the rows it is given — so writing back a filtered subset would leave the +/// images the filter is hiding at their old positions, interleaved with the new +/// ones arbitrarily. The user reorders what they can see; the rows they cannot +/// keep their place relative to it. +pub fn read_member_order( + catalog: &Catalog, + scope: dr_types::CollectionId, +) -> Result, dr_catalog::CatalogError> { + let mut stmt = catalog.connection().prepare( + "SELECT image_id FROM collection_members + WHERE collection_id = ?1 + ORDER BY position ASC, image_id ASC", + )?; + let ids = stmt + .query_map([scope.0 as i64], |r| { + Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64)) + })? + .collect::, _>>()?; + Ok(ids) +} + +/// TRACES: FR-CAT-7 +/// `current` with `moving` lifted out and set down beside `target`. +/// +/// `target` names a *photograph*, not an index, and that is the point: the grid +/// may be filtered, so the cell the user dropped on sits at one position in +/// what they can see and another in the membership being rewritten. An id +/// survives both. `after` puts the run on the far side of it, which is the only +/// way to name the last place in a collection — there is no cell beyond the +/// last one to drop in front of. +/// +/// The run keeps the order `current` has it in rather than the order the +/// selection was built in: the user is looking at the grid, and a selection +/// gathered by tapping the last frame first should not reverse itself on being +/// moved. +/// +/// A `target` that is itself being moved leaves the run at the end. There is no +/// gap between a run and itself to land in, so the caller refuses that drop +/// before it gets here; this is what the function does rather than panicking if +/// one ever arrives. +/// +/// Pure, so the awkward half of a drag can be tested without a window. +pub fn reordered( + current: &[dr_types::ImageId], + moving: &[dr_types::ImageId], + target: dr_types::ImageId, + after: bool, +) -> Vec { + let lifting: std::collections::BTreeSet<_> = moving.iter().copied().collect(); + let rest: Vec<_> = current + .iter() + .copied() + .filter(|id| !lifting.contains(id)) + .collect(); + let run: Vec<_> = current + .iter() + .copied() + .filter(|id| lifting.contains(id)) + .collect(); + + // Resolved against `rest`, not against `current`: the run has already been + // lifted, so an index into the original list would be off by however many + // of it sat ahead of the target. + let at = match rest.iter().position(|id| *id == target) { + Some(at) if after => at + 1, + Some(at) => at, + None => rest.len(), + }; + + let mut out = Vec::with_capacity(current.len()); + out.extend_from_slice(&rest[..at]); + out.extend(run); + out.extend_from_slice(&rest[at..]); + out +} + +/// TRACES: FR-CAT-7 +/// The ORDER BY the grid reads `scope` with, and the parameters it binds. +/// +/// Manual position where the grid is scoped to a single manual collection with +/// no children; [`GRID_ORDER`] — capture time, then filename — everywhere else. +/// +/// **Why the narrowing.** `position` is a column of `collection_members`, so it +/// only exists relative to one collection. A collection *set* shows its +/// descendants' images too, and two children's positions are unrelated integers +/// that would interleave arbitrarily; a smart collection has no member rows to +/// carry a position at all. Outside those cases there is no manual order to +/// read, and falling back is the only honest answer. +/// +/// **Why every reader must agree.** An ordinal only names a photograph relative +/// to an ordering. The window read and the span read are two halves of one +/// grid: a shift-click resolved through a different ORDER BY than the cells +/// were drawn with selects a different run than the one on screen, and the user +/// finds out when the export runs. That is the same invariant +/// [`read_ids_span`] already states about `GRID_ORDER`, widened to cover the +/// case where the ordering depends on the scope. +/// +/// A correlated subquery rather than a join, so the FROM and WHERE the two +/// readers already share are untouched: position is looked up per row through +/// `collection_members`' primary key, which is `(collection_id, image_id)`. +fn grid_order_for( + catalog: &Catalog, + scope: Option, +) -> (String, Vec) { + let Some(id) = scope else { + return (GRID_ORDER.to_string(), Vec::new()); + }; + + // A set orders by capture time. `descendants` includes the collection + // itself, so one entry means it has no children. + let alone = dr_catalog::collections::descendants(catalog.connection(), id) + .map(|d| d.len() == 1) + .unwrap_or(false); + let manual = matches!( + dr_catalog::collections::kind(catalog.connection(), id), + Ok(Some(dr_catalog::collections::CollectionKind::Manual)) + ); + if !alone || !manual { + return (GRID_ORDER.to_string(), Vec::new()); + } + + // `i.id` breaks the tie. Positions are dense after a `set_order`, but a + // collection that has never been reordered by hand has whatever + // `add_images` assigned, and two rows can share a position if a merge from + // another device brought one in — an ordering that is not total is an + // ordering the window read and the span read can disagree about. + ( + "ORDER BY (SELECT cm.position FROM collection_members cm + WHERE cm.collection_id = ? AND cm.image_id = i.id) ASC, + i.id ASC" + .to_string(), + vec![rusqlite::types::Value::Integer(id.0 as i64)], + ) +} + /// The SQL restricting a query to `scope` and its descendants, with the bound /// parameters to go with it. /// @@ -5510,6 +5664,180 @@ mod tests { assert_eq!(read_trashed_cells(&catalog, 0, 50).unwrap().len(), 3); } + // --- manual order within a collection (FR-CAT-7) ------------------------ + + fn ids(n: &[u64]) -> Vec { + n.iter().copied().map(dr_types::ImageId).collect() + } + + #[test] + fn a_run_moved_forward_lands_before_the_photograph_it_was_dropped_on() { + let current = ids(&[1, 2, 3, 4, 5]); + assert_eq!( + reordered(¤t, &ids(&[4]), dr_types::ImageId(2), false), + ids(&[1, 4, 2, 3, 5]) + ); + } + + #[test] + fn a_run_moved_backward_lands_before_it_too() { + // The direction of travel must not change what "before this one" means, + // or the same drop would land in two different places depending on + // where the photograph came from. + let current = ids(&[1, 2, 3, 4, 5]); + assert_eq!( + reordered(¤t, &ids(&[2]), dr_types::ImageId(5), false), + ids(&[1, 3, 4, 2, 5]) + ); + } + + #[test] + fn the_trailing_half_of_the_last_cell_is_how_the_end_is_reached() { + // There is no cell beyond the last one to drop in front of, so without + // `after` the final position is unreachable — which is exactly the + // place a "put this at the end" drag is aiming for. + let current = ids(&[1, 2, 3]); + assert_eq!( + reordered(¤t, &ids(&[1]), dr_types::ImageId(3), true), + ids(&[2, 3, 1]) + ); + } + + #[test] + fn a_moved_run_keeps_the_order_the_grid_shows_it_in() { + // Not the order the selection was built in. A user who tapped the last + // frame first has said nothing about how the run should be arranged — + // only about where it should go. + let current = ids(&[1, 2, 3, 4, 5]); + assert_eq!( + reordered(¤t, &ids(&[5, 1]), dr_types::ImageId(3), false), + ids(&[2, 1, 5, 3, 4]) + ); + } + + #[test] + fn a_run_dropped_on_one_of_its_own_members_stays_together() { + // The caller refuses this drop, so it is only reachable if that guard + // is ever lost. It must not lose photographs when it is. + let current = ids(&[1, 2, 3, 4]); + let moved = reordered(¤t, &ids(&[2, 3]), dr_types::ImageId(3), false); + assert_eq!(moved.len(), current.len(), "nothing was dropped"); + let mut sorted = moved.clone(); + sorted.sort(); + assert_eq!(sorted, ids(&[1, 2, 3, 4]), "and nothing was invented"); + } + + #[test] + fn a_reorder_never_loses_or_duplicates_a_member() { + // The property that matters most: this writes the whole membership + // back, so a run that dropped one image would delete it from the + // collection. + let current = ids(&[1, 2, 3, 4, 5, 6]); + for target in [1u64, 2, 3, 4, 5, 6] { + for after in [false, true] { + let moved = reordered(¤t, &ids(&[2, 5]), dr_types::ImageId(target), after); + let mut sorted = moved.clone(); + sorted.sort(); + assert_eq!( + sorted, + ids(&[1, 2, 3, 4, 5, 6]), + "target {target}, after {after}" + ); + } + } + } + + /// The scoped grid and the range a shift-click resolves are two halves of + /// one ordering. This is the assertion that keeps them one: an ordinal read + /// through a different ORDER BY names a different photograph, and the user + /// finds out when the export runs. + #[test] + fn a_manual_collection_is_read_and_spanned_in_the_order_it_was_given() { + let catalog = with_images(5); + let all = image_ids(&catalog); + let id = dr_catalog::collections::create( + catalog.connection(), + "Trip", + None, + dr_catalog::collections::CollectionKind::Manual, + ) + .unwrap(); + dr_catalog::collections::add_images(catalog.connection(), id, &all).unwrap(); + + // Reversed, so position and capture time disagree about everything. + let wanted: Vec<_> = all.iter().rev().copied().collect(); + dr_catalog::collections::set_order(catalog.connection(), id, &wanted).unwrap(); + + let cells = read_cells_scoped(&catalog, Some(id), &RatingFilter::default(), 0, 50).unwrap(); + let drawn: Vec<_> = cells + .iter() + .map(|c| dr_types::ImageId(c.image_id as u64)) + .collect(); + assert_eq!(drawn, wanted, "the grid draws the order that was written"); + + let spanned = + read_ids_span(&catalog, Some(id), &RatingFilter::default(), false, 0, 4).unwrap(); + assert_eq!(spanned, wanted, "and a range resolves through the same one"); + + assert_eq!( + read_member_order(&catalog, id).unwrap(), + wanted, + "and so does the membership a reorder rewrites" + ); + } + + #[test] + fn a_collection_with_children_falls_back_to_capture_time() { + // A set draws its descendants' images too, and two children's positions + // are unrelated integers. Ordering by them interleaves the two + // arbitrarily, which is worse than an order that at least means + // something. + let catalog = with_images(4); + let all = image_ids(&catalog); + let parent = dr_catalog::collections::create( + catalog.connection(), + "Iceland", + None, + dr_catalog::collections::CollectionKind::Manual, + ) + .unwrap(); + dr_catalog::collections::create( + catalog.connection(), + "Day one", + Some(parent), + dr_catalog::collections::CollectionKind::Manual, + ) + .unwrap(); + dr_catalog::collections::add_images(catalog.connection(), parent, &all).unwrap(); + let reversed: Vec<_> = all.iter().rev().copied().collect(); + dr_catalog::collections::set_order(catalog.connection(), parent, &reversed).unwrap(); + + let cells = + read_cells_scoped(&catalog, Some(parent), &RatingFilter::default(), 0, 50).unwrap(); + let drawn: Vec<_> = cells + .iter() + .map(|c| dr_types::ImageId(c.image_id as u64)) + .collect(); + assert_eq!(drawn, all, "capture time, not the positions that were set"); + } + + #[test] + fn a_smart_collection_has_no_manual_order_to_read() { + // No member rows at all, so `position` is not a column any of its + // images have. Falling back is the only thing there is to do. + let catalog = with_images(3); + let id = dr_catalog::collections::create( + catalog.connection(), + "Picks", + None, + dr_catalog::collections::CollectionKind::Smart, + ) + .unwrap(); + let (order, params) = grid_order_for(&catalog, Some(id)); + assert_eq!(order, GRID_ORDER); + assert!(params.is_empty()); + } + #[test] fn the_most_recently_trashed_image_is_listed_first() { // A mistaken delete is corrected within seconds, so the row the user diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index c1ab489..15224f2 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -4527,6 +4527,13 @@ pub fn wire( if coll_for_click.press_was_modified() { return; } + // TRACES: FR-UI-4 + // And a graze is not a tap. The press has already selected the cell, + // so the user sees what they touched; what they do not get is a + // photograph opened by a hand on its way past. + if coll_for_click.press_was_a_graze() { + return; + } let path = ctl.paths.borrow().get(i as usize).cloned(); if let Some(path) = path { // Leave the grid for the develop view. The status bar's @@ -5234,6 +5241,34 @@ pub fn wire( }); } + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_people_listed(move || { + let Some(w) = weak.upgrade() else { return }; + push_people_roster(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_filter_person_toggled(move |id| { + let Some(w) = weak.upgrade() else { return }; + let person = id.max(0) as u64; + { + let mut f = ctl.filter.borrow_mut(); + if let Some(at) = f.people.iter().position(|p| *p == person) { + f.people.remove(at); + } else { + f.people.push(person); + } + } + push_people_chips(&w, &ctl); + refilter(&w, &ctl); + }); + } + { let weak = window.as_weak(); let ctl = ctl.clone(); @@ -5596,6 +5631,9 @@ fn push_people_chips(window: &AppWindow, ctl: &Rc) { .set_library_filter_people(slint::ModelRc::new(slint::VecModel::from( Vec::::new(), ))); + // The tray, if it is open, has to lose its ticks with the chips: the + // roster carries `picked` and is the same fact drawn a second time. + push_people_roster(window, ctl); return; } @@ -5624,6 +5662,12 @@ fn push_people_chips(window: &AppWindow, ctl: &Rc) { PersonChip { id: *id as i32, name: name.into(), + // Not asked for on this side. The bar's job here is to say who + // the grid is narrowed to and offer a way out of it; a face + // count beside each would be a second number competing with the + // image counts already on the bar. + faces: -1, + picked: true, } }) .collect(); @@ -5633,6 +5677,71 @@ fn push_people_chips(window: &AppWindow, ctl: &Rc) { ctl.filter.borrow().people_mode, library::PeopleMode::All )); + push_people_roster(window, ctl); +} + +/// Fill the filter bar's people tray. +/// +/// Rebuilt whole rather than patched, because `picked` is on every row and a +/// toggle changes two things at once — the chip that was pressed and, under +/// `All`, what the whole filter means. +/// +/// **Named people first, then by how much of them the library holds.** The +/// catalog orders by face count alone, which on a real library puts a dozen +/// unnamed strangers ahead of the two people the user has actually named — and +/// the tray scrolls horizontally, so anything past the first few chips costs a +/// gesture to reach. Naming somebody is the user saying they matter; the order +/// says it back. +fn push_people_roster(window: &AppWindow, ctl: &Rc) { + let picked = ctl.filter.borrow().people.clone(); + let borrow = ctl.catalog.borrow(); + let people = borrow + .as_ref() + .and_then(|cat| dr_catalog::faces::people(cat.connection()).ok()) + .unwrap_or_default(); + + // Sorted as (unnamed, -faces) pairs beside the chip rather than by + // re-reading the drawn label: "is this person named" is a fact about the + // record, and recovering it from the `Unnamed (n faces)` wording would put + // a sort key inside a string meant for a human to read. + let mut rows: Vec<(bool, i64, PersonChip)> = people + .iter() + .filter(|p| { + let on = picked.contains(&p.id.0); + // A person already in the filter always has a chip, whatever else + // is true of them: the tray is where the filter is taken apart, and + // a term with no control is a term the user cannot remove. + // + // Otherwise: nobody the user set aside, and nobody with no faces — + // a named person emptied by a split would be a chip that narrows + // the grid to nothing whatever else is on the bar. + on || (!p.ignored && p.confirmed_faces + p.suggested_faces > 0) + }) + .map(|p| { + let faces = p.confirmed_faces + p.suggested_faces; + let unnamed = p.name.trim().is_empty(); + ( + unnamed, + faces as i64, + PersonChip { + id: p.id.0 as i32, + name: if unnamed { + format!("Unnamed ({faces} faces)").into() + } else { + p.name.clone().into() + }, + faces: faces as i32, + picked: picked.contains(&p.id.0), + }, + ) + }) + .collect(); + // Stable, so the catalog's own tiebreak by name survives inside each of the + // two blocks. + rows.sort_by_key(|(unnamed, faces, _)| (*unnamed, -faces)); + + let rows: Vec = rows.into_iter().map(|(_, _, chip)| chip).collect(); + window.set_library_people(slint::ModelRc::new(slint::VecModel::from(rows))); } fn refilter(window: &AppWindow, ctl: &Rc) { diff --git a/ui/dr-ui/src/settings_ui.rs b/ui/dr-ui/src/settings_ui.rs index 1c689c9..b882f9c 100644 --- a/ui/dr-ui/src/settings_ui.rs +++ b/ui/dr-ui/src/settings_ui.rs @@ -111,7 +111,13 @@ impl SettingsController { /// accidentally write back a stale copy of the fields it was not editing — /// with a save on every keystroke, two controls holding their own snapshots /// would overwrite each other. - fn edit(&self, f: impl FnOnce(&mut Settings)) { + /// + /// Public because the settings page is no longer the only screen that edits + /// this record: the People screen carries the grouping dials, for the reason + /// identity.slint gives, and they are the same per-device preferences saved + /// to the same file. Everything that writes settings comes through here, so + /// the sanitise-and-save discipline holds wherever the control lives. + pub fn edit(&self, f: impl FnOnce(&mut Settings)) { { let mut settings = self.settings.borrow_mut(); f(&mut settings); diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index e8f67b9..022980d 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -309,7 +309,16 @@ export component AppWindow inherits Window { callback library-toggle-date-range(); /// TRACES: FR-CAT-5 callback library-clear-selection(); - callback library-collection-from-selection(); + callback library-select-all(); + /// TRACES: FR-CAT-7 + /// Whether the grid is showing something with a manual order to change — + /// a single manual collection, not a set and not a saved filter. + in property library-reorderable: false; + /// Move the selection beside the photograph at this row of the loaded + /// window: after it where the drop landed on the cell's trailing half, + /// before it otherwise. + callback library-reorder-to(int, bool); + callback library-collection-from-selection(string); /// TRACES: FR-CAT-6 in property library-range-from; in property library-range-to; @@ -410,6 +419,15 @@ export component AppWindow inherits Window { callback identity-recluster(); in property identity-regrouping: false; in property identity-regroup-status; + // The grouping dials Regroup turns. Percent and a face count, both stored + // in the device settings file beside the cache budgets. + in property identity-merge-probability: 80; + in property identity-min-group-size: 2; + callback identity-merge-probability-changed(float); + callback identity-min-group-size-changed(int); + callback identity-preview-grouping(); + in property identity-previewing: false; + in property identity-grouping-preview; in property identity-ignored-count: 0; in property identity-show-ignored: false; in property identity-selected-ignored: false; @@ -566,7 +584,7 @@ export component AppWindow inherits Window { callback library-drag-finished(); in property library-selected-count: 0; - callback library-cell-pressed(int, bool, bool); + callback library-cell-pressed(int, bool, bool, bool); callback library-remove-from-collection(); // The keyboard cursor: where the arrows are in the library, as an image @@ -599,6 +617,10 @@ export component AppWindow inherits Window { in property library-filter-people-all: false; callback library-filter-person-cleared(int); callback library-filter-people-mode-toggled(); + /// Everyone the library knows, for the filter bar's people tray. + in property <[PersonChip]> library-people; + callback library-people-listed(); + callback library-filter-person-toggled(int); in property library-filter-min-rating: 0; in property library-filter-unjudged: false; in property library-filter-flag: 0; @@ -1299,6 +1321,13 @@ in property panel-visible: true; photos-filtered: root.library-filter-people.length > 0; regrouping: root.identity-regrouping; regroup-status: root.identity-regroup-status; + merge-probability: root.identity-merge-probability; + min-group-size: root.identity-min-group-size; + merge-probability-changed(v) => { root.identity-merge-probability-changed(v); } + min-group-size-changed(n) => { root.identity-min-group-size-changed(n); } + previewing: root.identity-previewing; + grouping-preview: root.identity-grouping-preview; + preview-grouping() => { root.identity-preview-grouping(); } ignored-count: root.identity-ignored-count; show-ignored: root.identity-show-ignored; selected-ignored: root.identity-selected-ignored; @@ -1429,7 +1458,12 @@ in property panel-visible: true; range-active: root.library-range-active; toggle-date-range() => { root.library-toggle-date-range(); } clear-selection() => { root.library-clear-selection(); } - collection-from-selection() => { root.library-collection-from-selection(); } + select-all() => { root.library-select-all(); } + reorderable: root.library-reorderable; + reorder-to(row, after) => { root.library-reorder-to(row, after); } + collection-from-selection(name) => { + root.library-collection-from-selection(name); + } range-from: root.library-range-from; range-to: root.library-range-to; range-invalid: root.library-range-invalid; @@ -1511,8 +1545,8 @@ in property panel-visible: true; open-settings() => { root.settings-open(); } open-people() => { root.identity-open(); } - cell-pressed(i, ctrl, shift) => { - root.library-cell-pressed(i, ctrl, shift); + cell-pressed(i, ctrl, shift, touch) => { + root.library-cell-pressed(i, ctrl, shift, touch); } cell-press-ended() => { root.library-cell-press-ended(); } cell-double-clicked(i) => { root.library-cell-double-clicked(i); } @@ -1543,6 +1577,7 @@ in property panel-visible: true; filter-people: root.library-filter-people; filter-people-all: root.library-filter-people-all; + people: root.library-people; filter-min-rating: root.library-filter-min-rating; filter-unjudged: root.library-filter-unjudged; filter-flag: root.library-filter-flag; @@ -1576,6 +1611,8 @@ in property panel-visible: true; filter-flag-changed(f) => { root.library-filter-flag-changed(f); } filter-person-cleared(id) => { root.library-filter-person-cleared(id); } filter-people-mode-toggled() => { root.library-filter-people-mode-toggled(); } + people-listed() => { root.library-people-listed(); } + filter-person-toggled(id) => { root.library-filter-person-toggled(id); } } } } diff --git a/ui/dr-ui/ui/identity.slint b/ui/dr-ui/ui/identity.slint index 1db0d13..8fda5bb 100644 --- a/ui/dr-ui/ui/identity.slint +++ b/ui/dr-ui/ui/identity.slint @@ -16,6 +16,7 @@ import { Theme } from "theme.slint"; import { Panel, Button, IconButton, Field } from "widgets.slint"; +import { SliderRow } from "controls.slint"; import { Icon } from "icons.slint"; export struct IdentityPerson { @@ -235,6 +236,11 @@ export component IdentityScreen inherits Rectangle { /// live and has to say what it is doing rather than simply stopping. in property regrouping: false; in property regroup-status; + /// Whether the grouping dials are open. + /// + /// Private to the screen: nothing in Rust needs to know, and a disclosure + /// state that round-tripped through a callback would flicker. + property grouping-open: false; /// People the user has set aside, and whether the rail is showing them. in property ignored-count: 0; in property show-ignored: false; @@ -251,6 +257,21 @@ export component IdentityScreen inherits Rectangle { /// "and also" button only appears when there is something to add to. in property photos-filtered: false; callback recluster(); + /// TRACES: FR-CULL-9 + /// How sure the pass has to be before it calls two groups one person, as a + /// percentage — the same units every confidence on this screen is printed + /// in, because a control in *cosines* would be the one place in the + /// subsystem that thresholds a bare similarity. + in property merge-probability: 80; + /// The smallest group the pass will make a person out of. + in property min-group-size: 2; + callback merge-probability-changed(float); + callback min-group-size-changed(int); + /// Ask what the dials would do, without doing it. + callback preview-grouping(); + in property previewing: false; + /// The answer, in words. Empty until one has been asked for. + in property grouping-preview; callback index-faces(); callback stop-indexing(); callback check-coverage(); @@ -433,7 +454,16 @@ export component IdentityScreen inherits Rectangle { : 0px; visible: root.selected-person >= 0; edited(t) => { root.name-edited(t); } - accepted(t) => { root.rename(t); } + // Enter *finishes* naming this person, so the field lets + // the keyboard go with it. Without this the entry keeps + // focus after the name is committed, and on a tablet the + // on-screen keyboard stays up over the faces the user + // pressed Enter to get back to — the name looks accepted + // and the screen looks stuck. + accepted(t) => { + root.rename(t); + name-field.release-focus(); + } } if root.selected-person < 0: Text { text: "Select a person"; @@ -521,6 +551,106 @@ export component IdentityScreen inherits Rectangle { enabled: !root.regrouping; clicked => { root.recluster(); } } + // The dials Regroup turns, immediately beside it. They belong + // here and not on the settings page: they are only meaningful + // next to the button that applies them and the rail that shows + // what they did, and a value changed three screens away from + // its effect is a value nobody can tune. + Button { + text: "Grouping…"; + active: root.grouping-open; + clicked => { root.grouping-open = !root.grouping-open; } + } + } + } + + // --- the grouping dials --------------------------------------- + // + // Opened inline rather than in a popup, the way the develop + // column's film picker is: this screen already scrolls as one, and + // a second overlay to dismiss is a second gesture to lose. + if root.grouping-open: Rectangle { + border-radius: Theme.radius; + background: Theme.surface-raised; + // Stated, because the layout above it is a VerticalLayout that + // would otherwise stretch this block over the faces grid. + height: dials.preferred-height + 2 * Theme.gap; + + dials := VerticalLayout { + x: Theme.gap; + y: Theme.gap; + width: parent.width - 2 * Theme.gap; + spacing: Theme.gap-sm; + + // **The hints are two words each, and that is deliberate.** + // `FieldRow` draws a hint as a non-wrapping elided + // `Caption`, whose *minimum* width is still its whole text + // — and a layout cannot be narrower than its children's + // minimums, so a sentence here would set the minimum width + // of this pane and, on a phone, of the screen. The + // sentences go in the wrapping paragraph below instead, + // where they cost nothing. + SliderRow { + label: "Match confidence"; + hint: "%"; + value: root.merge-probability; + default-value: 80; + minimum: 50; + maximum: 95; + precision: 0; + enabled: !root.regrouping; + changed(v) => { root.merge-probability-changed(v); } + reset => { root.merge-probability-changed(80); } + } + + SliderRow { + label: "Smallest group"; + hint: "faces"; + value: root.min-group-size; + default-value: 2; + minimum: 1; + maximum: 12; + precision: 0; + enabled: !root.regrouping; + changed(v) => { root.min-group-size-changed(v); } + reset => { root.min-group-size-changed(2); } + } + + // The dial is otherwise blind: nothing on the screen says + // what 78% means for *this* library until the user commits + // to a pass and reads the rail. `dr_face`'s tuning table is + // the right answer to that question and it is measured on + // somebody else's photographs, so this runs it here — one + // row of it, read-only, for the value actually set. + HorizontalLayout { + spacing: Theme.gap-sm; + alignment: start; + + Button { + text: root.previewing ? "Working…" : "What would this do?"; + enabled: !root.previewing && !root.regrouping; + clicked => { root.preview-grouping(); } + } + if root.grouping-preview != "": Text { + text: root.grouping-preview; + color: Theme.ink-dim; + font-size: Theme.text-sm; + vertical-alignment: center; + wrap: word-wrap; + horizontal-stretch: 1; + } + } + + // What the two dials mean, and — said plainly, because + // they look destructive and are not — what they cannot + // touch: they move the *suggested* half, which this pass + // owns and may revise as often as it likes. + Text { + text: "Lower confidence gathers more of each person together; too low and different people are welded into one. A bigger smallest group keeps one-off strangers off the rail.\n\nPress Regroup to apply. Names, confirmations and the groups you have set aside are kept whatever the dials say."; + color: Theme.ink-faint; + font-size: Theme.text-sm; + wrap: word-wrap; + } } } diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index d0ce8ac..d3c08f8 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -414,10 +414,20 @@ export component Timeline inherits Rectangle { } } -/// One person the grid is narrowed to, as drawn on the filter bar. +/// One person on the filter bar, in either of the two roles it plays there. +/// +/// The chips for who the grid is *currently* narrowed to, and the roster in the +/// tray the user picks from, are the same thing drawn twice — same id, same +/// wording — so they are one struct. `faces` and `picked` are the tray's half +/// and are simply not read by the narrowed-to chips. export struct PersonChip { id: int, name: string, + /// How many faces the library holds of them. `-1` where it was not asked + /// for, which `FilterChip` draws as no number at all rather than as zero. + faces: int, + /// Already one of the people the grid is narrowed to. + picked: bool, } export struct LibraryCell { @@ -1230,7 +1240,15 @@ export component LibraryGrid inherits Rectangle { // also why there is no badge position to compute here any more. /// Modifier state at press time, so Rust can decide replace / add / extend /// without the .slint file encoding the selection policy. - callback cell-pressed(int, bool, bool); + /// + /// The last argument is **whether a finger did it**, and it is here because + /// a finger and a pointer need different rules about what counts as a tap. + /// A mouse click is over in tens of milliseconds and means it; a hand + /// brushing a tablet on its way somewhere else produces exactly the same + /// press-and-release, and used to land the user in develop. Rust holds the + /// rule (`collections_ui::TAP_MIN_MS`) beside the hold timer it has to sit + /// between — this file only reports which kind of contact it was. + callback cell-pressed(int, bool, bool, bool); /// TRACES: FR-UI-2 | FR-UI-4 /// The press on a cell ended — lifted, or taken away by the Flickable when /// the finger travelled. Cancels the long-press timer that would otherwise @@ -1255,11 +1273,76 @@ export component LibraryGrid inherits Rectangle { in property select-mode: false; callback toggle-select-mode(); /// TRACES: FR-CAT-5 + // --- reordering a manual collection (FR-CAT-7) -------------------------- + // + // `collection_members.position` and `Sort::CollectionPosition` have existed + // in the catalog since collections did, and nothing above it ever wrote or + // read them: the grid ordered everything by capture time, always. This is + // the gesture that makes the column mean something. + // + // Only where there is a manual order to change — a single manual collection + // with no children. A set interleaves two children's unrelated positions + // and a smart collection has no member rows at all, so both fall back to + // capture time and refuse the drop rather than pretending. + /// Whether a drop on a cell should move photographs within the collection + /// being shown. Set by Rust from the scope, because what a scope *is* is a + /// catalog question. + in property reorderable: false; + /// Move the selection so it sits beside the photograph at this row of the + /// loaded window — before it, or after it where the drop landed on the + /// cell's trailing half. The trailing half is what makes the last position + /// reachable at all; without it there is no cell to drop "before". + callback reorder-to(int, bool); + /// Drop the selection without leaving select mode. callback clear-selection(); + /// TRACES: FR-CAT-5 | FR-UI-4 + /// Take everything the grid is currently showing — the whole library, or + /// the whole of whatever it is scoped and filtered to. Answered from the + /// catalog, not from the loaded window, for the reason `cell-pressed`'s + /// shift argument is: what is on screen is a fraction of what is meant. + callback select-all(); + + /// TRACES: FR-UI-2 | FR-UI-4 + /// Whether the next cell tap should take everything from the last cell + /// tapped to it. + /// + /// **Why this exists.** Touch has had a range gesture for as long as + /// selection mode has — double-tap the far end — and it was a gesture only + /// its author could find. It is invisible, it is unreliable on a grid that + /// scrolls under the second tap, and it extends from the anchor *before* + /// the two taps moved it, a rule subtle enough to need two paragraphs of + /// Rust to explain to itself. + /// + /// This is the same operation made visible: a button that arms it, a strip + /// that says what the next tap will do, and a way out. It also does the one + /// thing a drag-to-select sweep could not — the user may scroll as far as + /// they like between the two taps, and the run is resolved by the catalog + /// rather than by what happens to be on screen. The ranges that hurt on a + /// tablet are longer than a screenful, which is exactly where a sweep runs + /// out. + /// + /// Local to this file, and one-shot: the next press consumes it. Rust needs + /// no state for it, because it arrives as `cell-pressed`'s shift argument + /// and lands in `apply_press` as the shift-click it already knows how to + /// apply. + property ranging: false; + + // A range armed against nothing has no anchor to extend from, and the strip + // that says it is armed is only drawn while there is a selection — so + // "Done" in the header, which drops the selection from outside that strip, + // would leave the mode armed and invisible, and the next ordinary tap would + // take a run the user never asked for. + changed selected-count => { + if (root.selected-count == 0) { + root.ranging = false; + } + } /// TRACES: FR-CAT-5 - /// Make a new collection holding exactly what is selected. - callback collection-from-selection(); + /// Make a new collection, under the given name, holding exactly what is + /// selected. The name arrives from the sheet below rather than being + /// invented by Rust and corrected afterwards — see `naming`. + callback collection-from-selection(string); /// The drag payload: the selected image ids, wrapped by Rust. Called when a /// drag starts, so it always reflects the selection as it is at that moment. pure callback drag-payload() -> data-transfer; @@ -1294,6 +1377,24 @@ export component LibraryGrid inherits Rectangle { /// Whose photographs the grid is narrowed to — one chip each, so a /// selection of three can be taken apart one person at a time. in property <[PersonChip]> filter-people; + /// Everyone the library knows, for the tray. Filled on demand — see the + /// tray itself for why it is not simply pushed alongside the chips. + in property <[PersonChip]> people; + /// Asked for when the tray opens, so a roster is never built for a bar + /// nobody has opened and never goes stale in one that is. + callback people-listed(); + /// Add or remove one person from the filter, keeping the rest. + /// + /// The gesture the whole tray exists for: `filter-person-cleared` can only + /// take somebody *out*, so before this the only way to narrow to two people + /// at once was to visit the People screen twice. + callback filter-person-toggled(int); + /// Whether the people tray is open. + /// + /// Private to the view, like every other disclosure here: nothing in Rust + /// needs to know, and a strip whose open state round-tripped through a + /// callback would flicker on every press. + property people-tray: false; /// Whether those people are an intersection rather than a union. in property filter-people-all: false; callback filter-person-cleared(int); @@ -1458,6 +1559,26 @@ export component LibraryGrid inherits Rectangle { /// disclosure rather than a preference, and what closes it is dismissing it. property keywording: false; + // --- naming a new collection (FR-CAT-5, FR-CAT-7) ----------------------- + // + // "New collection from selection" used to create the collection under a + // placeholder name and then open the rename field in the sidebar tree. + // + // On a tablet the sidebar is not on screen. It is instantiated all the + // same — `app.slint` collapses it to zero width and `visible: false` + // rather than using an `if`, because an `if` there is a layout loop Slint + // panics on — so the rename field was created, its `init` took focus, and + // Android raised the on-screen keyboard for a box nobody could see. Nothing + // else on the screen is focusable, so the keyboard had nowhere to go: it + // stayed, the name could not be typed, and the collection was already + // written under the name the user did not want. + // + // Asked here instead, before anything is written. A sheet dismissed leaves + // no collection behind, which the create-then-rename order could not + // promise. + /// Whether the naming sheet is up. Local, like `filing` and `keywording`. + property naming: 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 @@ -1922,6 +2043,38 @@ export component LibraryGrid inherits Rectangle { clicked => { root.filter-people-mode-toggled(); } } + // The way in to the people tray, and the reason it exists. + // + // Narrowing to *two* people at once was already possible and + // effectively unreachable: the only control that could add a + // second one lived on the People screen, behind selecting them + // there, and it only appeared once the grid was already + // narrowed to somebody. So a user who wanted "photographs with + // both of them" had to guess a two-screen round trip. A filter + // belongs on the filter bar; this chip is the whole feature's + // front door and the tray below is where both terms and the + // any/all choice actually are. + FilterChip { + icon: root.people-tray ? "chevron-down" : "chevron-right"; + label: "People"; + // The number narrowed to, not the size of the roster: it + // says what the filter is doing, which is what every other + // count on this bar says. + count: root.filter-people.length > 0 + ? root.filter-people.length : -1; + active: root.people-tray; + y: (parent.height - self.height) / 2; + clicked => { + root.people-tray = !root.people-tray; + // Asked for on open rather than kept in step, so the + // roster is current — indexing and regrouping change + // who exists, and a list cached at startup would be + // stale for exactly the user who has just been naming + // people. + if (root.people-tray) { root.people-listed(); } + } + } + Caption { text: "Show"; vertical-alignment: center; @@ -2046,6 +2199,76 @@ export component LibraryGrid inherits Rectangle { } } + // --- the people tray ---------------------------------------------- + // + // A second strip under the filter bar rather than a popup, for the + // reason the develop column's film picker gives: this view already + // scrolls as one, so an inline strip is taller content and not a second + // overlay with its own dismiss gesture to lose a drag to. + // + // Horizontally scrolling, exactly like the bar above it and for the + // same hard reason — **a layout cannot be narrower than its children's + // minimums**, and a library with forty people would otherwise report a + // minimum width of forty chips and inflate the whole view. See the bar + // above for the full account of that fault. + if root.people-tray: Rectangle { + height: 38px; + background: Theme.surface; + + Rectangle { + y: parent.height - 1px; + height: 1px; + background: Theme.rule; + } + + Flickable { + width: 100%; + height: 100%; + viewport-height: self.height; + viewport-width: max(self.width, people-row.preferred-width); + + people-row := HorizontalLayout { + width: parent.viewport-width; + height: parent.viewport-height; + padding-left: Theme.gap; + padding-right: Theme.gap; + spacing: 4px; + alignment: start; + + Caption { + // Says what a *pair* of chips will mean before either + // is pressed, which is the thing the old design never + // said anywhere. + text: root.filter-people-all + ? "In every one:" : "In the picture:"; + vertical-alignment: center; + } + + // The roster. Ordered most-photographed-first with the + // named ahead of the rest, so the people a user actually + // intersects are the ones under the thumb without + // scrolling. + for p[i] in root.people: FilterChip { + icon: p.picked ? "check" : ""; + label: p.name; + count: p.faces; + active: p.picked; + y: (parent.height - self.height) / 2; + clicked => { root.filter-person-toggled(p.id); } + } + + // Not an error and not empty chrome: face indexing is an + // opt-in overnight pass, so "nobody yet" is the ordinary + // state of a library nobody has run it on, and it should + // say where the pass lives. + if root.people.length == 0: Caption { + text: "Nobody indexed yet — find faces on the People screen."; + vertical-alignment: center; + } + } + } + } + // --- what is selected, and what to do with it -------------------- // // The count and these actions used to live only in the header row, @@ -2070,9 +2293,33 @@ export component LibraryGrid inherits Rectangle { spacing: Theme.gap-sm; alignment: start; + // While a range is armed the strip stops reporting and starts + // instructing. The count is still true, but it is not what the + // user needs to read: they have pressed something that changes + // what the *next* tap means, and a mode with no visible state + // is the double-tap gesture this replaces. + if root.ranging: Value { + text: "Tap the last photograph"; + modified: true; + font-weight: 600; + vertical-alignment: center; + overflow: elide; + } + + if root.ranging: Rectangle { width: Theme.gap; } + + // Armed by mistake, or armed and thought better of. Without + // this the only way out is to tap a cell, which takes a run the + // user did not want and then has to be undone by hand. + if root.ranging: Button { + text: "Cancel"; + y: (parent.height - self.height) / 2; + clicked => { root.ranging = false; } + } + // State rather than a label: it is what the buttons beside it // act on. - Value { + if !root.ranging: Value { text: root.selected-count + (root.selected-count == 1 ? " photograph selected" : " photographs selected"); modified: true; @@ -2081,26 +2328,50 @@ export component LibraryGrid inherits Rectangle { overflow: elide; } - Rectangle { width: Theme.gap; } + if !root.ranging: Rectangle { width: Theme.gap; } // The way out that is not "undo every tap". Distinct from // "Done", which leaves select mode entirely: clearing keeps the // mode, so the next selection can start straight away. - Button { + if !root.ranging: Button { text: "Clear"; y: (parent.height - self.height) / 2; clicked => { root.clear-selection(); } } + // Everything the grid is showing. Cheap to offer and tedious to + // do by hand: a scoped grid of two hundred frames is two hundred + // taps otherwise, and "all of them, except those three" is a far + // more common shape than the taps it took to say it. + if !root.ranging: Button { + text: "Select all"; + y: (parent.height - self.height) / 2; + clicked => { root.select-all(); } + } + + // The visible half of `ranging` — see its declaration for why + // the double-tap it replaces was not good enough. Deliberately + // an unfinished sentence: the ellipsis is the promise that a + // second tap is coming. + if !root.ranging: Button { + text: "Select to…"; + y: (parent.height - self.height) / 2; + clicked => { root.ranging = true; } + } + // Filing a selection into a collection that does not exist yet // took four steps: make a collection, find it, select the // photographs again, add them. One press instead, which is how // a selection is usually meant. - Button { - text: "New collection from selection"; + // No longer "…from selection": the sheet it opens says + // "New collection holding 12 photographs" at the top, so a + // button repeating it is width the strip does not have on a + // tablet in portrait. + if !root.ranging: Button { + text: "New collection"; primary: true; y: (parent.height - self.height) / 2; - clicked => { root.collection-from-selection(); } + clicked => { root.naming = true; } } } } @@ -2680,6 +2951,41 @@ export component LibraryGrid inherits Rectangle { } drag-finished(action) => { root.drag-finished(); } + // Where a reorder lands. Behind the cell's content and + // before it in the file, for the reason `TreeRow`'s drop + // area gives: a `DropArea` only takes part in a drag, so it + // does not block the presses the cell's own TouchArea + // needs — but drawn first it cannot paint over the + // thumbnail either. + // + // Present on every cell rather than wrapped in an `if`, so + // the marker below can name it. `can-drop` is where the + // refusal lives, which also means the cursor says no while + // the user can still aim somewhere else. + reorder-drop := DropArea { + width: 100%; + height: 100%; + + /// Whether the run would land after this photograph + /// rather than before it. Tracked during the hover so + /// the marker can move to the edge the drop will + /// actually use. + property after: false; + + can-drop(ev) => { + if (!root.reorderable) { + return DragAction.none; + } + self.after = ev.position.x > self.width / 2; + return DragAction.copy; + } + + dropped(ev) => { + root.reorder-to(i, ev.position.x > self.width / 2); + return DragAction.copy; + } + } + Rectangle { // Lifted cells shrink toward their own centre, as though pulled // off the page. Inset rather than scaled: Slint has no transform @@ -2847,11 +3153,27 @@ export component LibraryGrid inherits Rectangle { // over from the gesture, handed back as a new // press. See `pinching`. if (!root.pinching) { + // An armed range reaches Rust as shift, + // which is what it is: `apply_press` reads + // ctrl+shift as "add the run from the + // anchor to here", and in selection mode + // ctrl is already set. Sent this way rather + // than as a third selection policy, so the + // rules stay in one place. root.cell-pressed( i, ev.modifiers.control || root.select-mode, - ev.modifiers.shift, + ev.modifiers.shift || root.ranging, + // Finger id 0 is the mouse — the same + // convention the pinch arbitration a + // few lines up already relies on. + ev.touch-finger-id != 0, ); + // One shot. The range was between two taps + // and the second has landed; left armed, it + // would turn every tap after it into + // another run. + root.ranging = false; } } if (ev.kind == PointerEventKind.up) { @@ -3011,6 +3333,28 @@ export component LibraryGrid inherits Rectangle { } } } + // Where the run would land. A bar in the gutter beside the + // cell it would sit next to, on whichever side the drop + // will actually use — the trailing edge is what makes the + // last place in a collection reachable, and a marker that + // did not move with it would be pointing at the wrong gap + // half the time. + // + // Last child of the `DragArea`, so it draws over the + // thumbnail rather than under it. In the gutter rather than + // on the cell, because a bar drawn *on* the first cell of a + // row reads as belonging to that cell instead of to the + // space before it. + if reorder-drop.has-drag: Rectangle { + x: reorder-drop.after + ? parent.width + Theme.gap / 2 - 1.5px + : -Theme.gap / 2 - 1.5px; + y: 0; + width: 3px; + height: parent.height; + background: Theme.active; + border-radius: 1.5px; + } } } @@ -3343,4 +3687,100 @@ export component LibraryGrid inherits Rectangle { } } } + + // --- the naming sheet (FR-CAT-5, FR-CAT-7) ------------------------------ + // + // Why a sheet at all, rather than the sidebar's rename field: see `naming` + // above. The short version is that on a tablet the sidebar is instantiated + // but not drawn, so the rename field could take the keyboard without ever + // being visible. + // + // The same card, scrim and dismissal as the two sheets above it, for the + // same reason they share one: a user who has filed a selection knows how + // this works. + if root.naming: 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.naming = false; } + } + + Rectangle { + width: min(420px, parent.width - 2 * Theme.gap-lg); + height: min(name-sheet.preferred-height, parent.height - 2 * Theme.gap-lg); + x: (parent.width - self.width) / 2; + // A third of the way down, not centred. The field below takes the + // keyboard as the sheet appears, and on a tablet the keyboard is + // the bottom half of the window — a card centred in the window is a + // card centred behind it. + y: max(Theme.gap-lg, (parent.height - self.height) / 3); + 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 { } + + name-sheet := VerticalLayout { + padding: Theme.gap-lg; + spacing: Theme.gap; + + Text { + text: root.selected-count == 1 + ? "New collection holding 1 photograph" + : "New collection holding " + root.selected-count + + " photographs"; + color: Theme.ink; + font-size: Theme.text-lg; + font-weight: 600; + wrap: word-wrap; + } + + name-field := Field { + placeholder: "Name this collection"; + // The card asks one question, so the field answers the + // keyboard for it. This is also what raises the on-screen + // keyboard on Android — over a field that is on screen, + // which is the whole difference from the old path. + init => { self.take-focus(); } + // Return commits, as it does in every other field here. + // Guarded rather than trusting `enabled` on the button + // beside it: this is a second way in and it must refuse an + // empty name on its own. + accepted(text) => { + if (text != "") { + root.collection-from-selection(text); + root.naming = false; + } + } + } + + HorizontalLayout { + spacing: Theme.gap-sm; + alignment: end; + + Button { + text: "Cancel"; + clicked => { root.naming = false; } + } + + Button { + text: "Create"; + primary: true; + // A collection called "New collection" is the state + // this sheet exists to prevent, so Create waits for a + // name rather than inventing one. + enabled: name-field.text != ""; + clicked => { + root.collection-from-selection(name-field.text); + root.naming = false; + } + } + } + } + } + } } diff --git a/ui/dr-ui/ui/widgets.slint b/ui/dr-ui/ui/widgets.slint index 793439e..18c32a6 100644 --- a/ui/dr-ui/ui/widgets.slint +++ b/ui/dr-ui/ui/widgets.slint @@ -617,6 +617,35 @@ export component Field inherits Rectangle { /// because the breakage is in Slint's binding model, not the styling. callback edited(string); + /// Take the keyboard, and select what is already there. + /// + /// For a sheet whose field is the only thing to do in it: the field arrives + /// with the sheet, nothing else on the card can sensibly hold focus, and + /// asking the user to tap a box that is the only box is a step with no + /// decision in it. On a tablet it is also what raises the on-screen + /// keyboard, which is the actual point. + /// + /// A function rather than a property, because focus is an event and not a + /// state: bound to a property it would fight anything else that took focus + /// afterwards, and re-take it on every unrelated re-evaluation. + public function take-focus() { + input.focus(); + input.select-all(); + } + + /// Give the keyboard back. + /// + /// The other half of `take-focus`, and the one a field that *submits* + /// needs: pressing Enter on a name has finished with the name, but Slint + /// leaves the entry focused, so on a tablet the on-screen keyboard stays + /// up covering the very thing the user just named. Nothing else on those + /// screens takes focus on its own, so the field has to let go itself. + /// + /// A function and not a property, for the reason `take-focus` gives. + public function release-focus() { + input.clear-focus(); + } + height: Theme.touch-target; border-radius: Theme.radius; border-width: 1px;