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;