Keep originals on this device, by pin and by use
Build and test / Desktop (Linux) (push) Failing after 1s
Build and test / Android (aarch64) (push) Failing after 0s
Build and test / Layer separation (push) Failing after 1s
Traceability / Requirement traces (push) Failing after 2s

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>
This commit is contained in:
2026-08-11 21:12:01 +02:00
co-authored by Claude Opus 5
parent cd75e5a4c6
commit fa12afed18
22 changed files with 4032 additions and 86 deletions
+149 -3
View File
@@ -4,6 +4,7 @@ import { LaunchScreen } from "launch.slint";
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState } from "widgets.slint";
import { CollectionsPanel, CollectionRow } from "collections.slint";
import { SettingsPage } from "settings.slint";
export { LibraryCell, TimelineBar, CollectionRow }
@@ -23,6 +24,7 @@ component StatusBar inherits Rectangle {
in property <bool> can-return-to-library: false;
callback back-to-library();
callback open-settings();
// Tall enough for a Button to sit in without the strip growing: the
// control height and this strip are both 28px by design.
@@ -59,6 +61,14 @@ component StatusBar inherits Rectangle {
Caption { text: root.layout-class; }
// Reachable from develop as well as from the grid: export defaults are
// most likely to be wanted with a finished photograph on screen, which
// is exactly where this bar is and the library header is not.
Button {
text: "Settings";
clicked => { root.open-settings(); }
}
// Degraded performance, so this is a caution rather than an active
// state: the frame rate has fallen below what the compositing path is
// supposed to sustain, which is exactly the assumption A1 exists to
@@ -258,6 +268,12 @@ export component AppWindow inherits Window {
callback library-toggle-local-only();
callback library-retry-connection();
// --- pinning a collection offline (FR-NC-6a) ---
in property <bool> library-scope-pinned: false;
in property <int> library-pin-done: 0;
in property <int> library-pin-total: 0;
callback library-toggle-pin-scope();
in property <string> library-root-label: "";
in-out property <[TimelineBar]> library-timeline;
in property <string> library-timeline-label: "";
@@ -403,6 +419,69 @@ export component AppWindow inherits Window {
callback curve-reset(int);
callback reset-all();
// --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) ---
//
// A page rather than an overlay, and the outermost of the view conditions
// below: it is reachable from the library and from develop, so guarding it
// with `!show-library` or `!show-launch` would make which one you came from
// decide whether it appears.
//
// Every control saves on change (see `settings_ui.rs`), so there is no
// dirty state here and nothing to confirm on the way out.
in-out property <bool> show-settings: false;
in property <string> settings-original-budget: "";
in property <bool> settings-original-unlimited: false;
in property <string> settings-thumbnail-budget: "";
in property <bool> settings-thumbnail-unlimited: false;
in property <bool> settings-keep-opened: true;
in property <string> settings-cache-usage: "";
callback settings-original-budget-changed(string);
callback settings-original-unlimited-toggled(bool);
callback settings-thumbnail-budget-changed(string);
callback settings-thumbnail-unlimited-toggled(bool);
callback settings-keep-opened-toggled(bool);
in property <[string]> settings-format-labels;
in property <int> settings-format-selected: 0;
in property <int> settings-quality: 90;
in property <bool> settings-quality-enabled: true;
in property <[string]> settings-colour-labels;
in property <int> settings-colour-selected: 0;
in property <[string]> settings-sizing-labels;
in property <int> settings-sizing-selected: 0;
in property <int> settings-sizing-value: 0;
in property <bool> settings-sizing-has-value: false;
in property <string> settings-sizing-unit: "px";
in property <bool> settings-allow-upscaling: false;
in property <[string]> settings-sharpening-labels;
in property <int> settings-sharpening-selected: 0;
in property <string> settings-filename-template: "";
in property <[string]> settings-collision-labels;
in property <int> settings-collision-selected: 0;
in property <bool> settings-strip-location: true;
in property <string> settings-destination: "";
in property <string> settings-error: "";
callback settings-format-picked(int);
callback settings-quality-changed(int);
callback settings-colour-picked(int);
callback settings-sizing-picked(int);
callback settings-sizing-value-changed(string);
callback settings-upscaling-toggled(bool);
callback settings-sharpening-picked(int);
callback settings-template-changed(string);
callback settings-collision-picked(int);
callback settings-strip-location-toggled(bool);
callback settings-destination-changed(string);
callback settings-reset();
/// Show the settings page. Reads the file first, so a second instance's
/// writes are picked up rather than overwritten.
callback settings-open();
callback settings-close();
// FR-UI-1: layout class follows window width, not device type. A narrow
// desktop window gets the compact layout, exactly as a tablet would.
//
@@ -418,9 +497,69 @@ export component AppWindow inherits Window {
// One-way: report width outward, never read layout back into it.
changed width => { root.window-resized(self.width); }
// Settings, over everything. First in the file and first in z-order so the
// conditions below can be read as "and settings is not open".
if root.show-settings: SettingsPage {
width: 100%;
height: 100%;
original-budget: root.settings-original-budget;
original-unlimited: root.settings-original-unlimited;
thumbnail-budget: root.settings-thumbnail-budget;
thumbnail-unlimited: root.settings-thumbnail-unlimited;
keep-opened: root.settings-keep-opened;
cache-usage: root.settings-cache-usage;
original-budget-changed(t) => { root.settings-original-budget-changed(t); }
original-unlimited-toggled(on) => {
root.settings-original-unlimited-toggled(on);
}
thumbnail-budget-changed(t) => { root.settings-thumbnail-budget-changed(t); }
thumbnail-unlimited-toggled(on) => {
root.settings-thumbnail-unlimited-toggled(on);
}
keep-opened-toggled(on) => { root.settings-keep-opened-toggled(on); }
format-labels: root.settings-format-labels;
format-selected: root.settings-format-selected;
quality: root.settings-quality;
quality-enabled: root.settings-quality-enabled;
colour-labels: root.settings-colour-labels;
colour-selected: root.settings-colour-selected;
sizing-labels: root.settings-sizing-labels;
sizing-selected: root.settings-sizing-selected;
sizing-value: root.settings-sizing-value;
sizing-has-value: root.settings-sizing-has-value;
sizing-unit: root.settings-sizing-unit;
allow-upscaling: root.settings-allow-upscaling;
sharpening-labels: root.settings-sharpening-labels;
sharpening-selected: root.settings-sharpening-selected;
filename-template: root.settings-filename-template;
collision-labels: root.settings-collision-labels;
collision-selected: root.settings-collision-selected;
strip-location: root.settings-strip-location;
destination: root.settings-destination;
error: root.settings-error;
format-picked(i) => { root.settings-format-picked(i); }
quality-changed(q) => { root.settings-quality-changed(q); }
colour-picked(i) => { root.settings-colour-picked(i); }
sizing-picked(i) => { root.settings-sizing-picked(i); }
sizing-value-changed(t) => { root.settings-sizing-value-changed(t); }
upscaling-toggled(on) => { root.settings-upscaling-toggled(on); }
sharpening-picked(i) => { root.settings-sharpening-picked(i); }
template-changed(t) => { root.settings-template-changed(t); }
collision-picked(i) => { root.settings-collision-picked(i); }
strip-location-toggled(on) => { root.settings-strip-location-toggled(on); }
destination-changed(t) => { root.settings-destination-changed(t); }
close() => { root.settings-close(); }
reset-defaults() => { root.settings-reset(); }
}
// The launch screen replaces the whole window rather than overlaying it:
// there is no library to look at until an account is configured.
if root.show-launch: LaunchScreen {
if !root.show-settings && root.show-launch: LaunchScreen {
width: 100%;
height: 100%;
signed-in: root.launch-signed-in;
@@ -457,7 +596,7 @@ export component AppWindow inherits Window {
// has been opened but no image chosen yet. The collections sidebar and the
// grid are siblings here rather than the sidebar living inside the grid,
// because the drag that connects them has to be owned above both.
if !root.show-launch && root.show-library: Rectangle {
if !root.show-settings && !root.show-launch && root.show-library: Rectangle {
background: Theme.ground;
HorizontalLayout {
@@ -518,6 +657,11 @@ export component AppWindow inherits Window {
local-only: root.library-local-only;
local-count: root.library-local-count;
toggle-local-only() => { root.library-toggle-local-only(); }
scope-pinned: root.library-scope-pinned;
pin-done: root.library-pin-done;
pin-total: root.library-pin-total;
toggle-pin-scope() => { root.library-toggle-pin-scope(); }
root-label: root.library-root-label;
timeline: root.library-timeline;
timeline-label: root.library-timeline-label;
@@ -554,6 +698,7 @@ export component AppWindow inherits Window {
cell-clicked(i) => { root.library-cell-clicked(i); }
rescan() => { root.library-rescan(); }
change-library() => { root.library-change(); }
open-settings() => { root.settings-open(); }
cell-pressed(i, ctrl, shift) => {
root.library-cell-pressed(i, ctrl, shift);
@@ -600,7 +745,7 @@ export component AppWindow inherits Window {
}
}
if !root.show-launch && !root.show-library: VerticalLayout {
if !root.show-settings && !root.show-launch && !root.show-library: VerticalLayout {
StatusBar {
adapter: root.adapter;
backend: root.backend;
@@ -612,6 +757,7 @@ export component AppWindow inherits Window {
// grid to return to.
can-return-to-library: root.library-total > 0;
back-to-library() => { root.back-to-library(); }
open-settings() => { root.settings-open(); }
}
HorizontalLayout {