ee1009743504f2aedc755d677980f7ed27ec0516
53
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ee10097435 |
Mask the subject the model found, not the regions underneath it
The watershed hierarchy does not survive a photograph, so local masking stops depending on it. A layer can now be one recognised object, and the object's own coverage is the mask. `Options::watershed` defaults off. It costs ~80 ms plus a full-resolution readback to produce a ladder that collapses, and paying that on every photograph buys a control that misleads. Kept switchable rather than deleted: the passes and the hierarchy are correct in themselves and it is the merge criterion that fails, which is a change to one function. Masks now rasterise in **source** space at proxy resolution and are sampled by the composed shader after the framing map. That fixes a real bug: they were rasterised in output space, so zooming slid the photograph underneath a mask that stayed pinned to the viewport, and cropping moved every adjustment to a different part of the picture. Doing it this way also leaves the framing map in exactly one place — a second copy in the mask shader would have been a second thing to keep in step, failing only when straightened. A subject is stored as identity, not pixels: the mask is megabytes and is reproducible by running the same model over the same image, so the sidecar carries the index, the class and the score, and the session carries the pixels. The class is there to be checked — if instance 3 comes back a "car" where it was a "dog", something changed and the layer is stale rather than silently masking the wrong thing. The overlay now draws instances and is transparent everywhere else. The region version covered every pixel and so hid the photograph it was drawn over; the question it exists to answer is whether an outline follows the subject, which you can only answer by seeing both. `examples/local.rs` is the worked example: subject in colour with the rest monochrome, and the subject lifted out of its background. Run on a 5472x3648 CR2 it finds two people and two cars, and the colour-pop keeps her hat and hair while the wall and grass behind go grey. |
||
|
|
b1433ad4a9 |
Give a mask an edge treatment, and find out the watershed has none worth having
Two things, and the second is why the first matters more than expected. Mask layers gain a feather, a falloff curve and a morphology, all defined against a signed distance from the boundary rather than as separate features — one exact distance field answers "how soft" and "how far" at once, so dilation is a threshold at -r, erosion one at +r, and closing and opening are one of each in sequence. The compound pair costs a second distance field, which is why they are named rather than presented as a radius that happens to be signed. Types, defaults and sidecar round-trip only; the field itself is next. `edge-feather` and `edge-falloff`, not `feather` and `falloff`, because a radial mask already writes `feather` for the fraction of its radius it ramps over. Same word, different quantity, different units — sharing the key would have made an existing file ambiguous. The diagnostic that provoked this is committed as an ignored test, because "does the ladder land on things a person means" is the question S15 exists to answer and it should not depend on whoever still has the script. On bus.jpg it answers badly: 35,075 regions at blur 2 over an 810x1080 frame, and cutting that to 400 gives *one* region covering nearly the whole picture plus 399 noise specks. Not over-segmentation — collapse. Almost every saddle is near zero, so the merge order joins everything meaningful before it joins anything spurious, and a global cut spends its entire budget on grain. So the granularity ladder does not currently work on a photograph, and the region masks built on it inherit that. Recorded rather than worked around: the next commits move local masking onto the model's instances, where the edge treatment above is what makes a quarter-resolution mask usable. |
||
|
|
12d320cf33 |
Record what the spec got wrong about the model that exists
docs/segmentation.md §4 priced arm B as costing a C dependency under the NDK and treated that as most of the difference between the arms. It is not a cost that has to be paid: `ort`'s `alternative-backend` disables its linking entirely and `ort-tract` supplies the API from tract, which is pure Rust. D13's "largest exception the policy would tolerate" turns out not to be needed, and the answer generalises to the face pipeline — so D13's runtime half is now answered and only its licensing half is open. Three findings contradict §4 outright and are recorded as F4-F6 rather than quietly designed around. There is no ADE20K-trained YOLO, so the shipped vocabulary selects subjects and not stuff — "select the sky" comes from the watershed or from nowhere. It is instance segmentation, so it partitions nothing and two people come back as two instances. And tract cannot parse a dynamic-shape export, which fixes the input at 640 square and makes tiling the only route to more semantic resolution. Arm C ships, but §8's criteria are not what decided it, and saying so matters more than claiming the process worked. §8 asked for a two- interaction margin over arm A on a traced corpus. That comparison was never run: F4 and F5 changed what the arms are, and a model that recognises subjects but has no word for sky cannot be a selection tool alone, while a watershed cannot tell a person from the wall behind them. They stopped being candidates and became complements. What is *not* done is written down as plainly: the 24-image corpus is untraced, so M1-M4 have no numbers and "this feels right" has not become one. M5 is answered on one device only, and region ids now reach the sidecar — so a cross-vendor divergence would mean a mask written on the desktop meaning something else on Android. F3 stands. |
||
|
|
9b4f0815e5 |
Show the regions, click one, and adjust it
The local panel sits above the adjust panel because it decides what those sliders act on; below it, a photographer would set an exposure and only then discover which scope it landed in. Selecting a layer re-scopes the existing controls to that layer's chain — there is no second set of sliders, and there must not be, or every operation added to `ops/` would need a local twin. The overlay is drawn over the canvas rather than blended into the render, because it is a diagnostic and not an edit: it must not reach the histogram, an export, or the texture handed to the compositor. Nearest- neighbour always — the map's values are *names*, so smoothing between region 4 and region 9 invents a colour belonging to neither and softens exactly the edge the overlay exists to show. Picking gets its own touch area above the pan handler. Panning wants press-drag-release and picking wants a click; interleaving them in one handler is how a drag ends up selecting a region the user was scrolling past. Shift is tracked as window state because a TouchArea's click carries no modifiers. Three states a layer can be in are worth distinguishing, and each has a different remedy: stale needs re-segmenting, "no adjustment yet" needs a slider moved, and the ordinary case needs nothing said. A bare selection renders nothing and looks identical to a broken mask, which is the first thing a new user will hit. Known rough edge, commented where it happens: segmentation blocks the UI thread for about half a second. Moving it to a worker needs the develop session — GPU resources behind a RefCell shared with every callback — to be reachable from another thread, which is a restructuring rather than a change to the call. The button says "Finding regions…" first so the stall is announced rather than looking like a hang. |
||
|
|
3085ec4d2e |
Leave the stars on screen on a touch device
**Hover is not something a finger does, but Slint reports it anyway.** `has-hover` goes true for any pointer event carrying a position, a touch press included, and false again on the `Exit` that follows the release. So the rating strip did appear on a tablet — for exactly the length of a tap. It flashed on under the finger, vanished as it lifted, and the tap carried on through to the cell and opened the photograph. An unjudged frame could not be rated from the grid at all. The previous fix stopped the strip disappearing when a *pointer* moved onto it; this is the same symptom with a different cause, and hover was the wrong signal in the first place. The strip now stands open where the session is a touch one. That is seeded from the platform rather than inferred, because inference needs a press to reach a cell and a quick flick never delivers one — the Flickable claims the gesture before the delay it would forward after — and a control that only appears once the finger is down has appeared too late to aim at. The grid still latches on the first non-zero touch id it sees, which is what covers a touchscreen on the desktop. One-way on purpose: a tablet with a mouse plugged in keeps the strips once touched, which is the harmless direction to be wrong in. The alternative is chrome that comes and goes as the user changes hands. |
||
|
|
2fba685e16 |
Make a pinch zoom the grid and nothing else
Two faults left over from making the gesture reach the grid at all. **It still opened photographs.** Checking the finger id stops the synthetic release Slint emits when the *second* finger lands, but not the other end of the gesture: lifting one finger of two leaves the other one down, and Slint replays that survivor as a fresh `Pressed` on whatever is under it — which is how it hands the pointer back to ordinary handling. Under it is a cell. So the cell was selected, and lifting that last finger was a complete, well-formed click on the same finger that pressed. No part of the event stream distinguishes it from a real tap, so the grid now remembers that a pinch just happened: a latch raised when the gesture starts and lowered a beat after it ends, during which cells take neither presses nor clicks. The press that *opens* a pinch is undone rather than suppressed — it has already happened by the time a second finger makes it a pinch. Undoing it has to be exact, or a cancel that restored the selection but left the anchor moved would make the next shift-click select a run from a cell nobody pointed at, so capture and restore are a tested pair. **And it was not smooth.** Two reasons. The pinch was thresholded into ±1 steps of 25%, so the grid lurched and then sat still; it now takes the ratio since the last update and tracks the fingers, with the drawn cell still landing on whole column counts because the columns divide the width. And `zoom-cells` was the one geometry change still reloading inline — a full catalog re-read, 360-row model rebuild and thumbnail batch per step, on the thread drawing the frame. It goes through the same settle timer as the rest now. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
7f524d2fd0 |
Give a mis-drag a way back
Develop edits now save themselves to a sidecar the moment you leave the image, so until this there was no way to undo one — the mistake was persisted and the only recourse was to remember the old number. The history is a stack of snapshots, because the edit graph is already plain data: `Preset::capture` reduces it to what differs from default and `Preset::apply` puts it back, so undo is those two calls and nothing else. A command object per action, with an inverse beside it, would have been a second thing every operation had to register — and operations are declared in YAML precisely so that a new one needs no code written for it. A snapshot cannot fall behind them. The interesting part is coalescing. A slider drag emits an event per frame and must be one step, not forty. Nothing in the interface reports a gesture boundary — the same wall the render coalescing hit, and it is answered the same way rather than by threading a "finger is down" out of every slider, curve point and crop handle. What stands in for the boundary is the control plus recency: changes to the same control within 700 ms amend one step. Which control is "the same" is asked of the graph, not listed: an operation whose declared presentation claims a parameter is one where a single gesture moves several — a curve point carries an x and a y — so those coalesce as one widget. Nothing in the history names the tone curve. The compromise, and it is a real one: a control let go of and picked up again within the window is one step rather than two. Buying the other answer costs a gesture-boundary signal on every control, which is more surface than the difference is worth. The stack is bounded at 64 states for NFR-RES-1 — a develop session stays open for hours. Sixty-four rather than a byte cap: what is being bounded is steps a photographer would want back, and a byte cap would give the elaborate edit the shallowest history, which is exactly backwards. The session owns its history and every mutator records into it, so the callbacks in `lib.rs` cannot change the edit and forget to — with a dozen generic callbacks that would have been one press of undo away from wrong every time a control was added. Opening a photograph makes its stored edit the floor rather than a step: it is not work done in this sitting, and an undo reaching behind it would discard a previous session's edit and then save that on the way out. Not yet done, from FR-DEV-5: history is per-session and in memory, and there are no named snapshots. What mattered was that a saved mis-drag had no way back at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2330ed25e9 |
Let a grouped slider be dragged, by flattening the panel that drew it
Build and test / Desktop (Linux) (push) Failing after 1h4m48s
Build and test / Layer separation (push) Successful in 34s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Failing after 1m4s
Build and test / Android (aarch64) (push) Failing after 9m43s
White balance, highlights and shadows, and the mixer took a press, jumped once, and went dead under the finger. Exposure and contrast dragged perfectly — which is what made it read as a slider bug rather than a layout one. The panel nested. A group's head row drew the *whole* group, repeating over `row.group-len` and indexing back into `root.rows`; every other row drew nothing. So the inner repeater's model was read off the head row, and depended on that row's identity. Moving any parameter in the group rewrites that row — its own value changed, or `group-modified` flipped for its neighbours — which re-evaluated the repeater, rebuilt its items, and destroyed the `TouchArea` holding the live gesture. A lone parameter had no inner repeater, so the five single-parameter operations were never affected. Now each row draws only itself, so an update touches one control and nothing structural. The facet heading comes off `starts-facet`, which Rust already marks on the first row of a run, and the group heading is drawn by the row that heads it. It also retires the old hazard of a head row building every control in its group — thirty-six live TouchAreas behind the mixer's twelve visible ones. The same identity hazard reached the rows themselves through `ModelRc`, which compares by identity rather than contents: a fresh empty model per row per call made every row differ from itself, so `sync_rows` rewrote all of them on every event. `points` now shares one empty model as `choices` already did. Both are held by tests, because the failure is invisible in a still — every value is right and the panel looks perfect. Curve rows are excluded: their points model carries live coordinates, is rebuilt by design, and `sync_rows` writes values through the existing model rather than swapping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cb1d2be240 |
Choose the export folder by walking the server, not by typing it
The destination for a Nextcloud export was a text field. Nobody recalls the exact spelling of a path three levels down, and getting it wrong does not fail — `create_dir` makes whatever was typed, so a misremembered folder becomes a new one at the root and the exports are somewhere nobody looks. So it is picked the way the library root is picked, using the same `FolderBrowser` model the launch screen drives: up, into, and "use this folder", confirming the folder currently *shown* rather than one selected in the list. Same rule in both places, so the phrase means one thing. The model is shared; the worker is not. `settings_ui::spawn_folder_list` is a near-twin of the launch screen's, because that one reaches into the `LaunchController` for its session and reports onto the launch screen's error line, while this one is handed credentials and writes to the settings page. Factoring them together needs a function taking both controllers or a trait implemented twice to abstract two call sites — more machinery than the twenty lines it saves. What matters is shared already: navigation behaves identically because both drive the same model. The callbacks are wired in `lib.rs` rather than in `settings_ui::wire`, because listing a remote folder needs credentials and the settings page holds no session on purpose — it is reachable before a library is opened and must not depend on one existing. With no account the picker says to sign in first, rather than showing an empty list that reads as a server with no folders. Details that are decisions rather than accidents: the picker opens at the library root rather than at whatever half-typed path is in the field, which would list nothing and look broken. The listing area is a fixed 180px, since a folder with sixty children would otherwise push the rest of the settings page off the bottom. "Up" is disabled at the root rather than hidden, so the row does not jump as the user navigates. A failed listing leaves the picker open on the folder it was showing — where the user had got to is not something to discard over a dropped request. And the chosen folder saves immediately like every other setting on a page that has no Save button. The poll timer lives on the controller for the reason `LaunchController` keeps its own there: a `slint::Timer` stops when dropped, so one local to the function that starts it would be collected before the listing arrived. Carries in-flight work from a parallel session — a segmentation pass in dr-gpu, a sidecar cache, and the develop panel's continuing changes. 1020 tests pass, fmt clean. One clippy warning remains and is not mine: `sidecar_cache::dir` is unused while that work is in progress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e00c99b864 |
Let a photograph leave: an export button, and a cache to leave from
dr-export could turn a frame into bytes and nothing could ask it to. This is the button, and the place the bytes go. **Everything is staged first.** An export bound for the server is written to a local outbox and uploaded afterwards; offline is not a special case, it is the same path with a drain that finds the server absent. Doing it the other way — upload directly, stage only on failure — makes the failure path the one that is rarely exercised and always broken, and a network drop mid-batch leaves some exports existing and some not with nothing recording which. Staged first, an export is finished the moment it is written and the upload is a promise kept later. The outbox sits beside the catalog rather than under the cache. dr_catalog's cache already draws that line: passive entries are a convenience and go under LRU, pinned ones are a promise and never do. An export awaiting upload is a promise — the user was told it succeeded — and sweeping it for disk would destroy the only copy. Bytes are written before the destination record, so a kill between the two leaves an orphan the drain ignores rather than a record pointing at nothing. The status line says "Queued for Exports/2026", never "Exported to Nextcloud", until it has actually landed. There is a test asserting that wording, because the tempting shorter sentence is a claim the app cannot keep. The drain runs on the sync pass, before the shards: a thumbnail shard can be rebuilt from the originals and the catalog is an index, but a queued export exists nowhere else. `DevelopSession::render_for_export` renders the framed size rather than reusing the frame on screen, which is deliberately viewport-sized (FR-DSP-1) — encoding that would hand the user a soft, screen-sized file with nothing to say anything had been lost (FR-EXP-9). One compromise, recorded rather than hidden: the export runs synchronously on the UI thread, so the window is unresponsive for the few hundred milliseconds a full-resolution render and encode takes. Moving a DevelopSession and its GPU pass to a worker is a larger change than one button earns, and it is batch export that makes the wait intolerable rather than merely noticeable. Still missing: the Nextcloud folder *picker*. The destination is typed into Settings for now. `FolderBrowser` in launch.rs is already the reusable model for it — it browses a remote tree and nothing about it is specific to choosing a library root — but wiring it into the settings page needs a listing worker and browser UI there, which is its own piece of work. Carries in-flight work from a parallel session — presets, the develop copy and paste, and the node schema's `presentation` and `enum` support. One misplaced callback in settings_ui.rs is moved from `render` to `wire`: registered in `render` it borrowed a `&SettingsController` into a 'static closure and would not compile, and that file's own docs say render pushes properties while wire connects callbacks. 992 tests pass, clippy and fmt clean. Traceability 48.3% -> 51.0%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0a331c717e |
Give the controls a vocabulary, and let a node ask for one
widgets.slint set the rule — screens consume components, and a bare `Theme.*` at a call site means a component is missing — and it set it for chrome only. The controls never got the same treatment, so they were written wherever they were first needed and copied from there. **The slider was private to the develop panel.** `SliderTrack`, with the fifty-line preamble explaining how it wrests a drag away from a Flickable, lived inside adjust.slint and no other screen could reach it. It shows: export quality is a 1-to-100 value, and the settings page offered a free-text box for it, with the range written in a hint and enforced nowhere. `to-float()` answers 0 for anything it cannot parse, so a typo saved a quality of 0 and the page displayed the 0 back as though it had been asked for. The tick-box was written twice, in launch.slint and settings.slint, from the same 18px box and the same handler; the second carried a comment deferring the lift until a third caller appeared. The label-and-hint header was written three times inside settings.slint alone. controls.slint is the input layer beside widgets.slint's chrome layer, and the constraint that makes it reusable is that **nothing in it knows about `ParamRow`** — that struct is the develop panel's flattening of the capability model, and a control that imported it could only ever be used by the develop panel. The primitives take plain numbers; the ParamRow-shaped wrappers stay in the panel that owns the model. 658 lines came out of the three screens. `SliderRow` is the slider-plus-number-box ARCH §4.3 names as the pointer presentation of a bounded scalar, and quality is its first adopter. It commits on gesture end rather than on every movement, because the settings page saves to disk on change and a two-second drag is a couple of hundred writes where a text field committed once. The develop panel keeps the live stream — that is what its pipeline is for — so `SliderTrack` now reports both. **The other half is the descriptor.** FR-DEV-3a and ARCH §4.3a already specify more than was built: an ordered preference list of widgets rather than one, the demands a widget makes, and kinds beyond scalar and bool. - `Presentation.widgets` is now a list, walked by `choose`, falling back to plain sliders. Falling off the end is not an error, and there is a test asserting an operation asking only for an unimplemented widget still yields one control per parameter. - `WidgetDemand` carries what a widget inherently needs — two-dimensional dragging, precise pointing — and no pixels, breakpoints or platform names. - `WidgetKind` grows to the specified set. There is deliberately no `Colour` *kind*: a colour is three numbers, and a value type that is not an `f32` would reach through the graph, the uniform block and the sidecar format to buy what `ColourWheel` over three scalars already describes. Every widget here is a hint over ordinary scalars, which is what keeps the fallback honest. - `ParamKind::Enum` is the one new shape, and it fits because a variant index is exact in binary32. `kind: enum` with a `variants:` list works in `ops/*.yaml`, so a node declaring one gets a segmented control with no UI file edited — which is the promise ops/mod.rs already makes. The panel's dispatch was duplicated: a lone parameter and a grouped one each wrote out their own list of kinds, so `enum` would have had to be added twice and a kind added to one would appear or vanish depending on how many parameters its operation happened to declare. `ParamControl` is now the only such chain. `rows_from` is free-standing rather than a method, which is what lets the FR-DEV-3c acceptance test requirements.md asks for actually be written: an operation the frontend has never heard of, appearing in a generated panel, with no GPU in sight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e7130ff891 |
Give the library header somewhere to put six buttons
A tablet in portrait is 1920 physical pixels at density 400 — 768 logical, which is below the 820 breakpoint, so the library was already taking the compact layout. The compact header simply was not compact: one flat HorizontalLayout holding a menu button, a title, three status readouts and six buttons. Slint's HorizontalLayout has no wrap and no overflow. Given less width than its children want it shrinks each to its minimum and lets the rest run past the edge, so "Change library" arrived as "Change li…", the readouts elided to nothing useful, and the row overflowed anyway. The six actions move into a `HeaderActions` component drawn in one of two places: inline in the header when expanded, and in a disclosure row under it behind a "More" button when compact. One component rather than two copies, because the alternative is six buttons with their visibility rules and callbacks written twice, and the copy that rots is the one behind a disclosure nobody opens while testing. The row closes itself when the window widens, so rotating to landscape does not leave a stray toolbar behind. "More" is a word rather than an ellipsis glyph: "⋯" sitting in a header of elided labels reads as one more truncation, which is the failure this change exists to remove. `LibraryGrid` takes `expanded` to decide, from the window width rather than the device (FR-UI-1) — so a narrow desktop window gets the same treatment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
151dcc3c02 |
Make a file out of a photograph
Export existed as a settings page and nothing else: format, quality, colour
space, five sizing modes, a filename template and a metadata switch, all
configurable in detail, and no way to produce a single file. dr-export is the
other half.
**It returns bytes and a name, and writes nothing.** An export has three
destinations with nothing in common — a path on Linux, a SAF document on
Android where there is no path at all (ARCH §6.9), and a PUT to a Nextcloud
folder — so a crate that opened the file itself would serve one of them and be
rewritten for the other two. The caller places the bytes.
Resize, then sharpen, then encode, in that order and for a reason: output
sharpening compensates for the softening the resample introduced, so its
strength scales with how much scaling actually happened, and sharpening before
shrinking would throw the result away. Lanczos-3, separable, with weights
computed once per output row — FR-EXP-4 asks for Lanczos or better because a
box filter turns a distant fence into moiré.
Collision handling takes the "is this name taken" test as a closure rather
than looking at a directory, because there is no directory it could look at
that works everywhere. That shape is not politeness toward Linux: Android's
createDocument renames on collision by itself and cannot overwrite at all, so
all three CollisionPolicy settings need the answer *before* anything is
created. Overwrite, Skip and Increment are each tested, and Increment gives up
after ten thousand rather than spinning against a destination that reports
everything as taken.
Three things are honest rather than done:
- **Colour space.** sRGB only. The shader encodes and clips to sRGB before
this crate sees a pixel, so tagging a file Display P3 would claim a gamut
it does not contain. Refused with a typed error instead of mislabelled;
honouring it is a pipeline change (FR-EXP-2).
- **AVIF and JPEG XL.** No encoder. libaom and libjxl are C, ravif is slow
enough to change what a batch feels like, and the settings page offers
both because FR-EXP-1 lists them — so asking for one says so rather than
writing a JPEG under a .avif name.
- **16-bit TIFF** is a real 16-bit file carrying eight bits of information,
because AdjustPass renders to Rgba8Unorm. Widened by *257, not <<8, so
white lands on 65535 rather than a quarter-percent grey. Making it mean
what it says needs the composer told what format to write.
Metadata is not written at all, which satisfies the half of FR-EXP-8 that
matters most: strip_location defaults to on, and a file with no EXIF block has
no GPS tag. Retaining camera and copyright when asked is not implemented and
cannot be faked by omission.
Also here:
- `AdjustPass::export_pixels`, ungated where `read_output` is behind a
feature. The two are the same transfer and opposites in intent: reading
pixels back to *display* them is what ARCH §6.1 forbids and AC-8 asserts
against, while reading them back to encode a JPEG is the only way a file
has ever been made. Separate methods so the instrumentation can count one
without counting the other.
- `ExportTarget`, so a destination can be a folder on the server. On Android
that is the only destination needing no platform work whatsoever — a PUT
against create_dir, already on the RemoteBackend trait, behaving
identically on both platforms. Switching target clears the destination,
since a path is not a remote folder and carrying one across would offer to
create a folder called `home` at the library root.
Verified end to end rather than by unit test alone: `cargo run -p dr-export
--example export` decodes a frame, runs the develop chain on the GPU at full
resolution, reads it back, and writes all five formats — 27 ms for a
full-size JPEG, 165 ms with a Lanczos reduction to 1200px. ImageMagick agrees
the 16-bit TIFF is 16-bit. dr-export cross-compiles clean for
aarch64-linux-android; all three encoders are pure Rust, which is why they
were chosen. 944 tests pass, clippy and fmt clean.
Not yet wired to a button. The develop view has no export action, so nothing
in the running app can reach any of this yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b08e94405c |
Give the activity row a name Slint will accept
`ActivityItem inherits VerticalLayout` declared `in property <ActivityRow> row`, and every element that can sit in a GridLayout already carries a `row` for its grid placement — so the declaration was an override of a built-in rather than a new property, and the compiler refused it. dr-ui had not built since. Renamed to `job`, which is also the better word: the property is one background job, and `row` described where it was drawn rather than what it holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f7e8cc99b1 |
Call them marks, not glyphs, now that they are drawn
The last comment left over from the icon change in
|
||
|
|
65e6a96a65 |
Say what the application is doing, in one bar and one list
Every background job reported into a window property of its own — library-thumbs-done, library-pin-total, library-syncing — which only the grid ever read. A pin download that outlived the view it was started from drew nothing at all once the user opened an image, and there was no answer anywhere to "what is this busy with", because the answer was spread across eight properties nothing collected. They report to one register now (ui/dr-ui/src/activity.rs). It publishes an aggregate, which draws a three-pixel bar across the top of the shell in every view, and a row per job, which the settings page lists: scans, thumbnail batches, pin and open downloads, sidecar uploads, the sync and the trash. Failures stay on the list until they are cleared; routine successes do not, or a scroll would bury them. The handle removes a still-running job when it drops, so a worker that dies mid-transfer takes its row with it rather than leaving the bar sweeping for the rest of the session. Also carries in-flight work from a parallel session — the drawn icon set and the dr-pipeline ops split. dr-pipeline's build script does not compile at this commit; ui/dr-ui does, with clippy clean and its tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
70435b712e |
Walk the grid with the arrow keys, and open with Return
Build and test / Desktop (Linux) (push) Successful in 17m36s
Build and test / Layer separation (push) Successful in 34s
Traceability / Requirement traces (push) Failing after 25s
🐳 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 9m17s
A cull is thousands of decisions. The grid already bound 0-5, P, X and U so the judgement itself needs no mouse, and then made the photographer reach for one to move to the next frame — which is most of the gesture, most of the time. Arrows now move a cursor, shift+arrow extends the selection, Home/End and PageUp/PageDown cover ground, and Return opens what the cursor is on. The cursor is a library ordinal, not a row of the loaded window. The window is a few screenfuls around wherever the user is looking, so a cursor held as a row would stop at its edge or keep counting into cells belonging to other photographs; walking out of the window reloads it around the new position, exactly as scrolling does. This is the argument the selection already made by keying on ids rather than rows. The anchor had that fault for real: it was a window row, so a shift-click after a scroll extended from whatever image had since drifted into it. It is an ordinal now, and apply_press takes the window's offset to map between the two. A range longer than the loaded window truncates to what is loaded, since selection is by id and an image the catalog has not been asked for has none. The pointer and the keyboard share select_row so the two cannot drift apart. The one deliberate difference: a plain arrow collapses the selection onto the cursor, where a plain click on an already-selected cell leaves it alone — that exception exists so a multi-image drag can start from one of its members, and there is no drag behind a keystroke. The grid scrolls to the cursor only when the cursor leaves the viewport, and then by as little as will do it. Reusing the scrub's seek() would put the cursor's row at the top on every press, which makes a row unreadable as you walk along it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9b2ee0d0eb |
Show the colour mixer as three runs of twelve, each row a colour
Build and test / Desktop (Linux) (push) Successful in 17m20s
Build and test / Layer separation (push) Successful in 33s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Failing after 26s
Build and test / Android (aarch64) (push) Failing after 8m59s
The mixer was thirty-six sliders reading "Hue / Sat / Lum" twelve times over with nothing saying which band any row belonged to. The identity was there all along — the descriptor declares param.mixer.orange.sat and BANDS carries orange at 30° — and was discarded on the way out: labels.rs had no mixer entries, so every key fell through to a derived label that yields the bare channel name. A parameter can now say which aspect it adjusts and which subject it adjusts it on, with the subject's hue where the subject is a colour (descriptor::Facet). That is data about what the operation does, not a layout: the mixer genuinely weights pixels around 30°. What to draw from 30°, and in what order to stack the runs, stay in dr-ui (ARCH §4.3a) — develop.rs brings rows sharing an aspect together and marks the first of each, and adjust.slint names the run once and draws a swatch, a track and a readout on one line. Grouped by channel rather than by band because an edit is almost never "everything about orange"; it is the saturation of the greens, made by comparing one channel across neighbouring bands. Twelve band sections put those twelve rows in twelve different places. The swatch is the label, which is what makes twelve rows fit where four did. The band name is not lost: it is the row's accessible label, so the control is not colour-only, and labels.rs is where the mapping is written down — including chartreuse as "Yellow-Green" and spring as "Blue-Green", since nobody hunting foliage scans a list for "Spring". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ab4a7e00e7 |
Leave the adjust panel somewhere to scroll from
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Desktop (Linux) (push) Successful in 19m8s
Build and test / Layer separation (push) Successful in 35s
Traceability / Requirement traces (push) Successful in 29s
Build and test / Android (aarch64) (push) Failing after 9m0s
A strip down the right-hand edge of the panel that no control reaches, so there is always somewhere to put a thumb that means "scroll" and nothing else. This is the other half of the slider arbitration. A `SliderTrack` stands the Flickable down the moment a finger touches it, which is what makes dragging an adjustment reliable — and the price is that the track can no longer be dragged past. What was left to scroll from was the ~20px band of label between one control and the next, which on a panel that is mostly tracks means aiming rather than reaching. Reserving the space outright is the honest version of what had been left to chance. Padding rather than a spacer element, and that is what makes it work: the strip is inside the Flickable but no child is laid out into it, so nothing puts a TouchArea over it. A press there reaches the Flickable directly, with no arbitration to lose. A full touch target wide (FR-UI-3). A gutter too narrow to hit confidently would be the problem it was added to fix, in a smaller space. It costs the tracks about 44px of a 280px column, which leaves travel enough that the readout still moves a step per pixel at the precisions the descriptors ask for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
67c0237ddd |
Make one slider, and take the lids off the develop column
Build and test / Desktop (Linux) (push) Successful in 20m28s
Build and test / Layer separation (push) Successful in 36s
Traceability / Requirement traces (push) Successful in 1m6s
🐳 Android image / Build and push (push) Successful in 13m45s
Build and test / android-image (push) Successful in 13m47s
Build and test / Android (aarch64) (push) Failing after 9m16s
Two complaints from a tablet, with one cause between them. **Some sliders dragged and others only answered a tap.** They were not the same control. `ParamSlider` read its geometry from a `ParamRow` for the generated panel and `PlainSlider` took plain numbers for the straighten angle, each with its own track, handle, hit area and gesture rules written out separately — and a comment arguing the duplication was safe, because "a slider that dragged differently depending on which panel it sat in would be a worse inconsistency than the duplication". That is exactly what happened. The touch arbitration fixed in the previous commit went into `ParamSlider` and `CurveEditor`; `PlainSlider` kept the old code, so two sliders in the same sidebar behaved differently and which one you got depended on where you were dragging. The duplication failed to survive its first change. There is now one `SliderTrack`, owning the track, the hit area, the claim test, the hover arbitration, click-to-jump and double-click reset. The two wrappers differ only in where their numbers and labels come from. **Nothing in the develop column collapses any more.** Every group was a `Section` with a disclosure triangle, including five operations that carry a single parameter — so the lid was most of the row, wrapping one slider whose own label repeated the heading word for word. A control behind a lid is one the user does not know the pipeline has. `GroupHeading` keeps what the section was actually for: the name, the dot that says something inside differs from its default, and the reset. The reset is now permanently visible rather than appearing on hover, because a hover-only control is one no finger can reach. IMAGE goes back to flat as well. The column as a whole still closes, from the status strip — which is the control that was wanted, at the level it makes sense at. This costs no vertical space: `Section` defaulted to expanded and nothing ever set it otherwise, so the panel was already unfolded and the lids were overhead with no saving behind them. Verified with cargo test -p dr-ui (192), clippy at -D warnings, and an arm64-v8a release build installed on a tablet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
94a2686dcb |
Fit the interface to the system bars, the finger and the back key
Four faults that only show on a device, and one that was hiding on the desktop too. The system bars. Target SDK 36 forces edge-to-edge, so the window spans the display and the develop status strip was drawn underneath the clock and the wifi icons. Slint already computes the inset from Android's OnApplyWindowInsetsListener and exposes it as Window.safe-area-insets; nothing read it. The four views now sit inside a shell placed within the safe area. Every inset is zero on the desktop, so that layout does not move. Sliders under a finger. A Flickable steals any gesture that drifts more than 8 logical pixels along its scrolling axis within half a second of the press, and it steals it by cancelling the child. ParamSlider's axis test correctly declined to claim vertical drags, but nothing told the Flickable to stand down once a drag was claimed — so an adjustment would start moving and then be taken away mid-motion. A mouse holds a horizontal line closely enough to stay under 8px; a finger does not, which is why these worked on the desktop and not on the tablet. The claim now sets `interactive: false` for the rest of the gesture. The tone curve had the same fault and worse: its points are dragged vertically, which is the Flickable's own axis, so every drag was stolen — on the desktop as well. The back gesture. Nothing handled it, so back closed the application from anywhere in it. Android delivers it as Key.Back to the focused item and bubbles it up the ancestors, which is the second reason the shell wraps the views rather than sitting beside them. The order is innermost first: settings, then crop, then zoom, then develop to the grid, then a collection scope. Answering false at the top of the stack leaves Android to close the activity, as it does for every other application there. Escape does the same on a keyboard. back_step is a pure function over a flat NavState so the ordering can be tested without a backend: which of two states is left first is the whole of the feature, and it is the part that is easy to get subtly wrong when spelled out in nested ifs over live properties. Develop's canvas now takes focus on show. Without it the arrow keys did nothing until the canvas was clicked, and Key.Back had no focus item to bubble from. Panels that close. The collections sidebar and the develop column are collapsible from the grid header and the status strip. The layout class now supplies only the default: a panel closed to see more of a photograph stays closed while the window keeps its shape, and the choice is dropped when the class changes, because rotating a tablet asks a different question from the one answered in landscape. IMAGE became a Section, being the only group in the column that could not be put away and the one whose content is read first and needed least. Pinch to zoom on the develop canvas, anchored on the midpoint between the fingers (FR-UI-4). The wheel is the desktop's answer and there is no wheel on a tablet. The develop status strip was 28px against the 44px headers on the library and settings pages either side of it — the one screen where a way out has to be found was the one drawn smallest. All three now agree. Verified with cargo test -p dr-ui (192 passing, 6 new), clippy at -D warnings, and an arm64-v8a release build packaged to an APK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4d78041d1d | Many imorovments | ||
|
|
fa12afed18 |
Keep originals on this device, by pin and by use
Fills in `image_cache`, which the previous commit's "On this device" filter read but nothing wrote. Also carries in-flight work that shared these files: the Android TLS root store, the settings page, and a regenerated traceability report. # Two populations, deliberately separate An original is kept here for one of two reasons, and conflating them produces the exact failure the feature exists to prevent. **Pinned** originals were asked for. Pinning a collection before a trip is a promise, so pinned rows are never evicted and never counted against the budget — a cap that could silently delete a pinned trip would make pinning worthless, because it could not be relied on without checking. **Passively cached** originals are a side effect of working: develop already downloads the whole file, so keeping it costs no bandwidth and saves the entire transfer next time. This population is what the budget bounds, evicted least-recently-used, because it otherwise grows until a day of culling fills a disk. Sharing one budget would let a large pin starve the passive cache, or let browsing evict a pin. They are separate. # What was built `dr_catalog::cache` owns the bookkeeping — held tier, size, last use, pinned — and writes the bytes; deciding to download stays with the caller, which is what keeps a crate with no network out of the network's business. Files are written to a temporary and renamed, so a dropped connection cannot leave a truncated file recorded as a complete original. They are named by image id, not filename: `Photos/IMG_0001.CR2` and `Trips/IMG_0001.CR2` are different photographs, and a flat cache keyed on the name would serve one for the other. `spawn_full_fetch` became read-through. A hit is a disk read; a miss stores what it downloads and enforces the budget. A cache that cannot be opened is a miss, not a failure to open the photograph. Pinning writes intent — `tier_desired` — without downloading, so the button responds immediately, and `spawn_pin_fetch` fills it in sequentially afterwards. Sequential because these are tens of megabytes each: the lanes that make the thumbnail sweep fast buy little against one connection's bandwidth and cost a great deal of memory. A pin interrupted by a lost connection resumes from where it stopped. Schema v5 adds `pinned` and `path`. `pinned` is a column rather than something inferred from `pinned_by_rule`, which is ON DELETE SET NULL and so cannot answer for an image whose rule was deleted. A v4 catalog migrates in place; existing rows default to unpinned, the safe direction. The budget and "keep opened originals" come from the settings page rather than a constant, and are applied at startup rather than only on change — a cache capped at 2 GB last session would otherwise spend this one filling to the default. Turning off keeping leaves what is already cached readable: those bytes are paid for, and refusing them would re-download images sitting right there, including pinned ones. Also removes a doubled `#[test]` introduced in the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cd75e5a4c6 |
Run the library from local data when the server is unreachable
Also carries in-flight work that shared these files: the zoom structure-key fix in the adjust pipeline, nearest-neighbour filtering past 1:1, the timeline scrub marker correction, the 423-Locked retry in the metadata sweep, and the thumbnail size-class migration. # Offline mode (FR-CAT-9) The app previously assumed the server was reachable and treated its absence as a series of unrelated per-operation failures. A launch without a connection produced an empty grid, even with a complete catalog on disk and every thumbnail already in the shards. Reachability is now inferred from traffic the app was already making, rather than probed for. `RemoteError::indicates_offline` draws the line that makes this possible: a dead connection is offline, a 403 or a 500 is not — the server answered, so blanking the library over one forbidden file would be a worse error than the one being reported. `Reachability` turns those outcomes into a state, so a library browsing happily never issues a probe at all. Going offline takes one failure, because the user is already experiencing it. Coming back requires evidence — a completed scan or a fetched thumbnail — with a capped exponential backoff behind the manual retry, so twelve sweep lanes failing together do not schedule twelve immediate probes. What keeps working: the catalog opens even when the scan that normally provides it failed, so the grid fills from the last successful scan. Thumbnails come from the shards. Rating, flagging and collecting are catalog writes that never touched the network. What stops is opening an original that was never stored locally, and it now says so in those words instead of reporting "network error: connection refused" over a photograph. Work that is pure network is refused rather than left to fail slowly: the metadata sweep, derived sync, and sidecar writes. The sweep would otherwise spend a timeout per image across the whole library while the progress bar implied something was happening. Deferring sidecars is a real gap rather than a hidden one — a rating made offline reaches its sidecar only when that image is judged again while connected — and it is recorded as such at the call site. # The "On this device" filter A chip beside the rating filters, narrowing the grid to images whose original is held locally. It composes with the rating terms rather than replacing them, so "five-star frames I can actually edit on this train" is one filter. The predicate is SQL, like the rating terms and for the same reason: the count in the header has to agree with the cells drawn. It reads `image_cache.tier_actual`, which nothing writes yet — the next commit fills it. Until then the chip honestly reports zero. `Tier` gains an explicit on-disk encoding. The variants are ordered by generosity and the derived `Ord` invites reordering them, which would silently reinterpret every cached row; the round-trip test is what holds the two in agreement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f6a100863e |
Place the timeline marker where the pointer actually is
The marker was drawn from a bucket index while a click reported a fraction of the track. Those are different quantities: bars each occupy one equal slot whatever span of time they cover, and the index snapped to the containing bucket's start edge, so the marker landed at the top of whichever slot held the instant — close enough to pass on a dense uniform axis, plainly wrong on a sparse one, and never under the click. Send the fraction instead, computed as the exact inverse of the interpolation the scrub handler applies, and position the marker from it. Marker and click are now the same quantity by construction. Scrolling the grid also left the marker behind: only an explicit scrub ever wrote the position, so the axis claimed to say "when you are" and stopped being true the moment the wheel moved. Map the first visible row back to a capture time and move the marker with it. That runs on every scroll event rather than behind the window-reload guard, which fires a few times per screenful and would make the marker advance in jerks. Only the marker moves, not the bars: rebuilding those means a GROUP BY aggregate over the library, far too much for a flick, and they do not change as the grid scrolls anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d49b4b41de |
Add thumbnail size classes and grid zoom; fix the scrub ordinal
The grid now zooms, which needs thumbnails at two resolutions rather than one, and exposed a scrub that landed in the wrong place. **Two thumbnail size classes.** `ThumbSize::Grid` (256px, ~10 KB) and `Large` (1024px, ~45 KB), with the class part of the store key so both coexist. Storing everything large would take the reference library from ~200 MB to ~860 MB, and shards sync, so that is transfer cost on every device rather than only disk. A store written before the class existed migrates in place: its entries are all grid-sized, which is what the column defaults to, so nothing already fetched is discarded. `forget` now drops every size for an image. Reading a single row left the other class's bytes on the shard's tally for good, sealing it early on space nothing occupied. **Grid zoom.** Ctrl+wheel and pinch resize cells between 90px and 420px in geometric steps, so the gesture feels the same at either end where a fixed pixel step would be imperceptible at 400px and violent at 90px. Crossing 256px switches to the large class, so a zoomed cell is sharp rather than upscaled. Columns and window capacity already derived from cell size, so the grid reflows for free. **The scrub landed about half a library too high.** It counted only dated images while the grid shows all of them — 10,733 dated against 19,841 rows — and ignored `shadowed_by`. Verified against the live catalog: the old formula gave 10,887, the new one 10,732, the true grid position 10,732. The scrub's count and the grid's window must use identical predicates and ordering; a test now fails if they diverge. **Timeline gestures are continuous.** Scrub and pan were quantised to whole buckets, so a slow drag did nothing until it crossed a boundary and then jumped a month. Both work in fractions of the visible span now, and pinch-to-zoom arrives for tablet, where there is no wheel to reach the axis with. The pinch accumulator was wrong on first writing: it took at most one step per update, so an 8x spread — three doublings — yielded one zoom level. `log2().trunc()` now extracts every whole doubling and carries the remainder. The original test asserted the wrong number and defended it in a comment, which is worth remembering: a test can entrench a bug as readily as catch one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9a24623e35 |
Fix the workspace build off-device
Two breaks that only appeared on a full `cargo test --workspace`. `slint::android` exists only when compiling for Android, so darkroom-android failed to compile on the host even though it is a workspace member. The entry point is now gated on the target rather than on a feature. The timeline forwarded `scrub` where the Timeline component declares `scrub-to`, which the Slint compiler rejects. Assisted-by: LLM |
||
|
|
8ad5c86ff9 |
Add the library, collections, and trash views; theme from style.yaml
The UI gains the views the catalog work was building toward: a windowed
library grid with ratings and flags, the collection tree with drag-to-add,
and trash with restore. derived_sync pushes thumbnail shards and the catalog
snapshot to the server's derived folder.
Tokens now have one source of truth. build.rs reads style.yaml and generates
theme.slint into OUT_DIR, which answers every existing
`import { Theme } from "theme.slint"` unchanged, because Slint resolves
imports against the importing file's directory first and the include paths
after. Generating into OUT_DIR rather than beside the hand-written Slint is
the point: a generated file sitting in ui/ looks exactly like the files
around it that are meant to be edited, and an edit to it would survive until
the next touch of style.yaml — a bug that hides for weeks. build.rs fails
loudly if a stale ui/theme.slint exists, which would otherwise shadow the
generated one silently and make every palette change vanish with no error.
The palette moves to near-neutral dark with achromatic signalling, so the
accent means "modified" or "active" rather than "heading". Shared components
land in widgets.slint: a token that binds several values into one concept is
a component, not a row in a YAML file.
Adds an optional live-style feature that makes the tokens in-out so they can
be written at startup — a feature rather than the default because it stops
the properties being constant-folded.
serde_norway is the YAML crate: serde_yaml and serde_yml are both deprecated,
and its mappings preserve insertion order, which is what lets the generated
Slint keep the token ordering the author chose.
Assisted-by: LLM
|
||
|
|
2ca1716a29 |
Make "Choose folder" an actual folder picker
It previously fetched the folder list and threw it away into a status line — a button that looked like it worked and did not. Now it opens a browsable picker: click a folder to descend, ".." to go back, "Use this folder" to select, "Cancel" to leave the root unchanged. Descends one level per click because that is what the backend supports: Depth: infinity is frequently disabled server-side and prohibitively expensive where it is not (ARCH §8.4). The chosen root persists immediately on confirm, so it survives a crash before the library is opened. Confirming at the account root is allowed — a user may legitimately keep everything at the top level — and cancelling leaves any previous selection untouched, which a test asserts. Verified against nextcloud.tourolle.paris at both depths: 30 folders at the root, 21 year-folders inside PhotosRaw. 19 launch tests, 38 in dr-ui. |
||
|
|
f630a3ff81 |
Wire the launch screen into the app
The app now opens on the login screen when there is nothing else to show
— no local paths and no configured library — and goes straight to the
images otherwise. Making someone click past a login they already
completed is pure friction.
launch.slint imported by app.slint, replacing the window rather
than overlaying it: there is no library to look at
until an account is configured
launch_ui.rs the Slint wiring, kept out of lib.rs so the launch
flow can change without touching the develop window
Login runs on a worker thread and posts results back through a channel,
since Slint's event loop is single-threaded and a 20-minute browser wait
cannot block it. The system browser is opened via xdg-open, never an
embedded webview (FR-NC-1).
Sign-out deletes the local credential even if server-side revocation
fails: a network error must not leave a usable secret on the machine.
Format tick-boxes persist on each toggle, so a selection survives a
crash before the library is opened.
Two things deliberately incomplete rather than faked:
- "Choose folder" lists the account's folders and reports them, but
there is no picker widget yet, so selection still happens via the
connect example.
- "Open library" logs the request. Opening a remote library needs the
scan-and-cache path, which belongs with the catalog work in flight.
Earlier I broke the other in-flight dr-ui work by calling
slint_build::compile twice, which replaces the generated module. The
correct wiring is an import inside app.slint, which is what this does.
30 dr-ui tests passing; both launch paths verified by running the app.
|
||
|
|
09e3043f4c |
Add secure credential storage, sessions, and a launch screen
Login now persists properly rather than through the JSON file the test
harness was using.
dr-plat SecretStore trait plus a Secret Service backend.
Verified against the live GNOME Keyring: store,
retrieve, delete, confirm-gone all round-trip.
Session/SessionStore splits credentials from settings — the app
password goes to the keyring (FR-NC-2), while
server, login, chosen root and format selection are
ordinary config. A test asserts the credential never
appears in the config file.
LaunchModel the launch-screen state machine, testable without a
display server: sign in, approve in browser, choose
folder, tick formats, sign out.
launch.slint the screen itself, in its own file.
Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.
Two bugs caught by tests rather than by running it:
- fail() after busy() signed the user out, because busy() had already
discarded the session. A failed *scan* would have logged you out.
Busy now carries the session.
- normalise_server upgrades http:// to https:// rather than accepting
it. NFR-SEC-3 requires TLS, and silently sending a credential in the
clear is not a decision to make on the user's behalf.
launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.
419 tests passing across ten crates.
|