From fa4ad6e2d600883613409099a29c1be43bf5022e Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 23 Aug 2026 18:42:42 +0200 Subject: [PATCH] Drag the date range on the axis it is chosen from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The range could be turned on with a finger and not aimed with one. Its two ends were typed as `YYYY-MM-DD` into 108px fields behind a soft keyboard, to name days already drawn on the axis a thumb away; and the chip that seeds them takes its span from the timeline's zoom and pan, which are a wheel and a middle button. A touch screen has neither, so on Android the filter was a switch with no aim. The band is now on the timeline. Two ends with grips, dragged along the bars, released to filter — the histogram was already how a period is found, and this makes it how a period is stated. Both ends snap to whole days, which is what the typed fields mean, what `show_range` reads back out, and a floor under a range dragged shut. The fields stay for what dragging cannot do: name an exact day, and say in words what the range is. For that to work the axis had to stop following the range. Redrawn to the band, it moved the ground under the very handles doing the narrowing, and there was nothing outside the range left to widen back into. While there: a fixed number of equal bins instead of calendar buckets. Between one calendar unit and the next the bar count is free to wander by a factor of twelve, so zooming in halved it two steps out of three — the same picture drawn wider until it jumped back to fine. Equal bins also include the empty ones, so a bar's position on the track and the date under it are finally the same quantity; before, a library with gaps drew a February six months wide and the marker, the band and a click all pointed somewhere else. The count is a setting, 32 or 64, because the right answer is a question about the screen. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-catalog/src/lib.rs | 28 ++- core/dr-types/src/lib.rs | 2 +- core/dr-types/src/settings.rs | 98 ++++++++ docs/traceability.md | 62 ++--- ui/dr-ui/src/lib.rs | 12 + ui/dr-ui/src/library.rs | 205 +++++++++++++--- ui/dr-ui/src/library_ui.rs | 434 +++++++++++++++++++++++++++++----- ui/dr-ui/src/settings_ui.rs | 33 ++- ui/dr-ui/ui/app.slint | 18 ++ ui/dr-ui/ui/library.slint | 215 ++++++++++++++++- ui/dr-ui/ui/settings.slint | 38 +++ 11 files changed, 1006 insertions(+), 139 deletions(-) diff --git a/core/dr-catalog/src/lib.rs b/core/dr-catalog/src/lib.rs index 7b073ce..197c6ec 100644 --- a/core/dr-catalog/src/lib.rs +++ b/core/dr-catalog/src/lib.rs @@ -134,6 +134,19 @@ impl Granularity { /// about 48 months — and on a linear measure the larger count always looks /// further away, which would bias every choice towards too few bars. pub fn for_span(seconds: i64) -> Self { + Self::for_bucket(seconds.max(1) / Self::TARGET_BARS) + } + + /// The calendar unit nearest a bucket of `seconds`, for *labelling* one. + /// + /// Split out from [`Self::for_span`] because the axis no longer buckets by + /// calendar unit at all — it divides the visible span into a fixed number + /// of equal bins (see `LibrarySettings::timeline_bars`). What is still + /// wanted is the unit a bin is closest to, so a bin of about a day is + /// labelled as a date and one of about a year as a year. Asked directly + /// rather than derived from the span, because the bin count is now the + /// user's rather than this module's target. + pub fn for_bucket(seconds: i64) -> Self { let seconds = seconds.max(1) as f64; // Finest first, so that when two options are equally far from the // target the finer one wins: `min_by` keeps the first minimum it saw, @@ -147,15 +160,14 @@ impl Granularity { .into_iter() .min_by(|a, b| { let cost = |g: Granularity| { - let bars = seconds / g.approx_seconds() as f64; - // How far off the target, measured multiplicatively: twice as - // many and half as many are equally wrong. + // How far off, measured multiplicatively: twice as long and + // half as long are equally wrong. // - // Deliberately not clamped to at least one bar. A span shorter - // than a bucket scores *worse* the coarser the bucket, which is - // what makes an hour of photographs pick hourly bars instead of - // every option tying at "one bar" and the coarsest winning. - (bars / Self::TARGET_BARS as f64).ln().abs() + // Deliberately not clamped. A bucket shorter than the unit + // scores *worse* the coarser the unit, which is what makes an + // hour of photographs pick hourly bars instead of every option + // tying at "one bucket" and the coarsest winning. + (seconds / g.approx_seconds() as f64).ln().abs() }; cost(*a) .partial_cmp(&cost(*b)) diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index 352fb83..cb6ab21 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -17,7 +17,7 @@ pub use colour::{Chromaticities, Transfer}; pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier}; pub use settings::{ CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings, - ExportTarget, ImportSettings, OutputSharpening, Settings, SizingMode, + ExportTarget, ImportSettings, LibrarySettings, OutputSharpening, Settings, SizingMode, }; pub use time::{ civil_from_unix, civil_from_unix_at, format_date, parse_date, unix_from_civil, Civil, diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs index f2baf62..861d9c4 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -53,6 +53,60 @@ pub struct Settings { pub export: ExportSettings, pub develop: DevelopSettings, pub import: ImportSettings, + pub library: LibrarySettings, +} + +// --------------------------------------------------------------------------- +// Library +// --------------------------------------------------------------------------- + +/// TRACES: FR-CAT-6 +/// How the library view is drawn, where the answer depends on the screen +/// rather than on the photographs. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct LibrarySettings { + /// How many bars the capture-time axis is divided into. + /// + /// **A fixed count, not a bucket size.** The axis used to bucket by + /// calendar unit — year, month, day, hour — and take whichever came + /// nearest a target count. Between two of those units the count is free to + /// wander by a factor of twelve or thirty, so zooming in halved the number + /// of bars two steps out of three: the same picture drawn wider, until it + /// suddenly jumped back to fine. Dividing the visible span into a fixed + /// number of equal bins makes every zoom step show the same amount of + /// detail at a finer scale, which is what zooming is for. + /// + /// It also makes the axis linear in *time*. Calendar buckets are only + /// emitted where photographs exist, and the widget gives each bar an equal + /// slot, so a library with gaps drew a February that was six months wide. + /// Equal bins include the empty ones, so a bar's position on the track and + /// the date under it are the same quantity — which is what lets the range + /// band and the position marker be drawn on the same axis as the bars. + /// + /// Why it is a setting: the right number is a question about the screen. + /// 64 bars on a phone held in the hand is finer than a finger can aim at; + /// 32 on a desktop monitor wastes most of a tall sidebar. + pub timeline_bars: u32, +} + +impl LibrarySettings { + /// The counts the settings page offers. + /// + /// Two, both powers of two, and far enough apart that the difference is + /// visible. A free number would be a control the user has to experiment + /// with to understand, for a choice with exactly two sensible answers: + /// one bar per finger-width on a phone, twice that where there is room. + pub const BAR_CHOICES: [u32; 2] = [32, 64]; +} + +impl Default for LibrarySettings { + fn default() -> Self { + // The coarser of the two. A bar has to be wide enough to hit with a + // finger before it has to be narrow enough to be precise, and the + // smallest screen is the one where getting this wrong hurts most. + Self { timeline_bars: 32 } + } } // --------------------------------------------------------------------------- @@ -709,6 +763,18 @@ impl Settings { pub fn sanitise(&mut self) { self.export.quality = self.export.quality.clamp(1, 100); + // 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 + // say what the axis is currently doing. + if !LibrarySettings::BAR_CHOICES.contains(&self.library.timeline_bars) { + let wanted = self.library.timeline_bars; + self.library.timeline_bars = LibrarySettings::BAR_CHOICES + .into_iter() + .min_by_key(|n| n.abs_diff(wanted)) + .unwrap_or(LibrarySettings::default().timeline_bars); + } + // A zero-pixel or zero-percent export produces no image. Nudged to the // smallest thing that does, rather than back to the default: the user // clearly wanted "small", and silently restoring 2048 would ignore @@ -1050,6 +1116,38 @@ mod tests { assert_eq!(s.export.quality, 1); } + #[test] + fn every_bar_count_can_be_chosen_on_the_page() { + // The page draws one chip per choice and lights the one that matches, + // so a default outside the list would leave every chip dark. + assert!(LibrarySettings::BAR_CHOICES.contains(&LibrarySettings::default().timeline_bars)); + } + + #[test] + fn sanitise_snaps_a_hand_edited_bar_count_to_an_offered_one() { + // The file is hand-editable and the page cannot show a count it does + // not offer. Snapped to the nearest rather than reset to the default: + // someone who wrote 60 wanted the finer axis, and 32 would ignore that. + let mut s = Settings::default(); + s.library.timeline_bars = 60; + s.sanitise(); + assert_eq!(s.library.timeline_bars, 64); + + s.library.timeline_bars = 0; + s.sanitise(); + assert_eq!(s.library.timeline_bars, 32); + } + + #[test] + fn sanitise_leaves_an_offered_bar_count_alone() { + for n in LibrarySettings::BAR_CHOICES { + let mut s = Settings::default(); + s.library.timeline_bars = n; + s.sanitise(); + assert_eq!(s.library.timeline_bars, n); + } + } + #[test] fn sanitise_rescues_a_zero_dimension() { let mut s = Settings::default(); diff --git a/docs/traceability.md b/docs/traceability.md index 36e12ca..e2be6d4 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 | 180 | -| TRACES tags found | 545 | +| TRACES tags found | 555 | | Requirements defined | 177 | | Requirements covered | 89 | | **Coverage** | **50.3%** (89/177) | @@ -34,25 +34,25 @@ _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:200`](../core/dr-types/src/lib.rs#L200), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`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:62`](../core/dr-types/src/settings.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:944`](../ui/dr-ui/src/lib.rs#L944), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:692`](../ui/dr-ui/ui/library.slint#L692), [`ui/dr-ui/ui/library.slint:842`](../ui/dr-ui/ui/library.slint#L842), [`ui/dr-ui/ui/library.slint:990`](../ui/dr-ui/ui/library.slint#L990) | +| 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), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:944`](../ui/dr-ui/src/lib.rs#L944), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1147`](../ui/dr-ui/ui/library.slint#L1147), [`ui/dr-ui/ui/library.slint:849`](../ui/dr-ui/ui/library.slint#L849), [`ui/dr-ui/ui/library.slint:999`](../ui/dr-ui/ui/library.slint#L999) | | 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:944`](../ui/dr-ui/src/lib.rs#L944), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2081`](../ui/dr-ui/src/library.rs#L2081), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109) | | FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) | -| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:365`](../core/dr-catalog/src/schema.rs#L365), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1173`](../ui/dr-ui/src/collections_ui.rs#L1173), [`ui/dr-ui/src/collections_ui.rs:1936`](../ui/dr-ui/src/collections_ui.rs#L1936), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2962`](../ui/dr-ui/src/library.rs#L2962), [`ui/dr-ui/src/library.rs:2994`](../ui/dr-ui/src/library.rs#L2994), [`ui/dr-ui/src/library_ui.rs:128`](../ui/dr-ui/src/library_ui.rs#L128), [`ui/dr-ui/src/library_ui.rs:643`](../ui/dr-ui/src/library_ui.rs#L643), [`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:365`](../core/dr-catalog/src/schema.rs#L365), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1173`](../ui/dr-ui/src/collections_ui.rs#L1173), [`ui/dr-ui/src/collections_ui.rs:1936`](../ui/dr-ui/src/collections_ui.rs#L1936), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2962`](../ui/dr-ui/src/library.rs#L2962), [`ui/dr-ui/src/library.rs:2994`](../ui/dr-ui/src/library.rs#L2994), [`ui/dr-ui/src/library_ui.rs:128`](../ui/dr-ui/src/library_ui.rs#L128), [`ui/dr-ui/src/library_ui.rs:666`](../ui/dr-ui/src/library_ui.rs#L666), [`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:53`](../core/dr-types/src/lib.rs#L53) | | 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:464`](../ui/dr-ui/src/import.rs#L464), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489), [`ui/dr-ui/src/library.rs:2557`](../ui/dr-ui/src/library.rs#L2557), [`ui/dr-ui/src/library.rs:2583`](../ui/dr-ui/src/library.rs#L2583), [`ui/dr-ui/src/library_ui.rs:115`](../ui/dr-ui/src/library_ui.rs#L115), [`ui/dr-ui/src/library_ui.rs:3366`](../ui/dr-ui/src/library_ui.rs#L3366), [`ui/dr-ui/src/library_ui.rs:4154`](../ui/dr-ui/src/library_ui.rs#L4154), [`ui/dr-ui/ui/app.slint:507`](../ui/dr-ui/ui/app.slint#L507), [`ui/dr-ui/ui/settings.slint:330`](../ui/dr-ui/ui/settings.slint#L330), [`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:464`](../ui/dr-ui/src/import.rs#L464), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489), [`ui/dr-ui/src/library.rs:2557`](../ui/dr-ui/src/library.rs#L2557), [`ui/dr-ui/src/library.rs:2583`](../ui/dr-ui/src/library.rs#L2583), [`ui/dr-ui/src/library_ui.rs:115`](../ui/dr-ui/src/library_ui.rs#L115), [`ui/dr-ui/src/library_ui.rs:3409`](../ui/dr-ui/src/library_ui.rs#L3409), [`ui/dr-ui/src/library_ui.rs:4328`](../ui/dr-ui/src/library_ui.rs#L4328), [`ui/dr-ui/ui/app.slint:513`](../ui/dr-ui/ui/app.slint#L513), [`ui/dr-ui/ui/settings.slint:340`](../ui/dr-ui/ui/settings.slint#L340), [`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), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | -| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/schema.rs:783`](../core/dr-catalog/src/schema.rs#L783), [`core/dr-decode/src/lib.rs: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:126`](../core/dr-pipeline/src/sidecar.rs#L126), [`ui/dr-ui/src/collections_ui.rs:1519`](../ui/dr-ui/src/collections_ui.rs#L1519), [`ui/dr-ui/src/collections_ui.rs:1538`](../ui/dr-ui/src/collections_ui.rs#L1538), [`ui/dr-ui/src/library_ui.rs:5533`](../ui/dr-ui/src/library_ui.rs#L5533), [`ui/dr-ui/src/library_ui.rs:5544`](../ui/dr-ui/src/library_ui.rs#L5544), [`ui/dr-ui/src/library_ui.rs:5557`](../ui/dr-ui/src/library_ui.rs#L5557), [`ui/dr-ui/src/library_ui.rs:5572`](../ui/dr-ui/src/library_ui.rs#L5572), [`ui/dr-ui/src/library_ui.rs:5581`](../ui/dr-ui/src/library_ui.rs#L5581), [`ui/dr-ui/ui/app.slint:458`](../ui/dr-ui/ui/app.slint#L458), [`ui/dr-ui/ui/app.slint:579`](../ui/dr-ui/ui/app.slint#L579), [`ui/dr-ui/ui/library.slint:1038`](../ui/dr-ui/ui/library.slint#L1038), [`ui/dr-ui/ui/library.slint:1041`](../ui/dr-ui/ui/library.slint#L1041), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:685`](../ui/dr-ui/ui/library.slint#L685), [`ui/dr-ui/ui/library.slint:736`](../ui/dr-ui/ui/library.slint#L736) | -| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`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:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library_ui.rs:4689`](../ui/dr-ui/src/library_ui.rs#L4689), [`ui/dr-ui/src/library_ui.rs:5673`](../ui/dr-ui/src/library_ui.rs#L5673), [`ui/dr-ui/ui/app.slint:461`](../ui/dr-ui/ui/app.slint#L461), [`ui/dr-ui/ui/app.slint:579`](../ui/dr-ui/ui/app.slint#L579), [`ui/dr-ui/ui/library.slint:1107`](../ui/dr-ui/ui/library.slint#L1107), [`ui/dr-ui/ui/library.slint:736`](../ui/dr-ui/ui/library.slint#L736) | -| 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:1634`](../ui/dr-ui/src/collections_ui.rs#L1634), [`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:3224`](../ui/dr-ui/src/library_ui.rs#L3224), [`ui/dr-ui/ui/app.slint:574`](../ui/dr-ui/ui/app.slint#L574), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) | -| FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:136`](../core/dr-pipeline/src/ops/curve.rs#L136), [`core/dr-pipeline/src/ops/curve.rs:652`](../core/dr-pipeline/src/ops/curve.rs#L652), [`core/dr-pipeline/src/sidecar.rs:1273`](../core/dr-pipeline/src/sidecar.rs#L1273), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`core/dr-pipeline/tests/tone_curve.rs:33`](../core/dr-pipeline/tests/tone_curve.rs#L33), [`ui/dr-ui/src/develop.rs:2600`](../ui/dr-ui/src/develop.rs#L2600), [`ui/dr-ui/src/develop.rs:2628`](../ui/dr-ui/src/develop.rs#L2628), [`ui/dr-ui/src/export.rs:749`](../ui/dr-ui/src/export.rs#L749), [`ui/dr-ui/src/lib.rs:1169`](../ui/dr-ui/src/lib.rs#L1169), [`ui/dr-ui/src/lib.rs:1509`](../ui/dr-ui/src/lib.rs#L1509), [`ui/dr-ui/src/lib.rs:1614`](../ui/dr-ui/src/lib.rs#L1614), [`ui/dr-ui/src/lib.rs:464`](../ui/dr-ui/src/lib.rs#L464), [`ui/dr-ui/src/lib.rs:796`](../ui/dr-ui/src/lib.rs#L796), [`ui/dr-ui/src/library.rs:1504`](../ui/dr-ui/src/library.rs#L1504), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:378`](../ui/dr-ui/src/library.rs#L378), [`ui/dr-ui/src/library.rs:415`](../ui/dr-ui/src/library.rs#L415), [`ui/dr-ui/src/library.rs:655`](../ui/dr-ui/src/library.rs#L655), [`ui/dr-ui/src/library_ui.rs:4443`](../ui/dr-ui/src/library_ui.rs#L4443), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | -| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2325`](../ui/dr-ui/src/develop.rs#L2325), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1464`](../ui/dr-ui/src/library.rs#L1464), [`ui/dr-ui/src/library.rs:1541`](../ui/dr-ui/src/library.rs#L1541), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:3177`](../ui/dr-ui/src/library.rs#L3177), [`ui/dr-ui/src/library.rs:415`](../ui/dr-ui/src/library.rs#L415), [`ui/dr-ui/src/library.rs:639`](../ui/dr-ui/src/library.rs#L639), [`ui/dr-ui/src/library.rs:655`](../ui/dr-ui/src/library.rs#L655), [`ui/dr-ui/src/library.rs:709`](../ui/dr-ui/src/library.rs#L709), [`ui/dr-ui/src/library_ui.rs:1450`](../ui/dr-ui/src/library_ui.rs#L1450), [`ui/dr-ui/src/library_ui.rs:1476`](../ui/dr-ui/src/library_ui.rs#L1476), [`ui/dr-ui/src/library_ui.rs:1492`](../ui/dr-ui/src/library_ui.rs#L1492), [`ui/dr-ui/src/library_ui.rs:1586`](../ui/dr-ui/src/library_ui.rs#L1586), [`ui/dr-ui/src/library_ui.rs:170`](../ui/dr-ui/src/library_ui.rs#L170), [`ui/dr-ui/src/library_ui.rs:203`](../ui/dr-ui/src/library_ui.rs#L203), [`ui/dr-ui/src/library_ui.rs:2119`](../ui/dr-ui/src/library_ui.rs#L2119), [`ui/dr-ui/src/library_ui.rs:2552`](../ui/dr-ui/src/library_ui.rs#L2552), [`ui/dr-ui/src/library_ui.rs:2734`](../ui/dr-ui/src/library_ui.rs#L2734), [`ui/dr-ui/src/library_ui.rs:3015`](../ui/dr-ui/src/library_ui.rs#L3015), [`ui/dr-ui/src/library_ui.rs:3087`](../ui/dr-ui/src/library_ui.rs#L3087), [`ui/dr-ui/src/library_ui.rs:3275`](../ui/dr-ui/src/library_ui.rs#L3275), [`ui/dr-ui/src/library_ui.rs:3393`](../ui/dr-ui/src/library_ui.rs#L3393), [`ui/dr-ui/src/library_ui.rs:358`](../ui/dr-ui/src/library_ui.rs#L358), [`ui/dr-ui/src/library_ui.rs:416`](../ui/dr-ui/src/library_ui.rs#L416), [`ui/dr-ui/src/library_ui.rs:4734`](../ui/dr-ui/src/library_ui.rs#L4734), [`ui/dr-ui/src/library_ui.rs:4849`](../ui/dr-ui/src/library_ui.rs#L4849), [`ui/dr-ui/src/presets.rs:309`](../ui/dr-ui/src/presets.rs#L309), [`ui/dr-ui/src/presets.rs:321`](../ui/dr-ui/src/presets.rs#L321), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/schema.rs:783`](../core/dr-catalog/src/schema.rs#L783), [`core/dr-decode/src/lib.rs: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:126`](../core/dr-pipeline/src/sidecar.rs#L126), [`ui/dr-ui/src/collections_ui.rs:1519`](../ui/dr-ui/src/collections_ui.rs#L1519), [`ui/dr-ui/src/collections_ui.rs:1538`](../ui/dr-ui/src/collections_ui.rs#L1538), [`ui/dr-ui/src/library_ui.rs:5851`](../ui/dr-ui/src/library_ui.rs#L5851), [`ui/dr-ui/src/library_ui.rs:5862`](../ui/dr-ui/src/library_ui.rs#L5862), [`ui/dr-ui/src/library_ui.rs:5875`](../ui/dr-ui/src/library_ui.rs#L5875), [`ui/dr-ui/src/library_ui.rs:5890`](../ui/dr-ui/src/library_ui.rs#L5890), [`ui/dr-ui/src/library_ui.rs:5899`](../ui/dr-ui/src/library_ui.rs#L5899), [`ui/dr-ui/ui/app.slint:458`](../ui/dr-ui/ui/app.slint#L458), [`ui/dr-ui/ui/app.slint:585`](../ui/dr-ui/ui/app.slint#L585), [`ui/dr-ui/ui/library.slint:1195`](../ui/dr-ui/ui/library.slint#L1195), [`ui/dr-ui/ui/library.slint:1198`](../ui/dr-ui/ui/library.slint#L1198), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:842`](../ui/dr-ui/ui/library.slint#L842), [`ui/dr-ui/ui/library.slint:893`](../ui/dr-ui/ui/library.slint#L893) | +| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`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:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:3123`](../ui/dr-ui/src/library.rs#L3123), [`ui/dr-ui/src/library_ui.rs:259`](../ui/dr-ui/src/library_ui.rs#L259), [`ui/dr-ui/src/library_ui.rs:328`](../ui/dr-ui/src/library_ui.rs#L328), [`ui/dr-ui/src/library_ui.rs:4874`](../ui/dr-ui/src/library_ui.rs#L4874), [`ui/dr-ui/src/library_ui.rs:4927`](../ui/dr-ui/src/library_ui.rs#L4927), [`ui/dr-ui/src/library_ui.rs:5991`](../ui/dr-ui/src/library_ui.rs#L5991), [`ui/dr-ui/ui/app.slint:461`](../ui/dr-ui/ui/app.slint#L461), [`ui/dr-ui/ui/app.slint:585`](../ui/dr-ui/ui/app.slint#L585), [`ui/dr-ui/ui/app.slint:814`](../ui/dr-ui/ui/app.slint#L814), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1264`](../ui/dr-ui/ui/library.slint#L1264), [`ui/dr-ui/ui/library.slint:893`](../ui/dr-ui/ui/library.slint#L893), [`ui/dr-ui/ui/settings.slint:115`](../ui/dr-ui/ui/settings.slint#L115) | +| 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:1634`](../ui/dr-ui/src/collections_ui.rs#L1634), [`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:3267`](../ui/dr-ui/src/library_ui.rs#L3267), [`ui/dr-ui/src/library_ui.rs:3930`](../ui/dr-ui/src/library_ui.rs#L3930), [`ui/dr-ui/ui/app.slint:580`](../ui/dr-ui/ui/app.slint#L580), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2496`](../ui/dr-ui/ui/library.slint#L2496), [`ui/dr-ui/ui/library.slint:864`](../ui/dr-ui/ui/library.slint#L864), [`ui/dr-ui/ui/library.slint:883`](../ui/dr-ui/ui/library.slint#L883) | +| FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:136`](../core/dr-pipeline/src/ops/curve.rs#L136), [`core/dr-pipeline/src/ops/curve.rs:652`](../core/dr-pipeline/src/ops/curve.rs#L652), [`core/dr-pipeline/src/sidecar.rs:1273`](../core/dr-pipeline/src/sidecar.rs#L1273), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`core/dr-pipeline/tests/tone_curve.rs:33`](../core/dr-pipeline/tests/tone_curve.rs#L33), [`ui/dr-ui/src/develop.rs:2600`](../ui/dr-ui/src/develop.rs#L2600), [`ui/dr-ui/src/develop.rs:2628`](../ui/dr-ui/src/develop.rs#L2628), [`ui/dr-ui/src/export.rs:749`](../ui/dr-ui/src/export.rs#L749), [`ui/dr-ui/src/lib.rs:1181`](../ui/dr-ui/src/lib.rs#L1181), [`ui/dr-ui/src/lib.rs:1521`](../ui/dr-ui/src/lib.rs#L1521), [`ui/dr-ui/src/lib.rs:1626`](../ui/dr-ui/src/lib.rs#L1626), [`ui/dr-ui/src/lib.rs:464`](../ui/dr-ui/src/lib.rs#L464), [`ui/dr-ui/src/lib.rs:796`](../ui/dr-ui/src/lib.rs#L796), [`ui/dr-ui/src/library.rs:1504`](../ui/dr-ui/src/library.rs#L1504), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:378`](../ui/dr-ui/src/library.rs#L378), [`ui/dr-ui/src/library.rs:415`](../ui/dr-ui/src/library.rs#L415), [`ui/dr-ui/src/library.rs:655`](../ui/dr-ui/src/library.rs#L655), [`ui/dr-ui/src/library_ui.rs:4617`](../ui/dr-ui/src/library_ui.rs#L4617), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2325`](../ui/dr-ui/src/develop.rs#L2325), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1464`](../ui/dr-ui/src/library.rs#L1464), [`ui/dr-ui/src/library.rs:1541`](../ui/dr-ui/src/library.rs#L1541), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:3230`](../ui/dr-ui/src/library.rs#L3230), [`ui/dr-ui/src/library.rs:415`](../ui/dr-ui/src/library.rs#L415), [`ui/dr-ui/src/library.rs:639`](../ui/dr-ui/src/library.rs#L639), [`ui/dr-ui/src/library.rs:655`](../ui/dr-ui/src/library.rs#L655), [`ui/dr-ui/src/library.rs:709`](../ui/dr-ui/src/library.rs#L709), [`ui/dr-ui/src/library_ui.rs:1473`](../ui/dr-ui/src/library_ui.rs#L1473), [`ui/dr-ui/src/library_ui.rs:1499`](../ui/dr-ui/src/library_ui.rs#L1499), [`ui/dr-ui/src/library_ui.rs:1515`](../ui/dr-ui/src/library_ui.rs#L1515), [`ui/dr-ui/src/library_ui.rs:1609`](../ui/dr-ui/src/library_ui.rs#L1609), [`ui/dr-ui/src/library_ui.rs:170`](../ui/dr-ui/src/library_ui.rs#L170), [`ui/dr-ui/src/library_ui.rs:203`](../ui/dr-ui/src/library_ui.rs#L203), [`ui/dr-ui/src/library_ui.rs:2162`](../ui/dr-ui/src/library_ui.rs#L2162), [`ui/dr-ui/src/library_ui.rs:2595`](../ui/dr-ui/src/library_ui.rs#L2595), [`ui/dr-ui/src/library_ui.rs:2777`](../ui/dr-ui/src/library_ui.rs#L2777), [`ui/dr-ui/src/library_ui.rs:3058`](../ui/dr-ui/src/library_ui.rs#L3058), [`ui/dr-ui/src/library_ui.rs:3130`](../ui/dr-ui/src/library_ui.rs#L3130), [`ui/dr-ui/src/library_ui.rs:3318`](../ui/dr-ui/src/library_ui.rs#L3318), [`ui/dr-ui/src/library_ui.rs:3436`](../ui/dr-ui/src/library_ui.rs#L3436), [`ui/dr-ui/src/library_ui.rs:381`](../ui/dr-ui/src/library_ui.rs#L381), [`ui/dr-ui/src/library_ui.rs:439`](../ui/dr-ui/src/library_ui.rs#L439), [`ui/dr-ui/src/library_ui.rs:4972`](../ui/dr-ui/src/library_ui.rs#L4972), [`ui/dr-ui/src/library_ui.rs:5087`](../ui/dr-ui/src/library_ui.rs#L5087), [`ui/dr-ui/src/presets.rs:309`](../ui/dr-ui/src/presets.rs#L309), [`ui/dr-ui/src/presets.rs:321`](../ui/dr-ui/src/presets.rs#L321), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) | | FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:126`](../core/dr-pipeline/src/sidecar.rs#L126), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1860`](../core/dr-gpu/src/adjust.rs#L1860), [`core/dr-gpu/src/adjust.rs:395`](../core/dr-gpu/src/adjust.rs#L395), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:120`](../core/dr-pipeline/src/graph.rs#L120), [`core/dr-pipeline/src/graph.rs:397`](../core/dr-pipeline/src/graph.rs#L397), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:439`](../core/dr-pipeline/src/operation.rs#L439), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`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:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1273`](../core/dr-pipeline/src/sidecar.rs#L1273), [`core/dr-pipeline/src/sidecar.rs:1329`](../core/dr-pipeline/src/sidecar.rs#L1329), [`core/dr-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1111`](../ui/dr-ui/src/develop.rs#L1111), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1584`](../ui/dr-ui/src/develop.rs#L1584), [`ui/dr-ui/src/develop.rs:1599`](../ui/dr-ui/src/develop.rs#L1599), [`ui/dr-ui/src/develop.rs:1621`](../ui/dr-ui/src/develop.rs#L1621), [`ui/dr-ui/src/develop.rs:1753`](../ui/dr-ui/src/develop.rs#L1753), [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/develop.rs:238`](../ui/dr-ui/src/develop.rs#L238), [`ui/dr-ui/src/develop.rs:2600`](../ui/dr-ui/src/develop.rs#L2600), [`ui/dr-ui/src/develop.rs:270`](../ui/dr-ui/src/develop.rs#L270), [`ui/dr-ui/src/develop.rs:2984`](../ui/dr-ui/src/develop.rs#L2984), [`ui/dr-ui/src/develop.rs:3038`](../ui/dr-ui/src/develop.rs#L3038), [`ui/dr-ui/src/develop.rs:3082`](../ui/dr-ui/src/develop.rs#L3082), [`ui/dr-ui/src/develop.rs:3132`](../ui/dr-ui/src/develop.rs#L3132), [`ui/dr-ui/src/develop.rs:506`](../ui/dr-ui/src/develop.rs#L506), [`ui/dr-ui/src/develop.rs:544`](../ui/dr-ui/src/develop.rs#L544), [`ui/dr-ui/src/lib.rs:1205`](../ui/dr-ui/src/lib.rs#L1205), [`ui/dr-ui/src/lib.rs:1857`](../ui/dr-ui/src/lib.rs#L1857), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/library.rs:378`](../ui/dr-ui/src/library.rs#L378), [`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:218`](../ui/dr-ui/src/segmentation.rs#L218), [`ui/dr-ui/ui/app.slint:1885`](../ui/dr-ui/ui/app.slint#L1885), [`ui/dr-ui/ui/app.slint:891`](../ui/dr-ui/ui/app.slint#L891) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1860`](../core/dr-gpu/src/adjust.rs#L1860), [`core/dr-gpu/src/adjust.rs:395`](../core/dr-gpu/src/adjust.rs#L395), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:120`](../core/dr-pipeline/src/graph.rs#L120), [`core/dr-pipeline/src/graph.rs:397`](../core/dr-pipeline/src/graph.rs#L397), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:439`](../core/dr-pipeline/src/operation.rs#L439), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`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:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1273`](../core/dr-pipeline/src/sidecar.rs#L1273), [`core/dr-pipeline/src/sidecar.rs:1329`](../core/dr-pipeline/src/sidecar.rs#L1329), [`core/dr-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1111`](../ui/dr-ui/src/develop.rs#L1111), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1584`](../ui/dr-ui/src/develop.rs#L1584), [`ui/dr-ui/src/develop.rs:1599`](../ui/dr-ui/src/develop.rs#L1599), [`ui/dr-ui/src/develop.rs:1621`](../ui/dr-ui/src/develop.rs#L1621), [`ui/dr-ui/src/develop.rs:1753`](../ui/dr-ui/src/develop.rs#L1753), [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/develop.rs:238`](../ui/dr-ui/src/develop.rs#L238), [`ui/dr-ui/src/develop.rs:2600`](../ui/dr-ui/src/develop.rs#L2600), [`ui/dr-ui/src/develop.rs:270`](../ui/dr-ui/src/develop.rs#L270), [`ui/dr-ui/src/develop.rs:2984`](../ui/dr-ui/src/develop.rs#L2984), [`ui/dr-ui/src/develop.rs:3038`](../ui/dr-ui/src/develop.rs#L3038), [`ui/dr-ui/src/develop.rs:3082`](../ui/dr-ui/src/develop.rs#L3082), [`ui/dr-ui/src/develop.rs:3132`](../ui/dr-ui/src/develop.rs#L3132), [`ui/dr-ui/src/develop.rs:506`](../ui/dr-ui/src/develop.rs#L506), [`ui/dr-ui/src/develop.rs:544`](../ui/dr-ui/src/develop.rs#L544), [`ui/dr-ui/src/lib.rs:1217`](../ui/dr-ui/src/lib.rs#L1217), [`ui/dr-ui/src/lib.rs:1869`](../ui/dr-ui/src/lib.rs#L1869), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/library.rs:378`](../ui/dr-ui/src/library.rs#L378), [`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:218`](../ui/dr-ui/src/segmentation.rs#L218), [`ui/dr-ui/ui/app.slint:1903`](../ui/dr-ui/ui/app.slint#L1903), [`ui/dr-ui/ui/app.slint:901`](../ui/dr-ui/ui/app.slint#L901) | | FR-DEV-3a | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:184`](../core/dr-pipeline/src/graph.rs#L184), [`core/dr-pipeline/src/graph.rs:18`](../core/dr-pipeline/src/graph.rs#L18), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294), [`core/dr-pipeline/src/ops/curve.rs:318`](../core/dr-pipeline/src/ops/curve.rs#L318), [`ui/dr-ui/src/develop.rs:1008`](../ui/dr-ui/src/develop.rs#L1008), [`ui/dr-ui/src/lib.rs:526`](../ui/dr-ui/src/lib.rs#L526) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294) | | FR-DEV-3c | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:184`](../core/dr-pipeline/src/graph.rs#L184), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`ui/dr-ui/src/develop.rs:3711`](../ui/dr-ui/src/develop.rs#L3711) | @@ -60,36 +60,36 @@ _None._ | 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:690`](../core/dr-gpu/src/adjust.rs#L690), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1341`](../core/dr-pipeline/src/operation.rs#L1341), [`core/dr-pipeline/src/operation.rs:1366`](../core/dr-pipeline/src/operation.rs#L1366), [`core/dr-pipeline/src/operation.rs:1381`](../core/dr-pipeline/src/operation.rs#L1381), [`core/dr-pipeline/src/operation.rs:1405`](../core/dr-pipeline/src/operation.rs#L1405), [`core/dr-pipeline/src/operation.rs:369`](../core/dr-pipeline/src/operation.rs#L369), [`core/dr-pipeline/src/operation.rs:379`](../core/dr-pipeline/src/operation.rs#L379), [`core/dr-pipeline/src/operation.rs:510`](../core/dr-pipeline/src/operation.rs#L510) | | 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:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | -| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2644`](../ui/dr-ui/src/develop.rs#L2644), [`ui/dr-ui/src/develop.rs:2654`](../ui/dr-ui/src/develop.rs#L2654), [`ui/dr-ui/src/develop.rs:488`](../ui/dr-ui/src/develop.rs#L488), [`ui/dr-ui/src/lib.rs:1197`](../ui/dr-ui/src/lib.rs#L1197) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:128`](../core/dr-types/src/settings.rs#L128), [`ui/dr-ui/src/develop.rs:2594`](../ui/dr-ui/src/develop.rs#L2594), [`ui/dr-ui/src/develop.rs:2615`](../ui/dr-ui/src/develop.rs#L2615), [`ui/dr-ui/src/lib.rs:1169`](../ui/dr-ui/src/lib.rs#L1169), [`ui/dr-ui/src/library.rs:1504`](../ui/dr-ui/src/library.rs#L1504), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2379`](../ui/dr-ui/src/library_ui.rs#L2379), [`ui/dr-ui/src/library_ui.rs:2734`](../ui/dr-ui/src/library_ui.rs#L2734), [`ui/dr-ui/src/library_ui.rs:384`](../ui/dr-ui/src/library_ui.rs#L384), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1124`](../ui/dr-ui/ui/library.slint#L1124), [`ui/dr-ui/ui/library.slint:666`](../ui/dr-ui/ui/library.slint#L666), [`ui/dr-ui/ui/library.slint:747`](../ui/dr-ui/ui/library.slint#L747), [`ui/dr-ui/ui/settings.slint:87`](../ui/dr-ui/ui/settings.slint#L87) | +| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2644`](../ui/dr-ui/src/develop.rs#L2644), [`ui/dr-ui/src/develop.rs:2654`](../ui/dr-ui/src/develop.rs#L2654), [`ui/dr-ui/src/develop.rs:488`](../ui/dr-ui/src/develop.rs#L488), [`ui/dr-ui/src/lib.rs:1209`](../ui/dr-ui/src/lib.rs#L1209) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:2594`](../ui/dr-ui/src/develop.rs#L2594), [`ui/dr-ui/src/develop.rs:2615`](../ui/dr-ui/src/develop.rs#L2615), [`ui/dr-ui/src/lib.rs:1181`](../ui/dr-ui/src/lib.rs#L1181), [`ui/dr-ui/src/library.rs:1504`](../ui/dr-ui/src/library.rs#L1504), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2422`](../ui/dr-ui/src/library_ui.rs#L2422), [`ui/dr-ui/src/library_ui.rs:2777`](../ui/dr-ui/src/library_ui.rs#L2777), [`ui/dr-ui/src/library_ui.rs:407`](../ui/dr-ui/src/library_ui.rs#L407), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:535`](../ui/dr-ui/src/settings_ui.rs#L535), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1291`](../ui/dr-ui/ui/library.slint#L1291), [`ui/dr-ui/ui/library.slint:823`](../ui/dr-ui/ui/library.slint#L823), [`ui/dr-ui/ui/library.slint:904`](../ui/dr-ui/ui/library.slint#L904), [`ui/dr-ui/ui/settings.slint:87`](../ui/dr-ui/ui/settings.slint#L87) | | FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265) | | FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1783`](../core/dr-gpu/src/adjust.rs#L1783), [`core/dr-gpu/src/adjust.rs:1860`](../core/dr-gpu/src/adjust.rs#L1860), [`core/dr-gpu/src/adjust.rs:1945`](../core/dr-gpu/src/adjust.rs#L1945), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:199`](../core/dr-gpu/tests/capture_sharpen.rs#L199), [`core/dr-gpu/tests/detail_stage.rs:327`](../core/dr-gpu/tests/detail_stage.rs#L327), [`core/dr-gpu/tests/local_contrast.rs:262`](../core/dr-gpu/tests/local_contrast.rs#L262), [`core/dr-gpu/tests/noise_reduction.rs:377`](../core/dr-gpu/tests/noise_reduction.rs#L377), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:367`](../core/dr-pipeline/src/graph.rs#L367), [`core/dr-pipeline/src/graph.rs:397`](../core/dr-pipeline/src/graph.rs#L397), [`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:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`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:658`](../core/dr-pipeline/src/ops/local_contrast.rs#L658), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:2161`](../ui/dr-ui/src/develop.rs#L2161), [`ui/dr-ui/src/develop.rs:2792`](../ui/dr-ui/src/develop.rs#L2792), [`ui/dr-ui/src/develop.rs:3256`](../ui/dr-ui/src/develop.rs#L3256), [`ui/dr-ui/src/develop.rs:3290`](../ui/dr-ui/src/develop.rs#L3290), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:667`](../ui/dr-ui/src/lib.rs#L667) | | FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) | -| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2206`](../ui/dr-ui/src/develop.rs#L2206), [`ui/dr-ui/src/develop.rs:4358`](../ui/dr-ui/src/develop.rs#L4358), [`ui/dr-ui/src/develop.rs:4390`](../ui/dr-ui/src/develop.rs#L4390), [`ui/dr-ui/src/develop.rs:499`](../ui/dr-ui/src/develop.rs#L499), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1256`](../ui/dr-ui/src/lib.rs#L1256), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:276`](../ui/dr-ui/ui/app.slint#L276), [`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-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:2206`](../ui/dr-ui/src/develop.rs#L2206), [`ui/dr-ui/src/develop.rs:4358`](../ui/dr-ui/src/develop.rs#L4358), [`ui/dr-ui/src/develop.rs:4390`](../ui/dr-ui/src/develop.rs#L4390), [`ui/dr-ui/src/develop.rs:499`](../ui/dr-ui/src/develop.rs#L499), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1268`](../ui/dr-ui/src/lib.rs#L1268), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:276`](../ui/dr-ui/ui/app.slint#L276), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs: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:2093`](../core/dr-gpu/src/adjust.rs#L2093), [`core/dr-pipeline/src/graph.rs:357`](../core/dr-pipeline/src/graph.rs#L357), [`core/dr-pipeline/src/graph.rs:410`](../core/dr-pipeline/src/graph.rs#L410), [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:541`](../core/dr-types/src/settings.rs#L541), [`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:2093`](../core/dr-gpu/src/adjust.rs#L2093), [`core/dr-pipeline/src/graph.rs:357`](../core/dr-pipeline/src/graph.rs#L357), [`core/dr-pipeline/src/graph.rs:410`](../core/dr-pipeline/src/graph.rs#L410), [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | -| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:561`](../ui/dr-ui/src/settings_ui.rs#L561) | -| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:941`](../ui/dr-ui/src/export.rs#L941), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175), [`ui/dr-ui/src/lib.rs:1780`](../ui/dr-ui/src/lib.rs#L1780), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/lib.rs:363`](../ui/dr-ui/src/lib.rs#L363), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library_ui.rs:3104`](../ui/dr-ui/src/library_ui.rs#L3104), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`ui/dr-ui/src/library_ui.rs:546`](../ui/dr-ui/src/library_ui.rs#L546), [`ui/dr-ui/src/library_ui.rs:5515`](../ui/dr-ui/src/library_ui.rs#L5515), [`ui/dr-ui/src/library_ui.rs:5592`](../ui/dr-ui/src/library_ui.rs#L5592), [`ui/dr-ui/src/library_ui.rs:5604`](../ui/dr-ui/src/library_ui.rs#L5604), [`ui/dr-ui/src/library_ui.rs:603`](../ui/dr-ui/src/library_ui.rs#L603), [`ui/dr-ui/ui/app.slint:1354`](../ui/dr-ui/ui/app.slint#L1354), [`ui/dr-ui/ui/app.slint:982`](../ui/dr-ui/ui/app.slint#L982), [`ui/dr-ui/ui/library.slint:1132`](../ui/dr-ui/ui/library.slint#L1132), [`ui/dr-ui/ui/library.slint:670`](../ui/dr-ui/ui/library.slint#L670), [`ui/dr-ui/ui/library.slint:762`](../ui/dr-ui/ui/library.slint#L762) | -| 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:1222`](../core/dr-decode/src/locate.rs#L1222), [`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:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/settings.rs:259`](../core/dr-types/src/settings.rs#L259), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:776`](../ui/dr-ui/src/export.rs#L776), [`ui/dr-ui/src/export.rs:793`](../ui/dr-ui/src/export.rs#L793), [`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:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:590`](../ui/dr-ui/src/settings_ui.rs#L590) | +| 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:941`](../ui/dr-ui/src/export.rs#L941), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175), [`ui/dr-ui/src/lib.rs:1792`](../ui/dr-ui/src/lib.rs#L1792), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/lib.rs:363`](../ui/dr-ui/src/lib.rs#L363), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library_ui.rs:3147`](../ui/dr-ui/src/library_ui.rs#L3147), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/library_ui.rs:569`](../ui/dr-ui/src/library_ui.rs#L569), [`ui/dr-ui/src/library_ui.rs:5833`](../ui/dr-ui/src/library_ui.rs#L5833), [`ui/dr-ui/src/library_ui.rs:5910`](../ui/dr-ui/src/library_ui.rs#L5910), [`ui/dr-ui/src/library_ui.rs:5922`](../ui/dr-ui/src/library_ui.rs#L5922), [`ui/dr-ui/src/library_ui.rs:626`](../ui/dr-ui/src/library_ui.rs#L626), [`ui/dr-ui/ui/app.slint:1372`](../ui/dr-ui/ui/app.slint#L1372), [`ui/dr-ui/ui/app.slint:992`](../ui/dr-ui/ui/app.slint#L992), [`ui/dr-ui/ui/library.slint:1299`](../ui/dr-ui/ui/library.slint#L1299), [`ui/dr-ui/ui/library.slint:827`](../ui/dr-ui/ui/library.slint#L827), [`ui/dr-ui/ui/library.slint:919`](../ui/dr-ui/ui/library.slint#L919) | +| 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:1222`](../core/dr-decode/src/locate.rs#L1222), [`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:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:776`](../ui/dr-ui/src/export.rs#L776), [`ui/dr-ui/src/export.rs:793`](../ui/dr-ui/src/export.rs#L793), [`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:791`](../core/dr-gpu/src/adjust.rs#L791), [`ui/dr-ui/src/develop.rs:2284`](../ui/dr-ui/src/develop.rs#L2284), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328) | | FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) | -| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library.rs:1541`](../ui/dr-ui/src/library.rs#L1541), [`ui/dr-ui/src/library.rs:415`](../ui/dr-ui/src/library.rs#L415), [`ui/dr-ui/src/library.rs:709`](../ui/dr-ui/src/library.rs#L709), [`ui/dr-ui/src/library.rs:906`](../ui/dr-ui/src/library.rs#L906), [`ui/dr-ui/src/library_ui.rs:1492`](../ui/dr-ui/src/library_ui.rs#L1492), [`ui/dr-ui/src/library_ui.rs:3104`](../ui/dr-ui/src/library_ui.rs#L3104), [`ui/dr-ui/src/library_ui.rs:416`](../ui/dr-ui/src/library_ui.rs#L416), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library.rs:1541`](../ui/dr-ui/src/library.rs#L1541), [`ui/dr-ui/src/library.rs:415`](../ui/dr-ui/src/library.rs#L415), [`ui/dr-ui/src/library.rs:709`](../ui/dr-ui/src/library.rs#L709), [`ui/dr-ui/src/library.rs:906`](../ui/dr-ui/src/library.rs#L906), [`ui/dr-ui/src/library_ui.rs:1515`](../ui/dr-ui/src/library_ui.rs#L1515), [`ui/dr-ui/src/library_ui.rs:3147`](../ui/dr-ui/src/library_ui.rs#L3147), [`ui/dr-ui/src/library_ui.rs:439`](../ui/dr-ui/src/library_ui.rs#L439), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:892`](../core/dr-sync-nextcloud/src/lib.rs#L892), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/lib.rs:40`](../core/dr-sync/src/lib.rs#L40), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) | | FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34) | -| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2557`](../ui/dr-ui/src/library.rs#L2557), [`ui/dr-ui/src/library.rs:2583`](../ui/dr-ui/src/library.rs#L2583), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3366`](../ui/dr-ui/src/library_ui.rs#L3366), [`ui/dr-ui/src/library_ui.rs:4154`](../ui/dr-ui/src/library_ui.rs#L4154), [`ui/dr-ui/ui/app.slint:507`](../ui/dr-ui/ui/app.slint#L507), [`ui/dr-ui/ui/settings.slint:330`](../ui/dr-ui/ui/settings.slint#L330), [`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:161`](../core/dr-decode/src/preview.rs#L161), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2557`](../ui/dr-ui/src/library.rs#L2557), [`ui/dr-ui/src/library.rs:2583`](../ui/dr-ui/src/library.rs#L2583), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3409`](../ui/dr-ui/src/library_ui.rs#L3409), [`ui/dr-ui/src/library_ui.rs:4328`](../ui/dr-ui/src/library_ui.rs#L4328), [`ui/dr-ui/ui/app.slint:513`](../ui/dr-ui/ui/app.slint#L513), [`ui/dr-ui/ui/settings.slint:340`](../ui/dr-ui/ui/settings.slint#L340), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | | FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) | | FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489) | | FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) | -| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/schema.rs:738`](../core/dr-catalog/src/schema.rs#L738), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3053`](../ui/dr-ui/src/collections_ui.rs#L3053), [`ui/dr-ui/src/collections_ui.rs:576`](../ui/dr-ui/src/collections_ui.rs#L576), [`ui/dr-ui/src/collections_ui.rs:638`](../ui/dr-ui/src/collections_ui.rs#L638), [`ui/dr-ui/src/lib.rs:1540`](../ui/dr-ui/src/lib.rs#L1540), [`ui/dr-ui/src/lib.rs:2280`](../ui/dr-ui/src/lib.rs#L2280), [`ui/dr-ui/src/library.rs:1283`](../ui/dr-ui/src/library.rs#L1283), [`ui/dr-ui/src/library.rs:1306`](../ui/dr-ui/src/library.rs#L1306), [`ui/dr-ui/src/library.rs:1464`](../ui/dr-ui/src/library.rs#L1464), [`ui/dr-ui/src/library_ui.rs:1024`](../ui/dr-ui/src/library_ui.rs#L1024), [`ui/dr-ui/src/library_ui.rs:1125`](../ui/dr-ui/src/library_ui.rs#L1125), [`ui/dr-ui/src/library_ui.rs:1180`](../ui/dr-ui/src/library_ui.rs#L1180), [`ui/dr-ui/src/library_ui.rs:1298`](../ui/dr-ui/src/library_ui.rs#L1298), [`ui/dr-ui/src/library_ui.rs:1417`](../ui/dr-ui/src/library_ui.rs#L1417), [`ui/dr-ui/src/library_ui.rs:1895`](../ui/dr-ui/src/library_ui.rs#L1895), [`ui/dr-ui/src/library_ui.rs:211`](../ui/dr-ui/src/library_ui.rs#L211), [`ui/dr-ui/src/library_ui.rs:222`](../ui/dr-ui/src/library_ui.rs#L222), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:242`](../ui/dr-ui/src/library_ui.rs#L242), [`ui/dr-ui/src/library_ui.rs:251`](../ui/dr-ui/src/library_ui.rs#L251), [`ui/dr-ui/src/library_ui.rs:317`](../ui/dr-ui/src/library_ui.rs#L317), [`ui/dr-ui/src/library_ui.rs:327`](../ui/dr-ui/src/library_ui.rs#L327), [`ui/dr-ui/src/library_ui.rs:369`](../ui/dr-ui/src/library_ui.rs#L369), [`ui/dr-ui/src/library_ui.rs:432`](../ui/dr-ui/src/library_ui.rs#L432), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:4751`](../ui/dr-ui/src/library_ui.rs#L4751), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`ui/dr-ui/src/library_ui.rs:4769`](../ui/dr-ui/src/library_ui.rs#L4769), [`ui/dr-ui/src/library_ui.rs:4781`](../ui/dr-ui/src/library_ui.rs#L4781), [`ui/dr-ui/src/library_ui.rs:951`](../ui/dr-ui/src/library_ui.rs#L951), [`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:2383`](../ui/dr-ui/ui/app.slint#L2383), [`ui/dr-ui/ui/app.slint:568`](../ui/dr-ui/ui/app.slint#L568), [`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:808`](../ui/dr-ui/ui/library.slint#L808) | -| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1180`](../ui/dr-ui/src/library_ui.rs#L1180) | -| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3053`](../ui/dr-ui/src/collections_ui.rs#L3053), [`ui/dr-ui/src/collections_ui.rs:576`](../ui/dr-ui/src/collections_ui.rs#L576), [`ui/dr-ui/src/collections_ui.rs:638`](../ui/dr-ui/src/collections_ui.rs#L638), [`ui/dr-ui/src/library_ui.rs:1024`](../ui/dr-ui/src/library_ui.rs#L1024), [`ui/dr-ui/src/library_ui.rs:951`](../ui/dr-ui/src/library_ui.rs#L951), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) | -| FR-NC-7 | [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:2583`](../ui/dr-ui/src/library.rs#L2583), [`ui/dr-ui/src/library_ui.rs:3366`](../ui/dr-ui/src/library_ui.rs#L3366), [`ui/dr-ui/ui/settings.slint:330`](../ui/dr-ui/ui/settings.slint#L330) | -| 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:62`](../core/dr-types/src/settings.rs#L62), [`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:944`](../ui/dr-ui/src/lib.rs#L944), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/schema.rs:738`](../core/dr-catalog/src/schema.rs#L738), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3053`](../ui/dr-ui/src/collections_ui.rs#L3053), [`ui/dr-ui/src/collections_ui.rs:576`](../ui/dr-ui/src/collections_ui.rs#L576), [`ui/dr-ui/src/collections_ui.rs:638`](../ui/dr-ui/src/collections_ui.rs#L638), [`ui/dr-ui/src/lib.rs:1552`](../ui/dr-ui/src/lib.rs#L1552), [`ui/dr-ui/src/lib.rs:2292`](../ui/dr-ui/src/lib.rs#L2292), [`ui/dr-ui/src/library.rs:1283`](../ui/dr-ui/src/library.rs#L1283), [`ui/dr-ui/src/library.rs:1306`](../ui/dr-ui/src/library.rs#L1306), [`ui/dr-ui/src/library.rs:1464`](../ui/dr-ui/src/library.rs#L1464), [`ui/dr-ui/src/library_ui.rs:1047`](../ui/dr-ui/src/library_ui.rs#L1047), [`ui/dr-ui/src/library_ui.rs:1148`](../ui/dr-ui/src/library_ui.rs#L1148), [`ui/dr-ui/src/library_ui.rs:1203`](../ui/dr-ui/src/library_ui.rs#L1203), [`ui/dr-ui/src/library_ui.rs:1321`](../ui/dr-ui/src/library_ui.rs#L1321), [`ui/dr-ui/src/library_ui.rs:1440`](../ui/dr-ui/src/library_ui.rs#L1440), [`ui/dr-ui/src/library_ui.rs:1918`](../ui/dr-ui/src/library_ui.rs#L1918), [`ui/dr-ui/src/library_ui.rs:211`](../ui/dr-ui/src/library_ui.rs#L211), [`ui/dr-ui/src/library_ui.rs:222`](../ui/dr-ui/src/library_ui.rs#L222), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:242`](../ui/dr-ui/src/library_ui.rs#L242), [`ui/dr-ui/src/library_ui.rs:251`](../ui/dr-ui/src/library_ui.rs#L251), [`ui/dr-ui/src/library_ui.rs:340`](../ui/dr-ui/src/library_ui.rs#L340), [`ui/dr-ui/src/library_ui.rs:350`](../ui/dr-ui/src/library_ui.rs#L350), [`ui/dr-ui/src/library_ui.rs:392`](../ui/dr-ui/src/library_ui.rs#L392), [`ui/dr-ui/src/library_ui.rs:455`](../ui/dr-ui/src/library_ui.rs#L455), [`ui/dr-ui/src/library_ui.rs:486`](../ui/dr-ui/src/library_ui.rs#L486), [`ui/dr-ui/src/library_ui.rs:4989`](../ui/dr-ui/src/library_ui.rs#L4989), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/library_ui.rs:5007`](../ui/dr-ui/src/library_ui.rs#L5007), [`ui/dr-ui/src/library_ui.rs:5019`](../ui/dr-ui/src/library_ui.rs#L5019), [`ui/dr-ui/src/library_ui.rs:974`](../ui/dr-ui/src/library_ui.rs#L974), [`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:2401`](../ui/dr-ui/ui/app.slint#L2401), [`ui/dr-ui/ui/app.slint:574`](../ui/dr-ui/ui/app.slint#L574), [`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:965`](../ui/dr-ui/ui/library.slint#L965) | +| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1203`](../ui/dr-ui/src/library_ui.rs#L1203) | +| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3053`](../ui/dr-ui/src/collections_ui.rs#L3053), [`ui/dr-ui/src/collections_ui.rs:576`](../ui/dr-ui/src/collections_ui.rs#L576), [`ui/dr-ui/src/collections_ui.rs:638`](../ui/dr-ui/src/collections_ui.rs#L638), [`ui/dr-ui/src/library_ui.rs:1047`](../ui/dr-ui/src/library_ui.rs#L1047), [`ui/dr-ui/src/library_ui.rs:974`](../ui/dr-ui/src/library_ui.rs#L974), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) | +| FR-NC-7 | [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:2583`](../ui/dr-ui/src/library.rs#L2583), [`ui/dr-ui/src/library_ui.rs:3409`](../ui/dr-ui/src/library_ui.rs#L3409), [`ui/dr-ui/ui/settings.slint:340`](../ui/dr-ui/ui/settings.slint#L340) | +| 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:944`](../ui/dr-ui/src/lib.rs#L944), [`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:123`](../ui/dr-ui/src/import.rs#L123), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import.rs:585`](../ui/dr-ui/src/import.rs#L585), [`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:944`](../ui/dr-ui/src/lib.rs#L944) | -| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/lib.rs:1509`](../ui/dr-ui/src/lib.rs#L1509), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:384`](../ui/dr-ui/src/library_ui.rs#L384) | +| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/lib.rs:1521`](../ui/dr-ui/src/lib.rs#L1521), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:407`](../ui/dr-ui/src/library_ui.rs#L407) | | FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`core/dr-pipeline/src/sidecar.rs:301`](../core/dr-pipeline/src/sidecar.rs#L301), [`ui/dr-ui/src/library.rs:709`](../ui/dr-ui/src/library.rs#L709), [`ui/dr-ui/src/library.rs:839`](../ui/dr-ui/src/library.rs#L839) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | @@ -98,14 +98,14 @@ _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:1365`](../core/dr-decode/src/locate.rs#L1365) | | FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175) | | 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:2362`](../ui/dr-ui/src/lib.rs#L2362), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/library.slint:866`](../ui/dr-ui/ui/library.slint#L866) | -| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:1450`](../ui/dr-ui/src/collections_ui.rs#L1450), [`ui/dr-ui/src/collections_ui.rs:1464`](../ui/dr-ui/src/collections_ui.rs#L1464), [`ui/dr-ui/src/collections_ui.rs:1509`](../ui/dr-ui/src/collections_ui.rs#L1509), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1607`](../ui/dr-ui/src/collections_ui.rs#L1607), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:943`](../ui/dr-ui/src/collections_ui.rs#L943), [`ui/dr-ui/src/collections_ui.rs:953`](../ui/dr-ui/src/collections_ui.rs#L953), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:4781`](../ui/dr-ui/src/library_ui.rs#L4781), [`ui/dr-ui/src/library_ui.rs:5623`](../ui/dr-ui/src/library_ui.rs#L5623), [`ui/dr-ui/ui/app.slint:2112`](../ui/dr-ui/ui/app.slint#L2112), [`ui/dr-ui/ui/app.slint:262`](../ui/dr-ui/ui/app.slint#L262), [`ui/dr-ui/ui/app.slint:598`](../ui/dr-ui/ui/app.slint#L598), [`ui/dr-ui/ui/app.slint:605`](../ui/dr-ui/ui/app.slint#L605), [`ui/dr-ui/ui/library.slint:1015`](../ui/dr-ui/ui/library.slint#L1015), [`ui/dr-ui/ui/library.slint:1022`](../ui/dr-ui/ui/library.slint#L1022), [`ui/dr-ui/ui/library.slint:1028`](../ui/dr-ui/ui/library.slint#L1028), [`ui/dr-ui/ui/library.slint:2069`](../ui/dr-ui/ui/library.slint#L2069), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/settings.slint:93`](../ui/dr-ui/ui/settings.slint#L93) | -| FR-UI-3 | [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/library_ui.rs:3949`](../ui/dr-ui/src/library_ui.rs#L3949), [`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/ui/app.slint:1885`](../ui/dr-ui/ui/app.slint#L1885), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) | -| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:124`](../ui/dr-ui/src/collections_ui.rs#L124), [`ui/dr-ui/src/collections_ui.rs:1450`](../ui/dr-ui/src/collections_ui.rs#L1450), [`ui/dr-ui/src/collections_ui.rs:1464`](../ui/dr-ui/src/collections_ui.rs#L1464), [`ui/dr-ui/src/collections_ui.rs:1509`](../ui/dr-ui/src/collections_ui.rs#L1509), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1607`](../ui/dr-ui/src/collections_ui.rs#L1607), [`ui/dr-ui/src/collections_ui.rs:1634`](../ui/dr-ui/src/collections_ui.rs#L1634), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:537`](../ui/dr-ui/src/collections_ui.rs#L537), [`ui/dr-ui/src/collections_ui.rs:943`](../ui/dr-ui/src/collections_ui.rs#L943), [`ui/dr-ui/src/collections_ui.rs:953`](../ui/dr-ui/src/collections_ui.rs#L953), [`ui/dr-ui/src/library_ui.rs:3949`](../ui/dr-ui/src/library_ui.rs#L3949), [`ui/dr-ui/src/library_ui.rs:3994`](../ui/dr-ui/src/library_ui.rs#L3994), [`ui/dr-ui/src/library_ui.rs:4097`](../ui/dr-ui/src/library_ui.rs#L4097), [`ui/dr-ui/src/library_ui.rs:4125`](../ui/dr-ui/src/library_ui.rs#L4125), [`ui/dr-ui/src/library_ui.rs:4769`](../ui/dr-ui/src/library_ui.rs#L4769), [`ui/dr-ui/src/library_ui.rs:4781`](../ui/dr-ui/src/library_ui.rs#L4781), [`ui/dr-ui/ui/app.slint:1581`](../ui/dr-ui/ui/app.slint#L1581), [`ui/dr-ui/ui/app.slint:574`](../ui/dr-ui/ui/app.slint#L574), [`ui/dr-ui/ui/app.slint:605`](../ui/dr-ui/ui/app.slint#L605), [`ui/dr-ui/ui/library.slint:1015`](../ui/dr-ui/ui/library.slint#L1015), [`ui/dr-ui/ui/library.slint:1022`](../ui/dr-ui/ui/library.slint#L1022), [`ui/dr-ui/ui/library.slint:1028`](../ui/dr-ui/ui/library.slint#L1028), [`ui/dr-ui/ui/library.slint:2069`](../ui/dr-ui/ui/library.slint#L2069), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) | -| 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:2007`](../ui/dr-ui/src/lib.rs#L2007), [`ui/dr-ui/src/lib.rs:2396`](../ui/dr-ui/src/lib.rs#L2396), [`ui/dr-ui/src/lib.rs:2581`](../ui/dr-ui/src/lib.rs#L2581), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:297`](../ui/dr-ui/ui/app.slint#L297), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2374`](../ui/dr-ui/src/lib.rs#L2374), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/library.slint:1023`](../ui/dr-ui/ui/library.slint#L1023) | +| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:1450`](../ui/dr-ui/src/collections_ui.rs#L1450), [`ui/dr-ui/src/collections_ui.rs:1464`](../ui/dr-ui/src/collections_ui.rs#L1464), [`ui/dr-ui/src/collections_ui.rs:1509`](../ui/dr-ui/src/collections_ui.rs#L1509), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1607`](../ui/dr-ui/src/collections_ui.rs#L1607), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:943`](../ui/dr-ui/src/collections_ui.rs#L943), [`ui/dr-ui/src/collections_ui.rs:953`](../ui/dr-ui/src/collections_ui.rs#L953), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:4874`](../ui/dr-ui/src/library_ui.rs#L4874), [`ui/dr-ui/src/library_ui.rs:5019`](../ui/dr-ui/src/library_ui.rs#L5019), [`ui/dr-ui/src/library_ui.rs:5941`](../ui/dr-ui/src/library_ui.rs#L5941), [`ui/dr-ui/ui/app.slint:2130`](../ui/dr-ui/ui/app.slint#L2130), [`ui/dr-ui/ui/app.slint:262`](../ui/dr-ui/ui/app.slint#L262), [`ui/dr-ui/ui/app.slint:604`](../ui/dr-ui/ui/app.slint#L604), [`ui/dr-ui/ui/app.slint:611`](../ui/dr-ui/ui/app.slint#L611), [`ui/dr-ui/ui/library.slint:1172`](../ui/dr-ui/ui/library.slint#L1172), [`ui/dr-ui/ui/library.slint:1179`](../ui/dr-ui/ui/library.slint#L1179), [`ui/dr-ui/ui/library.slint:1185`](../ui/dr-ui/ui/library.slint#L1185), [`ui/dr-ui/ui/library.slint:2246`](../ui/dr-ui/ui/library.slint#L2246), [`ui/dr-ui/ui/library.slint:815`](../ui/dr-ui/ui/library.slint#L815), [`ui/dr-ui/ui/library.slint:864`](../ui/dr-ui/ui/library.slint#L864), [`ui/dr-ui/ui/settings.slint:93`](../ui/dr-ui/ui/settings.slint#L93) | +| FR-UI-3 | [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/library_ui.rs:4123`](../ui/dr-ui/src/library_ui.rs#L4123), [`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/ui/app.slint:1903`](../ui/dr-ui/ui/app.slint#L1903), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) | +| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:124`](../ui/dr-ui/src/collections_ui.rs#L124), [`ui/dr-ui/src/collections_ui.rs:1450`](../ui/dr-ui/src/collections_ui.rs#L1450), [`ui/dr-ui/src/collections_ui.rs:1464`](../ui/dr-ui/src/collections_ui.rs#L1464), [`ui/dr-ui/src/collections_ui.rs:1509`](../ui/dr-ui/src/collections_ui.rs#L1509), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1607`](../ui/dr-ui/src/collections_ui.rs#L1607), [`ui/dr-ui/src/collections_ui.rs:1634`](../ui/dr-ui/src/collections_ui.rs#L1634), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:537`](../ui/dr-ui/src/collections_ui.rs#L537), [`ui/dr-ui/src/collections_ui.rs:943`](../ui/dr-ui/src/collections_ui.rs#L943), [`ui/dr-ui/src/collections_ui.rs:953`](../ui/dr-ui/src/collections_ui.rs#L953), [`ui/dr-ui/src/library_ui.rs:4123`](../ui/dr-ui/src/library_ui.rs#L4123), [`ui/dr-ui/src/library_ui.rs:4168`](../ui/dr-ui/src/library_ui.rs#L4168), [`ui/dr-ui/src/library_ui.rs:4271`](../ui/dr-ui/src/library_ui.rs#L4271), [`ui/dr-ui/src/library_ui.rs:4299`](../ui/dr-ui/src/library_ui.rs#L4299), [`ui/dr-ui/src/library_ui.rs:5007`](../ui/dr-ui/src/library_ui.rs#L5007), [`ui/dr-ui/src/library_ui.rs:5019`](../ui/dr-ui/src/library_ui.rs#L5019), [`ui/dr-ui/ui/app.slint:1599`](../ui/dr-ui/ui/app.slint#L1599), [`ui/dr-ui/ui/app.slint:580`](../ui/dr-ui/ui/app.slint#L580), [`ui/dr-ui/ui/app.slint:611`](../ui/dr-ui/ui/app.slint#L611), [`ui/dr-ui/ui/library.slint:1172`](../ui/dr-ui/ui/library.slint#L1172), [`ui/dr-ui/ui/library.slint:1179`](../ui/dr-ui/ui/library.slint#L1179), [`ui/dr-ui/ui/library.slint:1185`](../ui/dr-ui/ui/library.slint#L1185), [`ui/dr-ui/ui/library.slint:2246`](../ui/dr-ui/ui/library.slint#L2246), [`ui/dr-ui/ui/library.slint:815`](../ui/dr-ui/ui/library.slint#L815), [`ui/dr-ui/ui/library.slint:864`](../ui/dr-ui/ui/library.slint#L864), [`ui/dr-ui/ui/library.slint:883`](../ui/dr-ui/ui/library.slint#L883) | +| 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:2019`](../ui/dr-ui/src/lib.rs#L2019), [`ui/dr-ui/src/lib.rs:2408`](../ui/dr-ui/src/lib.rs#L2408), [`ui/dr-ui/src/lib.rs:2593`](../ui/dr-ui/src/lib.rs#L2593), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:297`](../ui/dr-ui/ui/app.slint#L297), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | -| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1552`](../ui/dr-ui/src/export.rs#L1552), [`ui/dr-ui/src/export.rs:1578`](../ui/dr-ui/src/export.rs#L1578), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:1814`](../ui/dr-ui/src/lib.rs#L1814), [`ui/dr-ui/ui/app.slint:993`](../ui/dr-ui/ui/app.slint#L993), [`ui/dr-ui/ui/library.slint:762`](../ui/dr-ui/ui/library.slint#L762) | +| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1552`](../ui/dr-ui/src/export.rs#L1552), [`ui/dr-ui/src/export.rs:1578`](../ui/dr-ui/src/export.rs#L1578), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:1826`](../ui/dr-ui/src/lib.rs#L1826), [`ui/dr-ui/ui/app.slint:1003`](../ui/dr-ui/ui/app.slint#L1003), [`ui/dr-ui/ui/library.slint:919`](../ui/dr-ui/ui/library.slint#L919) | | NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) | | NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) | | NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 5d5b295..3d81eac 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -1015,6 +1015,7 @@ pub fn run(paths: Vec) -> Result<()> { let stored = settings.snapshot(); library.set_cache_budget(stored.cache.original_budget_bytes); library.set_keep_opened_originals(stored.cache.keep_opened_originals); + library.set_timeline_bars(stored.library.timeline_bars); } // --- the export folder picker ---------------------------------------- @@ -1151,6 +1152,17 @@ pub fn run(paths: Vec) -> Result<()> { refresh_export_label(&w, &ctl); } + // The axis is cut into bars when the window is loaded, so a new + // count only shows once something reloads it. Done here, and only + // when the figure actually moved: a setting that appears to do + // nothing until the user scrolls is one they will change twice and + // then leave wrong. + if lib.set_timeline_bars(s.library.timeline_bars) { + if let Some(w) = weak.upgrade() { + library_ui::reload(&w, &lib); + } + } + // Lowering the ceiling evicts, so the figure beside it has just // changed — leaving the old one would show the cache still over a // limit that was enforced a moment ago. diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index 65c57b8..981d4d3 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -3120,44 +3120,97 @@ pub fn span_scoped( .and_then(|(lo, hi)| Some((lo?, hi?))) } -/// Histogram buckets within `scope` and a date range. +/// TRACES: FR-CAT-6 +/// The capture-time histogram as a fixed number of equal bins across +/// `from..=to`, empty ones included. /// -/// `Catalog::timeline_range` counts the whole library; a collection needs the -/// membership join, and the rating filter applied too, or the bars disagree -/// with the grid beneath them. -pub fn timeline_scoped( +/// # Why not calendar buckets +/// +/// [`timeline_scoped`] groups by year, month, day or hour, which has two +/// consequences the axis cannot live with. +/// +/// It emits **only the buckets that hold photographs**, and the widget gives +/// every bar an equal slot — so a library with a gap in it drew a February +/// that was six months wide. The position marker, the range band and a click +/// are all linear in time, so on a sparse library they pointed at bars that +/// were somewhere else. Equal bins including the empty ones make a bar's +/// position on the track and the date under it the same quantity. +/// +/// And the **count is free to jump by a factor of twelve** between one unit +/// and the next, so each zoom step halved the number of bars until a +/// threshold was crossed: zooming in made the picture coarser, twice out of +/// every three steps. A fixed count re-bins on every zoom instead, which is +/// what makes each step show finer structure rather than the same structure +/// drawn wider. +/// +/// The date range is lifted from the filter, like the bars' other terms are +/// not: this histogram is *how a range is chosen*, and drawing through the +/// range would empty every bin outside it and leave nothing to widen into. +/// +/// `bins` is clamped to at least one — a zero would be a division by zero in +/// SQL, and the caller's number comes from a hand-editable settings file. +pub fn timeline_uniform( catalog: &Catalog, scope: Option, filter: &RatingFilter, - granularity: dr_catalog::Granularity, from: i64, to: i64, + bins: u32, ) -> Result, dr_catalog::CatalogError> { - let (clause, mut params) = scope_clause(catalog, scope)?; + let bins = bins.max(1) as i64; + // At least one second, or every photograph lands in bin zero. + let span = (to - from).max(1); + let (clause, params) = scope_clause(catalog, scope)?; let rated = filter.without_date_range().sql(); + + // The bin index is arithmetic on the stored UTC instant, not `strftime` on + // a local one. A bin is not a calendar unit — it has no local midnight to + // respect — and the axis it is drawn on is labelled from the same UTC + // instants, so bucketing the two differently is the one way they could + // disagree about which bar a photograph belongs to. + // + // `min` caps the last edge: an image captured at exactly `to` divides to + // `bins`, which would be a bin past the end of the axis. + // + // Integers this code owns, formatted straight in — the same rule the + // rating terms follow. They cannot be bound parameters here without + // ordering them against the scope clause's, which appears later in the + // text but is bound first. let sql = format!( - "SELECT min(i.captured_at) AS start, count(*) AS n FROM images i + "SELECT min({bins} - 1, (i.captured_at - {from}) * {bins} / {span}) AS b, + count(*) AS n + FROM images i WHERE {VISIBLE}{rated} AND i.captured_at IS NOT NULL{clause} - AND i.captured_at >= ? AND i.captured_at <= ? - GROUP BY strftime('{}', i.captured_at - + coalesce(i.captured_offset, 0) * 60, 'unixepoch') - ORDER BY start ASC", - granularity.strftime() + AND i.captured_at >= {from} AND i.captured_at <= {to} + GROUP BY b + ORDER BY b ASC" ); - params.push(rusqlite::types::Value::Integer(from)); - params.push(rusqlite::types::Value::Integer(to)); let conn = catalog.connection(); let mut stmt = conn.prepare(&sql)?; - let rows = stmt + let counted = stmt .query_map(rusqlite::params_from_iter(params.iter()), |r| { - Ok(dr_catalog::TimeBucket { - start: r.get(0)?, - count: r.get::<_, i64>(1)? as u32, - }) + Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)? as u32)) })? .collect::, _>>()?; - Ok(rows) + + // Every bin, in order, whether or not the query returned one for it. The + // start is the bin's own left edge rather than the earliest photograph in + // it: an empty bin has no photograph to take one from, and a bar drawn at + // its contents' position rather than its bin's would put the axis back + // where the calendar buckets left it. + let mut bars: Vec = (0..bins) + .map(|i| dr_catalog::TimeBucket { + start: from + (i * span) / bins, + count: 0, + }) + .collect(); + for (i, n) in counted { + if let Some(bar) = bars.get_mut(i.clamp(0, bins - 1) as usize) { + bar.count = n; + } + } + Ok(bars) } /// Total images in the catalog, honouring the rating filter. @@ -4165,16 +4218,7 @@ mod tests { fn the_histogram_ignores_the_range_it_is_used_to_choose() { // Drawing the axis through the chosen range would collapse it onto the // selection, leaving nowhere to widen back out from. - let catalog = scanned(3); - let conn = catalog.connection(); - for (n, at) in [(1, 1_000), (2, 5_000), (3, 9_000)] { - conn.execute( - "UPDATE images SET captured_at = ?2 WHERE source_ref LIKE ?1", - rusqlite::params![format!("%IMG_000{n}%"), at], - ) - .unwrap(); - } - + let catalog = dated_three(); let ranged = RatingFilter { captured_from: Some(4_000), captured_to: Some(6_000), @@ -4187,6 +4231,105 @@ mod tests { ); } + /// Three photographs at 1000, 5000 and 9000 seconds. + fn dated_three() -> Catalog { + let catalog = scanned(3); + let conn = catalog.connection(); + for (n, at) in [(1, 1_000), (2, 5_000), (3, 9_000)] { + conn.execute( + "UPDATE images SET captured_at = ?2 WHERE source_ref LIKE ?1", + rusqlite::params![format!("%IMG_000{n}%"), at], + ) + .unwrap(); + } + catalog + } + + #[test] + fn the_histogram_has_the_number_of_bins_it_was_asked_for() { + // Fixed, whatever the span holds. The axis draws one bar per bin and + // positions it by index, so a query that returned only the occupied + // ones would put the bars at the wrong dates. + let catalog = dated_three(); + let filter = RatingFilter::default(); + for bins in [1_u32, 8, 32, 64] { + let bars = timeline_uniform(&catalog, None, &filter, 1_000, 9_000, bins).unwrap(); + assert_eq!(bars.len() as u32, bins); + assert_eq!( + bars.iter().map(|b| b.count).sum::(), + 3, + "every photograph is counted exactly once" + ); + } + } + + #[test] + fn a_bin_starts_where_the_axis_says_it_does() { + // The bar's start is its bin's left edge, not the earliest photograph + // in it. It is what the position marker and the range band are drawn + // against, and an empty bin has no photograph to borrow a date from. + let catalog = dated_three(); + let bars = timeline_uniform(&catalog, None, &RatingFilter::default(), 0, 8_000, 8).unwrap(); + for (i, bar) in bars.iter().enumerate() { + assert_eq!(bar.start, i as i64 * 1_000); + } + // 1000 and 5000 land in their own bins, 9000 is past the end. + assert_eq!(bars[1].count, 1); + assert_eq!(bars[5].count, 1); + assert_eq!(bars.iter().map(|b| b.count).sum::(), 2); + } + + #[test] + fn the_last_bin_holds_a_photograph_taken_at_the_very_end() { + // The division puts an image captured at exactly `to` one bin past the + // axis. Uncapped it would be dropped from the histogram — and it is + // precisely the image that defines the extent, so it would go missing + // on every unzoomed library. + let catalog = dated_three(); + let bars = + timeline_uniform(&catalog, None, &RatingFilter::default(), 1_000, 9_000, 4).unwrap(); + assert_eq!(bars.len(), 4); + assert_eq!(bars[3].count, 1, "the image at 9000 is in the last bin"); + assert_eq!(bars[0].count, 1); + } + + #[test] + fn the_bins_ignore_the_range_they_are_used_to_choose() { + // Same rule as the extent: the bars outside the band are what the + // range is widened back into, so counting through the range would + // leave every one of them empty. + let catalog = dated_three(); + let ranged = RatingFilter { + captured_from: Some(4_000), + captured_to: Some(6_000), + ..Default::default() + }; + let bars = timeline_uniform(&catalog, None, &ranged, 1_000, 9_000, 4).unwrap(); + assert_eq!( + bars.iter().map(|b| b.count).sum::(), + 3, + "all three, not just the one inside the range" + ); + } + + #[test] + fn the_bins_still_honour_every_other_filter() { + // A histogram of the five-star frames is a fair question, and the bars + // have to agree with the grid beneath them. + let catalog = dated_three(); + let strict = RatingFilter { + min_rating: 4, + ..Default::default() + }; + let bars = timeline_uniform(&catalog, None, &strict, 1_000, 9_000, 4).unwrap(); + assert_eq!(bars.len(), 4, "the axis keeps its shape"); + assert_eq!( + bars.iter().map(|b| b.count).sum::(), + 0, + "nothing here is rated" + ); + } + fn entry(path: &str, file_id: u64, size: u64) -> RemoteEntry { RemoteEntry { id: RemoteId::Stable(file_id), diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index bb85245..337db82 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -256,6 +256,16 @@ pub struct LibraryController { /// small budget still keeps a working set. A metered or small-disk device /// wants the first. keep_opened: std::cell::Cell, + /// TRACES: FR-CAT-6 + /// How many bars the capture-time axis is cut into, from the settings + /// page. + /// + /// Held here beside the cache budget and for the same reason: the axis is + /// redrawn from a dozen callbacks that have no config access, and reading + /// a JSON file on every scroll to answer "how many bars" would be the + /// wrong shape of question. A `Cell` because the page can change it while + /// a library is open. + timeline_bars: std::cell::Cell, /// Where every worker this module starts reports what it is doing. /// /// Held on the controller rather than passed to each function because the @@ -311,9 +321,22 @@ impl LibraryController { keep_opened: std::cell::Cell::new( dr_types::CacheSettings::default().keep_opened_originals, ), + timeline_bars: std::cell::Cell::new(dr_types::LibrarySettings::default().timeline_bars), }) } + /// TRACES: FR-CAT-6 + /// How many bars the capture-time axis is cut into. + /// + /// Answers whether it changed, so the caller can leave the grid alone when + /// it did not: this is set on every settings edit, and reloading the + /// window because a user changed their export folder would be a visible + /// stutter for nothing. + pub fn set_timeline_bars(&self, bars: u32) -> bool { + let bars = bars.max(1); + self.timeline_bars.replace(bars) != bars + } + /// TRACES: FR-NC-6a /// Whether a develop open should keep the original it downloads. /// @@ -1910,7 +1933,12 @@ fn load_window(window: &AppWindow, ctl: &Rc) { } else { library::total_images_scoped(catalog, scope, &filter).unwrap_or(0) }; + // Read before it is overwritten: the property still holds what the library + // was last time this ran, and a library that has become shorter is the + // signal that something was deleted out from under the view. + let was = window.get_library_total().max(0) as usize; window.set_library_total(total as i32); + let shrank = total < was; // Clamp so a scrub to the very end still fills the window rather than // showing a handful of cells. @@ -2064,6 +2092,21 @@ fn load_window(window: &AppWindow, ctl: &Rc) { // they are refreshed here rather than per cell. refresh_rating_counts(window, catalog); + // The library got shorter while the view was looking at it — a delete. + // + // Two things have already moved by this point and neither touches the + // viewport: the scrollable height shrank, and `offset` was re-clamped so + // the loaded window still fills. So the view is left pointing either past + // the end of the content (a blank grid) or at a different part of the + // library (an apparent jump to nowhere). Re-anchoring here puts it back on + // the photographs the user was actually looking at. + // + // Only on a shrink. Doing it on every load would fight a scrub, which sets + // exactly this property to go somewhere the user asked for. + if shrank { + restore_position(window, ctl); + } + if total == 0 { return; } @@ -3531,6 +3574,12 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc s, None => { @@ -3542,27 +3591,24 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc from => (from, to), - _ => span, - }; - // Zoom narrows the span around wherever the view sits rather than around // the library's midpoint, so zooming in keeps what you were looking at. let zoom = *ctl.timeline_zoom.borrow(); let (from, to) = zoomed_span(span, zoom, *ctl.timeline_centre.borrow()); - let granularity = dr_catalog::Granularity::for_span(to - from); - let buckets = match library::timeline_scoped(catalog, scope, &filter, granularity, from, to) { + // A fixed number of equal bins, re-cut on every zoom (FR-CAT-6). The + // count is the user's — see `LibrarySettings::timeline_bars` for why the + // calendar units it replaced made zooming in draw a coarser picture. + let bins = ctl.timeline_bars.get().max(1); + // Calendar units survive as a *label* vocabulary: what a bin is called + // depends on how long it is, not on how the counting was done. + let granularity = dr_catalog::Granularity::for_bucket((to - from).max(1) / bins as i64); + let buckets = match library::timeline_uniform(catalog, scope, &filter, from, to, bins) { Ok(b) => b, Err(e) => { log::debug!("timeline: {e}"); @@ -3596,7 +3642,16 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc (fraction_at((from, to), a), fraction_at((from, to), b)), + _ => (-1.0, -1.0), + }; + + // Which bar to light: the *bin* holding the grid's instant, not the + // instant itself. A bar's start is its bin's left edge — one of a fixed + // set of positions — so comparing it against an arbitrary capture time + // would light nothing, where before every bar started on a photograph. + // + // Taken from the bars themselves rather than recomputed, because the edges + // are integer division and a second spelling of `from + i * span / bins` + // is a second chance to be a second out. + let lit = current + .and_then(|t| { + let i = bin_of((from, to), t, bins); + buckets.get(i).map(|b| b.start) + }) + .unwrap_or(0); + window.set_library_current_bucket(lit as i32); window.set_library_current_fraction(fraction); + window.set_library_range_from_fraction(band_from); + window.set_library_range_to_fraction(band_to); window.set_library_timeline_anchored(current.is_some()); window .set_library_timeline_label(format!("{} – {}", format_date(from), format_date(to)).into()); @@ -3633,8 +3709,16 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc bool { - !matches!(g, dr_catalog::Granularity::Year) + matches!( + g, + dr_catalog::Granularity::Day | dr_catalog::Granularity::Hour + ) } /// Full month name, for the grid's headings. @@ -3663,6 +3747,64 @@ fn month_abbrev(m: i64) -> &'static str { NAMES[((m - 1).clamp(0, 11)) as usize] } +/// The instant a fraction along a span names. +/// +/// One home for `from + (to - from) * f`, because three things now depend on +/// it agreeing with itself: a scrub, the marker drawn where that scrub landed, +/// and the ends of the date range dragged along the same axis. A second copy +/// is a second chance for a click and what it produces to disagree. +fn instant_at(span: (i64, i64), f: f32) -> i64 { + span.0 + ((span.1 - span.0) as f64 * f.clamp(0.0, 1.0) as f64) as i64 +} + +/// Where an instant sits along a span, 0..1. The exact inverse of +/// [`instant_at`], clamped so an instant off the end of the axis is drawn at +/// the end it went past rather than off the widget. +fn fraction_at(span: (i64, i64), t: i64) -> f32 { + ((t - span.0) as f64 / (span.1 - span.0).max(1) as f64).clamp(0.0, 1.0) as f32 +} + +/// Which of `bins` equal bins across `span` holds `t`. +/// +/// The same arithmetic `library::timeline_uniform` groups by, in the same +/// order — integer division on the offset from the start — so the bar this +/// names is the bar that counted the photograph. Clamped, because the grid's +/// instant can sit outside a zoomed axis. +fn bin_of(span: (i64, i64), t: i64, bins: u32) -> usize { + let width = (span.1 - span.0).max(1); + let i = (t - span.0).clamp(0, width) * bins as i64 / width; + i.clamp(0, bins as i64 - 1) as usize +} + +/// Midnight UTC at or before `t`. +/// +/// `rem_euclid` rather than `%`: a negative timestamp — a scanned archive +/// holds photographs from before 1970 — would otherwise truncate *towards* +/// zero and name the following midnight, putting the day off by one. +fn day_start(t: i64) -> i64 { + t - t.rem_euclid(86_400) +} + +/// The range a drag of the band's two ends names, as it is stored. +/// +/// Whole days at both ends, which does three things. It is what the typed +/// fields mean, so a dragged range reads back out of them as the two days it +/// covers rather than as two instants nobody chose. It gives the band a floor: +/// two handles dragged onto each other name one day, not an empty grid. And it +/// makes the gesture reproducible — the same day is the same range however +/// close the finger came to a bucket edge. +/// +/// Ends dragged past each other are put back in order rather than refused: the +/// user has described the same two edges either way round. +fn dragged_range(span: (i64, i64), from: f32, to: f32) -> (i64, i64) { + let (from, to) = if from <= to { (from, to) } else { (to, from) }; + let start = day_start(instant_at(span, from)); + // Inclusive of the closing day. `parse_date` gives midnight, and a range + // ending there would exclude every photograph taken on the day named. + let end = day_start(instant_at(span, to)) + 86_400 - 1; + (start, end) +} + /// Narrow a span by a zoom level, centred on `centre`. /// /// Each step halves or doubles the visible duration. Clamped to the library's @@ -3701,6 +3843,12 @@ fn zoomed_span(full: (i64, i64), zoom: i32, centre: Option) -> (i64, i64) { /// collection, a scrub landed at an instant the collection did not contain and /// the view jumped somewhere the user had not asked for. fn catalog_span(catalog: &Catalog, ctl: &Rc) -> Option<(i64, i64)> { + // `span_scoped` reports the extent with the date range lifted, which is + // what every caller here wants: they measure the axis that is on screen, + // and that axis spans the library rather than the chosen range. Measured + // through the range, narrowing it would move the ground under the very + // handles doing the narrowing — each drag re-scaling the axis, so the next + // one meant something else. library::span_scoped(catalog, *ctl.scope.borrow(), &ctl.filter.borrow()) } @@ -3779,6 +3927,32 @@ fn scrub_to(window: &AppWindow, ctl: &Rc, when: i64) { load_window(window, ctl); } +/// TRACES: FR-CAT-7 +/// Put the view back where the photographer was after the library changed +/// under it. +/// +/// Deleting is the case this exists for. The grid draws each cell at its +/// absolute place in the library — `(i + offset) / columns` — and a delete does +/// two things at once: the library gets shorter, and `offset` is re-clamped +/// downward so the loaded window still fills. Neither touches `viewport-y`, so +/// the view ends up pointing at a different part of the library, or past the +/// end of it entirely. That is the "goes blank and jumps somewhere random". +/// +/// Anchored on the *ordinal the view was showing*, clamped into what is left. +/// Not on the deleted image's own position, which no longer exists, and not on +/// the top of the library, which would throw away the scroll position on every +/// delete — after removing one frame from a wall of twenty thousand, the next +/// one you want is the one that just moved into its place. +pub fn restore_position(window: &AppWindow, ctl: &Rc) { + let total = window.get_library_total().max(0) as usize; + if total == 0 { + return; + } + let anchor = (*ctl.offset.borrow()).min(total - 1); + window.set_library_scroll_to(anchor as i32); + window.set_library_scroll_token(window.get_library_scroll_token() + 1); +} + /// Format a bucket start for the histogram's hover label. fn format_bucket(t: i64, g: dr_catalog::Granularity) -> String { let (y, m, d, h) = civil_from_unix(t); @@ -4376,7 +4550,7 @@ pub fn wire( *ctl.timeline_zoom.borrow(), *ctl.timeline_centre.borrow(), ); - let when = from + ((to - from) as f64 * f.clamp(0.0, 1.0) as f64) as i64; + let when = instant_at((from, to), f); drop(borrow); scrub_to(&w, &ctl, when); }); @@ -4626,12 +4800,18 @@ pub fn wire( }); } - // Narrow the grid to the period the histogram is showing. + // Turn the range on, over the period the histogram is showing. // // The range is taken from the timeline rather than typed into two date // fields: finding the fortnight is what the histogram is *for*, and having // found it the user should not have to read the dates off the axis and key - // them back in. Zoom, then say "only that". + // them back in. + // + // A starting point rather than the answer. What this puts on the axis is a + // band with two draggable ends, and dragging them is how the range is + // actually stated — on a phone it is the only way that does not need a + // keyboard. Seeding it to the whole visible span means the first drag + // narrows from what the user is already looking at. // // Toggling off clears both ends rather than remembering them — a range you // cannot see the extent of is a filter that looks like an empty library. @@ -4670,7 +4850,12 @@ pub fn wire( ) }) }; - if let Some((from, to)) = seeded { + if let Some(span) = seeded { + // Through the same days-inclusive conversion a drag uses, + // so a seeded range and a dragged one are the same kind of + // thing — and so `show_range` reads this one back out as + // the two days it covers. + let (from, to) = dragged_range(span, 0.0, 1.0); let mut f = ctl.filter.borrow_mut(); f.captured_from = Some(from); f.captured_to = Some(to); @@ -4686,6 +4871,59 @@ pub fn wire( }); } + // TRACES: FR-CAT-6 | FR-UI-2 + // The ends of the range, dragged along the axis they were chosen from. + // + // This is the control the two typed fields were standing in for. On a + // phone they could not do the job: `YYYY-MM-DD` keyed into a 108px field + // behind a soft keyboard, to name a day already drawn on the axis a thumb + // away. Nor was the chip a way round them — its span comes from the + // timeline's zoom and pan, and those are a wheel and a middle button, + // neither of which a touch screen has. So on Android the date range was a + // filter that could be turned on and not aimed. + // + // The span is recomputed here rather than remembered from the last draw. + // It is the same expression `refresh_timeline` uses, over the same + // date-lifted extent, so a handle dropped at a fraction of the track names + // the instant that fraction was drawn at. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_timeline_range_changed(move |a, b| { + let Some(w) = weak.upgrade() else { return }; + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + let Some(full) = catalog_span(catalog, &ctl) else { + return; + }; + let span = zoomed_span( + full, + *ctl.timeline_zoom.borrow(), + *ctl.timeline_centre.borrow(), + ); + drop(borrow); + + let (from, to) = dragged_range(span, a, b); + { + let mut f = ctl.filter.borrow_mut(); + f.captured_from = Some(from); + f.captured_to = Some(to); + } + + // A dragged range cannot be mistyped, so any standing complaint + // about the fields is now about a range that no longer exists. + w.set_library_range_invalid(false); + w.set_library_range_active(true); + show_range(&w, &ctl); + // Reloads the window, and `load_window` redraws the timeline — + // which is what puts the band back under the finger that dropped + // it, at the whole days it was snapped to. + refilter(&w, &ctl); + }); + } + // TRACES: FR-CAT-6 // The ends of the range, typed. // @@ -4721,7 +4959,7 @@ pub fn wire( // Inclusive of the last day. "To the 5th" means the whole of // the 5th — `parse_date` returns its midnight, and a range // ending there would exclude every photograph taken on the day - // the user named. + // the user named. The same rule `dragged_range` applies. f.captured_to = Some(to + 86_400 - 1); } w.set_library_range_invalid(false); @@ -4885,12 +5123,6 @@ fn stop(slot: &RefCell>) { mod tests { use super::*; - /// The instant a scrub fraction names within a span, as the handler - /// computes it. - fn instant_at(span: (i64, i64), f: f32) -> i64 { - span.0 + ((span.1 - span.0) as f64 * f.clamp(0.0, 1.0) as f64) as i64 - } - #[test] fn a_scrub_fraction_interpolates_within_the_span() { // The point of going fractional: a slow drag must advance @@ -4904,12 +5136,6 @@ mod tests { assert_ne!(instant_at(span, 0.10), instant_at(span, 0.11)); } - /// Where the marker is drawn for an instant, as `refresh_timeline` - /// computes it. - fn marker_at(span: (i64, i64), t: i64) -> f32 { - ((t - span.0) as f64 / (span.1 - span.0).max(1) as f64).clamp(0.0, 1.0) as f32 - } - #[test] fn the_marker_lands_on_the_fraction_that_was_clicked() { // The bug this guards: the marker was placed from a *bar index* while @@ -4917,7 +5143,7 @@ mod tests { // the pointer. Marker and click must be inverses. let span = (1_000, 9_000); for f in [0.0_f32, 0.1, 0.25, 0.5, 0.75, 1.0] { - let round_trip = marker_at(span, instant_at(span, f)); + let round_trip = fraction_at(span, instant_at(span, f)); assert!( (round_trip - f).abs() < 1e-3, "clicked {f}, marker drawn at {round_trip}" @@ -4930,8 +5156,8 @@ mod tests { // Zooming in leaves the grid's instant outside the axis. The marker // belongs at the edge it went past, not off the widget. let span = (1_000, 2_000); - assert_eq!(marker_at(span, 0), 0.0); - assert_eq!(marker_at(span, 5_000), 1.0); + assert_eq!(fraction_at(span, 0), 0.0); + assert_eq!(fraction_at(span, 5_000), 1.0); } #[test] @@ -4943,6 +5169,92 @@ mod tests { assert_eq!(instant_at(span, 9.9), 200); } + /// A fortnight in March 2024, in whole days, as the axis would span it. + const MARCH: (i64, i64) = (1_709_251_200, 1_710_460_800); + + #[test] + fn a_dragged_range_covers_whole_days_at_both_ends() { + // What makes a dragged range the same kind of thing as a typed one: + // `show_range` reads the closing day back out of the stored second, so + // an end left mid-afternoon would be shown as the day before. + let (from, to) = dragged_range(MARCH, 0.13, 0.61); + assert_eq!(day_start(from), from, "opens at a midnight"); + assert_eq!( + day_start(to + 1), + to + 1, + "closes at the last second of a day" + ); + assert_eq!(dr_types::format_date(from), "2024-03-02"); + assert_eq!(dr_types::format_date(to - 86_400 + 1), "2024-03-09"); + } + + #[test] + fn ends_dragged_past_each_other_name_the_same_range() { + // Pulling the early end below the late one is how a range that was + // narrowed too far gets corrected; refusing it would make starting + // again the easier move. + assert_eq!( + dragged_range(MARCH, 0.6, 0.2), + dragged_range(MARCH, 0.2, 0.6) + ); + } + + #[test] + fn both_ends_dragged_together_name_one_day() { + // The floor the day snapping buys. Two handles on the same pixel is a + // gesture that happens; an empty grid is not what it means. + let (from, to) = dragged_range(MARCH, 0.5, 0.5); + assert_eq!(to - from, 86_399); + } + + #[test] + fn the_band_is_drawn_where_the_handle_was_dropped() { + // The band is placed by `fraction_at` and the range comes from + // `instant_at`. If those two ever disagreed the band would settle + // somewhere other than where the finger let go, which reads as the + // drag having been ignored. + for f in [0.0_f32, 0.25, 0.5, 0.75, 1.0] { + let (from, _) = dragged_range(MARCH, f, f); + let drawn = fraction_at(MARCH, from); + // Within a day of the span, which is the snapping and nothing else. + let day = 86_400.0 / (MARCH.1 - MARCH.0) as f32; + assert!((drawn - f).abs() <= day, "dropped at {f}, drawn at {drawn}"); + } + } + + #[test] + fn every_instant_on_the_axis_lands_in_a_bin() { + // The bar under a date and the bar that counted it must be the same + // bar: `bin_of` is the UI's copy of the division the SQL groups by. + let span = (1_000, 9_000); + assert_eq!(bin_of(span, 1_000, 8), 0); + assert_eq!(bin_of(span, 1_999, 8), 0); + assert_eq!(bin_of(span, 2_000, 8), 1); + // The last instant belongs to the last bin rather than to one past it. + assert_eq!(bin_of(span, 9_000, 8), 7); + } + + #[test] + fn an_instant_off_the_zoomed_axis_lands_at_the_end_it_went_past() { + // Zooming in leaves the grid's position outside the span. The lit bar + // belongs at the edge, not at an index that does not exist. + let span = (1_000, 9_000); + assert_eq!(bin_of(span, -50_000, 8), 0); + assert_eq!(bin_of(span, 50_000, 8), 7); + // And a degenerate axis still answers with a bin. + assert_eq!(bin_of((5, 5), 5, 32), 0); + } + + #[test] + fn a_day_before_1970_starts_at_its_own_midnight() { + // `%` truncates towards zero, which on a negative timestamp names the + // *following* midnight — a scanned archive of negatives would have + // every range off by a day at the early end. + assert_eq!(day_start(-1), -86_400); + assert_eq!(day_start(-86_400), -86_400); + assert_eq!(day_start(-86_401), -172_800); + } + /// One pinch update, as the handler applies it: fold the ratio in, take /// out whole doublings, carry the remainder. fn pinch_step(accum: &mut f32, ratio: f32) -> i32 { @@ -5189,32 +5501,38 @@ mod tests { assert_eq!(y, 1969); } - /// The axis follows a chosen range, so its granularity does too. + /// A bar is named after how long it is. /// - /// The complaint this fixes: a fortnight selected out of a fifteen-year - /// library was still drawn as one column of a year-per-bar axis, which is - /// the least useful picture of a fortnight available. + /// The axis no longer buckets by calendar unit — it cuts the visible span + /// into a fixed number of equal bins — so the unit survives only as a + /// vocabulary for *labelling* one. What this guards is that zooming in + /// never produces a coarser label: the complaint it came from was a + /// fortnight drawn as one column of a year-per-bar axis. #[test] - fn a_chosen_range_decides_the_granularity() { + fn a_bin_is_labelled_at_its_own_scale() { use dr_catalog::Granularity; const DAY: i64 = 86_400; - // Fifteen years unfiltered: a bar per year, fifteen of them. - assert_eq!(Granularity::for_span(15 * 365 * DAY), Granularity::Year); + // Fifteen years across 32 bars is nearly six months a bar, which on a + // ratio measure is nearer a year than a month — so the bars are + // labelled by year, which is what a fifteen-year axis wants anyway. + assert_eq!( + Granularity::for_bucket(15 * 365 * DAY / 32), + Granularity::Year + ); - // Narrowed to a fortnight, the axis is handed the range rather than - // the extent, and a fortnight is drawn as fourteen days. - assert_eq!(Granularity::for_span(14 * DAY), Granularity::Day); + // A fortnight across the same 32 bars is about ten hours a bar, which + // is nearer a day than an hour and is labelled as a date. + assert_eq!(Granularity::for_bucket(14 * DAY / 32), Granularity::Day); - // A single day's shoot is drawn by the hour. - assert_eq!(Granularity::for_span(DAY), Granularity::Hour); + // Zoom on until a bar is an hour or two, and it is named by the hour. + assert_eq!(Granularity::for_bucket(2 * 3600), Granularity::Hour); - // The property rather than the thresholds, because the thresholds are - // no longer what decides it: a narrower range never produces a coarser - // bucket than a wider one. + // The property rather than the thresholds: a shorter bin never gets a + // coarser label than a longer one. let mut previous = i64::MAX; for days in [3650, 730, 180, 60, 14, 3, 1] { - let g = Granularity::for_span(days * DAY); + let g = Granularity::for_bucket(days * DAY / 32); let bucket = match g { Granularity::Year => 365 * DAY, Granularity::Month => 30 * DAY, @@ -5223,7 +5541,7 @@ mod tests { }; assert!( bucket <= previous, - "{days} days chose a coarser bucket than the range above it" + "{days} days chose a coarser bin label than the span above it" ); previous = bucket; } diff --git a/ui/dr-ui/src/settings_ui.rs b/ui/dr-ui/src/settings_ui.rs index 66d8e96..a53cc88 100644 --- a/ui/dr-ui/src/settings_ui.rs +++ b/ui/dr-ui/src/settings_ui.rs @@ -26,8 +26,8 @@ use std::rc::Rc; use dr_types::settings::budget; use dr_types::{ - CollisionPolicy, ColourSpace, ExportFormat, ExportTarget, OutputSharpening, Settings, - SizingMode, + CollisionPolicy, ColourSpace, ExportFormat, ExportTarget, LibrarySettings, OutputSharpening, + Settings, SizingMode, }; use slint::ComponentHandle; @@ -146,6 +146,23 @@ pub fn render(window: &AppWindow, controller: &SettingsController) { window.set_settings_copy_includes_framing(s.develop.copy_includes_framing); window.set_settings_cache_usage(controller.usage_label.borrow().clone().into()); + // --- library ------------------------------------------------------- + // + // Labelled here rather than in the page, so the chips cannot offer a count + // the record does not: `BAR_CHOICES` is what `sanitise` snaps a + // hand-edited file to, and a third chip drawn beside them would be a + // choice that silently became one of the other two. + window.set_settings_timeline_bar_labels(slint::ModelRc::new(slint::VecModel::from( + LibrarySettings::BAR_CHOICES + .iter() + .map(|n| slint::SharedString::from(n.to_string())) + .collect::>(), + ))); + window.set_settings_timeline_bars_selected(index_of( + &LibrarySettings::BAR_CHOICES, + &s.library.timeline_bars, + )); + // --- export -------------------------------------------------------- // // Choice rows are sent as labels plus the selected index rather than as a @@ -404,6 +421,18 @@ where }); } + { + let weak = window.as_weak(); + let ctl = controller.clone(); + window.on_settings_timeline_bars_picked(move |i| { + let Some(w) = weak.upgrade() else { return }; + if let Some(n) = LibrarySettings::BAR_CHOICES.get(i as usize).copied() { + ctl.edit(|s| s.library.timeline_bars = n); + } + render(&w, &ctl); + }); + } + { let weak = window.as_weak(); let ctl = controller.clone(); diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 557b446..5a6a41e 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -463,6 +463,12 @@ export component AppWindow inherits Window { in property library-range-to; in property library-range-invalid: false; callback library-range-edited(string, string); + /// The same range as a band on the capture-time axis, and the ends of it + /// dragged. Fractions of the span the axis is drawn over — Rust owns the + /// conversion both ways, so the band lands where the finger left it. + in property library-range-from-fraction: -1; + in property library-range-to-fraction: -1; + callback library-timeline-range-changed(float, float); callback library-retry-connection(); // --- pinning a collection offline (FR-NC-6a) --- @@ -805,6 +811,10 @@ export component AppWindow inherits Window { callback settings-thumbnail-unlimited-toggled(bool); callback settings-keep-opened-toggled(bool); callback settings-copy-includes-framing-toggled(bool); + /// TRACES: FR-CAT-6 + in property <[string]> settings-timeline-bar-labels; + in property settings-timeline-bars-selected: 0; + callback settings-timeline-bars-picked(int); in property <[string]> settings-format-labels; in property settings-format-selected: 0; @@ -1136,6 +1146,9 @@ in property panel-visible: true; thumbnail-unlimited: root.settings-thumbnail-unlimited; keep-opened: root.settings-keep-opened; copy-includes-framing: root.settings-copy-includes-framing; + timeline-bar-labels: root.settings-timeline-bar-labels; + timeline-bars-selected: root.settings-timeline-bars-selected; + timeline-bars-picked(i) => { root.settings-timeline-bars-picked(i); } cache-usage: root.settings-cache-usage; original-budget-changed(t) => { root.settings-original-budget-changed(t); } @@ -1330,6 +1343,11 @@ in property panel-visible: true; range-to: root.library-range-to; range-invalid: root.library-range-invalid; range-edited(a, b) => { root.library-range-edited(a, b); } + range-from-fraction: root.library-range-from-fraction; + range-to-fraction: root.library-range-to-fraction; + timeline-range-changed(a, b) => { + root.library-timeline-range-changed(a, b); + } collections-visible: root.collections-visible; toggle-collections() => { root.toggle-collections(); } diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index 74339eb..aa7a616 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -68,6 +68,7 @@ export struct TimelineBar { // - **click or drag** — scrub the grid to that instant // - **drag with the middle button, or shift-drag** — pan the visible span // - **wheel** — zoom, which changes the bucket size Rust picks +// - **drag either end of the range band** — narrow the grid to a period // // # One hit area, not one per bar // @@ -105,6 +106,21 @@ export component Timeline inherits Rectangle { /// them together. Continuous, unlike the wheel's discrete steps. callback pinch(float); + /// TRACES: FR-CAT-6 + /// The chosen date range, as fractions of the drawn span — the same + /// coordinates `scrub-to` reports and `current-fraction` is drawn in, so + /// the band, the marker and a click cannot disagree about where a date + /// sits. Negative in both when no range is set. + in property range-from: -1; + in property range-to: -1; + /// An end of the range was dragged, and where both ends now are. + /// + /// Sent on release rather than continuously: each one of these re-runs the + /// grid's query and reloads its window, and doing that per frame of a drag + /// is how a filter turns into a stutter. The band tracks the finger + /// meanwhile — that is what `live-from` and `live-to` are for. + callback range-changed(float, float); + width: 96px; background: Theme.surface; @@ -113,6 +129,21 @@ export component Timeline inherits Rectangle { property track-height: max(1px, self.height - self.track-top - 6px); property slot: root.track-height / max(1, root.bars.length); + property has-range: root.range-from >= 0 && root.range-to >= 0; + /// Which end is under the finger: 0 neither, 1 the early end, 2 the late. + property grabbed: 0; + /// The ends as the drag has them, before Rust has been told. Drawing from + /// these is what lets the band follow the finger without the grid being + /// re-queried on every frame. + property live-from; + property live-to; + property shown-from: root.grabbed > 0 ? root.live-from : root.range-from; + property shown-to: root.grabbed > 0 ? root.live-to : root.range-to; + /// How near a press has to land to take hold of an end, as a fraction of + /// the track. Sized for a finger rather than a pointer: this control + /// exists because on a phone the range could only be typed (FR-UI-2). + property grab: 22px / root.track-height; + /// Bucket index under a y coordinate, clamped to the ends so a drag that /// leaves the widget still scrubs to the nearest bucket rather than /// stopping dead. @@ -132,6 +163,50 @@ export component Timeline inherits Rectangle { return clamp((y - root.track-top) / max(1px, root.track-height), 0.0, 1.0); } + /// Where a fraction sits on the track. The inverse of `fraction-at`, and + /// it has to stay so: the band is drawn with this and dragged with that. + function y-at(f: float) -> length { + return root.track-top + clamp(f, 0.0, 1.0) * root.track-height; + } + + /// Which end of the range a press at `y` takes hold of, 0 for neither. + /// + /// Compared in fractions rather than pixels because the ends are held as + /// fractions — converting them to a y here and back to a fraction on the + /// drag would be a second copy of the same arithmetic to keep in step. + function handle-at(y: length) -> int { + if (!root.has-range) { + return 0; + } + if (min(abs(root.fraction-at(y) - root.shown-from), + abs(root.fraction-at(y) - root.shown-to)) > root.grab) { + return 0; + } + // A range narrowed to a day draws one line, not two. Taking the + // nearer end there would always answer "the early one" and leave the + // late end impossible to pull back out; which side of the line the + // press landed on is what the user meant by it. + if (abs(root.shown-from - root.shown-to) < 0.001) { + return root.fraction-at(y) > root.shown-from ? 2 : 1; + } + return abs(root.fraction-at(y) - root.shown-from) + < abs(root.fraction-at(y) - root.shown-to) ? 1 : 2; + } + + /// Move the held end to `y`. + /// + /// The ends are allowed to cross. Dragging one past the other is a normal + /// way to say "no, from *here* instead", and Rust puts the pair back in + /// order — stopping the finger dead at the other end would make a range + /// that has been narrowed too far harder to correct than to start again. + function drag-end(y: length) { + if (root.grabbed == 1) { + root.live-from = root.fraction-at(y); + } else { + root.live-to = root.fraction-at(y); + } + } + // Header: the hovered bucket, else the whole span. Caption { x: 8px; @@ -183,10 +258,68 @@ export component Timeline inherits Rectangle { background: Theme.active; opacity: root.anchored ? 1.0 : 0.35; y: root.anchored && root.current-fraction >= 0 - ? root.track-top + clamp(root.current-fraction, 0.0, 1.0) * root.track-height + ? root.y-at(root.current-fraction) : root.track-top + root.track-height / 2; } + // --- the chosen date range ------------------------------------------ + // + // Drawn *on* the axis, and dragged there. The two typed date fields under + // the filter chips were the only way to state a range, and on a phone they + // were barely a way at all: `YYYY-MM-DD` keyed into a 108px field behind a + // soft keyboard, to name a day that is already drawn on the axis a thumb + // away. The other route — zoom the axis to a period, then "limit to range" + // — needs a wheel to zoom and a middle button to pan, and a touch screen + // has neither. + // + // The axis keeps its full extent while a range is on (see + // `refresh_timeline`, and `Filter::without_date_range`). It has to: the + // bars outside the band are what the range is widened back *into*, and an + // axis that redrew itself to the band would move the ground under the very + // handles doing the narrowing. + if root.has-range: Rectangle { + // Outside the range, held back rather than hidden — those bars are + // still the shape of the library, and still where the band is going + // next. Starts at the track, so the header line stays legible. + Rectangle { + x: 0; + y: root.track-top; + width: parent.width; + height: max(0px, root.y-at(min(root.shown-from, root.shown-to)) - root.track-top); + background: Theme.surface; + opacity: 0.72; + } + + Rectangle { + x: 0; + y: root.y-at(max(root.shown-from, root.shown-to)); + width: parent.width; + height: max(0px, parent.height - self.y); + background: Theme.surface; + opacity: 0.72; + } + + // The ends themselves: a line across the axis with a grip at the left, + // where the bars start. The right-hand strip carries the year and + // month labels, and a grip there would sit on top of them. + for edge[i] in [0, 1]: Rectangle { + x: 0; + y: root.y-at(i == 0 ? root.shown-from : root.shown-to) - 1px; + width: parent.width; + height: 2px; + background: Theme.active; + + Rectangle { + x: 2px; + y: -5px; + width: 18px; + height: 12px; + border-radius: 3px; + background: Theme.active; + } + } + } + // The single hit area. Everything above is inert. // Two-finger pinch, for tablet. There is no wheel there, so without this // the axis could only be zoomed by a control a finger cannot reach. @@ -218,7 +351,11 @@ export component Timeline inherits Rectangle { property panning; moved => { - if (self.panning) { + if (root.grabbed > 0) { + // The band follows the finger. The grid hears about it on + // release — see `range-changed`. + root.drag-end(self.mouse-y); + } else if (self.panning) { // Fractional, so a slow drag moves the view continuously // rather than sitting still until it crosses a bucket edge. root.pan((self.press-y - self.mouse-y) / max(1px, root.track-height)); @@ -236,11 +373,31 @@ export component Timeline inherits Rectangle { // modifier state belongs to the key handler, not the pointer // event, and a middle-drag is the unambiguous gesture. self.panning = e.button == PointerEventButton.middle; - if (!self.panning && root.bars.length > 0) { + // An end of the range first, if the press landed on one: a + // press within a finger's width of a handle means the handle, + // not the scrub it would otherwise have been. + root.grabbed = self.panning ? 0 : root.handle-at(self.mouse-y); + if (root.grabbed > 0) { + root.live-from = root.range-from; + root.live-to = root.range-to; + } else if (!self.panning && root.bars.length > 0) { root.scrub-to(root.fraction-at(self.mouse-y)); } } - if (e.kind == PointerEventKind.up) { self.panning = false; } + if (e.kind == PointerEventKind.up) { + self.panning = false; + if (root.grabbed > 0) { + root.grabbed = 0; + root.range-changed(root.live-from, root.live-to); + } + } + // A cancelled gesture — the system taking the touch for a + // back-swipe, most often — is not a range the user stated. The + // band goes back to where Rust still has it. + if (e.kind == PointerEventKind.cancel) { + self.panning = false; + root.grabbed = 0; + } } scroll-event(e) => { @@ -1105,6 +1262,16 @@ export component LibraryGrid inherits Rectangle { in property range-active: false; callback toggle-date-range(); /// TRACES: FR-CAT-6 + /// The same range as fractions of the span the axis is drawn over, for + /// the band on the timeline. Negative in both when there is none. + /// + /// A second representation of one range, which is worth it: the fields + /// below are how a date is *stated* and the band is how a period is + /// *found*, and neither is a good way to do the other's job. Both come + /// from the one filter in Rust, so they cannot drift apart. + in property range-from-fraction: -1; + in property range-to-fraction: -1; + callback timeline-range-changed(float, float); /// The ends of the range, as `YYYY-MM-DD`, when one is set. /// /// Strings rather than instants because this is what the user types and @@ -1736,10 +1903,12 @@ export component LibraryGrid inherits Rectangle { Rectangle { width: Theme.gap; } - // Narrow the grid to the span the timeline is showing. The - // histogram is already how you find a period, so the range is - // taken from it rather than typed: zoom to the fortnight you - // want, then say "only that". Clicking again lifts it. + // Turn the range on, over the span the timeline is showing. + // The histogram is already how you find a period, so this is + // a starting point rather than the answer: the band it puts on + // the axis is then dragged to the fortnight you meant, which + // is the one way in that needs neither a keyboard nor a wheel. + // Pressing again lifts the range. // // The histogram deliberately keeps drawing the full extent // while this is on, or there would be nothing left to widen @@ -1843,6 +2012,11 @@ export component LibraryGrid inherits Rectangle { // into the chip row — which is 34px tall and scrolls sideways, so // they were both clipped and off past the right-hand edge. // + // They are no longer the only way to state a range — the band on the + // axis is, and on a phone it is the only usable one. These stay for + // the two things dragging cannot do: name an exact day, and say what + // the range currently is in words rather than as a position. + // // A `Rectangle` stacks its children at the origin rather than laying // them out, which is why putting a second row inside the chips' one // drew it over them instead of under them. This is a row of the @@ -2024,7 +2198,10 @@ export component LibraryGrid inherits Rectangle { current-start: root.current-bucket; current-fraction: root.current-bucket-fraction; anchored: root.timeline-anchored; + range-from: root.range-from-fraction; + range-to: root.range-to-fraction; + range-changed(a, b) => { root.timeline-range-changed(a, b); } scrub-to(f) => { root.scrub-fraction(f); } pinch(r) => { root.timeline-pinch(r); } pan(d) => { root.timeline-pan(d); } @@ -2316,6 +2493,28 @@ export component LibraryGrid inherits Rectangle { // window is swapped underneath to match. viewport-height: root.total-rows * (root.cell-size + Theme.gap) + Theme.gap; + // TRACES: FR-CAT-7 + // Stay inside the content when the content shrinks. + // + // Deleting photographs makes the library shorter, and the + // viewport is sized to the *whole* library — so a view that was + // scrolled near the end is suddenly scrolled past it. Slint + // does not pull a Flickable back on its own, so the grid went + // blank: the cells were still there, above a viewport looking + // at empty space below them. + // + // Worse than blank, it also *moved*. Cells are drawn at + // `(i + offset) / columns`, and a delete re-clamps `offset` + // downward to keep the window full — so the same `viewport-y` + // now points at a different part of the library, and the grid + // appeared to jump somewhere arbitrary. Re-seeking below is the + // other half of this; this half stops the blank. + changed viewport-height => { + if (-self.viewport-y > max(0px, self.viewport-height - self.height)) { + self.viewport-y = -max(0px, self.viewport-height - self.height); + } + } + // Report the first fully-scrolled-past row so Rust can move the // window. Derived rather than eventful: Slint has no scroll // callback, and a `changed` handler on a derived integer fires only diff --git a/ui/dr-ui/ui/settings.slint b/ui/dr-ui/ui/settings.slint index 0c972a3..5a2a1b2 100644 --- a/ui/dr-ui/ui/settings.slint +++ b/ui/dr-ui/ui/settings.slint @@ -111,6 +111,16 @@ export component SettingsPage inherits Rectangle { callback keep-opened-toggled(bool); callback copy-includes-framing-toggled(bool); + // --- library ------------------------------------------------------- + /// TRACES: FR-CAT-6 + /// The bar counts the capture-time axis can be cut into, and which one is + /// chosen. Labels from Rust, like every other choice row: the numbers are + /// the ones the settings record offers, and a list written again here + /// would be a second place for them to go stale. + in property <[string]> timeline-bar-labels; + in property timeline-bars-selected: 0; + callback timeline-bars-picked(int); + // --- export -------------------------------------------------------- in property <[string]> format-labels; in property format-selected: 0; @@ -472,6 +482,34 @@ export component SettingsPage inherits Rectangle { } } + // --- library --------------------------------------------- + Rectangle { + width: content.column; + height: library-panel.preferred-height; + + library-panel := Panel { + width: 100%; + spacing: Theme.gap; + + PanelHeading { text: "LIBRARY"; } + + Segmented { + label: "Timeline detail"; + // Says what the choice is *about*, since neither + // number means anything on its own: it is how + // finely the capture-time axis beside the grid is + // divided, and the honest trade is precision + // against being able to hit a bar with a thumb. + hint: "bars on the capture-time axis — fewer are " + + "easier to hit with a finger, more show " + + "finer structure"; + options: root.timeline-bar-labels; + selected: root.timeline-bars-selected; + picked(i) => { root.timeline-bars-picked(i); } + } + } + } + // --- export ---------------------------------------------- Rectangle { width: content.column;