2459a759afa782fa32d997d96e78ec7f87e8af6c
171
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6ae0af3f72 |
Swipe up in develop for the photo roll
Develop opens one photograph. The grid handed over a path and nothing else, so `index` and `total` were pinned to "1 of 1" on the way in and the only route to the next frame was back to the library, find your place, tap again. Fine once; intolerable through a set of forty, which is the situation the develop view exists for. The roll is the grid's already-loaded window along the foot of the canvas. Swipe up to bring it out, swipe down to put it away — the sheet gesture, already in the hands of anyone who has used a phone — and a handle is drawn at the edge so the gesture is discoverable rather than folklore, and so a pointer, which has no swipe to make, has a way in. `SwipeGestureHandler` wraps the strip rather than sitting over or under it, which is what it is built for: it delays a press the way a Flickable does, forwards it to the children if no swipe develops and claims it once one does, so a tap reaches the thumbnail and a drag does not. It covers only the band along the bottom — above that, a drag still belongs to the photograph, for panning and for the crop. Picking goes through the same path a cell click does, so the outgoing edit is persisted before the next image loads. The strip marks what is open and scrolls to keep the mark in view. The position readout now says where in the *library* the open photograph sits rather than "1 of 1". Set after the open rather than before, since the generic open path resets it — and given as the library ordinal, not the row in the loaded window, which is an artefact of how much has been paged in and would jump about as the window moves. |
||
|
|
c72f197880 |
Stop a row of buttons deciding how wide the grid is
**A Slint layout cannot be narrower than its children's minimums.** Given less room than they need it lays them out at those minimums, lets the row run past the edge — and reports the oversized minimum upwards. That second half is what made this more than cosmetic. The header, the filter chips and the grid are siblings in one VerticalLayout, so the widest row's minimum became the whole view's minimum: `LibraryGrid` was laid out wider than the window. The grid then measured itself against that inflated box and sized its columns to fill space that was off the screen, so the right-hand column was cut by the edge no matter what the tiling arithmetic did. Fourteen filter chips do not fit across 768 logical pixels, so that was every tablet in portrait. It also explains why opening the collections sidebar did not reflow the grid. The view was already pinned at a minimum wider than the window, so taking 232px away for the sidebar could not shrink it — it just clipped more of it. Each of those rows is now a horizontal Flickable. A Flickable's own minimum is nothing, since it exists to be smaller than what it holds, so none of them can inflate anything — and the controls past the edge became reachable instead of merely absent. Applied to all four: the library header, the filter chips, the compact action row disclosed by "More", and the develop strip. |
||
|
|
d2c909414c |
Stop zooming rebuilding the grid twice a frame
A pinch is not one zoom step, it is a stream of them, and every step changes both the column count and the capacity — two reports. Each report re-queried the catalog, rebuilt all 360 rows of the model, re-read the badges and ratings for every one of them and spawned a thumbnail batch, synchronously, on the thread trying to draw the frame. Twice per step. That is why zooming juddered while scrolling the same grid is smooth: a scroll reloads a few times per screenful, a zoom reloaded twice a frame. None of that work is urgent, because none of it is about which photographs are on screen. The window holds the same images however they are laid out — the model already has them, and the cells re-flow from `columns` and `cell-size` with Rust not involved at all. What the reload actually recomputes is which cells begin a row, so the month headings land correctly, and which thumbnail size class to ask for now. Both can wait for the gesture to finish, so both are now coalesced behind a single settle timer: replacing the timer drops the previous one, and only the last report of a run lives long enough to fire. The anchor is captured on the first report of a run rather than read when the timer fires. As the grid re-flows the viewport keeps its pixel offset while the rows move underneath it, so the view drifts and reports the drift; reading the anchor at the end would faithfully return to wherever it had wandered. Taking it at the start returns to the photograph the user was looking at when they started the gesture. |
||
|
|
12a457b8d0 |
Tile the thumbnails to fill the width
The cells were drawn at whatever pixel size the class asked for and the remainder was left as a bare strip down the right-hand side — up to one short of a full column of nothing, which on a phone is a quarter of the screen. It also had the two quantities depending on each other the wrong way round. The column count was derived from the fixed cell size, so the two could disagree about how much room there was and the last column could start inside the viewport and end outside it. Solving for the cell removes both at once. Pick how many columns of roughly the requested size fit — rounded, not floored, because the size class is a request rather than a measurement and a width nine tenths of the way to another column should take it — then make those columns share the width. `columns` cells and the `columns + 1` gaps around them come to exactly the grid's width, so there is no remainder to strand and nothing can overhang. The size class still decides what the user gets, since it is what the column count is chosen from. It just no longer dictates the pixel, so the cell flexes a few percent either way to make the row come out even. |
||
|
|
90d6e551b3 |
Give the develop strip room for its controls on a tablet
The render backend, the layout class and the frame rate are developer readouts, and they were sitting in the middle of the one strip that also carries the way out of develop, the undo pair, the panel toggle and export. A HorizontalLayout given less width than its children's minimums does not shrink them — it runs off the end. A tablet in portrait is 768 logical pixels, which is not enough, so everything from the panel toggle rightwards was pushed off the right-hand edge. Below the breakpoint the develop column is *also* closed by default, so a toggle that could not be reached meant the column could not be opened at all — and copy and paste live in that column. That is the whole of "I have no idea how to copy a setting on Android and apply it to other images": there was no way to. The three readouts now collapse to zero width below the breakpoint. Width and not an `if`, because this strip is inside the layout that `expanded` feeds and a conditional child here is the shape that has already caused binding loops in this file — and because a `visible: false` child still takes its slot in a layout, so hiding alone would have freed nothing. |
||
|
|
1980fda737 |
Show the library on launch instead of scanning first
A launch does not have to discover the library. The catalog from the last run is on disk, complete, with its thumbnails in the shards beside it — exactly the state offline mode already leans on when the server cannot be reached. Every launch that *could* reach the server threw that away. The catalog handle was only opened when the scan reported Done, so the grid sat on "Scanning…" over an empty EmptyState for as long as a recursive WebDAV walk of the whole tree takes. On a real library that walk is essentially the whole startup time, and it was spent hiding a grid that was ready before it began. The catalog is now opened and the first window loaded before the scan thread is spawned — before, so the schema migration cannot race the worker opening the same file, and so the first thumbnail batch is already in flight while the walk runs. The scan still replaces all of it the moment it lands; it just no longer gates the first paint on the network. A first run has nothing to open, which stays silent: `Catalog::open` creates the file, the grid reads an empty catalog, and the empty state goes on saying "Scanning…" — which is true, and which an error here would contradict. |
||
|
|
80f1a210cc |
Keep the view pointing at the same photograph when the columns change
Two faults behind "the gallery randomly glitches to blank and needs a scroll to reset it", and behind the scrolling that skips. Cells are drawn at their absolute place in the library, so the row a photograph sits on is `index / columns`. When `columns` changes every cell moves — and the Flickable's `viewport-y` did not move with them. The view was left pointing at a row that now holds entirely different photographs, typically thousands of images from the ones the loaded window covers, so the grid drew nothing at all. It stayed that way until a scroll reported a first-visible row and dragged the window back under the view, which is exactly the reset the user found. None of its triggers are rare: a resize, the collections sidebar opening, a zoom step, or turning the tablet over. The last first-visible ordinal names the photograph being looked at, so it is now sent back through `scroll-to` and the view lands on that same photograph at whatever row it now occupies. The second fault is the window-move test. At either end of a scope the window is pinned — the first screenful cannot be centred further back than zero, the last cannot start past the last full screenful — so the margin test was unsatisfiable there and every row crossed in the first or last quarter of a window re-read the catalog, rebuilt the model and issued a thumbnail batch to arrive at the offset it already held. On a library of twenty-odd thousand that is a stutter at the top and the bottom of every collection, which is where a cull begins and ends. The decision is now a rule with tests rather than four lines inside the scroll handler: both of its ways of being wrong are invisible in the code and obvious on a tablet. |
||
|
|
deabac0923 |
Carry a photograph's thumbnail across a reload
Work in progress found uncommitted in the tree, committed as its own change so the fixes that follow can be read separately. Not authored in this session; the description below is written from the diff. `load_window` rebuilds every row, and a scroll reloads once the view has travelled a quarter of the loaded window — so three quarters of the cells being rebuilt are the same photographs already on screen. Rebuilding them empty blanked the grid to `Theme.ground` and refilled it a beat later, once a worker had re-read and re-decoded each one from the store. That is the black flash on every screenful of scrolling, and on a column change, a zoom step, a filter and a return from develop. Thumbnails are now held by `image_id` across the swap — a refcount per cell, no pixels move — along with the "no preview" verdict, which is an answer about the file worth keeping for the same reason. `requested` is rebuilt from what the new model actually holds rather than cleared, so a carried cell is not fetched again while one newly scrolled in still is. The size class each cell's pixels came from is tracked alongside, so a grid zoomed past that class still asks for the sharper one. Also guards the whole `scrolled` and `columns-changed` handlers on `show-library` rather than just the resume latch: a Flickable being torn down passes its viewport through zero, which was indistinguishable from a fling to the top and reloaded the window against the first rows of the catalog every time an image was opened. |
||
|
|
4d8196174c |
Let the grid be pinched instead of opening an image
Two separate faults, both only reachable with a second finger. The gesture never arrived. `ScaleRotateGestureHandler` was sized `100%` inside the Flickable, which is the Flickable's own height, not its viewport's — so the handler was one screenful tall at the top of a viewport thousands of rows long. A pinch is delivered to whatever lies under the midpoint of the two fingers, so it landed on the handler only while the grid was scrolled to the very top and found nothing anywhere else. Sized to `viewport-height` now, exactly as `zoom-catcher` above it already is. And the attempt opened a photograph. When a second finger lands Slint closes the first one's gesture by synthesising a `Released` at its position — that is how a Flickable is persuaded to let go of a scroll it has already claimed. A TouchArea cannot tell that release from a real one and fires `clicked`, so every pinch opened whichever image the first finger was resting on. The finger id separates them: the synthetic release carries the id of the finger that *arrived*, never the one that pressed. `clicked` fires before the pointer event that names the finger, so it now only raises a flag and the `up` handler decides. A mouse reports 0 for both, leaving the desktop path exactly as it was. |
||
|
|
892da2662a |
Keep the stars on screen long enough to click one
The rating strip was a sibling of the cell's TouchArea, declared after it so that hit-testing reached the stars first. That half worked: a star click set a rating without also opening the image. The other half did not. Hover is tracked per TouchArea, and Slint sends `Exit` to any item that drops out of the hit path. The strip taking the pointer is exactly that — `cell-touch` left the path, `has-hover` went false, and `show-empty` went with it. On an unrated cell the stars are drawn *only* on hover, so they vanished as the pointer arrived at them; the click then landed on the cell behind and opened the image. Reported as "the star menu disappears when I click on an image", which is precisely what it does. Nesting the strip inside `cell-touch` keeps both halves. Children are hit-tested before the element containing them, so a star still wins the click and still ends the walk before `cell-clicked` runs. And an ancestor stays on the item stack: it gets no `Exit`, and while a child holds the grab it is handed the event filter and never the event. Hover therefore holds for as long as the pointer is anywhere in the cell. |
||
|
|
76a751f125 |
Measure the grid against the grid, not the whole view
`cell-size`, `columns` and `visible-rows` were all derived from the LibraryGrid's own width and height. The cells are not drawn in that box: the capture-time axis is a sibling of the grid, 96px of it, and the header takes another 44px off the top. So `columns` counted the timeline as room for thumbnails and fitted one more column than there was space for. The last column started inside the Flickable and ended outside it — clipped, with no sideways scroll to reach it. At the 180px size class on a phone in portrait the timeline is a quarter of the screen, which is most of a column. `visible-rows` was wrong the same way and fed `capacity`, so every window over-fetched by the ratio of the header to the viewport. Both now read `grid-area`, the layout the cells actually live in. That is a descendant, and reading a descendant's geometry is the shape that causes binding loops elsewhere in this UI — but not here: nothing derived from these feeds back into the layout. The cells are placed absolutely inside the Flickable and a Flickable's layout constraints are a bare `stretch: 1` that its viewport cannot influence. |
||
|
|
3cfa78cde2 |
Give the app a face and a name on the launcher
There was no icon anywhere, and on Android that was not a missing line in the manifest. `aapt2 link` was being handed a manifest and nothing else, so the APK carried no res/ and no resources.arsc — there was no table for an `@mipmap/...` reference to resolve against even if one had been written. Packaging now compiles the resource tree first and links the result in, which is the two steps aapt2 insists on: link reads compiled input only, never a directory. That absent table is also why the launcher caption was blank, which had looked like a second, separate bug. `android:label="DarkRoom"` was there and correct the whole time, and Settings' App info read it fine; the launcher could not, because resolving a label goes through the package's Resources and there were none to open. Nothing about the label changed here. It came back with the table under it. `android:icon` then names one drawable for both icon generations, because the `anydpi-v26` qualifier is what separates them. API 26 and up take the adaptive icon and its three layers; the third of those, monochrome, is what lets Android 13 recolour it rather than drop the app out of the themed set. Below 26 the same name lands on a density-matched PNG. `roundIcon` is deliberately absent — a launcher old enough to read it is one that would ignore the adaptive XML, and minSdk is 28. The desktop icon is one `@image-url` on the window, and the only raster asset in a UI that is otherwise entirely Path. The reasoning at the top of icons.slint does not reach it: that is about glyphs a font might not carry, and this image is never drawn by us at all. It goes to the window manager, which wants pixels and composites them unmasked, so it is pre-shaped with rounded corners rather than square the way the Android layers are. Which exposed Slint's resource default. An `@image-url` compiles down to the absolute path it had on the build machine, to be opened at runtime — already wrong for Android, where the build happens under /work inside a container and no such directory exists on the device, and wrong silently, as an image that loads empty. `EmbedFiles` puts the bytes in the binary instead. It reaches nothing else, since every glyph is a Path. Verified on a device: the APK installs and the home screen draws both the icon and "DarkRoom" under it, where before it had neither. In the link step the adaptive icon resolves at all six densities and resources.arsc lands uncompressed, which API 30 requires and the existing zipalign preserves. On the desktop by reading _NET_WM_ICON off the running window — 256x256, as handed over. Where that actually shows is narrower than it sounds, and the comment says so: Wayland ignores the property in favour of matching app_id against an installed .desktop file, which this repo does not install. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ca833b6d2b |
Give every colour band its own compiled shader
🐳 Android image / Build and push (push) Successful in 5s
Build and test / android-image (push) Successful in 5s
Build and test / Desktop (Linux) (push) Failing after 57m33s
Build and test / Layer separation (push) Successful in 35s
Traceability / Requirement traces (push) Failing after 35s
Build and test / Android (aarch64) (push) Failing after 9m42s
The colour mixer emits a code block and a uniform only for the bands that are set, so which bands are adjusted is part of the shader's structure. The pipeline cache key was not: it hashed the set of *active operations*, which is "colour_mixer" whichever band that is. So a red adjustment and a blue one hashed alike. The second render was handed the first's compiled pipeline while its uniform was uploaded into a slot that shader had assigned to another band — whichever band compiled first kept acting on every subsequent move, and every other slider did nothing at all. Red is the first band declared, and the one reported as the only one working. The hash is now taken over the generated WGSL, because the source is what gets compiled and therefore is the structure. A summary of what went into it has to be kept in step with every operation's code generation by hand, and this one had fallen out of step. Values still do not enter it: no operation writes a parameter value into its source, so a slider drag regenerates identical text and reuses the pipeline, and one that did inline a value would have to recompile to be correct anyway. `each_colour_band_gets_its_own_pipeline` in dr-gpu renders a blue pixel through one pass with red set first and then blue, and fails on the old hash with the reported symptom — the blue slider returning the pixel unchanged to the byte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f76e024f41 |
Straighten a portrait frame in the frame the user is looking at
The framing prologue built the centred position `p` by scaling with the source's aspect, then straightened, then permuted the quarter turns. On a landscape frame those are one space and it worked. Once a turn has swapped the axes — the rotate button, or a file whose EXIF tag says the camera was held sideways — they are not: `p` was measured with the source's ruler on a frame that is no longer that shape, stretching one axis against the other by (w/h)², which is 2.25 on a 3:2 photograph. The quarter-turn permutation happened to undo that stretch, so rotation alone looked right, which is how this survived. The straighten in between did not, and a rotation in a space whose axes carry different scales is a shear. `p` is now built in `frame_aspect` — the frame as the user sees it — and the permutation becomes the one place the two rulers meet: each axis divided by the aspect it is read from, multiplied by the aspect it is written to. Asserted on pixels rather than on the generated WGSL, because reading the shader and reasoning about which space `p` lives in is how the wrong formula got written in the first place: a disc, straightened by 20° on a turned 3:2 frame, must come back circular by every route to a swapped frame — the button, the tag, and the two composed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f1cd6ed5b3 |
Make the drop decision a rule that can be tested
Build and test / Desktop (Linux) (push) Failing after 57m40s
Build and test / Layer separation (push) Successful in 34s
Traceability / Requirement traces (push) Failing after 27s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m28s
The gesture is Slint's and cannot be driven from a test — synthetic drags do not reach a DragArea at all — but the decision it leads to is where this can actually go wrong, and it was buried in a callback. `decide_drop` names the three outcomes and the order that separates them: an image drag always fills the payload, so photographs pressed after a row was clicked are still filed rather than read as a rearrangement. A row dropped on itself is a no-op here rather than a cycle error from the catalog, and an empty drag with nothing remembered — a file from another application — leaves the tree alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6acc73a9fa |
Drag a collection onto another to nest it
The tree could be built nested but never rearranged: `set_parent` existed, with its cycle check and its tests, and nothing in the UI called it. A collection created in the wrong place stayed there. Each row is already a drop target, so it becomes a `DragArea` too — wrapped at the instantiation site the way the grid's cells are, which keeps the row's own TouchArea nested underneath and a click still selecting. `allow-move`, not copy: a collection has one parent, unlike a photograph, which is filed in as many collections as you like. The drop is handed only the target's id, so the source is remembered from the press that precedes the drag — Slint builds the payload through a `pure` binding, which must not have side effects. An image drag always fills `dragging`, so an empty payload with a remembered row is unambiguously a rearrangement; the row is taken rather than read, or a later empty drop would move a collection nobody touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
edcaf42ded |
Show only the photographs taken in the period you are looking at
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Failing after 57m33s
Build and test / Layer separation (push) Successful in 36s
Traceability / Requirement traces (push) Failing after 29s
Build and test / Android (aarch64) (push) Failing after 9m28s
The library could be narrowed by rating, flag and availability, but not by when a photograph was taken — so finding a fortnight meant scrolling to it and holding position. The range rides on `RatingFilter` for the reason `local_only` already does: every query path threads that one struct, so the count in the header cannot claim a total the grid does not draw. Undated images are excluded whenever either end is set — they cannot be inside or outside a span, and drawing them made the range look as though it had not applied. Taken from the timeline rather than typed into two date fields. Finding the period is what the histogram is for, and having found it the user should not have to read the dates off the axis and key them back in. The histogram keeps drawing the full extent while the range is on, or there would be nowhere to widen back out from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d2d5d6f22b |
Keep the selection visible when the grid scrolls under it
Scrolling rebuilds every cell, and a fresh cell carries `selected: false`. `sync_badges` and `sync_ratings` refilled what the rebuild cleared; nothing refilled the selection, so the ticks vanished on every scroll. The selection itself was never lost — it is a set of image ids and survives untouched — which made this worse than losing it: the header buttons still acted on forty photographs the user could no longer see were held. `load_window` reaches the selection through a `Weak` handle set at wiring. Weak because the two controllers are joined only through the window, and an `Rc` each way would leak both; absent, the grid draws nothing selected, which is what it did before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
02d629922f |
Draw the date histogram over the collection you are looking at
Build and test / Desktop (Linux) (push) Failing after 57m25s
Build and test / Layer separation (push) Successful in 33s
Traceability / Requirement traces (push) Failing after 27s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m45s
The timeline counted the whole library whatever the grid was showing, so opening a collection left a fortnight in Arosa as one column of a fifteen-year axis — an axis describing photographs that were not on screen. Scope the buckets and the span to the same collection and rating filter the grid uses. `timeline_range` counts `images` alone and cannot express the membership join, so the scoped query lives beside the other scoped readers in the UI and shares their descendants-of-scope rule. `catalog_span` now delegates to the same scoped reader. Zoom and scrub measured the full library while the bars were scoped, so a scrub could land on an instant the collection did not contain and send the view somewhere the user had not asked to go. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b0206cbc7a |
Let a sub-collection stay under its parent through a sync
Build and test / Desktop (Linux) (push) Failing after 57m14s
Build and test / Layer separation (push) Successful in 33s
Traceability / Requirement traces (push) Failing after 29s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m47s
The merge inserted every incoming collection with `parent_id = NULL` and never set it on update, so the hierarchy flattened on each round trip: a collection nested on one device came back from the server at the top level. `r.parent_id` was selected and then not read. The id could not be copied — row ids are local, and the remote's integer names a different collection here, or none. So carry the parent's uuid and resolve it locally, in a second pass: rows arrive in whatever order the query returns, and a child can precede its parent. Guard the resolution against cycles. Each tree is acyclic alone, but the union need not be — we may hold A above B while the remote holds B above A — and closing that loop would make every tree walk spin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5700c37016 |
Look before overwriting the file we are asking about
The probe PUT its bytes first and read the outcome, which answers the question by destroying the evidence: pointed at a real sidecar it would replace an edit with the word "probe", and on success delete it outright. PROPFIND first. Permissions and status usually settle create-versus-update on their own, and a path that already exists is now reported and left alone. `--write` still forces the update test for a file worth losing, and the cleanup DELETE fires only for a path the probe itself created. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
18f20170b3 |
Ask the file, not its folder, why the write was refused
The 403 probe read `oc:permissions` off the parent collection and warned when `W` was missing. But Nextcloud reports `W` on files and `CK` on collections, so a directory legitimately lacks `W`: the warning fired on a healthy share and pointed at a mount that was fine. Probe the file itself. Its permissions answer the question that matters, and the status distinguishes the two cases the parent could not: a 404 means the sidecar does not exist and the refusal was about creating it, while a 200 without `W` means it exists and cannot be updated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6621c11ad6 |
Diagnose the refused sidecar: the library mount is create-only
Ratings and edits made on the tablet queue and are then refused on reconnect,
on a credential that pushes the catalog to the same library root in the same
sync pass. Chased on the device, since that is where the account lives.
A failed PUT now logs the server's own words, and on a 403 asks the parent
what rights it reports. The answer:
PUT .../PhotosRaw/2026/2026-08-03/_MG_9221.drsc -> 403
<s:exception>Sabre\DAV\Exception\Forbidden</s:exception>
parent permissions: MGNVCK
`M` mounted, `G` readable, `N` renameable, `V` moveable, `CK` create files and
folders. Absent: `W`, update an existing file, and `D`, delete. So `PhotosRaw`
is a mounted share that accepts a file once and refuses every change to it
afterwards.
That is the whole bug, and it is not one this side can retry its way out of. A
sidecar is rewritten on every rating and every edit, so the first judgement on
a photograph is written and every later one is refused — which reads as sync
being broken rather than as a share missing one permission. **The fix is to
grant update, and ideally delete, on that mount.**
`PermissionDenied` now says so, rather than "not allowed to write here", which
sent the reader to re-check a login that was working. Its test asserts the
intent — points at the folder, never at the credential — rather than a
phrase, so saying it better cannot read as a regression.
Also adds a `put_probe` example that makes the same request from a stored
session, for diagnosing this from a desktop when one is signed in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
71554714e7 |
Log why the server refused a write, in its own words
A queued sidecar fails to upload with 403 on a credential that pushes the catalog to the same library root in the same pass. `map_status` reduces every non-success to a typed error, which is right for the application and leaves nothing to work from: a read-only share, a file access control rule and a lock all arrive as `PermissionDenied`. Sabre says which in the response body. It is now logged on any failed PUT — the URL, the status, and the first line naming the exception or message, capped at 300 characters because an error page can be a whole document. Only on failure; a success has no body worth reading. Also adds a `put_probe` example that makes the same request from a stored session and prints the reason, for diagnosing this from a desktop rather than from a tablet's logcat. It needs a session on the machine it runs on, which is why the log line above exists as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
09f6cf8c0f |
Name the sidecar the drain could not deliver
The write path learned to say which file the server refused; the drain path —
the one that runs for work queued while offline — still reported only a count.
That is the path that matters most, because everything it carries was made
with no connection and exists on one device.
With it named, the failure on the tablet is:
draining PhotosRaw/2026/2026-08-03/_MG_9221.drsc:
permission denied
on a credential that pushes the catalog to the same library root in the same
pass. So this is not the account and not the app password: one path is
writable and another is not, which points at the server — a read-only share
over that folder, or a file access control rule on the extension — rather than
at anything this side can retry its way out of.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
6a7ed37aed |
Say which sidecar the server refused, and where
A queued sidecar failing to upload was reported as a count and a reason — "1 queued sidecar(s) still undelivered: permission denied" — with the path only at debug level, which the app filters out by default. That is unsynced user work: a rating or an edit that exists on one device and nowhere else. Which photograph it belongs to, and which path the server refused, is the whole of what makes the failure actionable, and without it a 403 on a single file reads the same as a whole library failing to sync. Observed on the tablet, where writes to the derived folder succeed on the same credential — the catalog pushes fine — while one sidecar beside its image is refused. That combination says the path matters and the account does not, so the path is the thing worth printing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dea826811e |
Render a blown highlight white instead of magenta
Every clipped sky came out bright pink. Measured, not guessed: developing _MG_8596.CR2 and looking at the export, the subject renders correctly and only the saturated region is wrong. A fully clipped pixel reaches the shader as (1, 1, 1) — three photosites that stopped counting, carrying no colour at all. The as-shot multipliers are not neutral, so balancing sends it to (1.93, 1.00, 1.68) on this body, and the camera matrix turns that into R 2.88, G 0.51, B 2.03. Red and blue clip at one; green, whose matrix row is far less positive-heavy, does not. Red and blue high with green low is magenta. Nothing upstream was at fault, which is why the two previous attempts missed it: the white balance is correct, the matrix is correct, and the sensor normalisation is correct. The input simply was not a colour, and correct arithmetic on a non-colour produces a confident wrong answer. So saturation is detected before the balance is applied — the last 1.5% of range, smoothstepped rather than switched, because a hard threshold draws a visible rim around every highlight and a backlit edge on skin is where that shows. Above it the pixel is pulled to the neutral of its own brightness, so it keeps its luminance and loses only the cast. Verified end to end on the file itself: the sky is white, the skin, the black dresses and the stone are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
31e20399c8 |
Read the true white level, and clamp the sensor stage at both ends
Two corrections to the sensor stage, found while chasing magenta highlights. Neither is the cause of that — see below — but both are wrong on their own terms. `white_level` took the *first* of rawler's per-channel saturation points. On a Canon 6D that reports 15070 while the data reaches 16383, so every sample above it was treated as brighter than white. It takes the maximum now. The normalisation clamped its floor and not its ceiling, so those over-white samples passed through as values above 1.0. Clamped at both ends. **This does not fix the pink.** Measured on _MG_8596.CR2, exported and looked at: the subject renders correctly and only the blown sky is magenta. A fully clipped pixel is (1,1,1) in raw, the as-shot balance multiplies it to (1.93, 1.00, 1.68), and the camera matrix turns that into R 2.88, G 0.51, B 2.03 — red and blue clip at one, green does not, and the result is magenta. It is correct white balance applied to already-saturated data, which is the classic highlight-clipping cast and needs highlight desaturation to fix: a pixel at saturation carries no colour information and must be rendered neutral, not balanced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7d0fb710a6 |
Bound a readback by time, so a full-resolution export can finish
Exporting a real 20 MP CR2 failed every time with "readback did not complete", while the copy itself was perfectly healthy. The bound was 100,000 non-blocking polls. That sounds generous and is not: a `Poll` that finds nothing returns immediately, so the loop spent its entire budget in a few milliseconds. Small transfers — the histogram's 4 KB, a viewport-sized frame — happened to land inside it. An 80 MB frame never could. It is a deadline now, thirty seconds, which is the only thing the bound was ever for: catching a lost device that will never deliver the callback. A one-millisecond pause after the first sixty-four spins stops the loop saturating a core for the length of the copy, while keeping a small transfer as immediate as it was. Found by developing /home/dtourolle/Downloads/_MG_8596.CR2 through the export example: 5472×3648 renders in 127 ms and writes all five formats. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f1f528fc42 |
Stop rendering a missing white balance as neutral, which came out pink
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Build and test / Desktop (Linux) (push) Failing after 57m10s
Build and test / Layer separation (push) Successful in 34s
Traceability / Requirement traces (push) Failing after 36s
Build and test / Android (aarch64) (push) Failing after 9m26s
`sane_wb` replaced any coefficient it could not use with 1.0. That reads as a safe default and is not one. A Bayer sensor's green photosites collect roughly twice the signal of its red and blue, so unbalanced data is strongly green — and the camera matrix is built assuming the data reaching it has already been balanced. Fed green-heavy input it subtracts green as designed, overshoots, and the frame lands in magenta. Bodies whose as-shot coefficients rawler does not report came out pink, and nothing anywhere said why. The fallback is now the camera's own response to daylight, which `cam_to_srgb_from` was already computing on its way to balancing the matrix and then discarding. `daylight_wb` exposes it, and both callers read the same matrix through the same illuminant preference — so the multipliers neutralise exactly the white the matrix expects to be neutral, by construction rather than by coincidence. With no matrix either, the body is unknown and neutral is the honest answer: uncalibrated beats wrong in a specific direction. A test caught me returning the response rather than its reciprocal, which inverts the correction — a sensor is *least* sensitive to the channel needing the largest multiplier, so that version boosted precisely the wrong one. The doc comment now says which of the two it returns, because they differ by an inversion and look alike. Four tests, on a real matrix (Canon 6D, D65) rather than a contrived one: the fallback is nowhere near neutral, lifts both red and blue against green, stays green-normalised, and — the property that makes it consistent rather than merely plausible — balancing by it and then applying the matrix maps the camera's white to a neutral sRGB. `daylight_wb` is also the anchor the white-balance presets need: a preset in kelvin requires an absolute illuminant to be a preset *of*, and the temperature control is currently a relative offset from whatever the camera chose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d2024da368 |
Scroll the develop column as one, histogram and all
Only the sliders scrolled. The capture metadata, the histogram, the geometry controls and copy-and-paste all sat above them in a fixed layout, so on a 280px column in portrait they took the height the sliders needed — and the histogram, which is the instrument the sliders are judged against, could neither be scrolled to nor scrolled past. The Flickable moves out of `AdjustPanel` and around the whole column. Nesting one inside the other was not an option: a slider drag already has to be won against one scroller, and a second would give it a third thing to be lost to. `slider-dragging` becomes an `out` property for the same reason. The arbitration is unchanged and still necessary — a track stands the scroller down as soon as a finger touches it, or every attempt to drag a slider would scroll the column instead — but the scroller obeying it now lives a level up. The scroll gutter stays where it is, as padding inside the content: it is what guarantees somewhere to put a thumb that means "scroll" and nothing else, and it matters more now that it serves the entire column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
654c11300e |
Pin the framing geometry on pixels, not on the generated shader
Chasing a reported shear on rotate and straighten. Two tests, and what they prove is that the pipeline is not where it comes from. A circle is the shape that makes anisotropy unmissable: any transform scaling the axes unequally returns an ellipse, and the ratio of its axes is the error. Both a quarter turn and a 20° straighten, on a 3:2 frame, return a circle within 8%. Worth recording because I had a confident and wrong hypothesis first. The quarter turn carries `* aspect.x` on one component and `/ aspect.x` on the other, which reads like an anisotropy of a-squared, and the reasoning that `p` is already isotropic is plausible enough that I changed it. The existing `a_quarter_turn_corrects_for_aspect_across_the_swap` caught that immediately, and these tests then showed the original was right all along: the crop rect is expressed in the *turned* frame and the output axes swap with it, so the factors are the conversion between those spaces rather than a mistake. Reading the shader and reasoning about which space `p` lives in is exactly how a plausible formula gets written twice. These assert on real pixels off a real adapter instead, so the next person to suspect this transform can rule it out in one command. The shear is therefore in the display path — the fit from the framed size to the viewport, or the crop overlay's uncropped render — and not in the geometry the pipeline computes. Not yet fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
02ae92ba0d |
Tidy what the seven-branch merge left behind
Build and test / Desktop (Linux) (push) Failing after 57m16s
Build and test / Layer separation (push) Successful in 34s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Successful in 1m7s
Build and test / Android (aarch64) (push) Failing after 9m41s
Three lints, all from merged work rather than from any one branch: `terrace` and `disc` were steps on the way to the ramp the plateau test now uses, and the reasoning that discarded them lives in docs/segmentation.md §12 rather than needing the code; two mechanical clippy suggestions in segment and cache. 1176 tests pass, clippy and fmt clean, traceability regenerated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c9c5c43aa1 |
Carry the library across when its home moves
The previous commit moved durable data out of Android's cache directory, and on its own that would have been an upgrade that quietly discarded work. The app looks in the new location, finds nothing, and rescans a library of tens of thousands of images over the network — while the old copy, including every offline rating and edit that had not yet synced, sits in a directory the system is free to delete. So the account's directory is moved once at startup, before anything opens a store. A rename rather than a copy: both are inside the app's own data on one filesystem, so it is atomic and cannot half-finish. An existing destination wins and the move is skipped — that covers a second run and a fresh install, and in neither case may this overwrite live data. A failed move is logged, not fatal. The cost is a rescan, which is recoverable; refusing to start is not. Two tests, against ordinary directories rather than the platform's idea of a cache: one puts an unsynced sidecar in the old location and asserts it is readable in the new one afterwards, the other pins that live data is never overwritten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4e62b89d17 |
Merge branch 'android-collections'
Build and test / Desktop (Linux) (push) Failing after 18m53s
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Failing after 1m4s
🐳 Android image / Build and push (push) Successful in 6s
Build and test / android-image (push) Successful in 5s
Build and test / Android (aarch64) (push) Failing after 9m40s
Touch multi-selection in the grid, filing a selection into a collection without a drag, and taking a collection offline from a held row. |
||
|
|
d913e50948 |
Select photographs with a finger, and take a collection with you
Two things a tablet could not do. Both existed for a pointer and had no touch form at all, which on Android meant the collection sidebar was somewhere to look at rather than somewhere to file into. **Selecting more than one.** Ctrl-click and shift-click are the only ways into a multi-selection, and touch has neither. Holding a cell now enters selection mode, where a tap toggles — reported to Rust as a ctrl-press, so it goes through the same `apply_press` as everything else rather than growing a second copy of the selection rules. A double tap takes the run between where selecting began and there: the touch form of shift-click, and the reason the anchor from *before* the double tap has to be remembered, since both of its taps move the anchor onto the cell being tapped. A "Select" button does the same thing where a gesture would go undiscovered (FR-UI-4). **Filing without a drag.** A one-finger drag beginning in the grid belongs to the Flickable that scrolls it — that is the arbitration working, not a bug to route around — so the selection can now be filed from a sheet listing the sidebar's own rows. Copy by default, as the drag has always been; moving out of the collection being shown is a switch, because it is the one that takes something away. **Taking a collection offline.** The machinery was there and reachable only by scoping the grid to a collection and finding a button behind a disclosure. Holding a collection's name now asks the question directly, and the tray on a row and the header button ask the same one — three affordances doing two different things is how a user comes to avoid all three. The question is asked rather than a toggle flipped because both answers are expensive: one downloads gigabytes, the other deletes them, and the counts and sizes go in the buttons where they are read before the tap. `Cache::release` is new and is the destructive half `unpin` deliberately is not. "Remove the local copies" is asked by someone whose device is full, and withdrawing a promise while leaving the bytes for a future eviction to notice is not an answer to it. It unpins before forgetting, or the next pin fetch would dutifully download everything it just deleted. The sidebar's trays read `tier_actual`, never `tier_desired`: the question is whether these will open on the aeroplane, and a pin whose download has not run yet answers no. TRACES: FR-CAT-7 | FR-NC-6a | FR-NC-6b | FR-NC-6c | FR-UI-2 | FR-UI-3 | FR-UI-4 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
76bb6b2847 | Merge branch 'worktree-watershed-plateaux' | ||
|
|
0b20436445 |
Prove the plateau pass does nothing, and stop paying for it
Picks up the lower-completion work a crashed session left mid-debug, with one failing test and no diagnosis. The diagnosis is that the pass is a no-op. Not "does not reduce basin count" — it changes *no pixel's basin at all*, zero of 9216, comparing one plateau iteration against sixty-four. That assertion is the substance of this commit: the original test asserted a consequence (fewer basins) which a working pass need not produce, so it could have been satisfied by weakening it. A no-op check cannot pass vacuously, and it is what turned an opinion into a fact. Three candidate causes were tried and none was it. Exact float equality is genuinely wrong and is fixed regardless — a gradient computed from 8-bit samples is never exactly equal across a region the eye calls flat, so `==` never fires and `<` fires everywhere; `LEVEL_EPS` now sits behind all three comparisons. The test image is not it either: a flat disc, a terraced disc and a constant-slope ramp all behave the same. The finding worth keeping is about the domain rather than the code. On a gradient-magnitude watershed every flat region of the picture is at gradient zero, the global minimum, and a plateau with no descending exit is a minimum — one basin already, nothing to resolve. The plateaux lower-completion is defined for are regions of constant non-zero gradient, which are rarer in a photograph than F1's phrasing implies. That may be the whole answer, or it may be hiding a fourth cause; I could not close it. So `plateau_iterations` defaults to 0. The implementation stays, correct as far as it goes and costing nothing until someone finishes it; the test stays, ignored with its reason; docs/segmentation.md §12 records what was ruled out so the next attempt starts further along than this one did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b8e9793908 |
Keep offline work out of a directory Android empties
The catalog, the sidecar cache and the export outbox were all landing in the app's cache directory on Android, where the system deletes them without asking under storage pressure. `catalog_path` derived its base from `XDG_DATA_HOME` or `HOME`, and neither is set on Android, so it fell through to `temp_dir()` — which resolves there to `/data/user/0/<pkg>/cache`. Confirmed on the tablet: the app logs its catalog under `cache/darkroom/...`. What sits beside that catalog is not disposable. `sidecars/` is the commit point for every rating and edit made with no connection — the whole mechanism that makes offline culling safe — and `outbox/` holds exports the user has already been told succeeded. A day of sorting on a train, evicted by an OS housekeeping pass before it ever reached the server, is the worst failure this application can have, and it would leave no error and no trace. The fallback is now `SessionStore::data_dir()`, the persistent per-app directory the Android entry point establishes before any store opens — the same one credentials and sessions already use. Desktop is untouched: the XDG data location is still preferred, so nobody's catalog moves. Two tests. One asserts no durable path contains `/cache/` or `/tmp/`; the other pins the outbox to the catalog's parent, because three call sites derive their location that way and a change here moves all of them at once. Found while verifying that offline browsing works on the tablet with wifi disabled — which it does: the app cold-starts with no network, reports the scan failure without crashing, and serves its grid from the local store. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
44f0a4971b |
Show the folder picker on the platform that needs it most, and upload at once
Two faults, both of my own making, reported from the tablet as "I cannot select a location" and "it does not upload". **The picker button was gated on `target-selected == 1`.** That index was Remote's position while both targets were offered. Making the target list platform-aware narrowed Android's to Remote alone, so Remote became index 0 and the button disappeared — on the one platform where the picker is the *only* way to set a destination, since a device folder is not reachable there at all. It is gated on a boolean derived from the target now. An index into a list whose length varies is not a fact about the target, and writing it as one is what made a correct change break the thing it was meant to fix. **A queued export waited for a sync pass.** Staging first is deliberate — an export is finished on disk the moment it is written, and offline is then just a longer queue — but nothing drained the outbox until the next sync, so "Queued for Exports" sat unchanged and read, fairly, as an upload that never happened. A finished batch that wrote anything now drains immediately. The sync-pass drain stays: the first makes an upload feel immediate, the second is what eventually delivers the exports made in a tunnel. Committed without the parallel session's in-flight collection work, which is mid-save and does not compile; verified by stashing it and building this tree alone. 281 dr-ui tests pass, clippy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a8b28136a6 |
Merge the batch export, and settle the seven-branch merge
Resolves the last of the parallel work. Two conflicts worth recording, because both were semantic rather than textual: `render_for_export` gained a colour space on master while the batch branch was rewriting the single-image export path around it. Kept both: the batch request supersedes the synchronous path, and the space still has to be chosen at render time because the conversion happens in the shader before the clip to 0..1. `render_open_frame` takes it as an argument rather than reaching for a controller it does not hold. The map-wait moved into `readback::await_mapping` on one branch while another was editing the constant it used, so `READBACK_POLL_LIMIT` survived the merge with no callers. Removed rather than left for clippy to find later. 1164 tests pass, clippy clean, fmt clean. Traceability 53.0% -> 54.3%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cfff6a3302 |
Merge branch 'zero-copy-display'
# Conflicts: # core/dr-gpu/src/adjust.rs # ui/dr-ui/src/develop.rs |
||
|
|
9fc8721fa8 |
Merge branch 'histogram'
# Conflicts: # ui/dr-ui/src/lib.rs |
||
|
|
7d3c8c521f |
Export a whole selection, on a thread that is not the interface's
The export button rendered, resampled and encoded a 24 MP frame on the UI
thread and the window was dead for all of it. That was written down as a known
compromise, on the grounds that a batch is what makes the wait intolerable
rather than merely noticeable. This is the batch, so the compromise comes due.
The grid's selection now exports (FR-EXP-7). A worker thread takes a clone of
the `GpuContext` — an `Arc` pair over a device and a queue — and opens each
photograph for itself: fetch, sidecar, decode, demosaic, render at full size,
resample, sharpen, encode, write. Nothing of that touches the interface, which
keeps drawing throughout, and the progress goes where every other background
job's does: one row in the activity register, with a count and a bar.
Why the worker does not borrow the session it could have had. A
`DevelopSession` owns the `AdjustPass` the canvas renders from, so handing it
to a worker would stop the develop view drawing for the length of the batch —
the same freeze, moved. Opening a session per image instead costs a
`Demosaicer` and an `AdjustPass` each time round, and the pipeline cache is
per-pass so the composed shader is recompiled per image rather than once for
the run. Against a full-resolution decode, render and encode that is a few
percent, and it keeps this file out of the pipeline `develop` owns. A reusable
export pass is the obvious next economy if a profile ever says so.
The open image is the exception, and it is why the develop button is not simply
a one-image batch. Its edit lives in the interface's session and may not have
reached a sidecar yet, so a worker that re-opened the file would export the
saved version rather than the one on screen. That frame is therefore rendered
by the caller and handed over as `Source::Rendered`; everything after the
render — the Lanczos reduction, the encode, the write, which is the larger half
of the wait and all of its variance — still leaves the UI thread. So the
develop export is no longer synchronous, but it is not fully off-thread either,
and the doc comment says so rather than claiming otherwise.
Cancellation (NFR-ARCH-3) is an `AtomicBool` read between stages, and the
export button becomes the cancel button while a run is live — a batch that
could only be stopped by not touching the selection would be a trap. Waits on
another worker use `recv_timeout` rather than `recv`, so a cancelled batch
sitting on a forty-megabyte download gives up within 100 ms instead of when the
transfer finishes. The honest bound is worse than that: a frame already in
render has no interior stopping point, so the worst case is one image. Closing
that needs the render itself to become interruptible, which is NFR-ARCH-2's
scheduler and not a finer poll here.
Failures are per image and typed (NFR-ARCH-4). One unreadable body, one folder
that cannot be written, one server that went away — each is a message on the
channel, a line in the log, and a count in the summary, and the batch carries
on. A run with any failure keeps its row until it is cleared, because that is
the row somebody came to the list to find; a cancelled run does not, because
they asked for it.
Two collisions that look alike and are not. `CollisionPolicy` is the user's
answer to "a file of this name was already there", and Overwrite is a fine
answer to that. It is not an answer to "the frame I exported four seconds ago
was also called this" — two folders in a library each holding an IMG_0001 is
ordinary — so a name the run has already issued is always stepped past whatever
the policy says about the folder. Both halves are held by tests.
Supporting changes, each smaller than it sounds. `open_session` comes out of
`load_bytes` so the worker shares the JPEG-versus-RAW routing rather than
carrying a copy that would drift; the half that builds a `slint::Image` stays
behind, where it belongs. `LibraryController::selected_image_paths` answers
from the catalog rather than from the loaded window, because selection is by id
and survives a scrub — a selection made before scrolling routinely names
photographs no row holds. `cache_context_for` takes an id for the same reason,
so a batch reads the originals cache instead of re-downloading three hundred
files. `format_date` is shared so `{date}` and the timeline agree about what
day a photograph was taken.
Left undone, deliberately: the batch is sequential, where FR-EXP-7 asks for all
available cores. Four full-resolution frames in flight is tens of megabytes
each and a straightforward way to exhaust a tablet, and the GPU is shared with
the interface in any case. Also undone: exporting with a chosen preset rather
than the current export settings — that is FR-EXP-5's machinery, which does not
exist yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0233df4bf2 |
See what the highlights are doing: a live histogram (FR-DSP-7)
Exposure, blacks and whites were set by eye. Nothing said a highlight had blown — the canvas shows white where a channel is at 250 and white where it is at 255, and the difference is the whole question. **Counted on the GPU, not on the readback.** There is a full frame sitting in CPU memory on every canvas update right now — `AdjustPass::read_output`, the bridge spike S1 removes — and walking it would have been thirty lines and no shader. FR-DSP-7 states the mechanism and not just the feature: "these derive from a GPU-side reduction into a small buffer. Per-frame CPU readback of image data is prohibited." A histogram founded on the bridge would be correct today and deleted by S1, and would meanwhile be the reason the bridge could not go. What crosses the bus here is 4104 bytes whatever the image size. The reduction tallies into workgroup memory first and merges once per workgroup. A photograph is not noise: a clear sky puts tens of thousands of adjacent pixels in one bin, and contending for that single global atomic serialises the dispatch. **On the settled frame only.** `render_now` already knows whether a gesture is still moving — `draft` is the flag `redraw` derives from `was_coalesced` — so the dispatch and its transfer happen once when the slider stops rather than on each of the forty frames a drag emits. Nothing is lost: a histogram flickering past under a finger is not a reading anyone takes. FR-DSP-7 requires exactly this, that it not extend the FR-DSP-3 frame budget. Luma is weighted in 8.8 fixed point — 54, 183, 19, summing to 256 exactly — rather than in floats. Not thrift: it makes the shader's arithmetic reproducible bit for bit, which is what lets the test below be an `assert_eq` against a CPU count rather than a tolerance. ARCH §6.13's line about integer state, applied where it happens to also be free. **What the numbers were checked against.** A flat frame must put all 4096 pixels in one bin and one only. A 256-wide ramp must occupy every level with exactly the same count, which is what catches an off-by-one in the quantisation — a `floor` where a rounding was needed shifts the whole photograph one bin left and looks like nothing at all. And a 101x37 frame of seeded pseudo-random pixels — deliberately not a multiple of the 16x16 workgroup, so the edge tiles run off the image — is compared slot for slot against a second, obvious CPU implementation. Exact equality, no tolerance. The CPU version is a deliberate reimplementation rather than shared code: the bugs worth catching here are ones shared code would commit identically on both sides. Above that, the presentation arithmetic is unit-tested headless, because it is where a wrong answer is invisible. A histogram of the wrong shape looks exactly as plausible as one of the right shape. So: 64 columns because it divides 256 and an uneven fold draws an even ramp as a comb; the peak excludes the end columns, or a night scene scaled against its own black spike is a flat line with no information in it; heights are clamped into the plot; and "0%" is kept distinct from "<0.1%" and from "—", since an indicator reading "clipped" over a figure reading "none" is a panel contradicting itself. Clipping counts a *pixel* with any channel at an extreme, not a channel. Any, because a blown red has no gradation left in it however much green and blue still hold — and it is the saturated highlight, the sunset and the red jersey, that clips first and recovers worst. Per pixel, because counting channels can report 200% of a frame clipped, and a percentage above 100 is a readout nobody trusts again. Two affordances for it, which NFR-A11Y-3 asks for: a bar standing at the end of the plot the tones are piling against, and a figure saying how much. Either alone reads. The panel sits directly under the capture metadata and above every control, because it is what the controls are judged against. It is hand-built rather than generated, and ARCH §4.3a is untroubled: a histogram is not an operation — no parameters, changes nothing, answers a question rather than asking one — and nothing in it reads a parameter out of a descriptor. Three plot colours and a neutral luma trace join the palette. That is the swatch's exception rather than a second one: a per-channel histogram has to say which channel, and no achromatic treatment distinguishes red from blue, so the hue is data exactly as the image beside it is. Held well back from full strength for the reason the theme preamble gives. The bounded, non-parking map wait moves out of `AdjustPass` into `readback::await_mapping`, shared with the histogram's transfer. Thirty lines of load-bearing reasoning about frozen interfaces and lost devices, and two copies of it would have drifted. The histogram describes the frame on the canvas, so it is in the output colour space FR-DSP-7 asks for, and when zoomed it describes the visible region — a photographer inspecting a highlight at 4x is asking about that highlight. A device that cannot build the reduction loses the histogram and keeps the photograph. Still to do for FR-DSP-7: the pixel colour readout under the cursor. 324 tests pass, clippy and fmt clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cf8f5b632f |
Show the develop frame itself, instead of a photocopy of it
The oldest open item in the project (ARCH §6.1, spike S1, AC-8). Every frame
in develop was read off the GPU into a `SharedPixelBuffer` and handed back to
Slint to upload again: ~7 ms at 4K against a 0.28 ms compute pass, 96% of the
frame spent carrying pixels to the CPU and back so they could be drawn where
they already were.
Slint 1.17 will adopt a `wgpu::Texture` directly, and the whole of what that
needs is arrangement rather than code.
**One device, made before the window.** A texture belongs to the device that
allocated it, so the compute passes and the compositor cannot each open their
own. `GpuContext::new_shared` opens one and hands back the instance and
adapter alongside it; `dr_ui::shared_gpu` gives all four to
`BackendSelector::require_wgpu_29(WGPUConfiguration::Manual { .. })`. That
call has to come before the first window, because creating one selects a
backend for you — which is why the GPU is now opened at the top of `run`
rather than two hundred lines down beside the other controllers.
dr-gpu still names no UI type. It hands out raw wgpu and does not ask who is
compositing (ARCH §6.5a).
**Vulkan only on the shared path**, where headless keeps its GL fallback.
wgpu's GL backend reaches its display through EGL at instance creation, and
before a window exists there is no display handle to give it — so a GL
instance cannot later produce the window surface Slint needs from it. A
machine with no Vulkan gets no shared device and browses without develop,
which is the same degradation as no adapter at all.
**`renderer-femtovg` becomes `renderer-femtovg-wgpu`.** The old one is FemtoVG
over OpenGL and cannot be handed a wgpu texture at all. It is not kept
alongside as a fallback: FemtoVG-over-GL has no branch for an imported
texture, falls through to "render this image into a buffer", gets nothing, and
draws nothing — a blank canvas with no error, which is worse than the failure
it would be papering over. The consequence is stated plainly in the manifest:
the desktop app now needs a working wgpu adapter to open a window.
**Two output textures, not one, and this is the part that is not obvious.**
Slint repaints when the image property *changes*, and it decides that with
`PartialEq` — which for two images over the same `wgpu::Texture` says
"unchanged". A pass that reused a single target would have rendered every
slider move correctly on the GPU and shown none of them: right, and invisible.
`AdjustPass` alternates between two targets, so consecutive frames are
genuinely different values. It also settles the read-while-write question that
one queue was already answering.
`RENDER_ATTACHMENT` is added to both render targets. Neither pass uses it;
Slint rejects an imported texture without it, on the reasoning that a
compositor handed a texture may need to draw into it.
**`AdjustPass::read_output` is deleted rather than gated.** It and
`export_pixels` were the same transfer under two names, and the comments
explaining why they were separate are the point of the whole criterion:
reading pixels back to *display* them is the defect, reading them back to
*encode a file* is the only way a file is made. The display twin is now gone
outright, which is stronger than a feature flag — it cannot be turned back on.
`export_pixels` is untouched and still ungated. The `readback` feature comes
off dr-ui, darkroom-desktop and darkroom-android; it stays in dr-gpu, where it
still gates `RenderTarget::read_pixels` and the segmentation field readback.
`examples/develop` moves to `export_pixels`, which is honest — it writes a
PPM — and so no longer needs the feature.
Four tests, each named for what it protects and each of which fails without a
screen if the property it guards breaks:
- the adjust target satisfies every condition Slint's import checks, asserted
in the crate that owns the descriptor, because a descriptor that drifts
fails at runtime on a real display and nothing else would notice;
- consecutive renders are different textures, and the third is the first
again, so the alternation is a rotation and not an allocation per frame;
- the develop canvas has no CPU pixel buffer and does have a wgpu texture —
AC-8 itself, in the terms Slint uses;
- consecutive frames compare unequal as `slint::Image`, which is the property
the repaint actually depends on.
The zoom test's readback moves into the test module. It has to: there is no
library function that copies a displayed frame to the CPU any more, and that
is the point — the round-trip now exists in the test binary and nowhere a
shipping build can reach.
**What is not proven.** No GUI was run. What is verified is that the texture
satisfies the import contract, that the import succeeds, that the canvas is a
texture rather than a buffer, and that consecutive frames are distinguishable.
What is unverified is everything that needs a display: that Slint's FemtoVG
wgpu renderer adopts the Manual configuration on a real surface, that the
picture appears the right way up and the right colour, and the frame timing
that motivated the whole exercise. Android is untouched by testing — the
android backend routes a WGPU29 request to Skia, whose wgpu surface does
handle imported textures, but that is read from the source, not observed.
56 dr-gpu tests and 255 dr-ui tests pass, clippy clean under `-D warnings`,
fmt clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4b36ca66aa |
Render an export into the colour space its file will claim
The colour-managed export branch left one call site deliberately unfixed, and this is it. `render_for_export` composed with the default sRGB shader, so a Display P3 export failed with an accurate error rather than producing a mislabelled file — the right way to leave a half-finished path, and no way to leave it. The space is chosen at render time because that is the only time it can be: the conversion happens in the shader, before the clip to 0..1, so by the time pixels reach an encoder they are in exactly one space and the only honest thing left is to label them. `Frame::in_space` carries which, and a mismatch between what was rendered and what was asked for stays a typed error. Also regenerates the traceability matrix over the four merged branches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1c16ca3d27 | Merge branch 'colour-managed-export' | ||
|
|
8e330a24e9 | Merge branch 'undo-redo' | ||
|
|
c26b6082a2 | Merge branch 'local-libraries' |