94cfea47484525b8466097dcb8b906f3b2a29d7d
24
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|