Compare commits

..
6 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 a3785ba55d Say which version this is: 0.6.0
Build and test / Desktop (Linux) (push) Failing after 2m24s
Build and test / Layer separation (push) Successful in 26s
🐳 Android image / Build and push (push) Successful in 8s
Build and test / android-image (push) Successful in 7s
Traceability / Requirement traces (push) Successful in 1m5s
Build and test / Android (aarch64) (push) Failing after 4s
Set by tools/set-version.sh, which is the only thing that should. The
workspace, the pacman package and — through Cargo.toml at link time — the
APK all state 0.6.0, so a bug report naming a version names one commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 19:59:30 +02:00
dtourolleandClaude Opus 5 6b5ffc6a93 Anchor the delete on what the view was showing, not on the window's start
Build and test / Desktop (Linux) (push) Failing after 2m33s
Build and test / Layer separation (push) Successful in 23s
Traceability / Requirement traces (push) Successful in 22s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Failing after 6s
The grid still jumped. Anchoring on `offset` was wrong for a reason this file
already states, a few hundred lines away: "the first *visible* ordinal, not the
window's start: the loaded window deliberately begins a quarter of a screen
above the view, so its first cell is one the user cannot see."

Two things made `offset` the wrong number. It is a screen-quarter above what is
being looked at, and by the point this runs it has already been re-clamped
against the new, smaller total — so seeking to it moved the view somewhere the
photographer had not been. A smaller jump than the original, and the same fault.

`resume_at` is what the grid last reported as its first visible image, which is
the photograph the person is actually looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 19:38:27 +02:00
dtourolleandClaude Opus 5 cd64166b15 Keep the view on the photographs after some of them are deleted
Build and test / Desktop (Linux) (push) Failing after 2m21s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Successful in 27s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 4s
Deleting made the grid go blank and jump somewhere arbitrary. Two causes, both
of them the viewport being left behind by everything else that moved.

The Flickable is sized to the *whole* library so its scrollbar is a real
address into twenty thousand images. Delete some and that content gets shorter,
which leaves a view near the end scrolled past what now exists — cells sitting
above a viewport looking at empty space. Slint does not pull a Flickable back
on its own. (The clamp for that went in with the previous commit.)

The jump is the other half. Cells are drawn at their absolute place in the
library, `(i + offset) / columns`, and a delete re-clamps `offset` downward so
the loaded window still fills. Nothing touches `viewport-y`, so the same scroll
position now addresses different photographs and the grid appears to leap
somewhere unrelated.

`restore_position` re-anchors 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 the scroll position away
on every delete — after removing one frame from a wall of twenty thousand, the
one you want next is the one that just moved into its place.

Only on a shrink, and the shrink is detected by reading `library-total` before
overwriting it. Re-anchoring on every load would fight a scrub, which sets
exactly this property to go where the user asked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 19:03:15 +02:00
dtourolleandClaude Opus 5 fa4ad6e2d6 Drag the date range on the axis it is chosen from
The range could be turned on with a finger and not aimed with one. Its two
ends were typed as `YYYY-MM-DD` into 108px fields behind a soft keyboard, to
name days already drawn on the axis a thumb away; and the chip that seeds them
takes its span from the timeline's zoom and pan, which are a wheel and a middle
button. A touch screen has neither, so on Android the filter was a switch with
no aim.

The band is now on the timeline. Two ends with grips, dragged along the bars,
released to filter — the histogram was already how a period is found, and this
makes it how a period is stated. Both ends snap to whole days, which is what
the typed fields mean, what `show_range` reads back out, and a floor under a
range dragged shut. The fields stay for what dragging cannot do: name an exact
day, and say in words what the range is.

For that to work the axis had to stop following the range. Redrawn to the band,
it moved the ground under the very handles doing the narrowing, and there was
nothing outside the range left to widen back into.

While there: a fixed number of equal bins instead of calendar buckets. Between
one calendar unit and the next the bar count is free to wander by a factor of
twelve, so zooming in halved it two steps out of three — the same picture drawn
wider until it jumped back to fine. Equal bins also include the empty ones, so
a bar's position on the track and the date under it are finally the same
quantity; before, a library with gaps drew a February six months wide and the
marker, the band and a click all pointed somewhere else. The count is a
setting, 32 or 64, because the right answer is a question about the screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 18:42:42 +02:00
dtourolleandClaude Opus 5 b0b6dd559a Show what is selected, and let a collection be made of it
Build and test / Desktop (Linux) (push) Failing after 2m37s
Build and test / Layer separation (push) Successful in 26s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 5s
Traceability / Requirement traces (push) Successful in 40s
Build and test / Android (aarch64) (push) Failing after 6s
Three things a selection needed and did not have.

**Seeing it.** The count existed — "12 selected" — in the header row, which
scrolls sideways. On a tablet it sat past the right-hand edge along with every
button beside it, so a selection was something you could make and then not
see. A selection you cannot see is one you act on by accident.

**Putting it down.** The only way to clear one was "Done", which also leaves
select mode — so after filing forty photographs the next forty began by
re-entering a mode the user had not meant to leave. Clearing is now its own
action and keeps the mode.

**Filing it somewhere new.** Making a collection of a selection took four
steps: create one, find it in the tree, select the photographs again because
creating it changed the scope, then add them. It is one press, which is how a
selection is usually meant — it is gathered *because* it is going somewhere.

The new collection is created at the top level rather than inside the current
scope, unlike the tree's "+". A selection can be gathered from anywhere,
including across collections, so filing it under whichever one happens to be
open would put it somewhere its contents did not come from. It opens straight
into its name field, for the reason `collection_new` already does: the
placeholder name is nobody's choice, and making the user find the rename
afterwards is asking them to finish a job we started.

All of it on its own strip beside the date range's, appearing only while there
is a selection — the third control this session that was invisible for being
put in a row that scrolls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:57:35 +02:00
dtourolleandClaude Opus 5 ffc40c42d2 Regenerate the matrix where the tags are changed, not after the push
Build and test / Desktop (Linux) (push) Failing after 2m25s
Build and test / Layer separation (push) Successful in 28s
🐳 Android image / Build and push (push) Successful in 5s
Build and test / android-image (push) Successful in 5s
Traceability / Requirement traces (push) Successful in 26s
Build and test / Android (aarch64) (push) Failing after 4s
The traceability gate has failed on six commits in a row, every time for the
same reason: someone added a `TRACES:` tag and did not regenerate
docs/traceability.md. The gate is right to fail — a matrix that disagrees with
the tree is worse than none, because it is read as current — but it says so
after a push, on a commit that is otherwise fine, and by then the tag and the
matrix are two separate things to remember instead of one.

A pre-commit hook regenerates it and stages it, so the two travel together.
Enabled with `core.hooksPath`, which is a local setting: run

    git config core.hooksPath .githooks

in a fresh clone, or the hook sits there doing nothing.

Only runs when something that can carry a tag is staged, and says nothing
unless it changed the matrix — a hook that prints on every commit is one people
start passing `--no-verify` to. If the report cannot run at all it leaves the
matrix alone and lets the commit through: refusing to commit because a build is
broken would be a worse failure than the one it prevents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 15:01:44 +02:00
16 changed files with 1275 additions and 158 deletions
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Keep docs/traceability.md in step with the tags in the tree.
#
# The gate regenerates the matrix in CI and fails if the result differs from
# what is committed. That is the right check — a matrix that disagrees with the
# tree is worse than none, because it is read as current — but it fails *after*
# a push, on a commit that is otherwise fine, and it has now done so on six
# commits in a row because adding a `TRACES:` tag and regenerating the matrix
# are two actions and only the first is on anyone's mind.
#
# So it happens here instead, where the tags are being changed.
#
# Only when something that can carry a tag is staged: a commit touching
# workflows, packaging or the matrix itself pays nothing.
set -euo pipefail
staged="$(git diff --cached --name-only --diff-filter=ACMR)"
if ! grep -qE '\.(rs|slint|yaml|md)$' <<< "${staged}"; then
exit 0
fi
# The matrix is generated from the tree, so regenerating it because it was
# itself edited would be circular.
if [ "$(tr -d '[:space:]' <<< "${staged}")" = "docs/traceability.md" ]; then
exit 0
fi
repo="$(git rev-parse --show-toplevel)"
cd "${repo}"
# Quiet unless it has something to say. A hook that prints on every commit is
# a hook people start passing --no-verify to.
if ! cargo run -q -p traceability -- report >/dev/null 2>&1; then
echo "pre-commit: could not run the traceability report; leaving the matrix alone" >&2
exit 0
fi
if ! git diff --quiet -- docs/traceability.md; then
git add docs/traceability.md
echo "pre-commit: regenerated docs/traceability.md and staged it"
fi
Generated
+17 -17
View File
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "darkroom-android"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"android_logger",
"dr-sync-nextcloud",
@@ -1232,7 +1232,7 @@ dependencies = [
[[package]]
name = "darkroom-desktop"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"anyhow",
"dr-ui",
@@ -1404,7 +1404,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dr-catalog"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"dr-plat",
"dr-types",
@@ -1417,7 +1417,7 @@ dependencies = [
[[package]]
name = "dr-decode"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"dr-types",
"env_logger",
@@ -1431,7 +1431,7 @@ dependencies = [
[[package]]
name = "dr-export"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"dr-decode",
"dr-gpu",
@@ -1449,7 +1449,7 @@ dependencies = [
[[package]]
name = "dr-gpu"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"bytemuck",
"dr-decode",
@@ -1465,7 +1465,7 @@ dependencies = [
[[package]]
name = "dr-ingest"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"dr-plat",
"dr-types",
@@ -1477,7 +1477,7 @@ dependencies = [
[[package]]
name = "dr-lens"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"lensfun",
"log",
@@ -1485,7 +1485,7 @@ dependencies = [
[[package]]
name = "dr-pipeline"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"dr-types",
"log",
@@ -1494,7 +1494,7 @@ dependencies = [
[[package]]
name = "dr-plat"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"android-native-keyring-store",
"dr-types",
@@ -1507,7 +1507,7 @@ dependencies = [
[[package]]
name = "dr-segment"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"env_logger",
"log",
@@ -1520,7 +1520,7 @@ dependencies = [
[[package]]
name = "dr-sync"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"async-trait",
"dr-types",
@@ -1531,7 +1531,7 @@ dependencies = [
[[package]]
name = "dr-sync-nextcloud"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"async-trait",
"dr-decode",
@@ -1552,7 +1552,7 @@ dependencies = [
[[package]]
name = "dr-thumbs"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"dr-types",
"jpeg-encoder",
@@ -1564,7 +1564,7 @@ dependencies = [
[[package]]
name = "dr-types"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"serde",
"serde_json",
@@ -1573,7 +1573,7 @@ dependencies = [
[[package]]
name = "dr-ui"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"anyhow",
"dr-catalog",
@@ -6896,7 +6896,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "traceability"
version = "0.5.0"
version = "0.6.0"
dependencies = [
"anyhow",
"serde",
+1 -1
View File
@@ -21,7 +21,7 @@ members = [
]
[workspace.package]
version = "0.5.0"
version = "0.6.0"
edition = "2021"
rust-version = "1.92"
license = "GPL-3.0-or-later"
+20 -8
View File
@@ -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))
+1 -1
View File
@@ -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,
+98
View File
@@ -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();
+31 -31
View File
@@ -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 | 540 |
| 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:1848`](../ui/dr-ui/src/collections_ui.rs#L1848), [`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:504`](../ui/dr-ui/ui/app.slint#L504), [`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:4339`](../ui/dr-ui/src/library_ui.rs#L4339), [`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/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:576`](../ui/dr-ui/ui/app.slint#L576), [`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:458`](../ui/dr-ui/ui/app.slint#L458), [`ui/dr-ui/ui/app.slint:576`](../ui/dr-ui/ui/app.slint#L576), [`ui/dr-ui/ui/library.slint:1101`](../ui/dr-ui/ui/library.slint#L1101), [`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:1546`](../ui/dr-ui/src/collections_ui.rs#L1546), [`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:571`](../ui/dr-ui/ui/app.slint#L571), [`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:5902`](../ui/dr-ui/src/library_ui.rs#L5902), [`ui/dr-ui/src/library_ui.rs:5913`](../ui/dr-ui/src/library_ui.rs#L5913), [`ui/dr-ui/src/library_ui.rs:5926`](../ui/dr-ui/src/library_ui.rs#L5926), [`ui/dr-ui/src/library_ui.rs:5941`](../ui/dr-ui/src/library_ui.rs#L5941), [`ui/dr-ui/src/library_ui.rs:5950`](../ui/dr-ui/src/library_ui.rs#L5950), [`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:4885`](../ui/dr-ui/src/library_ui.rs#L4885), [`ui/dr-ui/src/library_ui.rs:4938`](../ui/dr-ui/src/library_ui.rs#L4938), [`ui/dr-ui/src/library_ui.rs:6042`](../ui/dr-ui/src/library_ui.rs#L6042), [`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:4628`](../ui/dr-ui/src/library_ui.rs#L4628), [`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:4983`](../ui/dr-ui/src/library_ui.rs#L4983), [`ui/dr-ui/src/library_ui.rs:5098`](../ui/dr-ui/src/library_ui.rs#L5098), [`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:1880`](../ui/dr-ui/ui/app.slint#L1880), [`ui/dr-ui/ui/app.slint:888`](../ui/dr-ui/ui/app.slint#L888) |
| 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:1118`](../ui/dr-ui/ui/library.slint#L1118), [`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:1349`](../ui/dr-ui/ui/app.slint#L1349), [`ui/dr-ui/ui/app.slint:979`](../ui/dr-ui/ui/app.slint#L979), [`ui/dr-ui/ui/library.slint:1126`](../ui/dr-ui/ui/library.slint#L1126), [`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:5884`](../ui/dr-ui/src/library_ui.rs#L5884), [`ui/dr-ui/src/library_ui.rs:5961`](../ui/dr-ui/src/library_ui.rs#L5961), [`ui/dr-ui/src/library_ui.rs:5973`](../ui/dr-ui/src/library_ui.rs#L5973), [`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:504`](../ui/dr-ui/ui/app.slint#L504), [`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:4339`](../ui/dr-ui/src/library_ui.rs#L4339), [`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:2965`](../ui/dr-ui/src/collections_ui.rs#L2965), [`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:2378`](../ui/dr-ui/ui/app.slint#L2378), [`ui/dr-ui/ui/app.slint:565`](../ui/dr-ui/ui/app.slint#L565), [`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:2965`](../ui/dr-ui/src/collections_ui.rs#L2965), [`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:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/library_ui.rs:5000`](../ui/dr-ui/src/library_ui.rs#L5000), [`ui/dr-ui/src/library_ui.rs:5018`](../ui/dr-ui/src/library_ui.rs#L5018), [`ui/dr-ui/src/library_ui.rs:5030`](../ui/dr-ui/src/library_ui.rs#L5030), [`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:1519`](../ui/dr-ui/src/collections_ui.rs#L1519), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs: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:2107`](../ui/dr-ui/ui/app.slint#L2107), [`ui/dr-ui/ui/app.slint:262`](../ui/dr-ui/ui/app.slint#L262), [`ui/dr-ui/ui/app.slint:595`](../ui/dr-ui/ui/app.slint#L595), [`ui/dr-ui/ui/app.slint:602`](../ui/dr-ui/ui/app.slint#L602), [`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:2003`](../ui/dr-ui/ui/library.slint#L2003), [`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:1880`](../ui/dr-ui/ui/app.slint#L1880), [`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:1519`](../ui/dr-ui/src/collections_ui.rs#L1519), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1546`](../ui/dr-ui/src/collections_ui.rs#L1546), [`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:1576`](../ui/dr-ui/ui/app.slint#L1576), [`ui/dr-ui/ui/app.slint:571`](../ui/dr-ui/ui/app.slint#L571), [`ui/dr-ui/ui/app.slint:602`](../ui/dr-ui/ui/app.slint#L602), [`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:2003`](../ui/dr-ui/ui/library.slint#L2003), [`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:4885`](../ui/dr-ui/src/library_ui.rs#L4885), [`ui/dr-ui/src/library_ui.rs:5030`](../ui/dr-ui/src/library_ui.rs#L5030), [`ui/dr-ui/src/library_ui.rs:5992`](../ui/dr-ui/src/library_ui.rs#L5992), [`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:4134`](../ui/dr-ui/src/library_ui.rs#L4134), [`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:4134`](../ui/dr-ui/src/library_ui.rs#L4134), [`ui/dr-ui/src/library_ui.rs:4179`](../ui/dr-ui/src/library_ui.rs#L4179), [`ui/dr-ui/src/library_ui.rs:4282`](../ui/dr-ui/src/library_ui.rs#L4282), [`ui/dr-ui/src/library_ui.rs:4310`](../ui/dr-ui/src/library_ui.rs#L4310), [`ui/dr-ui/src/library_ui.rs:5018`](../ui/dr-ui/src/library_ui.rs#L5018), [`ui/dr-ui/src/library_ui.rs:5030`](../ui/dr-ui/src/library_ui.rs#L5030), [`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:990`](../ui/dr-ui/ui/app.slint#L990), [`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) |
+1 -1
View File
@@ -4,7 +4,7 @@
# makes `makepkg -si` in this directory install what you are actually working
# on. Swap `source` for a tagged tarball when there is something to release.
pkgname=darkroom
pkgver=0.5.0
pkgver=0.6.0
pkgrel=1
pkgdesc="Non-destructive RAW photo library and editor"
arch=('x86_64')
+88
View File
@@ -1516,6 +1516,94 @@ pub fn wire<S, R, C>(
});
}
// TRACES: FR-CAT-5
// Put the selection down without leaving select mode.
//
// Distinct from "Done", which does both: after picking forty photographs
// and filing them, the next forty start with the mode already on, and
// making the user re-enter it is asking them to undo a step they did not
// take. It is also the only way back from a mis-tap that is not tapping
// every one of them again.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let visible = visible_ids.clone();
window.on_library_clear_selection(move || {
let Some(w) = weak.upgrade() else { return };
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
});
}
// TRACES: FR-CAT-5
// A collection holding exactly what is selected.
//
// This was four steps — make a collection, find it in the tree, select the
// photographs again because creating one changed the scope, then add them
// — and the selection is usually made *because* it is going somewhere. One
// press instead.
//
// Created at the top level rather than inside the current scope, unlike
// the tree's "+". A selection can be gathered from anywhere, including
// across collections, so filing it under whichever one happens to be open
// would put it somewhere its contents did not come from.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let catalog = catalog.clone();
let visible = visible_ids.clone();
let reload = on_scope_changed.clone();
window.on_library_collection_from_selection(move || {
let Some(w) = weak.upgrade() else { return };
let images = ctl.selected();
if images.is_empty() {
return;
}
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else {
w.set_collection_error("Open a library first.".into());
return;
};
let name = unique_name(cat.connection(), None);
let made = coll::create(cat.connection(), &name, None, CollectionKind::Manual)
.and_then(|id| coll::add_images(cat.connection(), id, &images).map(|n| (id, n)));
match made {
Ok((id, added)) => {
w.set_collection_error(slint::SharedString::new());
w.set_library_status(
format!(
"{added} photograph{} in {name}",
if added == 1 { "" } else { "s" }
)
.into(),
);
refresh_tree(&w, &ctl, cat);
drop(borrow);
// The selection has been filed; holding on to it invites
// the next press acting on photographs the user considers
// dealt with.
ctl.clear_selection();
sync_selection(&w, &ctl, &visible());
// Into the name field, for the same reason `collection_new`
// does it: "New collection" is a placeholder nobody wants
// to keep, and making them find the rename afterwards is
// asking them to finish a job we started.
w.set_collection_renaming(id.0 as i32);
reload();
}
Err(e) => {
drop(borrow);
w.set_collection_error(format!("making a collection: {e}").into());
}
}
});
}
// TRACES: FR-UI-2 | FR-UI-4
// The button half of selection mode. The long press is faster and this is
// the one that can be found — a gesture with no visible counterpart is a
+12
View File
@@ -1015,6 +1015,7 @@ pub fn run(paths: Vec<PathBuf>) -> 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<PathBuf>) -> 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.
+174 -31
View File
@@ -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<dr_types::CollectionId>,
filter: &RatingFilter,
granularity: dr_catalog::Granularity,
from: i64,
to: i64,
bins: u32,
) -> Result<Vec<dr_catalog::TimeBucket>, 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::<Result<Vec<_>, _>>()?;
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<dr_catalog::TimeBucket> = (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::<u32>(),
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::<u32>(), 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::<u32>(),
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::<u32>(),
0,
"nothing here is rated"
);
}
fn entry(path: &str, file_id: u64, size: u64) -> RemoteEntry {
RemoteEntry {
id: RemoteId::Stable(file_id),
+427 -58
View File
@@ -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<bool>,
/// 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<u32>,
/// 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<LibraryController>) {
} 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<LibraryController>) {
// 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<LibraryContr
// fortnight in Arosa landed in one column of a fifteen-year axis.
let scope = *ctl.scope.borrow();
let filter = *ctl.filter.borrow();
// Passed whole, date range included, and both queries below lift that
// range themselves — see `span_scoped` and `timeline_uniform`. Every other
// term still applies, because a histogram of the five-star frames is a
// fair question; the range does not, because the axis is *how a range is
// chosen* and drawing through it would empty every bin outside the band
// and leave nothing to widen back into.
let span = match library::span_scoped(catalog, scope, &filter) {
Some(s) => s,
None => {
@@ -3542,27 +3591,24 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<LibraryContr
}
};
// A chosen date range is what the axis draws, when there is one.
// A range is drawn *over* this span as a band, not substituted for it —
// see the band below. The scale a narrow range deserves is reached by
// zooming the axis instead, which is a gesture a finger has (pinch) and
// does not disturb the range while it is being adjusted.
//
// It used to keep drawing the full extent while a range was on, because
// the axis was the only way back out: narrow it to the range and there
// would be nothing left to widen from. The range now has typed ends, and
// those are the way back out — so the axis is free to show the span the
// user actually asked about, at the granularity that span deserves. A
// fortnight drawn across fifteen years is one column; drawn across itself
// it is fourteen days.
let span = match (filter.captured_from, filter.captured_to) {
(Some(from), Some(to)) if to > 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<LibraryContr
// Square root rather than linear: it keeps a 3-image day
// visible beside a 400-image one without a log scale's
// misleading flatness.
height: ((b.count as f32 / peak as f32).sqrt()).clamp(0.02, 1.0),
//
// A bin holding nothing draws nothing. The floor exists so a
// quiet day is not rounded away, and empty bins are common now
// that every one of them is emitted — a sliver on each would
// draw a library that has photographs in months it does not.
height: if b.count == 0 {
0.0
} else {
((b.count as f32 / peak as f32).sqrt()).clamp(0.02, 1.0)
},
start: b.start as i32,
count: b.count as i32,
label: format_bucket(b.start, granularity).into(),
@@ -3618,12 +3673,33 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<LibraryContr
// slot contained the instant — near enough on a dense uniform axis, plainly
// wrong on a sparse one, and never under the click.
let current = *ctl.current_bucket.borrow();
let fraction = current
.map(|t| ((t - from) as f64 / (to - from).max(1) as f64).clamp(0.0, 1.0) as f32)
.unwrap_or(-1.0);
let fraction = current.map(|t| fraction_at((from, to), t)).unwrap_or(-1.0);
window.set_library_current_bucket(current.unwrap_or(0) as i32);
// The chosen range in those same coordinates, for the band. Both ends or
// neither: half a band is a line the user cannot tell from a handle.
let (band_from, band_to) = match (filter.captured_from, filter.captured_to) {
(Some(a), Some(b)) => (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<LibraryContr
/// Whether this bucket size is fine enough for month labels to mean anything.
///
/// A year-bucketed axis labelled by month would put twelve labels on one bar.
/// Month-sized bins are excluded too, now that the axis has a fixed number of
/// them: a bin of about a month changes month at nearly every bar, so the rule
/// below would write a label on all sixty-four of them and the right-hand
/// strip would be a column of overlapping text. Below a month the labels are
/// occasional again, which is what makes them readable.
fn granularity_labels_months(g: dr_catalog::Granularity) -> 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, 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<LibraryController>) -> 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,43 @@ fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, 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<LibraryController>) {
let total = window.get_library_total().max(0) as usize;
if total == 0 {
return;
}
// The first *visible* ordinal, not the loaded window's start.
//
// Anchoring on `offset` was the first attempt and it still jumped, because
// the two are deliberately different: the window begins about a quarter of
// a screen above the view so scrolling up has rows ready, and by this point
// in `load_window` it has also been re-clamped against the new, smaller
// total. Seeking to it therefore moved the view to somewhere the user was
// not — a smaller jump than before, but the same fault.
//
// `resume_at` is what the grid last reported as its first visible image,
// which is the thing the photographer is actually looking at.
let anchor = ctl.resume_at.get().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 +4561,7 @@ pub fn wire<F>(
*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 +4811,18 @@ pub fn wire<F>(
});
}
// 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 +4861,12 @@ pub fn wire<F>(
)
})
};
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 +4882,59 @@ pub fn wire<F>(
});
}
// 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 +4970,7 @@ pub fn wire<F>(
// 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 +5134,6 @@ fn stop(slot: &RefCell<Option<slint::Timer>>) {
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 +5147,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 +5154,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 +5167,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 +5180,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 +5512,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,12 +5552,52 @@ 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;
}
}
/// Deleting must not move the view somewhere the user did not ask to be.
///
/// The grid draws each cell at its absolute place in the library, and a
/// delete shortens the library *and* re-clamps the loaded window's offset.
/// Neither touches the viewport, so before this the grid went blank — the
/// view left pointing past the end of the content — or appeared to jump,
/// the same scroll position now addressing different photographs.
///
/// The anchor is the last *visible* ordinal the grid reported, clamped into
/// what is left — not the loaded window's `offset`, which sits about a
/// quarter of a screen above the view and is itself re-clamped by the
/// delete. Anchoring on that was the first attempt and still jumped.
#[test]
fn a_shorter_library_anchors_the_view_instead_of_losing_it() {
// The rule `restore_position` applies, stated where it can be checked.
let anchor = |offset: usize, total: usize| -> Option<usize> {
if total == 0 {
None
} else {
Some(offset.min(total - 1))
}
};
// The everyday case: one frame removed from the middle of a wall of
// twenty thousand. The view does not move — the photograph that slid
// into the gap is the one you want next.
assert_eq!(anchor(12_000, 19_999), Some(12_000));
// Near the end, which is where the blank came from: the view was
// showing ordinals past what now exists, so it lands on the last.
assert_eq!(anchor(19_990, 5), Some(4));
// Emptied entirely. Seeking into an empty library would be the same
// fault in the other direction, so there is nothing to do.
assert_eq!(anchor(500, 0), None);
// The boundary: an offset equal to the new total is one past the end.
assert_eq!(anchor(10, 10), Some(9));
}
#[test]
fn bucket_labels_match_their_granularity() {
use dr_catalog::Granularity;
+31 -2
View File
@@ -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::<Vec<_>>(),
)));
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();
+23
View File
@@ -455,11 +455,20 @@ export component AppWindow inherits Window {
/// Whether the grid is narrowed to the timeline's visible span.
in-out property <bool> library-range-active: false;
callback library-toggle-date-range();
/// TRACES: FR-CAT-5
callback library-clear-selection();
callback library-collection-from-selection();
/// TRACES: FR-CAT-6
in property <string> library-range-from;
in property <string> library-range-to;
in property <bool> 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 <float> library-range-from-fraction: -1;
in property <float> library-range-to-fraction: -1;
callback library-timeline-range-changed(float, float);
callback library-retry-connection();
// --- pinning a collection offline (FR-NC-6a) ---
@@ -802,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 <int> settings-timeline-bars-selected: 0;
callback settings-timeline-bars-picked(int);
in property <[string]> settings-format-labels;
in property <int> settings-format-selected: 0;
@@ -1133,6 +1146,9 @@ in property <bool> 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); }
@@ -1321,10 +1337,17 @@ in property <bool> panel-visible: true;
toggle-local-only() => { root.library-toggle-local-only(); }
range-active: root.library-range-active;
toggle-date-range() => { root.library-toggle-date-range(); }
clear-selection() => { root.library-clear-selection(); }
collection-from-selection() => { root.library-collection-from-selection(); }
range-from: root.library-range-from;
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(); }
+273 -8
View File
@@ -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 <float> range-from: -1;
in property <float> 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 <length> track-height: max(1px, self.height - self.track-top - 6px);
property <length> slot: root.track-height / max(1, root.bars.length);
property <bool> 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 <int> 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 <float> live-from;
property <float> live-to;
property <float> shown-from: root.grabbed > 0 ? root.live-from : root.range-from;
property <float> 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 <float> 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 <bool> 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) => {
@@ -1035,6 +1192,12 @@ export component LibraryGrid inherits Rectangle {
/// as the ctrl flag rather than as a third selection policy.
in property <bool> select-mode: false;
callback toggle-select-mode();
/// TRACES: FR-CAT-5
/// Drop the selection without leaving select mode.
callback clear-selection();
/// TRACES: FR-CAT-5
/// Make a new collection holding exactly what is selected.
callback collection-from-selection();
/// The drag payload: the selected image ids, wrapped by Rust. Called when a
/// drag starts, so it always reflects the selection as it is at that moment.
pure callback drag-payload() -> data-transfer;
@@ -1099,6 +1262,16 @@ export component LibraryGrid inherits Rectangle {
in property <bool> 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 <float> range-from-fraction: -1;
in property <float> 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
@@ -1730,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
@@ -1767,6 +1942,66 @@ export component LibraryGrid inherits Rectangle {
}
}
// --- what is selected, and what to do with it --------------------
//
// The count and these actions used to live only in the header row,
// which scrolls sideways: on a tablet "12 selected" and everything
// beside it sat past the right-hand edge, so a selection was something
// you could make and then not see. A selection you cannot see is one
// you act on by accident.
//
// Its own strip, like the date range's, appearing only while there is
// a selection — so it costs nothing the rest of the time, and when it
// is there it has the width of the window rather than the tail of a
// row that did not fit.
if root.selected-count > 0: Rectangle {
height: 40px;
background: Theme.surface;
HorizontalLayout {
width: 100%;
height: 100%;
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
// State rather than a label: it is what the buttons beside it
// act on.
Value {
text: root.selected-count + (root.selected-count == 1
? " photograph selected" : " photographs selected");
modified: true;
font-weight: 600;
vertical-alignment: center;
overflow: elide;
}
Rectangle { width: Theme.gap; }
// The way out that is not "undo every tap". Distinct from
// "Done", which leaves select mode entirely: clearing keeps the
// mode, so the next selection can start straight away.
Button {
text: "Clear";
y: (parent.height - self.height) / 2;
clicked => { root.clear-selection(); }
}
// Filing a selection into a collection that does not exist yet
// took four steps: make a collection, find it, select the
// photographs again, add them. One press instead, which is how
// a selection is usually meant.
Button {
text: "New collection from selection";
primary: true;
y: (parent.height - self.height) / 2;
clicked => { root.collection-from-selection(); }
}
}
}
// --- the date range's ends ---------------------------------------
//
// Its own strip, a sibling of the chips rather than a child of them.
@@ -1777,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
@@ -1958,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); }
@@ -2250,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
+38
View File
@@ -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 <int> timeline-bars-selected: 0;
callback timeline-bars-picked(int);
// --- export --------------------------------------------------------
in property <[string]> format-labels;
in property <int> 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;