Compare commits

..
34 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
dtourolleandClaude Opus 5 f9c7aba8b6 Regenerate the traceability matrix
Build and test / Desktop (Linux) (push) Failing after 2m34s
Build and test / Layer separation (push) Successful in 23s
Traceability / Requirement traces (push) Successful in 1m6s
🐳 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
Six commits added nineteen TRACES tags between them and none regenerated the
matrix, so the gate failed on every one of them — including the commit the
release tag points at. The gate is doing its job: a matrix that disagrees with
the tree is worse than none, because it is read as current.

Coverage is unchanged at 50.3%; what moved is where each requirement is
tagged, which is the half of the file that is actually consulted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 15:00:45 +02:00
dtourolleandClaude Opus 5 fb4b05fb6f Let the date range be opened before there is a date range
Build and test / Desktop (Linux) (push) Failing after 2m16s
Build and test / Layer separation (push) Successful in 23s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 59s
Build and test / Android (aarch64) (push) Failing after 6s
Pressing "limit to range" did nothing, and the reason was two early returns
that sat above the line which shows the controls. If the catalog was not open,
or nothing in it carried a capture date, the handler returned before
`set_library_range_active`, so no state changed and nothing appeared.

Capture dates are read from EXIF as thumbnails load, so a freshly opened
library has none — the button was inert on exactly the libraries where someone
is most likely to go looking for a date, and it failed by doing nothing at all,
which is the hardest failure to report.

Underneath that was a smaller mistake with the same shape: whether the controls
showed was read from whether a range was set. The fields are how a range gets
set, so requiring one before they appear is a door locked from the inside. The
panel has its own state now, and the timeline span is a seed for the fields
rather than a precondition for them — no span means two empty fields waiting to
be typed into, which is a way to choose a range rather than a refusal to offer
one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:15:34 +02:00
dtourolleandClaude Opus 5 5a9beedca6 Give the range's ends a row of their own, not a corner of someone else's
Build and test / Desktop (Linux) (push) Failing after 2m32s
Build and test / Layer separation (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 5s
Traceability / Requirement traces (push) Failing after 1m3s
Build and test / Android (aarch64) (push) Failing after 7s
Third attempt at one control, and each failure was a different reason it could
not be seen.

It narrowed to the whole library, because it took its span from a timeline zoom
that is zero until someone zooms. The fields that fixed that went into the chip
row, which scrolls sideways, so they sat past the right-hand edge. And moving
them "below the chips" put them inside the same `Rectangle` — which stacks its
children at the origin rather than laying them out, so they were drawn over the
chips inside a strip 34px tall, unconstrained in width and clipped in height.

A `Rectangle` is not a layout. The row is a sibling of the chips' strip in the
header's `VerticalLayout` now, with a height of its own and the width of the
window: two 108px fields, a "to", and a warning when what was typed is not a
date — about 354px, against 768 on a tablet in portrait.

Left-aligned and inset by the same gap the chips use, so the two rows begin on
one vertical line instead of a few pixels apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:06:37 +02:00
dtourolleandClaude Opus 5 db43d8ec8b Put the range's ends where a tablet can see them
"Limit to range" looked broken a second time, for a second reason. The ends
were added to the filter chip row, and that row scrolls: its own comment
records that fourteen chips do not fit across 768 logical pixels, "so that is
every tablet in portrait". Two date fields and a caption went straight past the
right-hand edge, into the part of the row that has to be panned to.

So the fix for a control that appeared to do nothing was itself invisible, and
pressing the chip still looked like it did nothing.

They have their own line now, below the chips and outside the Flickable, and it
exists only while a range does. Nothing competes with it for width, and nothing
has to be panned to reach it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 13:58:56 +02:00
dtourolleandClaude Opus 5 3a42c63b5b Format the refine tests the way the gate asks for it
Build and test / Desktop (Linux) (push) Failing after 3m36s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Failing after 1m7s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 4s
Build and test / Android (aarch64) (push) Failing after 7s
`cargo fmt --check` is a required step and the mask-refine work landed with a
test body it disagrees with. Whitespace only — kept as its own commit so it can
be skipped wholesale rather than read for a change that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 13:24:48 +02:00
dtourolleandClaude Sonnet 5 b3641b5307 Let a subject's mask be refined, and let several masks be edited at once
Two related gaps in the mask panel, from the same conversation: a subject's
outline is only ever as sharp as the whole-frame pass that found it, and a
change meant for several layers had to be dragged once per layer.

## Refine mask

A "Refine mask" button on a subject layer re-runs detection on a padded crop
around that instance's own box instead of the whole frame — the subject
reaches the model at its own size rather than squeezed into the model's fixed
640x640 window alongside everything else in the photograph. `RefineJob`
mirrors `SegmentationJob`'s split (built on the session, run off it, adopted
back), and the crop itself is rendered through `Framing::set_view` — the same
ephemeral viewport the interactive zoom already uses to render a region above
proxy resolution, so no new render path and no change to the model's own
input size was needed. `dr-segment` is untouched: `Tiling::Whole` already
treats whatever buffer it is handed as the one window.

The result is still downsampled onto the shared proxy grid every instance's
mask lives on, but from a sharper source than the whole-frame pass ever saw
for that subject, which is what the edge actually reads out of.

## Multi-select

`active_mask: Option<String>` is now `active_masks: Vec<String>`. A plain
click still replaces the selection; a control- or command-click toggles one
layer in or out of it. `set_param` and `reset_op` fan out to every selected
layer, each set to the exact value the slider now shows rather than offset by
however far it already was — one slider, one reading, applied everywhere
selected. Dragging a gradient's on-canvas handle is deliberately not
extended to multi-select: several gradients have no single geometry a shared
handle could move, so `gradient_handles` stays empty unless exactly one
layer is selected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:17:07 +02:00
dtourolleandClaude Sonnet 5 e6b01226eb Let the segmentation export script pick its own input size
Comparing a larger model against a larger input size meant re-exporting at
resolutions other than the shipped 640, and the script only ever wrote that
one number. `IMGSZ` is now a second positional argument, defaulted to 640 so
every existing call is unchanged.

The experiment this was built for found bigger input a net loss on its own
merits — yolo26n-seg and yolo26s-seg at 1280 both lost track of large,
frame-filling subjects (a bus's box shrank and its score nearly halved)
in exchange for catching small or partially-occluded ones tiling already
handles. Nothing shipped from it, but the ability to re-run that comparison
is worth keeping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 13:16:51 +02:00
dtourolleandClaude Opus 5 4b7648082e Let a date range be stated, and draw the axis at the scale it deserves
"Limit to range" did nothing, and the reason was not visible from the button.
It took its span from the timeline's zoom, which is zero until someone zooms —
so `zoomed_span` returned the whole library and the filter narrowed to
everything. The chip lit up and the grid did not change.

The range has ends now, shown and typed as `YYYY-MM-DD`. Seeding them from the
timeline is kept, because zooming to a fortnight and pressing the chip is the
fast path; the fields say which fortnight it landed on and let it be corrected.
Ends given backwards are swapped rather than refused — there is exactly one
range between two days — and the closing day is included, since "to the 5th"
means the whole of the 5th and a range ending at its midnight contains none of
it. `parse_date` refuses anything that is not a date rather than guessing at an
order, because the alternative is a library silently filtered to a span nobody
asked for.

The axis then follows the range. It used to keep drawing the full extent while
a range was on, because it was the only way back out; the typed ends are the
way back out now, so it is free to show what was asked about.

And bucket size is chosen by how many bars it makes rather than by fixed
cut-offs. Each zoom step halves the span, so under thresholds the bar count
halved with it until a boundary was crossed: fifteen years went 15 bars, 8,
then 46, 23, 11, and finally 6. Zooming in made the picture coarser, which is
the opposite of what zooming is for. Aiming at forty bars keeps the count in
the same neighbourhood at every level, and the test asserts the property
directly — halving a span never coarsens the bucket.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 12:40:14 +02:00
dtourolleandClaude Opus 5 fbf286d504 Keep the mask when the photograph is closed
A local adjustment survived until the session ended and then did not exist.
Nothing reported it, because nothing had failed.

The sidecar format has carried masks since they were added, `Version::apply`
restores them into the graph, and `Version::update` captures them — that work
landed complete and was never called. The autosave writes `copy_settings()`,
which is a `Preset`: a map from (operation, parameter) to a number. A mask is
not a parameter. It is a rule about *where*, with a chain of its own, so it
fell outside the only thing being written, and the read path had nothing to
read.

The save now carries the stack beside the preset, on both the local and the
remote path. Deliberately not merged but replaced wholesale: this is the stack
as it stands, so a layer the user deleted has to leave the file too. Merging
two devices' stacks is `Sidecar::merge`'s job and belongs to sync (FR-NC-9).

A paste still carries no masks, and the `Option` is how that is said. Settings
travel between photographs; a mask does not, because it is drawn against one
frame and describes nothing on another — and `Scope` cannot express that,
since it filters parameters and a mask is not one.

The test fails against the old save path with "the mask must come back with
the photograph", which is the whole of the defect: not a crash, not an error,
just an edit that was not there in the morning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 12:29:41 +02:00
dtourolleandClaude Opus 5 f4c7afb74c Say which version this is: 0.5.0
Build and test / Desktop (Linux) (push) Failing after 3m8s
Build and test / Layer separation (push) Successful in 1m32s
Traceability / Requirement traces (push) Failing after 2m56s
🐳 Android image / Build and push (push) Successful in 24m43s
Build and test / android-image (push) Successful in 24m45s
Build and test / Android (aarch64) (push) Failing after 6s
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.5.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 11:48:03 +02:00
dtourolleandClaude Opus 5 5807b67693 Caption a photograph with its name, not with how it is stored
A grid cell is about four words wide and `.CR2` spent one of them saying
something the photographer already knows: in a RAW library every frame ends
the same way, so the extension distinguishes nothing while taking room from
the part that does. The caption elides from the end under pressure, so an
extension can push the digits that actually identify a frame off the visible
part of its own label.

Dropped in the view, not in the model. `LibraryCell::name` keeps the true
filename and `remote_path` the full path, because both are used to find the
file again and a stem is not a filename.

The case it is wrong for, recorded rather than discovered later: a library
holding `IMG_1234.CR2` beside `IMG_1234.JPG` now shows two cells captioned
`IMG_1234`. They remain two rows with two thumbnails and two entries in the
info panel, and a RAW+JPEG pair is usually one photograph anyway — but the
caption alone no longer separates them.

Only the last dot goes, and only when something precedes it: `2026.08.23-a.dng`
keeps its dates, and `.hidden` keeps its leading dot, because that dot is how
the name starts rather than an extension.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:46:40 +02:00
dtourolleandClaude Opus 5 a8969c149e Move the instrumentation off the header and into About
The render backend, the layout class and the frame rate sat permanently in a
44px strip that also carries the only way out of develop, the undo pair, the
panel toggle and the export button. They cost about 200 logical pixels, and a
`HorizontalLayout` given less width than its children's minimums does not
shrink them — it runs off the end.

There was already a breakpoint hiding them below 820px, which is why a tablet
in portrait (768) looked fine and landscape (1200) did not: above the
breakpoint the readouts came back and pushed the header off the right-hand
edge. A breakpoint that hides a problem at one size and not another is a
workaround, and this removes the reason for it rather than moving it.

They are diagnostics — read once when something looks wrong, and then not
again — so they are in Settings under ABOUT, beside the version, which is
where someone goes when they have a bug to report rather than a photograph to
edit. The version is there for the same reason and comes from
`CARGO_PKG_VERSION`, so the line cannot disagree with the binary showing it.

The frame rate keeps its warning hue. It is the number that says whether the
zero-copy display path is holding up, which is the assumption the whole
display design rests on, and that is worth colour wherever it is shown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:41:17 +02:00
dtourolleandClaude Opus 5 7a5e1adf51 Merge what two tiles saw of one subject, instead of picking a side
"Look closer" tiles the frame so a small subject reaches a fixed 640x640 model
at its own size. A tile sees only the part of an object inside it, so an object
on a seam produces two *partial* masks — neither of them the object.

This kept the higher-scoring one and discarded the other, which quietly threw
away what tiling had just been paid 2.8 seconds for: a bird with its tail cut
off at a tile edge, described by whichever tile happened to hold more of the
bird. Both halves existed; one survived.

They are unioned now, and the overlap is what makes that sound. At 25% every
pixel is seen by at least one tile at full resolution and pixels near a seam by
two, so the pointwise maximum is the better estimate everywhere rather than a
compromise: where one tile saw a pixel its opinion is the only one there is,
and where both did, the higher value came from the tile with more context
around it. A maximum of soft coverage also stays soft, which is what `prior.rs`
weights merges by and what a mask layer's edge treatment needs.

Each quantity gets the operation that suits it: maximum for coverage, union
for the box, and the higher score rather than a blend — the score is shown to a
photographer and means "how sure the model is this is a bird", so averaging in
a tile that saw a wingtip would make a confident detection look doubtful for
straddling a seam.

The test fails against the old rule with "pixel 4 was seen by a tile and must
survive the merge", which is the whole defect in one line: not a crash, not a
duplicate, just a plausible mask missing half its subject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:14:53 +02:00
dtourolleandClaude Opus 5 5a0a9719eb Set the version once, in one place, for every artefact
The workspace read 0.1.0 for two releases, so every desktop binary reported a
version two releases stale. The APK was worse: `AndroidManifest.xml` states no
version at all, so a device showed `versionName=null` and `versionCode=0` while
the library inside the APK knew exactly what it was. A version edited by hand
in several files is a version that is wrong in at least one of them.

`tools/set-version.sh` is now the only thing that sets one. It takes the
version from the latest git tag, or is told, and writes the two files that must
state it before anything is built: the workspace `Cargo.toml`, from which every
crate inherits, and `packaging/PKGBUILD`, which pacman reads before a build
exists. It refreshes `Cargo.lock`, because members appear there by version and
CI builds `--locked`. `--commit` commits the result.

Android is not in that list on purpose. `package.sh` reads the version out of
`Cargo.toml` and hands it to `aapt2 link`, so the APK cannot drift from the
binary it contains — there is no third file to forget. `versionCode` has to be
one increasing integer, which a semantic version is not, so it is packed as
MAJOR*10000 + MINOR*100 + PATCH: ordered the way Android requires, and readable
at a glance.

A version that is not MAJOR.MINOR.PATCH is refused rather than coerced. It is
a contract with whoever reads a bug report, and silently turning "0.4" into
something else is worse than being asked to type it again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:09:03 +02:00
dtourolleandClaude Opus 5 7fcb8dc107 Give the develop column room, and the mask a way out of the way
Three faults reported together, and they share a shape: each is something the
panel decided on the photographer's behalf.

**The column was 280px on every screen.** That width was chosen for a tablet,
where the column is a large fraction of the display and every pixel of it is
taken from the photograph. On a desktop window the mode strip alone — two
modes, a separator, "All", and a chip per attribute the operation set declares
— does not fit, so it scrolled sideways. A control you have to pan to reach is
one you do not know is there. 380px when the window is classed expanded, 280px
when it is not; driven off `layout-class` because reading the window width
inside the layout that sets it is a binding loop.

**The overlay could not be hidden.** An earlier "Overlay" button was removed
for a good reason — it *armed* the overlay, so local mode could be entered and
still show nothing. Hiding is the opposite need and was never served: a mask is
judged against the photograph beneath it, and that photograph is exactly what
the overlay covers. `overlay-hidden` is kept separate from `overlay-on` so a
recompute cannot switch the overlay back on under someone who just turned it
off.

**The masks were coarse because the model saw the subject small.** The graph's
input is a fixed 640x640 and every frame is letterboxed into it, so a bird
200px across in a 1600px proxy reaches the model at 80px. `Tiling::Grid` has
been implemented and tested since the segmentation spike and defaulted off,
because it costs one inference per tile — 2.8s for a 3x2 grid against 470ms.
Now offered as "Look closer (slower)", which says what it costs, rather than
spending it on every image or on none.

The tiling choice enters the segmentation signature. A mask stores the
signature its region ids index into, and a tiled run finds different instances
in a different order; sharing a signature would silently reinterpret a layer
built against the coarse pass — a wrong mask rather than a stale one, and
nothing announces it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:54:35 +02:00
dtourolleandClaude Opus 5 ec8582032e Fetch the model CI has been building without
Both failing jobs failed for the same reason, and it was not the reason either
of them appeared to fail. Desktop reported a Clippy failure and Android
reported the API-level check; both were `dr-segment`'s build script panicking
because `models/yolo26n-seg.onnx` was a 133-byte LFS pointer rather than the
11 MB model.

No checkout step asked for LFS objects. `.gitattributes` predicted this exactly
— "a clone without git-lfs gets a ~130-byte pointer file where the model should
be" — and the build script fails loudly by design rather than embedding a
pointer and failing at inference. That design worked; nothing was reading the
message.

It stayed hidden because the Android job built `-p dr-gpu`, which never reaches
dr-segment. Building the app crate does, which is how one fix surfaced another.

Only the two jobs that compile get `lfs: true`. `layering` runs `cargo tree`
and builds nothing, so it has no reason to fetch 11 MB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:44:09 +02:00
dtourolleandClaude Opus 5 b7bd2356be Regenerate the traceability matrix
The gate regenerates it and fails if the result differs from what is
committed, which is what it is for: a matrix that disagrees with the tree is
worse than none, because it is read as current.

Stale since the detail stage landed — 160 files and 436 tags then against 180
and 521 now. Coverage 49.1% to 50.3%, and no orphan tags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:26:59 +02:00
dtourolleandClaude Opus 5 e25f0ad2c6 Let the launcher find an icon for the window
The icon was already embedded and had been all along: `app.slint` sets it and
`build.rs` compiles it into the binary with `EmbedFiles`. That is not what a
Wayland compositor looks at. It ignores a client-set icon entirely, matches
the surface's `app_id` against installed `.desktop` files, and takes the icon
from the one whose basename agrees. Nothing set an app id and no desktop entry
existed, so GNOME had nothing to resolve and drew the placeholder.

Both halves are needed and neither substitutes for the other: the embedded
icon is what X11 and the window itself use, and the desktop entry is what the
overview, the dash and alt-tab use.

The app id, the `.desktop` basename and the installed icon's filename are one
string in three places — `paris.tourolle.darkroom`, the same reverse-DNS name
the Android manifest already uses, because it is one application. Change one
without the others and the icon disappears again with nothing logged, so each
file says so where the string appears.

The PKGBUILD builds from the checkout rather than a release tarball, so
`makepkg -si` installs what is being worked on. Install verified by inspection
of the built package: binary, desktop entry, and the icon under
hicolor/256x256 by the name the entry asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 10:26:04 +02:00
dtourolleandClaude Opus 5 2a4a48c0fb Release 0.4.0
Build and test / Desktop (Linux) (push) Failing after 8m39s
Build and test / Layer separation (push) Successful in 26s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 1m4s
Build and test / Android (aarch64) (push) Failing after 22m44s
Not a patch: this carries a data-loss fix and a feature.

Collection membership merged on `images.content_hash`, which the schema
computes only for import dedup and reconnect — never in a scan. On a synced
library it is NULL for every image, so the union matched nothing and the
catalog push that follows a merge overwrote whatever the other device had
uploaded. Collections arrived named and empty on every device, and each sync
destroyed the other's membership. Keyed on `oc:fileid` now, which every scanned
image has. Verified on a 23,000-image library: 247 memberships recovered, and a
round trip between tablet and desktop confirmed in both directions.

Card import (FR-CAT-10, FR-CAT-11, FR-NC-7a, FR-NC-7b) arrives with it: the
ingest engine, the write half of the storage abstraction, removable-volume
detection, duplicate detection in two tiers, dated folders on both sides, and
thumbnails made while the originals are still in hand. Uploads always go to the
library on the server; what lands on this machine is a staging copy the app
removes once the upload is confirmed, and a move-import empties the card only
of photographs the server has acknowledged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:23:53 +02:00
dtourolleandClaude Opus 5 85aae25d0f Let the header readouts shrink instead of pushing the controls off screen
Found uncommitted in the working tree and committed as its own change rather
than folded into the sync work beside it: the three header readouts gain
`overflow: elide` and `horizontal-stretch: 1`, which is what actually lets a
Text shrink under pressure in Slint. Without the stretch they kept their full
natural width and pushed the sidebar toggle and the More button past the right
edge on a narrow window, reachable only by knowing to flick-scroll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 09:22:30 +02:00
dtourolleandClaude Opus 5 11a63fc7a8 Refresh the collection tree when a sync brings membership
New-York gained 127 photographs from the tablet and the sidebar went on showing
no number beside it.

Two reasons, and the second is why the first was never noticed. The sync's
completion handler reloads the grid and never rebuilds the tree — the scan
already does, and the sync merges the same tables. And the condition it reloads
under was `collections_gained > 0`, which counts collections, not members: a
sync that files 127 photographs into a collection both devices already had
gains no collection at all, so the count was zero and nothing refreshed.

`SyncReport` now carries `members_gained` from the merge report, and either one
rebuilds the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 22:10:40 +02:00
dtourolleandClaude Opus 5 1297e3259b Stop claiming the app crates cannot cross-compile
The note above the core-crate check said the UI and app crates would join
"once the Android shell exists". They already had. Building `darkroom-android`
for aarch64 takes 4m23s and produces `libdarkroom.so`, linked for Android 28 —
which is what the step below now checks, and what a device installs.

Rewritten to say what the core check is actually for: a fast, link-free gate
that fails early and names a smaller suspect, ahead of the full build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:23:52 +02:00
dtourolleandClaude Sonnet 5 d78c9a27fd Exit desktop process immediately after window close
window.run() returning unwinds through main, which races a still-live
zbus/keyring background thread's TLS teardown and aborts with
"thread local ... during or after destruction". Skip that unwind with
a hard exit once the run succeeds; dr_ui::run itself is left alone
since the Android entry point also calls it and must not be exited.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:20:48 +02:00
dtourolleandClaude Opus 5 89f0f1fb4b Merge branch 'fix/collection-members-by-fileid'
Collection membership merged on a column that is NULL on every scanned
library, so collections synced their names and arrived empty everywhere.
Keyed on oc:fileid now, as keyword assignment already was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:20:27 +02:00
dtourolleandClaude Opus 5 83528c068a Check the API level of an object a linker actually produced
The step built `-p dr-gpu` and then looked for a `*.so` to read the API level
out of with `file`. dr-gpu declares no `crate-type`, so it produces an rlib —
an archive of object files no linker has touched, and about which `file` has
nothing to say. The step could therefore only ever reach its own "no aarch64
.so was produced" branch, whatever the linker did, which is the one thing it
exists to rule out.

Builds `darkroom-android` instead: the workspace's only
`crate-type = ["cdylib"]`, and the artefact that ships in the APK. The API
level verified is now the one a device will refuse to install against.

The neighbouring comment claiming only core crates cross-compile is left
alone until the build proves otherwise; it is either stale or this commit is
wrong, and the same run answers both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:19:46 +02:00
dtourolleandClaude Opus 5 67f15bffb7 Key collection membership on the identity that exists
Collections synced their names and arrived empty on every device. The names are
keyed on a uuid and worked; the membership union was keyed on
`images.content_hash`, and the schema says plainly what that column is:
"computed only when something needs it (import dedup, reconnect-by-hash), never
in a scan". A library that has only ever been scanned has one for no image at
all, so the join matched nothing and `WHERE ri.content_hash IS NOT NULL`
discarded whatever survived. The union could never have moved a single row.

Measured on a real catalog: 23,174 images, content hashes for 0 of them,
`oc:fileid` for all 23,174, twelve collections, zero members.

So membership now resolves through the file id first, exactly as keyword
assignment already did — `ASSIGN_BY_FILE_ID` was added for this same reason and
its doc comment even notes that membership was still on the hash. It is
recorded for every image the moment a remote scan sees it, survives server-side
rename and move (FR-NC-5), is the same integer on every device pointed at one
Nextcloud, and is already what the thumbnail shards are keyed by.

The content-hash union is kept rather than replaced: a local-only library has no
`remote` rows, and where a hash has been computed it is a true identity that
survives a library moving between servers. Both statements run; `INSERT OR
IGNORE` against the primary key makes the overlap free.

This repairs the merge. It cannot invent membership that no device recorded —
where the rows were never written, collections stay empty until they are filled
in again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:19:13 +02:00
dtourolleandClaude Opus 5 c75849040c Format the tree the way the gate asks for it
`cargo fmt --check` is a required step and had drifted across 45 files. Most of
it arrived this week: several operations were written in parallel worktrees and
merged by hand, and a hand-merge resolves conflicts without ever running the
formatter over the result.

No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its
own commit so the next reader can skip it wholesale rather than search it for
one that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:16:34 +02:00
dtourolleandClaude Opus 5 d04087af83 Import into the library, which is on the server
There was a local destination, defaulting to ~/Pictures, and an "upload" switch
that could be turned off. That was wrong twice over. DarkRoom's library *is* a
folder on a Nextcloud server (FR-NC-6) — there is no local library — so a
user-chosen local destination built a second pile of photographs that no view
in the application ever lists, and made "where did my import go" a question
with two answers.

An import now has exactly one destination and the page asks nothing about it.
With no account there is nowhere to go at all, so Import is refused rather than
quietly filling a folder.

What lands on the device is a staging copy in a directory the app owns, the
same shape `export` uses for its outbox and for the reason its module docs
give: staging first is the only path, not a fallback for being offline. The
bytes have to reach disk before the network — streaming a card straight to the
server would let a move-import erase a card against an in-flight upload, and
would make importing impossible with no connection (FR-NC-10). A staged file is
removed once the server confirms it; one that is not confirmed stays queued, and
the next import drains it.

And the rule that was stated but never enforced: `retirable` was reported and
`dr_ingest::retire` was never called by anything, so a move-import silently
behaved as a copy. The card is now emptied by the worker, of exactly those
photographs the *server* has confirmed — not those merely written here, because
the staging copy is removed moments later and anything unconfirmed would then
exist nowhere at all.

FR-NC-7b said "copied locally first ... then queued for upload", which is a
staging area; it has been rewritten to say so in terms that do not also permit
what was built.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:09:26 +02:00
76 changed files with 4330 additions and 668 deletions
+43 -3
View File
@@ -29,7 +29,18 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
# `lfs: true` is load-bearing, not hygiene. `core/dr-segment/models/*.onnx`
# is tracked in LFS (see .gitattributes), and a checkout without it writes
# a ~130-byte pointer where the 11 MB model should be. `dr-segment`'s build
# script detects that and panics by design, so every job that compiles it
# fails at the build script with a message about git-lfs — which reads like
# a broken build rather than a missing fetch.
#
# It stayed hidden while the Android job built `-p dr-gpu`, which does not
# reach dr-segment. Building the app crate does.
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
lfs: true
- name: Cache cargo - name: Cache cargo
uses: actions/cache@v4 uses: actions/cache@v4
@@ -84,7 +95,18 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
# `lfs: true` is load-bearing, not hygiene. `core/dr-segment/models/*.onnx`
# is tracked in LFS (see .gitattributes), and a checkout without it writes
# a ~130-byte pointer where the 11 MB model should be. `dr-segment`'s build
# script detects that and panics by design, so every job that compiles it
# fails at the build script with a message about git-lfs — which reads like
# a broken build rather than a missing fetch.
#
# It stayed hidden while the Android job built `-p dr-gpu`, which does not
# reach dr-segment. Building the app crate does.
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
lfs: true
- name: Cache cargo - name: Cache cargo
uses: actions/cache@v4 uses: actions/cache@v4
@@ -94,8 +116,15 @@ jobs:
target-android target-android
key: android-${{ hashFiles('**/Cargo.lock') }} key: android-${{ hashFiles('**/Cargo.lock') }}
# Only the core crates cross-compile today; the UI and app crates join # A fast gate on the crates most likely to break the cross-compile, run
# once the Android shell exists (milestone v0.1, FR-PLAT-AND-*). # before the expensive part. It is `cargo check`, so it type-checks
# without linking and returns in a fraction of the time the step below
# takes.
#
# Not a statement that only these crates cross-compile — `darkroom-android`
# and the whole UI stack beneath it build for aarch64 too, which is what
# the API-level step below does. This one exists to fail fast and name a
# smaller suspect when it does.
- name: Cross-compile core - name: Cross-compile core
env: env:
CARGO_TARGET_DIR: target-android CARGO_TARGET_DIR: target-android
@@ -117,7 +146,18 @@ jobs:
CARGO_TARGET_DIR: target-android CARGO_TARGET_DIR: target-android
run: | run: |
set -e set -e
cargo ndk -t arm64-v8a build -p dr-gpu --release # `darkroom-android`, not a core crate: this step reads the API level
# out of a *linked* object, and only that crate produces one. It is
# the workspace's single `crate-type = ["cdylib"]`; a library crate
# builds an rlib, which is an archive of object files that no linker
# has yet touched and that `file` therefore has nothing to say about.
# Asking for `-p dr-gpu` here could only ever reach the "no aarch64
# .so was produced" branch below, whatever the linker did.
#
# It is also the honest artefact to check: the .so this names is the
# one that ships in the APK, so the API level verified here is the
# API level a device will refuse to install against.
cargo ndk -t arm64-v8a build -p darkroom-android --release
MIN_API=$(sed -n 's/^ARG MIN_API=\([0-9]*\).*/\1/p' docker/android/Dockerfile) MIN_API=$(sed -n 's/^ARG MIN_API=\([0-9]*\).*/\1/p' docker/android/Dockerfile)
# Empty on both sides would compare equal and pass, so neither side # Empty on both sides would compare equal and pass, so neither side
# is allowed to be the result of a failed parse. # is allowed to be the result of a failed parse.
+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
+9
View File
@@ -2,3 +2,12 @@
/target-android /target-android
Cargo.lock.bak Cargo.lock.bak
*.log *.log
# makepkg build products. `packaging/PKGBUILD` and the .desktop entry are
# sources and belong in the tree; everything makepkg derives from them does
# not — `pkg/` and `src/` are staging directories it recreates on every run,
# and the package itself is 33 MB of compiled output.
/packaging/pkg/
/packaging/src/
/packaging/*.pkg.tar.*
/packaging/*.log
Generated
+17 -17
View File
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]] [[package]]
name = "darkroom-android" name = "darkroom-android"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"dr-sync-nextcloud", "dr-sync-nextcloud",
@@ -1232,7 +1232,7 @@ dependencies = [
[[package]] [[package]]
name = "darkroom-desktop" name = "darkroom-desktop"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dr-ui", "dr-ui",
@@ -1404,7 +1404,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]] [[package]]
name = "dr-catalog" name = "dr-catalog"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"dr-plat", "dr-plat",
"dr-types", "dr-types",
@@ -1417,7 +1417,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-decode" name = "dr-decode"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"env_logger", "env_logger",
@@ -1431,7 +1431,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-export" name = "dr-export"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"dr-decode", "dr-decode",
"dr-gpu", "dr-gpu",
@@ -1449,7 +1449,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-gpu" name = "dr-gpu"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"dr-decode", "dr-decode",
@@ -1465,7 +1465,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-ingest" name = "dr-ingest"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"dr-plat", "dr-plat",
"dr-types", "dr-types",
@@ -1477,7 +1477,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-lens" name = "dr-lens"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"lensfun", "lensfun",
"log", "log",
@@ -1485,7 +1485,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-pipeline" name = "dr-pipeline"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"log", "log",
@@ -1494,7 +1494,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-plat" name = "dr-plat"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"android-native-keyring-store", "android-native-keyring-store",
"dr-types", "dr-types",
@@ -1507,7 +1507,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-segment" name = "dr-segment"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"env_logger", "env_logger",
"log", "log",
@@ -1520,7 +1520,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync" name = "dr-sync"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-types", "dr-types",
@@ -1531,7 +1531,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-sync-nextcloud" name = "dr-sync-nextcloud"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dr-decode", "dr-decode",
@@ -1552,7 +1552,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-thumbs" name = "dr-thumbs"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"dr-types", "dr-types",
"jpeg-encoder", "jpeg-encoder",
@@ -1564,7 +1564,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-types" name = "dr-types"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -1573,7 +1573,7 @@ dependencies = [
[[package]] [[package]]
name = "dr-ui" name = "dr-ui"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dr-catalog", "dr-catalog",
@@ -6896,7 +6896,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]] [[package]]
name = "traceability" name = "traceability"
version = "0.3.0" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",
+1 -1
View File
@@ -21,7 +21,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.3.0" version = "0.6.0"
edition = "2021" edition = "2021"
rust-version = "1.92" rust-version = "1.92"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
+8 -1
View File
@@ -17,5 +17,12 @@ fn main() -> anyhow::Result<()> {
eprintln!("usage: darkroom-desktop <file-or-directory>..."); eprintln!("usage: darkroom-desktop <file-or-directory>...");
} }
dr_ui::run(paths) dr_ui::run(paths)?;
// Skip Rust's normal static/thread-local teardown on the way out: a
// background zbus/keyring connection opened by dr_ui::launch_ui can
// still be alive here, and unwinding through it races its async-io
// reactor thread, panicking with "thread local ... during or after
// destruction" when the window is closed.
std::process::exit(0);
} }
+108 -7
View File
@@ -114,13 +114,90 @@ impl Granularity {
} }
/// A sensible bucket size for a span of seconds, so the UI need not guess. /// A sensible bucket size for a span of seconds, so the UI need not guess.
///
/// # Chosen by how many bars it produces, not by fixed cut-offs
///
/// This used to be four thresholds on the span, which reads sensibly and
/// behaves badly under zoom. Each zoom step halves the span, so the bar
/// count halves with it until a threshold is crossed — a fifteen-year
/// library went 15 bars, 8, then 46, 23, 11, and finally *6*. Zooming in
/// made the picture coarser, which is the opposite of what zooming is for.
///
/// So the choice is made on the axis's terms: of the four bucket sizes,
/// take the one whose bar count comes nearest [`Self::TARGET_BARS`]. The
/// count then stays in the same neighbourhood at every zoom level, and
/// each step in genuinely shows finer structure rather than the same
/// structure drawn wider.
///
/// Nearest in *ratio*, not in difference: the counts available for a given
/// span are orders of magnitude apart — a span is either about 4 years or
/// 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 { 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,
// and more detail is the better failure.
[
Granularity::Hour,
Granularity::Day,
Granularity::Month,
Granularity::Year,
]
.into_iter()
.min_by(|a, b| {
let cost = |g: Granularity| {
// How far off, measured multiplicatively: twice as long and
// half as long are equally wrong.
//
// 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))
// Ties cannot arise from real spans, but a NaN would; falling
// back to the coarser option keeps the axis drawable.
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(Granularity::Day)
}
/// How many bars the timeline wants across its axis.
///
/// Not a hard count — the bucket sizes are calendar units, so the actual
/// number lands where the calendar puts it. It is the figure the choice
/// aims at: enough bars that a busy fortnight is visibly busier than a
/// quiet one, few enough that each is wide enough to hit with a finger.
const TARGET_BARS: i64 = 40;
/// Nominal length of one bucket, for choosing between them.
///
/// Approximate on purpose: months and years vary and it does not matter
/// here, because this only ranks four options that are a factor of ~12 or
/// ~30 apart. The exact boundaries come from `strftime` on the real dates.
fn approx_seconds(self) -> i64 {
const DAY: i64 = 86_400; const DAY: i64 = 86_400;
match seconds { match self {
s if s > 5 * 365 * DAY => Granularity::Year, Granularity::Year => 365 * DAY,
s if s > 90 * DAY => Granularity::Month, Granularity::Month => 30 * DAY,
s if s > 2 * DAY => Granularity::Day, Granularity::Day => DAY,
_ => Granularity::Hour, Granularity::Hour => 3600,
} }
} }
} }
@@ -422,10 +499,34 @@ mod tests {
#[test] #[test]
fn timeline_granularity_follows_the_span() { fn timeline_granularity_follows_the_span() {
const DAY: i64 = 86_400; const DAY: i64 = 86_400;
assert_eq!(Granularity::for_span(10 * 365 * DAY), Granularity::Year); // Chosen by how many bars it makes, not by fixed cut-offs — see
assert_eq!(Granularity::for_span(120 * DAY), Granularity::Month); // `for_span`. Ten years of yearly bars is ten bars, which says almost
// nothing about a library; monthly is 122, which is a shape.
assert_eq!(Granularity::for_span(10 * 365 * DAY), Granularity::Month);
assert_eq!(Granularity::for_span(120 * DAY), Granularity::Day);
assert_eq!(Granularity::for_span(10 * DAY), Granularity::Day); assert_eq!(Granularity::for_span(10 * DAY), Granularity::Day);
assert_eq!(Granularity::for_span(3600), Granularity::Hour); assert_eq!(Granularity::for_span(3600), Granularity::Hour);
// The property the target exists for: zooming in never coarsens the
// axis. Under the old thresholds a fifteen-year library went 15 bars,
// then 8, then 46, 23, 11 — finer spans drawn with wider bars.
let mut span = 15 * 365 * DAY;
let mut previous = Granularity::for_span(span).approx_seconds();
for _ in 0..10 {
span /= 2;
let bucket = Granularity::for_span(span).approx_seconds();
assert!(
bucket <= previous,
"halving the span to {span}s coarsened the bucket \
from {previous}s to {bucket}s"
);
previous = bucket;
}
// And a span shorter than any bucket still picks the finest, rather
// than every option tying at one bar and the coarsest winning.
assert_eq!(Granularity::for_span(60), Granularity::Hour);
assert_eq!(Granularity::for_span(1), Granularity::Hour);
} }
#[test] #[test]
+177 -19
View File
@@ -383,30 +383,70 @@ fn merge_collections_within(tx: &Connection, report: &mut MergeReport) -> Result
// ---- membership ------------------------------------------------------- // ---- membership -------------------------------------------------------
// //
// Set union, keyed on (collection uuid, image content hash). The hash // Set union, keyed on (collection uuid, image identity). Not the image id,
// rather than the image id, for the same reason collections use a uuid: // for the same reason collections use a uuid: image ids are local. An image
// image ids are local. An image the remote has and we do not is skipped — // the remote has and we do not is skipped — it will join when a scan or
// it will join when a scan or sync catalogues it, and the next merge picks // sync catalogues it, and the next merge picks it up.
// it up. //
// Both identities are tried, and the file id first. See
// [`MEMBERS_BY_FILE_ID`] for why keying on the content hash alone made this
// whole union a no-op on every ordinary library.
// //
// Tombstoned collections are excluded, or a merge would repopulate a // Tombstoned collections are excluded, or a merge would repopulate a
// collection it had just deleted. // collection it had just deleted.
let added = tx.execute( let added = tx.execute(MEMBERS_BY_FILE_ID, [])? + tx.execute(MEMBERS_BY_CONTENT_HASH, [])?;
"INSERT OR IGNORE INTO main.collection_members(collection_id, image_id, position, added)
SELECT lc.id, li.id, rm.position, rm.added
FROM remote_cat.collection_members rm
JOIN remote_cat.collections rc ON rc.id = rm.collection_id
JOIN main.collections lc ON lc.uuid = rc.uuid AND lc.deleted = 0
JOIN remote_cat.images ri ON ri.id = rm.image_id
JOIN main.images li ON li.content_hash = ri.content_hash
WHERE ri.content_hash IS NOT NULL",
[],
)?;
report.members_added = added; report.members_added = added;
Ok(()) Ok(())
} }
/// Take membership for images both devices know by the server's file id.
///
/// **This is the identity that exists.** Membership was keyed on
/// `images.content_hash` alone, and the schema is explicit that the column is
/// "computed only when something needs it (import dedup, reconnect-by-hash),
/// never in a scan" — so on an ordinary library it is NULL for every row, the
/// join matched nothing, and `WHERE ri.content_hash IS NOT NULL` discarded what
/// little was left. Collections synced their names, because those are keyed on
/// a uuid, and arrived empty on every device. A 23,000-image library had a
/// content hash for none of them and an `oc:fileid` for all of them.
///
/// `oc:fileid` is recorded for every image the moment a remote scan sees it, is
/// stable across server-side rename and move (FR-NC-5), and is the same integer
/// on every device pointed at the same Nextcloud — which is exactly the
/// situation where two devices share collections. It is already what the
/// thumbnail shards are keyed by, and what [`ASSIGN_BY_FILE_ID`] uses for
/// keywords; membership was the one thing left behind.
const MEMBERS_BY_FILE_ID: &str = "
INSERT OR IGNORE INTO main.collection_members(collection_id, image_id, position, added)
SELECT lc.id, li.id, rm.position, rm.added
FROM remote_cat.collection_members rm
JOIN remote_cat.collections rc ON rc.id = rm.collection_id
JOIN main.collections lc ON lc.uuid = rc.uuid AND lc.deleted = 0
JOIN remote_cat.remote rr ON rr.image_id = rm.image_id
JOIN main.remote lr ON lr.file_id = rr.file_id
JOIN main.images li ON li.id = lr.image_id";
/// The same union for a library with no server behind it.
///
/// A local-only library has no `remote` rows at all, so [`MEMBERS_BY_FILE_ID`]
/// matches nothing and the content hash is the only identity available. Kept
/// rather than replaced: where a hash *has* been computed — an imported card,
/// a reconnect — it is a true identity, and one that survives a library moving
/// between servers.
///
/// Both statements run. `INSERT OR IGNORE` against the
/// `(collection_id, image_id)` primary key makes the overlap free.
const MEMBERS_BY_CONTENT_HASH: &str = "
INSERT OR IGNORE INTO main.collection_members(collection_id, image_id, position, added)
SELECT lc.id, li.id, rm.position, rm.added
FROM remote_cat.collection_members rm
JOIN remote_cat.collections rc ON rc.id = rm.collection_id
JOIN main.collections lc ON lc.uuid = rc.uuid AND lc.deleted = 0
JOIN remote_cat.images ri ON ri.id = rm.image_id
JOIN main.images li ON li.content_hash = ri.content_hash
WHERE ri.content_hash IS NOT NULL";
/// Schema name the downloaded remote catalog is attached under. /// Schema name the downloaded remote catalog is attached under.
/// ///
/// Repeated from [`crate::sync`] rather than shared, because the SQL below /// Repeated from [`crate::sync`] rather than shared, because the SQL below
@@ -615,9 +655,9 @@ const ASSIGN_BY_FILE_ID: &str = "
/// The same union for a library with no server behind it. /// The same union for a library with no server behind it.
/// ///
/// A local-only library has no `remote` rows at all, so [`ASSIGN_BY_FILE_ID`] /// A local-only library has no `remote` rows at all, so [`ASSIGN_BY_FILE_ID`]
/// matches nothing and this is the only identity available — and it is the one /// matches nothing and this is the only identity available — the same pair
/// collection membership already uses, so a library where membership merges /// collection membership uses, so a library where membership merges has
/// has keywords that merge too. /// keywords that merge too.
const ASSIGN_BY_CONTENT_HASH: &str = " const ASSIGN_BY_CONTENT_HASH: &str = "
INSERT OR IGNORE INTO main.keywords(version_id, keyword) INSERT OR IGNORE INTO main.keywords(version_id, keyword)
SELECT lv.id, rk.keyword SELECT lv.id, rk.keyword
@@ -741,6 +781,43 @@ mod tests {
c c
} }
/// An image with no content hash — which is every image in a library that
/// has only ever been scanned.
fn add_image_without_hash(c: &Connection, db: &str, id: i64) {
c.execute(
&format!(
"INSERT INTO {db}.roots(id, kind, label) VALUES (1, 'remote', 'lib')
ON CONFLICT(id) DO NOTHING"
),
[],
)
.unwrap();
c.execute(
&format!(
"INSERT INTO {db}.images(id, root_id, source_ref, added_at)
VALUES (?1, 1, ?2, 0)"
),
rusqlite::params![id, format!("img{id}.CR3")],
)
.unwrap();
}
fn count(c: &Connection, sql: &str) -> i64 {
c.query_row(sql, [], |r| r.get(0)).unwrap()
}
/// Give an image the `oc:fileid` a remote scan records for it.
///
/// What every image in a real synced library has, and what none of them
/// has a content hash for.
fn add_remote_id(c: &Connection, db: &str, image_id: i64, file_id: i64) {
c.execute(
&format!("INSERT INTO {db}.remote(image_id, file_id) VALUES (?1, ?2)"),
rusqlite::params![image_id, file_id],
)
.unwrap();
}
fn add_image(c: &Connection, db: &str, id: i64, hash: &str) { fn add_image(c: &Connection, db: &str, id: i64, hash: &str) {
c.execute( c.execute(
&format!( &format!(
@@ -910,6 +987,87 @@ mod tests {
assert_eq!(n, 2, "both devices' additions survive"); assert_eq!(n, 2, "both devices' additions survive");
} }
#[test]
fn a_library_that_never_computed_a_hash_still_merges_membership() {
// The bug this replaces. `content_hash` is computed only by import
// dedup or a reconnect — never by a scan — so a synced library has one
// for no image at all. Keying membership on it alone meant collections
// arrived with their names and none of their contents, on every device,
// for every user.
let c = two_catalogs();
add_collection(&c, "main", 1, "shared", "Trip", 1);
add_collection(&c, "remote_cat", 1, "shared", "Trip", 1);
// No hashes anywhere, and a file id for both — a real library.
add_image_without_hash(&c, "main", 77);
add_image_without_hash(&c, "remote_cat", 3);
add_remote_id(&c, "main", 77, 90210);
add_remote_id(&c, "remote_cat", 3, 90210);
c.execute(
"INSERT INTO remote_cat.collection_members(collection_id, image_id, added)
VALUES (1, 3, 0)",
[],
)
.unwrap();
let report = merge_collections(&c).unwrap();
assert_eq!(report.members_added, 1);
let img: i64 = c
.query_row("SELECT image_id FROM main.collection_members", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(img, 77, "resolved through the server's file id");
}
#[test]
fn a_different_photograph_on_the_server_is_not_adopted() {
// The file id is an identity, so two different ids must not join.
let c = two_catalogs();
add_collection(&c, "main", 1, "shared", "Trip", 1);
add_collection(&c, "remote_cat", 1, "shared", "Trip", 1);
add_image_without_hash(&c, "main", 77);
add_image_without_hash(&c, "remote_cat", 3);
add_remote_id(&c, "main", 77, 111);
add_remote_id(&c, "remote_cat", 3, 222);
c.execute(
"INSERT INTO remote_cat.collection_members(collection_id, image_id, added)
VALUES (1, 3, 0)",
[],
)
.unwrap();
let report = merge_collections(&c).unwrap();
assert_eq!(report.members_added, 0);
assert_eq!(count(&c, "SELECT COUNT(*) FROM main.collection_members"), 0);
}
#[test]
fn an_image_identified_both_ways_is_added_once() {
// Both statements run, and their overlap must be free rather than a
// constraint violation or a double count.
let c = two_catalogs();
add_collection(&c, "main", 1, "shared", "Trip", 1);
add_collection(&c, "remote_cat", 1, "shared", "Trip", 1);
add_image(&c, "main", 77, "same-photo");
add_image(&c, "remote_cat", 3, "same-photo");
add_remote_id(&c, "main", 77, 90210);
add_remote_id(&c, "remote_cat", 3, 90210);
c.execute(
"INSERT INTO remote_cat.collection_members(collection_id, image_id, added)
VALUES (1, 3, 0)",
[],
)
.unwrap();
merge_collections(&c).unwrap();
assert_eq!(count(&c, "SELECT COUNT(*) FROM main.collection_members"), 1);
}
#[test] #[test]
fn membership_maps_across_devices_by_content_hash() { fn membership_maps_across_devices_by_content_hash() {
// The same photograph carries different integer ids on each device. // The same photograph carries different integer ids on each device.
+4 -15
View File
@@ -487,7 +487,8 @@ mod tests {
// And the last span is shallower than the one before it, which is // And the last span is shallower than the one before it, which is
// what a shoulder *is*. Without one the curve clips highlights // what a shoulder *is*. Without one the curve clips highlights
// harder than the linear rendering did. // harder than the linear rendering did.
let slope = |i: usize| (curve.ys[i + 1] - curve.ys[i]) / (curve.xs[i + 1] - curve.xs[i]); let slope =
|i: usize| (curve.ys[i + 1] - curve.ys[i]) / (curve.xs[i + 1] - curve.xs[i]);
assert!( assert!(
slope(POINTS - 2) < slope(POINTS - 3), slope(POINTS - 2) < slope(POINTS - 3),
"{name} has no highlight shoulder" "{name} has no highlight shoulder"
@@ -502,13 +503,7 @@ mod tests {
// as a dark halo in a gradient, which reads as a rendering fault // as a dark halo in a gradient, which reads as a rendering fault
// rather than as a bad profile. // rather than as a bad profile.
assert_eq!( assert_eq!(
BaseCurve::from_points(&[ BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.4], [0.5, 0.3], [0.75, 0.8], [1.0, 1.0]]),
[0.0, 0.0],
[0.25, 0.4],
[0.5, 0.3],
[0.75, 0.8],
[1.0, 1.0]
]),
None None
); );
} }
@@ -540,13 +535,7 @@ mod tests {
// point above 1.0 would put the shoulder outside the range the curve // point above 1.0 would put the shoulder outside the range the curve
// is defined over and silently flatten everything below it. // is defined over and silently flatten everything below it.
assert_eq!( assert_eq!(
BaseCurve::from_points(&[ BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.3], [0.5, 1.4], [0.75, 1.5], [1.0, 1.6]]),
[0.0, 0.0],
[0.25, 0.3],
[0.5, 1.4],
[0.75, 1.5],
[1.0, 1.6]
]),
None None
); );
} }
+8 -2
View File
@@ -375,7 +375,10 @@ fn rawler_location(gps: &rawler::exif::ExifGPS) -> Option<Location> {
(r.d != 0).then(|| r.n as f64 / r.d as f64) (r.d != 0).then(|| r.n as f64 / r.d as f64)
} }
fn degrees(dms: &[rawler::formats::tiff::Rational; 3], reference: Option<&String>) -> Option<f64> { fn degrees(
dms: &[rawler::formats::tiff::Rational; 3],
reference: Option<&String>,
) -> Option<f64> {
let d = ratio(&dms[0])? + ratio(&dms[1])? / 60.0 + ratio(&dms[2])? / 3600.0; let d = ratio(&dms[0])? + ratio(&dms[1])? / 60.0 + ratio(&dms[2])? / 3600.0;
// South and west are stored as positive magnitudes with a letter. // South and west are stored as positive magnitudes with a letter.
let negative = matches!( let negative = matches!(
@@ -534,7 +537,10 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage, DecodeError> {
// both callers ask the same object for it. // both callers ask the same object for it.
let profile = profile::CameraProfile::extract(&image, &dng); let profile = profile::CameraProfile::extract(&image, &dng);
let color_matrix = profile.as_ref().and_then(|p| p.cam_to_srgb()); let color_matrix = profile.as_ref().and_then(|p| p.cam_to_srgb());
let wb_coeffs = sane_wb(image.wb_coeffs, profile.as_ref().map(|p| p.xyz_to_cam()).as_ref()); let wb_coeffs = sane_wb(
image.wb_coeffs,
profile.as_ref().map(|p| p.xyz_to_cam()).as_ref(),
);
// The rendering half of the profile (FR-DEV-3e). rawler's cleaned strings // The rendering half of the profile (FR-DEV-3e). rawler's cleaned strings
// are preferred where it has them — they are what the shipped database is // are preferred where it has them — they are what the shipped database is
+5 -2
View File
@@ -678,7 +678,8 @@ fn read_gps_entries(r: &TiffReader, entries: &[Entry]) -> Option<dr_types::Locat
// still writes the entry. // still writes the entry.
let degrees = |tag: u16, ref_tag: u16| -> Option<f64> { let degrees = |tag: u16, ref_tag: u16| -> Option<f64> {
let e = find(tag)?; let e = find(tag)?;
let d = r.rational(e, 0)? + r.rational(e, 1).unwrap_or(0.0) / 60.0 let d = r.rational(e, 0)?
+ r.rational(e, 1).unwrap_or(0.0) / 60.0
+ r.rational(e, 2).unwrap_or(0.0) / 3600.0; + r.rational(e, 2).unwrap_or(0.0) / 3600.0;
// The magnitude is unsigned; the hemisphere is a letter beside it. // The magnitude is unsigned; the hemisphere is a letter beside it.
let south_or_west = find(ref_tag) let south_or_west = find(ref_tag)
@@ -1412,7 +1413,9 @@ pub fn defects(tiff_data: &[u8]) -> Defects {
/// skip one this build does not implement rather than abandoning the list — /// skip one this build does not implement rather than abandoning the list —
/// and lists mixing a warp with a defect map are ordinary. /// and lists mixing a warp with a defect map are ordinary.
fn read_opcode_list(bytes: &[u8], out: &mut Defects) { fn read_opcode_list(bytes: &[u8], out: &mut Defects) {
let Some(count) = be_u32(bytes, 0) else { return }; let Some(count) = be_u32(bytes, 0) else {
return;
};
// A sensor has a handful of opcodes, not thousands. A huge count is a // A sensor has a handful of opcodes, not thousands. A huge count is a
// corrupt or hostile file. // corrupt or hostile file.
if count > 256 { if count > 256 {
+5 -1
View File
@@ -1048,7 +1048,11 @@ mod tests {
// would render black, which is much harder to diagnose than // would render black, which is much harder to diagnose than
// uncalibrated. // uncalibrated.
assert!(!usable(&[[0.0; 3]; 3])); assert!(!usable(&[[0.0; 3]; 3]));
assert!(!usable(&[[f32::NAN, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])); assert!(!usable(&[
[f32::NAN, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0]
]));
assert!(usable(&SIX_D_D65)); assert!(usable(&SIX_D_D65));
} }
+17 -8
View File
@@ -77,9 +77,7 @@ pub fn encode(
.map(|m| m.sanitised(settings.strip_location)); .map(|m| m.sanitised(settings.strip_location));
// JPEG and PNG take a finished block; TIFF writes the tags into its own // JPEG and PNG take a finished block; TIFF writes the tags into its own
// directory and needs the fields. // directory and needs the fields.
let block = carried let block = carried.as_ref().and_then(|m| exif::block(m, width, height));
.as_ref()
.and_then(|m| exif::block(m, width, height));
match settings.format { match settings.format {
ExportFormat::Jpeg => jpeg( ExportFormat::Jpeg => jpeg(
@@ -303,8 +301,11 @@ where
W: std::io::Write + std::io::Seek, W: std::io::Write + std::io::Seek,
K: tiff::encoder::TiffKind, K: tiff::encoder::TiffKind,
{ {
dir.write_tag(tiff::tags::Tag::Unknown(TAG_ICC_PROFILE), Undefined(profile)) dir.write_tag(
.map_err(|e| ExportError::Encode(e.to_string())) tiff::tags::Tag::Unknown(TAG_ICC_PROFILE),
Undefined(profile),
)
.map_err(|e| ExportError::Encode(e.to_string()))
} }
/// TRACES: FR-EXP-8 /// TRACES: FR-EXP-8
@@ -376,7 +377,10 @@ where
)?; )?;
} }
if let Some(f) = md.aperture.filter(|f| *f > 0.0) { if let Some(f) = md.aperture.filter(|f| *f > 0.0) {
dir.write_tag(Tag::Unknown(exif::tag::FNUMBER), Rationals(&[exif::tenths(f)]))?; dir.write_tag(
Tag::Unknown(exif::tag::FNUMBER),
Rationals(&[exif::tenths(f)]),
)?;
} }
if let Some(f) = md.focal_length.filter(|f| *f > 0.0) { if let Some(f) = md.focal_length.filter(|f| *f > 0.0) {
dir.write_tag( dir.write_tag(
@@ -935,11 +939,16 @@ mod tests {
assert_eq!(md.aperture, Some(2.8), "{format:?}"); assert_eq!(md.aperture, Some(2.8), "{format:?}");
assert_eq!(md.focal_length, Some(85.0), "{format:?}"); assert_eq!(md.focal_length, Some(85.0), "{format:?}");
let loc = md.location.unwrap_or_else(|| panic!("{format:?} lost the fix")); let loc = md
.location
.unwrap_or_else(|| panic!("{format:?} lost the fix"));
// Within a metre of where it started, which is finer than any // Within a metre of where it started, which is finer than any
// consumer receiver and far finer than the tag's own rounding. // consumer receiver and far finer than the tag's own rounding.
assert!((loc.latitude - LATITUDE).abs() < 1e-5, "{format:?} {loc:?}"); assert!((loc.latitude - LATITUDE).abs() < 1e-5, "{format:?} {loc:?}");
assert!((loc.longitude - LONGITUDE).abs() < 1e-5, "{format:?} {loc:?}"); assert!(
(loc.longitude - LONGITUDE).abs() < 1e-5,
"{format:?} {loc:?}"
);
assert_eq!(loc.altitude, Some(35.0), "{format:?}"); assert_eq!(loc.altitude, Some(35.0), "{format:?}");
} }
} }
+21 -3
View File
@@ -250,7 +250,13 @@ mod tests {
#[test] #[test]
fn png_export_produces_a_png() { fn png_export_produces_a_png() {
let out = export(&frame(32, 32), &settings(ExportFormat::Png), "a.png".into(), None).unwrap(); let out = export(
&frame(32, 32),
&settings(ExportFormat::Png),
"a.png".into(),
None,
)
.unwrap();
assert_eq!(&out.bytes[..8], b"\x89PNG\r\n\x1a\n"); assert_eq!(&out.bytes[..8], b"\x89PNG\r\n\x1a\n");
} }
@@ -270,8 +276,20 @@ mod tests {
fn a_sixteen_bit_tiff_is_larger_than_an_eight_bit_one() { fn a_sixteen_bit_tiff_is_larger_than_an_eight_bit_one() {
// Both are uncompressed RGB; the only difference is the sample width, // Both are uncompressed RGB; the only difference is the sample width,
// so this is what proves the 16-bit path is not quietly writing 8. // so this is what proves the 16-bit path is not quietly writing 8.
let eight = export(&frame(16, 16), &settings(ExportFormat::Tiff8), "a".into(), None).unwrap(); let eight = export(
let sixteen = export(&frame(16, 16), &settings(ExportFormat::Tiff16), "a".into(), None).unwrap(); &frame(16, 16),
&settings(ExportFormat::Tiff8),
"a".into(),
None,
)
.unwrap();
let sixteen = export(
&frame(16, 16),
&settings(ExportFormat::Tiff16),
"a".into(),
None,
)
.unwrap();
assert!(sixteen.bytes.len() > eight.bytes.len()); assert!(sixteen.bytes.len() > eight.bytes.len());
} }
+45 -8
View File
@@ -26,9 +26,7 @@
//! mask pinned to the viewport, a slice read from the wrong layer — shows up //! mask pinned to the viewport, a slice read from the wrong layer — shows up
//! here as an adjustment in the wrong place, and nowhere else. //! here as an adjustment in the wrong place, and nowhere else.
use dr_gpu::{ use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass, SubjectMasks};
AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass, SubjectMasks,
};
use dr_pipeline::descriptor::ParamId; use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack, Morphology}; use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack, Morphology};
use dr_pipeline::operation::compose_full; use dr_pipeline::operation::compose_full;
@@ -152,7 +150,18 @@ fn main() {
drain.feather = 0.02; drain.feather = 0.02;
pop.push(drain); pop.push(drain);
render_stack(&ctx, &source, &mut masks, &mut adjust, &pop, &alpha, pw, ph, ow, oh); render_stack(
&ctx,
&source,
&mut masks,
&mut adjust,
&pop,
&alpha,
pw,
ph,
ow,
oh,
);
write(&format!("{prefix}-colour-pop.ppm"), &adjust); write(&format!("{prefix}-colour-pop.ppm"), &adjust);
// ---- 2. lift the subject out of its background ------------------------ // ---- 2. lift the subject out of its background ------------------------
@@ -170,7 +179,18 @@ fn main() {
darker.feather = 0.03; darker.feather = 0.03;
lift.push(darker); lift.push(darker);
render_stack(&ctx, &source, &mut masks, &mut adjust, &lift, &alpha, pw, ph, ow, oh); render_stack(
&ctx,
&source,
&mut masks,
&mut adjust,
&lift,
&alpha,
pw,
ph,
ow,
oh,
);
write(&format!("{prefix}-subject-lift.ppm"), &adjust); write(&format!("{prefix}-subject-lift.ppm"), &adjust);
// ---- 3. the same edit, grown and shrunk ------------------------------- // ---- 3. the same edit, grown and shrunk -------------------------------
@@ -192,7 +212,18 @@ fn main() {
layer.morph_radius = radius; layer.morph_radius = radius;
stack.push(layer); stack.push(layer);
render_stack(&ctx, &source, &mut masks, &mut adjust, &stack, &alpha, pw, ph, ow, oh); render_stack(
&ctx,
&source,
&mut masks,
&mut adjust,
&stack,
&alpha,
pw,
ph,
ow,
oh,
);
write(&format!("{prefix}-{name}.ppm"), &adjust); write(&format!("{prefix}-{name}.ppm"), &adjust);
} }
@@ -292,12 +323,18 @@ fn render_stack(
fn fit(w: u32, h: u32, longest: u32) -> (u32, u32) { fn fit(w: u32, h: u32, longest: u32) -> (u32, u32) {
let s = (longest as f32 / w.max(h) as f32).min(1.0); let s = (longest as f32 / w.max(h) as f32).min(1.0);
(((w as f32 * s) as u32).max(1), ((h as f32 * s) as u32).max(1)) (
((w as f32 * s) as u32).max(1),
((h as f32 * s) as u32).max(1),
)
} }
fn write(path: &str, adjust: &AdjustPass) { fn write(path: &str, adjust: &AdjustPass) {
let (rgba, w, h) = adjust.export_pixels().expect("readback"); let (rgba, w, h) = adjust.export_pixels().expect("readback");
let rgb: Vec<u8> = rgba.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); let rgb: Vec<u8> = rgba
.chunks_exact(4)
.flat_map(|p| [p[0], p[1], p[2]])
.collect();
write_ppm(path, &rgb, w, h); write_ppm(path, &rgb, w, h);
} }
+1 -1
View File
@@ -12,8 +12,8 @@
//! PPM for the same reason `develop` uses it: no encoder dependency, and //! PPM for the same reason `develop` uses it: no encoder dependency, and
//! every viewer reads it. This is a diagnostic, not an export path. //! every viewer reads it. This is a diagnostic, not an export path.
use dr_segment::{MergeTree, RegionField};
use dr_gpu::{DemosaicedImage, Demosaicer, GpuContext, SegmentOptions, SegmentPass}; use dr_gpu::{DemosaicedImage, Demosaicer, GpuContext, SegmentOptions, SegmentPass};
use dr_segment::{MergeTree, RegionField};
/// The ladder the example dumps. Chosen to span "far too fine to be useful" /// The ladder the example dumps. Chosen to span "far too fine to be useful"
/// through "one or two objects", because both ends are informative: if no rung /// through "one or two objects", because both ends are informative: if no rung
+8 -8
View File
@@ -589,14 +589,14 @@ impl AdjustPass {
}); });
if !reuse { if !reuse {
let params_buf = self let params_buf =
.ctx self.ctx
.device .device
.create_buffer_init(&wgpu::util::BufferInitDescriptor { .create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("adjust-params"), label: Some("adjust-params"),
contents: bytemuck::cast_slice(&uniforms), contents: bytemuck::cast_slice(&uniforms),
usage: wgpu::BufferUsages::UNIFORM, usage: wgpu::BufferUsages::UNIFORM,
}); });
let bind_group = self let bind_group = self
.ctx .ctx
.device .device
+1 -2
View File
@@ -123,8 +123,7 @@ impl Intermediates {
// output these are never handed to a compositor, and no // output these are never handed to a compositor, and no
// COPY_SRC, because nothing reads them back — that is the // COPY_SRC, because nothing reads them back — that is the
// point (ARCH §6.1). // point (ARCH §6.1).
usage: wgpu::TextureUsages::STORAGE_BINDING usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING,
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[], view_formats: &[],
}); });
let view = texture.create_view(&Default::default()); let view = texture.create_view(&Default::default());
+1 -1
View File
@@ -30,8 +30,8 @@ pub use adjust::AdjustPass;
// rather than an implementation detail: a detail pass is guaranteed linear, // rather than an implementation detail: a detail pass is guaranteed linear,
// unclipped, full internal precision (FR-DEV-2), and anyone reasoning about // unclipped, full internal precision (FR-DEV-2), and anyone reasoning about
// VRAM at 24 MP needs to know what an intermediate costs. // VRAM at 24 MP needs to know what an intermediate costs.
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
pub use demosaic::{DemosaicedImage, Demosaicer}; pub use demosaic::{DemosaicedImage, Demosaicer};
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
pub use error::GpuError; pub use error::GpuError;
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing // Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
// at all at a crate root shared with demosaic and segmentation. // at all at a crate root shared with demosaic and segmentation.
+33 -22
View File
@@ -582,10 +582,7 @@ impl MaskPass {
MaskSource::Subject { .. } => match subjects.filter(|s| slot < s.len()) { MaskSource::Subject { .. } => match subjects.filter(|s| slot < s.len()) {
Some(s) => (s, slot), Some(s) => (s, slot),
None => { None => {
log::warn!( log::warn!("mask layer {} has no distance field; skipping", layer.id);
"mask layer {} has no distance field; skipping",
layer.id
);
continue; continue;
} }
}, },
@@ -599,7 +596,14 @@ impl MaskPass {
} }
_ => { _ => {
let selected = self.selection_buffer(layer, field); let selected = self.selection_buffer(layer, field);
self.draw(&mut encoder, slot as u32, &params, field, &selected, subject); self.draw(
&mut encoder,
slot as u32,
&params,
field,
&selected,
subject,
);
} }
} }
} }
@@ -730,14 +734,14 @@ impl MaskPass {
// Still worth beginning the pass: the slice has to be cleared, or an // Still worth beginning the pass: the slice has to be cleared, or an
// unpainted layer would show whatever the last edit left in it. // unpainted layer would show whatever the last edit left in it.
let bind_group = (!batch.is_empty()).then(|| { let bind_group = (!batch.is_empty()).then(|| {
let params_buf = self let params_buf =
.ctx self.ctx
.device .device
.create_buffer_init(&wgpu::util::BufferInitDescriptor { .create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask-brush-params"), label: Some("mask-brush-params"),
contents: bytemuck::bytes_of(params), contents: bytemuck::bytes_of(params),
usage: wgpu::BufferUsages::UNIFORM, usage: wgpu::BufferUsages::UNIFORM,
}); });
// Rebuilt per rasterisation rather than kept and patched. This runs // Rebuilt per rasterisation rather than kept and patched. This runs
// when a mask's shape changes, not per frame, and a few kilobytes // when a mask's shape changes, not per frame, and a few kilobytes
// of stroke geometry is cheaper to upload than a residency scheme // of stroke geometry is cheaper to upload than a residency scheme
@@ -901,12 +905,9 @@ impl MaskPass {
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 3, binding: 3,
resource: wgpu::BindingResource::TextureView( resource: wgpu::BindingResource::TextureView(
subject subject.0.view(subject.1).unwrap_or_else(|| {
.0 self.empty_subject.view(0).expect("placeholder exists")
.view(subject.1) }),
.unwrap_or_else(|| {
self.empty_subject.view(0).expect("placeholder exists")
}),
), ),
}, },
], ],
@@ -949,7 +950,11 @@ impl MaskPass {
} }
fn ensure_array(&mut self, width: u32, height: u32, layers: u32) -> Result<(), GpuError> { fn ensure_array(&mut self, width: u32, height: u32, layers: u32) -> Result<(), GpuError> {
if self.array.as_ref().is_some_and(|a| a.matches(width, height, layers)) { if self
.array
.as_ref()
.is_some_and(|a| a.matches(width, height, layers))
{
return Ok(()); return Ok(());
} }
@@ -1111,7 +1116,9 @@ mod tests {
})); }));
let mut pass = MaskPass::new(&ctx).expect("mask pass"); let mut pass = MaskPass::new(&ctx).expect("mask pass");
let array = pass.render(&stack, Some(&field), None, w, h).expect("render"); let array = pass
.render(&stack, Some(&field), None, w, h)
.expect("render");
assert_eq!(array.size(), (w, h)); assert_eq!(array.size(), (w, h));
assert_eq!(array.layers(), 1); assert_eq!(array.layers(), 1);
} }
@@ -1201,7 +1208,11 @@ mod tests {
assert_eq!(batch.headers.len(), 1); assert_eq!(batch.headers.len(), 1);
let h = &batch.headers[0]; let h = &batch.headers[0];
assert!(h.hi[0] - h.lo[0] > 0.2, "wide enough for the dab: {h:?}", h = (h.lo, h.hi)); assert!(
h.hi[0] - h.lo[0] > 0.2,
"wide enough for the dab: {h:?}",
h = (h.lo, h.hi)
);
assert!( assert!(
h.hi[1] - h.lo[1] > h.hi[0] - h.lo[0], h.hi[1] - h.lo[1] > h.hi[0] - h.lo[0],
"and taller than it is wide in normalised units, since the radius \ "and taller than it is wide in normalised units, since the radius \
+1 -3
View File
@@ -62,9 +62,7 @@ fn rendered_level(ctx: &GpuContext, level: u16, curve: BaseCurve) -> u8 {
.expect("demosaic"); .expect("demosaic");
let shader = EditGraph::default_chain().compose(); let shader = EditGraph::default_chain().compose();
let mut adjust = AdjustPass::new(ctx); let mut adjust = AdjustPass::new(ctx);
adjust adjust.render(&source, &shader, SIZE, SIZE).expect("render");
.render(&source, &shader, SIZE, SIZE)
.expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback"); let (pixels, _, _) = adjust.export_pixels().expect("readback");
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4; let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
pixels[centre as usize] pixels[centre as usize]
+5 -7
View File
@@ -84,12 +84,7 @@ fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph {
/// The whole calling convention a frontend adopts, in five lines: compose both /// The whole calling convention a frontend adopts, in five lines: compose both
/// halves from one graph at one output space, ask the graph for the scale, and /// halves from one graph at one output space, ask the graph for the scale, and
/// pass the invalidation key through. /// pass the invalidation key through.
fn render( fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
pass: &mut AdjustPass,
graph: &EditGraph,
source: &DemosaicedImage,
out: u32,
) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out)); let scale = graph.render_scale(source.size(), (out, out));
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb); let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
@@ -281,7 +276,10 @@ fn a_proxy_and_an_export_sharpen_the_same_photograph() {
proxy_width > 0.05 && export_width > 0.05, proxy_width > 0.05 && export_width > 0.05,
"{proxy_width:.3} / {export_width:.3}" "{proxy_width:.3} / {export_width:.3}"
); );
assert!(proxy_area > 1.0 && export_area > 1.0, "{proxy_area} / {export_area}"); assert!(
proxy_area > 1.0 && export_area > 1.0,
"{proxy_area} / {export_area}"
);
} }
#[test] #[test]
+10 -5
View File
@@ -80,9 +80,7 @@ fn expected_profile(size: u32, r: i32) -> Vec<u8> {
let edge = (size / 2) as i32; let edge = (size / 2) as i32;
(0..size as i32) (0..size as i32)
.map(|x| { .map(|x| {
let white = (-r..=r) let white = (-r..=r).filter(|i| (x + i).clamp(0, last) >= edge).count();
.filter(|i| (x + i).clamp(0, last) >= edge)
.count();
srgb_encode(white as f32 / (2 * r + 1) as f32) srgb_encode(white as f32 / (2 * r + 1) as f32)
}) })
.collect() .collect()
@@ -232,7 +230,10 @@ fn an_inactive_detail_operation_costs_exactly_nothing() {
let mut without = AdjustPass::new(&ctx); let mut without = AdjustPass::new(&ctx);
let b = render(&ctx, &mut without, &plain, &source, SIZE); let b = render(&ctx, &mut without, &plain, &source, SIZE);
assert_eq!(a, b, "an operation at its defaults must not touch the image"); assert_eq!(
a, b,
"an operation at its defaults must not touch the image"
);
} }
#[test] #[test]
@@ -252,7 +253,11 @@ fn moving_a_detail_parameter_does_not_re_run_the_colour_pass() {
graph.set_param(PROBE, RADIUS, 0.0625); graph.set_param(PROBE, RADIUS, 0.0625);
render(&ctx, &mut pass, &graph, &source, SIZE); render(&ctx, &mut pass, &graph, &source, SIZE);
assert_eq!(pass.colour_dispatches(), 1); assert_eq!(pass.colour_dispatches(), 1);
assert_eq!(pass.detail_dispatches(), 2, "a separable blur is two passes"); assert_eq!(
pass.detail_dispatches(),
2,
"a separable blur is two passes"
);
// Drag the sharpening slider. The colour chain is untouched, so the linear // Drag the sharpening slider. The colour chain is untouched, so the linear
// intermediate it wrote is still exactly right. // intermediate it wrote is still exactly right.
+20 -20
View File
@@ -65,12 +65,7 @@ fn render_at(
h: u32, h: u32,
) -> Vec<u8> { ) -> Vec<u8> {
let source = grey_at(ctx, w, h); let source = grey_at(ctx, w, h);
let shader = compose_full( let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
);
let mut masks = MaskPass::new(ctx).expect("mask pass"); let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, field, None, w, h).expect("rasterise"); let array = masks.render(stack, field, None, w, h).expect("rasterise");
@@ -245,11 +240,14 @@ fn stacked_layers_use_their_own_masks() {
ids: vec![0], ids: vec![0],
})); }));
// Right half down. // Right half down.
let mut darken = MaskLayer::new("m2", MaskSource::Regions { let mut darken = MaskLayer::new(
signature: 1, "m2",
level: 2, MaskSource::Regions {
ids: vec![1], signature: 1,
}); level: 2,
ids: vec![1],
},
);
darken.set_param("exposure", ParamId("exposure"), -2.0); darken.set_param("exposure", ParamId("exposure"), -2.0);
stack.push(darken); stack.push(darken);
@@ -412,12 +410,7 @@ fn a_stroke_that_doubles_back_does_not_build_up() {
return; return;
}; };
let once = stack_of(painted(&[( let once = stack_of(painted(&[(false, 0.15, 0.5, vec![(0.1, 0.5), (0.9, 0.5)])]));
false,
0.15,
0.5,
vec![(0.1, 0.5), (0.9, 0.5)],
)]));
let twice = stack_of(painted(&[( let twice = stack_of(painted(&[(
false, false,
0.15, 0.15,
@@ -473,10 +466,17 @@ fn a_dab_is_round_on_a_frame_that_is_not_square() {
let pixels = render_at(&ctx, &stack, None, W, H); let pixels = render_at(&ctx, &stack, None, W, H);
let lit = |v: u8| v > 160; let lit = |v: u8| v > 160;
let across = (0..W).filter(|&x| lit(luma_in(&pixels, W, x, H / 2))).count(); let across = (0..W)
let down = (0..H).filter(|&y| lit(luma_in(&pixels, W, W / 2, y))).count(); .filter(|&x| lit(luma_in(&pixels, W, x, H / 2)))
.count();
let down = (0..H)
.filter(|&y| lit(luma_in(&pixels, W, W / 2, y)))
.count();
assert!(across > 4 && down > 4, "the dab should exist: {across}x{down}"); assert!(
across > 4 && down > 4,
"the dab should exist: {across}x{down}"
);
assert!( assert!(
across.abs_diff(down) <= 2, across.abs_diff(down) <= 2,
"a dab must be as wide as it is tall, got {across} across and {down} down" "a dab must be as wide as it is tall, got {across} across and {down} down"
+15 -3
View File
@@ -158,7 +158,12 @@ fn clarity_lifts_local_contrast_and_leaves_the_flat_regions_alone() {
// Far outside the kernel's reach the base equals the pixel, the detail // Far outside the kernel's reach the base equals the pixel, the detail
// signal is zero, and the output must be the input to the last code value. // signal is zero, and the output must be the input to the last code value.
for x in [0, reach / 2, SIZE as usize - 1 - reach / 2, SIZE as usize - 1] { for x in [
0,
reach / 2,
SIZE as usize - 1 - reach / 2,
SIZE as usize - 1,
] {
assert!( assert!(
edited[x].abs_diff(plain[x]) <= 1, edited[x].abs_diff(plain[x]) <= 1,
"column {x} moved by {} away from any edge", "column {x} moved by {} away from any edge",
@@ -246,7 +251,10 @@ fn the_soft_limit_bounds_the_halo_at_a_hard_edge() {
an unlimited unsharp mask would give" an unlimited unsharp mask would give"
); );
// But it is still a real effect, not a control that does nothing. // But it is still a real effect, not a control that does nothing.
assert!(worst > 0.1, "clarity moved almost nothing: {worst:.3} stops"); assert!(
worst > 0.1,
"clarity moved almost nothing: {worst:.3} stops"
);
} }
#[test] #[test]
@@ -476,7 +484,11 @@ fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() {
let mut graph = graph_with(CLARITY, 40.0); let mut graph = graph_with(CLARITY, 40.0);
render(&mut pass, &graph, &source, 256); render(&mut pass, &graph, &source, 256);
assert_eq!(pass.colour_dispatches(), 1); assert_eq!(pass.colour_dispatches(), 1);
assert_eq!(pass.detail_dispatches(), 2, "a separable mask is two passes"); assert_eq!(
pass.detail_dispatches(),
2,
"a separable mask is two passes"
);
let pipelines = pass.cached_detail_pipelines(); let pipelines = pass.cached_detail_pipelines();
for amount in [50.0, 60.0, 70.0] { for amount in [50.0, 60.0, 70.0] {
+3 -1
View File
@@ -25,7 +25,9 @@ fn ctx() -> Option<GpuContext> {
} }
fn grey(ctx: &GpuContext, size: u32) -> DemosaicedImage { fn grey(ctx: &GpuContext, size: u32) -> DemosaicedImage {
let data: Vec<u8> = (0..size * size).flat_map(|_| [128, 128, 128, 255]).collect(); let data: Vec<u8> = (0..size * size)
.flat_map(|_| [128, 128, 128, 255])
.collect();
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload") DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
} }
+59 -17
View File
@@ -112,11 +112,7 @@ fn split(pixels: &[u8], width: u32, x: u32, y: u32) -> (f32, [f32; 3]) {
} }
/// Upload an image built from a per-pixel closure. /// Upload an image built from a per-pixel closure.
fn upload( fn upload(ctx: &GpuContext, size: u32, f: impl Fn(u32, u32) -> [u8; 3]) -> DemosaicedImage {
ctx: &GpuContext,
size: u32,
f: impl Fn(u32, u32) -> [u8; 3],
) -> DemosaicedImage {
let data: Vec<u8> = (0..size * size) let data: Vec<u8> = (0..size * size)
.flat_map(|i| { .flat_map(|i| {
let (x, y) = (i % size, i / size); let (x, y) = (i % size, i / size);
@@ -248,7 +244,11 @@ fn modulation(
let mut count = 0.0; let mut count = 0.0;
for x in margin..(width - margin) { for x in margin..(width - margin) {
let value = sample(linear(pixels, width, x, row)); let value = sample(linear(pixels, width, x, row));
let sign = if (x / half_period) % 2 == 0 { 1.0 } else { -1.0 }; let sign = if (x / half_period) % 2 == 0 {
1.0
} else {
-1.0
};
total += value * sign; total += value * sign;
count += 1.0; count += 1.0;
} }
@@ -305,7 +305,13 @@ fn chroma_noise_reduction_never_moves_lightness() {
let mut off = AdjustPass::new(&ctx); let mut off = AdjustPass::new(&ctx);
let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &source, (SIZE, SIZE)); let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &source, (SIZE, SIZE));
let mut on = AdjustPass::new(&ctx); let mut on = AdjustPass::new(&ctx);
let chroma = render(&ctx, &mut on, &graph_with(0.0, 100.0), &source, (SIZE, SIZE)); let chroma = render(
&ctx,
&mut on,
&graph_with(0.0, 100.0),
&source,
(SIZE, SIZE),
);
let colour_before = modulation(&plain, SIZE, 4, chroma_r); let colour_before = modulation(&plain, SIZE, 4, chroma_r);
let colour_after = modulation(&chroma, SIZE, 4, chroma_r); let colour_after = modulation(&chroma, SIZE, 4, chroma_r);
@@ -338,7 +344,13 @@ fn luminance_noise_reduction_never_moves_colour() {
let mut off = AdjustPass::new(&ctx); let mut off = AdjustPass::new(&ctx);
let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &source, (SIZE, SIZE)); let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &source, (SIZE, SIZE));
let mut on = AdjustPass::new(&ctx); let mut on = AdjustPass::new(&ctx);
let luma = render(&ctx, &mut on, &graph_with(100.0, 0.0), &source, (SIZE, SIZE)); let luma = render(
&ctx,
&mut on,
&graph_with(100.0, 0.0),
&source,
(SIZE, SIZE),
);
// The colour difference — not the raw channel, which follows lightness. // The colour difference — not the raw channel, which follows lightness.
let row = SIZE / 2; let row = SIZE / 2;
@@ -413,10 +425,10 @@ fn the_same_edit_denoises_the_same_at_two_resolutions() {
let Some(ctx) = ctx() else { return }; let Some(ctx) = ctx() else { return };
const SOURCE: u32 = 256; const SOURCE: u32 = 256;
const HALF_PERIOD: u32 = 8; // in source pixels const HALF_PERIOD: u32 = 8; // in source pixels
// Six code values of swing. Small on purpose: the colour difference has to // Six code values of swing. Small on purpose: the colour difference has to
// land near the filter's threshold, because a larger one is a colour // land near the filter's threshold, because a larger one is a colour
// boundary and the whole point of a bilateral is that it refuses to cross // boundary and the whole point of a bilateral is that it refuses to cross
// those. There would be nothing to measure at either resolution. // those. There would be nothing to measure at either resolution.
let source = chroma_pattern(&ctx, SOURCE, HALF_PERIOD, 6); let source = chroma_pattern(&ctx, SOURCE, HALF_PERIOD, 6);
// Sixty percent is an eight-source-pixel radius, which halves to exactly // Sixty percent is an eight-source-pixel radius, which halves to exactly
@@ -430,14 +442,26 @@ fn the_same_edit_denoises_the_same_at_two_resolutions() {
let proxy_size = SOURCE / 2; let proxy_size = SOURCE / 2;
let mut proxy_pass = AdjustPass::new(&ctx); let mut proxy_pass = AdjustPass::new(&ctx);
let proxy = render(&ctx, &mut proxy_pass, &graph, &source, (proxy_size, proxy_size)); let proxy = render(
&ctx,
&mut proxy_pass,
&graph,
&source,
(proxy_size, proxy_size),
);
let proxy_amp = modulation(&proxy, proxy_size, HALF_PERIOD / 2, chroma_r); let proxy_amp = modulation(&proxy, proxy_size, HALF_PERIOD / 2, chroma_r);
// Both must be doing something: two flat images would agree perfectly and // Both must be doing something: two flat images would agree perfectly and
// prove nothing. About 0.0098 of 0.0188, by the derivation above. // prove nothing. About 0.0098 of 0.0188, by the derivation above.
let untouched = { let untouched = {
let mut pass = AdjustPass::new(&ctx); let mut pass = AdjustPass::new(&ctx);
let plain = render(&ctx, &mut pass, &graph_with(0.0, 0.0), &source, (SOURCE, SOURCE)); let plain = render(
&ctx,
&mut pass,
&graph_with(0.0, 0.0),
&source,
(SOURCE, SOURCE),
);
modulation(&plain, SOURCE, HALF_PERIOD, chroma_r) modulation(&plain, SOURCE, HALF_PERIOD, chroma_r)
}; };
assert!( assert!(
@@ -486,7 +510,13 @@ fn each_amount_costs_only_the_dispatches_it_needs() {
for (luminance, chroma, expected) in [(60.0, 0.0, 1), (0.0, 60.0, 2), (60.0, 60.0, 3)] { for (luminance, chroma, expected) in [(60.0, 0.0, 1), (0.0, 60.0, 2), (60.0, 60.0, 3)] {
let mut pass = AdjustPass::new(&ctx); let mut pass = AdjustPass::new(&ctx);
render(&ctx, &mut pass, &graph_with(luminance, chroma), &source, (SIZE, SIZE)); render(
&ctx,
&mut pass,
&graph_with(luminance, chroma),
&source,
(SIZE, SIZE),
);
assert_eq!( assert_eq!(
pass.detail_dispatches(), pass.detail_dispatches(),
expected, expected,
@@ -496,7 +526,13 @@ fn each_amount_costs_only_the_dispatches_it_needs() {
} }
let mut neutral = AdjustPass::new(&ctx); let mut neutral = AdjustPass::new(&ctx);
let a = render(&ctx, &mut neutral, &graph_with(0.0, 0.0), &source, (SIZE, SIZE)); let a = render(
&ctx,
&mut neutral,
&graph_with(0.0, 0.0),
&source,
(SIZE, SIZE),
);
assert_eq!(neutral.detail_dispatches(), 0); assert_eq!(neutral.detail_dispatches(), 0);
assert_eq!(neutral.detail_allocations(), 0, "nothing was allocated"); assert_eq!(neutral.detail_allocations(), 0, "nothing was allocated");
@@ -504,7 +540,13 @@ fn each_amount_costs_only_the_dispatches_it_needs() {
// touch the image, and a stage that ran and wrote back the same values // touch the image, and a stage that ran and wrote back the same values
// would still have quantised twice. // would still have quantised twice.
let mut absent = AdjustPass::new(&ctx); let mut absent = AdjustPass::new(&ctx);
let b = render(&ctx, &mut absent, &EditGraph::default_chain(), &source, (SIZE, SIZE)); let b = render(
&ctx,
&mut absent,
&EditGraph::default_chain(),
&source,
(SIZE, SIZE),
);
assert_eq!(a, b, "a neutral denoiser changed the picture"); assert_eq!(a, b, "a neutral denoiser changed the picture");
} }
+8 -7
View File
@@ -24,7 +24,9 @@ fn ctx() -> Option<GpuContext> {
/// The centre pixel's red, green and blue, after `graph` has run over flat /// The centre pixel's red, green and blue, after `graph` has run over flat
/// mid-grey. /// mid-grey.
fn rendered(ctx: &GpuContext, graph: &EditGraph) -> (u8, u8, u8) { fn rendered(ctx: &GpuContext, graph: &EditGraph) -> (u8, u8, u8) {
let data: Vec<u8> = (0..SIZE * SIZE).flat_map(|_| [128, 128, 128, 255]).collect(); let data: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|_| [128, 128, 128, 255])
.collect();
let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload"); let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload");
// Composed the way the display path composes it. A curve that generates // Composed the way the display path composes it. A curve that generates
@@ -44,11 +46,7 @@ fn rendered(ctx: &GpuContext, graph: &EditGraph) -> (u8, u8, u8) {
/// an edit. /// an edit.
fn lifted(channel: Channel) -> EditGraph { fn lifted(channel: Channel) -> EditGraph {
let mut graph = EditGraph::default_chain(); let mut graph = EditGraph::default_chain();
graph.set_param( graph.set_param(curve::ID, curve::coordinate(channel, 2, Axis::Y), 0.75);
curve::ID,
curve::coordinate(channel, 2, Axis::Y),
0.75,
);
graph graph
} }
@@ -62,7 +60,10 @@ fn the_master_curve_lifts_every_component_together() {
let (r0, g0, b0) = rendered(&ctx, &EditGraph::default_chain()); let (r0, g0, b0) = rendered(&ctx, &EditGraph::default_chain());
let (r, g, b) = rendered(&ctx, &lifted(Channel::Master)); let (r, g, b) = rendered(&ctx, &lifted(Channel::Master));
assert!(r > r0, "the master curve did not lift the image: {r} vs {r0}"); assert!(
r > r0,
"the master curve did not lift the image: {r} vs {r0}"
);
// Grey in, grey out: the master curve is applied as a ratio over // Grey in, grey out: the master curve is applied as a ratio over
// luminance, so it changes tone and not hue. A tolerance of one code // luminance, so it changes tone and not hue. A tolerance of one code
// value, because the components travel through the ratio separately and // value, because the components travel through the ratio separately and
+4 -1
View File
@@ -1532,7 +1532,10 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> {
.iter() .iter()
.map(|a| { .map(|a| {
let mut c = a.chars(); let mut c = a.chars();
let head = c.next().expect("attribute names are non-empty").to_uppercase(); let head = c
.next()
.expect("attribute names are non-empty")
.to_uppercase();
format!("Attribute::{head}{}", c.as_str()) format!("Attribute::{head}{}", c.as_str())
}) })
.collect(); .collect();
+1 -5
View File
@@ -499,11 +499,7 @@ pub fn compose_detail(
// case where a photographer has a kernel switched on at a scale that // case where a photographer has a kernel switched on at a scale that
// cannot draw it — against the alternative of the preview failing outright // cannot draw it — against the alternative of the preview failing outright
// or `compose_full` growing a resolution argument it has no other use for. // or `compose_full` growing a resolution argument it has no other use for.
if planned.is_empty() if planned.is_empty() && ops.iter().any(|o| o.is_active() && o.detail().is_some()) {
&& ops
.iter()
.any(|o| o.is_active() && o.detail().is_some())
{
return ComposedDetail { return ComposedDetail {
passes: vec![compose_one( passes: vec![compose_one(
RESOLVE_ID, RESOLVE_ID,
+10 -4
View File
@@ -8,8 +8,7 @@
//! so reordering the pipeline needs no code change. //! so reordering the pipeline needs no code change.
use crate::descriptor::{ use crate::descriptor::{
Attribute, Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
}; };
use crate::framing::{CropRect, Framing}; use crate::framing::{CropRect, Framing};
use crate::mask::MaskStack; use crate::mask::MaskStack;
@@ -378,7 +377,11 @@ impl EditGraph {
/// Only the detail stage needs this. Every point operation is scale-free /// Only the detail stage needs this. Every point operation is scale-free
/// — a multiply is a multiply at any resolution — which is why nothing in /// — a multiply is a multiply at any resolution — which is why nothing in
/// the pipeline had to know its own size until a kernel arrived. /// the pipeline had to know its own size until a kernel arrived.
pub fn render_scale(&self, source: (u32, u32), render: (u32, u32)) -> crate::detail::RenderScale { pub fn render_scale(
&self,
source: (u32, u32),
render: (u32, u32),
) -> crate::detail::RenderScale {
let (fw, fh) = self.framing.output_size(source.0, source.1); let (fw, fh) = self.framing.output_size(source.0, source.1);
let view = self.framing.view(); let view = self.framing.view();
// The *viewed* part of the framed image, at source resolution. Zoom // The *viewed* part of the framed image, at source resolution. Zoom
@@ -397,7 +400,10 @@ impl EditGraph {
/// Empty for every edit with no active neighbourhood operation, which is /// Empty for every edit with no active neighbourhood operation, which is
/// almost all of them — and in that case [`Self::compose`] emits the /// almost all of them — and in that case [`Self::compose`] emits the
/// single encoded dispatch it always has. /// single encoded dispatch it always has.
pub fn compose_detail(&self, scale: crate::detail::RenderScale) -> crate::detail::ComposedDetail { pub fn compose_detail(
&self,
scale: crate::detail::RenderScale,
) -> crate::detail::ComposedDetail {
self.compose_detail_for(scale, dr_types::ColourSpace::Srgb) self.compose_detail_for(scale, dr_types::ColourSpace::Srgb)
} }
+2 -2
View File
@@ -44,8 +44,8 @@ pub mod preset;
pub mod sidecar; pub mod sidecar;
pub use descriptor::{ pub use descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Presentation, Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind,
Scale, Unit, WidgetDemand, WidgetKind, Presentation, Scale, Unit, WidgetDemand, WidgetKind,
}; };
pub use detail::{ pub use detail::{
compose_detail, ComposedDetail, ComposedDetailPass, DetailPass, DetailStage, RenderScale, compose_detail, ComposedDetail, ComposedDetailPass, DetailPass, DetailStage, RenderScale,
+30 -10
View File
@@ -856,7 +856,11 @@ impl MaskLayer {
pub fn begin_stroke(&mut self, erase: bool, radius: f32, hardness: f32, flow: f32) -> bool { pub fn begin_stroke(&mut self, erase: bool, radius: f32, hardness: f32, flow: f32) -> bool {
let full = self.stroke_points() >= MAX_LAYER_POINTS; let full = self.stroke_points() >= MAX_LAYER_POINTS;
let MaskSource::Brush { strokes } = &mut self.source else { let MaskSource::Brush { strokes } = &mut self.source else {
log::warn!("layer {} is a {} mask, not a brush", self.id, self.source.kind()); log::warn!(
"layer {} is a {} mask, not a brush",
self.id,
self.source.kind()
);
return false; return false;
}; };
if full { if full {
@@ -1121,7 +1125,10 @@ impl MaskStack {
/// Generate a fresh layer id that does not collide with an existing one. /// Generate a fresh layer id that does not collide with an existing one.
pub fn next_id(&self) -> String { pub fn next_id(&self) -> String {
(1..).map(|n| format!("m{n}")).find(|id| self.get(id).is_none()).expect("infinite range") (1..)
.map(|n| format!("m{n}"))
.find(|id| self.get(id).is_none())
.expect("infinite range")
} }
} }
@@ -1178,10 +1185,7 @@ pub(crate) fn compose_layers(stack: &MaskStack) -> LayerShader {
// place. A second copy here would be a second thing to keep in step // place. A second copy here would be a second thing to keep in step
// with `Framing::wgsl_prologue`, and the failure would be a mask that // with `Framing::wgsl_prologue`, and the failure would be a mask that
// is subtly wrong only when straightened. // is subtly wrong only when straightened.
let _ = writeln!( let _ = writeln!(out.body, " var m = sample_mask(uv_src, {slot});");
out.body,
" var m = sample_mask(uv_src, {slot});"
);
let _ = writeln!( let _ = writeln!(
out.body, out.body,
" m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);" " m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);"
@@ -1403,7 +1407,11 @@ mod tests {
assert!(shader.uniform_fields.contains("mask1_exposure_")); assert!(shader.uniform_fields.contains("mask1_exposure_"));
assert_eq!( assert_eq!(
shader.uniform_values.len(), shader.uniform_values.len(),
shader.uniform_fields.lines().filter(|l| l.trim_start().starts_with("mask")).count(), shader
.uniform_fields
.lines()
.filter(|l| l.trim_start().starts_with("mask"))
.count(),
"one value per emitted field" "one value per emitted field"
); );
} }
@@ -1448,7 +1456,11 @@ mod tests {
// A gradient has no region ids, so nothing can go stale about it. // A gradient has no region ids, so nothing can go stale about it.
let grad = MaskLayer::new( let grad = MaskLayer::new(
"m2", "m2",
MaskSource::Linear { centre: (0.5, 0.5), angle: 0.0, width: 0.2 }, MaskSource::Linear {
centre: (0.5, 0.5),
angle: 0.0,
width: 0.2,
},
); );
assert!(!grad.is_stale(999)); assert!(!grad.is_stale(999));
} }
@@ -1458,8 +1470,16 @@ mod tests {
let base = segmentation_signature(1600, 1067, 6730, 2); let base = segmentation_signature(1600, 1067, 6730, 2);
assert_eq!(base, segmentation_signature(1600, 1067, 6730, 2)); assert_eq!(base, segmentation_signature(1600, 1067, 6730, 2));
assert_ne!(base, segmentation_signature(1600, 1067, 6730, 5), "tuning"); assert_ne!(base, segmentation_signature(1600, 1067, 6730, 5), "tuning");
assert_ne!(base, segmentation_signature(800, 1067, 6730, 2), "proxy size"); assert_ne!(
assert_ne!(base, segmentation_signature(1600, 1067, 42, 2), "region count"); base,
segmentation_signature(800, 1067, 6730, 2),
"proxy size"
);
assert_ne!(
base,
segmentation_signature(1600, 1067, 42, 2),
"region count"
);
} }
#[test] #[test]
+5 -5
View File
@@ -472,10 +472,7 @@ pub fn compose_full(
// than from a flag the caller sets, because a caller that got the flag // than from a flag the caller sets, because a caller that got the flag
// wrong would produce a shader whose storage format does not match the // wrong would produce a shader whose storage format does not match the
// texture bound to it. // texture bound to it.
let output_mode = if ops let output_mode = if ops.iter().any(|o| o.is_active() && o.detail().is_some()) {
.iter()
.any(|o| o.is_active() && o.detail().is_some())
{
OutputMode::LinearWorking OutputMode::LinearWorking
} else { } else {
OutputMode::Encoded OutputMode::Encoded
@@ -1389,7 +1386,10 @@ mod tests {
// how a shoulder rolls off. // how a shoulder rolls off.
let mut curve = crate::ops::ToneCurve::new(); let mut curve = crate::ops::ToneCurve::new();
curve.set_param(crate::ops::curve::P2_Y, 0.7); curve.set_param(crate::ops::curve::P2_Y, 0.7);
assert!(curve.is_active(), "the fixture must actually reach the shader"); assert!(
curve.is_active(),
"the fixture must actually reach the shader"
);
let source = compose(&[Box::new(curve)]).source; let source = compose(&[Box::new(curve)]).source;
assert_eq!( assert_eq!(
+3 -1
View File
@@ -32,7 +32,9 @@
//! least to perceived sharpness. Scaling all three about a virtual reference //! least to perceived sharpness. Scaling all three about a virtual reference
//! would soften the image even when the correction is right. //! would soften the image even when the correction is right.
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit}; use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::lens::Warp; use crate::lens::Warp;
use crate::operation::{Helper, Uniform}; use crate::operation::{Helper, Uniform};
+42 -8
View File
@@ -764,9 +764,11 @@ mod tests {
}; };
assert_eq!(gate_of(0.0), 0.0); assert_eq!(gate_of(0.0), 0.0);
assert!((gate_of(1.0) - 0.25).abs() < 1e-6); assert!((gate_of(1.0) - 0.25).abs() < 1e-6);
assert!(chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512))).passes[0] assert!(
.source chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512))).passes[0]
.contains("gate > 0.0")); .source
.contains("gate > 0.0")
);
} }
#[test] #[test]
@@ -783,7 +785,11 @@ mod tests {
for scale in scales { for scale in scales {
for pass in chain_at(&sharpening(75.0, 1.0), scale).passes { for pass in chain_at(&sharpening(75.0, 1.0), scale).passes {
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label); assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
assert!(pass.uniforms.iter().all(|v| v.is_finite()), "{}", pass.label); assert!(
pass.uniforms.iter().all(|v| v.is_finite()),
"{}",
pass.label
);
} }
} }
} }
@@ -805,10 +811,38 @@ mod tests {
// word be safe in one stage and not in the other, which is the sort of // word be safe in one stage and not in the other, which is the sort of
// difference nobody discovers until a shader fails to compile. // difference nobody discovers until a shader fails to compile.
const RESERVED: &[&str] = &[ const RESERVED: &[&str] = &[
"target", "sample", "filter", "texture", "buffer", "binding", "const", "enum", "mat", "target",
"vec", "ptr", "ref", "shared", "static", "typedef", "union", "unless", "handle", "sample",
"layout", "packed", "premerge", "regardless", "active", "do", "input", "output", "filter",
"private", "resource", "restrict", "self", "std", "where", "texture",
"buffer",
"binding",
"const",
"enum",
"mat",
"vec",
"ptr",
"ref",
"shared",
"static",
"typedef",
"union",
"unless",
"handle",
"layout",
"packed",
"premerge",
"regardless",
"active",
"do",
"input",
"output",
"private",
"resource",
"restrict",
"self",
"std",
"where",
]; ];
for pass in chain_at(&sharpening(100.0, 2.0), RenderScale::full((512, 512))).passes { for pass in chain_at(&sharpening(100.0, 2.0), RenderScale::full((512, 512))).passes {
+3 -1
View File
@@ -30,7 +30,9 @@
//! adjusted, each one's share depends on what the other is set to, so turning //! adjusted, each one's share depends on what the other is set to, so turning
//! up one colour's saturation quietly weakened its neighbour's hue shift. //! up one colour's saturation quietly weakened its neighbour's hue shift.
use crate::descriptor::{Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId}; use crate::descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId,
};
use crate::operation::{Helper, Operation, Uniform}; use crate::operation::{Helper, Operation, Uniform};
use crate::ops::helpers; use crate::ops::helpers;
+8 -8
View File
@@ -121,12 +121,7 @@ impl Channel {
/// Master first because it runs first — see the module documentation for /// Master first because it runs first — see the module documentation for
/// why that is the composition order — and because a list showing the /// why that is the composition order — and because a list showing the
/// grade before the tone would be describing a different operation. /// grade before the tone would be describing a different operation.
pub const ALL: [Channel; 4] = [ pub const ALL: [Channel; 4] = [Channel::Master, Channel::Red, Channel::Green, Channel::Blue];
Channel::Master,
Channel::Red,
Channel::Green,
Channel::Blue,
];
/// Position in [`Self::ALL`], and so in every array keyed by channel. /// Position in [`Self::ALL`], and so in every array keyed by channel.
pub const fn index(self) -> usize { pub const fn index(self) -> usize {
@@ -771,7 +766,10 @@ impl Operation for ToneCurve {
// declared from, so a call and its uniform block cannot disagree // declared from, so a call and its uniform block cannot disagree
// about a name. // about a name.
let args = UNIFORM_NAMES[channel.index()].join(", "); let args = UNIFORM_NAMES[channel.index()].join(", ");
let _ = writeln!(body, "c.{component} = channel_curve(c.{component}, {args});"); let _ = writeln!(
body,
"c.{component} = channel_curve(c.{component}, {args});"
);
} }
// Whichever curves ran, the result has to be a colour: the spline's // Whichever curves ran, the result has to be a colour: the spline's
@@ -1345,7 +1343,9 @@ mod tests {
let body = c.wgsl_body(); let body = c.wgsl_body();
let master = body.find("apply_tone_gain").expect("the master curve runs"); let master = body.find("apply_tone_gain").expect("the master curve runs");
let red = body.find("c.r = channel_curve").expect("the red curve runs"); let red = body
.find("c.r = channel_curve")
.expect("the red curve runs");
assert!(master < red, "the master curve must run first:\n{body}"); assert!(master < red, "the master curve must run first:\n{body}");
} }
+3 -1
View File
@@ -26,7 +26,9 @@
//! profile is a "make the horizon straight" task, which one term does well. //! profile is a "make the horizon straight" task, which one term does well.
//! The full triple is reachable by loading a profile. //! The full triple is reachable by loading a profile.
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit}; use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::lens::Warp; use crate::lens::Warp;
use crate::operation::{Helper, Uniform}; use crate::operation::{Helper, Uniform};
+15 -4
View File
@@ -619,7 +619,10 @@ mod tests {
let scale = RenderScale::full((2000, 1500)); let scale = RenderScale::full((2000, 1500));
let only_clarity = composed(50.0, 0.0, scale); let only_clarity = composed(50.0, 0.0, scale);
assert_eq!(only_clarity.len(), 2, "one operation, two passes"); assert_eq!(only_clarity.len(), 2, "one operation, two passes");
assert!(only_clarity.passes.iter().all(|p| p.label.starts_with("clarity/"))); assert!(only_clarity
.passes
.iter()
.all(|p| p.label.starts_with("clarity/")));
let both = composed(50.0, 50.0, scale); let both = composed(50.0, 50.0, scale);
assert_eq!(both.len(), 4); assert_eq!(both.len(), 4);
@@ -701,7 +704,10 @@ mod tests {
// acutance family's unit. // acutance family's unit.
let proxy = RenderScale::new((2000, 1500), (6000, 4500)); let proxy = RenderScale::new((2000, 1500), (6000, 4500));
let clarity = Clarity::with_amount(60.0); let clarity = Clarity::with_amount(60.0);
assert_eq!(clarity.kernel(proxy), clarity.kernel(RenderScale::full((2000, 1500)))); assert_eq!(
clarity.kernel(proxy),
clarity.kernel(RenderScale::full((2000, 1500)))
);
assert!( assert!(
proxy.source_pixels(96.0) < 40.0, proxy.source_pixels(96.0) < 40.0,
"the same length in the other unit would have collapsed" "the same length in the other unit would have collapsed"
@@ -786,7 +792,9 @@ mod tests {
// slider can produce is `gain * threshold` stops however violent the // slider can produce is `gain * threshold` stops however violent the
// edge — a bound that holds by construction rather than by tuning. // edge — a bound that holds by construction rather than by tuning.
let combine = &Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1]; let combine = &Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1];
assert!(combine.wgsl.contains("threshold * tanh(detail / threshold)")); assert!(combine
.wgsl
.contains("threshold * tanh(detail / threshold)"));
let bound = |u: &[Uniform]| { let bound = |u: &[Uniform]| {
let get = |n| u.iter().find(|x| x.name == n).unwrap().value; let get = |n| u.iter().find(|x| x.name == n).unwrap().value;
@@ -819,7 +827,10 @@ mod tests {
// And texture does not carry the helpers it would need for one, so its // And texture does not carry the helpers it would need for one, so its
// shader says what it does rather than merely not calling it. // shader says what it does rather than merely not calling it.
assert!(Texture::new().helpers().iter().any(|h| h.name == "log_luma")); assert!(Texture::new()
.helpers()
.iter()
.any(|h| h.name == "log_luma"));
assert!(!Texture::new() assert!(!Texture::new()
.helpers() .helpers()
.iter() .iter()
+7 -1
View File
@@ -820,7 +820,13 @@ mod tests {
// Everything else about the two must match, or one axis is filtered // Everything else about the two must match, or one axis is filtered
// harder than the other and a round blotch comes out oval. // harder than the other and a round blotch comes out oval.
assert_eq!(passes[0].radius, passes[1].radius); assert_eq!(passes[0].radius, passes[1].radius);
for name in ["radius", "inv_spatial", "guide_k", "chroma_k", "noise_floor"] { for name in [
"radius",
"inv_spatial",
"guide_k",
"chroma_k",
"noise_floor",
] {
let of = |p: &DetailPass| { let of = |p: &DetailPass| {
p.uniforms p.uniforms
.iter() .iter()
+6 -5
View File
@@ -67,9 +67,7 @@ use std::fmt;
use std::fmt::Write as _; use std::fmt::Write as _;
use crate::graph::EditGraph; use crate::graph::EditGraph;
use crate::mask::{ use crate::mask::{Falloff, MaskLayer, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER};
Falloff, MaskLayer, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER,
};
use crate::preset::{resolve, Preset}; use crate::preset::{resolve, Preset};
/// Format version of the document itself. /// Format version of the document itself.
@@ -662,7 +660,6 @@ impl Sidecar {
} }
} }
/// Write one mask layer as its own block. /// Write one mask layer as its own block.
/// ///
/// The version uuid is repeated in the header rather than relying on the /// The version uuid is repeated in the header rather than relying on the
@@ -1292,7 +1289,11 @@ mod tests {
// else. // else.
assert_eq!(g.param(curve::ID, curve::P1_Y), Some(0.15)); assert_eq!(g.param(curve::ID, curve::P1_Y), Some(0.15));
assert_eq!(g.param(curve::ID, curve::P3_Y), Some(0.85)); assert_eq!(g.param(curve::ID, curve::P3_Y), Some(0.85));
for channel in [curve::Channel::Red, curve::Channel::Green, curve::Channel::Blue] { for channel in [
curve::Channel::Red,
curve::Channel::Green,
curve::Channel::Blue,
] {
for point in 0..curve::POINTS { for point in 0..curve::POINTS {
for axis in [curve::Axis::X, curve::Axis::Y] { for axis in [curve::Axis::X, curve::Axis::Y] {
let id = curve::coordinate(channel, point, axis); let id = curve::coordinate(channel, point, axis);
+4 -1
View File
@@ -40,7 +40,10 @@ fn the_groups_are_derivable_from_the_chain() {
present.sort(); present.sort();
present.dedup(); present.dedup();
assert!(present.contains(&Attribute::Tone), "the chain has tonal work"); assert!(
present.contains(&Attribute::Tone),
"the chain has tonal work"
);
assert!(present.contains(&Attribute::Colour)); assert!(present.contains(&Attribute::Colour));
assert!( assert!(
present.contains(&Attribute::Geometry), present.contains(&Attribute::Geometry),
+23 -4
View File
@@ -55,7 +55,14 @@ fn a_region_mask_survives_a_round_trip() {
assert_eq!(layer.id, "m1"); assert_eq!(layer.id, "m1");
assert_eq!(layer.name, "Subject"); assert_eq!(layer.name, "Subject");
assert_eq!(layer.source, regions(&[3, 7, 12])); assert_eq!(layer.source, regions(&[3, 7, 12]));
assert_eq!(layer.ops.iter().find(|o| o.descriptor().id.0 == "exposure").map(|o| o.param(ParamId("exposure"))), Some(0.75)); assert_eq!(
layer
.ops
.iter()
.find(|o| o.descriptor().id.0 == "exposure")
.map(|o| o.param(ParamId("exposure"))),
Some(0.75)
);
} }
#[test] #[test]
@@ -120,7 +127,10 @@ fn flags_and_opacity_survive() {
let layer = &restored.masks().layers()[0]; let layer = &restored.masks().layers()[0];
assert!(layer.invert); assert!(layer.invert);
assert!((layer.opacity - 0.35).abs() < 1e-6); assert!((layer.opacity - 0.35).abs() < 1e-6);
assert!(!layer.enabled, "a disabled layer must stay disabled, not vanish"); assert!(
!layer.enabled,
"a disabled layer must stay disabled, not vanish"
);
} }
/// A selection's identity is the *set*. Two files naming the same regions in /// A selection's identity is the *set*. Two files naming the same regions in
@@ -272,7 +282,10 @@ fn unknown_top_level_keys_still_round_trip_alongside_masks() {
exposure.exposure = 1\n"; exposure.exposure = 1\n";
let out = Sidecar::parse(text).expect("parse").to_text(); let out = Sidecar::parse(text).expect("parse").to_text();
assert!(out.contains("future.thing"), "unknown keys are still preserved"); assert!(
out.contains("future.thing"),
"unknown keys are still preserved"
);
assert!(out.contains("[mask default m1]")); assert!(out.contains("[mask default m1]"));
} }
@@ -361,7 +374,13 @@ fn a_brush_layer_writes_one_line_per_stroke() {
.lines() .lines()
.filter(|l| l.starts_with("stroke = ")) .filter(|l| l.starts_with("stroke = "))
.collect(); .collect();
assert_eq!(lines, ["stroke = add 0.05 0.5 1 0.2,0.5 0.8,0.5", "stroke = erase 0.05 0.5 1 0.5,0.5"]); assert_eq!(
lines,
[
"stroke = add 0.05 0.5 1 0.2,0.5 0.8,0.5",
"stroke = erase 0.05 0.5 1 0.5,0.5"
]
);
} }
#[test] #[test]
+2 -2
View File
@@ -12,7 +12,8 @@ use dr_pipeline::{EditGraph, Sidecar};
/// A version block carrying `params`, in the on-disk spelling. /// A version block carrying `params`, in the on-disk spelling.
fn sidecar_with(params: &[(&str, f32)]) -> String { fn sidecar_with(params: &[(&str, f32)]) -> String {
let mut text = String::from("drsc 1\n\n[version u1]\nname = Default\nrevision = 2\nmodified = 0\n"); let mut text =
String::from("drsc 1\n\n[version u1]\nname = Default\nrevision = 2\nmodified = 0\n");
for (key, value) in params { for (key, value) in params {
text.push_str(&format!("{key} = {value}\n")); text.push_str(&format!("{key} = {value}\n"));
} }
@@ -199,4 +200,3 @@ fn every_point_of_every_curve_round_trips_through_a_sidecar() {
} }
} }
} }
+7 -9
View File
@@ -46,7 +46,11 @@ fn main() {
}; };
println!( println!(
"tiling {}", "tiling {}",
if tiled { "grid, 25% overlap" } else { "whole frame" } if tiled {
"grid, 25% overlap"
} else {
"whole frame"
}
); );
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
@@ -57,10 +61,7 @@ fn main() {
let instances = model let instances = model
.detect(&rgb, width, height, &options) .detect(&rgb, width, height, &options)
.expect("inference"); .expect("inference");
println!( println!("detect {:.0} ms", t1.elapsed().as_secs_f32() * 1000.0);
"detect {:.0} ms",
t1.elapsed().as_secs_f32() * 1000.0
);
println!("found {} instances", instances.len()); println!("found {} instances", instances.len());
for (i, inst) in instances.iter().enumerate() { for (i, inst) in instances.iter().enumerate() {
@@ -131,10 +132,7 @@ fn load_jpeg(path: &str) -> (Vec<f32>, usize, usize) {
// where *not* linearising is the correct thing to do. // where *not* linearising is the correct thing to do.
let rgb = match pixels.len() / (w * h) { let rgb = match pixels.len() / (w * h) {
3 => pixels.iter().map(|&v| v as f32 / 255.0).collect(), 3 => pixels.iter().map(|&v| v as f32 / 255.0).collect(),
1 => pixels 1 => pixels.iter().flat_map(|&v| [v as f32 / 255.0; 3]).collect(),
.iter()
.flat_map(|&v| [v as f32 / 255.0; 3])
.collect(),
n => panic!("unexpected {n} components per pixel"), n => panic!("unexpected {n} components per pixel"),
}; };
+11 -4
View File
@@ -346,8 +346,8 @@ impl Shaped {
radius_px: f32, radius_px: f32,
) -> Self { ) -> Self {
let distance = signed_distance(coverage, width, height, threshold); let distance = signed_distance(coverage, width, height, threshold);
let distance = apply_morphology(&distance, width, height, morphology, radius_px) let distance =
.unwrap_or(distance); apply_morphology(&distance, width, height, morphology, radius_px).unwrap_or(distance);
Self { Self {
distance, distance,
width, width,
@@ -403,7 +403,11 @@ mod tests {
// Distance from the boundary, so the half-pixel comes back off to // Distance from the boundary, so the half-pixel comes back off to
// compare against the centre-to-centre figures. // compare against the centre-to-centre figures.
let at = |x: usize, y: usize| -d[y * 5 + x] + 0.5; let at = |x: usize, y: usize| -d[y * 5 + x] + 0.5;
assert!((at(1, 1) - std::f32::consts::SQRT_2).abs() < 1e-4, "{}", at(1, 1)); assert!(
(at(1, 1) - std::f32::consts::SQRT_2).abs() < 1e-4,
"{}",
at(1, 1)
);
assert!((at(0, 0) - (8.0f32).sqrt()).abs() < 1e-4, "{}", at(0, 0)); assert!((at(0, 0) - (8.0f32).sqrt()).abs() < 1e-4, "{}", at(0, 0));
assert_eq!(at(2, 0), 2.0, "straight up is exactly two"); assert_eq!(at(2, 0), 2.0, "straight up is exactly two");
} }
@@ -489,7 +493,10 @@ mod tests {
m[10 * w + 10] = 0; m[10 * w + 10] = 0;
let before = signed_distance(&m, w, h, 128); let before = signed_distance(&m, w, h, 128);
assert!(before[10 * w + 10] < 0.0, "the hole starts outside the mask"); assert!(
before[10 * w + 10] < 0.0,
"the hole starts outside the mask"
);
let after = apply_morphology(&before, w, h, Morphology::Close, 2.0).expect("recomputed"); let after = apply_morphology(&before, w, h, Morphology::Close, 2.0).expect("recomputed");
assert!(after[10 * w + 10] >= 0.0, "closing should have filled it"); assert!(after[10 * w + 10] >= 0.0, "closing should have filled it");
+37 -18
View File
@@ -142,7 +142,11 @@ impl Membership {
/// The instance a region belongs to, if any. /// The instance a region belongs to, if any.
pub fn instance_of(&self, region: u32) -> Option<usize> { pub fn instance_of(&self, region: u32) -> Option<usize> {
self.of.get(region as usize).copied().flatten().map(|(i, _)| i) self.of
.get(region as usize)
.copied()
.flatten()
.map(|(i, _)| i)
} }
/// How two regions relate semantically, in `-1.0..=1.0`. /// How two regions relate semantically, in `-1.0..=1.0`.
@@ -233,11 +237,7 @@ pub fn apply_semantic_prior(
/// ///
/// The returned ids are sorted, so the same click always produces the same /// The returned ids are sorted, so the same click always produces the same
/// mask — which is what lets it be a cache key. /// mask — which is what lets it be a cache key.
pub fn regions_for_instance( pub fn regions_for_instance(field: &RegionField, mask: &[f32], options: &PriorOptions) -> Vec<u32> {
field: &RegionField,
mask: &[f32],
options: &PriorOptions,
) -> Vec<u32> {
let mut coverage = vec![0.0f32; field.region_count]; let mut coverage = vec![0.0f32; field.region_count];
let mut sizes = vec![0u32; field.region_count]; let mut sizes = vec![0u32; field.region_count];
@@ -278,10 +278,26 @@ mod tests {
labels: vec![0, 0, 2, 2, 1, 1, 3, 3], labels: vec![0, 0, 2, 2, 1, 1, 3, 3],
region_count: 4, region_count: 4,
adjacency: vec![ adjacency: vec![
Edge { a: 0, b: 1, saddle: 1.0 }, Edge {
Edge { a: 0, b: 2, saddle: 1.0 }, a: 0,
Edge { a: 1, b: 3, saddle: 1.0 }, b: 1,
Edge { a: 2, b: 3, saddle: 1.0 }, saddle: 1.0,
},
Edge {
a: 0,
b: 2,
saddle: 1.0,
},
Edge {
a: 1,
b: 3,
saddle: 1.0,
},
Edge {
a: 2,
b: 3,
saddle: 1.0,
},
], ],
} }
} }
@@ -361,7 +377,10 @@ mod tests {
#[test] #[test]
fn zero_strength_is_arm_a_exactly() { fn zero_strength_is_arm_a_exactly() {
let f = field(); let f = field();
let opts = PriorOptions { strength: 0.0, ..PriorOptions::default() }; let opts = PriorOptions {
strength: 0.0,
..PriorOptions::default()
};
let m = Membership::compute(&f, &[&left_half()], &opts).unwrap(); let m = Membership::compute(&f, &[&left_half()], &opts).unwrap();
assert_eq!(apply_semantic_prior(&f, &m, &opts).adjacency, f.adjacency); assert_eq!(apply_semantic_prior(&f, &m, &opts).adjacency, f.adjacency);
} }
@@ -394,7 +413,11 @@ mod tests {
let err = Membership::compute(&f, &[&vec![0.0; 3]], &PriorOptions::default()).unwrap_err(); let err = Membership::compute(&f, &[&vec![0.0; 3]], &PriorOptions::default()).unwrap_err();
assert_eq!( assert_eq!(
err, err,
MembershipError::MaskSize { instance: 0, expected: 8, got: 3 } MembershipError::MaskSize {
instance: 0,
expected: 8,
got: 3
}
); );
} }
@@ -403,12 +426,8 @@ mod tests {
fn different_instances_repel() { fn different_instances_repel() {
let f = field(); let f = field();
let right_half = vec![0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0]; let right_half = vec![0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0];
let m = Membership::compute( let m = Membership::compute(&f, &[&left_half(), &right_half], &PriorOptions::default())
&f, .unwrap();
&[&left_half(), &right_half],
&PriorOptions::default(),
)
.unwrap();
assert_eq!(m.instance_of(0), Some(0)); assert_eq!(m.instance_of(0), Some(0));
assert_eq!(m.instance_of(2), Some(1)); assert_eq!(m.instance_of(2), Some(1));
+145 -7
View File
@@ -145,6 +145,39 @@ impl Instance {
} }
} }
/// Take in another view of the same object, from an overlapping tile.
///
/// Pointwise maximum over the coverage, union of the boxes, higher of the
/// scores — see `merge_into` for why each is right for its own quantity.
/// Both instances describe the whole frame in the same coordinates, so no
/// resampling is involved and the masks are already aligned pixel for
/// pixel.
fn absorb(&mut self, other: Self) {
debug_assert_eq!(
(self.width, self.height),
(other.width, other.height),
"instances from one detection run share the frame they are defined over"
);
for (mine, theirs) in self.mask.iter_mut().zip(&other.mask) {
if *theirs > *mine {
*mine = *theirs;
}
}
self.bbox = (
self.bbox.0.min(other.bbox.0),
self.bbox.1.min(other.bbox.1),
self.bbox.2.max(other.bbox.2),
self.bbox.3.max(other.bbox.3),
);
if other.score > self.score {
self.score = other.score;
// The name travels with the score: they are one judgement, and a
// mask labelled by the less confident of two detections would be
// labelled by the one we just decided to trust less.
self.class_name = other.class_name;
}
}
fn iou(&self, other: &Self, threshold: f32) -> f32 { fn iou(&self, other: &Self, threshold: f32) -> f32 {
let mut inter = 0usize; let mut inter = 0usize;
let mut union = 0usize; let mut union = 0usize;
@@ -357,7 +390,6 @@ impl SemanticModel {
options, options,
)) ))
} }
} }
/// Turn one forward pass into instances in source-image space. /// Turn one forward pass into instances in source-image space.
@@ -543,7 +575,11 @@ fn assemble_mask(
let (gx1, gy1) = ((gx0 + 1).min(pw - 1), (gy0 + 1).min(ph - 1)); let (gx1, gy1) = ((gx0 + 1).min(pw - 1), (gy0 + 1).min(ph - 1));
let mut acc = 0.0; let mut acc = 0.0;
for (k, &c) in coeffs.iter().enumerate().take(PROTOTYPES.min(protos.shape()[0])) { for (k, &c) in coeffs
.iter()
.enumerate()
.take(PROTOTYPES.min(protos.shape()[0]))
{
if c == 0.0 { if c == 0.0 {
continue; continue;
} }
@@ -567,8 +603,33 @@ fn assemble_mask(
/// ///
/// Only needed for [`Tiling::Grid`]: an object straddling a seam is seen by /// Only needed for [`Tiling::Grid`]: an object straddling a seam is seen by
/// both tiles, and without this it would appear twice in the list a person /// both tiles, and without this it would appear twice in the list a person
/// chooses from. Keeps the higher-scoring copy, which is generally the tile /// chooses from.
/// that saw more of the object. ///
/// # Why the two are unioned rather than one of them chosen
///
/// This kept the higher-scoring copy and discarded the other. That is the
/// wrong answer for the case tiling exists to serve, and it quietly threw away
/// what tiling had just paid for.
///
/// A tile sees the part of an object that falls inside it and nothing of the
/// rest, so an object on a seam produces *two partial masks*, neither of them
/// the object. Keeping the better one keeps the larger fragment — a bird with
/// its tail cut off at the tile edge, described by whichever tile held more of
/// the bird. Both halves exist; only one survived.
///
/// Unioning is sound precisely because the tiles overlap. With a 25% overlap
/// every pixel is seen by at least one tile at full resolution and pixels near
/// a seam by two, so the pointwise maximum of the two coverages is the better
/// estimate everywhere rather than a compromise: where only one tile saw a
/// pixel it is the only opinion available, and where both did, the higher
/// value comes from the tile that had more context around it. Taking a maximum
/// of soft coverage also keeps the mask soft, which is what `prior.rs` weights
/// merges by and what a mask layer's edge treatment needs.
///
/// The score is the higher of the two rather than a blend. It is shown to a
/// photographer beside the class name and means "how sure the model is this is
/// a bird"; averaging in the tile that saw only a wingtip would make a
/// confident detection look doubtful for straddling a seam.
fn merge_into(found: &mut Vec<Instance>, batch: Vec<Instance>, options: &SemanticOptions) { fn merge_into(found: &mut Vec<Instance>, batch: Vec<Instance>, options: &SemanticOptions) {
for candidate in batch { for candidate in batch {
let duplicate = found.iter_mut().find(|existing| { let duplicate = found.iter_mut().find(|existing| {
@@ -577,8 +638,7 @@ fn merge_into(found: &mut Vec<Instance>, batch: Vec<Instance>, options: &Semanti
}); });
match duplicate { match duplicate {
Some(existing) if existing.score < candidate.score => *existing = candidate, Some(existing) => existing.absorb(candidate),
Some(_) => {}
None => found.push(candidate), None => found.push(candidate),
} }
} }
@@ -643,7 +703,12 @@ mod tests {
#[test] #[test]
fn letterbox_round_trips_a_landscape_window() { fn letterbox_round_trips_a_landscape_window() {
let window = Window { x: 0.0, y: 0.0, w: 1600.0, h: 1067.0 }; let window = Window {
x: 0.0,
y: 0.0,
w: 1600.0,
h: 1067.0,
};
let lb = Letterbox::fit(window.w, window.h); let lb = Letterbox::fit(window.w, window.h);
// A source point maps into the input and back to where it started. // A source point maps into the input and back to where it started.
@@ -663,6 +728,79 @@ mod tests {
assert!(lb.pad_y > 100.0, "wide image should pad in y: {}", lb.pad_y); assert!(lb.pad_y > 100.0, "wide image should pad in y: {}", lb.pad_y);
} }
/// A subject on a seam, seen in part by each of two tiles.
///
/// The failure this pins down is not a crash and not a duplicate: it is a
/// mask that looks plausible and is missing the half of the subject that
/// fell in the other tile. Keeping the higher-scoring detection produced
/// exactly that, and it is invisible unless you already know what the
/// whole subject should have been.
#[test]
fn two_tiles_seeing_one_subject_produce_the_whole_subject() {
// The left tile sees the left half strongly and nothing of the right;
// the right tile sees the right half. Together they are one bar.
let left = instance_with(
&[0.9, 0.9, 0.9, 0.8, 0.0, 0.0, 0.0, 0.0],
0.80,
(0.0, 0.0, 4.0, 1.0),
);
let right = instance_with(
&[0.0, 0.0, 0.3, 0.7, 0.9, 0.9, 0.9, 0.0],
0.60,
(2.0, 0.0, 7.0, 1.0),
);
let options = SemanticOptions {
mask_threshold: 0.5,
merge_iou: 0.1,
..SemanticOptions::default()
};
let mut found = vec![left];
merge_into(&mut found, vec![right], &options);
assert_eq!(found.len(), 1, "one subject, not two");
let m = &found[0];
// Every pixel either tile was sure about survives. Under the old
// keep-the-better-one rule, pixels 4..=6 were lost entirely.
for i in [0, 1, 2, 3, 4, 5, 6] {
assert!(
m.mask[i] >= 0.5,
"pixel {i} was seen by a tile and must survive the merge: {:?}",
m.mask
);
}
assert!(m.mask[7] < 0.5, "a pixel neither tile saw must stay out");
// Pointwise maximum, not an average: pixel 2 is 0.9 in one tile and
// 0.3 in the other, and averaging would report 0.6 — a softer edge
// than either tile actually saw.
assert!(
(m.mask[2] - 0.9).abs() < 1e-6,
"expected the max, got {}",
m.mask[2]
);
assert!(
(m.score - 0.80).abs() < 1e-6,
"the confident detection's score survives"
);
assert_eq!(m.bbox, (0.0, 0.0, 7.0, 1.0), "the box covers both halves");
}
fn instance_with(mask: &[f32], score: f32, bbox: (f32, f32, f32, f32)) -> Instance {
Instance {
class_id: 14,
class_name: "bird".into(),
score,
bbox,
mask: mask.to_vec(),
width: mask.len(),
height: 1,
}
}
/// The seam case tiling exists for, and the one it must not double-count. /// The seam case tiling exists for, and the one it must not double-count.
#[test] #[test]
fn grid_tiling_covers_the_frame_with_overlap() { fn grid_tiling_covers_the_frame_with_overlap() {
+4 -2
View File
@@ -17,9 +17,11 @@ pub use colour::{Chromaticities, Transfer};
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier}; pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
pub use settings::{ pub use settings::{
CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings, 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,
}; };
pub use time::{civil_from_unix, civil_from_unix_at, format_date, Civil};
/// Identifies a granted library location — a directory on Linux, a persisted /// Identifies a granted library location — a directory on Linux, a persisted
/// document tree on Android. /// document tree on Android.
+111 -13
View File
@@ -53,6 +53,60 @@ pub struct Settings {
pub export: ExportSettings, pub export: ExportSettings,
pub develop: DevelopSettings, pub develop: DevelopSettings,
pub import: ImportSettings, 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 }
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -62,9 +116,14 @@ pub struct Settings {
/// TRACES: FR-CAT-10 | FR-NC-7a /// TRACES: FR-CAT-10 | FR-NC-7a
/// What an import defaults to, remembered between cards. /// What an import defaults to, remembered between cards.
/// ///
/// A photographer imports the same way every time — same folders, same /// A photographer imports the same way every time — same folders, same answer
/// destination, same answer about the card — and retyping a template on every /// about the card — and retyping a template on every card is the kind of
/// card is the kind of friction that makes people stop using the importer. /// friction that makes people stop using the importer.
///
/// **There is no destination here.** The library is the folder on the server
/// (FR-NC-6), so an import has exactly one place to go and nothing to ask.
/// What lands on this machine is a staging copy the app owns and removes once
/// the server confirms it (FR-NC-7b), which is not a setting either.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct ImportSettings { pub struct ImportSettings {
@@ -76,15 +135,13 @@ pub struct ImportSettings {
/// defined — so it owns the default too, and this records only a /// defined — so it owns the default too, and this records only a
/// deliberate departure from it. /// deliberate departure from it.
pub folder_template: String, pub folder_template: String,
/// Where originals are copied to on this machine. Empty until chosen.
///
/// A local folder even on a library that lives on a server: FR-NC-7b has
/// the bytes land and be verified here first, and the upload follow.
pub destination: String,
/// The optional second copy (FR-CAT-10). Empty means none. /// The optional second copy (FR-CAT-10). Empty means none.
///
/// The only local path an import has, and deliberately so: the library
/// lives on the server, so there is no *first* destination for the user to
/// choose. This is an extra copy kept somewhere of their choosing — an
/// external drive, another disk — and nothing reads it back.
pub backup: String, pub backup: String,
/// Whether to send imported originals on to the server (FR-NC-7b).
pub upload: bool,
/// Whether the card is to be emptied once everything is confirmed. /// Whether the card is to be emptied once everything is confirmed.
/// ///
/// **Defaults to false and is the one setting worth not remembering /// **Defaults to false and is the one setting worth not remembering
@@ -109,9 +166,7 @@ impl Default for ImportSettings {
fn default() -> Self { fn default() -> Self {
Self { Self {
folder_template: String::new(), folder_template: String::new(),
destination: String::new(),
backup: String::new(), backup: String::new(),
upload: true,
// The one irreversible thing this application does starts off. // The one irreversible thing this application does starts off.
move_from_card: false, move_from_card: false,
verify: true, verify: true,
@@ -708,6 +763,18 @@ impl Settings {
pub fn sanitise(&mut self) { pub fn sanitise(&mut self) {
self.export.quality = self.export.quality.clamp(1, 100); 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 // 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 // smallest thing that does, rather than back to the default: the user
// clearly wanted "small", and silently restoring 2048 would ignore // clearly wanted "small", and silently restoring 2048 would ignore
@@ -1049,6 +1116,38 @@ mod tests {
assert_eq!(s.export.quality, 1); 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] #[test]
fn sanitise_rescues_a_zero_dimension() { fn sanitise_rescues_a_zero_dimension() {
let mut s = Settings::default(); let mut s = Settings::default();
@@ -1136,7 +1235,6 @@ mod import_settings_tests {
let s: Settings = serde_json::from_str(stored).unwrap(); let s: Settings = serde_json::from_str(stored).unwrap();
assert!(s.import.verify); assert!(s.import.verify);
assert!(s.import.skip_duplicates); assert!(s.import.skip_duplicates);
assert!(s.import.upload);
// And the irreversible one stays off. // And the irreversible one stays off.
assert!(!s.import.move_from_card); assert!(!s.import.move_from_card);
} }
+107
View File
@@ -64,6 +64,68 @@ pub fn civil_from_unix_at(t: i64, offset: Option<i32>) -> Civil {
civil_from_unix(t + offset.unwrap_or(0) as i64 * 60) civil_from_unix(t + offset.unwrap_or(0) as i64 * 60)
} }
/// TRACES: FR-CAT-6
/// A civil date back to Unix seconds — midnight UTC on that day.
///
/// The exact inverse of [`civil_from_unix`]'s date part, by the companion
/// era-based algorithm, so a date formatted by [`format_date`] and read back
/// here lands on the same day it named. Hand-rolled for the same reason the
/// forward direction is: four fields do not justify a date library, and two
/// implementations that disagree would put a photograph on a day the timeline
/// does not show it.
///
/// Midnight rather than noon because a range is half-open in spirit: "from the
/// 3rd" means from the first instant of the 3rd, and the caller decides what
/// "to the 5th" means by adding a day if it wants the 5th included.
pub fn unix_from_civil(year: i64, month: i64, day: i64) -> i64 {
let y = if month <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = if month > 2 { month - 3 } else { month + 9 };
let doy = (153 * mp + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
(era * 146_097 + doe - 719_468) * 86_400
}
/// TRACES: FR-CAT-6
/// `YYYY-MM-DD` to Unix seconds, or `None` if that is not what it is.
///
/// Strict on purpose. This reads what a person typed into a date field, and
/// the useful answer to "13/2026/08" is that it is not a date — guessing at an
/// order would silently filter a library to a span nobody asked for, and the
/// grid going empty is a poor way to learn your input was misread.
///
/// The day is checked against the month it is in, so `2026-02-30` is refused
/// rather than quietly becoming the 2nd of March.
pub fn parse_date(text: &str) -> Option<i64> {
let text = text.trim();
let mut parts = text.split('-');
let year: i64 = parts.next()?.parse().ok()?;
let month: i64 = parts.next()?.parse().ok()?;
let day: i64 = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
if !(1..=12).contains(&month) || day < 1 {
return None;
}
if day > days_in_month(year, month) {
return None;
}
Some(unix_from_civil(year, month, day))
}
/// Length of a month, with the full Gregorian leap rule.
fn days_in_month(year: i64, month: i64) -> i64 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
2 => 28,
_ => 0,
}
}
/// A capture instant as `YYYY-MM-DD`. /// A capture instant as `YYYY-MM-DD`.
pub fn format_date(t: i64) -> String { pub fn format_date(t: i64) -> String {
let c = civil_from_unix(t); let c = civil_from_unix(t);
@@ -74,6 +136,51 @@ pub fn format_date(t: i64) -> String {
mod tests { mod tests {
use super::*; use super::*;
/// The two directions must agree, or a photograph is filed on a day the
/// timeline does not show it on — the failure this module exists to stop.
#[test]
fn a_date_survives_the_round_trip() {
for t in [
0, // the epoch
1_760_000_000, // an ordinary recent instant
951_782_400, // 2000-02-29, the leap day the 100/400 rule keeps
-86_400, // the day before the epoch
] {
let c = civil_from_unix(t);
let back = unix_from_civil(c.year, c.month, c.day);
assert_eq!(
civil_from_unix(back),
Civil { hour: 0, ..c },
"{t} -> {c:?} -> {back} disagreed"
);
}
}
#[test]
fn parsing_accepts_a_date_and_refuses_everything_else() {
assert_eq!(parse_date("1970-01-01"), Some(0));
assert_eq!(parse_date(" 2026-08-23 "), parse_date("2026-08-23"));
assert_eq!(format_date(parse_date("2026-08-23").unwrap()), "2026-08-23");
// A leap day is real in 2024 and not in 2026.
assert!(parse_date("2024-02-29").is_some());
assert_eq!(parse_date("2026-02-29"), None);
// Refused rather than guessed at: silently reordering these would
// filter the library to a span nobody asked for.
assert_eq!(
parse_date("23-08-2026"),
None,
"day-first is not a format here"
);
assert_eq!(parse_date("2026-13-01"), None, "there is no month 13");
assert_eq!(parse_date("2026-08-32"), None);
assert_eq!(parse_date("2026-08"), None, "a month is not a date");
assert_eq!(parse_date("2026-08-23-01"), None);
assert_eq!(parse_date(""), None);
assert_eq!(parse_date("yesterday"), None);
}
#[test] #[test]
fn the_epoch_is_the_first_of_january_1970() { fn the_epoch_is_the_first_of_january_1970() {
let c = civil_from_unix(0); let c = civil_from_unix(0);
+22
View File
@@ -27,6 +27,26 @@ CACHE="${XDG_CACHE_HOME:-${HOME}/.cache}/darkroom-android"
OUT="${CACHE}/target/apk" OUT="${CACHE}/target/apk"
APK="${OUT}/darkroom.apk" APK="${OUT}/darkroom.apk"
# The version, taken from the workspace rather than restated here.
#
# `AndroidManifest.xml` deliberately declares none: a manifest that states a
# version is a second place for one to be wrong, and it was — the APK reported
# `versionName=null` and `versionCode=0` on the device while the binary inside
# it knew perfectly well what it was.
#
# `versionCode` must be a single increasing integer, which a semantic version
# is not, so it is packed: MAJOR*10000 + MINOR*100 + PATCH. That keeps the
# ordering Android needs (it refuses to install an APK whose code is lower than
# the installed one) and stays readable — 0.4.0 is 400. It allows 99 minors and
# 99 patches per major, which is a limit worth knowing about and a long way off.
VERSION_NAME="$(sed -n 's/^version = "\(.*\)"$/\1/p' "${REPO}/Cargo.toml" | head -1)"
if [[ -z "${VERSION_NAME}" ]]; then
echo "error: no version in Cargo.toml" >&2
exit 1
fi
VERSION_CODE="$(awk -F. '{ print $1 * 10000 + $2 * 100 + $3 }' <<< "${VERSION_NAME}")"
echo "==> version ${VERSION_NAME} (code ${VERSION_CODE})"
INSTALL=0 INSTALL=0
[[ "${1:-}" == "--install" ]] && INSTALL=1 [[ "${1:-}" == "--install" ]] && INSTALL=1
@@ -100,6 +120,8 @@ echo "==> packaging APK"
-R "${OUT}/res.zip" \ -R "${OUT}/res.zip" \
--min-sdk-version 28 \ --min-sdk-version 28 \
--target-sdk-version 36 \ --target-sdk-version 36 \
--version-name "'"${VERSION_NAME}"'" \
--version-code "'"${VERSION_CODE}"'" \
-o "${OUT}/base.apk" \ -o "${OUT}/base.apk" \
--auto-add-overlay --auto-add-overlay
+23 -6
View File
@@ -654,14 +654,31 @@ Two consequences the mechanics do not give for free:
Sidecars follow their image, not the template: they are named from `oc:fileid` (FR-NC-8) and live Sidecars follow their image, not the template: they are named from `oc:fileid` (FR-NC-8) and live
beside the file they describe. beside the file they describe.
**FR-NC-7b — Ingest to remote.** Import (FR-CAT-10) and upload (FR-NC-7) shall compose: a card **FR-NC-7b — Ingest to remote.** Import (FR-CAT-10) and upload (FR-NC-7) compose, and the library
import may target a Nextcloud library, in which case files are copied locally first, verified by an import targets is **always** the remote one. There is no local library for a card to land in:
checksum, and only then queued for upload. The local copy is not deleted on a move-import until FR-NC-6 puts the library on the server, so an import has exactly one destination and the interface
the upload is confirmed — a card erased against an in-flight upload is unrecoverable, which is the shall not offer a choice of another.
one failure in this app with no undo.
What lands on the device is a **staging copy**, in a location the app owns, and it is not a
library: no view lists it, and it is removed once the server confirms the file. A staged file the
server did not take remains queued, and a later import drains the queue. An import with no
reachable server therefore succeeds and defers, rather than failing (FR-NC-10); an import with no
*account* has nowhere to go at all and shall be refused.
The bytes shall reach the device before they reach the network. Streaming a card straight to the
server would make a move-import erase a card against an in-flight upload, and would make importing
impossible offline.
**On erasing the card.** A move-import shall delete from the card only those photographs the
**server has confirmed** — not those merely written to the staging copy, since that copy is removed
as soon as the upload succeeds and would otherwise be the only remaining copy. Anything that does
not upload keeps its card copy. A card erased against an unfinished upload is unrecoverable, which
is the one failure in this app with no undo.
Duplicate detection (FR-CAT-11) runs against the catalog before upload, so re-inserting a card Duplicate detection (FR-CAT-11) runs against the catalog before upload, so re-inserting a card
that was already imported transfers nothing. that was already imported transfers nothing. Where the catalog cannot answer — a file already in
the destination folder, locally or on the server — the folder's own listing shall answer instead,
so a re-import neither duplicates nor renames what is already held.
**FR-NC-8 — Edit metadata sync.** Edit graphs sync bidirectionally as sidecars, one per image, **FR-NC-8 — Edit metadata sync.** Edit graphs sync bidirectionally as sidecars, one per image,
named deterministically from `oc:fileid`. Each sidecar carries a monotonic revision counter, a named deterministically from `oc:fileid`. Each sidecar carries a monotonic revision counter, a
+55 -55
View File
@@ -10,7 +10,7 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
| Metric | Value | | Metric | Value |
|---|---| |---|---|
| Source files scanned | 180 | | Source files scanned | 180 |
| TRACES tags found | 516 | | TRACES tags found | 555 |
| Requirements defined | 177 | | Requirements defined | 177 |
| Requirements covered | 89 | | Requirements covered | 89 |
| **Coverage** | **50.3%** (89/177) | | **Coverage** | **50.3%** (89/177) |
@@ -33,92 +33,92 @@ _None._
| ID | Tagged in | | 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:198`](../core/dr-types/src/lib.rs#L198), [`core/dr-types/src/lib.rs:267`](../core/dr-types/src/lib.rs#L267), [`core/dr-types/src/lib.rs:300`](../core/dr-types/src/lib.rs#L300), [`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-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_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:916`](../ui/dr-ui/src/lib.rs#L916), [`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:916`](../ui/dr-ui/src/lib.rs#L916), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2052`](../ui/dr-ui/src/library.rs#L2052), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | 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:111`](../core/dr-pipeline/src/sidecar.rs#L111) | | 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-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:1171`](../ui/dr-ui/src/collections_ui.rs#L1171), [`ui/dr-ui/src/collections_ui.rs:1846`](../ui/dr-ui/src/collections_ui.rs#L1846), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2933`](../ui/dr-ui/src/library.rs#L2933), [`ui/dr-ui/src/library.rs:2965`](../ui/dr-ui/src/library.rs#L2965), [`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:51`](../core/dr-types/src/lib.rs#L51) | | 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-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:373`](../ui/dr-ui/src/import.rs#L373), [`ui/dr-ui/src/import.rs:398`](../ui/dr-ui/src/import.rs#L398), [`ui/dr-ui/src/library.rs:2528`](../ui/dr-ui/src/library.rs#L2528), [`ui/dr-ui/src/library.rs:2554`](../ui/dr-ui/src/library.rs#L2554), [`ui/dr-ui/src/library_ui.rs:115`](../ui/dr-ui/src/library_ui.rs#L115), [`ui/dr-ui/src/library_ui.rs:3339`](../ui/dr-ui/src/library_ui.rs#L3339), [`ui/dr-ui/src/library_ui.rs:4113`](../ui/dr-ui/src/library_ui.rs#L4113), [`ui/dr-ui/ui/app.slint:528`](../ui/dr-ui/ui/app.slint#L528), [`ui/dr-ui/ui/settings.slint:316`](../ui/dr-ui/ui/settings.slint#L316), [`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-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:401`](../core/dr-decode/src/lib.rs#L401), [`core/dr-pipeline/src/sidecar.rs:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library_ui.rs:5391`](../ui/dr-ui/src/library_ui.rs#L5391), [`ui/dr-ui/src/library_ui.rs:5402`](../ui/dr-ui/src/library_ui.rs#L5402), [`ui/dr-ui/src/library_ui.rs:5415`](../ui/dr-ui/src/library_ui.rs#L5415), [`ui/dr-ui/src/library_ui.rs:5430`](../ui/dr-ui/src/library_ui.rs#L5430), [`ui/dr-ui/src/library_ui.rs:5439`](../ui/dr-ui/src/library_ui.rs#L5439), [`ui/dr-ui/ui/app.slint:600`](../ui/dr-ui/ui/app.slint#L600), [`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-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), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/ui/app.slint:600`](../ui/dr-ui/ui/app.slint#L600), [`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/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:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/ui/app.slint:595`](../ui/dr-ui/ui/app.slint#L595), [`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-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:141`](../core/dr-pipeline/src/ops/curve.rs#L141), [`core/dr-pipeline/src/ops/curve.rs:657`](../core/dr-pipeline/src/ops/curve.rs#L657), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/tests/tone_curve.rs:32`](../core/dr-pipeline/tests/tone_curve.rs#L32), [`ui/dr-ui/src/develop.rs:2237`](../ui/dr-ui/src/develop.rs#L2237), [`ui/dr-ui/src/export.rs:749`](../ui/dr-ui/src/export.rs#L749), [`ui/dr-ui/src/lib.rs:1140`](../ui/dr-ui/src/lib.rs#L1140), [`ui/dr-ui/src/lib.rs:1480`](../ui/dr-ui/src/lib.rs#L1480), [`ui/dr-ui/src/lib.rs:1585`](../ui/dr-ui/src/lib.rs#L1585), [`ui/dr-ui/src/lib.rs:464`](../ui/dr-ui/src/lib.rs#L464), [`ui/dr-ui/src/lib.rs:768`](../ui/dr-ui/src/lib.rs#L768), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library_ui.rs:4402`](../ui/dr-ui/src/library_ui.rs#L4402), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | 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:117`](../core/dr-types/src/lib.rs#L117), [`ui/dr-ui/src/develop.rs:1945`](../ui/dr-ui/src/develop.rs#L1945), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:3148`](../ui/dr-ui/src/library.rs#L3148), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:610`](../ui/dr-ui/src/library.rs#L610), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library_ui.rs: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:2548`](../ui/dr-ui/src/library_ui.rs#L2548), [`ui/dr-ui/src/library_ui.rs:2730`](../ui/dr-ui/src/library_ui.rs#L2730), [`ui/dr-ui/src/library_ui.rs:3011`](../ui/dr-ui/src/library_ui.rs#L3011), [`ui/dr-ui/src/library_ui.rs:3083`](../ui/dr-ui/src/library_ui.rs#L3083), [`ui/dr-ui/src/library_ui.rs:3248`](../ui/dr-ui/src/library_ui.rs#L3248), [`ui/dr-ui/src/library_ui.rs:3366`](../ui/dr-ui/src/library_ui.rs#L3366), [`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:4632`](../ui/dr-ui/src/library_ui.rs#L4632), [`ui/dr-ui/src/library_ui.rs:4747`](../ui/dr-ui/src/library_ui.rs#L4747), [`ui/dr-ui/src/presets.rs:284`](../ui/dr-ui/src/presets.rs#L284), [`ui/dr-ui/src/presets.rs:296`](../ui/dr-ui/src/presets.rs#L296), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-CAT-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-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:373`](../ui/dr-ui/src/import.rs#L373) | | 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:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340) | | FR-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-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:121`](../core/dr-pipeline/src/graph.rs#L121), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:439`](../core/dr-pipeline/src/operation.rs#L439), [`core/dr-pipeline/src/ops/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:223`](../core/dr-pipeline/src/ops/curve.rs#L223), [`core/dr-pipeline/src/ops/curve.rs:636`](../core/dr-pipeline/src/ops/curve.rs#L636), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/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:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:1328`](../core/dr-pipeline/src/sidecar.rs#L1328), [`core/dr-pipeline/src/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1298`](../ui/dr-ui/src/develop.rs#L1298), [`ui/dr-ui/src/develop.rs:1313`](../ui/dr-ui/src/develop.rs#L1313), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1418`](../ui/dr-ui/src/develop.rs#L1418), [`ui/dr-ui/src/develop.rs:1492`](../ui/dr-ui/src/develop.rs#L1492), [`ui/dr-ui/src/develop.rs:243`](../ui/dr-ui/src/develop.rs#L243), [`ui/dr-ui/src/develop.rs:2595`](../ui/dr-ui/src/develop.rs#L2595), [`ui/dr-ui/src/develop.rs:2649`](../ui/dr-ui/src/develop.rs#L2649), [`ui/dr-ui/src/develop.rs:276`](../ui/dr-ui/src/develop.rs#L276), [`ui/dr-ui/src/develop.rs:830`](../ui/dr-ui/src/develop.rs#L830), [`ui/dr-ui/src/lib.rs:1176`](../ui/dr-ui/src/lib.rs#L1176), [`ui/dr-ui/src/lib.rs:1828`](../ui/dr-ui/src/lib.rs#L1828), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1884`](../ui/dr-ui/ui/app.slint#L1884) | | 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:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:19`](../core/dr-pipeline/src/graph.rs#L19), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/mask.rs:950`](../core/dr-pipeline/src/mask.rs#L950), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294), [`core/dr-pipeline/src/ops/curve.rs:323`](../core/dr-pipeline/src/ops/curve.rs#L323), [`ui/dr-ui/src/develop.rs:727`](../ui/dr-ui/src/develop.rs#L727), [`ui/dr-ui/src/lib.rs:526`](../ui/dr-ui/src/lib.rs#L526) | | 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:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294) | | 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:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/mask.rs:950`](../core/dr-pipeline/src/mask.rs#L950), [`ui/dr-ui/src/develop.rs:3228`](../ui/dr-ui/src/develop.rs#L3228) | | 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) |
| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:709`](../core/dr-gpu/src/adjust.rs#L709), [`core/dr-gpu/src/adjust.rs:764`](../core/dr-gpu/src/adjust.rs#L764), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/src/adjust.rs:97`](../core/dr-gpu/src/adjust.rs#L97), [`core/dr-gpu/tests/capture_sharpen.rs:431`](../core/dr-gpu/tests/capture_sharpen.rs#L431), [`core/dr-gpu/tests/detail_stage.rs:240`](../core/dr-gpu/tests/detail_stage.rs#L240), [`core/dr-gpu/tests/local_contrast.rs:467`](../core/dr-gpu/tests/local_contrast.rs#L467), [`core/dr-gpu/tests/noise_reduction.rs:513`](../core/dr-gpu/tests/noise_reduction.rs#L513), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:420`](../core/dr-pipeline/src/graph.rs#L420), [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) | | FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:709`](../core/dr-gpu/src/adjust.rs#L709), [`core/dr-gpu/src/adjust.rs:764`](../core/dr-gpu/src/adjust.rs#L764), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/src/adjust.rs:97`](../core/dr-gpu/src/adjust.rs#L97), [`core/dr-gpu/tests/capture_sharpen.rs:429`](../core/dr-gpu/tests/capture_sharpen.rs#L429), [`core/dr-gpu/tests/detail_stage.rs:241`](../core/dr-gpu/tests/detail_stage.rs#L241), [`core/dr-gpu/tests/local_contrast.rs:475`](../core/dr-gpu/tests/local_contrast.rs#L475), [`core/dr-gpu/tests/noise_reduction.rs:555`](../core/dr-gpu/tests/noise_reduction.rs#L555), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:426`](../core/dr-pipeline/src/graph.rs#L426), [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) |
| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:702`](../core/dr-decode/src/lib.rs#L702), [`core/dr-decode/src/lib.rs:742`](../core/dr-decode/src/lib.rs#L742), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:690`](../core/dr-gpu/src/adjust.rs#L690), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1344`](../core/dr-pipeline/src/operation.rs#L1344), [`core/dr-pipeline/src/operation.rs:1369`](../core/dr-pipeline/src/operation.rs#L1369), [`core/dr-pipeline/src/operation.rs:1384`](../core/dr-pipeline/src/operation.rs#L1384), [`core/dr-pipeline/src/operation.rs:1405`](../core/dr-pipeline/src/operation.rs#L1405), [`core/dr-pipeline/src/operation.rs:369`](../core/dr-pipeline/src/operation.rs#L369), [`core/dr-pipeline/src/operation.rs:379`](../core/dr-pipeline/src/operation.rs#L379), [`core/dr-pipeline/src/operation.rs:513`](../core/dr-pipeline/src/operation.rs#L513) | | FR-DEV-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:401`](../core/dr-decode/src/lib.rs#L401), [`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:334`](../core/dr-types/src/lib.rs#L334) | | 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-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:2253`](../ui/dr-ui/src/develop.rs#L2253), [`ui/dr-ui/src/develop.rs:225`](../ui/dr-ui/src/develop.rs#L225), [`ui/dr-ui/src/develop.rs:2263`](../ui/dr-ui/src/develop.rs#L2263), [`ui/dr-ui/src/lib.rs:1168`](../ui/dr-ui/src/lib.rs#L1168) | | 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:127`](../core/dr-types/src/settings.rs#L127), [`ui/dr-ui/src/develop.rs:2214`](../ui/dr-ui/src/develop.rs#L2214), [`ui/dr-ui/src/develop.rs:2224`](../ui/dr-ui/src/develop.rs#L2224), [`ui/dr-ui/src/lib.rs:1140`](../ui/dr-ui/src/lib.rs#L1140), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2379`](../ui/dr-ui/src/library_ui.rs#L2379), [`ui/dr-ui/src/library_ui.rs:2730`](../ui/dr-ui/src/library_ui.rs#L2730), [`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:1105`](../ui/dr-ui/ui/library.slint#L1105), [`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-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-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:204`](../core/dr-gpu/tests/capture_sharpen.rs#L204), [`core/dr-gpu/tests/detail_stage.rs:322`](../core/dr-gpu/tests/detail_stage.rs#L322), [`core/dr-gpu/tests/local_contrast.rs:254`](../core/dr-gpu/tests/local_contrast.rs#L254), [`core/dr-gpu/tests/noise_reduction.rs:365`](../core/dr-gpu/tests/noise_reduction.rs#L365), [`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:368`](../core/dr-pipeline/src/graph.rs#L368), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`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:655`](../core/dr-pipeline/src/ops/local_contrast.rs#L655), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:1781`](../ui/dr-ui/src/develop.rs#L1781), [`ui/dr-ui/src/develop.rs:2401`](../ui/dr-ui/src/develop.rs#L2401), [`ui/dr-ui/src/develop.rs:2773`](../ui/dr-ui/src/develop.rs#L2773), [`ui/dr-ui/src/develop.rs:2807`](../ui/dr-ui/src/develop.rs#L2807), [`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-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-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:1826`](../ui/dr-ui/src/develop.rs#L1826), [`ui/dr-ui/src/develop.rs:236`](../ui/dr-ui/src/develop.rs#L236), [`ui/dr-ui/src/develop.rs:3875`](../ui/dr-ui/src/develop.rs#L3875), [`ui/dr-ui/src/develop.rs:3907`](../ui/dr-ui/src/develop.rs#L3907), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1227`](../ui/dr-ui/src/lib.rs#L1227), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:305`](../ui/dr-ui/ui/app.slint#L305), [`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-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:358`](../core/dr-pipeline/src/graph.rs#L358), [`core/dr-pipeline/src/graph.rs:404`](../core/dr-pipeline/src/graph.rs#L404), [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:540`](../core/dr-types/src/settings.rs#L540), [`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-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-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-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-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:1751`](../ui/dr-ui/src/lib.rs#L1751), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175), [`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:3100`](../ui/dr-ui/src/library_ui.rs#L3100), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`ui/dr-ui/src/library_ui.rs:5373`](../ui/dr-ui/src/library_ui.rs#L5373), [`ui/dr-ui/src/library_ui.rs:5450`](../ui/dr-ui/src/library_ui.rs#L5450), [`ui/dr-ui/src/library_ui.rs:5462`](../ui/dr-ui/src/library_ui.rs#L5462), [`ui/dr-ui/src/library_ui.rs:546`](../ui/dr-ui/src/library_ui.rs#L546), [`ui/dr-ui/src/library_ui.rs:603`](../ui/dr-ui/src/library_ui.rs#L603), [`ui/dr-ui/ui/app.slint:1353`](../ui/dr-ui/ui/app.slint#L1353), [`ui/dr-ui/ui/app.slint:992`](../ui/dr-ui/ui/app.slint#L992), [`ui/dr-ui/ui/library.slint:1113`](../ui/dr-ui/ui/library.slint#L1113), [`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-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:1163`](../core/dr-decode/src/locate.rs#L1163), [`core/dr-decode/src/locate.rs:1221`](../core/dr-decode/src/locate.rs#L1221), [`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:119`](../core/dr-export/src/encode.rs#L119), [`core/dr-export/src/encode.rs:163`](../core/dr-export/src/encode.rs#L163), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:208`](../core/dr-export/src/encode.rs#L208), [`core/dr-export/src/encode.rs:237`](../core/dr-export/src/encode.rs#L237), [`core/dr-export/src/encode.rs:310`](../core/dr-export/src/encode.rs#L310), [`core/dr-export/src/encode.rs:324`](../core/dr-export/src/encode.rs#L324), [`core/dr-export/src/encode.rs:404`](../core/dr-export/src/encode.rs#L404), [`core/dr-export/src/encode.rs:452`](../core/dr-export/src/encode.rs#L452), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:791`](../core/dr-export/src/encode.rs#L791), [`core/dr-export/src/encode.rs:805`](../core/dr-export/src/encode.rs#L805), [`core/dr-export/src/encode.rs:846`](../core/dr-export/src/encode.rs#L846), [`core/dr-export/src/encode.rs:894`](../core/dr-export/src/encode.rs#L894), [`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:442`](../core/dr-types/src/lib.rs#L442), [`core/dr-types/src/settings.rs:258`](../core/dr-types/src/settings.rs#L258), [`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-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:503`](../core/dr-decode/src/lib.rs#L503), [`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:1904`](../ui/dr-ui/src/develop.rs#L1904), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328) | | 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-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:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1492`](../ui/dr-ui/src/library_ui.rs#L1492), [`ui/dr-ui/src/library_ui.rs:3100`](../ui/dr-ui/src/library_ui.rs#L3100), [`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-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-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:2528`](../ui/dr-ui/src/library.rs#L2528), [`ui/dr-ui/src/library.rs:2554`](../ui/dr-ui/src/library.rs#L2554), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3339`](../ui/dr-ui/src/library_ui.rs#L3339), [`ui/dr-ui/src/library_ui.rs:4113`](../ui/dr-ui/src/library_ui.rs#L4113), [`ui/dr-ui/ui/app.slint:528`](../ui/dr-ui/ui/app.slint#L528), [`ui/dr-ui/ui/settings.slint:316`](../ui/dr-ui/ui/settings.slint#L316), [`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-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:398`](../ui/dr-ui/src/import.rs#L398) | | 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-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:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/lib.rs:1511`](../ui/dr-ui/src/lib.rs#L1511), [`ui/dr-ui/src/lib.rs:2251`](../ui/dr-ui/src/lib.rs#L2251), [`ui/dr-ui/src/library.rs:1254`](../ui/dr-ui/src/library.rs#L1254), [`ui/dr-ui/src/library.rs:1277`](../ui/dr-ui/src/library.rs#L1277), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library_ui.rs: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:4649`](../ui/dr-ui/src/library_ui.rs#L4649), [`ui/dr-ui/src/library_ui.rs:4667`](../ui/dr-ui/src/library_ui.rs#L4667), [`ui/dr-ui/src/library_ui.rs:4679`](../ui/dr-ui/src/library_ui.rs#L4679), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`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:2359`](../ui/dr-ui/ui/app.slint#L2359), [`ui/dr-ui/ui/app.slint:589`](../ui/dr-ui/ui/app.slint#L589), [`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-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:1180`](../ui/dr-ui/src/library_ui.rs#L1180) | | 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:117`](../core/dr-types/src/lib.rs#L117), [`core/dr-types/src/lib.rs:199`](../core/dr-types/src/lib.rs#L199), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/library_ui.rs: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-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:2554`](../ui/dr-ui/src/library.rs#L2554), [`ui/dr-ui/src/library_ui.rs:3339`](../ui/dr-ui/src/library_ui.rs#L3339), [`ui/dr-ui/ui/settings.slint:316`](../ui/dr-ui/ui/settings.slint#L316) | | 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: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:75`](../ui/dr-ui/src/import.rs#L75), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:916`](../ui/dr-ui/src/lib.rs#L916), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | 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:75`](../ui/dr-ui/src/import.rs#L75), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:916`](../ui/dr-ui/src/lib.rs#L916) | | 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:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1480`](../ui/dr-ui/src/lib.rs#L1480), [`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:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`core/dr-pipeline/src/sidecar.rs:303`](../core/dr-pipeline/src/sidecar.rs#L303), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:810`](../ui/dr-ui/src/library.rs#L810) | | FR-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:51`](../core/dr-types/src/lib.rs#L51) | | 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) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | | FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:736`](../ui/dr-ui/src/lib.rs#L736), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:127`](../core/dr-types/src/lib.rs#L127), [`core/dr-types/src/lib.rs:198`](../core/dr-types/src/lib.rs#L198) | | FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:129`](../core/dr-types/src/lib.rs#L129), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200) |
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:503`](../core/dr-decode/src/lib.rs#L503), [`core/dr-decode/src/locate.rs:1364`](../core/dr-decode/src/locate.rs#L1364) | | 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-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-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:2333`](../ui/dr-ui/src/lib.rs#L2333), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/ui/library.slint:866`](../ui/dr-ui/ui/library.slint#L866) | | 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:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/lib.rs: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:4679`](../ui/dr-ui/src/library_ui.rs#L4679), [`ui/dr-ui/ui/app.slint:619`](../ui/dr-ui/ui/app.slint#L619), [`ui/dr-ui/ui/app.slint:626`](../ui/dr-ui/ui/app.slint#L626), [`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:1900`](../ui/dr-ui/ui/library.slint#L1900), [`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) | | 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:1492`](../ui/dr-ui/src/develop.rs#L1492), [`ui/dr-ui/src/library_ui.rs:3908`](../ui/dr-ui/src/library_ui.rs#L3908), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:792`](../ui/dr-ui/src/masks_ui.rs#L792), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1884`](../ui/dr-ui/ui/app.slint#L1884), [`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-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:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:537`](../ui/dr-ui/src/collections_ui.rs#L537), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/library_ui.rs:3908`](../ui/dr-ui/src/library_ui.rs#L3908), [`ui/dr-ui/src/library_ui.rs:3953`](../ui/dr-ui/src/library_ui.rs#L3953), [`ui/dr-ui/src/library_ui.rs:4056`](../ui/dr-ui/src/library_ui.rs#L4056), [`ui/dr-ui/src/library_ui.rs:4084`](../ui/dr-ui/src/library_ui.rs#L4084), [`ui/dr-ui/src/library_ui.rs:4667`](../ui/dr-ui/src/library_ui.rs#L4667), [`ui/dr-ui/src/library_ui.rs:4679`](../ui/dr-ui/src/library_ui.rs#L4679), [`ui/dr-ui/ui/app.slint:1580`](../ui/dr-ui/ui/app.slint#L1580), [`ui/dr-ui/ui/app.slint:595`](../ui/dr-ui/ui/app.slint#L595), [`ui/dr-ui/ui/app.slint:626`](../ui/dr-ui/ui/app.slint#L626), [`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:1900`](../ui/dr-ui/ui/library.slint#L1900), [`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-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:1978`](../ui/dr-ui/src/lib.rs#L1978), [`ui/dr-ui/src/lib.rs:2367`](../ui/dr-ui/src/lib.rs#L2367), [`ui/dr-ui/src/lib.rs:2552`](../ui/dr-ui/src/lib.rs#L2552), [`ui/dr-ui/src/masks_ui.rs:747`](../ui/dr-ui/src/masks_ui.rs#L747), [`ui/dr-ui/ui/app.slint:326`](../ui/dr-ui/ui/app.slint#L326), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | 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) | | 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-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:1785`](../ui/dr-ui/src/lib.rs#L1785), [`ui/dr-ui/ui/app.slint:1003`](../ui/dr-ui/ui/app.slint#L1003), [`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-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-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) | | NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
| NFR-P13 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) | | NFR-P13 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) |
| NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:941`](../ui/dr-ui/src/export.rs#L941), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) | | NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:941`](../ui/dr-ui/src/export.rs#L941), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) |
| NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:267`](../core/dr-types/src/lib.rs#L267), [`core/dr-types/src/lib.rs:300`](../core/dr-types/src/lib.rs#L300) | | NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`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) |
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877) | | NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:906`](../ui/dr-ui/src/library.rs#L906) |
| NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) | | NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) |
| NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | | NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) |
| NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59) | | NFR-RES-1 | [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59) |
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2528`](../ui/dr-ui/src/library.rs#L2528) | | NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2557`](../ui/dr-ui/src/library.rs#L2557) |
| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
| R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) | | R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) |
| R4 | [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | | R4 | [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) |
+46
View File
@@ -0,0 +1,46 @@
# Maintainer: Duncan Tourolle <duncan@tourolle.paris>
#
# Builds from the local checkout rather than a release tarball, which is what
# 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.6.0
pkgrel=1
pkgdesc="Non-destructive RAW photo library and editor"
arch=('x86_64')
url="https://gitea.tourolle.paris/dtourolle/DarkRoom"
license=('GPL-3.0-or-later')
# Runtime: Vulkan for wgpu, and a Secret Service implementation for the
# Nextcloud credentials (FR-NC-2) — gnome-keyring or kwallet both provide it.
depends=('vulkan-icd-loader' 'fontconfig' 'libxkbcommon')
makedepends=('cargo' 'git')
optdepends=('gnome-keyring: store Nextcloud credentials'
'kwallet: store Nextcloud credentials')
options=('!lto') # the workspace sets its own LTO in Cargo.toml
_repo="$(cd "${startdir}/.." && pwd)"
build() {
cd "${_repo}"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR="${_repo}/target"
cargo build --release --locked -p darkroom-desktop
}
package() {
cd "${_repo}"
install -Dm755 "target/release/darkroom-desktop" \
"${pkgdir}/usr/bin/darkroom-desktop"
install -Dm644 "packaging/paris.tourolle.darkroom.desktop" \
"${pkgdir}/usr/share/applications/paris.tourolle.darkroom.desktop"
# The icon's *name* is the contract, not its path: the desktop entry says
# `Icon=paris.tourolle.darkroom` and the compositor resolves that through
# the hicolor theme. Installed under 256x256 because that is the source's
# actual size — lying about it makes scaled-down icons look wrong.
install -Dm644 "ui/dr-ui/ui/app-icon.png" \
"${pkgdir}/usr/share/icons/hicolor/256x256/apps/paris.tourolle.darkroom.png"
install -Dm644 "README.md" "${pkgdir}/usr/share/doc/${pkgname}/README.md"
}
+18
View File
@@ -0,0 +1,18 @@
[Desktop Entry]
Type=Application
Name=DarkRoom
GenericName=Photo Editor
Comment=Non-destructive RAW photo library and editor
# The basename of this file is the contract: a Wayland compositor finds it by
# matching the surface's app_id, which `dr_ui::run` sets to the same string.
# Rename one without the other and the icon silently disappears.
Exec=darkroom-desktop %F
Icon=paris.tourolle.darkroom
Terminal=false
Categories=Graphics;Photography;RasterGraphics;2DGraphics;
Keywords=RAW;photo;photography;develop;darkroom;library;
# X11 falls back to matching the WM_CLASS instead of the app_id; winit sets it
# from the same string, so naming it here covers both display servers.
StartupWMClass=paris.tourolle.darkroom
StartupNotify=true
MimeType=image/x-canon-cr2;image/x-canon-cr3;image/x-nikon-nef;image/x-sony-arw;image/x-adobe-dng;image/x-fuji-raf;image/x-olympus-orf;image/x-panasonic-rw2;image/jpeg;image/png;image/tiff;
+6 -4
View File
@@ -17,7 +17,7 @@
# (see the workspace manifest for why that backend was chosen), and it is what # (see the workspace manifest for why that backend was chosen), and it is what
# makes the tiling option in `semantic.rs` the only route to more resolution. # makes the tiling option in `semantic.rs` the only route to more resolution.
# #
# `imgsz=640` square rather than a rectangle matched to 3:2: one graph has to # `imgsz` square rather than a rectangle matched to 3:2: one graph has to
# serve portrait, landscape, square crops and panoramas. A landscape-shaped # serve portrait, landscape, square crops and panoramas. A landscape-shaped
# graph trades letterbox waste on 3:2 for worse waste on everything else. # graph trades letterbox waste on 3:2 for worse waste on everything else.
set -euo pipefail set -euo pipefail
@@ -27,21 +27,23 @@ REPO="$(cd "${HERE}/.." && pwd)"
OUT="${REPO}/core/dr-segment/models" OUT="${REPO}/core/dr-segment/models"
MODEL="${1:-yolo26n-seg}" MODEL="${1:-yolo26n-seg}"
IMGSZ="${2:-640}"
WORK="$(mktemp -d)" WORK="$(mktemp -d)"
trap 'rm -rf "${WORK}"' EXIT trap 'rm -rf "${WORK}"' EXIT
echo "==> exporting ${MODEL} in ${WORK}" echo "==> exporting ${MODEL} at imgsz=${IMGSZ} in ${WORK}"
cd "${WORK}" cd "${WORK}"
uv venv --python 3.12 venv uv venv --python 3.12 venv
VIRTUAL_ENV="${WORK}/venv" uv pip install ultralytics onnx onnxslim VIRTUAL_ENV="${WORK}/venv" uv pip install ultralytics onnx onnxslim
VIRTUAL_ENV="${WORK}/venv" "${WORK}/venv/bin/python" - "${MODEL}" <<'PY' VIRTUAL_ENV="${WORK}/venv" "${WORK}/venv/bin/python" - "${MODEL}" "${IMGSZ}" <<'PY'
import sys, json import sys, json
from ultralytics import YOLO from ultralytics import YOLO
name = sys.argv[1] name = sys.argv[1]
imgsz = int(sys.argv[2])
m = YOLO(f"{name}.pt") m = YOLO(f"{name}.pt")
path = m.export(format="onnx", opset=17, simplify=True, imgsz=640, dynamic=False) path = m.export(format="onnx", opset=17, simplify=True, imgsz=imgsz, dynamic=False)
print("ONNX:", path) print("ONNX:", path)
# The class names travel with the model rather than being retyped into Rust — # The class names travel with the model rather than being retyped into Rust —
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Set the project's version in the few places that must state it, from one
# source, and optionally commit the result.
#
# tools/set-version.sh --from-tag # take it from the latest git tag
# tools/set-version.sh 0.5.0 # or state it
# tools/set-version.sh --from-tag --commit
#
# Why this exists: the workspace read 0.1.0 for two releases, so every binary
# reported a version two releases stale, and the APK reported none at all. A
# version that has to be edited in several files by hand is a version that will
# be wrong in at least one of them.
#
# The number of places is kept deliberately small. Only two files state the
# version; everything else derives it:
#
# Cargo.toml the source of truth for every crate in the workspace
# packaging/PKGBUILD pacman needs it before anything is built
#
# AndroidManifest.xml states NO version on purpose — `package.sh` passes
# it to `aapt2 link` from Cargo.toml, so the APK cannot
# drift from the binary inside it.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${REPO}"
COMMIT=0
VERSION=""
for arg in "$@"; do
case "${arg}" in
--commit) COMMIT=1 ;;
--from-tag) VERSION="$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')" ;;
-h|--help) sed -n '2,20p' "${BASH_SOURCE[0]}"; exit 0 ;;
-*) echo "error: unknown flag ${arg}" >&2; exit 2 ;;
*) VERSION="${arg#v}" ;;
esac
done
if [[ -z "${VERSION}" ]]; then
echo "error: no version given, and no tag to take one from" >&2
echo "usage: tools/set-version.sh [--from-tag | <version>] [--commit]" >&2
exit 2
fi
# Rejected rather than coerced. A version is a contract with whoever reads a
# bug report, and "0.4" or "v0.4.0-wip" silently becoming something else is
# worse than being told to type it again.
if ! [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "error: '${VERSION}' is not MAJOR.MINOR.PATCH" >&2
exit 2
fi
CURRENT="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)"
echo "==> ${CURRENT:-unknown} -> ${VERSION}"
# 1. The workspace. Every member inherits this through `version.workspace`.
# Anchored to the [workspace.package] table so a dependency's version
# string cannot be hit by accident.
python3 - "${VERSION}" <<'PY'
import re, sys, pathlib
version = sys.argv[1]
p = pathlib.Path("Cargo.toml")
s = p.read_text()
m = re.search(r"(\[workspace\.package\][^\[]*?\nversion = \")([^\"]+)(\")", s, re.S)
if not m:
sys.exit("error: no version under [workspace.package] in Cargo.toml")
p.write_text(s[:m.start(2)] + version + s[m.end(2):])
print(f" Cargo.toml {m.group(2)} -> {version}")
PY
# 2. pacman. `pkgrel` returns to 1: it counts packaging revisions *of one
# version*, so carrying it across a version bump would claim this is the
# second package of a version nobody has packaged yet.
python3 - "${VERSION}" <<'PY'
import re, sys, pathlib
version = sys.argv[1]
p = pathlib.Path("packaging/PKGBUILD")
s = p.read_text()
old = re.search(r"^pkgver=(.*)$", s, re.M)
s = re.sub(r"^pkgver=.*$", f"pkgver={version}", s, count=1, flags=re.M)
s = re.sub(r"^pkgrel=.*$", "pkgrel=1", s, count=1, flags=re.M)
p.write_text(s)
print(f" packaging/PKGBUILD {old.group(1) if old else '?'} -> {version}")
PY
# 3. Cargo.lock. The workspace members appear in it by version, so leaving it
# alone makes `--locked` builds fail — which is exactly what CI uses.
cargo update --workspace --offline >/dev/null 2>&1 \
|| cargo update --workspace >/dev/null 2>&1 \
|| echo " warning: could not refresh Cargo.lock; run 'cargo update --workspace'" >&2
echo " Cargo.lock refreshed"
echo "==> Android takes its version from Cargo.toml at link time; nothing to edit"
if [[ "${COMMIT}" == "1" ]]; then
git add Cargo.toml Cargo.lock packaging/PKGBUILD
if git diff --cached --quiet; then
echo "==> nothing changed; no commit made"
else
git commit -q -m "Say which version this is: ${VERSION}
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 ${VERSION}, so a bug report naming a version names one commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
echo "==> committed: $(git log --oneline -1)"
fi
else
echo "==> not committed (pass --commit to commit)"
fi
+92 -2
View File
@@ -548,8 +548,10 @@ pub fn cancel_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[
let Some(undo) = ctl.press_undo.borrow_mut().take() else { let Some(undo) = ctl.press_undo.borrow_mut().take() else {
return; return;
}; };
let (previous_anchor, cursor) = let (previous_anchor, cursor) = undo.restore(
undo.restore(&mut ctl.selection.borrow_mut(), &mut ctl.anchor.borrow_mut()); &mut ctl.selection.borrow_mut(),
&mut ctl.anchor.borrow_mut(),
);
ctl.previous_anchor.set(previous_anchor); ctl.previous_anchor.set(previous_anchor);
ctl.set_cursor(cursor); ctl.set_cursor(cursor);
sync_selection(window, ctl, ids); sync_selection(window, ctl, ids);
@@ -1514,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 // TRACES: FR-UI-2 | FR-UI-4
// The button half of selection mode. The long press is faster and this is // 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 // the one that can be found — a gesture with no visible counterpart is a
+8
View File
@@ -53,6 +53,13 @@ pub struct SyncReport {
pub catalog_uploaded: bool, pub catalog_uploaded: bool,
pub catalog_merged: bool, pub catalog_merged: bool,
pub collections_gained: usize, pub collections_gained: usize,
/// Membership rows the merge brought in (FR-CAT-7).
///
/// Counted apart from `collections_gained`, which counts the collections
/// themselves. A sync that files 127 photographs into a collection both
/// devices already had gains *no* collection, and reporting only the
/// former left the sidebar showing no count beside a full collection.
pub members_gained: usize,
} }
impl SyncReport { impl SyncReport {
@@ -334,6 +341,7 @@ async fn sync_catalog(
Ok(merge) => { Ok(merge) => {
report.catalog_merged = true; report.catalog_merged = true;
report.collections_gained = merge.inserted + merge.updated; report.collections_gained = merge.inserted + merge.updated;
report.members_gained = merge.members_added;
} }
Err(e) => log::warn!("merging remote catalog: {e}"), Err(e) => log::warn!("merging remote catalog: {e}"),
}, },
+534 -49
View File
@@ -206,12 +206,275 @@ impl SegmentationJob {
// few places in this codebase where not linearising is correct. // few places in this codebase where not linearising is correct.
let rgb = rgba let rgb = rgba
.chunks_exact(4) .chunks_exact(4)
.flat_map(|p| [p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0]) .flat_map(|p| {
[
p[0] as f32 / 255.0,
p[1] as f32 / 255.0,
p[2] as f32 / 255.0,
]
})
.collect(); .collect();
Ok((rgb, pw as usize, ph as usize)) Ok((rgb, pw as usize, ph as usize))
} }
} }
/// Longest edge the refine crop is rendered at.
///
/// Matches [`dr_segment::semantic::INPUT_EDGE`] rather than exceeding it: the
/// model's own input is still fixed at 640x640, so rendering the crop larger
/// only gets downsampled again inside the model's letterbox. The resolution
/// win is entirely from *what fills the window* — a padded crop around one
/// subject rather than the whole frame — not from feeding the model more
/// pixels than it has ever read.
const REFINE_EDGE: u32 = 640;
/// How much of the box's own size is added on each side before cropping.
///
/// Context for the model to place the subject's edge against, and slack for
/// a box that under-ran the subject slightly on the first pass. Not so much
/// that a second subject standing nearby gets pulled into the same window.
const REFINE_PADDING: f32 = 0.25;
/// TRACES: FR-DEV-3
/// A refine pass lifted out of the session that asked for it.
///
/// The same split as [`SegmentationJob`], for the same reason — see its
/// docs. This one answers for a single already-detected instance rather than
/// the whole frame: it re-runs the model over a padded crop of just that
/// subject's box, so the subject reaches the model at its own size instead
/// of squeezed into the model's fixed window alongside everything else in
/// the photograph.
pub struct RefineJob {
ctx: GpuContext,
source: Arc<DemosaicedImage>,
session: SessionId,
abandon: Abandon,
/// Which instance this answers for. A snapshot of what it needs from the
/// segmentation, taken when the job was built — the same reason
/// `SegmentationJob` carries a proxy render rather than the session.
index: usize,
class_name: Arc<str>,
bbox: (f32, f32, f32, f32),
proxy: (usize, usize),
}
impl RefineJob {
pub fn session(&self) -> SessionId {
self.session
}
pub fn abandon(&self) -> Abandon {
self.abandon.clone()
}
/// TRACES: FR-DEV-3
/// Re-detect this one subject at higher effective resolution. **Blocking.**
///
/// `Ok(None)` covers both "abandoned" and "the model found nothing of the
/// same class in the crop" — a photographer pressing refine on a subject
/// that the padded window no longer contains is a possible outcome, not
/// a bug, and the caller treats it as "kept what was there" either way.
pub fn run(&self) -> Result<Option<RefinedInstance>, String> {
if self.abandon.asked() {
return Ok(None);
}
let (px0, py0, px1, py1) = self.bbox;
let (pw, ph) = (self.proxy.0 as f32, self.proxy.1 as f32);
if pw <= 0.0 || ph <= 0.0 {
return Ok(None);
}
let (bw, bh) = ((px1 - px0).max(1.0), (py1 - py0).max(1.0));
let (padx, pady) = (bw * REFINE_PADDING, bh * REFINE_PADDING);
let x0 = (px0 - padx).max(0.0);
let y0 = (py0 - pady).max(0.0);
let x1 = (px1 + padx).min(pw);
let y1 = (py1 + pady).min(ph);
if x1 <= x0 || y1 <= y0 {
return Ok(None);
}
let (cw_px, ch_px) = (x1 - x0, y1 - y0);
let view = CropRect {
x: x0 / pw,
y: y0 / ph,
width: cw_px / pw,
height: ch_px / ph,
};
// Aspect-matched render target, capped against blowing a tiny box up
// absurdly far past what the source ever had to offer.
let scale = (REFINE_EDGE as f32 / cw_px.max(ch_px)).min(4.0);
let (tw, th) = (
(cw_px * scale).round().max(1.0) as u32,
(ch_px * scale).round().max(1.0) as u32,
);
if self.abandon.asked() {
return Ok(None);
}
let (rgb, rw, rh) = self.render_view(view, tw, th)?;
if self.abandon.asked() {
return Ok(None);
}
let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?;
let options = dr_segment::SemanticOptions {
tiling: dr_segment::Tiling::Whole,
..dr_segment::SemanticOptions::default()
};
let found = model
.detect(&rgb, rw, rh, &options)
.map_err(|e| e.to_string())?;
// The crop was built around one subject, so the right answer among
// whatever the model found in it is the same class closest to the
// window's centre — not merely the highest score, which a second,
// unrelated instance caught in the padding could win.
let (cx, cy) = (rw as f32 * 0.5, rh as f32 * 0.5);
let best = found
.into_iter()
.filter(|i| i.class_name.as_ref() == self.class_name.as_ref())
.min_by(|a, b| centre_distance(a, cx, cy).total_cmp(&centre_distance(b, cx, cy)));
let Some(instance) = best else {
return Ok(None);
};
// Downsampled back onto the shared proxy grid like every other
// instance's mask is, but built from a sharper source than the
// whole-frame pass ever saw for this subject.
let mask = paste_into_proxy(&instance.mask, rw, rh, self.proxy, (x0, y0), (cw_px, ch_px));
let bbox = (
x0 + instance.bbox.0 / scale,
y0 + instance.bbox.1 / scale,
x0 + instance.bbox.2 / scale,
y0 + instance.bbox.3 / scale,
);
Ok(Some(RefinedInstance {
index: self.index,
summary: segmentation::InstanceSummary {
class_name: instance.class_name,
score: instance.score,
mask,
bbox,
},
}))
}
/// Render the *unedited* image, showing only `view`, at `(width, height)`.
///
/// The same neutral, as-captured render [`SegmentationJob::neutral_proxy`]
/// uses — a refine pass must read the same kind of pixels the first pass
/// did, or a subject would gain or lose an edge depending on which pass
/// found it. `view` is framing's ephemeral viewport (`Framing::set_view`),
/// the mechanism the on-screen zoom already uses to render a region at
/// more than proxy resolution — not the crop tool's own persisted
/// rectangle, and nothing here touches that.
fn render_view(
&self,
view: CropRect,
width: u32,
height: u32,
) -> Result<(Vec<f32>, usize, usize), String> {
let mut neutral = EditGraph::default_chain();
neutral.framing_mut().set_view(view);
let mut pass = AdjustPass::new(&self.ctx);
pass.render(&self.source, &neutral.compose(), width, height)
.map_err(|e| format!("could not render the refine crop: {e}"))?;
let (rgba, pw, ph) = pass
.export_pixels()
.map_err(|e| format!("could not read the refine crop: {e}"))?;
let rgb = rgba
.chunks_exact(4)
.flat_map(|p| {
[
p[0] as f32 / 255.0,
p[1] as f32 / 255.0,
p[2] as f32 / 255.0,
]
})
.collect();
Ok((rgb, pw as usize, ph as usize))
}
}
/// What a finished [`RefineJob`] hands back: a replacement for one instance
/// in the segmentation it was run against.
pub struct RefinedInstance {
index: usize,
summary: segmentation::InstanceSummary,
}
fn centre_distance(instance: &dr_segment::Instance, cx: f32, cy: f32) -> f32 {
let (x0, y0, x1, y1) = instance.bbox;
let (ix, iy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5);
((ix - cx).powi(2) + (iy - cy).powi(2)).sqrt()
}
/// Sample a crop-local mask back onto its footprint in the shared proxy grid.
///
/// Bilinear, the same as every other resampling in this mask pipeline
/// (`dr_segment::semantic`'s own prototype sampling, the letterbox that feeds
/// it) — correct whether the crop was rendered denser than the proxy (the
/// common case this feature exists for) or coarser than it (a subject large
/// enough that refining it buys little, which still renders a sensible if
/// unremarkable answer rather than a distorted one).
fn paste_into_proxy(
crop_mask: &[f32],
crop_w: usize,
crop_h: usize,
proxy: (usize, usize),
origin_px: (f32, f32),
extent_px: (f32, f32),
) -> Vec<u8> {
let (pw, ph) = proxy;
let mut out = vec![0u8; pw * ph];
let (ew, eh) = extent_px;
if crop_w == 0 || crop_h == 0 || pw == 0 || ph == 0 || ew <= 0.0 || eh <= 0.0 {
return out;
}
let (ox, oy) = origin_px;
let px0 = ox.floor().max(0.0) as usize;
let py0 = oy.floor().max(0.0) as usize;
let px1 = ((ox + ew).ceil() as usize).min(pw);
let py1 = ((oy + eh).ceil() as usize).min(ph);
for py in py0..py1 {
let gy = (py as f32 + 0.5 - oy) / eh * crop_h as f32;
if gy < 0.0 || gy >= crop_h as f32 {
continue;
}
for px in px0..px1 {
let gx = (px as f32 + 0.5 - ox) / ew * crop_w as f32;
if gx < 0.0 || gx >= crop_w as f32 {
continue;
}
let v = bilinear_sample(crop_mask, crop_w, crop_h, gx, gy);
out[py * pw + px] = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
}
}
out
}
fn bilinear_sample(mask: &[f32], w: usize, h: usize, x: f32, y: f32) -> f32 {
let (fx0, fy0) = (x.floor(), y.floor());
let (fx, fy) = (x - fx0, y - fy0);
let x0 = (fx0 as isize).clamp(0, w as isize - 1) as usize;
let y0 = (fy0 as isize).clamp(0, h as isize - 1) as usize;
let x1 = (x0 + 1).min(w - 1);
let y1 = (y0 + 1).min(h - 1);
let at = |x: usize, y: usize| mask[y * w + x];
let top = at(x0, y0) * (1.0 - fx) + at(x1, y0) * fx;
let bot = at(x0, y1) * (1.0 - fx) + at(x1, y1) * fx;
top * (1.0 - fy) + bot * fy
}
pub struct DevelopSession { pub struct DevelopSession {
/// This session's name, for work that outlives the frame it started on. /// This session's name, for work that outlives the frame it started on.
id: SessionId, id: SessionId,
@@ -259,12 +522,17 @@ pub struct DevelopSession {
/// has, so this deliberately does *not* include them: dragging those /// has, so this deliberately does *not* include them: dragging those
/// sliders must not rebuild anything. /// sliders must not rebuild anything.
subject_key: u64, subject_key: u64,
/// Which layer the develop panel is editing, if any. /// Which layers the develop panel is editing, if any.
/// ///
/// This is what lets one panel serve both scopes: with a layer selected, /// This is what lets one panel serve both scopes: with layers selected,
/// the sliders read and write *its* chain, and the photographer is /// the sliders read and write *their* chains, and the photographer is
/// adjusting a region rather than the frame. /// adjusting one or more regions rather than the frame.
active_mask: Option<String>, ///
/// A plain click replaces this outright; a modifier-click toggles one id
/// in or out, so several layers can be shaped by the same slider drag —
/// "make these three subjects a stop darker" is one gesture rather than
/// three. Order is insertion order and nothing reads it, only membership.
active_masks: Vec<String>,
/// Whether to draw the false-coloured region overlay. /// Whether to draw the false-coloured region overlay.
show_overlay: bool, show_overlay: bool,
/// Which attribute the panel is filtered to, or all of them. /// Which attribute the panel is filtered to, or all of them.
@@ -350,7 +618,7 @@ impl DevelopSession {
masks: None, masks: None,
subjects: None, subjects: None,
subject_key: 0, subject_key: 0,
active_mask: None, active_masks: Vec::new(),
show_overlay: false, show_overlay: false,
active_tab: None, active_tab: None,
curve_channel: 0, curve_channel: 0,
@@ -430,14 +698,27 @@ impl DevelopSession {
.and_then(|i| self.tabs().get(i).map(|(a, _)| *a)); .and_then(|i| self.tabs().get(i).map(|(a, _)| *a));
} }
/// The selection's representative layer, for anything that can only show
/// one answer — which tab is open, what value a slider currently reads.
///
/// The first id selected, not "the" active layer: with more than one
/// selected there is no single truth to show, and the panel has to pick
/// something. Whichever layer this is, [`Self::set_param`] and
/// [`Self::reset_op`] still write to every selected layer, not just this
/// one — a slider shows one number and applies it everywhere selected.
fn active_layer(&self) -> Option<&MaskLayer> { fn active_layer(&self) -> Option<&MaskLayer> {
let id = self.active_mask.as_ref()?; let id = self.active_masks.first()?;
self.graph.masks().get(id) self.graph.masks().get(id)
} }
fn active_layer_mut(&mut self) -> Option<&mut MaskLayer> { /// Every selected layer, mutably — what a batched slider or reset walks.
let id = self.active_mask.clone()?; fn active_layers_mut(&mut self) -> impl Iterator<Item = &mut MaskLayer> {
self.graph.masks_mut().get_mut(&id) let selected = self.active_masks.clone();
self.graph
.masks_mut()
.layers_mut()
.iter_mut()
.filter(move |l| selected.contains(&l.id))
} }
} }
@@ -962,11 +1243,11 @@ impl DevelopSession {
let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else { let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
return; return;
}; };
if self.active_mask.is_some() { if !self.active_masks.is_empty() {
let params: Vec<_> = cap.params.iter().map(|p| (p.id, p.default)).collect(); let params: Vec<_> = cap.params.iter().map(|p| (p.id, p.default)).collect();
let id = cap.id.0; let id = cap.id.0;
if let Some(layer) = self.active_layer_mut() { for layer in self.active_layers_mut() {
for (param, default) in params { for &(param, default) in &params {
layer.set_param(id, param, default); layer.set_param(id, param, default);
} }
} }
@@ -999,12 +1280,17 @@ impl DevelopSession {
log::warn!("control at ({op_index}, {param_index}) has no parameter"); log::warn!("control at ({op_index}, {param_index}) has no parameter");
return; return;
}; };
if self.active_mask.is_some() { if !self.active_masks.is_empty() {
if let Some(layer) = self.active_layer_mut() { // Every selected layer is set to the same absolute value the
// slider now shows, not offset by however far each one already
// was from it — the slider has one position, and "apply this
// reading to all of them" is the reading a photographer gets
// from watching it move.
for layer in self.active_layers_mut() {
layer.set_param(op.0, param, value); layer.set_param(op.0, param, value);
} }
// Coalesced the same way a global drag is: a slider dragged across // Coalesced the same way a global drag is: a slider dragged across
// a masked layer is still one gesture and must undo as one. // masked layers is still one gesture and must undo as one.
let edit = Edit::for_param(&self.graph, op, param); let edit = Edit::for_param(&self.graph, op, param);
self.history.record(&self.graph, edit); self.history.record(&self.graph, edit);
return; return;
@@ -1332,6 +1618,55 @@ impl DevelopSession {
self.subject_key = 0; self.subject_key = 0;
} }
/// TRACES: FR-DEV-3
/// Everything a refine pass needs for one already-detected instance, so
/// it can be run somewhere else — see [`Self::segmentation_job`] for why
/// the split exists.
///
/// `None` where there is nothing to refine: no segmentation yet, or an
/// index the panel offered a button for a moment ago but the segmentation
/// underneath has since changed.
pub fn refine_job(&self, index: usize) -> Option<RefineJob> {
let seg = self.segmentation.as_ref()?;
let instance = seg.instances().get(index)?;
Some(RefineJob {
ctx: self.ctx.clone(),
source: self.demosaiced.clone(),
session: self.id,
abandon: Abandon::default(),
index,
class_name: instance.class_name.clone(),
bbox: instance.bbox,
proxy: seg.proxy_size(),
})
}
/// Take on a refined instance found elsewhere.
///
/// Same caution as [`Self::adopt_segmentation`]: the caller checks the
/// job's session before handing back its answer, not this. Every mask
/// layer pointing at this instance's index reads it fresh next redraw —
/// `subject_key` is reset because the pixels changed under an index
/// `subject_signature` has no way to know changed, unlike a morphology
/// slider it does track.
pub fn adopt_refined(&mut self, refined: RefinedInstance) {
if let Some(seg) = self.segmentation.as_mut() {
seg.replace_instance(refined.index, refined.summary);
}
self.subjects = None;
self.subject_key = 0;
}
/// The mask layer's original detection index, for the refine button —
/// `None` for a layer that is not a subject at all (a gradient has no
/// instance to re-detect).
pub fn subject_instance_index(&self, id: &str) -> Option<usize> {
match &self.graph.masks().get(id)?.source {
MaskSource::Subject { index, .. } => Some(*index as usize),
_ => None,
}
}
/// Find the subjects and take them on, blocking until both are done. /// Find the subjects and take them on, blocking until both are done.
/// ///
/// Test-only, and deliberately: a session-shaped blocking call is exactly /// Test-only, and deliberately: a session-shaped blocking call is exactly
@@ -1450,7 +1785,7 @@ impl DevelopSession {
l.id.clone(), l.id.clone(),
l.display_name().to_string(), l.display_name().to_string(),
l.enabled, l.enabled,
Some(&l.id) == self.active_mask.as_ref(), self.active_masks.iter().any(|a| a == &l.id),
) )
}) })
.collect() .collect()
@@ -1458,10 +1793,7 @@ impl DevelopSession {
/// What kind of mask a layer is — "regions", "linear", "radial". /// What kind of mask a layer is — "regions", "linear", "radial".
pub fn mask_kind(&self, id: &str) -> &'static str { pub fn mask_kind(&self, id: &str) -> &'static str {
self.graph self.graph.masks().get(id).map_or("", |l| l.source.kind())
.masks()
.get(id)
.map_or("", |l| l.source.kind())
} }
pub fn mask_inverted(&self, id: &str) -> bool { pub fn mask_inverted(&self, id: &str) -> bool {
@@ -1485,15 +1817,25 @@ impl DevelopSession {
.is_some_and(|l| l.active_ops().next().is_some()) .is_some_and(|l| l.active_ops().next().is_some())
} }
/// The panel's representative selection — see [`Self::active_layer`] for
/// what "representative" means once more than one layer is selected.
pub fn active_mask(&self) -> Option<&str> { pub fn active_mask(&self) -> Option<&str> {
self.active_mask.as_deref() self.active_masks.first().map(String::as_str)
}
/// Every selected layer's id, in selection order.
pub fn active_masks(&self) -> &[String] {
&self.active_masks
} }
/// TRACES: FR-DEV-3 | FR-UI-3 /// TRACES: FR-DEV-3 | FR-UI-3
/// The selected gradient's handles, in fractions of the shown image. /// The selected gradient's handles, in fractions of the shown image.
/// ///
/// Empty unless a gradient layer is selected, which is what makes this the /// Empty unless **exactly one** gradient layer is selected. Dragging a
/// panel's whole test for "is there anything to draw on the canvas". /// shared handle for several gradients at once has no single geometry to
/// move — each one's centre, angle and extent differ — so multi-select
/// simply offers no handles rather than moving one layer's shape while
/// silently leaving the others behind.
/// ///
/// Recomputed on every redraw rather than cached, because the answer /// Recomputed on every redraw rather than cached, because the answer
/// changes with the *view* and not only with the mask: a pan moves every /// changes with the *view* and not only with the mask: a pan moves every
@@ -1501,6 +1843,9 @@ impl DevelopSession {
/// not work worth caching, and a cache keyed on the wrong thing is how a /// not work worth caching, and a cache keyed on the wrong thing is how a
/// handle comes to sit where the mask used to be. /// handle comes to sit where the mask used to be.
pub fn gradient_handles(&self) -> Vec<crate::GradientHandle> { pub fn gradient_handles(&self) -> Vec<crate::GradientHandle> {
if self.active_masks.len() != 1 {
return Vec::new();
}
let Some(layer) = self.active_layer() else { let Some(layer) = self.active_layer() else {
return Vec::new(); return Vec::new();
}; };
@@ -1522,9 +1867,12 @@ impl DevelopSession {
press: (f32, f32), press: (f32, f32),
now: (f32, f32), now: (f32, f32),
) -> Option<MaskSource> { ) -> Option<MaskSource> {
if self.active_masks.len() != 1 {
return None;
}
let (sw, sh) = self.demosaiced.size(); let (sw, sh) = self.demosaiced.size();
let framing = *self.graph.framing(); let framing = *self.graph.framing();
let id = self.active_mask.clone()?; let id = self.active_masks.first()?.clone();
let start = match origin { let start = match origin {
Some(s) => s.clone(), Some(s) => s.clone(),
None => self.graph.masks().get(&id)?.source.clone(), None => self.graph.masks().get(&id)?.source.clone(),
@@ -1554,12 +1902,33 @@ impl DevelopSession {
self.active_layer().map(|l| l.source.clone()) self.active_layer().map(|l| l.source.clone())
} }
/// Select a layer for editing, or `None` to return the panel to the /// Select exactly one layer for editing, or `None` to return the panel to
/// global chain. /// the global chain. Replaces whatever was selected before, including a
/// multi-selection — the ordinary, unmodified click.
pub fn set_active_mask(&mut self, id: Option<&str>) { pub fn set_active_mask(&mut self, id: Option<&str>) {
self.active_mask = id self.active_masks = id
.filter(|id| self.graph.masks().get(id).is_some()) .filter(|id| self.graph.masks().get(id).is_some())
.map(|id| id.to_string()); .map(|id| vec![id.to_string()])
.unwrap_or_default();
}
/// Add or remove one layer from the selection, keeping the rest — the
/// modifier-click that builds a multi-selection.
///
/// A layer id the graph no longer has is dropped rather than toggled in:
/// the row that offered it is stale by the time the click lands, and
/// selecting a ghost would make every subsequent batched edit silently
/// skip it (`active_layers_mut` filters by membership, not existence).
pub fn toggle_active_mask(&mut self, id: &str) {
if self.graph.masks().get(id).is_none() {
return;
}
match self.active_masks.iter().position(|a| a == id) {
Some(i) => {
self.active_masks.remove(i);
}
None => self.active_masks.push(id.to_string()),
}
} }
/// Mask the object under a normalised image point. /// Mask the object under a normalised image point.
@@ -1603,7 +1972,7 @@ impl DevelopSession {
if !self.graph.masks_mut().push(layer) { if !self.graph.masks_mut().push(layer) {
return None; return None;
} }
self.active_mask = Some(id.clone()); self.active_masks = vec![id.clone()];
self.history.record(&self.graph, Edit::Discrete); self.history.record(&self.graph, Edit::Discrete);
Some(id) Some(id)
} }
@@ -1625,19 +1994,21 @@ impl DevelopSession {
width: 0.3, width: 0.3,
} }
}; };
if !self.graph.masks_mut().push(MaskLayer::new(id.clone(), source)) { if !self
.graph
.masks_mut()
.push(MaskLayer::new(id.clone(), source))
{
return None; return None;
} }
self.active_mask = Some(id.clone()); self.active_masks = vec![id.clone()];
self.history.record(&self.graph, Edit::Discrete); self.history.record(&self.graph, Edit::Discrete);
Some(id) Some(id)
} }
pub fn remove_mask(&mut self, id: &str) { pub fn remove_mask(&mut self, id: &str) {
if self.graph.masks_mut().remove(id).is_some() { if self.graph.masks_mut().remove(id).is_some() {
if self.active_mask.as_deref() == Some(id) { self.active_masks.retain(|a| a != id);
self.active_mask = None;
}
self.history.record(&self.graph, Edit::Discrete); self.history.record(&self.graph, Edit::Discrete);
} }
} }
@@ -1660,7 +2031,8 @@ impl DevelopSession {
pub fn set_mask_feather(&mut self, id: &str, feather: f32) { pub fn set_mask_feather(&mut self, id: &str, feather: f32) {
if let Some(layer) = self.graph.masks_mut().get_mut(id) { if let Some(layer) = self.graph.masks_mut().get_mut(id) {
layer.feather = feather.clamp(0.0, 1.0); layer.feather = feather.clamp(0.0, 1.0);
self.history.record(&self.graph, Edit::Op(OpId("mask-feather"))); self.history
.record(&self.graph, Edit::Op(OpId("mask-feather")));
} }
} }
@@ -1694,7 +2066,8 @@ impl DevelopSession {
pub fn set_mask_morph_radius(&mut self, id: &str, radius: f32) { pub fn set_mask_morph_radius(&mut self, id: &str, radius: f32) {
if let Some(layer) = self.graph.masks_mut().get_mut(id) { if let Some(layer) = self.graph.masks_mut().get_mut(id) {
layer.morph_radius = radius.clamp(0.0, 1.0); layer.morph_radius = radius.clamp(0.0, 1.0);
self.history.record(&self.graph, Edit::Op(OpId("mask-morph"))); self.history
.record(&self.graph, Edit::Op(OpId("mask-morph")));
} }
} }
@@ -1702,7 +2075,10 @@ impl DevelopSession {
pub fn mask_falloff(&self, id: &str) -> usize { pub fn mask_falloff(&self, id: &str) -> usize {
use dr_pipeline::mask::Falloff; use dr_pipeline::mask::Falloff;
self.graph.masks().get(id).map_or(0, |l| { self.graph.masks().get(id).map_or(0, |l| {
Falloff::ALL.iter().position(|&f| f == l.falloff).unwrap_or(0) Falloff::ALL
.iter()
.position(|&f| f == l.falloff)
.unwrap_or(0)
}) })
} }
@@ -1732,7 +2108,10 @@ impl DevelopSession {
pub fn mask_is_shapeable(&self, id: &str) -> bool { pub fn mask_is_shapeable(&self, id: &str) -> bool {
use dr_pipeline::mask::MaskSource; use dr_pipeline::mask::MaskSource;
self.graph.masks().get(id).is_some_and(|l| { self.graph.masks().get(id).is_some_and(|l| {
matches!(l.source, MaskSource::Subject { .. } | MaskSource::Regions { .. }) matches!(
l.source,
MaskSource::Subject { .. } | MaskSource::Regions { .. }
)
}) })
} }
@@ -1742,7 +2121,8 @@ impl DevelopSession {
// `Op` rather than `Discrete`: opacity is dragged, and a drag is // `Op` rather than `Discrete`: opacity is dragged, and a drag is
// one decision however many values it passes through. `Discrete` // one decision however many values it passes through. `Discrete`
// would put every intermediate position on the undo stack. // would put every intermediate position on the undo stack.
self.history.record(&self.graph, Edit::Op(OpId("mask-opacity"))); self.history
.record(&self.graph, Edit::Op(OpId("mask-opacity")));
} }
} }
@@ -2217,6 +2597,17 @@ impl DevelopSession {
/// Captured at full scope — framing included — because the decision about /// Captured at full scope — framing included — because the decision about
/// what travels is made when the preset is *applied*. Copying, then /// what travels is made when the preset is *applied*. Copying, then
/// changing one's mind about the crop, must not mean copying again. /// changing one's mind about the crop, must not mean copying again.
/// TRACES: FR-DEV-3 | FR-CAT-8
/// The local adjustment stack, for writing this image's edit back.
///
/// Beside `copy_settings` rather than part of it: that returns a `Preset`,
/// which travels *between* photographs, and a mask must not — it is drawn
/// against one frame and describes nothing on another. The save path takes
/// both; the paste path takes only the preset.
pub fn masks(&self) -> &dr_pipeline::mask::MaskStack {
self.graph.masks()
}
pub fn copy_settings(&self) -> Preset { pub fn copy_settings(&self) -> Preset {
Preset::capture(&self.graph) Preset::capture(&self.graph)
} }
@@ -2510,9 +2901,8 @@ mod tests {
fn a_job_carries_the_session_it_was_taken_from() { fn a_job_carries_the_session_it_was_taken_from() {
let Some(ctx) = headless() else { return }; let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
let session = let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) .expect("session");
.expect("session");
assert_eq!(session.segmentation_job().session(), session.id()); assert_eq!(session.segmentation_job().session(), session.id());
} }
@@ -2523,9 +2913,8 @@ mod tests {
fn an_abandoned_job_does_no_work() { fn an_abandoned_job_does_no_work() {
let Some(ctx) = headless() else { return }; let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
let session = let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) .expect("session");
.expect("session");
let job = session.segmentation_job(); let job = session.segmentation_job();
job.abandon().now(); job.abandon().now();
@@ -2646,6 +3035,100 @@ mod tests {
); );
} }
/// TRACES: FR-DEV-3
/// Multi-select: one slider, applied to every selected layer.
///
/// `toggle_active_mask` builds the selection a control-click makes, and
/// `set_param`/`reset_op` are what a drag and a reset call — this pins
/// down that both fan out to every layer in it rather than only the
/// first, which is the whole point of selecting more than one.
#[test]
fn a_slider_moved_with_two_layers_selected_moves_both() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
.expect("session");
let a = session.add_gradient_mask(true).expect("first gradient");
let b = session.add_gradient_mask(false).expect("second gradient");
// Adding `b` selected it alone — build the multi-selection a
// control-click would, starting from that single-layer state.
session.toggle_active_mask(&a);
assert_eq!(session.active_masks(), [b.clone(), a.clone()].as_slice());
assert!(
!session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b),
"neither layer has been touched yet"
);
let row = session.rows()[0].clone();
session.set_param(row.op_index, row.param_index, row.maximum);
assert!(
session.mask_is_adjusted(&a) && session.mask_is_adjusted(&b),
"one slider, both layers selected, both layers must show the edit"
);
// And a reset walks the same set.
session.reset_op(row.op_index);
assert!(
!session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b),
"resetting with both selected must clear both, not just the one \
the panel happens to read values from"
);
}
/// TRACES: FR-DEV-3
/// A refine crop's mask, pasted back onto the proxy grid it replaces,
/// must land exactly where the crop was — not shifted by the origin, not
/// scaled onto the wrong footprint.
#[test]
fn a_refined_mask_pastes_back_at_the_crops_own_position() {
// A crop entirely on (1.0), covering proxy pixels 2..6 in x and
// 2..6 in y of an 8x8 proxy — everywhere inside that box must read
// back as fully covered, everywhere outside as untouched.
let crop = vec![1.0f32; 4 * 4];
let mask = paste_into_proxy(&crop, 4, 4, (8, 8), (2.0, 2.0), (4.0, 4.0));
assert_eq!(mask[2 * 8 + 2], 255, "top-left corner of the box");
assert_eq!(mask[5 * 8 + 5], 255, "bottom-right corner of the box");
assert_eq!(mask[0], 0, "outside the box, untouched");
assert_eq!(mask[7 * 8 + 7], 0, "outside the box, untouched");
}
#[test]
fn bilinear_sample_averages_its_four_neighbours() {
// Two rows, black then white: the exact midpoint reads as grey.
let mask = [0.0f32, 0.0, 1.0, 1.0];
let v = bilinear_sample(&mask, 2, 2, 0.5, 0.5);
assert!(
(v - 0.5).abs() < 1e-6,
"expected the midpoint grey, got {v}"
);
}
/// A control-click twice — once to add, once to remove — is a no-op on
/// the selection, which is the sanity check for `toggle_active_mask`
/// itself before trusting anything built on it.
#[test]
fn toggling_a_layer_twice_returns_to_the_starting_selection() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
.expect("session");
let a = session.add_gradient_mask(true).expect("gradient");
assert_eq!(session.active_masks(), [a.clone()].as_slice());
session.toggle_active_mask(&a);
assert!(session.active_masks().is_empty(), "removed by the toggle");
session.toggle_active_mask(&a);
assert_eq!(session.active_masks(), [a.clone()].as_slice(), "added back");
}
/// TRACES: FR-DEV-3 /// TRACES: FR-DEV-3
/// The mask array and the segmentation proxy are the same size on purpose. /// The mask array and the segmentation proxy are the same size on purpose.
/// ///
@@ -3993,7 +4476,10 @@ mod tests {
let narrowed = session.rows().len(); let narrowed = session.rows().len();
assert!(narrowed > 0, "a tab must show something"); assert!(narrowed > 0, "a tab must show something");
assert!(narrowed < all, "and less than everything: {narrowed} of {all}"); assert!(
narrowed < all,
"and less than everything: {narrowed} of {all}"
);
} }
/// The trap: `op_index` counts over *every* capability, so a row that /// The trap: `op_index` counts over *every* capability, so a row that
@@ -4054,7 +4540,6 @@ mod tests {
session.set_active_tab(99); session.set_active_tab(99);
assert_eq!(session.rows().len(), all); assert_eq!(session.rows().len(), all);
} }
} }
/// `dr_pipeline`'s morphology, as `dr_segment` names it. /// `dr_pipeline`'s morphology, as `dr_segment` names it.
+215 -9
View File
@@ -19,6 +19,26 @@
//! user sees "1,847 photographs, 61.2 GB" and then a progress bar, rather than //! user sees "1,847 photographs, 61.2 GB" and then a progress bar, rather than
//! a frozen window and then a progress bar. //! a frozen window and then a progress bar.
//! //!
//! # There is no local library, so there is no destination to choose
//!
//! DarkRoom's library is a folder on a Nextcloud server (FR-NC-6). An import
//! therefore has exactly one place to go, and the interface has nothing to ask
//! about it.
//!
//! What lands on this machine is a **staging copy**, in a directory the app
//! owns beside the catalog — the same shape `export` uses for its outbox, and
//! for the same reason its module docs give: staging first is the only path,
//! not a fallback for the offline case.
//!
//! The bytes have to touch disk before they touch the network. Streaming a
//! card straight to the server would mean a move-import erasing a card against
//! an in-flight upload, which is the one operation here with no undo, and it
//! would make importing impossible with no connection, which FR-NC-10 forbids.
//!
//! A staged file is removed once the server confirms it. One that is not
//! confirmed stays where it is, and the next import drains it — so an import
//! made on a train finishes when the train arrives somewhere.
//!
//! # The import does not catalogue what it wrote //! # The import does not catalogue what it wrote
//! //!
//! It writes files into the library and then asks for a scan, rather than //! It writes files into the library and then asks for a scan, rather than
@@ -33,7 +53,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender}; use std::sync::mpsc::{Receiver, Sender};
use std::sync::Arc; use std::sync::Arc;
use dr_ingest::{Candidate, DupKey, Imported, Ingest, Options, Report, Shot}; use dr_ingest::{Candidate, DupKey, Imported, Ingest, Options, Report, Shot, TransferMode};
use dr_plat::{DirRef, LocalStorage, Storage, WritableStorage}; use dr_plat::{DirRef, LocalStorage, Storage, WritableStorage};
use dr_sync::{RemoteBackend, RemotePath}; use dr_sync::{RemoteBackend, RemotePath};
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend}; use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
@@ -56,8 +76,9 @@ pub type Cancel = Arc<AtomicBool>;
pub struct Request { pub struct Request {
/// Where the card is mounted. /// Where the card is mounted.
pub card: PathBuf, pub card: PathBuf,
/// The library root the template's folders are created under. /// Where the copies are staged before they go up. Owned by the app, not
pub library: PathBuf, /// chosen by the user — see the module docs.
pub staging: PathBuf,
/// The optional second destination (FR-CAT-10). /// The optional second destination (FR-CAT-10).
pub backup: Option<PathBuf>, pub backup: Option<PathBuf>,
/// The catalog, opened by the worker for its duplicate queries. /// The catalog, opened by the worker for its duplicate queries.
@@ -68,8 +89,9 @@ pub struct Request {
/// Which file types to take off the card. /// Which file types to take off the card.
pub filter: FormatFilter, pub filter: FormatFilter,
pub options: Options, pub options: Options,
/// Where to send the originals afterwards, if anywhere (FR-NC-7b). /// Where the photographs are going. An import without one has nowhere to
pub upload: Option<Upload>, /// put anything, and the page refuses to start.
pub upload: Upload,
} }
/// TRACES: FR-NC-7a | FR-NC-7b /// TRACES: FR-NC-7a | FR-NC-7b
@@ -89,6 +111,22 @@ pub struct Upload {
/// Where the thumbnail shards live, so a thumbnail made during the import /// Where the thumbnail shards live, so a thumbnail made during the import
/// can be filed under the server's `oc:fileid` once the upload assigns one. /// can be filed under the server's `oc:fileid` once the upload assigns one.
pub thumbs: PathBuf, pub thumbs: PathBuf,
/// Where copies wait between disk and the server.
///
/// Per account, beside the catalog, like the export outbox: a file queued
/// for one server is meaningless to another, and a staging directory
/// shared between accounts would upload a card to whichever was signed in
/// when the connection came back.
pub staging: PathBuf,
}
/// TRACES: FR-NC-7b
/// Where an account's imports wait between disk and the server.
pub fn staging_dir(server: &str, user_id: &str) -> PathBuf {
crate::library::catalog_path(server, user_id)
.parent()
.map(|p| p.join("staging"))
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-import-staging"))
} }
impl std::fmt::Debug for Upload { impl std::fmt::Debug for Upload {
@@ -98,6 +136,7 @@ impl std::fmt::Debug for Upload {
.field("user_id", &self.user_id) .field("user_id", &self.user_id)
.field("library", &self.library) .field("library", &self.library)
.field("thumbs", &self.thumbs) .field("thumbs", &self.thumbs)
.field("staging", &self.staging)
.finish_non_exhaustive() .finish_non_exhaustive()
} }
} }
@@ -166,6 +205,18 @@ pub struct Outcome {
/// and the card has not been touched. Reported so the user knows the /// and the card has not been touched. Reported so the user knows the
/// server does not have them yet. /// server does not have them yet.
pub upload_failed: usize, pub upload_failed: usize,
/// Card files actually deleted, for a move-import.
///
/// Never more than `retirable`, and zero for a copy-import. Reported
/// because "the card is now empty" is a claim the user needs to be able to
/// check against what they expected.
pub retired: usize,
/// Staged copies still waiting to go up.
///
/// The same files as `upload_failed`, named for what happens next rather
/// than for what went wrong: they are queued, and the next import sends
/// them. An import made with no connection is this and nothing else.
pub staged: usize,
} }
/// Start an import. /// Start an import.
@@ -188,7 +239,16 @@ pub fn spawn(request: Request, cancel: Cancel) -> Receiver<Message> {
fn run(request: Request, cancel: &Cancel, tx: &Sender<Message>) -> Result<(), String> { fn run(request: Request, cancel: &Cancel, tx: &Sender<Message>) -> Result<(), String> {
let card = LocalStorage::with_root(CARD, request.card.clone()); let card = LocalStorage::with_root(CARD, request.card.clone());
let library = LocalStorage::with_root(LIBRARY, request.library.clone()); // Created rather than assumed: the first import on a machine finds no
// staging directory, and a failure here is worth reporting as itself
// rather than as every file failing to be written.
if let Err(e) = std::fs::create_dir_all(&request.staging) {
return Err(format!(
"could not prepare {}: {e}",
request.staging.display()
));
}
let library = LocalStorage::with_root(LIBRARY, request.staging.clone());
let backup = request let backup = request
.backup .backup
.as_ref() .as_ref()
@@ -242,7 +302,8 @@ fn run(request: Request, cancel: &Cancel, tx: &Sender<Message>) -> Result<(), St
// FR-NC-7b. Everything above has already finished: every file is on disk // FR-NC-7b. Everything above has already finished: every file is on disk
// and its digest checked, so nothing from here on can cost the user a // and its digest checked, so nothing from here on can cost the user a
// photograph — only a transfer. // photograph — only a transfer.
if let Some(upload) = &request.upload { {
let upload = &request.upload;
let (sent, thumbnails) = upload_all(upload, &library, &report.imported, cancel, tx); let (sent, thumbnails) = upload_all(upload, &library, &report.imported, cancel, tx);
outcome.thumbnails = thumbnails; outcome.thumbnails = thumbnails;
outcome.uploaded = sent.iter().filter(|s| s.transferred).count(); outcome.uploaded = sent.iter().filter(|s| s.transferred).count();
@@ -264,6 +325,36 @@ fn run(request: Request, cancel: &Cancel, tx: &Sender<Message>) -> Result<(), St
// now. The metadata tier covers the interval, which is why this is not // now. The metadata tier covers the interval, which is why this is not
// worth waiting for a sync to do properly. // worth waiting for a sync to do properly.
record_digests(catalog.connection(), &upload.library, &sent); record_digests(catalog.connection(), &upload.library, &sent);
// The staging copy has done its job the moment the server confirms it:
// the photograph is in the library, and keeping a second copy in a
// directory no view ever shows would be a slow disk leak. What is *not*
// confirmed stays exactly where it is — that is the queue FR-NC-7b
// describes, and the next import drains it.
outcome.staged = report.imported.len() - sent.len();
clear_staged(&library, &report.imported, &sent);
// TRACES: FR-CAT-10 | FR-NC-7b
// The card, last of all, and only for what the server has.
//
// The rule this enforces: a copy-import never touches the card, and a
// move-import empties it only of photographs that are confirmed on the
// server — not merely written to disk here, because the staging copy
// above has just been removed and this machine is no longer holding
// them either. Anything the upload did not take keeps its card copy,
// which is then the only copy that exists.
//
// Done here rather than handed to the caller: `dr_ingest::retire` is
// deliberately awkward to reach, and a caller that forgot to call it
// was exactly the bug this replaces — Move behaved as Copy, silently.
if request.options.mode == TransferMode::Move {
let confirmed = confirmed_sources(&report.imported, &sent);
outcome.retirable = confirmed.len();
let failures = dr_ingest::retire(&card, &confirmed);
outcome.retired = confirmed.len() - failures.len();
} else {
outcome.retirable = 0;
}
} }
let _ = tx.send(Message::Finished(outcome)); let _ = tx.send(Message::Finished(outcome));
@@ -478,6 +569,43 @@ async fn file_thumbnails(
filed filed
} }
/// The card sources whose photographs the server now holds.
///
/// Paired positionally with `imported`, which is how `upload_all` builds
/// `sent`: one entry per original it got an answer about, in order. A file the
/// upload skipped or failed on has no entry, and so is never returned here.
fn confirmed_sources(imported: &[Imported], sent: &[Sent]) -> Vec<dr_types::SourceRef> {
imported
.iter()
.zip(sent.iter())
.map(|(image, _)| image.source.clone())
.collect()
}
/// TRACES: FR-NC-7b
/// Remove the staging copies the server has confirmed.
///
/// Keyed on what came back from the upload rather than on what was imported:
/// a file the server did not take must survive, because the staging directory
/// is the only place it exists once the card is put away.
///
/// Failures are logged, not propagated. A staged file that cannot be deleted
/// costs disk; refusing to finish an import over it would cost the user the
/// report telling them their photographs are safe.
fn clear_staged(library: &LocalStorage, imported: &[Imported], sent: &[Sent]) {
let confirmed: std::collections::HashSet<&str> =
sent.iter().map(|s| s.remote_path.as_str()).collect();
for (image, item) in imported.iter().zip(sent.iter()) {
if !confirmed.contains(item.remote_path.as_str()) {
continue;
}
if let Err(e) = library.remove_file(&image.written) {
log::warn!("could not clear the staged copy of {}: {e}", image.name);
}
}
}
/// Read an imported file back out of the library. /// Read an imported file back out of the library.
fn read_all(library: &LocalStorage, image: &Imported) -> Result<Vec<u8>, String> { fn read_all(library: &LocalStorage, image: &Imported) -> Result<Vec<u8>, String> {
use std::io::Read; use std::io::Read;
@@ -640,6 +768,8 @@ pub fn summarise(report: &Report) -> Outcome {
uploaded: 0, uploaded: 0,
already_on_server: 0, already_on_server: 0,
upload_failed: 0, upload_failed: 0,
staged: 0,
retired: 0,
thumbnails: 0, thumbnails: 0,
} }
} }
@@ -698,12 +828,18 @@ pub fn describe(outcome: &Outcome) -> String {
} }
if outcome.upload_failed > 0 { if outcome.upload_failed > 0 {
out.push_str(&format!( out.push_str(&format!(
", {} still only on this computer", ", {} waiting to go up — the next import sends them",
outcome.upload_failed outcome.upload_failed
)); ));
} }
} }
// The card. Said plainly, because emptying one is irreversible and the
// user should not have to infer it from a count of uploads.
if outcome.retired > 0 {
out.push_str(&format!(". {} removed from the card", outcome.retired));
}
// Worth saying: it is why the grid fills in immediately after an import // Worth saying: it is why the grid fills in immediately after an import
// rather than fetching a preview out of every original that just went up. // rather than fetching a preview out of every original that just went up.
if outcome.thumbnails > 0 { if outcome.thumbnails > 0 {
@@ -938,7 +1074,77 @@ mod tests {
}; };
let text = describe(&o); let text = describe(&o);
assert!(text.contains("2 uploaded"), "{text}"); assert!(text.contains("2 uploaded"), "{text}");
assert!(text.contains("1 still only on this computer"), "{text}"); // Named for what happens next rather than for what went wrong: the
// file is queued, not lost, and the next import sends it.
assert!(text.contains("1 waiting to go up"), "{text}");
}
#[test]
fn emptying_the_card_is_stated_plainly() {
// Irreversible, so the user should not have to infer it from a count
// of uploads.
let o = Outcome {
uploaded: 3,
retirable: 3,
retired: 3,
..outcome()
};
assert!(
describe(&o).contains("3 removed from the card"),
"{}",
describe(&o)
);
}
#[test]
fn a_copy_import_never_mentions_the_card() {
let o = Outcome {
uploaded: 3,
..outcome()
};
assert!(!describe(&o).contains("card"), "{}", describe(&o));
}
#[test]
fn only_confirmed_photographs_are_taken_off_the_card() {
// The rule: a card is emptied of what the *server* has, never of what
// merely reached this disk — the staging copy is removed straight
// after, so anything not uploaded would otherwise exist nowhere.
let imported: Vec<Imported> = (0..3)
.map(|i| Imported {
source: dr_types::SourceRef::Local {
root: CARD,
relative: format!("IMG_{i}.CR3"),
},
written: dr_types::SourceRef::Local {
root: LIBRARY,
relative: format!("2026/2026-08-22/IMG_{i}.CR3"),
},
folders: vec!["2026".into(), "2026-08-22".into()],
name: format!("IMG_{i}.CR3"),
digest: "x".into(),
size: 10,
dated_from: DateSource::Capture,
backup: None,
})
.collect();
// The server took the first two; the third never went up.
let sent: Vec<Sent> = (0..2)
.map(|i| Sent {
remote_path: format!("PhotosRaw/2026/2026-08-22/IMG_{i}.CR3"),
digest: "x".into(),
transferred: true,
thumbnail: None,
})
.collect();
let confirmed = confirmed_sources(&imported, &sent);
assert_eq!(confirmed.len(), 2);
assert_eq!(confirmed[0].key(), "IMG_0.CR3");
assert_eq!(confirmed[1].key(), "IMG_1.CR3");
// IMG_2 keeps its card copy, which is now the only copy of it.
assert!(!confirmed.iter().any(|s| s.key() == "IMG_2.CR3"));
} }
#[test] #[test]
+44 -31
View File
@@ -188,7 +188,9 @@ impl ImportController {
fn can_start(&self) -> bool { fn can_start(&self) -> bool {
!self.running.get() !self.running.get()
&& !self.card.borrow().is_empty() && !self.card.borrow().is_empty()
&& !self.options().destination.is_empty() // An account, because the library is on the server and an import
// with none has nowhere to go at all.
&& !self.upload_target.borrow().is_empty()
&& self.survey.borrow().map(|(n, _)| n > 0).unwrap_or(false) && self.survey.borrow().map(|(n, _)| n > 0).unwrap_or(false)
} }
} }
@@ -252,17 +254,9 @@ pub fn render(window: &AppWindow, ctl: &Rc<ImportController>) {
.into(), .into(),
); );
window.set_import_destination(stored.destination.as_str().into()); // The only destination there is. Empty means no account, which is what
// Empty where no account is signed in, or where the user turned the upload // keeps Import disabled — see `can_start`.
// off — the page then shows one destination because there is one. window.set_import_upload_target(ctl.upload_target.borrow().as_str().into());
window.set_import_upload_target(
if stored.upload {
ctl.upload_target.borrow().clone()
} else {
String::new()
}
.into(),
);
window.set_import_folder_template(stored.folder_template.as_str().into()); window.set_import_folder_template(stored.folder_template.as_str().into());
window.set_import_template_preview(preview(&stored, crate::library::now_secs()).into()); window.set_import_template_preview(preview(&stored, crate::library::now_secs()).into());
@@ -333,13 +327,6 @@ where
// does: another instance may have written it since. // does: another instance may have written it since.
*ctl.settings.settings.borrow_mut() = ctl.settings.store.load(); *ctl.settings.settings.borrow_mut() = ctl.settings.store.load();
// A destination the user has never chosen defaults to somewhere
// that exists, rather than to an empty field they must decode.
if ctl.options().destination.is_empty() {
if let Some(home) = default_destination() {
ctl.edit(|s| s.destination = home.display().to_string());
}
}
*ctl.upload_target.borrow_mut() = *ctl.upload_target.borrow_mut() =
context().map(|c| c.library_label).unwrap_or_default(); context().map(|c| c.library_label).unwrap_or_default();
ctl.refresh_volumes(); ctl.refresh_volumes();
@@ -505,16 +492,6 @@ where
} }
} }
/// Somewhere sensible for a first import to go.
fn default_destination() -> Option<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from)?;
let pictures = home.join("Pictures");
// Only if it exists: inventing a folder the user has not asked for, in a
// field they may not read, is how photographs end up somewhere nobody
// looks. An empty field keeps Import disabled until they choose.
pictures.exists().then_some(pictures)
}
/// Count what is on the card, without copying anything. /// Count what is on the card, without copying anything.
/// ///
/// Runs on the UI thread, which is defensible only because it is a directory /// Runs on the UI thread, which is defensible only because it is a directory
@@ -570,14 +547,21 @@ fn start(
Some(PathBuf::from(stored.backup.trim())) Some(PathBuf::from(stored.backup.trim()))
}; };
let Some(upload) = context.upload else {
*ctl.error.borrow_mut() =
"Sign in first — the library this imports into is on the server.".into();
render(window, ctl);
return;
};
let request = Request { let request = Request {
card: PathBuf::from(ctl.card.borrow().clone()), card: PathBuf::from(ctl.card.borrow().clone()),
library: PathBuf::from(&stored.destination), staging: upload.staging.clone(),
backup, backup,
catalog: context.catalog.clone(), catalog: context.catalog.clone(),
filter: context.filter.clone(), filter: context.filter.clone(),
options: ctl.ingest_options(), options: ctl.ingest_options(),
upload: stored.upload.then_some(context.upload).flatten(), upload,
}; };
let cancel: import::Cancel = Default::default(); let cancel: import::Cancel = Default::default();
@@ -745,6 +729,35 @@ mod tests {
assert_eq!(preview(&s, AUG_22), "2026/EOS R5"); assert_eq!(preview(&s, AUG_22), "2026/EOS R5");
} }
#[test]
fn an_import_needs_an_account_before_it_can_start() {
// The library is on the server, so "where does this go" has no local
// answer to fall back on. Import stays disabled rather than quietly
// filling a folder nothing ever shows.
let ctl = ImportController::new(
crate::settings_ui::SettingsController::new(),
crate::activity::ActivityLog::new(),
);
*ctl.card.borrow_mut() = "/run/media/duncan/EOS DIGITAL".into();
*ctl.survey.borrow_mut() = Some((12, 1234));
assert!(!ctl.can_start(), "no account, yet Import was offered");
*ctl.upload_target.borrow_mut() = "PhotosRaw".into();
assert!(ctl.can_start());
}
#[test]
fn an_empty_card_cannot_be_imported_either() {
let ctl = ImportController::new(
crate::settings_ui::SettingsController::new(),
crate::activity::ActivityLog::new(),
);
*ctl.card.borrow_mut() = "/run/media/duncan/EOS DIGITAL".into();
*ctl.upload_target.borrow_mut() = "PhotosRaw".into();
*ctl.survey.borrow_mut() = Some((0, 0));
assert!(!ctl.can_start());
}
#[test] #[test]
fn a_volume_says_why_it_is_being_offered() { fn a_volume_says_why_it_is_being_offered() {
// "EOS DIGITAL" and "archive" look equally plausible in a list, and // "EOS DIGITAL" and "archive" look equally plausible in a list, and
+41
View File
@@ -733,6 +733,30 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let entries = Rc::new(RefCell::new(collect(&paths))); let entries = Rc::new(RefCell::new(collect(&paths)));
log::info!("{} image(s) to browse", entries.borrow().len()); log::info!("{} image(s) to browse", entries.borrow().len());
// TRACES: FR-PLAT-LIN-1
// The name the compositor knows this window by, and the reason the
// launcher shows a real icon rather than a grey square.
//
// `app.slint` sets `icon:`, and that is genuinely embedded (see
// `build.rs`) — but a Wayland compositor ignores a client-set icon
// entirely. It takes the icon from the `.desktop` file whose basename
// matches the surface's `app_id`, and nothing else. So the embedded icon
// is what X11 and the window itself use, and *this* is what GNOME's
// overview, dash and alt-tab use. Both are needed and neither substitutes
// for the other.
//
// The string must equal the installed `.desktop` file's basename exactly:
// `packaging/paris.tourolle.darkroom.desktop`. It matches the Android
// package id (`AndroidManifest.xml`) on purpose — one application, one
// reverse-DNS name on both platforms.
//
// Before the window, necessarily: it is read when the surface is created.
if let Err(e) = slint::set_xdg_app_id("paris.tourolle.darkroom") {
// Not fatal. On X11 and on Android this does nothing useful, and a
// missing app id costs an icon rather than a working application.
log::debug!("could not set the xdg app id: {e}");
}
// Before the window, and it has to be: this selects the Slint backend, and // Before the window, and it has to be: this selects the Slint backend, and
// creating a window selects one for us. See `shared_gpu`. The device is // creating a window selects one for us. See `shared_gpu`. The device is
// shared by demosaic, the adjust pass and the compositor; without one the // shared by demosaic, the adjust pass and the compositor; without one the
@@ -740,6 +764,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let gpu = shared_gpu(); let gpu = shared_gpu();
let window = AppWindow::new()?; let window = AppWindow::new()?;
// The version the About page shows. Taken from the crate rather than
// passed in, so it is the version of the code that is running and cannot
// be set to something else by a caller.
window.set_app_version(env!("CARGO_PKG_VERSION").into());
match &gpu { match &gpu {
Some(ctx) => { Some(ctx) => {
log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend()); log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend());
@@ -953,6 +981,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// pushes, so a thumbnail made during an import is the // pushes, so a thumbnail made during an import is the
// one every other client gets. // one every other client gets.
thumbs: library::thumbs_dir(&session.server, &session.user_id), thumbs: library::thumbs_dir(&session.server, &session.user_id),
staging: import::staging_dir(&session.server, &session.user_id),
}), }),
}) })
}, },
@@ -986,6 +1015,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let stored = settings.snapshot(); let stored = settings.snapshot();
library.set_cache_budget(stored.cache.original_budget_bytes); library.set_cache_budget(stored.cache.original_budget_bytes);
library.set_keep_opened_originals(stored.cache.keep_opened_originals); library.set_keep_opened_originals(stored.cache.keep_opened_originals);
library.set_timeline_bars(stored.library.timeline_bars);
} }
// --- the export folder picker ---------------------------------------- // --- the export folder picker ----------------------------------------
@@ -1122,6 +1152,17 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
refresh_export_label(&w, &ctl); 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 // Lowering the ceiling evicts, so the figure beside it has just
// changed — leaving the old one would show the cache still over a // changed — leaving the old one would show the cache still over a
// limit that was enforced a moment ago. // limit that was enforced a moment ago.
+204 -32
View File
@@ -375,6 +375,23 @@ pub enum Amendment {
Settings { Settings {
preset: dr_pipeline::Preset, preset: dr_pipeline::Preset,
scope: dr_pipeline::Scope, scope: dr_pipeline::Scope,
/// TRACES: FR-DEV-3 | FR-CAT-8
/// The local adjustments, when this is an image's own edit being
/// written back rather than a paste onto someone else's.
///
/// A `Preset` is a parameter map, and a mask is not a parameter — it
/// is a rule about *where*, with a chain of its own. So a save that
/// carried only the preset wrote the sliders and silently dropped
/// every local adjustment: the sidecar format has stored masks since
/// they were added and `Version::apply` restores them, but nothing
/// ever put any there. The mask survived until the session ended and
/// then did not exist.
///
/// `None` for a paste, which must not carry the source image's masks
/// onto the target: a mask is drawn against one photograph and means
/// nothing on another, and `Scope` cannot express that because it
/// filters parameters.
masks: Option<dr_pipeline::mask::MaskStack>,
}, },
} }
@@ -591,8 +608,20 @@ fn amend(base: dr_pipeline::Sidecar, w: &SidecarWrite) -> dr_pipeline::Sidecar {
version.rating = *rating; version.rating = *rating;
version.flag = *flag; version.flag = *flag;
} }
Amendment::Settings { preset, scope } => { Amendment::Settings {
preset,
scope,
masks,
} => {
preset.amend(&mut version.params, *scope); preset.amend(&mut version.params, *scope);
// Replaced wholesale rather than merged: this is the whole of the
// image's local adjustment stack as it stands, so a layer the user
// deleted has to leave the sidecar too. Cross-device merging of
// two stacks is `Sidecar::merge`'s job and happens on sync, not
// here (FR-NC-9).
if let Some(masks) = masks {
version.masks = masks.clone();
}
} }
} }
@@ -3091,44 +3120,97 @@ pub fn span_scoped(
.and_then(|(lo, hi)| Some((lo?, hi?))) .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 /// # Why not calendar buckets
/// membership join, and the rating filter applied too, or the bars disagree ///
/// with the grid beneath them. /// [`timeline_scoped`] groups by year, month, day or hour, which has two
pub fn timeline_scoped( /// 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, catalog: &Catalog,
scope: Option<dr_types::CollectionId>, scope: Option<dr_types::CollectionId>,
filter: &RatingFilter, filter: &RatingFilter,
granularity: dr_catalog::Granularity,
from: i64, from: i64,
to: i64, to: i64,
bins: u32,
) -> Result<Vec<dr_catalog::TimeBucket>, dr_catalog::CatalogError> { ) -> 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(); 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!( 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} WHERE {VISIBLE}{rated} AND i.captured_at IS NOT NULL{clause}
AND i.captured_at >= ? AND i.captured_at <= ? AND i.captured_at >= {from} AND i.captured_at <= {to}
GROUP BY strftime('{}', i.captured_at GROUP BY b
+ coalesce(i.captured_offset, 0) * 60, 'unixepoch') ORDER BY b ASC"
ORDER BY start ASC",
granularity.strftime()
); );
params.push(rusqlite::types::Value::Integer(from));
params.push(rusqlite::types::Value::Integer(to));
let conn = catalog.connection(); let conn = catalog.connection();
let mut stmt = conn.prepare(&sql)?; let mut stmt = conn.prepare(&sql)?;
let rows = stmt let counted = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| { .query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(dr_catalog::TimeBucket { Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)? as u32))
start: r.get(0)?,
count: r.get::<_, i64>(1)? as u32,
})
})? })?
.collect::<Result<Vec<_>, _>>()?; .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. /// Total images in the catalog, honouring the rating filter.
@@ -4136,16 +4218,7 @@ mod tests {
fn the_histogram_ignores_the_range_it_is_used_to_choose() { fn the_histogram_ignores_the_range_it_is_used_to_choose() {
// Drawing the axis through the chosen range would collapse it onto the // Drawing the axis through the chosen range would collapse it onto the
// selection, leaving nowhere to widen back out from. // selection, leaving nowhere to widen back out from.
let catalog = scanned(3); let catalog = dated_three();
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 ranged = RatingFilter { let ranged = RatingFilter {
captured_from: Some(4_000), captured_from: Some(4_000),
captured_to: Some(6_000), captured_to: Some(6_000),
@@ -4158,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 { fn entry(path: &str, file_id: u64, size: u64) -> RemoteEntry {
RemoteEntry { RemoteEntry {
id: RemoteId::Stable(file_id), id: RemoteId::Stable(file_id),
+625 -46
View File
@@ -256,6 +256,16 @@ pub struct LibraryController {
/// small budget still keeps a working set. A metered or small-disk device /// small budget still keeps a working set. A metered or small-disk device
/// wants the first. /// wants the first.
keep_opened: std::cell::Cell<bool>, 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. /// Where every worker this module starts reports what it is doing.
/// ///
/// Held on the controller rather than passed to each function because the /// Held on the controller rather than passed to each function because the
@@ -311,9 +321,22 @@ impl LibraryController {
keep_opened: std::cell::Cell::new( keep_opened: std::cell::Cell::new(
dr_types::CacheSettings::default().keep_opened_originals, 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 /// TRACES: FR-NC-6a
/// Whether a develop open should keep the original it downloads. /// Whether a develop open should keep the original it downloads.
/// ///
@@ -1910,7 +1933,12 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
} else { } else {
library::total_images_scoped(catalog, scope, &filter).unwrap_or(0) 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); 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 // Clamp so a scrub to the very end still fills the window rather than
// showing a handful of cells. // showing a handful of cells.
@@ -2008,7 +2036,7 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
period_heading: heading.into(), period_heading: heading.into(),
// A freshly loaded window has no drag in flight. // A freshly loaded window has no drag in flight.
lifted: false, lifted: false,
name: c.name.as_str().into(), name: without_extension(&c.name).into(),
thumbnail: carried.map(|h| h.thumbnail.clone()).unwrap_or_default(), thumbnail: carried.map(|h| h.thumbnail.clone()).unwrap_or_default(),
has_thumb: carried.is_some_and(|h| h.has_thumb), has_thumb: carried.is_some_and(|h| h.has_thumb),
unavailable: carried.is_some_and(|h| h.unavailable), unavailable: carried.is_some_and(|h| h.unavailable),
@@ -2064,6 +2092,21 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
// they are refreshed here rather than per cell. // they are refreshed here rather than per cell.
refresh_rating_counts(window, catalog); 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 { if total == 0 {
return; return;
} }
@@ -2475,6 +2518,10 @@ fn collect_settings_writes(
amendment: library::Amendment::Settings { amendment: library::Amendment::Settings {
preset: preset.clone(), preset: preset.clone(),
scope, scope,
// A paste carries no masks, and must not: a mask is drawn
// against one photograph and describes nothing on another.
// The target keeps whatever local adjustments it already had.
masks: None,
}, },
}) })
}); });
@@ -3211,9 +3258,32 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
} }
// Adopted thumbnails and merged collections both change // Adopted thumbnails and merged collections both change
// what the grid should show. // what the grid should show.
if report.thumbnails_adopted > 0 || report.collections_gained > 0 { if report.thumbnails_adopted > 0
|| report.collections_gained > 0
|| report.members_gained > 0
{
load_window(&w, &ctl_cb); load_window(&w, &ctl_cb);
} }
// TRACES: FR-CAT-7
// And the sidebar, which the grid reload does not
// touch. Membership counts as a change: a sync that
// files 127 photographs into a collection both devices
// already had gains no *collection*, so keying this on
// `collections_gained` alone left the tree showing no
// number beside a collection that had just been filled.
//
// The scan already does this after it finishes; the
// sync merges the same tables and did not.
if report.collections_gained > 0 || report.members_gained > 0 {
let coll = ctl_cb.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade());
if let Some(coll) = coll {
let borrow = ctl_cb.catalog();
let borrow = borrow.borrow();
if let Some(cat) = borrow.as_ref() {
crate::collections_ui::refresh_tree(&w, &coll, cat);
}
}
}
stop(&ctl_cb.sync_timer); stop(&ctl_cb.sync_timer);
return; return;
} }
@@ -3504,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. // fortnight in Arosa landed in one column of a fifteen-year axis.
let scope = *ctl.scope.borrow(); let scope = *ctl.scope.borrow();
let filter = *ctl.filter.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) { let span = match library::span_scoped(catalog, scope, &filter) {
Some(s) => s, Some(s) => s,
None => { None => {
@@ -3515,13 +3591,24 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<LibraryContr
} }
}; };
// 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.
//
// Zoom narrows the span around wherever the view sits rather than around // 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. // the library's midpoint, so zooming in keeps what you were looking at.
let zoom = *ctl.timeline_zoom.borrow(); let zoom = *ctl.timeline_zoom.borrow();
let (from, to) = zoomed_span(span, zoom, *ctl.timeline_centre.borrow()); let (from, to) = zoomed_span(span, zoom, *ctl.timeline_centre.borrow());
let granularity = dr_catalog::Granularity::for_span(to - from); // A fixed number of equal bins, re-cut on every zoom (FR-CAT-6). The
let buckets = match library::timeline_scoped(catalog, scope, &filter, granularity, from, to) { // 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, Ok(b) => b,
Err(e) => { Err(e) => {
log::debug!("timeline: {e}"); log::debug!("timeline: {e}");
@@ -3555,7 +3642,16 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<LibraryContr
// Square root rather than linear: it keeps a 3-image day // Square root rather than linear: it keeps a 3-image day
// visible beside a 400-image one without a log scale's // visible beside a 400-image one without a log scale's
// misleading flatness. // 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, start: b.start as i32,
count: b.count as i32, count: b.count as i32,
label: format_bucket(b.start, granularity).into(), label: format_bucket(b.start, granularity).into(),
@@ -3577,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 // slot contained the instant — near enough on a dense uniform axis, plainly
// wrong on a sparse one, and never under the click. // wrong on a sparse one, and never under the click.
let current = *ctl.current_bucket.borrow(); let current = *ctl.current_bucket.borrow();
let fraction = current let fraction = current.map(|t| fraction_at((from, to), t)).unwrap_or(-1.0);
.map(|t| ((t - from) as f64 / (to - from).max(1) as f64).clamp(0.0, 1.0) as f32)
.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_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_anchored(current.is_some());
window window
.set_library_timeline_label(format!("{} – {}", format_date(from), format_date(to)).into()); .set_library_timeline_label(format!("{} – {}", format_date(from), format_date(to)).into());
@@ -3592,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. /// 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. /// 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 { 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. /// Full month name, for the grid's headings.
@@ -3622,6 +3747,64 @@ fn month_abbrev(m: i64) -> &'static str {
NAMES[((m - 1).clamp(0, 11)) as usize] 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`. /// Narrow a span by a zoom level, centred on `centre`.
/// ///
/// Each step halves or doubles the visible duration. Clamped to the library's /// Each step halves or doubles the visible duration. Clamped to the library's
@@ -3660,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 /// collection, a scrub landed at an instant the collection did not contain and
/// the view jumped somewhere the user had not asked for. /// the view jumped somewhere the user had not asked for.
fn catalog_span(catalog: &Catalog, ctl: &Rc<LibraryController>) -> Option<(i64, i64)> { 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()) library::span_scoped(catalog, *ctl.scope.borrow(), &ctl.filter.borrow())
} }
@@ -3738,6 +3927,43 @@ fn scrub_to(window: &AppWindow, ctl: &Rc<LibraryController>, when: i64) {
load_window(window, ctl); 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. /// Format a bucket start for the histogram's hover label.
fn format_bucket(t: i64, g: dr_catalog::Granularity) -> String { fn format_bucket(t: i64, g: dr_catalog::Granularity) -> String {
let (y, m, d, h) = civil_from_unix(t); let (y, m, d, h) = civil_from_unix(t);
@@ -4335,7 +4561,7 @@ pub fn wire<F>(
*ctl.timeline_zoom.borrow(), *ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.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); drop(borrow);
scrub_to(&w, &ctl, when); scrub_to(&w, &ctl, when);
}); });
@@ -4585,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 // 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 // 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 // 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 // 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. // cannot see the extent of is a filter that looks like an empty library.
@@ -4599,32 +4831,151 @@ pub fn wire<F>(
let ctl = ctl.clone(); let ctl = ctl.clone();
window.on_library_toggle_date_range(move || { window.on_library_toggle_date_range(move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
let on = !ctl.filter.borrow().has_date_range(); // Whether the *controls* are showing, which is not the same as
// whether a range is set — and reading it from the filter was why
// pressing this did nothing. The fields are how a range gets set,
// so requiring one before they appear is a door locked from the
// inside.
let on = !w.get_library_range_active();
if on { if on {
// The span the axis is currently drawn over, which is what the // Seeded from the span the axis is drawn over, which is what
// user is looking at when they ask for "this range". // the user is looking at when they ask for "this range".
let borrow = ctl.catalog.borrow(); //
let Some(catalog) = borrow.as_ref() else { // Only a seed. If there is no catalog yet, or nothing in it
return; // carries a capture date — dates are read from EXIF as
// thumbnails load, so a freshly opened library has none — the
// fields simply open empty and wait to be typed into. This
// used to `return` in both cases, which meant the button
// silently did nothing on exactly the libraries where a person
// is most likely to be looking for a date.
let seeded = {
let borrow = ctl.catalog.borrow();
borrow
.as_ref()
.and_then(|catalog| catalog_span(catalog, &ctl))
.map(|span| {
zoomed_span(
span,
*ctl.timeline_zoom.borrow(),
*ctl.timeline_centre.borrow(),
)
})
}; };
let Some(span) = catalog_span(catalog, &ctl) else { if let Some(span) = seeded {
return; // Through the same days-inclusive conversion a drag uses,
}; // so a seeded range and a dragged one are the same kind of
let (from, to) = zoomed_span( // thing — and so `show_range` reads this one back out as
span, // the two days it covers.
*ctl.timeline_zoom.borrow(), let (from, to) = dragged_range(span, 0.0, 1.0);
*ctl.timeline_centre.borrow(), let mut f = ctl.filter.borrow_mut();
); f.captured_from = Some(from);
drop(borrow); f.captured_to = Some(to);
let mut f = ctl.filter.borrow_mut(); }
f.captured_from = Some(from);
f.captured_to = Some(to);
} else { } else {
let mut f = ctl.filter.borrow_mut(); let mut f = ctl.filter.borrow_mut();
f.captured_from = None; f.captured_from = None;
f.captured_to = None; f.captured_to = None;
} }
w.set_library_range_active(on); w.set_library_range_active(on);
show_range(&w, &ctl);
refilter(&w, &ctl);
});
}
// 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.
//
// The chip alone used to be the whole control and it read its span from
// the timeline's zoom, which is zero until someone zooms — so "limit to
// range" set the range to the entire library and appeared to do nothing.
// The span is shown now, and these two fields are how it is corrected.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_library_range_edited(move |from, to| {
let Some(w) = weak.upgrade() else { return };
// Both ends, or neither. A range with one end parsed and the other
// silently dropped is a filter nobody asked for, and the grid
// going empty is a poor way to find out a date was mistyped.
let (Some(from), Some(to)) = (
dr_types::parse_date(from.as_str()),
dr_types::parse_date(to.as_str()),
) else {
w.set_library_range_invalid(true);
return;
};
// Typed the other way round is a slip, not an error: the user has
// said which two days they mean and there is exactly one range
// between them.
let (from, to) = if from <= to { (from, to) } else { (to, from) };
{
let mut f = ctl.filter.borrow_mut();
f.captured_from = Some(from);
// 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 same rule `dragged_range` applies.
f.captured_to = Some(to + 86_400 - 1);
}
w.set_library_range_invalid(false);
w.set_library_range_active(true);
show_range(&w, &ctl);
refilter(&w, &ctl); refilter(&w, &ctl);
}); });
} }
@@ -4783,12 +5134,6 @@ fn stop(slot: &RefCell<Option<slint::Timer>>) {
mod tests { mod tests {
use super::*; 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] #[test]
fn a_scrub_fraction_interpolates_within_the_span() { fn a_scrub_fraction_interpolates_within_the_span() {
// The point of going fractional: a slow drag must advance // The point of going fractional: a slow drag must advance
@@ -4802,12 +5147,6 @@ mod tests {
assert_ne!(instant_at(span, 0.10), instant_at(span, 0.11)); 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] #[test]
fn the_marker_lands_on_the_fraction_that_was_clicked() { fn the_marker_lands_on_the_fraction_that_was_clicked() {
// The bug this guards: the marker was placed from a *bar index* while // The bug this guards: the marker was placed from a *bar index* while
@@ -4815,7 +5154,7 @@ mod tests {
// the pointer. Marker and click must be inverses. // the pointer. Marker and click must be inverses.
let span = (1_000, 9_000); let span = (1_000, 9_000);
for f in [0.0_f32, 0.1, 0.25, 0.5, 0.75, 1.0] { 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!( assert!(
(round_trip - f).abs() < 1e-3, (round_trip - f).abs() < 1e-3,
"clicked {f}, marker drawn at {round_trip}" "clicked {f}, marker drawn at {round_trip}"
@@ -4828,8 +5167,8 @@ mod tests {
// Zooming in leaves the grid's instant outside the axis. The marker // Zooming in leaves the grid's instant outside the axis. The marker
// belongs at the edge it went past, not off the widget. // belongs at the edge it went past, not off the widget.
let span = (1_000, 2_000); let span = (1_000, 2_000);
assert_eq!(marker_at(span, 0), 0.0); assert_eq!(fraction_at(span, 0), 0.0);
assert_eq!(marker_at(span, 5_000), 1.0); assert_eq!(fraction_at(span, 5_000), 1.0);
} }
#[test] #[test]
@@ -4841,6 +5180,92 @@ mod tests {
assert_eq!(instant_at(span, 9.9), 200); 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 /// One pinch update, as the handler applies it: fold the ratio in, take
/// out whole doublings, carry the remainder. /// out whole doublings, carry the remainder.
fn pinch_step(accum: &mut f32, ratio: f32) -> i32 { fn pinch_step(accum: &mut f32, ratio: f32) -> i32 {
@@ -5087,6 +5512,92 @@ mod tests {
assert_eq!(y, 1969); assert_eq!(y, 1969);
} }
/// A bar is named after how long it is.
///
/// 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_bin_is_labelled_at_its_own_scale() {
use dr_catalog::Granularity;
const DAY: i64 = 86_400;
// 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
);
// 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);
// 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: 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_bucket(days * DAY / 32);
let bucket = match g {
Granularity::Year => 365 * DAY,
Granularity::Month => 30 * DAY,
Granularity::Day => DAY,
Granularity::Hour => 3600,
};
assert!(
bucket <= previous,
"{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] #[test]
fn bucket_labels_match_their_granularity() { fn bucket_labels_match_their_granularity() {
use dr_catalog::Granularity; use dr_catalog::Granularity;
@@ -5477,3 +5988,71 @@ mod tests {
assert_eq!(paths, vec!["a.CR2", "b.CR2"]); assert_eq!(paths, vec!["a.CR2", "b.CR2"]);
} }
} }
/// TRACES: FR-UI-2
/// A filename as a caption: without the part that says how it is stored.
///
/// A grid cell is about four words wide, and `.CR2` spends one of them saying
/// something the photographer already knows — every frame in a RAW library
/// ends the same way, so the extension distinguishes nothing while taking
/// room from the part that does. The name is elided under pressure, and it is
/// the *end* that goes, so an extension can push the digits that identify a
/// frame off the visible part of its own label.
///
/// Display only. `LibraryCell::name` keeps the true filename and
/// `remote_path` the full path, because both are used to find the file again
/// and a stem is not a filename.
///
/// **The case this is wrong for**, worth knowing before it is reported: a
/// library holding `IMG_1234.CR2` beside `IMG_1234.JPG` shows two cells
/// captioned `IMG_1234`. They are still two rows with two thumbnails and two
/// entries in the info panel, and RAW+JPEG pairs are usually shot to be one
/// photograph anyway — but the caption alone no longer separates them.
fn without_extension(name: &str) -> &str {
match name.rsplit_once('.') {
// The guard is for a leading dot: `.hidden` splits to an empty stem,
// and a dotfile's name starts with that dot rather than ending with an
// extension. A *trailing* dot needs no guard — `odd.` splits to `odd`,
// which is the better caption anyway.
Some((stem, _)) if !stem.is_empty() => stem,
_ => name,
}
}
#[cfg(test)]
mod display_name_tests {
use super::without_extension;
#[test]
fn an_extension_is_dropped_and_nothing_else_is() {
assert_eq!(without_extension("IMG_1234.CR2"), "IMG_1234");
assert_eq!(without_extension("IMG_1234.jpeg"), "IMG_1234");
// Only the last one: a name can contain dots and they are part of it.
assert_eq!(without_extension("2026.08.23-a.dng"), "2026.08.23-a");
// Nothing to drop.
assert_eq!(without_extension("IMG_1234"), "IMG_1234");
// A dotfile is not an extensionless name with an extension.
assert_eq!(without_extension(".hidden"), ".hidden");
// A trailing dot is an empty extension, and dropping it is right.
assert_eq!(without_extension("odd."), "odd");
assert_eq!(without_extension(""), "");
}
}
/// TRACES: FR-CAT-6
/// Show the range the filter is actually using.
///
/// Pushed back rather than left as the user typed it, because the filter may
/// have adjusted it — ends given backwards are swapped, and the closing day is
/// extended to include itself. A field showing something other than what is
/// being filtered on is worse than one showing nothing.
fn show_range(window: &AppWindow, ctl: &Rc<LibraryController>) {
let f = ctl.filter.borrow();
let text = |t: Option<i64>| -> slint::SharedString {
t.map(dr_types::format_date).unwrap_or_default().into()
};
window.set_library_range_from(text(f.captured_from));
// The stored end is the last second of the closing day; naming that day is
// what the user typed and what they should read back.
window.set_library_range_to(text(f.captured_to.map(|t| t - 86_400 + 1)));
}
+135 -20
View File
@@ -28,7 +28,7 @@ use std::rc::Rc;
use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel}; use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel};
use crate::develop::{Abandon, DevelopSession, Segmented, SessionId}; use crate::develop::{Abandon, DevelopSession, RefinedInstance, Segmented, SessionId};
use crate::segmentation; use crate::segmentation;
use crate::{sync_rows, AppWindow, GradientHandle, MaskRow, ParamRow, SubjectRow}; use crate::{sync_rows, AppWindow, GradientHandle, MaskRow, ParamRow, SubjectRow};
@@ -48,17 +48,18 @@ pub(crate) const GLOBAL_SCOPE: &str = "ADJUST";
/// string the row in the stack above shows — one name for one thing, so the /// string the row in the stack above shows — one name for one thing, so the
/// selected row and the panel it scopes cannot appear to disagree. /// selected row and the panel it scopes cannot appear to disagree.
pub(crate) fn scope_label(session: &DevelopSession) -> String { pub(crate) fn scope_label(session: &DevelopSession) -> String {
let Some(id) = session.active_mask() else { match session.active_masks() {
return GLOBAL_SCOPE.to_string(); [] => GLOBAL_SCOPE.to_string(),
}; [id] => session
session .mask_layers()
.mask_layers() .into_iter()
.into_iter() .find(|(layer_id, ..)| layer_id == id)
.find(|(layer_id, ..)| layer_id == id) .map_or_else(
.map_or_else( || GLOBAL_SCOPE.to_string(),
|| GLOBAL_SCOPE.to_string(), |(_, label, ..)| label.to_uppercase(),
|(_, label, ..)| label.to_uppercase(), ),
) many => format!("{} LAYERS", many.len()),
}
} }
/// How often the window looks to see whether the model has finished. /// How often the window looks to see whether the model has finished.
@@ -309,7 +310,7 @@ pub(crate) fn wire(
let redraw = redraw.clone(); let redraw = redraw.clone();
let rows = rows.clone(); let rows = rows.clone();
let running = running.clone(); let running = running.clone();
window.on_segment_image(move || { window.on_segment_image(move |fine| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
// Both the photograph and the device come out of the session, so // Both the photograph and the device come out of the session, so
// no session is nothing to look at and nothing to look with. // no session is nothing to look at and nothing to look with.
@@ -329,7 +330,13 @@ pub(crate) fn wire(
w.set_segmenting(true); w.set_segmenting(true);
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
let options = segmentation::Options::default(); // The only thing the button decides. Everything else about the
// run is the same, which is what makes the second pass a genuine
// re-run of the first rather than a different feature.
let options = segmentation::Options {
fine,
..segmentation::Options::default()
};
std::thread::spawn(move || { std::thread::spawn(move || {
// A failed send means the window stopped waiting — the user // A failed send means the window stopped waiting — the user
// moved on, or the app is closing. Neither is worth reporting: // moved on, or the app is closing. Neither is worth reporting:
@@ -341,6 +348,49 @@ pub(crate) fn wire(
}); });
} }
// --- refining one subject's mask ---------------------------------------
//
// Its own `Running` slot rather than sharing the segmentation one: the
// two answer different questions (the whole frame's subjects versus one
// already-found instance) and there is no reason a refine in flight
// should block a fresh "Find subjects", or the reverse.
let refining: Rc<RefCell<Running>> = Rc::default();
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
let running = refining.clone();
window.on_mask_refined(move |id| {
let Some(w) = weak.upgrade() else { return };
let Some(index) = session
.borrow()
.as_ref()
.and_then(|s| s.subject_instance_index(&id))
else {
return;
};
let Some(job) = session.borrow().as_ref().and_then(|s| s.refine_job(index)) else {
return;
};
let session_id = job.session();
let claimed = running.borrow_mut().start(session_id, job.abandon());
if !claimed {
return;
}
w.set_refining(true);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(job.run());
});
watch_refine(&w, session_id, rx, &session, &rows, &redraw, &running);
});
}
// --- dragging a gradient on the photograph ---------------------------- // --- dragging a gradient on the photograph ----------------------------
// //
// The geometry the gesture started from, held for its duration. // The geometry the gesture started from, held for its duration.
@@ -429,15 +479,23 @@ pub(crate) fn wire(
let weak = window.as_weak(); let weak = window.as_weak();
let session = session.clone(); let session = session.clone();
let rows = rows.clone(); let rows = rows.clone();
window.on_mask_selected(move |id| { window.on_mask_selected(move |id, extend| {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
{ {
let mut slot = session.borrow_mut(); let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return }; let Some(s) = slot.as_mut() else { return };
// Clicking the selected layer again deselects it, which is how if extend {
// the panel gets back to the whole photograph without a // Control/command-click: add or remove this one layer,
// separate "edit globally" control. // keeping whatever else was already selected.
if s.active_mask() == Some(id.as_str()) { s.toggle_active_mask(&id);
} else if s.active_masks() == [id.to_string()] {
// Clicking the sole selected layer again deselects it,
// which is how the panel gets back to the whole
// photograph without a separate "edit globally" control.
// Only when it is the *only* one selected — a plain click
// on one row of a multi-selection collapses to just that
// row rather than clearing everything, which is the more
// useful reading of "I clicked a specific layer".
s.set_active_mask(None); s.set_active_mask(None);
} else { } else {
s.set_active_mask(Some(&id)); s.set_active_mask(Some(&id));
@@ -666,6 +724,63 @@ fn watch(
running.borrow_mut().poll = Some(timer); running.borrow_mut().poll = Some(timer);
} }
/// [`watch`]'s counterpart for a refine pass — same shape, same reason: the
/// window polls rather than being called back from the worker, and a
/// photograph the user has left discards its answer instead of applying it.
fn watch_refine(
window: &AppWindow,
id: SessionId,
rx: std::sync::mpsc::Receiver<Result<Option<RefinedInstance>, String>>,
session: &Rc<RefCell<Option<DevelopSession>>>,
rows: &Rc<VecModel<ParamRow>>,
redraw: &Rc<dyn Fn(&AppWindow)>,
running: &Rc<RefCell<Running>>,
) {
let weak = window.as_weak();
let session = session.clone();
let rows = rows.clone();
let redraw = redraw.clone();
let slot = running.clone();
let timer = Rc::new(slint::Timer::default());
let stop = Rc::downgrade(&timer);
timer.start(slint::TimerMode::Repeated, POLL, move || {
let done = || {
if let Some(t) = stop.upgrade() {
t.stop();
}
};
if delivery(id, open_session(&session)) == Delivery::Discard {
slot.borrow_mut().finish(id);
done();
return;
}
let Ok(answer) = rx.try_recv() else { return };
slot.borrow_mut().finish(id);
done();
let Some(w) = weak.upgrade() else { return };
match answer {
Ok(Some(refined)) => {
if let Some(s) = session.borrow_mut().as_mut() {
s.adopt_refined(refined);
}
}
// Abandoned, or the model found nothing of the same class in the
// padded crop — both leave the instance exactly as it was.
Ok(None) => {}
Err(e) => log::warn!("refine failed: {e}"),
}
w.set_refining(false);
sync(&w, &session);
sync_rows(&w, &rows, &session);
redraw(&w);
});
running.borrow_mut().poll = Some(timer);
}
/// Clear the panel when the open image changes. /// Clear the panel when the open image changes.
/// ///
/// Its own function rather than a call to [`sync`] with an empty session, /// Its own function rather than a call to [`sync`] with an empty session,
@@ -677,6 +792,7 @@ fn watch(
pub(crate) fn reset(window: &AppWindow) { pub(crate) fn reset(window: &AppWindow) {
window.set_overlay_on(false); window.set_overlay_on(false);
window.set_segmenting(false); window.set_segmenting(false);
window.set_refining(false);
window.set_segmented(false); window.set_segmented(false);
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default())); window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default())); window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
@@ -844,7 +960,6 @@ mod tests {
); );
} }
/// The ordinary case: the answer comes back to the photograph that asked. /// The ordinary case: the answer comes back to the photograph that asked.
#[test] #[test]
fn a_result_for_the_open_photograph_is_applied() { fn a_result_for_the_open_photograph_is_applied() {
+98 -12
View File
@@ -177,7 +177,16 @@ pub fn load_local(image: &Path) -> Option<Sidecar> {
/// Read first for the same reason the remote writer does: the file may already /// Read first for the same reason the remote writer does: the file may already
/// hold a rating, or an operation this build does not know about, and writing /// hold a rating, or an operation this build does not know about, and writing
/// a fresh document containing only the current edit would delete both. /// a fresh document containing only the current edit would delete both.
pub fn save_local(image: &Path, preset: &Preset, scope: Scope) -> Result<(), String> { /// `masks` carries the local adjustments when this is the image's own edit,
/// and is `None` for a paste, which must leave the target's masks alone — a
/// mask is drawn against one photograph and describes nothing on another. See
/// `library::Amendment::Settings` for the same distinction on the remote path.
pub fn save_local(
image: &Path,
preset: &Preset,
scope: Scope,
masks: Option<&dr_pipeline::mask::MaskStack>,
) -> Result<(), String> {
let path = local_sidecar_path(image); let path = local_sidecar_path(image);
// Distinguish "no sidecar yet" from "a sidecar this build cannot read". // Distinguish "no sidecar yet" from "a sidecar this build cannot read".
@@ -217,6 +226,11 @@ pub fn save_local(image: &Path, preset: &Preset, scope: Scope) -> Result<(), Str
}); });
preset.amend(&mut version.params, scope); preset.amend(&mut version.params, scope);
// Wholesale, not merged: this is the stack as it stands, so a layer the
// user deleted must leave the file too.
if let Some(masks) = masks {
version.masks = masks.clone();
}
version.revision = version.revision.saturating_add(1); version.revision = version.revision.saturating_add(1);
version.modified = now_secs(); version.modified = now_secs();
sidecar.put(version); sidecar.put(version);
@@ -252,7 +266,13 @@ pub fn save_open_edit(
session: &Rc<RefCell<Option<DevelopSession>>>, session: &Rc<RefCell<Option<DevelopSession>>>,
library: &Rc<library_ui::LibraryController>, library: &Rc<library_ui::LibraryController>,
) { ) {
let Some(preset) = session.borrow().as_ref().map(|s| s.copy_settings()) else { // Both taken in one borrow: they are one edit, and a mask stack captured
// from a session that had already moved on would be a different image's.
let Some((preset, masks)) = session
.borrow()
.as_ref()
.map(|s| (s.copy_settings(), s.masks().clone()))
else {
return; return;
}; };
@@ -265,7 +285,7 @@ pub fn save_open_edit(
// `Everything`: this is the image's *own* edit being written back, // `Everything`: this is the image's *own* edit being written back,
// not a paste onto someone else's. Excluding framing here would // not a paste onto someone else's. Excluding framing here would
// make a crop the one adjustment that never survived a restart. // make a crop the one adjustment that never survived a restart.
if let Err(e) = save_local(path, &preset, Scope::Everything) { if let Err(e) = save_local(path, &preset, Scope::Everything, Some(&masks)) {
log::warn!("saving {}: {e}", path.display()); log::warn!("saving {}: {e}", path.display());
} }
} }
@@ -277,6 +297,11 @@ pub fn save_open_edit(
amendment: library::Amendment::Settings { amendment: library::Amendment::Settings {
preset, preset,
scope: Scope::Everything, scope: Scope::Everything,
// The image's own edit, so the whole of it: `Everything`
// already says framing travels, and the local adjustments
// have to travel for the same reason. Without this the
// sliders were written and every mask was dropped.
masks: Some(masks),
}, },
}; };
library_ui::start_sidecar_writes(window, library, vec![write]); library_ui::start_sidecar_writes(window, library, vec![write]);
@@ -318,8 +343,8 @@ fn refresh_thumbnail_for(
session: &Rc<RefCell<Option<DevelopSession>>>, session: &Rc<RefCell<Option<DevelopSession>>>,
library: &Rc<library_ui::LibraryController>, library: &Rc<library_ui::LibraryController>,
) { ) {
let worth_it = window.get_can_undo() let worth_it =
|| session.borrow().as_ref().is_some_and(|s| !s.is_neutral()); window.get_can_undo() || session.borrow().as_ref().is_some_and(|s| !s.is_neutral());
if !worth_it { if !worth_it {
return; return;
} }
@@ -521,6 +546,66 @@ mod tests {
g g
} }
/// The bug this exists to prevent: a local adjustment that survived until
/// the session ended and then did not exist.
///
/// The sidecar format has stored masks since they were added, and
/// `Version::apply` restores them — but the save path wrote a `Preset`,
/// which is a parameter map, and a mask is not a parameter. So the sliders
/// came back and every mask was silently gone. Nothing reported it,
/// because nothing had failed.
#[test]
fn a_mask_survives_being_written_and_read_back() {
use dr_pipeline::mask::{MaskLayer, MaskSource};
let dir = tempdir("mask-roundtrip");
let image = dir.join("a.CR2");
let mut graph = edited();
let mut layer = MaskLayer::new(
"m1",
MaskSource::Radial {
centre: (0.4, 0.6),
radii: (0.2, 0.3),
angle: 0.0,
feather: 0.5,
},
);
layer.opacity = 0.75;
graph.masks_mut().push(layer);
assert_eq!(graph.masks().len(), 1, "the premise: the edit has a mask");
save_local(
&image,
&Preset::capture(&graph),
Scope::Everything,
Some(graph.masks()),
)
.unwrap();
// Read it back the way opening the photograph again does.
let text = std::fs::read_to_string(local_sidecar_path(&image)).unwrap();
let sidecar = Sidecar::parse(&text).unwrap();
let version = sidecar.default_version().expect("a default version");
let mut reopened = EditGraph::default_chain();
version.apply(&mut reopened);
assert_eq!(
reopened.masks().len(),
1,
"the mask must come back with the photograph"
);
let back = &reopened.masks().layers()[0];
assert_eq!(back.id, "m1");
assert!((back.opacity - 0.75).abs() < 1e-6, "and with its settings");
assert!(
matches!(back.source, MaskSource::Radial { .. }),
"and its kind: {:?}",
back.source.kind()
);
}
#[test] #[test]
fn a_sidecar_sits_beside_its_image_with_the_extension_replaced() { fn a_sidecar_sits_beside_its_image_with_the_extension_replaced() {
// Replaced, not appended, so a RAW and the JPEG beside it share one // Replaced, not appended, so a RAW and the JPEG beside it share one
@@ -537,7 +622,7 @@ mod tests {
let dir = tempdir("round-trip"); let dir = tempdir("round-trip");
let image = dir.join("a.CR2"); let image = dir.join("a.CR2");
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap(); save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
let sidecar = load_local(&image).expect("a sidecar was written"); let sidecar = load_local(&image).expect("a sidecar was written");
let mut restored = EditGraph::default_chain(); let mut restored = EditGraph::default_chain();
@@ -563,8 +648,8 @@ mod tests {
let dir = tempdir("one-version"); let dir = tempdir("one-version");
let image = dir.join("a.CR2"); let image = dir.join("a.CR2");
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap(); save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap(); save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
assert_eq!(load_local(&image).expect("a sidecar").versions.len(), 1); assert_eq!(load_local(&image).expect("a sidecar").versions.len(), 1);
} }
@@ -576,13 +661,13 @@ mod tests {
let dir = tempdir("revision"); let dir = tempdir("revision");
let image = dir.join("a.CR2"); let image = dir.join("a.CR2");
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap(); save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
let first = load_local(&image) let first = load_local(&image)
.unwrap() .unwrap()
.default_version() .default_version()
.unwrap() .unwrap()
.revision; .revision;
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap(); save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
let second = load_local(&image) let second = load_local(&image)
.unwrap() .unwrap()
.default_version() .default_version()
@@ -610,7 +695,7 @@ mod tests {
}); });
std::fs::write(local_sidecar_path(&image), sidecar.to_text()).unwrap(); std::fs::write(local_sidecar_path(&image), sidecar.to_text()).unwrap();
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap(); save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
let back = load_local(&image).unwrap(); let back = load_local(&image).unwrap();
let v = back.default_version().unwrap(); let v = back.default_version().unwrap();
@@ -627,7 +712,7 @@ mod tests {
let image = dir.join("a.CR2"); let image = dir.join("a.CR2");
std::fs::write(local_sidecar_path(&image), "drsc 99\n").unwrap(); std::fs::write(local_sidecar_path(&image), "drsc 99\n").unwrap();
assert!(save_local(&image, &Preset::capture(&edited()), Scope::Everything).is_err()); assert!(save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).is_err());
assert_eq!( assert_eq!(
std::fs::read_to_string(local_sidecar_path(&image)).unwrap(), std::fs::read_to_string(local_sidecar_path(&image)).unwrap(),
"drsc 99\n", "drsc 99\n",
@@ -642,6 +727,7 @@ mod tests {
&dir.join("a.CR2"), &dir.join("a.CR2"),
&Preset::capture(&edited()), &Preset::capture(&edited()),
Scope::Everything, Scope::Everything,
None,
) )
.unwrap(); .unwrap();
+73 -5
View File
@@ -49,6 +49,12 @@ pub struct InstanceSummary {
/// This is the *source* a distance field is built from, not the mask /// This is the *source* a distance field is built from, not the mask
/// itself — `dr_segment::Shaped` turns it into one. /// itself — `dr_segment::Shaped` turns it into one.
pub mask: Vec<u8>, pub mask: Vec<u8>,
/// `(x0, y0, x1, y1)` in [`Segmentation::proxy_size`] pixels.
///
/// Carried through from `dr_segment::Instance` rather than re-derived
/// from the mask, so a refine pass knows what region to crop without
/// scanning a proxy-sized buffer for its own extent.
pub bbox: (f32, f32, f32, f32),
} }
/// One image's recognised objects, ready to mask. /// One image's recognised objects, ready to mask.
@@ -83,6 +89,18 @@ impl Segmentation {
self.instances.get(index).map(|i| i.mask.as_slice()) self.instances.get(index).map(|i| i.mask.as_slice())
} }
/// Replace one instance in place, keeping every other index and the
/// signature unchanged.
///
/// What a refine pass calls once it has a sharper mask for the subject at
/// `index`: the layers pointing at this run by index still mean what they
/// meant, they just read better pixels now.
pub fn replace_instance(&mut self, index: usize, instance: InstanceSummary) {
if let Some(slot) = self.instances.get_mut(index) {
*slot = instance;
}
}
/// The strongest instance covering a point in normalised image /// The strongest instance covering a point in normalised image
/// coordinates. /// coordinates.
/// ///
@@ -197,11 +215,29 @@ pub struct Options {
/// costs a subject that cannot be selected at all, which is the worse /// costs a subject that cannot be selected at all, which is the worse
/// failure for a selection tool. /// failure for a selection tool.
pub confidence: f32, pub confidence: f32,
/// TRACES: FR-DEV-3
/// Run the model over overlapping tiles instead of the whole frame once.
///
/// Off by default and deliberately so. The graph's input is fixed at
/// 640x640 (docs/segmentation.md F6), so every image is letterboxed into
/// it and a subject 200px across in a 1600px proxy reaches the model at
/// 80px — which is where a coarse outline comes from. Tiling is the only
/// route to more resolution with a fixed window, and it costs one
/// inference per tile: about 2.8s for a 3x2 grid against 470ms whole-frame.
///
/// Six times the wait is the wrong default for the common case, where the
/// subject is large in frame and whole-frame inference is already the best
/// answer. It is the right answer for a bird against sky, so it is offered
/// per-image rather than chosen once for all of them.
pub fine: bool,
} }
impl Default for Options { impl Default for Options {
fn default() -> Self { fn default() -> Self {
Self { confidence: 0.30 } Self {
confidence: 0.30,
fine: false,
}
} }
} }
@@ -218,7 +254,7 @@ pub fn compute(
height: usize, height: usize,
options: &Options, options: &Options,
) -> Result<Segmentation, String> { ) -> Result<Segmentation, String> {
let found = detect(rgb, width, height)?; let found = detect(rgb, width, height, options.fine)?;
let instances: Vec<InstanceSummary> = found let instances: Vec<InstanceSummary> = found
.iter() .iter()
@@ -227,6 +263,7 @@ pub fn compute(
class_name: i.class_name.clone(), class_name: i.class_name.clone(),
score: i.score, score: i.score,
mask: quantise(&i.mask), mask: quantise(&i.mask),
bbox: i.bbox,
}) })
.collect(); .collect();
@@ -234,7 +271,19 @@ pub fn compute(
width as u32, width as u32,
height as u32, height as u32,
instances.len() as u32, instances.len() as u32,
options.confidence.to_bits() as u64, // The tiling choice belongs in the signature as much as the
// confidence does. A mask stores the signature of the segmentation its
// region ids index into (`MaskSource::Regions`), and a tiled run finds
// different instances in a different order — so if the two runs shared
// a signature, a layer built against the coarse pass would be silently
// reinterpreted against the fine one. That is a *wrong* mask, which is
// far worse than a stale one, because nothing announces it.
options.confidence.to_bits() as u64
^ if options.fine {
0x9E37_79B9_7F4A_7C15
} else {
0
},
); );
Ok(Segmentation { Ok(Segmentation {
@@ -249,10 +298,27 @@ pub fn compute(
/// Loading is ~24 ms against the ~470 ms of inference that follows, and this /// Loading is ~24 ms against the ~470 ms of inference that follows, and this
/// runs once per image — so caching the session would keep 11 MB of weights /// runs once per image — so caching the session would keep 11 MB of weights
/// resident for the life of the app to save five percent of a background task. /// resident for the life of the app to save five percent of a background task.
fn detect(rgb: &[f32], width: usize, height: usize) -> Result<Vec<dr_segment::Instance>, String> { fn detect(
rgb: &[f32],
width: usize,
height: usize,
fine: bool,
) -> Result<Vec<dr_segment::Instance>, String> {
let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?; let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?;
let options = dr_segment::SemanticOptions {
// A quarter shared with each neighbour. It has to exceed zero at all,
// or a subject sitting on a seam is cut in half by both tiles and
// recognised by neither; a quarter is enough to carry a whole subject
// inside one tile at the sizes tiling is reached for.
tiling: if fine {
dr_segment::Tiling::Grid { overlap: 0.25 }
} else {
dr_segment::Tiling::Whole
},
..dr_segment::SemanticOptions::default()
};
model model
.detect(rgb, width, height, &dr_segment::SemanticOptions::default()) .detect(rgb, width, height, &options)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
@@ -291,11 +357,13 @@ mod tests {
class_name: "bus".into(), class_name: "bus".into(),
score: 0.5, score: 0.5,
mask: big, mask: big,
bbox: (0.0, 0.0, 6.0, h as f32),
}, },
InstanceSummary { InstanceSummary {
class_name: "person".into(), class_name: "person".into(),
score: 0.9, score: 0.9,
mask: small, mask: small,
bbox: (4.0, 0.0, 8.0, h as f32),
}, },
], ],
signature: 1, signature: 1,
+31 -2
View File
@@ -26,8 +26,8 @@ use std::rc::Rc;
use dr_types::settings::budget; use dr_types::settings::budget;
use dr_types::{ use dr_types::{
CollisionPolicy, ColourSpace, ExportFormat, ExportTarget, OutputSharpening, Settings, CollisionPolicy, ColourSpace, ExportFormat, ExportTarget, LibrarySettings, OutputSharpening,
SizingMode, Settings, SizingMode,
}; };
use slint::ComponentHandle; 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_copy_includes_framing(s.develop.copy_includes_framing);
window.set_settings_cache_usage(controller.usage_label.borrow().clone().into()); 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 -------------------------------------------------------- // --- export --------------------------------------------------------
// //
// Choice rows are sent as labels plus the selected index rather than as a // 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 weak = window.as_weak();
let ctl = controller.clone(); let ctl = controller.clone();
+100 -58
View File
@@ -104,64 +104,31 @@ component StatusBar inherits Rectangle {
Rectangle { horizontal-stretch: 1; } Rectangle { horizontal-stretch: 1; }
// --- instrumentation, and only where there is room for it --------- // --- instrumentation lives in Settings now ------------------------
// //
// The render backend, the layout class and the frame rate are all // The render backend, the layout class and the frame rate are
// developer readouts. They cost about 200 logical pixels of a strip // developer readouts. They cost about 200 logical pixels of a strip
// that also carries the only way out of develop, the undo pair, the // that also carries the only way out of develop, the undo pair, the
// panel toggle and the export button — and a `HorizontalLayout` given // panel toggle and the export button — and a `HorizontalLayout` given
// less width than its children's minimums does not shrink them, it // less width than its children's minimums does not shrink them, it
// runs off the end. // runs off the end.
// //
// A tablet in portrait is 768 logical pixels, so that is exactly what // A tablet in portrait is 768 logical pixels and was already below the
// happened: everything from the panel toggle rightwards was pushed off // breakpoint that hid them, so it kept its header. Landscape is 1200
// the right-hand edge. The develop column is *also* closed by default // and is not, so the readouts came back and the header ran off the
// below the breakpoint, so the toggle being unreachable meant the // right-hand edge there instead — a breakpoint that hides a problem at
// column could not be opened at all — and with it went copy and paste, // one size and not another is a workaround, not a fix.
// which live in it. Reported as "I have no idea how to copy a setting
// on Android and apply it to other images", and the answer was that
// there was no way to.
// //
// Collapsed to zero width rather than wrapped in an `if`: this strip // The backend, the frame rate and the layout class used to live here.
// is inside the layout that `expanded` feeds, and a conditional child // They are diagnostics — read once when something looks wrong, and
// here is the shape that has already caused binding loops in this file // never again — and as permanent furniture in a 44px strip they cost
// (see the panel below). A `visible: false` child still takes its slot // three slots that the controls beside them actually needed. On a
// in a layout, so the width has to go as well. // tablet in landscape the header ran off the right-hand edge, and this
Rectangle { // was a third of the reason.
width: root.expanded ? diagnostics.preferred-width : 0px; //
visible: root.expanded; // They are in Settings under ABOUT now, next to the version, which is
clip: true; // where someone goes when they have a bug to report rather than a
// photograph to edit.
diagnostics := HorizontalLayout {
spacing: Theme.gap;
// The backend is an identifying label, not a state — it says
// which path is in use, and it says the same thing whether
// that path is fast or slow. The accent it used to carry made
// every frame look like an alert; `fps` below is the thing
// here that can go wrong.
Label {
text: root.backend;
vertical-alignment: center;
}
Caption {
text: root.layout-class;
vertical-alignment: center;
}
// Degraded performance, so this is a caution rather than an
// active state: the frame rate has fallen below what the
// compositing path is supposed to sustain, which is exactly
// the assumption A1 exists to test. `warn` is the one hue left
// in the palette and this earns it.
Caption {
text: root.fps + " fps";
warn: root.fps < 55;
vertical-alignment: center;
}
}
}
// Undo and redo, in the strip rather than in the develop column: the // Undo and redo, in the strip rather than in the develop column: the
// panel can be put away, and the one control that takes back a // panel can be put away, and the one control that takes back a
@@ -292,6 +259,10 @@ export component AppWindow inherits Window {
in property <string> adapter: "detecting…"; in property <string> adapter: "detecting…";
in property <string> backend: "—"; in property <string> backend: "—";
in property <int> fps: 0; in property <int> fps: 0;
/// TRACES: FR-UI-2
/// Set from `CARGO_PKG_VERSION`, so the About line cannot disagree with
/// the binary it is part of.
in property <string> app-version: "unknown";
// Current image, for the status strip and empty state. // Current image, for the status strip and empty state.
in property <string> filename: ""; in property <string> filename: "";
@@ -484,6 +455,20 @@ export component AppWindow inherits Window {
/// Whether the grid is narrowed to the timeline's visible span. /// Whether the grid is narrowed to the timeline's visible span.
in-out property <bool> library-range-active: false; in-out property <bool> library-range-active: false;
callback library-toggle-date-range(); 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(); callback library-retry-connection();
// --- pinning a collection offline (FR-NC-6a) --- // --- pinning a collection offline (FR-NC-6a) ---
@@ -781,7 +766,6 @@ export component AppWindow inherits Window {
in property <bool> import-card-looks-right: false; in property <bool> import-card-looks-right: false;
in property <bool> import-surveying: false; in property <bool> import-surveying: false;
in property <string> import-survey-summary: ""; in property <string> import-survey-summary: "";
in property <string> import-destination: "";
in property <string> import-upload-target: ""; in property <string> import-upload-target: "";
in property <string> import-folder-template: ""; in property <string> import-folder-template: "";
in property <string> import-template-preview: ""; in property <string> import-template-preview: "";
@@ -827,6 +811,10 @@ export component AppWindow inherits Window {
callback settings-thumbnail-unlimited-toggled(bool); callback settings-thumbnail-unlimited-toggled(bool);
callback settings-keep-opened-toggled(bool); callback settings-keep-opened-toggled(bool);
callback settings-copy-includes-framing-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 <[string]> settings-format-labels;
in property <int> settings-format-selected: 0; in property <int> settings-format-selected: 0;
@@ -910,6 +898,16 @@ export component AppWindow inherits Window {
/// opaque map hides the thing being judged. /// opaque map hides the thing being judged.
in property <float> overlay-strength: 0.55; in property <float> overlay-strength: 0.55;
in property <bool> overlay-on: false; in property <bool> overlay-on: false;
/// TRACES: FR-DEV-3
/// The photographer's switch for the same overlay, kept separate from
/// `overlay-on` because the two answer different questions: `overlay-on`
/// is "is there a mask to draw", which Rust decides, and this is "do I
/// want to look at it right now", which only they can.
///
/// Folding them into one property would mean the next recompute — any
/// mask edit — silently switched the overlay back on under someone who had
/// just turned it off to check their work against the photograph.
in-out property <bool> overlay-hidden: false;
/// Which part of the source-space overlay the view is showing, in overlay /// Which part of the source-space overlay the view is showing, in overlay
/// pixels. Without it the overlay stays frame-sized while the photograph /// pixels. Without it the overlay stays frame-sized while the photograph
/// moves under it. /// moves under it.
@@ -939,6 +937,7 @@ export component AppWindow inherits Window {
in property <[SubjectRow]> subject-rows; in property <[SubjectRow]> subject-rows;
in property <bool> segmented: false; in property <bool> segmented: false;
in property <bool> segmenting: false; in property <bool> segmenting: false;
in property <bool> refining: false;
/// The selected layer's name, already resolved, or "ADJUST" when the /// The selected layer's name, already resolved, or "ADJUST" when the
/// controls are global. Names the adjust panel's heading. /// controls are global. Names the adjust panel's heading.
in property <string> adjust-scope: "ADJUST"; in property <string> adjust-scope: "ADJUST";
@@ -958,12 +957,13 @@ export component AppWindow inherits Window {
/// rather than as one per frame of the gesture. /// rather than as one per frame of the gesture.
callback gradient-handle-released(); callback gradient-handle-released();
callback segment-image(); callback segment-image(bool);
/// A click on the photograph, in fractions of the shown image, plus /// A click on the photograph, in fractions of the shown image, plus
/// whether it should extend the selection rather than replace it. /// whether it should extend the selection rather than replace it.
callback region-picked(float, float, bool); callback region-picked(float, float, bool);
callback mask-selected(string); callback mask-selected(string, bool);
callback mask-removed(string); callback mask-removed(string);
callback mask-refined(string);
callback mask-toggled(string, bool); callback mask-toggled(string, bool);
callback mask-invert-toggled(string, bool); callback mask-invert-toggled(string, bool);
callback mask-opacity-changed(string, float); callback mask-opacity-changed(string, float);
@@ -1099,7 +1099,6 @@ in property <bool> panel-visible: true;
card-looks-right: root.import-card-looks-right; card-looks-right: root.import-card-looks-right;
surveying: root.import-surveying; surveying: root.import-surveying;
survey-summary: root.import-survey-summary; survey-summary: root.import-survey-summary;
destination: root.import-destination;
upload-target: root.import-upload-target; upload-target: root.import-upload-target;
folder-template: root.import-folder-template; folder-template: root.import-folder-template;
template-preview: root.import-template-preview; template-preview: root.import-template-preview;
@@ -1135,12 +1134,21 @@ in property <bool> panel-visible: true;
width: 100%; width: 100%;
height: 100%; height: 100%;
adapter: root.adapter;
backend: root.backend;
fps: root.fps;
layout-class: root.layout-class;
app-version: root.app-version;
original-budget: root.settings-original-budget; original-budget: root.settings-original-budget;
original-unlimited: root.settings-original-unlimited; original-unlimited: root.settings-original-unlimited;
thumbnail-budget: root.settings-thumbnail-budget; thumbnail-budget: root.settings-thumbnail-budget;
thumbnail-unlimited: root.settings-thumbnail-unlimited; thumbnail-unlimited: root.settings-thumbnail-unlimited;
keep-opened: root.settings-keep-opened; keep-opened: root.settings-keep-opened;
copy-includes-framing: root.settings-copy-includes-framing; 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; cache-usage: root.settings-cache-usage;
original-budget-changed(t) => { root.settings-original-budget-changed(t); } original-budget-changed(t) => { root.settings-original-budget-changed(t); }
@@ -1329,6 +1337,17 @@ in property <bool> panel-visible: true;
toggle-local-only() => { root.library-toggle-local-only(); } toggle-local-only() => { root.library-toggle-local-only(); }
range-active: root.library-range-active; range-active: root.library-range-active;
toggle-date-range() => { root.library-toggle-date-range(); } 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; collections-visible: root.collections-visible;
toggle-collections() => { root.toggle-collections(); } toggle-collections() => { root.toggle-collections(); }
@@ -1521,7 +1540,7 @@ in property <bool> panel-visible: true;
// it is a diagnostic and not an edit: it must not reach // it is a diagnostic and not an edit: it must not reach
// the histogram, an export, or the texture the develop // the histogram, an export, or the texture the develop
// pass hands the compositor. // pass hands the compositor.
if root.overlay-on && root.total > 0: Image { if root.overlay-on && !root.overlay-hidden && root.total > 0: Image {
x: parent.shown-x; x: parent.shown-x;
y: parent.shown-y; y: parent.shown-y;
width: parent.shown-w; width: parent.shown-w;
@@ -2108,7 +2127,24 @@ in property <bool> panel-visible: true;
// width, which the layout then influences. Slint flags it, and // width, which the layout then influences. Slint flags it, and
// it can panic at runtime. // it can panic at runtime.
Rectangle { Rectangle {
width: root.panel-visible ? 280px : 0px; // TRACES: FR-UI-2
// 280px was chosen for a tablet, where the column is a
// large fraction of the screen and every pixel of it is
// taken from the photograph. On a desktop window it is
// simply too narrow: the mode strip alone is two modes,
// a separator, "All", and one chip per attribute the
// operation set declares, which is more words than 280px
// holds — so it scrolled sideways, and a control you have
// to pan to reach is one you do not know is there.
//
// Driven off `layout-class` rather than `root.width`
// because reading the window width inside the layout that
// sets it is the binding loop the comment on `expanded`
// above describes. Rust already classifies the window; this
// just spends the extra width where there is some.
width: root.panel-visible
? (root.layout-class == "expanded" ? 380px : 280px)
: 0px;
visible: root.panel-visible; visible: root.panel-visible;
background: Theme.surface; background: Theme.surface;
clip: true; clip: true;
@@ -2257,10 +2293,16 @@ in property <bool> panel-visible: true;
subjects: root.subject-rows; subjects: root.subject-rows;
segmented: root.segmented; segmented: root.segmented;
segmenting: root.segmenting; segmenting: root.segmenting;
refining: root.refining;
// Two-way: the panel is where it is toggled and
// the canvas is what obeys it, so neither owns
// the value alone.
overlay-hidden <=> root.overlay-hidden;
segment => { root.segment-image(); } segment(fine) => { root.segment-image(fine); }
mask-selected(id) => { root.mask-selected(id); } mask-selected(id, extend) => { root.mask-selected(id, extend); }
mask-removed(id) => { root.mask-removed(id); } mask-removed(id) => { root.mask-removed(id); }
mask-refined(id) => { root.mask-refined(id); }
mask-toggled(id, on) => { root.mask-toggled(id, on); } mask-toggled(id, on) => { root.mask-toggled(id, on); }
mask-invert-toggled(id, on) => { mask-invert-toggled(id, on) => {
root.mask-invert-toggled(id, on); root.mask-invert-toggled(id, on);
+18 -21
View File
@@ -85,11 +85,11 @@ export component ImportPage inherits Rectangle {
in property <string> survey-summary; in property <string> survey-summary;
// --- where they are going -------------------------------------------- // --- where they are going --------------------------------------------
/// The library root. Shown, not editable — see the header comment. /// The library folder on the server — the only destination there is.
in property <string> destination; ///
/// The folder on the server the originals go on to, once they are safely /// Empty means no account is signed in, and Import stays disabled: the
/// on disk (FR-NC-7b). Empty when no account is signed in, which hides the /// library lives on the server, so an import without one has nowhere to
/// line rather than showing an empty one. /// go at all.
in property <string> upload-target; in property <string> upload-target;
in-out property <string> folder-template; in-out property <string> folder-template;
/// The template expanded against a real date, so the answer to "what will /// The template expanded against a real date, so the answer to "what will
@@ -272,27 +272,24 @@ export component ImportPage inherits Rectangle {
spacing: Theme.gap; spacing: Theme.gap;
Label { text: "Library"; } Label { text: "Library"; }
Value { Value {
text: root.destination; text: root.upload-target != "" ? root.upload-target
: "not signed in";
overflow: elide; overflow: elide;
horizontal-stretch: 1; horizontal-stretch: 1;
} }
} }
if root.upload-target != "": HorizontalLayout { // The order is the promise, and a user watching a slow
spacing: Theme.gap; // upload needs to already know the photographs are safe.
Label { text: "Server"; } // Said here, beside the destination, rather than beside the
Value { // Import button where it would read as a warning.
text: root.upload-target;
overflow: elide;
horizontal-stretch: 1;
}
}
// Said once, here, rather than beside the Import button:
// the order is the promise, and a user watching a slow
// upload needs to already know the photographs are on disk.
if root.upload-target != "": Caption { if root.upload-target != "": Caption {
text: "Copied here and verified first, then uploaded."; text: "Copied to this computer and verified first, then uploaded. Anything that does not go up stays queued for the next import.";
wrap: word-wrap;
}
if root.upload-target == "": Caption {
text: "Sign in first — the library this imports into is on the server.";
wrap: word-wrap; wrap: word-wrap;
} }
@@ -333,7 +330,7 @@ export component ImportPage inherits Rectangle {
// The one operation in this application with no undo, // The one operation in this application with no undo,
// and the only honest place to say so is beside the // and the only honest place to say so is beside the
// control that arms it (FR-NC-7b). // control that arms it (FR-NC-7b).
text: "The card is emptied only after every file is verified in the library — and, on a library that syncs, after it has reached the server."; text: "The card is emptied only of photographs the server has confirmed. Anything that does not upload keeps its card copy — which is then the only copy there is.";
wrap: word-wrap; wrap: word-wrap;
} }
+378 -10
View File
@@ -68,6 +68,7 @@ export struct TimelineBar {
// - **click or drag** — scrub the grid to that instant // - **click or drag** — scrub the grid to that instant
// - **drag with the middle button, or shift-drag** — pan the visible span // - **drag with the middle button, or shift-drag** — pan the visible span
// - **wheel** — zoom, which changes the bucket size Rust picks // - **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 // # 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. /// them together. Continuous, unlike the wheel's discrete steps.
callback pinch(float); 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; width: 96px;
background: Theme.surface; 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> track-height: max(1px, self.height - self.track-top - 6px);
property <length> slot: root.track-height / max(1, root.bars.length); 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 /// 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 /// leaves the widget still scrubs to the nearest bucket rather than
/// stopping dead. /// 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); 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. // Header: the hovered bucket, else the whole span.
Caption { Caption {
x: 8px; x: 8px;
@@ -183,10 +258,68 @@ export component Timeline inherits Rectangle {
background: Theme.active; background: Theme.active;
opacity: root.anchored ? 1.0 : 0.35; opacity: root.anchored ? 1.0 : 0.35;
y: root.anchored && root.current-fraction >= 0 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; : 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. // The single hit area. Everything above is inert.
// Two-finger pinch, for tablet. There is no wheel there, so without this // 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. // 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; property <bool> panning;
moved => { 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 // Fractional, so a slow drag moves the view continuously
// rather than sitting still until it crosses a bucket edge. // rather than sitting still until it crosses a bucket edge.
root.pan((self.press-y - self.mouse-y) / max(1px, root.track-height)); 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 // modifier state belongs to the key handler, not the pointer
// event, and a middle-drag is the unambiguous gesture. // event, and a middle-drag is the unambiguous gesture.
self.panning = e.button == PointerEventButton.middle; 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)); 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) => { scroll-event(e) => {
@@ -1035,6 +1192,12 @@ export component LibraryGrid inherits Rectangle {
/// as the ctrl flag rather than as a third selection policy. /// as the ctrl flag rather than as a third selection policy.
in property <bool> select-mode: false; in property <bool> select-mode: false;
callback toggle-select-mode(); 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 /// 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. /// drag starts, so it always reflects the selection as it is at that moment.
pure callback drag-payload() -> data-transfer; pure callback drag-payload() -> data-transfer;
@@ -1098,6 +1261,29 @@ export component LibraryGrid inherits Rectangle {
/// Whether the grid is narrowed to the timeline's visible span. /// Whether the grid is narrowed to the timeline's visible span.
in property <bool> range-active: false; in property <bool> range-active: false;
callback toggle-date-range(); 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
/// what they read back. Rust parses them and refuses what is not a date;
/// `range-invalid` is how that refusal reaches the field, since a filter
/// that silently ignored a typo would show an empty grid and no reason.
in property <string> range-from;
in property <string> range-to;
in property <bool> range-invalid: false;
/// Both ends at once: they are one range, and applying half of an edit
/// would filter to a span the user never asked for.
callback range-edited(string, string);
/// How many images are selected, for the header's count. /// How many images are selected, for the header's count.
in property <int> selected-count: 0; in property <int> selected-count: 0;
@@ -1349,11 +1535,25 @@ export component LibraryGrid inherits Rectangle {
// thought it could draw. Seven action buttons and four readouts do // thought it could draw. Seven action buttons and four readouts do
// not fit across a tablet even in landscape, which is why the last // not fit across a tablet even in landscape, which is why the last
// one was clipped by the screen edge. // one was clipped by the screen edge.
//
// Sized to `min-width`, not `preferred-width`: the latter is the
// row's *untruncated* footprint — every readout at its natural
// text width — so the Flickable was always at least that wide and
// the row never actually shrank, it just scrolled, with the
// sidebar toggle and the "More" disclosure off past the right
// edge on anything narrower than a tablet. `min-width` is what
// the row needs once its `elide` + `horizontal-stretch: 1`
// readouts (image count, selection count, scan status, the
// window label below) are collapsed to an ellipsis, so a window
// with room for that shrunk row now gets the row shrunk to fit
// instead of scrolled — and the toggle and disclosure, which
// don't shrink, stay on screen. Only a window too narrow even for
// the shrunk row still scrolls.
Flickable { Flickable {
width: 100%; width: 100%;
height: 100%; height: 100%;
viewport-height: self.height; viewport-height: self.height;
viewport-width: max(self.width, header-row.preferred-width); viewport-width: max(self.width, header-row.min-width);
header-row := HorizontalLayout { header-row := HorizontalLayout {
width: parent.viewport-width; width: parent.viewport-width;
@@ -1381,7 +1581,18 @@ export component LibraryGrid inherits Rectangle {
overflow: elide; overflow: elide;
} }
Caption { text: root.total > 0 ? root.total + " images" : ""; } // `elide` + `horizontal-stretch: 1`, like `window-label` below:
// stretch is what actually lets a `Text` shrink under pressure
// in Slint, not `elide` alone (see the comment on the header
// row's Flickable) — without it these three readouts kept
// their full natural width and were what pushed the sidebar
// toggle and the "More" button past the right edge on a
// narrow window, reachable only by knowing to flick-scroll.
Caption {
text: root.total > 0 ? root.total + " images" : "";
overflow: elide;
horizontal-stretch: 1;
}
// The selection count, and how to act on it. Both appear only // The selection count, and how to act on it. Both appear only
// when something is selected — an empty selection has nothing // when something is selected — an empty selection has nothing
@@ -1395,6 +1606,8 @@ export component LibraryGrid inherits Rectangle {
modified: true; modified: true;
compact: true; compact: true;
font-weight: 600; font-weight: 600;
overflow: elide;
horizontal-stretch: 1;
} }
Label { Label {
@@ -1410,6 +1623,8 @@ export component LibraryGrid inherits Rectangle {
: (root.sweeping : (root.sweeping
? "indexing " + root.sweep-done + " / " + root.sweep-total ? "indexing " + root.sweep-done + " / " + root.sweep-total
: root.scan-status); : root.scan-status);
overflow: elide;
horizontal-stretch: 1;
} }
// Where in the library the visible window sits. A scrubbable // Where in the library the visible window sits. A scrubbable
@@ -1688,10 +1903,12 @@ export component LibraryGrid inherits Rectangle {
Rectangle { width: Theme.gap; } Rectangle { width: Theme.gap; }
// Narrow the grid to the span the timeline is showing. The // Turn the range on, over the span the timeline is showing.
// histogram is already how you find a period, so the range is // The histogram is already how you find a period, so this is
// taken from it rather than typed: zoom to the fortnight you // a starting point rather than the answer: the band it puts on
// want, then say "only that". Clicking again lifts it. // 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 // The histogram deliberately keeps drawing the full extent
// while this is on, or there would be nothing left to widen // while this is on, or there would be nothing left to widen
@@ -1725,6 +1942,132 @@ 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.
//
// Twice now this control has looked broken. First it narrowed to the
// whole library, because it took its span from a timeline zoom that
// is zero until someone zooms. Then the fields that fixed that went
// 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
// header's `VerticalLayout`, so it gets a line of its own and the
// width of the window.
if root.range-active: Rectangle {
height: 40px;
background: Theme.surface;
HorizontalLayout {
width: 100%;
height: 100%;
// The same leading inset the chips above use, so the two rows
// start on one vertical line rather than a few pixels apart.
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap-sm;
// Fixed-width fields and a two-letter word: left-aligned, so
// they sit under the chip that turned them on instead of
// spreading across the window.
alignment: start;
Field {
width: 108px;
y: (parent.height - self.height) / 2;
text: root.range-from;
placeholder: "YYYY-MM-DD";
accepted(t) => { root.range-edited(t, root.range-to); }
}
Caption {
text: "to";
vertical-alignment: center;
}
Field {
width: 108px;
y: (parent.height - self.height) / 2;
text: root.range-to;
placeholder: "YYYY-MM-DD";
accepted(t) => { root.range-edited(root.range-from, t); }
}
if root.range-invalid: Caption {
text: "not a date";
warn: true;
vertical-alignment: center;
}
}
}
// --- progress ----------------------------------------------------- // --- progress -----------------------------------------------------
// //
// The bar that used to sit here is now the shell's, drawn across the // The bar that used to sit here is now the shell's, drawn across the
@@ -1855,7 +2198,10 @@ export component LibraryGrid inherits Rectangle {
current-start: root.current-bucket; current-start: root.current-bucket;
current-fraction: root.current-bucket-fraction; current-fraction: root.current-bucket-fraction;
anchored: root.timeline-anchored; 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); } scrub-to(f) => { root.scrub-fraction(f); }
pinch(r) => { root.timeline-pinch(r); } pinch(r) => { root.timeline-pinch(r); }
pan(d) => { root.timeline-pan(d); } pan(d) => { root.timeline-pan(d); }
@@ -2147,6 +2493,28 @@ export component LibraryGrid inherits Rectangle {
// window is swapped underneath to match. // window is swapped underneath to match.
viewport-height: root.total-rows * (root.cell-size + Theme.gap) + Theme.gap; 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 // Report the first fully-scrolled-past row so Rust can move the
// window. Derived rather than eventful: Slint has no scroll // window. Derived rather than eventful: Slint has no scroll
// callback, and a `changed` handler on a derived integer fires only // callback, and a `changed` handler on a derived integer fires only
+78 -6
View File
@@ -73,8 +73,13 @@ export struct SubjectRow {
component MaskEntry inherits Rectangle { component MaskEntry inherits Rectangle {
in property <MaskRow> data; in property <MaskRow> data;
in property <bool> enabled: true; in property <bool> enabled: true;
/// Whether a refine pass is running (for any row — only one runs at a
/// time), so this row's button can say so and refuse a second press.
in property <bool> refining: false;
callback selected(); /// `true` when the click should add this row to the selection rather
/// than replace it with just this row.
callback selected(bool);
callback removed(); callback removed();
callback toggled(bool); callback toggled(bool);
callback invert-toggled(bool); callback invert-toggled(bool);
@@ -83,6 +88,7 @@ component MaskEntry inherits Rectangle {
callback falloff-picked(int); callback falloff-picked(int);
callback morphology-picked(int); callback morphology-picked(int);
callback morph-radius-changed(float); callback morph-radius-changed(float);
callback refined();
height: layout.preferred-height; height: layout.preferred-height;
background: root.data.selected ? Theme.surface-raised : transparent; background: root.data.selected ? Theme.surface-raised : transparent;
@@ -91,7 +97,15 @@ component MaskEntry inherits Rectangle {
touch := TouchArea { touch := TouchArea {
mouse-cursor: pointer; mouse-cursor: pointer;
enabled: root.enabled; enabled: root.enabled;
clicked => { root.selected(); } // `clicked` carries no modifiers, so a plain click and a
// multi-select click need the pointer event itself: released, over
// the row, with control or command held is "add to the selection"
// rather than "replace it".
pointer-event(event) => {
if event.kind == PointerEventKind.up && event.button == PointerEventButton.left {
root.selected(event.modifiers.control || event.modifiers.meta);
}
}
} }
layout := VerticalLayout { layout := VerticalLayout {
@@ -190,6 +204,15 @@ component MaskEntry inherits Rectangle {
} }
} }
// Only a subject has a detection behind it worth re-running. A
// gradient's edge is geometry the photographer placed, and there
// is nothing for the model to find twice.
if root.data.kind == "subject": Button {
text: root.refining ? "Refining…" : "Refine mask";
enabled: !root.refining;
clicked => { root.refined(); }
}
// The edge. All of it reads one signed distance field, so these // The edge. All of it reads one signed distance field, so these
// are free to move — none of them recomputes anything except a // are free to move — none of them recomputes anything except a
// compound morphology. // compound morphology.
@@ -242,12 +265,23 @@ export component MaskPanel inherits Rectangle {
in property <bool> segmented: false; in property <bool> segmented: false;
/// One is being computed now. /// One is being computed now.
in property <bool> segmenting: false; in property <bool> segmenting: false;
/// A refine pass is running for one subject layer.
in property <bool> refining: false;
/// Whether the photographer has hidden the overlay to see the photograph.
/// Two-way bound to the window's own, so the canvas and this control never
/// disagree about it.
in-out property <bool> overlay-hidden: false;
callback segment(); /// `true` asks for the slower, tiled pass.
callback segment(bool);
callback mask-selected(string); /// `true` when the click should add the layer to the selection rather
/// than replace it.
callback mask-selected(string, bool);
callback mask-removed(string); callback mask-removed(string);
callback mask-refined(string);
callback mask-toggled(string, bool); callback mask-toggled(string, bool);
callback mask-invert-toggled(string, bool); callback mask-invert-toggled(string, bool);
callback mask-opacity-changed(string, float); callback mask-opacity-changed(string, float);
@@ -299,7 +333,7 @@ export component MaskPanel inherits Rectangle {
text: root.segmenting ? "Looking…" : "Find subjects"; text: root.segmenting ? "Looking…" : "Find subjects";
enabled: !root.segmenting; enabled: !root.segmenting;
primary: true; primary: true;
clicked => { root.segment(); } clicked => { root.segment(false); }
} }
// No "Overlay" button and no "Select" button. Both switched on things // No "Overlay" button and no "Select" button. Both switched on things
@@ -312,6 +346,42 @@ export component MaskPanel inherits Rectangle {
wrap: word-wrap; wrap: word-wrap;
} }
// Hiding the overlay is not the "Overlay" button this panel used to
// have, and the distinction is the reason it is back. That one *armed*
// the overlay: local mode could be entered and still show nothing,
// which is the fault the comment below records. This one only takes an
// overlay that is already there out of the way for a moment, which is
// the one thing a photographer needs constantly and had no way to do —
// a mask is judged against the photograph under it, and you cannot see
// that photograph through the thing describing it.
//
// Reads as its own action rather than its state: "Show the mask" is
// what pressing it will do, not what is currently true.
if root.enabled && root.segmented: Button {
text: root.overlay-hidden ? "Show the mask" : "Hide the mask";
clicked => { root.overlay-hidden = !root.overlay-hidden; }
}
// The second pass, offered rather than taken automatically.
//
// The model's input is a fixed 640x640 square and every frame is
// letterboxed into it, so a subject that is small in the frame reaches
// the model small — a bird at 200px in a 1600px proxy arrives at 80px,
// and an outline traced at 80px is what a coarse mask is. Tiling runs
// the model over overlapping windows instead, so that bird arrives at
// its own size.
//
// It costs one inference per tile: about 2.8s for a 3x2 grid against
// 470ms for the whole frame. That is the wrong trade for the usual
// photograph, where the subject fills much of the frame and the first
// pass is already the best answer available — so it is a button and
// not a default, and it says what it costs.
if root.enabled && root.segmented: Button {
text: root.segmenting ? "Looking closer…" : "Look closer (slower)";
enabled: !root.segmenting;
clicked => { root.segment(true); }
}
// Granularity, labelled by what it does rather than by its number: // Granularity, labelled by what it does rather than by its number:
// "detail" is what a photographer is choosing between, where "300 // "detail" is what a photographer is choosing between, where "300
// regions" is an implementation detail they would have to learn. // regions" is an implementation detail they would have to learn.
@@ -386,8 +456,10 @@ export component MaskPanel inherits Rectangle {
for mask in root.masks: MaskEntry { for mask in root.masks: MaskEntry {
data: mask; data: mask;
enabled: root.enabled; enabled: root.enabled;
selected => { root.mask-selected(mask.id); } refining: root.refining;
selected(extend) => { root.mask-selected(mask.id, extend); }
removed => { root.mask-removed(mask.id); } removed => { root.mask-removed(mask.id); }
refined => { root.mask-refined(mask.id); }
toggled(on) => { root.mask-toggled(mask.id, on); } toggled(on) => { root.mask-toggled(mask.id, on); }
invert-toggled(on) => { root.mask-invert-toggled(mask.id, on); } invert-toggled(on) => { root.mask-invert-toggled(mask.id, on); }
opacity-changed(v) => { root.mask-opacity-changed(mask.id, v); } opacity-changed(v) => { root.mask-opacity-changed(mask.id, v); }
+111
View File
@@ -90,6 +90,20 @@ export component SettingsPage inherits Rectangle {
/// What the cache currently holds. Empty hides the line. /// What the cache currently holds. Empty hides the line.
in property <string> cache-usage; in property <string> cache-usage;
/// TRACES: FR-UI-2
/// What is drawing, and how fast. These used to sit in the window's top
/// strip, where they were three items of permanent furniture answering a
/// question almost nobody asks twice — and on a tablet in landscape they
/// were part of why the header ran off the screen.
///
/// They are diagnostics, so they belong where a person goes to look
/// something up, not where they are looked at all day.
in property <string> adapter;
in property <string> backend;
in property <int> fps;
in property <string> layout-class;
in property <string> app-version;
callback original-budget-changed(string); callback original-budget-changed(string);
callback original-unlimited-toggled(bool); callback original-unlimited-toggled(bool);
callback thumbnail-budget-changed(string); callback thumbnail-budget-changed(string);
@@ -97,6 +111,16 @@ export component SettingsPage inherits Rectangle {
callback keep-opened-toggled(bool); callback keep-opened-toggled(bool);
callback copy-includes-framing-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 -------------------------------------------------------- // --- export --------------------------------------------------------
in property <[string]> format-labels; in property <[string]> format-labels;
in property <int> format-selected: 0; in property <int> format-selected: 0;
@@ -458,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 ---------------------------------------------- // --- export ----------------------------------------------
Rectangle { Rectangle {
width: content.column; width: content.column;
@@ -551,6 +603,65 @@ export component SettingsPage inherits Rectangle {
} }
} }
// --- about and diagnostics -------------------------------
Rectangle {
width: content.column;
height: about-panel.preferred-height;
about-panel := Panel {
width: 100%;
spacing: Theme.gap;
PanelHeading { text: "ABOUT"; }
// The version first, because it is the one line anyone
// is ever asked to quote. A bug report that names a
// version names a commit.
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Version"; }
Value { text: root.app-version; horizontal-stretch: 1; overflow: elide; }
}
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Graphics"; }
Value {
text: root.adapter + " (" + root.backend + ")";
horizontal-stretch: 1;
overflow: elide;
}
}
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Frame rate"; }
// Still earns the warning hue here. It is the
// number that says whether the zero-copy path is
// holding up, which is the assumption the whole
// display design rests on.
// `Caption` rather than `Value` here alone: it is
// the one that carries `warn`, and the frame rate
// is the one reading on this page that can be bad
// news rather than merely a fact.
Caption { text: root.fps + " fps"; warn: root.fps < 55; horizontal-stretch: 1; }
}
HorizontalLayout {
spacing: Theme.gap;
Label { text: "Layout"; }
Value { text: root.layout-class; horizontal-stretch: 1; }
}
Caption {
text: "Graphics and frame rate describe this session, "
+ "not a setting — they are here so a bug report "
+ "can quote them.";
wrap: word-wrap;
}
}
}
// --- naming and destination ------------------------------ // --- naming and destination ------------------------------
// //
// Its own panel rather than more of the export one: format and // Its own panel rather than more of the export one: format and