Commit Graph
7 Commits
Author SHA1 Message Date
dtourolle df741a8a49 Let one mask be built from more than one selection, and paint into it
A mask the model draws arrives approximately right — stopping inside a
shoulder, leaking into the hair — and FR-DEV-3's edge controls move the
*whole* boundary, so no value of feather or dilation fixes two errors that
go opposite ways. What fixes them is a second selection joined to the first,
and a layer that held exactly one source had nowhere to put one. The brush
the core has had all along was reachable from no control in the application.

A layer is now an ordered list of parts. Each names a source and how it
joins the mask before it — added to it, or taken out of it — and carries its
own edge treatment, because a model's soft coverage and a stroke painted
where it stopped short do not want the same feather. Invert and opacity stay
on the layer, where the composed shader already reads them.

The sidecar grows `[part]` blocks and nothing else. A layer of one part
writes exactly the bytes it always did; a mask block with no part blocks
after it reads back as one part; and a stroke, a join or a source this build
cannot read costs that part rather than the layer. So every sidecar in every
library still parses to the edit it always was.

On the device the parts fold into the layer's one slice, so eight layers
still cost eight channels: union is a `max` blend and subtraction is the
erase blend the brush already used. A part is drawn into a scratch texture
before it is joined, and that is not incidental — an erase stroke means a
hole in *that part*, not a hole in the mask, and drawn straight onto the
accumulator it would punch through the subject underneath. A layer of one
part skips all of it and takes the path it always took.

In the interface: a part list under the selected layer with a chip saying
which way each joins, Add and Subtract beside it, a Select/Paint/Erase strip
with the brush's size, hardness and flow, and a drag on the photograph that
paints. Pressing Paint on a mask that cannot hold a stroke joins a part that
can, rather than explaining that a subject is not a brush. A whole stroke is
one step in the history.

The edge controls now shape the part that is selected rather than the layer,
which is the one behaviour change to an existing control: with a correction
selected, the feather slider softens the correction and leaves the model's
mask alone.
2026-09-07 20:00:40 +02:00
dtourolle 1e171c6d31 Let a collection be picked up, rearranged, and emptied after the fact
Collections could be made and filled and never reorganised. Nesting had
a drag; un-nesting had nothing, in either direction — "All photographs"
refused every drop, which is right for a photograph and wrong for a
collection, which has a top level to be returned to. So a collection put
inside another was in there permanently. Right-click deleted an *empty*
collection outright and refused otherwise, which is wrong in both
directions at once: destructive with no confirmation, and no way at all
to delete a collection that held anything without emptying it by hand,
child by child. And a photograph could only leave the collection the
grid was scoped to, since that is the only one a button in the header
can name — the cell's badge says a photograph is in three collections
and never which three.

Three ways in, one vocabulary:

**Hold a row.** The tree is inside a Flickable, which claims any drag
beginning inside it, so with a finger a drag on a row is a scroll until
something says otherwise. The hold is that something. It lifts the row —
drawn before anything moves, so the gesture says it has been understood
— and then what the user does decides which of two things they meant:
move, and it is a rearrangement; let go, and it is the row menu. The
same fork the grid already uses to tell hold-to-select from drag-to-file.
`decide_release` is that fork, and it is tested, because getting it
wrong one way puts a sheet over every tidied tree and the other way
makes the menu unreachable by touch.

**The row menu.** Rename, new collection inside, move to top level,
keep offline, delete. Deleting asks once when there is anything to lose
and says what survives: the photographs stay in the library, and nested
collections move up rather than going with it — which is what the
catalog does, and what a user would never assume. An empty collection
goes on the first press, because a dialogue about losing nothing is how
people learn to dismiss dialogues.

**"Collections…" on a selection.** Every collection the selection is
filed in, each with a count — "3 of 40", so nobody takes forty
photographs out of a collection thirty-seven were never in — and a way
out of any of them without navigating there first.

The long press used to open the offline question by itself. That
question is one item in this menu now: there is one hold per row, and
while it was spent on a single action nothing else the tree can do had
a touch route at all. Nothing is lost — the tray on the row keeps its
tap, and the question gains a full-width control in place of a 30px
icon in a row shorter than the touch minimum.

The row-press handler moves to `collections_ui` with the rest of what a
collection row does; it lived in `library_ui` only because it opened
that prompt.
2026-09-07 19:59:44 +02:00
dtourolle 3994caba12 Write down the develop gestures, since only their author knew them
FR-UI-4 says a gesture with no visible counterpart is a feature only its
author knows about, and the vocabulary the application actually publishes had
two sections in it — the library grid and people. Develop had none. Every one
of its gestures was documented in the comment beside the `TouchArea` that
implements it, which is where the previous sixteen were before this scanner
existed, and unreachable to anybody not reading the source.

Thirteen now carry tags: magnify by pinch or wheel, pan a magnified frame,
fit and 1:1, hold to see the original, sample a neutral, undo and redo, step
through the folder, reset one control, show or hide a mask layer, choose a
group of adjustments, and copy and paste the settings. Each has a pointer and
a touch route, so none of them is keyboard-only.

Three bindings were genuinely missing and are added here rather than merely
described. Ctrl+C and Ctrl+V for the settings clipboard, which the Settings
panel's own comment has claimed existed for as long as the panel has and
nothing bound; and [ and ] to step through the adjustment groups. The groups
are whatever the operation set declares itself to be about, so there are as
many as the pipeline has and no key can name one of them — stepping is the
binding that survives a node being added, and "everything" is part of the
cycle rather than a way out of it.

Two are described and not bound. Resetting a control from the keyboard and
toggling a mask layer from the keyboard both need a notion of which control
or layer has focus, and the generated panel has none — the rows are a model
the repeater rebuilds, and inventing a focus ring for them is a larger change
than a keyboard shortcut. Both are reachable by pointer and by finger, and
the tags say so rather than promising a key that is not there.
2026-09-06 19:01:52 +02:00
dtourolle 1c5c55b4c9 Let the photographer say which frame the burst stands for
`choose_representative` has been in the catalog since the grouping landed,
with tests behind it and nothing calling it. So the frame a folded burst drew
was always the earliest one, and the only way to disagree was to open the
group and leave it open — which is to say there was no way to disagree at all,
because a burst that stays open is a burst that was never collapsed.

The earliest frame is the right default and it is deliberately not a
judgement: nothing here scores a photograph, and FR-CULL-5 names the failure
that rule avoids. But the whole point of a burst is that one of the twelve is
better than the other eleven, and the person who knows which is the one
looking at them.

So a ring on each frame of an open group, ticked on the one the group folds
to. It is drawn only while the burst is open, because that is the one moment
the alternatives are on screen to be compared — offering the choice on a
folded burst would be asking about frames it is hiding. Bottom right, opposite
the count in the other corner, clear of the flag and the collection badge and,
deliberately, of the trash target: a slip between the ring and the fifth star
sets a rating, which is the harmless direction for an ambiguous press.

The mark stays live on the frame that already wears it. A disabled TouchArea
would let the press fall through to the cell behind it, so tapping the one
ring that is ticked would have opened the photograph — and pressing it is a
thing the user may mean anyway: it records the choice the default was making
silently, which then survives a regroup that finds an earlier frame.

Choosing repaints the badges instead of reloading the window, which is what
separates it from folding a group up. Folding changes what the grid's query
returns; this changes only which cell wears the tick, and the tick has to
leave the frame that was carrying it, so the whole window is refilled in the
one statement `sync_badges` already runs.

The gesture is documented where FR-UI-4 requires it to be documented: in a
tagged comment beside the control, which is the only copy. The gesture book,
the gesture document and the requirements matrix are regenerated from the tree
alongside it.
2026-09-06 19:01:49 +02:00
dtourolleandClaude Opus 5 b34e786f01 Give the drag a pick-up, so it stops losing to the scroll
Dragging a photograph out of the grid worked about half the time, and
nothing on the screen explained the other half.

`DragArea` and `Flickable` do arbitrate, but not evenly. The Flickable
claims any press that travels more than eight pixels along its own axis
within half a second of landing, and holds that claim until the finger
lifts. So a drag toward the sidebar only ever began two ways: a flick
sideways clean enough that the finger never wandered eight pixels
vertically, or a wait of half a second before moving at all. Both are
real gestures and neither was written down.

The wait is now the gesture, and it has a mark. The long press that
already turns on selection mode also picks the photograph up: a ring
opens around the cell and the grid stops scrolling under it, so from
that moment the drag is the only thing the finger can be doing. The cue
can only arrive after the ambiguity has passed, which is the right way
round — when the photograph lifts, dragging it works.

Two details worth naming. The hold is now armed even when selection mode
is already on; it used to be skipped there, on the grounds that there
was no mode left to switch on — but that is precisely the state a
forty-image drag starts from, so the one gesture that most needed a
pick-up was the one with none. And the ring is drawn after the cell
loop rather than on the cell: z-order inside a `for` is loop order, so a
cell grown past its bounds would stand over two neighbours and be cut
off by the other two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:20:56 +02:00
dtourolleandClaude Opus 5 b120ce48ec Float the selection bar over the grid instead of above it
Selecting the first photograph inserted a 40px row into the view's
vertical flow, so every cell in the grid moved down by it. The act of
selecting shifted the thing being selected out from under the finger —
and a second tap aimed at the neighbour landed on the row below it,
which is the worst possible response to a gesture whose whole job is to
say "this one".

The bar is a floating one now, at the foot of the view. Nothing above it
is re-laid out, so selecting changes what is drawn and never where.

The grid's viewport grows by the same 40px while the bar is there rather
than the Flickable shrinking, which is what keeps that change invisible
too: every cell stays exactly where it was and there is simply further to
scroll, so the last row can be brought clear of the bar instead of being
trapped under it.

It also swallows presses that land on it. A bar floating over the grid is
a bar a thumb can reach for and miss into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:47:20 +02:00
dtourolleandClaude Opus 5 31a3580f9d Extract the gesture vocabulary from the code that implements it
Every gesture the application has was documented in the comment beside
the `TouchArea` that implements it. Excellent comments, and unreachable
by anyone not reading the source — which is the FR-UI-4 failure in a
different costume: a gesture nobody can find is a feature only its author
knows about.

Writing them out again in a hand-kept help page is the failure this
avoids. Two descriptions of one gesture drift, and it is always the prose
that drifts: the code is exercised every time somebody uses the
application and the page is exercised never. A help screen confidently
describing a double tap the grid stopped honouring last week is worse
than no help screen — and the grid did stop honouring one, in the commit
before this.

So the comment beside the implementation stays the only copy, and a
`GESTURE:` block beside it is scanned into two artefacts: `docs/gestures.md`
for a reader, and a Rust table for the application to draw a help sheet
from. Both committed, both gated, so neither can quietly stop describing
the code.

It lives in the traceability crate because it is the same operation on
the same input — walk the tree, pull structured tags out of comments,
render, fail if the committed artefact has moved. Only the vocabulary is
new. It scans `ui` and `apps` alone: a gesture needs an interface to be
performed on, and excluding `tools` is also what stops the scanner
extracting its own worked examples as broken gestures.

Fifteen gestures so far, across the library grid and the People screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:09:49 +02:00