9f577ed6a2d664c390bd73acfe0d42cc0b4b3c8d
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
03326242a1 |
Make the CI checks say what they mean, and format the workspace
The Android job's "Verify minimum API level" step has never verified the minimum API level. It took the first `*.so` anywhere under the target directory, which is a host proc-macro from debug/deps — an x86-64 object built by the runner's gcc, whose .comment section cannot mention Android and so can never contradict the expected value. It now reads the artifact under the target triple, compares against MIN_API parsed from the Dockerfile rather than a second copy of the number, and fails on a mismatch. Both sides are checked non-empty first: two failed parses would otherwise compare equal and pass, which is the same silent success in a new costume. The Android image installs one SDK package per layer and keeps the output. sdkmanager is a JVM program that aborts when it cannot get memory, and the single `> /dev/null` step reported that as a bare "exit code 134" while a retry re-downloaded everything that had already succeeded. tools/ci-local.sh runs all four jobs — desktop, android, layering, traceability — against the host toolchain, which is pinned to the same 1.92.0 CI installs. Its matrix check compares regeneration against the working tree rather than against HEAD: CI starts from a clean checkout, so git's answer is the right one there and reports every local run stale here. The rest is rustfmt across the workspace, and the clippy findings that surfaced once it did: manual_contains in dr-thumbs and collections_ui, a map iterated as pairs for its keys, an index loop over a slice, and two runtime assertions on a constant now made at compile time. 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> |
||
|
|
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
|