Commit Graph
88 Commits
Author SHA1 Message Date
dtourolle 715fcf8512 Regenerate the traceability matrix after the rebase 2026-09-26 14:03:27 -04:00
dtourolle e82190fdf2 Import Lightroom presets as looks
A Lightroom preset changes the settings it was saved with and leaves every
other one where the photograph had it. Imported as a whole edit, a preset
holding only a grade reset the exposure, white balance and noise reduction
it was put on top of — the opposite of what the photographer had in
Lightroom.

Imported presets now reach only the operations they name
(`Reach::Named`), the rule the shipped presets already follow.
2026-09-26 13:44:32 -04:00
dtourolle a7b090cf36 Ship presets with the application instead of seeding them
The six starter presets were copied into the photographer's own library
on a first run and were theirs from then on. That cannot grow into a
real collection: a copy is frozen at the release that wrote it, so an
improved preset reaches nobody who had the old one, and re-seeding would
overwrite a preset someone had tuned.

`dr_pipeline::bundled` now holds the shipped presets as `.drpl` files
compiled into the binary, in sections — Essentials (the former six) and
three sections of film presets, one per measured stock in dr-film,
printed on the paper its profile names — and never writes them to the
user's file. Every shipped preset is a look (`Reach::Named`), so applying
one keeps the corrections a photograph already has.

A name links a photographer's copy to a shipped preset. Saving over a
shipped name makes their version the one that name applies; it is listed
in the shipped section, marked as changed, and deleting it reverts to the
shipped one. Renaming it makes it one of their own and the shipped preset
reappears. Keyed on the name because that is what the photographer sees
and chooses by.

Copies an older first run seeded are forgotten on load where they are
still exactly as seeded — otherwise all six would list as changed and
stay frozen at their old values. A tuned one is kept and now overrides.

The sheet lists "Yours" first, then each shipped section, with headings.
Shipped rows apply and nothing else; a changed row offers Revert where
the photographer's own offer Delete. A dr-ui test checks every shipped
film names a stock this build can bake, on that stock's own paper,
because dr-pipeline does not link the profile database.

The film presets name stocks by id; the measurements behind them are
spektrafilm's (CC BY-SA 4.0), attributed in each file as in dr-film.
2026-09-26 13:44:32 -04:00
dtourolle 90c0695c05 Let a preset name its film, and let a look reach only what it names
A preset could not choose a film stock. The stock is a choice of material
rather than a parameter, so `Preset` — a map of `op.param = value` — had
nowhere to hold it, and "Portra 400, printed" could not be saved, copied
or shipped as a look. Worse, the film node's own sliders *were*
parameters: a paste moved one stock's exposure and push onto whatever
stock the target was on, and left the target's tables baked from the
values it had just replaced.

A preset now carries a `FilmRef` beside its parameters. It travels under
whichever scope carries the film node, so the stock and its sliders are
never split, and by the replacement rule every other parameter follows:
applied at that scope, a preset without a film develops the target
without one. `Preset::apply` returns the `FilmRebake` it owes, as
`EditGraph::set_state` already did, because this crate cannot bake a
stock; the develop session pays it before recording the step, and the
batch paste writes the stock into each sidecar through `film_for`. The
library file spells it `film =` / `film_print =`, as a sidecar does, and
an older build keeps those lines as ones it does not understand.

`EditState` keeps the film in its own field only: the parameters it
captures leave it out, so one edit has one place to say which stock it
is on.

Second, a preset now has a reach. Replacement is right for a copy of a
whole edit — "make these match" — and wrong for a look: a stock-only
"Portra 400" applied that way would put the photograph's exposure, white
balance and noise reduction back to default. `Reach::Named` replaces only
the operations a preset names (whole operations, so a look that sets the
blacks resets the whites beside them) and the film only if it names one.
Saved edits and the clipboard keep `Reach::Whole`; the line `reach =
named` is written only for the other, so existing libraries write the
same bytes.
2026-09-26 13:44:32 -04:00
dtourolle c6d4c1ba62 Regenerate the traceability matrix after the rebase 2026-09-26 13:29:34 -04:00
dtourolle 73059f2656 Count the label chips from the labelled versions, not from every image
`label_histogram` runs on every label keystroke and after every batch of
judgements is saved. On the reference library it cost 7.2-8.4 ms best-of-50
by catalog_bench (13 ms on a busy machine), to report that none of 23,500
images carried a label.

The join was the rating histogram's, with one thing worse: the index does
not carry `label`, so each probe went on to read the version's row.

  SCAN i USING COVERING INDEX images_folder
  SEARCH v USING INDEX versions_judgement (image_id=?) LEFT-JOIN
  USE TEMP B-TREE FOR GROUP BY

It now takes the rating histogram's shape: only labelled default versions
are grouped, and the unlabelled slot is what is left of `judged_rows`.

  SCAN versions USING INDEX versions_judgement
  USE TEMP B-TREE FOR GROUP BY          (the labelled rows only)

That pass still reads each default version's row for `label`, but in the
index's order, which follows the table's; a partial index on the labelled
rows would make it index-only, and was not worth a new index for the
remaining 1 ms. After: 1.7-2.0 ms, the same answer on the reference library,
and a test that compares it with the old join over the awkward states the
rating test uses (a second default's label counted, unknown codes and zero
folded into unlabelled).
2026-09-26 13:28:50 -04:00
dtourolle 81118728f4 Count the rating chips from the rated versions, not from every image
`rating_histogram` runs on every star keystroke. On the reference library
(24k images, 1,200 of them rated) it cost 6.3 ms best-of-50 by
catalog_bench, and up to 10-14 ms when the machine is busy.

It was `images LEFT JOIN versions ON ... AND is_default = 1 GROUP BY
rating`. The plan:

  SCAN i USING COVERING INDEX images_folder
  SEARCH v USING COVERING INDEX versions_judgement (image_id=?) LEFT-JOIN
  USE TEMP B-TREE FOR GROUP BY

A probe of the index per image, then a sort of all 23,500 rows, to put
22,000 of them in slot zero.

Now the rated rows are grouped on their own (`rating != 0`: one pass over
`versions_judgement`, a sort of 1,200 rows), and slot zero is what is left
of the join's row count. That count is three index-only aggregates -- the
library size, the default versions, and the images holding one -- so an
image with no version is still unrated, and an image with two default
versions still counts twice, exactly as the join counted it:

  SCAN versions USING COVERING INDEX versions_judgement      (x3)
  SCAN images USING COVERING INDEX images_folder

`count(DISTINCT image_id)` has its own statement because alone it reads the
distinct values off the index order; beside other aggregates SQLite builds a
temporary b-tree for it.

After: 1.2 ms. The histogram is the same on the reference library
([22364, 663, 19, 47, 115, 374]), and a new test compares it with the old
join on a catalog holding every state the schema allows: no version, only a
virtual copy, two defaults, ratings below zero and above five.
2026-09-26 13:28:50 -04:00
dtourolle fabc1c5b56 Regenerate the traceability matrix after the rebase 2026-09-26 13:22:08 -04:00
dtourolle 5fcd3752d7 Stop the local walk queueing work nothing claims
walk::scan_root enqueued an ExtractMetadata and a Thumbnail job for every
image it inserted or found changed. No handler claims either kind. The
walk is only reachable from the scan_local example today, so no real
catalog holds these rows, but it is the same leftover the remote scan
carried (#73) and it is what a local library would inherit.

Both debts are already recorded where their consumers look: an inserted
or changed image is written at metadata_state 1, which is the metadata
sweep's work list, and the thumbnail store answers for itself.

The tests that used job rows as the measure of "this image owes work"
now read metadata_state, which is the record the sweep actually uses;
the no-requeue test marks the first image read before the second scan,
so it still proves an unchanged neighbour is not put back in debt.

Refs #73
2026-09-26 13:15:17 -04:00
dtourolle 5da28584a4 Stop the scan queueing a thumbnail job per photograph
The reference catalog held 23,582 Thumbnail jobs, one per image, and
every scan re-coalesced all of them. Nothing has ever claimed that kind:
no JobHandler is registered for it on desktop or Android, and
dr_catalog::sync never merges another device's jobs in.

Thumbnails are owed by the store, not the queue. The grid's worker and
the thumbnail sweep both find their work by asking ThumbStore what it
lacks, and the store is shared between devices, so it is the only record
that knows another device already made one. A queue row was a second,
staler copy of that debt that grew with the library and was read by
nothing.

persist still writes the images and their remote identities in the one
transaction; it just no longer adds a row to jobs for each of them. The
two tests that asserted the rows existed become one that asserts a
repeated scan queues nothing.

Refs #73
2026-09-26 13:09:44 -04:00
dtourolle 4bec01eaf1 Say a photograph is downloading, and how far, instead of failing
The develop view reported a remote original on its way through the
error message, so it read "Could not load image" over "Downloading…".
It did so on every step along the roll, including a cached frame that
was ready within a tick, so each step flashed the error.

Waiting is now its own state. On the step, the grid's thumbnail of the
photograph stands in at once. Only when a transfer is really on the
wire does it dim under "Not on this device yet", with a line like
"Downloading — 12.4 of 38.0 MB" and a progress bar.

The bytes come from a new RemoteBackend::get_reporting. The Nextcloud
backend overrides it to read the body chunk by chunk; the default
reports once at the end. Progress is kept in the in-flight registry by
path, because a step usually lands on a frame the prefetcher is already
fetching. The catalog's file length stands in when the server sends no
Content-Length.
2026-09-26 11:02:11 -04:00
dtourolle 3b97195b37 Keep a stepped-past download from replacing the open photograph
Opening a photograph from the library starts a download and a timer that
polls for it. Every step along the roll started another, and each one put
its result on screen when it landed, so a frame stepped past earlier
could arrive last and replace the one whose name was showing. Each open
now takes a generation number; a download that lands for an older
generation is recorded in the activity list (its bytes are cached) and
goes no further.

The outgoing session also stayed live until the new download landed.
Its sliders kept working, and a second step before the first landed
saved that session's edit under the new photograph's identity. The
session is now dropped as soon as its edit is saved.
2026-09-26 11:00:36 -04:00
dtourolle ea44aba110 Put a scrollbar on the library grid
The timeline beside the grid says *when* the view is. It does not say
how far through the library the view is, and scrubbing it jumps by
date. The bar gives the plain desktop answer: a thumb in proportion to
one screen of the whole grid, dragged or paged by clicking the track.
It reads the viewport that the rows are counted into, so it spans every
photograph and not only the loaded window of cells.

The Flickable keeps its id, its `interactive` arbitration with the
hold-to-pick-up and its pinch and zoom catchers. It now fills a
Rectangle that takes its place, and its stretch, in the grid's layout.
2026-09-26 07:25:46 -04:00
dtourolle f7df276295 Put a scrollbar on the settings page
The page is several screens long, and nothing said so until something
had been scrolled. Same wrapping as the develop column; the bar sits at
the window's right edge, clear of the capped 680px form.
2026-09-26 07:25:45 -04:00
dtourolle 005dc3a835 Put a scrollbar on the collections sidebar
A tree longer than the panel looked, to a mouse, like the whole tree.
Same shape as the develop column: the Flickable fills a Rectangle that
takes its stretch, and a ScrollBar is drawn over its right edge. It is
drawn only when the tree overflows.
2026-09-26 07:25:07 -04:00
dtourolle 825015a58e Put a scrollbar on the develop column
The column (histogram, compose, every adjustment group, history) runs
several screens past the window. A mouse without a wheel could only drag
the column's contents, and a drag that starts on a slider moves the
slider.

The Flickable now sits in a plain Rectangle with a ScrollBar beside it,
bound to its viewport. Nothing about how the column sizes itself
changes: the Flickable fills the Rectangle, and the Rectangle takes the
stretch the Flickable had in the layout under the group strip. The bar
is drawn over the column's right-hand 10px, so the mandated panel width
is not reduced. When the pointer is on the bar, the lit track covers
the value labels of the compose rows, which already sit flush against
the column's edge.
2026-09-26 07:25:06 -04:00
dtourolle 2ef971bf37 Fail when the last film stock cannot be reached
The film list bug gave no failure anywhere: the data was right, the
markup compiled, and the list rendered. Two guards now check that the
list can be walked to its end, both by driving input rather than by
reading markup.

tests/film_list_reaches_every_stock.rs runs in CI and needs no display.
It builds the real AppWindow on Slint's testing backend and gives it 28
stocks. It dispatches window events through the same routing a window
uses: popup, Flickables, arbitration. It then checks that the last stock
is on screen, that is, not clipped away:
- after Down past the end, and that Enter chooses it;
- after drags on the list;
- after a run of wheel events with a still pointer;
- after dragging the scrollbar thumb.

Element queries need the Slint compiler's debug tables, which build.rs
emitted only for the `automation` feature. It now emits them for every
debug build too. Release builds, the ones that ship, are unchanged. The
testing backend is a dev-dependency at the same pinned version the
automation feature already uses, so no new crate enters the lockfile.
The test is compiled out of release test runs.

film_reach in tools/manual/scenes.py is the same check on the recording
rig: a real X pointer from xdotool, the release build, and the demo
library. It makes no picture, so it adds nothing to the manual. It runs
with every recording, or alone with `record.sh LIBRARY film_reach`, and
fails the run if the last stock (Ilford HP5 Plus) is out of reach by
the wheel, a drag, the scrollbar or the keys.

Both have to add the popup's position back. The testing backend reports
anything inside a popup relative to the popup, and so does the
automation hook built on it. They take the popup's position from the
Film row and Slint's clamp into the window.
2026-09-26 07:24:46 -04:00
dtourolle e957d483fc Give the film list a scrollbar on the desktop
A list cut off at its edge looks, to a mouse, like a list that ends
there. The open film list shows ten rows of twenty-eight, and nothing
said there were more. The user asked for visible scrollbars on every
platform but Android.

ScrollBar (widgets.slint) is a vertical bar drawn over a Flickable's
right-hand edge. It shows the share on screen and the position, it can
be dragged by the thumb from wherever it was grabbed, a click on the
track moves a page toward the click, and the wheel over it scrolls. It
is the Flickable's sibling rather than a wrapper, bound to
`viewport-y <=> flick.viewport-y` and the two heights, so a scroller
keeps its own sizing. It is drawn over the content rather than beside
it, so the mandated column widths are not reduced. With nothing to
scroll it is not drawn and takes no input.

Whether to draw it is Scrolling.bars, which Rust sets from
dr_plat::is_touch_first(). That is the same function that puts the
develop groups in the rail, and it answers the same question: what is
the user pointing with? On Android, lists still scroll by flick only.
Placing the bar inside another scroller would put it back in that
scroller's arbitration. The film list can have one because it is now a
popup.
2026-09-26 07:24:45 -04:00
dtourolle 80938b0527 Open the film list as a popup, so every stock can be scrolled to
The open stock list showed ten rows, None to Kodak Kodachrome 64, and
the other eighteen - all seven black-and-white stocks among them - could
not be reached. The data was whole; the list could not be scrolled.

Reproduced on the manual rig (Xvfb, xdotool, the automation hook):

- a drag on the list scrolled the develop column, never the list;
- a wheel run over the list scrolled the column past it, whenever the
  column had scrolled under that pointer in the last 800 ms - which is
  how the list is reached, by wheeling the column down to it. After a
  pause and a pointer move the wheel did reach the list;
- no key did anything.

The cause is Slint's routing, not the list. Since 2d878c2 the list was a
Flickable inside the develop column's Flickable, and Slint offers every
pointer event to the outermost Flickable first
(input_event_filter_before_children, i-slint-core 1.17.1 flickable.rs).
The column holds a press back (DelayForwarding) and intercepts the first
move past 8 px on the axis it can scroll, so the list never saw a drag.
For the wheel it intercepts while its own last wheel event is under
800 ms old and within 2 px, and always for a touchpad gesture that opens
with TouchPhase::Started - so on a touchpad the list could get no wheel
at all.

The list is now a PopupWindow under the Film row. A popup is its own
item tree: while it is open, events go to it and to nothing beneath it,
so the list scrolls by wheel, drag and flick however the panel is nested
and whatever the column did last. The alternative, standing the column
down while the pointer is over the list (the sliders' hover trick), fixes
the drag but not the wheel - `interactive: false` does not gate wheel
interception - so it would have left the bug for touchpad users.

The reason 2d878c2 bounded the list still holds: it is at most 320 px
and never lengthens the column, and now it covers the sliders instead of
pushing them down. The column cannot be scrolled while it is open, which
suits a one-click question; it closes on choosing, on Escape or Back, or
on a press outside it.

Keys, with the list open: Up and Down move along it from the chosen
stock and scroll it into view, Enter chooses, Escape or Back closes it
unchanged. A popup is its own focus tree, so the keys are taken when it
opens and Slint returns focus to the develop view's scope when it closes.
The Film row gains a button role, so a screen reader and the automation
hook can name it.
2026-09-26 07:24:28 -04:00
dtourolle 6640ce0ca8 Regenerate the matrix, the gesture book and the manual page for the duplicates review 2026-09-26 07:20:19 -04:00
dtourolle d430ec9528 Drop a detail pass that changes nothing when it sits between two others
Capture sharpening at a scale too coarse to draw its radius emits one pass
with an empty body (`nothing_to_sharpen`), so that a chain still ends in
something that performs the output transform. At fit on any modern sensor
that is most of the time. When another neighbourhood operation follows it
- dehaze, clarity, texture - the pass is not last and does nothing: it
reads the rgba16float intermediate and writes the same texels to the other
one. It still cost a full render-sized read and write every frame:

  scene                           before     after
  sharpen+clarity 2560x1600 fit   18.66 ms   14.16 ms
  sharpen+clarity 3840x2160 fit   37.93 ms   27.88 ms
  every operation 2560x1600 fit   71.80 ms   67.24 ms
  clarity alone   2560x1600 fit   14.23 ms   14.20 ms  (control)
  sharpen+clarity 2560x1600 1:1   23.01 ms   22.97 ms  (control: resolves)

(Laptop RTX 3050 held at 420/810 MHz by its power cap, synthetic 60 MP
source, median of five alternated runs of forty frames each.)

`compose_detail_with` now drops such a pass where dropping it is exact:
not the last pass, whose output transform would otherwise move onto the
previous pass's f32 result and round differently; and not a pass right
after a reduced one, because a full-resolution pass is what closes the
reduced chain for the operation after it. `DetailPass::is_identity` says
what "changes nothing" means: full size, nothing bound at binding 3, and a
body with no code in it.

The rgba8 output is bit-identical: every scene above hashed the same
before and after, and the sharpen+clarity frame hashes the same as clarity
on its own, which is the claim in one line. A new dr-pipeline test pins
the three cases - dropped ahead of another operation, kept when last, kept
when alone.
2026-09-26 07:10:41 -04:00
dtourolle 1dc7b45cfe Read the fit view's source gather once per framing, not once per frame
At fit, every output pixel of the fused pass loads one texel from a source
three or four times its width, on a stride. The memory system fetches the
texels it skips along with the one it wanted, so on a 60 MP rgba16float
source that gather was most of what the fused pass cost: 10.6 ms of a
2560x1600 frame against 3.8 ms for the same shader reading a contiguous
window (the 1:1 view). At 3840x2160 it was 21.1 ms. Those are the laptop
RTX 3050 with its clocks held at 420/810 MHz by the power cap; unthrottled
the same frames were about 2.0 and 3.2 ms, and the gather is the same
share of them.

Which texel an output pixel reads depends only on the framing prologue,
the framing and warp uniforms, the source and the render size. None of
those move during a slider drag, so the gather is the same work every
frame. The fused shader now takes a render-sized rgba16float cache of it
(bindings 6 and 7, declared in every generated shader like the masks) and
a pair of uniform flags: write what was gathered, or read it back at the
pixel's own coordinate. AdjustPass keeps the cache and decides per
dispatch. The composer supplies `ComposedShader::sample_key`, a hash of
the prologue and those uniforms, and AdjustPass adds the image and the
size; an image gets a process-unique id for this rather than being held
alive by the key.

The picture is bit-for-bit the same. The source is rgba16float and so is
the cache, so the stored texel is the texel, and only the path that reads
a texel whole takes part: an interpolated sample (straightening, lens
warps, CA) is a blend that f16 could not hold exactly, so the composer
gives it no key and it reads directly as before.

The cache is written on the second frame with a given key, not the first:
a crop or zoom drag changes the key every frame, and writing then would
add a render-sized write to exactly the gestures that can afford it least.
It is kept only up to 3840x2400, so an export never parks a full-frame
copy on the device, and `release_caches` drops it.

Measured with a scratch probe rendering the synthetic 60 MP frame from
examples/frame_budget.rs, forty frames per run after six warm-up, five
runs of each binary alternated, median of the per-run p50 (GPU idle apart
from the power cap):

  scene                    before     after
  neutral   2560x1600 fit  10.62 ms   3.88 ms
  exposure  2560x1600 fit  10.83 ms   3.87 ms
  nr chroma 2560x1600 fit  19.84 ms  12.69 ms
  neutral   3840x2160 fit  21.05 ms   7.11 ms
  exposure  3840x2160 fit  21.08 ms   6.94 ms
  clarity   3840x2160 fit  42.20 ms  27.88 ms
  neutral   2560x1600 1:1   3.83 ms   3.84 ms  (control: nothing to gain)

The rgba8 output of every scene hashed identically before and after, in
isolated runs and across all 38 scene/size/view combinations of the
probe. New tests walk a pass through direct, write and read frames, a
slider move, a neighbourhood operation and a framing change, and compare
every frame with a fresh pass that can only have read directly.
2026-09-26 07:10:35 -04:00
dtourolle 284fc4a456 Regenerate the traceability matrix after the rebase 2026-09-25 23:25:08 -04:00
dtourolle d9f259656a Open Help from develop as well as from the grid
The "Controls and shortcuts" sheet was drawn by LibraryGrid, so only the
grid's Help button and its F1 could open it. Develop, where most of the
keys it lists are bound (Ctrl+E, Ctrl+Shift+C, A/D, Z, R, H, [ ]), had no
way to it: a photographer who wanted to look a shortcut up had to leave
the photograph they wanted it for.

The sheet now hangs off the shell beside the export and copy sheets, on a
`help-open` property both views set. The grid's Help button and F1 raise
it through a callback, and its keys stand down through the `sheet-open`
they already honour for the export sheet, so Escape falls through to the
shell, which closes it. Develop gains a Help button beside Settings in
its top bar, as in the library header, and F1 in its key scope; its keys
decline while the sheet is up, as they do for the other two sheets, so
nothing behind it is rated or stepped. The book already begins with the
Develop section, so from develop it opens where the reader wants it.

Tests hold the shape: the sheet is drawn by the shell and not the grid,
and develop's opening guard names all three sheets and its F1 opens this
one. The new GESTURE: block puts the develop route in the book.
2026-09-25 23:24:52 -04:00
dtourolle 19dd3257e3 Release the offset borrow before bring_window_to reloads the window
Holding D in develop across the edge of the loaded window panicked with
"RefCell already borrowed" at the first step that had to move it. The
`*ctl.offset.borrow()` written inside the `if let` condition is a
temporary that lives to the end of the `if let` block (edition 2021),
and the block calls `load_window`, which borrows the offset mutably.
The unit tests drive the placement arithmetic, not the RefCells, so
they could not see it; stepping 400 frames in the app did.

The offset is copied out before the test. `follow_open` gets the same
treatment for `image_ids`: the lookup's result is bound first, so no
borrow is held while it writes properties back to the window.
2026-09-25 23:06:24 -04:00
dtourolle a030bfd239 Step along the roll by library ordinal, and keep its mark on the open photograph
In a library's develop view the arrows, space, A and D opened
`library-roll-pick(library-roll-current ± 1)`. `roll-current` is a row
of the loaded window, set when a photograph was opened and never again.
Two things followed on any library larger than one window:

- Holding D stopped dead at the end of the loaded window: the pick of a
  row past `ctl.paths` found no path and did nothing, a few screenfuls
  into a library of thousands. A stopped at its start the same way.
- Any reload of the window — a background sync, a judgement that drops
  the open frame out of a filter — left `roll-current` on a row that now
  held another photograph. The roll marked it, develop's rating keys
  judged it, and the next step walked on from it.

The keys now call `library-roll-step(delta)`, and Rust steps as
`move_cursor` does in the grid: from the open photograph's ordinal
(`index`, which `report_position` already keeps), clamped to the
library, loading the window around the target only when it lies
outside. The window-bringing half of `place_cursor` is shared for this
as `bring_window_to`, so the grid cursor and the roll move the window
the same way. Catalog reads stay proportional to the window: one
`load_window` per window crossed, none per step within it.

The open photograph is also remembered by image id. Every
`load_window` finds it again in the rows it just read (a scan of one
window, no query), puts `roll-current` and `index` back on it, or takes
the mark off when it is not there. When it is missing but its ordinal
is still inside the window, it has left the grid and its successor
moved up into its place, so the next step forward lands on that ordinal
instead of skipping the successor.

A click on the roll still reports a row; it is right because the cells
it is drawn from and `ctl.paths` are replaced together, and it now goes
through the same open path as a step.

Fixes #64.
2026-09-25 23:00:55 -04:00
dtourolle 454375243c Measure what opening the catalog, a sync pass and a scan cost on a real library
Two benches for reading side by side before and after a change, against a
copy of a real catalog, in the manner of identity_bench:

- `dr-catalog --example catalog_bench CATALOG [FACES_DIR]` times
  `Catalog::open` and the backfill inside it step by step, the upload
  snapshot, a merge of the catalog with a copy of itself, and the face
  shard export and import in the steady state where nothing is new.

- `persist_bench`, an ignored test in dr-ui's scan module because
  `persist` and `apply_judgement` are private to it, replays the
  catalog's own rows through `persist` (the largest folder, and the whole
  library) and looks up every `.drsc` sidecar the catalog has read. It
  works on a scratch copy and prints a fingerprint of what `persist` left,
  so two builds can be shown to agree.

Both print best, median and CPU time; the CPU figure is the one to compare
while other builds share the machine.
2026-09-25 22:06:58 -04:00
dtourolle a6ea6ba83f Let the manual's scripts find a control by its name
Every scene in tools/manual aimed at window pixels written in by hand, so
a panel that gained a row moved every slider under it and the recording
went on dragging where the slider used to be. The develop column has
already moved that way (Compose now sits above Adjust), and nothing said.

A build with the `automation` feature listens on the Unix socket named
by DR_AUTOMATION and answers where an element is: by its accessible
label, the name a screen reader reads, or by its markup id for the few
things that are not controls (the canvas, the crop rectangle). It uses
Slint's element queries, which need the compiler's debug tables, so the
feature also turns those on in build.rs. It only answers questions; the
input is still xdotool's real pointer. No default build has the feature,
and one that has it listens only when the variable is set.

drive.py gains click-on, drag-on, hold-on, wait-for, wait-gone, labels
and ids. The grid's cells are now named by their file, each rating star
by its value, the sidebar's + as "New collection", and the Adjust
heading's reset as "Reset all adjustments" - controls a screen reader
could not reach before either.
2026-09-25 07:26:36 -04:00
dtourolle a8043e6827 Hand Android's develop frame to the compositor as a texture again
With Skia drawing pre-rotated on wgpu's Vulkan swapchain, Android no
longer needs to draw with Skia over OpenGL, which was the only reason
the develop view read its frame back through memory (TD-1).

So `unstable-wgpu-29` moves back to the common slint dependency. The
android-activity backend then builds `SkiaRenderer::default_wgpu_29`,
and `shared_gpu` loses its Android arm. The one wgpu device is handed to
Slint through `BackendSelector::require_wgpu_29` on both platforms.
`slint::android::init_with_event_listener` runs before `dr_ui::run`, so
the selector reaches the Android adapter before its window exists.
`renderer-femtovg-wgpu` stays desktop-only, since Android has no FemtoVG.

The two `#[cfg(target_os = "android")]` readbacks in `develop::render`
(the frame through `export_pixels` and the focus overlay through
`read_overlay`) are gone. `read_overlay` stays for the tests that check
what the overlay marks.

Built for arm64 and release-signed. Not yet run on the tablet.
2026-09-25 04:20:19 -04:00
dtourolle aa2a88f655 Show each frame's flag and stars on the develop roll
FR-UI-5's 2026-09-19 amendment asks for the rating and flag wherever they
can be set and on the roll's cells, so that stepping along a set in develop
shows what has been judged. The roll drew thumbnails only, so the keys that
now judge the open photograph left no trace on its neighbours.

Each roll cell carries a small badge with a tick or a cross and a star
count, drawn only when there is something to show, in shapes and a number
rather than colours (NFR-A11Y-3).
2026-09-24 23:42:28 -04:00
dtourolle f8737e3fda Close the help sheet with Escape, and keep keys from acting behind it
F1 opens the help sheet from the grid, and nothing on the keyboard closed
it: Escape fell through to the shell, and every other key went on judging,
labelling and keywording the photographs hidden behind the sheet.

Escape and Back now close the sheet first, ahead of the grid's other
sheets, since it is drawn over all of them. While it is open the grid's
handler declines every other key, so a stray P or Ctrl+K changes nothing
the reader cannot see.
2026-09-24 23:42:27 -04:00
dtourolle d489a34190 Drive develop, the grid, the sidebar and People from the keyboard
An audit of every action by view against the keys the handlers bind left
develop without zoom, pan, fit or a way back to the grid, the grid without
select-none, thumbnail size or keywording, People with no key at all, and
the export and copy sheets without Enter. It also found the reverse gap
FR-UI-5 forbids: pick and reject had no route but P, X and U, and the
2026-09-19 amendment's judging in develop had not been built.

Develop: Ctrl+= and Ctrl+Plus zoom in and Ctrl+- out about the middle of the
view, Ctrl+0 fits and Ctrl+1 goes to 1:1, Shift and an arrow pan a magnified
view, G goes back to the grid, Ctrl+Y redoes, and Enter keeps a crop that hid
a mask. 0-5, P, X and U rate and flag the open photograph without moving on,
with stars and Pick/Reject in the top bar as the pointer and touch route.
= and - nudge the control last moved by a hundredth of its travel; the
framing sliders, perspective included, now count as "last moved", so R puts
them back as well. J turns the selected mask part's join chip.

Grid: Ctrl+D and Ctrl+Shift+A clear the selection, = and - resize the
thumbnails, Ctrl+K opens keywording, and Flag in the selection bar gives
pick and reject a pointer and touch route. Sidebar: Enter commits a
collection's name, and Enter or Escape hands the keyboard back to the grid,
where it used to go nowhere until something was clicked. People: Up and Down
walk the rail, F2 puts the name field under the keys, and Escape or Back now
leave the screen the way its back button does instead of doing nothing.
Sheets: Enter does what the export or copy sheet's button does.

The choices follow Lightroom where it has one. No new key steals typing: the
grid's and People's keys live on focus holders that are not ancestors of any
text field, and the sheets' Enter comes after a focused field has had it.
Every binding is tagged beside its handler, and the gate added in the
previous commit holds the two to each other.
2026-09-24 23:42:26 -04:00
dtourolle 9d1e31ffbb Fail CI when a key is bound but not in the gesture book, or listed but not bound
The gesture book is generated from GESTURE tags, so it could not describe a
gesture nobody tagged, but nothing made anyone tag one. The arrow keys, Enter,
P, X, U, Delete, F1 and F2 all worked in the grid with no line in the help
sheet, and a tag could name a key whose handler had gone.

Key handlers now compare one canonical string, Keys.chord(event) == "Ctrl+Z",
instead of reading event.text and the modifiers themselves. keys.slint folds
the key and its modifiers into that spelling, so the literal in the handler is
the whole binding and the checker reads exactly what the handler dispatches
on. Each handler carries a KEYMAP comment naming the gesture-book section its
keys belong to, and a tag's keys field names its keys between backticks.
gestures-check now fails when a handler binds a key no tag in that section
names, when a tag names a key no handler there binds, when any .slint file
other than keys.slint reads event.text, when a compared literal is not
canonical, and when keys.slint's named keys drift from the Rust list.

Spellings are normalised in one place, chord.rs: Ctrl+z, Control+Z and
LeftArrow all mean what the handler's "Ctrl+Z" and "Left" mean. Shift and Alt
count only for letters and named keys, because on the French layout every
digit needs shift and a 6 has to be a 6 however it was typed.

A Rust keymap that both dispatched and was read by the generator was the
alternative. It would have moved the handlers' decisions away from the Slint
state they depend on, and a window that forgot to install it would have had
no working keys at all.

The keys that were already bound and undocumented are now tagged.
2026-09-24 23:42:25 -04:00
dtourolle 150e53e878 Link fifty gestures on the help sheet to the manual section that shows them
The sheet could now offer "See it", but no gesture said where to look.

Every GESTURE tag whose move the manual describes names that section:
the white-balance picker, zoom and pan, masks, undo and snapshots,
export, colour labels and ratings, selection, collections, the People
page, thumbnail size. Fifty of the fifty-one; the one left, putting a
single control back to its default, has no section and is too small to
earn one.

Three important gestures had nowhere to land, so the manual gains three
short sections, without pictures for now: Bursts (opening a folded
burst, choosing the frame it shows, and the eyes-open filter), Moving
between photographs (the roll, the arrows and A/D in develop) and
Copying settings (Copy, Paste, Paste to N, and choosing what a copy
carries). The page, the gesture book and docs/gestures.md are
regenerated from them.
2026-09-24 23:25:37 -04:00
dtourolle 7591738c73 Let a gesture name the manual section that shows it
The help sheet says which move does a thing, and the manual has a
picture of the thing being done, but nothing joined the two: a user
reading "Pinch it with two fingers" had no way from there to the GIF of
it.

A GESTURE tag takes an optional `manual:` field naming a heading of
docs/manual/README.md by its anchor. The scan checks every one against
the anchors the bundled page is rendered with and fails when the manual
has no such heading, so renaming a section cannot leave the sheet
linking to the top of the page; gestures-check carries the same failure
into CI. The anchor goes into gesture_book.rs as a new field, and into
docs/gestures.md as a "See it" link to manual/README.md#anchor. The help
sheet draws a "See it" button beside the title of each gesture that has
one, which opens the bundled manual at that section.

The field is additive: a tag without it is unchanged, and no gesture
carries one yet.
2026-09-24 23:24:17 -04:00
dtourolle 352e59498b Open the bundled manual from Help and from Settings
The packages now carry the manual, but nothing in the application opened
it: the help sheet listed gestures and stopped there.

The help sheet gains a Manual button beside Done, and Settings a Manual
row under About beside the version. Both go through dr_ui::manual, which
finds the installed page through dr_plat::system_data_dirs (the package's
share directory on Linux, the executable's directory on Windows), and a
development build also in the checkout it was compiled from. A copy with
no manual says so on the status line rather than doing nothing.

On the desktop the page goes to the system browser. A section is a URL
fragment, and xdg-open's generic mode and Windows' FileProtocolHandler
both turn a file: URL into a path and drop the fragment, so a section is
opened through a one-line redirect page written to the data directory:
the opener gets a plain path, which every opener keeps, and the browser
follows the redirect to index.html#section itself. The launcher behind
the sign-in's open_in_browser is split out so both share it; the https
check stays with the sign-in.

Android has no path to give a browser: an asset is not a file, a copy in
private storage is unreadable to other apps, a file: URI across apps is
refused, and a content: URI leaves the browser resolving every picture
against the provider. So ManualActivity, a WebView reading
file:///android_asset/manual/index.html straight out of the APK, shows
it, started by class name with the section as an extra. JavaScript is
off, links off the page go to the browser, and the theme is day-night so
the page's own light and dark follow the system. A test checks that the
manifest, the Java class and dr_ui agree on the name and the extra.
2026-09-24 22:56:09 -04:00
dtourolle 10216355c1 Render the manual as one HTML page the application can carry
The manual existed only as docs/manual/README.md, which the forge renders
and nothing else does. An installed copy of the application, on a laptop
with no network or on a tablet, had no manual it could open.

`traces manual` renders the README to docs/manual/index.html with
pulldown-cmark (already in the tree as Slint's Markdown parser, so this
adds a dependency edge and no crate). The page is one file with an inline
stylesheet that follows the system's light or dark preference, a
contents list of every section and subsection, and the pictures by their
relative media/ paths. Each heading carries the id the forge gives it, so
README.md#rating-and-flagging and index.html#rating-and-flagging are the
same link. A picture alone in its paragraph becomes a figure whose alt
text is shown as the caption, and every picture reserves its 16:11 box
before it loads, so a jump into the middle of the page lands where it
aimed rather than a screenful above. Links to design documents, which the
installed page has no copy of, point at the forge.

The page is committed rather than rendered at build time, as the gesture
book is: it is user-facing text reviewed in the diff, and the three
packagers then only copy it. `traces manual-check` fails in CI when the
committed page is not the render of the README, and the pre-commit hook
regenerates it when the README is staged.
2026-09-24 22:32:30 -04:00
dtourolle 114d979397 Add Vertical and Horizontal perspective sliders to Compose
The keystone existed in framing but nothing in develop could reach it:
framing is presented by its own Compose panel rather than generated, so
new framing parameters get no control until the panel names them.

Compose now has Vertical and Horizontal sliders under Straighten,
mirrored from the session like the angle, recorded as parameter steps
("Vertical Perspective" in the history), cleared by the Compose reset
and by opening the next photograph. Releasing either slider refits the
crop the way releasing the straighten slider does: a keystone alone
needs no crop, but it moves the empty corners of a straightened frame,
so the crop that avoided them before may not after, or may have room
to grow back.
2026-09-24 22:13:12 -04:00
dtourolle 5a500118ae Correct converging verticals with a keystone in framing
There was no perspective transform anywhere in the pipeline: framing
offered a ±45° straighten, quarter turns and flips, and a building shot
looking up kept its leaning walls.

Framing gains a vertical and a horizontal keystone (-100..100). They are
parameters of framing rather than a new stage, so they carry its Compose
attribute, persist in the sidecar under framing, and are withheld from a
default paste exactly as the crop is. In the prologue the keystone runs
after the crop and the straightening and before the stored orientation
and the lens warp, so "vertical" is the photograph's displayed height and
the lens still sees its whole frame.

The map takes the output frame onto a trapezoid inside the source, built
as a homography from four corners and uploaded as three columns in the
framing uniform block (which grows from two vec4s to five). A keystone on
its own therefore never exposes an empty corner and leaves any crop valid.
Combined with a straightening angle the empty area is a pulled-back
quadrilateral the closed-form inscribed rectangle cannot describe, so
max_inscribed_crop searches for the largest centred rectangle whose
corners all have a source pixel behind them. source_at and output_at
apply the same map, so masks, gradients and spot handles follow it.
2026-09-24 22:13:11 -04:00
dtourolle ff89a4fa21 Specify perspective correction as FR-DEV-20
Issue #13 asks for a vertical and horizontal keystone, and its number was
renumbered from FR-DEV-19 when the spec gave that to mask editing. The
clause was never written into the register, so the work had nothing to
trace to.

It is written as part of framing: after the crop and the straightening,
before the stored orientation and the lens warp, carrying framing's
Compose attribute, and with the inscribed crop accounting for it.
2026-09-24 22:13:11 -04:00
dtourolle 04495afbfd Regenerate the traceability matrix for the colour-label work
The pre-commit hook left the matrix as it stood on two of the commits before this one, so it named neither the new test file's NFR-A11Y-3 tag nor the line numbers the label code moved. Regenerated from the tree as it now is.
2026-09-24 21:52:23 -04:00
dtourolle 46f5b95828 Show and set colour labels in the grid and develop, and filter by them
Colour labels could be read from a Lightroom sidecar and queried by the
selector, but nothing drew one or set one, so the only labels a library
held were ones another program had written.

Every mark carries its label's initial on its colour — R, Y, G, B, P —
so a label is read without telling red from green, which is what
NFR-A11Y-3 asks of colour labels by name. A grid cell shows the mark
before its filename. In the grid, 6, 7, 8 and 9 set red, yellow, green and
blue as Lightroom's keys do, on the photograph under the pointer or on
the selection by the rule the star keys follow; the same key again takes
the label off, and over a mixed selection it sets it on all. The
selection bar gains Label, which opens the six choices — each a mark and
a name — and purple, which has no key, is there. In develop the top bar
says "Label: Green" beside the mark, opens the same choices, and 6-9
label the open photograph.

Each gesture is one catalog transaction, then the grid, the counts and
both sidecars are written as a rating's are. The filter bar gains a chip
per label, its mark and its name with a count, one at a time; the filter
is one SQL term, travels in the place record, and "All" clears it.
2026-09-24 21:52:23 -04:00
dtourolle ade627a5d0 Fade the draft into the sharp frame when a drag settles
When a gesture stopped, the half-resolution draft was replaced by the
full-resolution frame in one step, a visible jump from soft to sharp.
FR-DSP-4 asks for a refinement that is smooth, not a jarring swap.

The canvas now keeps the last draft frame (`canvas-previous`) and draws
it over the sharp one, fading it out over 150 ms when the draft flag
clears. The fade costs no render: the draft is a refcount on the texture
it was drawn into, and the adjust pass ping-pongs between two output
targets, so the sharp frame is written into the other one. While a
gesture is drafting the layer is hidden and snapped opaque, so a new
drag shows its draft at once; past the fade it is hidden again, and a
settled canvas composites one image as before.
2026-09-24 21:52:23 -04:00
dtourolle 4642c77e18 Dim the histogram while the canvas shows a draft
The histogram is measured on settled frames only, so during a drag it
describes the frame from before the gesture while the canvas shows
something newer, and nothing said so. The draft flag stopped at the
render closure.

It now reaches the interface: `canvas-draft` on the window and
`Levels.provisional` for the readouts, both set on every canvas render
from the flag that chose the frame's resolution, and cleared when a
render fails. The histogram panel dims its display reading to half
while a draft is up and brings it back when the frame settles. Dimmed
rather than captioned, because a caption appearing on every drag would
move the column; the raw reading has no frame to lag and is left alone.
2026-09-24 21:52:23 -04:00
dtourolle 7d0870c3fb Keep a drag in draft until it stops, then render sharp once
During any drag longer than 120 ms the canvas rendered a full-resolution
frame every 128 ms under the finger. The settle timer was armed by the
first draft of a burst and not re-armed by later ones, so it counted from
the start of the gesture rather than from its last movement, fired
mid-drag, and the next coalesced event armed it again. Each of those
frames is the most expensive one the canvas draws, landing where the
frame budget is tightest.

The draft/sharp decision now lives in `refine::Refine`, apart from the
timers that carry it out. Every draft frame arms a settle timer carrying
a generation token and only the newest token is honoured, so the sharp
frame lands SETTLE_DELAY after the last movement. A request arriving
while a settle is still owed also counts as part of the gesture, so a
slow stretch of a drag (one event per frame, nothing to coalesce) no
longer renders sharp between drafts. A timer that fires with a render
already posted defers to it.

The tests drive the state machine through simulated timelines; the
long-drag case reproduced the four mid-drag sharp frames before the fix.
2026-09-24 21:52:23 -04:00
dtourolle c3d1f83b96 Say so when a crop leaves a mask outside the frame
Cropping tighter past a mask layer made it invisible without a word:
the layer stayed in the panel and the sidecar, and its adjustment went
on landing on pixels nobody would see again.

When a crop is let go, develop now measures what the gesture did to the
mask stack (dr_pipeline::orphan) and, if any layer is now entirely or
mostly outside the frame, shows a notice over the photograph: how many
layers, their names, "Undo crop" and "Keep crop". The crop is already
applied and nothing waits on the answer.

The crop overlay gains a release callback carrying the rect the press
began from, so the measurement runs once per gesture and never on the
drag's per-frame changes. Choosing a ratio is measured the same way,
being a crop committed in one click.

"Undo crop" is the ordinary undo, and the notice is tied to the history
revision it was raised at: the redraw that follows any history move
clears it, so the crop and its warning go back as one step. A second
drag folded into the same step is measured from where that step began.
A crop that strands nothing shows nothing.
2026-09-24 21:52:03 -04:00
dtourolle 9772785f81 Measure which mask layers a crop takes out of the frame
Mask geometry is stored in source coordinates, so re-cropping tighter
never destroys a layer. It makes it invisible: the layer stays in the
panel and the sidecar, its adjustment lands on pixels nobody will see,
and nothing says so. The spec had no clause for this; FR-DEV-17 now
states it, under the ID issue #10 reserved.

dr_pipeline::orphan samples each layer's mask on a 64x64 lattice over
the source, with the gradient, radial, brush and model-raster geometry
the mask shader uses, folds the parts by their joins and inversions, and
maps the samples through the framing to see how much of the coverage
the crop keeps. `hidden_by_crop` reports the layers whose share fell
below a tenth, and only those the change newly hid, so an already
stranded layer is not announced again on every later adjustment.

Ranges follow the picture and region selections need a label map this
crate does not hold, so a layer that adds either is never reported: a
false alarm on the common path would teach the notice to be dismissed
unread.
2026-09-24 21:52:02 -04:00
dtourolle 414094bd38 Route dr-ui's decoding through the Decoder trait
With the trait in place the claim still meant nothing while every caller
named dr_decode's free functions: a second decoder would have had to be
threaded through the scan, the thumbnail ladder, import, the viewer,
export, merge and repairs at the moment it arrived.

Each of those now takes a &dyn Decoder and reads headers, previews,
orientation and sensor data through it, including the header budget a
remote fetch asks for (header_bytes) and where it finds the embedded
preview (locate_preview). Only the places that start a job name
dr_decode::default(): the thumbnail, sweep and thumbnail-sweep threads,
the viewer's open handlers, and the request structs a job is handed
(BatchRequest, MergeRequest, the import Request, the repairs Toolkit),
so a caller can be given another decoder by changing what it is handed.

The default is rawler through the same free functions as before, so
nothing a user sees changes. The trait gains Debug as a supertrait so
request structs that derive Debug can carry one.
2026-09-24 21:33:14 -04:00
dtourolle 8cdad3863d Keep only where two selections agree, as a third way to join a mask part
A layer's parts could be added to the mask or taken out of it, and nothing
else. The selections that need composing most are the ones that are
neither: the sky that is also bright, the subject that is also skin. With
union and subtract alone, "this and that" had to be spelled as "this minus
everything that is not that", which needs a second part that selects the
complement and rarely exists.

Join gains Intersect, stored as "intersect" in the part block of a sidecar.
It is the product of the two coverages, dst * src, which is one more
fixed-function blend state beside union's max and subtract's
dst * (1 - src) (mask-editing.md 5.2): the same scratch texture, the same
three vertices, no shader arithmetic. The product equals the minimum
wherever either side is fully in or out, and is the softer reading where
two soft edges overlap. Join::apply spells the three operations on the CPU
so the GPU tests can be held to one definition.

A layer that intersects with a part covering nothing now reports that it
covers nothing, so it is not rasterised as an empty slice. Old sidecars
never contain the word, so they read as before; a build from before this
reads "intersect" as a union, the existing unknown-join fallback, which
keeps the part visible rather than dropping it. Join::ALL keeps union and
subtract at indices 0 and 1 so a stored panel index still means the same
join.
2026-09-24 21:25:48 -04:00
dtourolle 229def0afc Show the file's own pixels at 1:1 and beyond
Zoomed to 1:1 or past it, the develop canvas showed a smoothed blur
rather than the photograph's pixels, so focus and noise could not be
judged at the magnification meant for judging them.

Two things caused it. The canvas only switched to nearest-neighbour
strictly past 1:1, with a margin, so the 1:1 inspection itself stayed
smooth. And the switch mostly had nothing to act on: the pipeline
rendered a viewport-sized frame at every zoom, so past 1:1 it was the
pipeline doing the enlarging - bilinearly whenever a straightening angle
or lens correction was in the chain - and the detail stage then sharpened
and denoised those invented pixels at radii scaled up to match. The
texture reached the canvas already blurred and was presented 1:1.

Now, from 1:1 on, the visible region is rendered at the source's own
resolution (render::render_size) and the canvas enlarges it with
nearest-neighbour, so the blocks on screen are the pixels an export would
have; it is also less shading. The decision lives in two small
functions, render::magnification and render::shows_source_pixels,
measured in physical pixels like one_to_one_zoom, with a half-percent
tolerance so the inspection zoom counts as 1:1 even where fit() rounded
the other edge. Below 1:1 the render and the smooth filter are unchanged.
2026-09-24 21:24:57 -04:00