Commit Graph
34 Commits
Author SHA1 Message Date
dtourolle 33779a70bd Answer a thumbnail miss with the other stored class before the network
Offline, a grid zoomed past 256px was blank wherever it had not been
zoomed over before. The store was asked only for the exact class the cell
wanted, and the sweep stores only the grid class, so every zoomed cell
missed and went to a server that was not there — with its 256px thumbnail
sitting in the store the whole time. Online it cost the same round trip,
just without the blank cell at the end of it.

The split now tries the other class on a miss. A smaller one stands in
and the fetch for the real class still goes out; a larger one answers
the request outright, since there is nothing a fetch would improve on.
`ThumbnailReady` carries the class its pixels are, and the drain records
that rather than the batch's class, so a stand-in is replaced on the next
reload instead of being counted as served. `already_served` counts a
held large thumbnail as serving the grid class too, so zooming back out
does not re-read the store for pixels already on screen.

The split moves into `split_by_store` so it can be tested without a
worker thread or a network.
2026-10-03 16:44:58 -04:00
dtourolle 6050a8e703 Size a panorama's cell and thumbnail by class: two, three or four columns
One lookup, natural_span, maps a photograph's aspect to the columns its
cell spans, and the same number names its thumbnail class, Wide2, Wide3
or Wide4, 512 pixels of long edge per column, so a 4:1 panorama is as
sharp across four columns as a frame is in one. The boundaries are
where the two neighbouring cells would leave the same share of
themselves undrawn, sqrt(s(s+1)): 2.45 and 3.46, with the first at 1.9
so a 3:2 frame stays a frame. A grid too narrow for the class falls
back to the widest that fits, the tablet gives the whole row, and a
cell asks for the class it is actually drawn at: its span, but never
more than its own class. The merge renders each wide class up to the
composite's own, which covers every fallback.
2026-09-28 19:57:37 -04:00
dtourolle ae4e1a0f07 Give a panorama a wide cell in the grid
A 4:1 composite drawn in one square cell is a strip a few pixels high.
A photograph about twice as wide as it is tall (1.9 and up) now spans
two columns, three from 2.9, with a thumbnail class of its own whose
long edge is sized for that width; on the tablet, or where the columns
are too few to put it beside anything, it takes the whole row.

Rows are computed in one place, library_ui::layout. The grid is a
lattice of slots: each cell is drawn at the slot Rust gives it, and a
wide cell that would not fit in what is left of a row starts the next
one, leaving the gap empty so the grid still reads in capture order.
The scrollbar spans the slots, a scroll reports a slot that the layout
turns back into an ordinal, and scrubs, restores and the cursor go
through the same conversion. Up and down step by rows through the
layout rather than by a row's worth of ordinals; left and right, a
shift-click's run, burst folding and the timeline are ordinal-based
and unchanged.

The window's own read carries each photograph's w and h, so the cells
know their shape with no query per cell. Where the wide ones sit in
the whole list is one query, run when what the grid lists changes or
when a window finds the layout out of date, and a library with no
panorama answers it from a partial index created on first use
(images_wide), not a schema bump. The merge makes the wide thumbnail
for a wide composite along with the others.
2026-09-28 19:57:37 -04:00
dtourolle ce5b7d72e3 Thumbnail a composite during the merge, as develop first shows it
The DNG a merge writes has no embedded preview, and an embedded preview
is all the grid's thumbnail path reads, so a composite stood in the grid
as a blank cell until it was opened. Reading an 800 MB file back to make
one would cost what the merge already has in hand.

The bands are box-reduced as they are written, after the border fill,
into a copy 4096 pixels long. That copy is written as a linear DNG in
memory with the composite's own profile, header and crop, and opened
through open_session, the function develop opens every file with: the
same decode, the default graph and view transform, the as-shot white
balance and the conversion to the display's space. The grid and large
thumbnails are rendered from that session, staged beside the payload
before the rename releases it to a drain, and put in the store under the
file id once the upload has learned it. Until then the grid draws them
from memory, so the cell is not blank while the file is uploading.

A test develops a synthetic composite both ways, the whole file as
develop opens it and the merge's reduced copy, and holds the thumbnail's
mean, 95th and 99.5th luma percentiles to within 3-4 levels of develop's
render; the naive balanced-and-gamma picture the merge's preview draws
misses by 13.
2026-09-28 19:57:37 -04:00
dtourolle 98a67393d9 Catalogue a merged panorama the moment it is written
A finished merge drained the outbox and started a rescan beside it. The
scan raced the upload: on a folder library the 800 MB copy was still
running when the folder was listed, on Nextcloud the upload takes
minutes, and either way the listing lacked the composite, recorded the
folder's validator, and nothing looked again until the next sync pass.

The job now reports what the catalog needs (the name, the size of the
picture it opens on, the capture time it wrote into the DNG) and the
library writes the row at once, in one transaction, keyed where the scan
will list the file; a composite with no time of its own takes its
sources' earliest. The grid reloads and shows it beside its sources.

The upload then gives the row what only the server knows: after sending
a file the catalog already has a row for, it lists the folder once, takes
the file id the server assigned, and records it (and, once the merge
makes them, the thumbnails waiting beside the payload) under that id.
Every drain now rescans when something landed in the library, after the
upload rather than beside it. A second merge of the same frames is no
longer named over the first: the name is checked against the catalog's
names in that folder, which is all that knows it once the outbox is empty.
2026-09-28 19:57:37 -04:00
dtourolle 502023c0f4 Show a thin scroll cue on Android instead of no scrollbar at all
On Android the desktop scrollbar is off, so every scroller that has one
on a desktop (the develop column, the grid, the collections sidebar,
Settings, the help sheet and the film list) gave no sign of how long it
was or where the view was in it. That is how the black-and-white film
stocks came to look deleted when the list could not scroll.

ScrollBar now has a second mode, chosen in the one Scrolling global:
where bars are off and `cue` is on, it draws the thumb alone, 3 px wide
against the edge, while the viewport moves, and fades it 500 ms after
the last move. It has no TouchArea, so a flick that starts on it
scrolls the content. Rust sets cue on touch-first builds; a desktop
build shows it when DR_SCROLL_CUE is set, to look at it without a
device. Desktop is otherwise unchanged.

Test: on the testing backend's help sheet, a press on the cue moves
nothing, a drag starting on it carries the list with the finger, the
cue is drawn while the list moves (drag, fling, wheel) and not once it
is idle; with bars on there is no cue and the thumb still takes a drag.
2026-09-27 07:22:09 -04:00
dtourolle adbb9ac9e6 Tag the judgement dispatch R7 and record which half of FR-CULL-13 is met
apply_judgement and apply_label carry TRACES: R7, the burst
representative callback FR-CULL-13 with a note that choosing it writes
the grouping and no verdict. The register's status for FR-CULL-13 says
the write-path test is met and the evidence chips are outstanding.
Traceability matrix regenerated.
2026-09-27 07:20:41 -04:00
dtourolle b1d1c47261 Start every worker thread through the executors module
Thirty-nine spawn sites in dr-ui, and one in the Android entry point,
called std::thread::spawn or a Builder of their own, and most of the
threads they started were <unnamed> in a panic message or a profiler.
Each now calls executors::spawn with its executor and a role, so the thread is
named <executor>:<role> — net:sync, decode:thumbs, io:catalog-open —
and knows which executor it is on. The three that already set a name
(automation, import, prefetch) keep their name as the role.

Behaviour is unchanged: each job still gets a thread of its own when it
starts, and spawn panics where std::thread::spawn did.

The module's documentation now says how a job is assigned: by what it
spends its time on, so a sweep that fetches bytes and then decodes them
is Decode, and a sidecar write that touches the catalog is Network.

Left as they were: the segmentation and refine workers in masks_ui.rs,
which another change is reworking, and test-only threads.
2026-09-27 07:08:37 -04:00
dtourolle 7a56d16df1 Count the whole library in "All photographs" whatever is scoped (#76)
The sidebar's "All photographs" row was bound to library-total, which
is the scope's count: the header's "412 images" and the scrollbar's
size. Under a collection, and now under an album, the row read as the
album's size — 4 where the library holds 70.

It reads a separate library-whole-total now: the same number as the
scope's when nothing is scoped (no second count), and otherwise the
unscoped count under the same filter, read only when the view's facts
move, so scrolling inside an album does not recount the library.
2026-09-26 16:19:54 -04:00
dtourolle 9b580c3720 Satisfy rustfmt and clippy on the album and folder picker changes
rustfmt over the files the albums work touched, and the album merge's
incoming row as a named struct rather than an eight-field tuple, which
clippy's type_complexity refused.
2026-09-26 14:13:54 -04:00
dtourolle 7cbcacc02e Export to an album instead of a folder in the settings
Export took a path typed into the settings page, or a folder inside the
library on the server. The first is how exports end up somewhere nobody
looks; the second put JPEGs into the tree a scan catalogues, where they
came back as photographs beside the RAWs they were made from.

The destination is now an album (FR-EXP-10), chosen by name in the
export sheet. Albums are listed under the collections in the sidebar;
"+" there, or "New album…" in the sheet, opens a sheet for its name and
its folder — on this device through the platform's dialogue, or on the
server through the browser with "New folder". A server folder inside
the library is refused, and the sheet says why. Selecting an album
narrows the grid to the photographs behind its files: library::Scope
is Collection or Album, and scope_clause is the one place the two are
spelled, which also retires the two copies of the collection predicate
total_images_scoped and read_cells_scoped had inlined.

A batch resolves the album when it starts, and refuses in words when
none is chosen, it has gone, or its folder is local to another device.
Each item reports the image it came from, and the files written are
recorded against the album in one transaction when the batch ends.

A server album lives outside the library, so its queued uploads are
relative to the account root. That is a third line in the outbox's
.dest record rather than a leading slash, because a record written
before albums may carry a stray slash and must keep the meaning it was
written with.

An export folder set before albums becomes an album called "Exports"
on first open, so upgrading does not lose where exports were going.
The old destination fields stay in ExportSettings so older settings
files still read.
2026-09-26 14:13:53 -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 6e67ef4467 Drop retired Thumbnail jobs whenever a catalog is opened
Stopping the enqueue leaves the rows already queued: 23,582 on the
reference catalog, about 1 MB of table and indexes that every query over
jobs pays for.

A migration would be the usual tool and is the wrong one here. A schema
bump makes an older build refuse the synced catalog snapshot, and the
tablet is on 0.16.0. So the rows are dropped at runtime instead, by
jobs::drop_retired over a new JobKind::RETIRED list, from runner::recover
- which already runs exactly once per catalog open, before any worker.

It runs every open rather than once because an older build sharing the
catalog queues them again on its next scan. kind leads the
UNIQUE(kind, subject_id) index, so with nothing left it is one index
probe. Measured on a copy of the reference catalog: 23,582 rows dropped
in 40 ms on the first open, 0.07 ms after.

Thumbnail stays in the enum so its number is never reused for a kind
that would then inherit old rows. The runner tests that call recover
move to a live kind; the jobs.rs tests of queue mechanics never call
it and are unchanged.

Refs #73
2026-09-26 13:15:18 -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 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 54aee50539 Count duplicate originals once the sweep has dated them
A copy becomes a duplicate only once its capture time is read, and on a
fresh library that is the sweep, not the scan: the sidebar row stayed
hidden until the next launch. The count is refreshed when a sweep that
dated anything finishes.

Also says on a group left out of the plan that nothing will move, drops
an unused method, and names the review's completion callback type for
clippy.
2026-09-26 07:19:13 -04:00
dtourolle 220e9af222 Add the duplicate originals review, from the sidebar and from Settings
"Duplicate originals" appears under the trash in the collections
sidebar while the catalog holds any, and Settings says how many there
are beside the other whole-library passes. Both open one page: every
group with its picture and paths, the copy that stays (tap another path
to change it), a per-group Include box, what the survivor will gain and
any flag, label or face conflict, and why a group was skipped.

The summary is the dry run -- "N groups, M files to trash, K skipped" --
and nothing moves until "Check" has read the copies and "Move M copies
to trash" is pressed. Both run on workers with progress on the page, in
the activity register and, for the move, on the library status line;
Stop ends a job between groups. When it ends the grid, the sidebar and
the trash are refreshed and the survivors' judgements are written to
their sidecars and XMP the way a rating keystroke writes them.

The page is paginated at 30 groups, so a redraw decodes 30 thumbnails
and previews 30 merges whatever the size of the library. Back and
Escape leave it like its own Back button.
2026-09-26 07:19:13 -04:00
dtourolle 9de37bed81 Hold develop's judgement keys to the open photograph, and to staying on it
Rating and flagging in develop (FR-UI-5, amended 2026-09-19) landed with
the keyboard audit: 0-5, P, X and U in develop's key scope, stars and
Pick/Reject in its top bar, and flag and stars on the roll's cells. Two
of the amendment's rules were held by nothing. The keys must judge the
photograph on screen and never a selection left behind in the grid, and
judging must not move on to the next frame, which is culling's
auto-advance and not develop's.

The rating, flag and label callbacks that take a row each spelled the
row-to-image lookup themselves. It is now one function, `image_at_row`,
which answers None for a negative row as well as one past the end: the
roll passes -1 when the open photograph is outside the loaded window,
and the right answer then is to judge nothing. A test says so. The key
bindings live in Slint, where no test can press them, so a second test
reads develop's handler as the gestures gate and the canvas-order test
do, and checks that each judgement key calls the row callback on
`library-roll-current` and that none of them steps the roll or the
cursor.

The writes themselves go through `apply_judgement`, the grid's own path:
one catalog statement, then the sidecar and XMP writes behind it.
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 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 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 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 d748527a4c Keep colour labels in the sidecar so they survive and travel
A rating and a flag are written to DarkRoom's sidecar as well as the
catalog, because the catalog is a disposable index and the sidecar is
how a judgement reaches the photographer's other devices. A label had no
place there, so once labels could be set, one would have lived only in
the catalog of the device it was set on and gone with it.

The sidecar version now carries `label` (0 none, 1-5 as the catalog
codes it), written only when set. It merges under the rating's rule, so a
device that never labelled a frame cannot clear another device's label,
and a code this build does not know reads as none rather than as some
other colour. A judgement write carries the catalog's label with the
stars, and the scan takes a sidecar's label into the catalog when it has
one. An older build keeps the line as an unknown key and writes it back.
2026-09-24 21:52:23 -04:00
dtourolle a3f3e188e1 Move the people tray's ticks in place instead of rebuilding it per press
Benchmarks / CPU and I/O (per commit) (push) Successful in 1m52s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 45m4s
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Successful in 29s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Successful in 29m25s
Build and test / Windows (x86_64, cross) (push) Successful in 34m4s
Build and test / Publish the release (push) Skipped
Filtering the grid by a face crawled on the reference library. The SQL
is not it — the person predicate counts in ~20 ms, the eyes-open term in
~60 — but every press on the tray ran `push_people_chips`, which read the
whole people table (26,362 rows, nearly all empty groups a regrouping
pass left behind) and then called `push_people_roster`, which read it
again and replaced the roster model. The roster is every person holding
a face, 1,581 chips, in a row Slint does not virtualise: a new model
tore down and re-created all of them and laid the row out again, to
move one tick.

A press now walks the roster model and sets `picked` on the rows whose
tick changed; the roster is built only when the tray opens. Both reads
use `people_in_use` (2,140 rows) rather than `people`. A picked person
the in-use query leaves out — emptied by a split while the filter held
them — still gets a chip, since a term with no chip cannot be removed,
and without one the in-place update would fall back to a rebuild on
every press.
2026-09-24 20:21:50 -04:00
dtourolle 031315bdb6 Run cargo fmt over the develop shortcuts and the star-range filter
Benchmarks / CPU and I/O (per commit) (push) Successful in 2m8s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 33s
Build and test / Android (aarch64) (push) Successful in 14m21s
Build and test / android-image (push) Successful in 1s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 47m57s
Build and test / windows-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / Layer separation (push) Successful in 51s
Build and test / Windows (x86_64, cross) (push) Successful in 34m3s
Build and test / Publish the release (push) Successful in 1m3s
bddf325 and 00c028c went in unformatted, so the Desktop job's
`cargo fmt --check` step failed on master (run 1693) and the release job
that needs it was skipped. Whitespace only.
2026-09-24 20:05:02 -04:00
dtourolle 00c028c8c8 Rate under the pointer, filter a star range, and name Help as help
Benchmarks / CPU and I/O (per commit) (push) Successful in 1m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 2m50s
Build and test / Layer separation (push) Successful in 33s
Traceability / Requirement traces (push) Successful in 40s
🐳 Android image / Build and push (push) Successful in 4s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 4s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Successful in 14m19s
Build and test / Windows (x86_64, cross) (push) Successful in 17m50s
Build and test / Publish the release (push) Skipped
Rating keys in the grid follow darktable's rule: with the pointer over a
photograph outside the selection, 0-5, P, X and U judge that photograph
alone; over one inside it, the whole selection, as before; off the grid,
the selection. The hover is cleared when the grid scrolls, so a key after
a wheel turn cannot judge whatever used to be under the pointer.

Holding F and tapping digits filters by stars: one digit for exactly
that many, two for everything between them, F alone to show every
rating again. The filter gains a ceiling to do it (`max_rating`, one
BETWEEN in the query). The place record carries it, and a record from an
older build reads as having none. The star chips light across a capped
range and the bar says "2-3★ only" beside them.

The grid also takes Ctrl+E and Ctrl+Shift+E for the selection, Ctrl+V to
paste onto it and Ctrl+A to select all. The "Gestures" button is now
"Help", its sheet "Controls and shortcuts", and F1 opens it.
2026-09-24 05:11:36 +02:00
dtourolle af162dd010 Merge: origin's sync ordering and adoption work, its scan-complete trigger ported into library_ui/ 2026-09-21 11:32:05 +02:00
dtourolle 1e472fd251 Move the library's routes and status onto its own Slint global
The scan/opening/status/error lines, offline mode and its retry, pinning a
collection offline, the sync and thumbnail-sweep state, and the callbacks
that route the grid to a rescan, a sync, another library, a panorama merge
or an export — the last of what AppWindow still carried under the
library- prefix — move onto the `Library` global started earlier on this
branch. Rust reaches them through window.global::<Library>() rather than
window.set_/get_/on_/invoke_ on the root.

library-visible is the one name that stays: it is computed from
active-page and active-view, the shell's own routing state, which a
global cannot read. AppWindow now declares no other library- property or
callback.
2026-09-20 22:22:55 +02:00
dtourolle 6bf67cefc4 Move the filter bar and the timeline onto the library's Slint global
The date-range fields and their band on the capture-time axis, the
timeline's bars, labels, scrub/pinch/pan/zoom callbacks and its
sweep/current-bucket/anchored state, and the filter bar's people chips,
mode, eyes-open toggle and gesture reference move from AppWindow onto the
`Library` global. Rust reaches them through window.global::<Library>()
rather than window.set_/get_/on_ on the root, as the earlier commits on
this branch did for the grid's cells, selection, ratings and keywords.
2026-09-20 22:07:12 +02:00
dtourolle 00e2fe6aaf Move ratings, flags and keywording onto the library's Slint global
The keywording sheet's rows and its open/assign/unassign callbacks, the
star and flag callbacks a cell click or a judgement key fires, the burst
toggle and representative-chosen callbacks, the trash-selection shortcut,
and the rating/unjudged/flag filter chips with their rating-counts model
move from AppWindow onto the `Library` global started in the previous
commit. Rust reaches them through window.global::<Library>() rather than
window.set_/get_/on_ on the root.
2026-09-20 21:57:40 +02:00
dtourolle 402dcdc24c Move the library grid's cells and selection onto their own Slint global
AppWindow carried the grid's loaded window of cells, the keyboard cursor,
drag and drop, the held-row long-press state, columns and cell size, the
scroll and viewport bookkeeping, the photo roll's pick and centre-request,
and the local-only/reorder/collection-filing gestures that act on a
selection, as properties and callbacks on the root component. That state now
lives in the `Library` global declared in library.slint, next to the structs
(LibraryCell, TimelineBar, KeywordRow, PersonChip) it and the grid's other
components already share; Rust reaches it through
window.global::<Library>() instead of window.set_/get_/on_/invoke_ on the
root, the same change collections.slint's `Collections` global made for the
sidebar.

library-visible stays on AppWindow: it is computed from active-page and
active-view, the shell's own routing state, which a global cannot read.
Everything else still prefixed library- — the timeline, the filter bar,
ratings and flags, keywording, and the routes and status lines — stays on
the window for now and moves in the commits that follow.
2026-09-20 21:42:48 +02:00
dtourolle cc73ea3153 Split library_ui.rs into a module directory by area of behaviour
controller holds LibraryController and the window-sizing constants every
other module reads and writes through pub(super) fields, the same shape
collections_ui and develop already use. open is the launch-to-scan cycle and
the worker that checks the catalog file before either touches it. offline is
what of a collection is on this device and the prompt that offers to change
it. window fills the grid model from the catalog and drains the thumbnail
fetch, which is the piece the catalog-reads-are-proportional-to-what-changed
rule (docs/catalog.md §1) bears on most directly. sync is the background
passes that reach beyond the loaded window: the metadata sweep, the
whole-library thumbnail pass, and the exchange with the server.
ratings_keywords applies a judgement or a keyword to a selection and queues
the sidecar and XMP writes behind it. timeline is the capture-time sidebar
and the photographer's place together, kept in one file because a restored
place ends by moving the timeline marker and a scrub is a restore of one
instant, so most calls between the two would otherwise cross a module
boundary. grid wires the grid's own callbacks — the keyboard cursor,
cell-size zoom, the routes into and out of develop — and filter_bar wires
the rating, people, date and offline-scope filters, calling back into
whichever of the above owns the work a filter change triggers.

Extracted by item rather than by line range, so every doc comment and
TRACES/GESTURE annotation stayed attached to the code it describes; the
sorted set of TRACES/GESTURE lines in the new directory is identical to the
original file's. Tests moved with the code they exercise, including the
handful of fixtures — settle, model_of, with_catalog, zoom_cell, pinch_step
— that only one target module needed and so were not worth sharing through
a test_support module the way the other splits use one. Items that crossed
a new module boundary were widened from private to pub(super), narrower
than the whole-file access the original gave them; a few items already
pub(crate) for recovery_ui or presets stayed there rather than being
narrowed, since nothing needed them tightened further.

mod.rs re-exports the same surface library_ui:: callers used before, so
lib.rs and every other caller needed no change.
2026-09-20 21:10:02 +02:00