a2c7789007a7339e2d6dea40d589bc7fe8cf1dc9
148
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4f31123b0c |
Let the user choose which SCRFD finds their faces
faces.md §12.3 measured what the cheapest detector costs: the small faces in every group shot, and a dog embedded a dozen times. Which trade is right depends on the machine doing the sweep — a desktop left overnight and a tablet on a battery want different answers — so the detector is now a per-device setting, Fast / Balanced / Thorough on the settings page beside the indexing button, persisted with the rest of the settings file. A detector is half of a model id. Every face, marker, shard and calibration is keyed on faces.model_id precisely so that a model change is a new id and a re-index rather than a silent change under existing data, and a detector change is a model change: it decides which faces exist and where the landmarks that align them land. So each choice names its own pipeline. 500M keeps the bare "w600k_mbf" every existing library was written under, so an upgrade disturbs nothing; the others are qualified. Choosing one restarts coverage from zero under the new id, the sweep re-detects, confirmed names carry across by box overlap, and the sync shards are keyed by the same id so a peer on another setting neither adopts nor pollutes them. The library controller carries the id into the sync the same way it carries the cache budget, because the sync starts from places that have no settings in reach. All three shape-fixed exports ship — APK, Arch, Flatpak — since a tablet has no other way to obtain the one it was not installed with; the APK grows by twenty megabytes for the choice. |
||
|
|
5d175cc668 |
Let a press on the photograph reach the tool that was armed for it
The brush did nothing, and neither did three other things nobody had tried lately: clicking a subject on the photograph to select it, placing a repair, and sampling a neutral. All four are TouchAreas over the canvas, and all four sat behind the pan/zoom area, which is full-canvas and enabled for everything but a crop. It took every press in the viewport and they were never offered one. Slint hit-tests siblings front-to-back (`send_mouse_event_to_item` visits children `TraversalOrder::FrontToBack`), a TouchArea answers `GrabMouse` on any press it is enabled for, and the first grab aborts the traversal. Front means *last declared*. Each of the four carried a comment saying it sat "above the pan/zoom area so a click reaches it first" — true of the order they were written in, and backwards. Nothing about the geometry decides this, so nothing about the geometry could have fixed it. The pan area is declared first now, as the backstop it always meant to be, and the rule it leaves behind is that the general case goes above the specific ones. `GradientHandles` is the other end of that rule and is why dragging a handle has worked all along while everything between it and the pan area did not. The order is asserted in a test, because this is a fault that compiles, passes every other test, and silently removes four tools at once. |
||
|
|
0a5eab0487 |
Wire the develop panels through globals, so a second copy is one line
Every panel in the develop column declared its inputs and its callbacks and had `app.slint` bind each one to a property or a callback on the window root. That is fine while a panel is drawn once. N9 draws them a second time, in the portrait dock, and the wiring is what would have to be copied: `MaskPanel` alone ran to forty lines of forwarding, and a callback added to one copy and not the other compiles, renders, and simply does nothing on the layout nobody was looking at. So the wiring moved to Slint globals. A panel reads the global and calls the global; Rust hooks the global instead of the window; and the instantiation in the column is now the panel's name and a pair of braces — every one of the ten children of the column, with no property that differs by placement left to supply. There is a global per panel family rather than one for all of them, and the reason is an import cycle. Each panel's model struct — `ParamRow`, `MaskRow`, `HistogramView` — is declared in the panel's own file, so a single global holding `[MaskRow]` and `[ParamRow]` would have to live in a file importing `masks.slint` and `adjust.slint` while both imported the global back, which Slint rejects. Breaking that needs six model declarations relocated, which is a change to the data model and not to the plumbing this is about. A global beside the panel it serves also lets each name drop the prefix it was carrying only because the window root is one flat namespace: `root.spot-radius` is `Repair.radius`, and `root.peaking-on` is `Peaking.showing`. `session.slint` is new and holds the two facts every family needs and none of them owns: whether there is an open photograph to edit, and which mode the view is in, with the three readings of the mode derived once instead of at each of the dozen places that tested one. `ViewMode` moves there from `adjust.slint`, where it was only ever a lodger. Nothing on screen changes. What is not here: the tool rail and the status strip still take their properties at the instantiation, because they are drawn once and N9 does not copy them; the preset sheet's own state stays on the window, because the library grid opens the same sheet and a global cannot bind the window's state — which is why `Transfer.open-presets` is handled in `presets.rs`, beside the summary it already had to compute. |
||
|
|
dd14243dba |
Lay the develop view out by coordinate, so the column can sit below
Slint cannot turn a layout on its side, and that is what D-N7 asks for. So the HorizontalLayout holding the rail, the canvas and the develop column becomes a plain Rectangle and each of the three states its own x, y, width and height. With `column-below` false those come out where the layout put them to the pixel — a HorizontalLayout has no spacing or padding of its own, the rail and the column took their declared widths at the two edges, and the canvas was the only child that stretched. The alternative was the column subtree declared twice under two `if`s, which is four hundred lines of bindings copied, in a file whose own notes record a conditional child in a layout as the shape that has produced binding loops here before. The column is the same column either way: the same contents, the same Flickable, the same toggle. `panel-visible` collapses the dock's height exactly as it collapsed the column's width, so `column-width` and `dock-height` are each zero unless the column is both open and on that axis, and the canvas can subtract both without asking which case it is in. The stack inside stretches to the dock's width on its own — a layout that is the direct child of a Rectangle fills it, and the Flickable's viewport was already bound to its own width. Nothing is reflowed; N9 does that. `dock-height` is mandated in style.yaml for the reason `panel-width` beside it is, on the other axis: a dock that sizes itself to its contents is a photograph that changes height when a caption wraps. 480 until N6 measures the device. The seam follows the column round: a hairline down its left edge beside the photograph, along its top edge under it, so it stays between the two. |
||
|
|
5f0b11c1f4 |
Ask the window how tall it is, and say when the column belongs below
D-N7 puts the develop column under the photograph on a tall window, and the axis it turns on is aspect rather than width: a 960-wide portrait tablet is expanded by width and wants the dock, a 1500-wide landscape desktop is expanded by width and does not. So this cannot be folded into the layout class, and it is not remembered per class either — closing the column in landscape closes the dock in portrait, because it is the same column. `window-resized` reported width alone and now reports both, from a `shell-height` that subtracts the safe-area insets exactly as `shell-width` subtracts them: on Android the strips the status and navigation bars occupy are on the axis being measured, so the aspect of the window and the aspect of the space the interface actually gets are not the same number. `column_below` is the decision, with two thresholds rather than one. It is read on every resize event, and a single threshold means a window dragged along its own diagonal crosses it several times a second while the pointer is still down. Entering at 1.25 and leaving at 1.15 is a dead band no plausible drag re-crosses. The comment on EXPANDED_MIN_WIDTH claimed a tablet in portrait gets the compact layout. It does not — its panel is about 960 logical pixels across, which clears 820 — and that mistaken example is the one D-N2 reasoned from. Corrected in the same breath, since this is the commit that says what portrait actually changes. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
901f51e6c4 |
Point at something grey and let the pipeline work out the rest
FR-DEV-3 has asked for "white balance (temperature/tint, and picker)" since it was written, and only the first half existed. `WidgetKind::WhitePoint` was in the vocabulary and `develop::supported` answered false for it, so the node degraded to two sliders — correct behaviour that had quietly become the only behaviour. Sampling a neutral is the first move of the global tonal pass and every colour judgement afterwards is measured against where the grey was put, so guessing at two sliders until a wall stops looking green is the wrong way round. The awkward part is that a picker genuinely needs to know how far a hundred units of temperature move red against blue, and that number is declared in the node's own file. So the inversion lives in `dr_pipeline::neutral` rather than in the interface: the canvas hands over a colour, the core finds the operation that asked to be driven by a pixel and bisects its declared response until the sample comes back grey. Nothing in `ui/` names white balance, and nothing holds a second copy of a response that would be wrong the first time somebody adjusted the range. A bisection rather than a closed-form inverse because only monotonicity is part of the bargain — the expression is free to become a table tomorrow. The result is rounded to the precision the control is drawn at, which is not cosmetic: unrounded, sampling something already neutral lands a ten-thousandth off zero, and the photograph comes back modified with an undo step for a correction of nothing. On the panel side this needed one distinction the generated path was missing. `is_on_canvas` was being read as "and so the panel draws nothing for it", which is right for a crop — four edge fractions are not controls anyone drags in a list — and wrong for an eyedropper, which *writes* temperature and tint and leaves them exactly the controls a photographer reaches for next. So a sampling widget keeps its sliders and puts the affordance that arms the canvas in the group's heading, built like the reset beside it. One click, one sample, one history step: `Edit::Action` never coalesces, and there is no hover preview to fill the stack with temperatures nobody chose. Declaring the presentation also groups temperature and tint under one undo step, where they were two. That follows from what `Presentation` means and reads correctly — white balance is one decision — but it is a change, and worth saying so. |
||
|
|
2584b9ecbc |
Hold one key to see the photograph before you touched it
FR-DEV-7 asks for the current edit against the unedited original and nothing implemented it. What the develop view had was history navigation, which *changes* the edit rather than previewing against it — so the only way to look was to undo, look, and redo, and that puts two real steps on the stack at exactly the moment a photographer suspects they have overcooked a frame and is least sure of what they are doing. Holding the "Before" button, or backslash, renders the graph with every adjustment stripped and hands it straight back afterwards: the same suspend-render-restore shape the crop overlay already uses to show an uncropped frame and an export uses to suspend the zoom. Nothing is recorded, no rows are re-synced, and the photograph is still modified when the key comes up — the panel goes on describing the edit the photographer has, because only the canvas is answering a question. The framing deliberately stays on. A held comparison is a question about tone and colour, and re-cropping the canvas under someone's thumb would move the detail they are comparing; worse, the zoom is a rectangle of the *framed* image, so dropping the crop at 4× would quietly show a different part of the photograph rather than the same part unedited. What the crop took away is already compared in Compose, which shows the whole frame. Not a split screen: that halves the working image on the tablet this column was sized for, and the comparison photographers describe making is a flick back and forth rather than two pictures side by side. Press-and-hold is one gesture on a finger and on a mouse, which is what FR-DEV-3b's mapping wants, and it has no mode to be stranded in — the button reports both edges, so a press the system cancels puts the original down too. |
||
|
|
9b674a88d8 |
Let the photographer look at the pixels, and keep looking
Noise reduction and capture sharpening are judgements about individual pixels, and at a fitted view several of the file's pixels are averaged into each one on screen. The frame therefore looks cleaner and softer than it is, the photographer corrects for a softness the display invented, and over-sharpening is the documented result. Nothing in the develop view reached 1:1 at all: the wheel and the pinch zoom by ratios, the double tap dropped straight to fit, and the only readout was a percentage nobody was aiming at. So the double tap now does what FR-UI-4 always said it did — toggle fit and 1:1 — and the zoom readout, which used to be a dead "Fit" button on a fitted photograph, becomes the way in when there is nothing to clear. Z does the same from the keyboard, and the back gesture goes out through the same toggle so putting the magnifier down really puts it down. 1:1 is computed from the file's own resolution against the viewport rather than fixed at some multiple, because that is the only version of it that answers the question the two detail controls are asking. The point being inspected and whether the magnifier is up are held beside the session rather than in it, on the argument focus peaking already makes: a session is one photograph and this is a way of looking at a folder of them. Checking the same eye across forty portraits is the reason to reach 1:1 in the first place, and a magnification that reset with the session would make that forty zooms and forty pans instead of forty keystrokes. It stays a viewing state throughout — the view is kept out of `is_active`, `output_size`, the sidecar and the export, and an export still suspends it — so none of this reaches the file. |
||
|
|
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. |
||
|
|
68ebf5d78b |
Let a mask start from a tone or a colour, not only a shape
Every local adjustment began from a shape: painted, drawn with a handle, or found by a model. So the only way to hold back a sky was to draw a line near where it ended, and the only way to warm skin was to paint round it — both of which put the edit's edge where the photographer put a gesture rather than where the picture changes. A gradient across a treeline halos, and an adjustment traced round a face stops on the outline of a hand. MaskSource grows two variants that select by what a pixel *is*. Luminance carries two bounds on the perceptual tone scale plus a softness; Colour carries an arc of hue, a range of chroma, and one softness for every edge of both. Five floats and three, so they diff, sync and merge per field under FR-NC-9 exactly as a gradient's geometry does — the property a stored raster has none of, and the reason the model's coverage had to sit beside its source rather than inside it. The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the demosaiced source as a sixth binding and two new modes read it: decode, balance, pull a clipped photosite back to neutral, apply the camera matrix, then weigh the band. Nothing crosses to the CPU but the numbers and the matrix, and each mask texel averages its own footprint in the source, so a band lands on the tone an area is rather than on whichever texel a proxy grid happened to land on. The photograph it measures is the one the camera recorded, before this edit. A band over the edited result would slide out from under the edit as the edit was made — raising the highlights would change which pixels counted as highlights, and the slider would chase its own mask. Feather, falloff and morphology stay off a range layer, which is what `shapeable` already meant. All three are functions of the signed distance from a boundary, and a range has no boundary to be at a distance from; its edge is the softness of its own band, in the band's units. Offering them would be four controls that move and change nothing. |
||
|
|
59917c5183 |
Call the tool Compose, since that is what its panel says
Benchmarks / CPU and I/O (per commit) (push) Successful in 14m35s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 1h20m20s
Build and test / Layer separation (push) Successful in 51s
Traceability / Requirement traces (push) Successful in 2m8s
🐳 Android image / Build and push (push) Successful in 5s
Build and test / android-image (push) Successful in 5s
Build and test / Android (aarch64) (push) Successful in 1h5m51s
The rail entry read "Crop" while the panel it opens is headed COMPOSE and the button leaving it said "Done Cropping". One mode, three names, and the odd one out was named after a single control rather than after the decision — which is what made cropping look like a category of its own in the first place. Straightening, the quarter turns and the flips are already in that panel, and perspective will be. `ViewMode.crop` keeps its name: it identifies a canvas interaction, which is exactly what it still is. Found by looking at the running application rather than by reading, which is also how the two halves of this were noticed to disagree at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6a97fdf6f9 |
Put the adjustment groups in the rail where a finger is driving
Reported from the tablet: the tool rail is very useful there, and the same interface under a mouse and keyboard is not. That is `ui-navigation.md` D-N2's central assumption failing in use, and the interesting part is which half of it failed. D-N2 was right that platform is the wrong axis and width is the wrong axis: a tablet in landscape wants what a desktop wants, and a desktop window dragged narrow wants what a small screen wants. `apply_layout_class` still decides the layout class from the window and nothing here changes that. What D-N2 got wrong is the sentence "touch changes hit regions, not layout" — it identified input as the real difference between the targets and then assumed that difference could never reach the layout. Two controls answer one question — which group of adjustments am I looking at — and neither is better in general. A horizontal strip above the column is one gesture to a target the eye has already found, and it pans when the operation set is rich, so a group can sit off the end with nothing saying so: a pointer user tolerates that, a finger user never discovers it. The same list down the rail is every entry visible at once, each finger-sized, on the edge of the screen the hand is already holding, and it costs no width because the rail is already there. So `ToolRail` grows a second section, and `GroupStrip` stands down when it does. The two are never both on screen, which is why they can share `adjust-tab-picked`: Rust is not told which was pressed and has no reason to want to. Mode and group stay independent axes as N1 requires — one entry lit in each section, and choosing a group while a tool is held still filters without putting the tool down. They stay drawn differently, which N1 also required. The tools fill with `active-dim` and invert their ink; the groups take a bar down the leading edge — the strip's underline turned ninety degrees — so a lit entry says which kind of state it is without the reader having to remember which section it was in. The rule between the sections is the second signal. The rail scrolls now. Its own note argued against a Flickable because "this list is four entries written in this file"; with the groups in it the list comes from the operation set, which is exactly the "something the user's data decides" that note excluded this control from. The axis is input, and it is a preference because the automatic answer is a guess that cannot be made reliable. Neither platform can be asked what the user is holding: an Android tablet in a keyboard case is being driven like a desktop, and a touchscreen laptop is whichever its owner says. `dr_plat::is_touch_first` reports the usual case per platform, and `GroupNavigation` lets it be overridden. Settings names what Automatic resolves to on this device rather than leaving it to be found by pressing. D-N6 records the reversal beside the decision it reverses, including the half that still stands and the question it opens: whether Local is a mode at all, or a scope that would collapse the two sections into one list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c4ddcbe0f7 |
Look the lens up and say plainly whether one was found
`dr-lens` has held a complete Lensfun lookup — distortion, TCA and vignetting coefficients from a lens name, a focal length and an aperture — with no dependents anywhere in the workspace. The three corrections it feeds now exist in the graph, so this connects the two and finishes the chain. The coefficient structs stay duplicated. `dr-pipeline` is organised around having no dependencies so its codegen is testable without a device or a database (ARCH §6.5a), and `dr-lens` carries an XML parser and 5.5 MB of profile data. Neither crate can convert to the other, so the conversion goes above both, in `develop.rs`, which is the only place that sees them together. Both traits grow the same defaulted door. The optical corrections do not sit on the same side of the fetch — distortion and CA rewrite coordinates and are `Warp`s, vignetting applies a gain to the pixel already there and is an ordinary node — and fanning a profile out by which trait each happens to implement would make the caller reason about that distinction. Each correction takes its own share of the whole profile instead, and `set_lens_profile` walks both lists identically. The lookup happens in `set_source_metadata` rather than in its caller, because that is the one place a session is told which file it came from. Doing it there makes it unforgettable, in the shape `FilmRebake` already uses for the other derived thing — and, more to the point, makes *clearing* unforgettable: a session that opened a second photograph while still holding the first one's profile would correct it for the wrong optics, invisibly, in a way that looks exactly like the lens. It needs the whole shot and not just a name. Distortion is interpolated across a zoom's focal range and vignetting depends strongly on aperture — a fast prime can be two stops down in the corners wide open and clean by f/8 — so a lookup missing either returns coefficients measured for a shot nobody took. Missing any of the three refuses rather than guesses. A profile is derived, not persisted: it comes from the file's EXIF and a database, so it is not a parameter, not in the sidecar and not undoable. What is an edit is the manual trim beside it, which each correction composes with the measurement — so a photographer can lean on it, override it, or work without one. `InfoPanel` gains a lens line, and it distinguishes three cases rather than two. `dr-lens` states the rule it exists for: an automatic correction that silently did nothing is worse than one the user can see is unavailable. A session with no header draws nothing, a header naming no lens reads "Lens not recorded", and a lens the database has never heard of reads "· no profile". Collapsing the last two would send somebody hunting for a profile that was never missing — which, for third-party and adapted glass, is the ordinary case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8e7b1350bf |
Name the frame's category for the decision, not the maths
`Attribute::Geometry` becomes `Attribute::Compose`, and `film_sim` moves from `[tone, colour]` to `[effect]`. Two categories were doing the wrong job. "Geometry" describes what crop, straighten and the quarter turns do to coordinates — but it describes lens distortion correction exactly as well, and that is not a compositional choice at all. Naming the attribute for the photographer's decision is what separates it from `Optics`: one is what the lens did, the other is what they chose. The maths the two have in common is not the thing worth filing them under. A film stock declared both `tone` and `colour`, so "Kodachrome" appeared in the Light group beside exposure and again in Colour beside white balance — two places, neither of which is where anyone looks for it. It is neither: `Effect` is defined in this same file as "applied rather than corrected — a look, not a fix", which is what a stock is. That it moves tone and colour is true of every look, and is not what the attribute is for. `from_name` still accepts "geometry" on the way in. That string is persisted in `develop.copy_attributes`, and an entry it fails to parse is not an error — `presets::scope_for` logs it and drops it — so without the alias an existing settings file would have quietly narrowed what a paste carries. `name` writes the current spelling, so the file migrates itself the first time it is saved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8bf5e13faf |
Centre the photo roll on the frame it opens with
The roll brought the open photograph into view by the shortest move, which is right for stepping along it and wrong for the first look: a frame near either end of the loaded window arrived hard against an edge, with nothing on that side to give it any context. It now centres on the first settle of a develop session and steps minimally after that. A one-shot request that the strip itself clears -- the only thing that knows the request has been honoured is the code honouring it -- rather than something recomputed on creation, because the strip is created far more often than a session begins: leaving develop for Settings and coming back rebuilds it, and re-centring then would undo a roll the user had scrolled by hand. Raised on the two ways into develop from the grid, and not on a pick along the roll, which is a step within a session rather than the start of one. Centring is clamped to the ends: the third photograph of a window cannot be centred without scrolling empty space in beside it, and a strip that begins with a gap reads as broken rather than as centred. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
eed27eb36d |
Keep the grid's place when another screen covers it
Opening Settings, Import or People and coming back landed at the top of the library however deep in it you had been. The grid is gated on an `if` in the markup, so every route away from it destroys the subtree and rebuilds it. A Flickable being destroyed passes its viewport through zero on the way out, and that reaches `on_library_scrolled` looking exactly like the user having flung the grid to the top. The handler already guarded against it -- but on `show-library`, which means "the library rather than develop" and stays true while any of those four screens replaces the window. So the guard covered the develop route and none of the other three: `resume_at` was overwritten with 0 on the way out, and the position was gone before anything could restore it. The condition the `if` is actually spelled with is now computed once, in `app.slint`, and Rust reads that. The two cannot drift apart again because there is only one of them. That fixes the overwrite. The second half is that nothing replayed the position on the way back in: `on_back_to_library` does it by hand, and Settings, Import, People and the launch screen do not go through it. Rather than teaching three more modules to call it, `scroll-to` is now kept current on every scroll. It is read by `seek()`, which runs on a token change and on `init`, so writing it without bumping the token cannot move the grid on screen -- and is exactly what the next grid reads when it is built. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4dc954f01a |
Take the photo roll's grab band off the buttons that end a mode
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 58s
Build and test / Layer separation (push) Successful in 45s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Failing after 1m0s
Build and test / Android (aarch64) (push) Failing after 31s
"Done Cropping", "Done Repairing", "Done Masking" and "Fit" float over the foot of the canvas. So does the photo roll's swipe handler, and a gesture handler is not a layout box — it is an input surface. A press inside one is delayed, then offered to that handler's own children and to nothing else: `input_event_filter_before_children` returns `DelayForwarding`, which aborts the hit-test traversal outright, and the replay afterwards visits only the handler's subtree. Everything behind it is never asked, hover included. The band was `strip-height + reach` — 136px along the bottom — whether the roll was out or away. So the button that ends a mode was drawn, was lit, and did nothing for as long as a library was open, which is the whole time anybody is developing from one. The tool rail kept working because it is a sibling of the canvas rather than behind the roll, which is exactly why this looked like two dead buttons rather than a dead region. The band now goes where the roll goes. The handler carries the strip instead of standing still while the strip animates inside it: closed, only `reach` is on screen and the rest hangs below the window where nothing can press it; open, it still covers the thumbnails, which is what lets a swipe down anywhere across them put the roll away. The 180ms travel moved from the strip onto the handler, so the drawn positions in both states are what they were. The controls are then positioned against that band rather than against the bottom of the canvas, and ride up with the strip when it comes out. Reordering them in front of the roll would have been the other fix, and it is the wrong one — the band would become the thing that cannot be reached, and a gesture nobody can start is worse than a button with a second way out. `roll-strip` and `roll-reach` are tokens now, because two files have to agree on where that band is for either of them to keep out of it. The bottom of the photograph comes back with it: the crop's lower handles and a repair placed near the bottom edge were inside the same 136px and had the same fault. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d48e9f6033 |
Open the catalog on a worker, so a launch is not a page-by-page read
The second thing standing between `android_main` and the first `poll_events`, and the one that grows with the library rather than with the APK. `library_ui::open` called `show_catalog_now`, which called `Catalog::open_verified`. That runs `PRAGMA quick_check`, which reads every page of the database, and then `Catalog::open`, which takes a full SQLite backup of the file before a migration and rewrites its structure afterwards. On a 50,000-image library that is tens of megabytes of I/O on a tablet's flash, and it happened before the window had painted anything — so on Android it was counted against the five seconds the input dispatcher allows, and on the desktop it was a launch that sat on a blank window. `dr_catalog::recovery`'s own module documentation says the check is affordable "at startup, where a failure has a user in front of it who can answer a question". That was the intent and it was not true: there was no interface yet in which to ask. Now there is, because the open happens on a worker and the answer arrives on a channel drained by a timer — the same shape the scan, the thumbnails and the login already use. The gate the synchronous call provided is kept, and is the reason the scan moved with it. `Catalog::open` succeeds on a damaged file whose header survived, so a scan running beside an unanswered recovery question writes ETags and image rows into damaged pages and turns a catalog that had a backup into one where the backup is the only copy left. So the scan now starts from the drain, on the two answers that permit it, and not at all on `Corrupt`. `library-scanning` stays true throughout, which hides the Rescan button and stops the gate being merely advisory. What the user sees while it runs is a third empty state. The grid already refused to conflate "still scanning" with "scanned, found nothing"; "opening the library" is a third answer and it gets its own sentence, because a grid saying "Scanning…" while nothing is on the network is the same kind of lie the other two were separated to avoid. `show_catalog_now` stays, unchanged and blocking, for `recovery_ui`. That call site has the event loop running, has just replaced the file under a `forget_catalog`, and has `recovery-busy` on screen — the same reasoning `recovery_ui::answer` already gives for doing its file copy in place. The part both paths share is now `adopt_catalog`. One consequence worth naming: the cache-usage figure on the settings page was read at startup from a catalog that is no longer open by then. It moves to the page's `on_open` closure, beside the face coverage, which is read there for exactly the same reason — it is only ever looked at while that page is on screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4efab496c2 |
Put the refinement on a slider, per layer
Benchmarks / CPU and I/O (per commit) (push) Successful in 3m27s
Benchmarks / Frame budget (on demand) (push) Skipped
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Failing after 1h14m30s
Build and test / Layer separation (push) Successful in 43s
Traceability / Requirement traces (push) Failing after 46s
Build and test / Android (aarch64) (push) Successful in 1h8m56s
The evidence was being gathered and spent immediately at one strictness nobody could see or change. This makes it a control. `MaskLayer::refine` is shaping, like the feather, and it lives on the layer for the layer's reason: two layers may sit on the same category and want different amounts of it, and the model ran once for both. ## What the segmentation now stores `CategorySummary` keeps the **coarse** mask and the `Refinement` beside it, rather than a refined mask. That is what gives the control an off position that is bit-for-bit the model's own weighting, and what stops a strictness change from needing the model again. `category_mask_at` borrows at zero and wherever no refinement could be fitted, so a layer nobody has touched costs nothing over the old path. The fit moved to the far side of the orientation permutation. The verdict is a per-pixel field over the same grid as the mask it gates, so fitting it upright would mean permuting a proxy-sized buffer afterwards to match — a second rotation, and a second chance to get one wrong. One logit cell is the same number of pixels either way: the letterbox scales by the longer edge and a permutation does not change which edge that is. The two frames became a `Frames` struct rather than six parameters. This function reads the picture twice for opposite purposes — the model needs it upright or it recognises far less, the refinement needs the sensor's grid — and a transposed pair produces a plausible mask over slightly the wrong pixels, which is the failure this module is most prone to. ## It rebuilds the field, and the signature says so `refine` is mixed into `subject_signature`. Unlike a feather, which is read off a field that is already correct, this changes which pixels are in the mask at all — so it changes the coverage the field is measured from. Omitting it is the bug where the slider moves and nothing happens until some unrelated control invalidates the cache. That puts it in the same cost class as a close or an open, which is why the row takes `SliderRow::changed` — already once-per-gesture, since that row takes `SliderTrack`'s `committed` internally — rather than a live stream. The slider is offered only where there is something to move: a category source, *and* a refinement the frame actually gave enough to fit. A control that moves and does nothing is worse than an absent one. ## Two defaults that are deliberately different A layer added from the panel starts at 4.0, because a category's edges are twenty proxy pixels wide before anything is done to them and a photographer adding a sky mask wants the sky rather than the sky plus every chimney in it. A layer read from a sidecar with no `refine` key starts at **zero**. A file written before this control existed has to render as it did then, and a default of 4 on absence would quietly re-grade every stored category mask in the catalogue. `a_categorys_refine_strictness_survives_and_defaults_off` holds both halves, and `an_out_of_range_refine_is_clamped` holds the file to the scale — past the top of it every colour fails and the mask deletes itself, which reads as lost work rather than as a bad file. `MAX_REFINE` is dr-pipeline's own constant mirroring `dr_segment::STRICTNESS_MAX`, following `Falloff` and `Morphology`: this crate holds the description of an edit and must not depend on the crate that runs a model. dr-ui is where the two meet, and the only place that converts. Verified: fmt clean, clippy --workspace -D warnings clean, 488 dr-pipeline and 60 dr-segment tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
328fda6f7c |
Merge: mask a whole category, not just one instance
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # apps/darkroom-desktop/Cargo.toml # docs/traceability.md |
||
|
|
80f0a0eeac |
Merge branch 'master' into feat/library-toolbar
# Conflicts: # docs/gestures.md # docs/traceability.md |
||
|
|
7f350e8c4c |
Offer the categories in the masks panel
Everything before this was reachable only from an example that writes PPMs. This is the part a photographer can touch: a list under the subjects, click one, get a mask layer for every pixel of that category. ## Under the subjects, and the order is the argument Clicking the photograph is how a local adjustment usually starts, so the things the model *found* come first. A category is the move you reach for deliberately — grade the sky, not this one bird — and putting it second says so without a word of explanation. Coverage is shown for the same reason a subject's score is: it tells the photographer whether a category is worth a click before they spend one finding out. ## Not a new tab, which is what was asked for A develop tab is derived from `Attribute`, not declared — the tabs exist because operations claim an attribute, and no amount of Slint adds one. A seventh attribute would have meant duplicating every adjustment once per category, and the combinatorics get silly by the third. Reached through masks instead, a category composes with every adjustment that already exists, and inherits feather and falloff rather than needing its own. What was described as "per-category sliders with feather and decay" is exactly what this is; only the door is different. ## Verified as far as it can be here Compiles, populates, round-trips, 560 dr-ui tests green. **Not clicked** — synthetic input is blocked on this setup, so how it looks and feels is unverified and wants a human at the window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d70dcf78d1 |
Merge: recover a damaged catalog, and capture a crash locally
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
700e592b48 |
Correct the breakpoint note, which still counted six buttons
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
c7e1822f77 |
Put the two collection actions in the collections panel
"Keep offline" and "Change library" were buttons in the library header, in a row that is otherwise entirely library-wide. Both refer to the tree instead. "Keep offline" could only ever mean the scoped collection, while sitting nowhere near the tree that says which that is — and while the sidebar already offered the same question twice, on each row's tray and on a held row. It now sits under the tree, in the panel whose selection decides what it acts on, in the same place and shape the trash already gives Restore and Empty. It stays a labelled control rather than being dropped for the tray: a 26px row's tray is a small thing to hit, and "Kept offline" spelt out for the collection you are looking at is the discoverable version. "Change library" was wedged between Sync and Rescan, two buttons that act on the library you already have. Reading it as one of that group is a way to lose a scan by aiming badly. It is now the last thing in the panel, under the tree it replaces wholesale, behind a rule. On a tablet both are one tap further away, behind the sidebar toggle that leads the header. That is the panel a user is already in when they scope the grid to a collection, so it is where they are when either of these becomes the thing they want. The grid keeps `pin-done`/`pin-total` and its progress bar: the transfer is worth reporting wherever it was started from, including a row the grid is not scoped to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8510a2f6a7 |
Give the selection one bar instead of two ends of the window
"What can I do with these twelve photographs?" was answered in two places. Six buttons in the header — Add to collection, Keywords, Presets, Paste to N, Export N, Remove from collection — and four more on the floating bar at the foot of the grid, beside the count that says what they would act on. The header half was the worse of the two. Those six appeared and disappeared from the middle of the row as photographs were picked, so Sync, Settings and everything beside them slid several hundred pixels sideways at the exact moment a hand was already travelling toward one. On a tablet the header scrolls sideways, so they were often not on screen at all. All six move to the bar. It now reads left to right as shaping the selection — Clear, Select all, Select to… — then acting on it, with a gap between the two thoughts. The header keeps only what belongs to the library, and changes only when the library does. Two consequences worth stating. The bar holds ten controls in the worst case, so it scrolls sideways like every other row in this view, for the reason set out on the header's Flickable: a layout given less width than its children need overruns rather than shrinking, and the buttons past the edge are simply gone. And the bar now stays up for a running export whatever the selection has since become, because Cancel export lived on a button that used to have its own `|| exporting` escape hatch — the grid's viewport inset follows the same condition so the last row of thumbnails is never trapped underneath. `settings-summary` went with them: threaded from the window into the grid and into HeaderActions, and never once drawn. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8eeb9ba0f6 |
Offer the backup, and then the rebuild, when the index turns out to be damaged
NFR-R6 asks for an integrity check at startup and two offers behind it, and none of it existed. `PRAGMA integrity_check` appeared nowhere in the tree, `Catalog::open` was `open` → `configure` → `migrate` → `backfill` and nothing else, and corruption therefore surfaced as whatever rusqlite error the first unlucky query happened to produce — "database disk image is malformed" attached to a thumbnail refresh, elided into a 34px banner, over an empty grid saying "No images found · Check the library folder". Two messages that disagreed, and no way forward but deleting catalog.sqlite by hand. The property that makes the second offer real was already here and load- bearing: the catalog is an index, not a source of truth, rebuildable from sources plus sidecars (invariant §5.2.4, cited by schema.rs, trash.rs and lib.rs). And sync.rs already knew how to take a coherent snapshot of a WAL database. What was missing was the check, the type, and the conversation. Four pieces: **The type.** `CatalogError::Corrupt`, and — the part that makes it worth having — a hand-written `From<rusqlite::Error>` that classifies rather than wraps. `SQLITE_CORRUPT` and `SQLITE_NOTADB` become `Corrupt` wherever they arise, so a background job that trips over the damage first reports the same thing the startup check would have. `SQLITE_IOERR` and `SQLITE_BUSY` deliberately do not: a dropped network mount is a different problem, and telling someone to rebuild their index would be a wrong answer delivered confidently. **The check.** `Catalog::open_verified`, `quick_check` before the open rather than after, because opening runs migrations and a damaged catalog with an intact header would otherwise have structure rewritten on top of structure that is already wrong. Bound to `open_verified` and not to `open`: the check reads every page, which is affordable once at startup where a user can answer a question, and not affordable on the dozens of opens a session's background tasks make. **The backup.** NFR-R2's second clause, taken between `configure` and `migrate` in `Catalog::open`. A migration is the one routine operation that rewrites table structure, so it is the likeliest way this file becomes unreadable, and it is the last moment the pre-migration state exists to be copied. Three generations, through SQLite's backup API after a TRUNCATE checkpoint — never `fs::copy`, which on a WAL database backs up a state older than the catalog and possibly torn. A failure to take the copy is logged, not raised: a full disk must not be what makes a library unopenable. **The conversation.** The first line of the dialogue is that the photographs and the edits are safe, before the diagnosis, because that is the question the user is actually asking. Then the two offers, which are *not* interchangeable and are not presented as if they were: a restore keeps collections, and a rebuild cannot, because a manual collection is a set of images assembled by hand and nothing in the filesystem records it (docs/catalog.md §8.1). The labels say so, and the rebuild does not take the affirmative styling while a restore is on the table. One thing that is a fix rather than a feature: `show_catalog_now` now gates the scan. `Catalog::open` succeeds on a file whose header survived, so the scan that used to start immediately afterwards would write folder ETags and image rows into damaged pages in the seconds while the user was still reading the question — turning a file that had a backup into one where the backup is the only copy left. Restore also deletes the damaged catalog's `-wal` and `-shm`. That step is easy to leave out and fatal to leave out: a journal belonging to the old file, sitting beside the new one under the same name, is replayed into it on the next open. That is not a restore, it is a fresh corruption with the evidence gone. Tested by corrupting a fixture catalog — 500 images and a collection, then every page past the second overwritten — and driving both branches. The restore is asserted on the collection, because a collection is precisely what distinguishes the two paths; the rebuild on the damaged file being kept and the next open producing an empty catalog at the current schema. Plus the `SQLITE_NOTADB` presentation, a damaged backup being refused rather than installed, and a v1 catalog whose pre-migration backup comes back reading v1 rather than v11. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ef07e6ca3e |
Give the canvas tools a rail of their own, and the column one width
Build and test / Desktop (Linux) (push) Failing after 1h14m38s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 16m30s
Build and test / android-image (push) Successful in 16m31s
Traceability / Requirement traces (push) Successful in 1m47s
Build and test / Android (aarch64) (push) Successful in 1h0m21s
Crop, Local and Repair were chips at the head of the develop column, sharing a row with the adjustment groups and told apart from them by the shape of their highlight. Three things followed from that, and only the last is cosmetic: the column closes, so the way out of a mode went away with the way in — hence the duplicate "Done Cropping" over the canvas; the chips are generated from the operation set, so the widest thing in the sidebar was a row nobody had chosen the contents of; and a mode and a filter are different kinds of state wearing one control. They are a fixed 60px rail down the left now, generated from a single table in toolrail.slint. A tool is one row of it plus a drawing plus a ViewMode variant; nothing in app.slint is touched to add one. What is left of the strip is the group filters, so it is GroupStrip. The column stops measuring itself. Every panel published a content-width and declared it as min-width, and the column took the largest — which spent the photograph's pixels on whatever happened to be widest, and moved the image sideways when switching tools swapped one set of panels for another. It is panel-width now, one number in style.yaml. That number is 360 and it is measured, not picked: the contents report a minimum of 344 in every mode, and they do not compress below it because a Text that does not elide reports the same minimum as preferred. 320 was tried and sliced Paste down the middle. The Flickable's viewport is floored at the layout's minimum rather than its preferred width for the same reason — content that is never told how much room it has cannot adapt to having less. Removing the eight content-width declarations repairs three comments an earlier edit had spliced sentences into. The raw histogram's note on keeping its hint short is rewritten rather than dropped: an over-long hint no longer widens the column, it pushes the column's minimum past the width it has and clips the panel, which makes that constraint sharper rather than obsolete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f41edc03ff |
Merge master into wave-2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # docs/traceability.md |
||
|
|
1cd5ab6815 |
Put the gesture reference in the application
The document the previous commit generates is for somebody reading the repository. The person who needs it most is holding a tablet, has just discovered that a hold does something, and has nowhere to ask what else does. So the same scan writes a table the application draws: a "Gestures" button beside Settings, a sheet with the same scrim and dismissal as the ones that file and name, and every gesture grouped by where it applies with its touch, pointer and keyboard routes side by side. Not the `why` — that is the argument for the design and belongs in the document; on a phone-sized card it would bury the one line the sheet was opened to read. The sheet's file knows nothing about what a gesture is. It draws the rows it is handed, and the rows come from the generated table, because a help screen with its text typed into it is a second description of one behaviour — and the second description is always the one that goes stale. The commit before this deleted a gesture; a hand-kept sheet would still be describing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4b212089d2 |
Merge master into wave-2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # docs/traceability.md |
||
|
|
df90dd95a2 |
Merge: a histogram that reads the sensor, beside the one that reads the frame
FR-CULL-3's other two bullets. What existed was a display histogram tagged FR-DSP-7, counting AdjustPass's 8-bit output with r == 255 clipping counters -- it says a highlight is gone precisely where this requirement needs it to say the highlight is recoverable. The new reduction runs over the demosaiced scene-linear texture on a stops-below-saturation axis: camera-native, unbalanced, unmatrixed, uncurved, normalised by the sensor's own black and white levels, so 1.0 is saturation by construction. Four series, and the fourth is the brightest channel rather than luma, because a weighted sum of unbalanced values is a number about nothing. Cached per photograph, not per frame: nothing downstream of the demosaic can move a count. Both readings are legitimate and answer different questions, so the panel offers a choice rather than replacing one with the other. ARCH 5.5 is amended to match. It specified a pre-demosaic reduction; retaining the CFA samples costs 48 MB at 24 MP and 120 MB at 60 MP resident on every photograph opened, whether or not anyone looks at the histogram, on the platform ARCH 6.2 exists for. The spec now records two reductions, why the more complete one was not worth its cost, and what the cheaper one cannot answer: it counts pixels not photosites, it cannot see above white, and it is measured after the CFA pattern is gone. Verified: clippy -D warnings clean, 98 dr-gpu tests, 556 dr-ui tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
20c368d3fc |
Give each touch gesture one meaning, and give tap-to-open back
I broke opening a photograph. The dwell added in "Tell a tap on a photograph from a hand going past" required a finger to stay down 120 ms, and a deliberate tap is routinely quicker than that — so the grid stopped opening anything. Duration was the wrong discriminator: a tap and a brush are the same length. **Travel is what separates them, and a graze is by definition a moving contact.** A press now records where it landed and the release compares: within 12px it is a tap, beyond that the hand was going somewhere else. No dwell, so no deliberate tap can be refused, and the rule is the same for a finger and a mouse — one rule instead of two, and the `touch` argument the dwell needed goes away with it. Two real conflicts went with it, because a gesture set that overlaps itself is unlearnable however each half is documented. **A drag was also a hold.** Grabbing a cell and moving inside 450 ms left the hold timer armed underneath the drag, so it fired mid-gesture and put the grid into selection mode nobody asked for — the drag finished into a mode that changed what every later tap meant. Starting a drag now cancels it, exactly as a pinch already did. **A double tap was also a range.** In selection mode two taps on one cell selected everything back to where selecting began: no visible state, no warning, from a thing a hand does by accident. "Select to…" does that job and announces itself first, so the double tap is gone and two taps are now two toggles that land where they started. `extend_to_row` went with it — a second range implementation that only the double tap reached, where every other range goes through `apply_press`. The resulting vocabulary, one meaning each: tap opens, tap-and-slide does nothing, hold starts selecting, drag files, two fingers resize, and while selecting a tap only ever toggles. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4b6c110816 |
Count the sensor's own numbers, so a cull can see headroom the render hides
FR-CULL-3's remaining two bullets. What existed was a *display* histogram tagged FR-DSP-7: it binds AdjustPass's Rgba8Unorm output, recovers an 8-bit code value, and counts clipping as `r == 255`. Its own documentation says a clipped bin means "a highlight that is actually gone rather than one the transform might still recover", which is the opposite of what a culling decision needs. FR-CULL-3 asks for the histogram of the sensor data, on the explicit grounds that a rendered image "systematically lies about what is recoverable in the raw", and a readout that measures the render cannot answer that however it is presented. So this is a second instrument beside the first rather than a setting on it. Both are true; they are true about different things; the panel offers both behind a chip row and the words travel with the numbers, because a raw saturation figure drawn under a heading saying Highlights would be mislabelled exactly where the difference matters. **What is reduced over, and what it cost to decide.** ARCH §5.5 specified the pre-demosaic CFA samples. This reduces over the demosaiced scene-linear texture instead, and §5.5 is amended to record the choice rather than let the specification and the code disagree in silence. The texture is camera-native — unbalanced, unmatrixed, uncurved — and normalised by the sensor's own black and white levels, so 1.0 is saturation by construction and the distribution below it is the headroom question with no calibration to carry. Retaining the CFA samples would mean keeping the packed u32 buffer Demosaicer::run currently drops: 48 MB at 24 MP, 120 MB at 60 MP, resident per open photograph whether or not anyone looks at the histogram, on a platform §6.2 exists because memory is scarce on. Three things it therefore cannot say, written into the module docs and into §5.5 rather than left to be discovered: it counts pixels not photosites, so a saturated site drags its interpolated neighbours up and per-channel clipping is smeared by about a demosaic kernel; it cannot see above white, because demosaic.wgsl clamps each photosite at 1.0 for its own good reasons (a Canon 6D reads to 16383 against a declared 15070) so "at saturation" and "a stop past it" share a bin; and it is measured after the CFA pattern is gone, so it can name which colour clipped in the reconstructed image but not which photosite went first. The axis is stops below saturation, 16 bins per stop over 256 bins — the same bin count the display reduction uses, so the fold into drawable columns is shared and a divergence between the two plots would have to be deliberate. A linear axis spends half its width on the top stop, which is why nobody has ever drawn a useful linear raw histogram. The fourth series is the brightest channel rather than luma: these values are unbalanced, so any weighted sum of them is a number about nothing, and the brightest channel is the one that saturates first and so the one the headroom question is actually about. It is a property of the file and not of the render, which has two consequences. It is computed once per photograph and cached — nothing downstream of the demosaic can move a count in it — so a cull does not pay the display histogram's per-frame cost three thousand times. And it describes the whole frame rather than the visible region, deliberately opposite to DevelopSession::histogram: a crop changes what is on screen and changes nothing about what the sensor recorded. Tags are on the reduction, the type, its constructor and the presentation arithmetic, each of which has a test that fails if the behaviour goes. The Slint panel and the push from lib.rs keep their reasoning as prose: nothing asserts them, and a tag would claim coverage the assertions are not making. |
||
|
|
e5db849f04 |
Mark a burst in the grid, and let it be folded away
The counterpart to the grouping: where the signatures come from, and how a group reaches a cell. Signatures are computed from the 256px thumbnails dr-thumbs already holds -- vastly more resolution than a 9x8 reduction can use -- so a library that has been browsed, or that has synced somebody else's shards, has already paid for them and no RAW is decoded for this. The consequence is stated rather than hidden: an image with no thumbnail gets no signature and never joins a burst. That is self-correcting, and it is why the pass runs when the thumbnail sweep finishes rather than on a timer. Nothing happens at import and nothing happens at query time. The mark is drawn as a child of the cell's TouchArea, for the same reason the star strip is: a click on it must not also reach `cell-clicked` and throw the user into develop, and children are hit-tested before the element they sit in. It is never hidden on hover the way the stars are -- a collapsed burst stands in for frames that are not on screen, and something has to say so whether or not a pointer is nearby. Folding changes what the grid's *query* returns rather than what its cells draw, because the grid is a window over an ordered query and the frames a fold hides are mostly not loaded. So the predicate joins VISIBLE in every query that lists or counts cells -- the window, the header's count, the run a shift-click resolves, and the ordinal a scrub lands on -- under the discipline VISIBLE's own comment sets out: present in four places of five is worse than absent, because the counts disagree with the cells and neither looks wrong on its own. There is a test for exactly that. `the_window_read_walks_the_ordering_index` now includes the burst clause. It asserts on the query plan while holding its own copy of the query, so left alone it would have gone on reporting green against a query the grid no longer runs. If the clause costs `images_grid_order` and puts the sort back, that fails here rather than becoming jitter someone measures in six months. The pass keeps its own drain timer in a thread-local instead of taking fields on the library controller, so everything the feature needs to run lives in one file and the screen that starts it holds nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d0b671d4db |
Put the grouping dials where the regrouping is
The merge probability was `dr_face`'s constant and the smallest group was a bare `< 2` in the clustering pass. Both were tuned on one library — 1,813 faces of one photographer's family — and the quantity they optimise is a property of the population, not of the model. A household at close family resemblance and two thousand strangers at a wedding want different answers, and neither of them is the reference library. The doc comment already conceded the point and pointed at `face_index --tune`; a photographer does not have a terminal. So they are `FaceSettings` now, saved per device beside the cache budgets and edited from the People screen — beside the Regroup button that applies them and the rail that shows what they did, because a value changed three screens away from its effect is one nobody can tune. Moving them is safe by construction, which is why nothing asks for confirmation: a regroup writes only the suggested half, and confirmations, names and ignores enter as anchors and come back unchanged. The smallest-group rule is applied only to groups the system invented — a group the user named or set aside survives it whatever its size, because a display preference does not overrule a judgement. **Withdrawal, without which the setting does nothing visible.** Raising the smallest group stops the pass creating small groups; it does not remove the ones a previous pass made, because those still hold their suggestions, so they are not empty, so the prune leaves them. The pass now releases every unanchored face it did not place before pruning. And a dial you cannot see the effect of is not a dial. "What would this do?" runs the same population through the clusterer without opening a transaction and reports groups, faces grouped and largest group — one row of `--tune`'s table, on the user's own library, on a worker thread. The line leads with the group count because that is the number that says which side of the right setting you are on: it climbs as fragments are gathered into people and falls as separate people start being welded, while the grouped-face count rises straight through both. The preview parks its poll timer in a slot of its own. A preview and a regroup are allowed to be in flight together, and sharing the sweep's single slot would have the second to start drop the first's timer — visible as a Regroup that finished on its worker and never said so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e465ff0c80 |
Put the people filter where the filters are
Narrowing the grid to two people at once has worked since people became a selector term, and it was effectively unreachable. The only control that could add a second person lived on the People screen, behind selecting them there, and it appeared only once the grid was already narrowed to somebody — so "photographs with both of them" needed a two-screen round trip the user had to guess at. A filter belongs on the filter bar. A "People" chip there opens a tray of everyone the library knows; tapping a name adds or removes them, and the any/all chip beside it — already there, and already the thing nobody found — now has something to sit next to that explains it. The caption leads the row so a pair of chips means something before either is pressed. The tray is a strip under the bar rather than a popup, the way the develop column's film picker is: the view scrolls as one, so an inline strip is taller content and not a second overlay to dismiss. It scrolls horizontally for the same hard reason the bar above it does — a layout cannot be narrower than its children's minimums, and forty people would otherwise set the minimum width of the whole view. The roster is built on open, not kept in step: indexing and regrouping change who exists, and a list cached at startup would be stale for exactly the user who has just been naming people. Named first, then by how much of them the library holds — the catalog orders by face count alone, which puts a dozen unnamed strangers ahead of the two people the user actually cares about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6d5de21fb7 |
Tell a tap on a photograph from a hand going past
A brush across the grid opened whichever photograph was under it. Travel was already answered — the Flickable claims the pointer and the press is cancelled — but a contact that neither travels nor lasts reaches a TouchArea as an ordinary press and release, and it was landing the user in develop. So a finger now has to stay down for `TAP_MIN_MS` before letting go counts as opening anything. That is the floor under a tap where the 450 ms `HOLD_DELAY_MS` is the ceiling: below is a graze, between is a tap, above is a hold that starts a selection. One scale, three gestures. Only a finger is held to it. A mouse click is a discrete decision made by a button and is routinely over in thirty milliseconds, so `cell-pressed` now reports whether a finger did it — the same finger-id convention the pinch arbitration beside it already uses — and the dwell applies to touch alone. A graze still *selects* the cell it landed on, because the press already did that. That is the right failure mode: something visible and reversible rather than a silent nothing, and rather than develop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f5edd49b6b |
Let a manual collection be put in the order it is meant to be seen in
`collection_members.position` and `Sort::CollectionPosition` have been in the catalog since collections were, and nothing above dr-catalog has ever written or read either: `collections::set_order` had no callers, and the grid ordered everything by capture time whatever it was scoped to — dr-ui does not construct a `Query` at all, it has its own `GRID_ORDER` constant. So a manual collection was a set with an order nobody could see or change. Three pieces, because it could not be fewer: `grid_order_for` decides the ordering from the scope, and both readers take it from there. That is the load-bearing part. An ordinal only names a photograph relative to an ordering, so the window read and the span read have to agree — a shift-click resolved through a different ORDER BY than the cells were drawn with selects a different run than the one on screen, and the user finds out when the export runs. `read_ids_span` already stated that invariant about `GRID_ORDER`; this widens it to an ordering that depends on the scope. Only a single manual collection has one. A set draws its descendants' images too, and two children's positions are unrelated integers that interleave arbitrarily; a smart collection has no member rows to carry a position at all. Both fall back to capture time and refuse the drop rather than pretending. The drop is on the cell, on whichever half of it the finger landed — the trailing edge is the only way to name the last place in a collection, since there is no cell beyond the last one to drop in front of. `reordered` is pure and the membership is rewritten whole. `set_order` sets the positions it is given and leaves the rest, so a partial write would interleave the moved run with rows nobody touched; and it is read unfiltered, so what the filter is hiding keeps its place relative to what the user can see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9d11554c71 |
Make the range gesture visible, and offer the whole grid at once
Touch has had a range gesture for as long as selection mode has: double-tap the far end. It is invisible, it is unreliable on a grid that scrolls under the second tap, and it extends from the anchor *before* the two taps moved it — a rule subtle enough that the code needs two paragraphs to explain it to itself. Nobody who was not told about it has ever used it. "Select to…" is the same operation with state you can see. Press it, the strip stops reporting and says "Tap the last photograph", and the next cell taken is the far end. It reaches Rust as shift on `cell-pressed`, so it lands in `apply_press` as the ctrl+shift it already is, and there is no third selection policy to keep in step with the other two. This is deliberately not the sweep gesture. A drag that paints cells can only reach what is on screen, and the ranges that hurt on a tablet are longer than a screenful — between the two taps here the user may scroll as far as they like, and the run is resolved by the catalog rather than by what happened to be loaded. A sweep is still worth having for short runs; it is not what this should have rested on. "Select all" beside it, asked of the catalog for the same reason: a select-all that quietly meant "the hundred cells that happen to be loaded" is a lie the user cannot see until the export runs. The double-tap stays. It is tested, and an accelerator that costs nothing is worth keeping for whoever has already learnt it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d1f3b97245 |
Ask for the collection's name where the keyboard can reach it
"New collection from selection" created the collection under a placeholder name and then opened the rename field in the sidebar tree. On a tablet the sidebar is not on screen. It is instantiated all the same — app.slint collapses it to zero width and `visible: false` rather than using an `if`, because an `if` there is a layout loop Slint panics on — so the rename field was created, its `init` took focus, and Android raised the on-screen keyboard over a box nobody could see. Nothing else on the screen is focusable, so the keyboard had nowhere to go: it stayed, the name could not be typed, and the collection was already written under the name the user did not want. Asked in a sheet instead, on the same card as the filing and keywording sheets, before anything is written. That also fixes what was hiding behind it: an abandoned rename used to leave a "New collection" in the tree, because the collection existed before the name did. `Field` gains `take-focus()` so a sheet whose field is the only thing to do in it can answer the keyboard for the user — a function rather than a property, because focus is an event and a bound property would re-take it on every unrelated re-evaluation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b9891e2c04 |
Merge master into tablet-selection
Two real conflicts, both from work that landed either side of the same lines rather than against them. `lib.rs`: the settings controller was hoisted above the People screen's wiring, and Android's thumbnail-tier eviction registered itself at the same point. Independent, so both stay. `library.rs`: manual collection ordering and burst folding each added a clause to the same two queries. The scoped range read now carries both — the folding matters there for one step further on than it does in the grid, because a collapsed burst is one cell, so an ordinal counted over a list still holding every frame names a photograph several places away from the one the user pointed at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
23c74d7063 |
Put the grouping dials where the regrouping is
The merge probability was `dr_face`'s constant and the smallest group was a bare `< 2` in the clustering pass. Both were tuned on one library — 1,813 faces of one photographer's family — and the quantity they optimise is a property of the population, not of the model. A household at close family resemblance and two thousand strangers at a wedding want different answers, and neither of them is the reference library. The doc comment already conceded the point and pointed at `face_index --tune`; a photographer does not have a terminal. So they are `FaceSettings` now, saved per device beside the cache budgets and edited from the People screen — beside the Regroup button that applies them and the rail that shows what they did, because a value changed three screens away from its effect is one nobody can tune. Moving them is safe by construction, which is why nothing asks for confirmation: a regroup writes only the suggested half, and confirmations, names and ignores enter as anchors and come back unchanged. The smallest-group rule is applied only to groups the system invented — a group the user named or set aside survives it whatever its size, because a display preference does not overrule a judgement. **Withdrawal, without which the setting does nothing visible.** Raising the smallest group stops the pass creating small groups; it does not remove the ones a previous pass made, because those still hold their suggestions, so they are not empty, so the prune leaves them. The pass now releases every unanchored face it did not place before pruning. And a dial you cannot see the effect of is not a dial. "What would this do?" runs the same population through the clusterer without opening a transaction and reports groups, faces grouped and largest group — one row of `--tune`'s table, on the user's own library, on a worker thread. The line leads with the group count because that is the number that says which side of the right setting you are on: it climbs as fragments are gathered into people and falls as separate people start being welded, while the grouped-face count rises straight through both. The preview parks its poll timer in a slot of its own. A preview and a regroup are allowed to be in flight together, and sharing the sweep's single slot would have the second to start drop the first's timer — visible as a Regroup that finished on its worker and never said so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a4b9deaf96 |
Put the people filter where the filters are
Narrowing the grid to two people at once has worked since people became a selector term, and it was effectively unreachable. The only control that could add a second person lived on the People screen, behind selecting them there, and it appeared only once the grid was already narrowed to somebody — so "photographs with both of them" needed a two-screen round trip the user had to guess at. A filter belongs on the filter bar. A "People" chip there opens a tray of everyone the library knows; tapping a name adds or removes them, and the any/all chip beside it — already there, and already the thing nobody found — now has something to sit next to that explains it. The caption leads the row so a pair of chips means something before either is pressed. The tray is a strip under the bar rather than a popup, the way the develop column's film picker is: the view scrolls as one, so an inline strip is taller content and not a second overlay to dismiss. It scrolls horizontally for the same hard reason the bar above it does — a layout cannot be narrower than its children's minimums, and forty people would otherwise set the minimum width of the whole view. The roster is built on open, not kept in step: indexing and regrouping change who exists, and a list cached at startup would be stale for exactly the user who has just been naming people. Named first, then by how much of them the library holds — the catalog orders by face count alone, which puts a dozen unnamed strangers ahead of the two people the user actually cares about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2403f355d6 |
Tell a tap on a photograph from a hand going past
A brush across the grid opened whichever photograph was under it. Travel was already answered — the Flickable claims the pointer and the press is cancelled — but a contact that neither travels nor lasts reaches a TouchArea as an ordinary press and release, and it was landing the user in develop. So a finger now has to stay down for `TAP_MIN_MS` before letting go counts as opening anything. That is the floor under a tap where the 450 ms `HOLD_DELAY_MS` is the ceiling: below is a graze, between is a tap, above is a hold that starts a selection. One scale, three gestures. Only a finger is held to it. A mouse click is a discrete decision made by a button and is routinely over in thirty milliseconds, so `cell-pressed` now reports whether a finger did it — the same finger-id convention the pinch arbitration beside it already uses — and the dwell applies to touch alone. A graze still *selects* the cell it landed on, because the press already did that. That is the right failure mode: something visible and reversible rather than a silent nothing, and rather than develop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6246a2e346 |
Merge: group the frames of one moment, and let a burst fold away
FR-CULL-5. Frames join a burst when they are adjacent in time and look like the frame before them -- both, because time alone groups a whole ceremony and similarity alone groups a studio setup across two days. Adjacent pairs only, chained; there is no all-pairs step and there must never be one. No selection of any kind. The representative is the earliest frame, a fact about the clock rather than a judgement about the photograph, and a newly found burst arrives open, so the pass never takes a row off the screen. Verified: fmt, clippy --workspace --all-targets -D warnings, 346 dr-catalog tests, 511 dr-ui tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |