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
+581
View File
@@ -0,0 +1,581 @@
import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Section } from "widgets.slint";
// Settings: how much disk the app may spend, and what an export defaults to.
//
// A full-window page rather than a modal dialogue. Two reasons, and the second
// is the load-bearing one. A modal has to be dismissed to check anything it
// refers to, and these settings refer to the library constantly — how full the
// cache is, where exports land. And every control here saves on change
// (see `settings_ui.rs`), so there is no OK/Cancel pair for a modal to host;
// a dialogue frame with only a close button is a window pretending to be a
// decision.
//
// It replaces the view rather than overlaying it because the app already
// switches views this way — launch, library, develop — and an overlay would be
// a fourth mechanism for the same job.
// One choice out of several, drawn as a row of chips.
//
// Not a dropdown. Every choice set on this page is short and the options are
// worth reading side by side — a photographer picking an output colour space
// benefits from seeing that ProPhoto exists next to sRGB, which a collapsed
// menu hides behind a click. `FilterChip` in widgets.slint is the same idea for
// the library's rating filter, but it carries a count and a filter's
// on-off semantics; this is single-selection over a fixed list, so the
// behaviour differs where it matters.
component ChoiceChip inherits Rectangle {
in property <string> label;
in property <bool> selected: false;
in property <bool> enabled: true;
callback clicked();
height: Theme.control-height;
// Wide enough that a one-word label is still a comfortable target, which
// is what `control-min-width` exists for — but chips sit several to a row,
// so they take their own narrower floor rather than the button's.
min-width: 64px;
border-radius: Theme.radius;
border-width: 1px;
border-color: root.selected ? Theme.active : Theme.rule;
// The selected chip fills, matching the checked box and the slider fill:
// `active` is the token for an engaged control, and this is the one chip in
// the row that is engaged.
background: !root.enabled ? transparent
: (root.selected ? Theme.active
: (touch.pressed ? Theme.pressed
: (touch.has-hover ? Theme.hover : transparent)));
opacity: root.enabled ? 1.0 : 0.4;
touch := TouchArea {
// FR-UI-3: the drawn chip is `control-height`, so the target grows
// past its own bounds rather than the ink growing.
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.enabled;
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
Text {
text: root.label;
// Dark on the fill: `active` is near-white and ink on it is invisible.
color: root.selected ? Theme.ground : Theme.ink;
font-size: Theme.text;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
}
// A labelled row of chips, with the label above rather than beside.
//
// Above, because the chip rows are wide and a left-hand label column would
// either crush them or leave the page half empty at narrow widths. Stacked,
// every row uses the full width at any window size (FR-UI-1).
component ChoiceRow inherits VerticalLayout {
in property <string> label;
in property <string> hint;
in property <[string]> options;
in property <int> selected: 0;
in property <bool> enabled: true;
callback picked(int);
spacing: 4px;
HorizontalLayout {
spacing: Theme.gap;
Label { text: root.label; body: true; }
Caption {
text: root.hint;
horizontal-alignment: right;
horizontal-stretch: 1;
overflow: elide;
}
}
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: start;
for option[i] in root.options: ChoiceChip {
label: option;
selected: i == root.selected;
enabled: root.enabled;
clicked => { root.picked(i); }
}
}
}
// A switch: one setting that is either on or off.
//
// The tick-box shape is `FormatCheck`'s from launch.slint, which is the
// established idiom for a boolean in this codebase. Reproduced rather than
// shared because that one is private to the launch screen and lives inside its
// format list; lifting it into widgets.slint would be the better move once a
// third caller appears, and doing it for the second is how a component ends up
// with parameters for every caller's variation.
component Switch inherits Rectangle {
in property <string> label;
in property <string> hint;
in-out property <bool> checked;
callback toggled(bool);
height: max(row.preferred-height, Theme.control-height);
touch := TouchArea {
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
clicked => {
root.checked = !root.checked;
root.toggled(root.checked);
}
}
row := HorizontalLayout {
spacing: Theme.gap;
alignment: start;
Rectangle {
width: 18px;
height: 18px;
y: (parent.height - self.height) / 2;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: root.checked ? Theme.active : Theme.rule;
background: root.checked ? Theme.active : transparent;
Text {
text: "✓";
color: Theme.ground;
font-size: 12px;
visible: root.checked;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
}
VerticalLayout {
spacing: 1px;
alignment: center;
Label { text: root.label; body: true; emphasised: touch.has-hover; }
// The hint carries *why* a default is what it is, for the settings
// where that is not obvious from the name — upscaling being off,
// location being stripped. A page of bare switches makes the user
// guess at the consequence of each.
Caption { text: root.hint; visible: root.hint != ""; wrap: word-wrap; }
}
}
}
// A text entry with its label above and an optional unit after it.
//
// `Field` is `touch-target` tall and stretches, which is right for a server
// URL on the launch screen and wrong for a byte count — so this constrains the
// width rather than restyling the field.
component EntryRow inherits VerticalLayout {
in property <string> label;
in property <string> hint;
in-out property <string> text;
in property <string> unit;
in property <string> placeholder;
in property <bool> enabled: true;
in property <length> field-width: 140px;
callback accepted(string);
spacing: 4px;
HorizontalLayout {
spacing: Theme.gap;
Label { text: root.label; body: true; }
Caption {
text: root.hint;
horizontal-alignment: right;
horizontal-stretch: 1;
overflow: elide;
}
}
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: start;
Rectangle {
width: root.field-width;
height: field.preferred-height;
opacity: root.enabled ? 1.0 : 0.4;
field := Field {
width: 100%;
text <=> root.text;
placeholder: root.placeholder;
// Committed on Enter *and* on losing focus. Enter alone loses
// an edit the moment the user clicks the next control, which
// on a page that saves continuously reads as the setting not
// having taken.
accepted(t) => { root.accepted(t); }
}
// `Field` reports focus but does not signal losing it, so the
// change is watched here.
property <bool> focused: field.has-focus;
changed focused => {
if (!self.focused) {
root.accepted(root.text);
}
}
}
Label {
text: root.unit;
visible: root.unit != "";
vertical-alignment: center;
height: Theme.touch-target;
}
}
}
export component SettingsPage inherits Rectangle {
// --- cache ---------------------------------------------------------
in-out property <string> original-budget;
in property <bool> original-unlimited: false;
in-out property <string> thumbnail-budget;
in property <bool> thumbnail-unlimited: false;
in property <bool> keep-opened: true;
/// What the cache currently holds. Empty hides the line.
in property <string> cache-usage;
callback original-budget-changed(string);
callback original-unlimited-toggled(bool);
callback thumbnail-budget-changed(string);
callback thumbnail-unlimited-toggled(bool);
callback keep-opened-toggled(bool);
// --- export --------------------------------------------------------
in property <[string]> format-labels;
in property <int> format-selected: 0;
in property <int> quality: 90;
in property <bool> quality-enabled: true;
in property <[string]> colour-labels;
in property <int> colour-selected: 0;
in property <[string]> sizing-labels;
in property <int> sizing-selected: 0;
in-out property <int> sizing-value: 0;
in property <bool> sizing-has-value: false;
in property <string> sizing-unit: "px";
in property <bool> allow-upscaling: false;
in property <[string]> sharpening-labels;
in property <int> sharpening-selected: 0;
in-out property <string> filename-template;
in property <[string]> collision-labels;
in property <int> collision-selected: 0;
in property <bool> strip-location: true;
in-out property <string> destination;
callback format-picked(int);
callback quality-changed(int);
callback colour-picked(int);
callback sizing-picked(int);
callback sizing-value-changed(string);
callback upscaling-toggled(bool);
callback sharpening-picked(int);
callback template-changed(string);
callback collision-picked(int);
callback strip-location-toggled(bool);
callback destination-changed(string);
/// A save failed. The page's whole contract is that what it shows is
/// stored, so this cannot be swallowed.
in property <string> error;
callback close();
callback reset-defaults();
background: Theme.ground;
VerticalLayout {
// --- header ----------------------------------------------------
//
// 44px and `surface`, matching the library's header exactly: this is
// the same kind of bar in the same place, and a page that drew its own
// height would read as a different application.
Rectangle {
height: 44px;
background: Theme.surface;
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap;
Button {
text: "‹ Back";
y: (parent.height - self.height) / 2;
clicked => { root.close(); }
}
Value { text: "Settings"; }
Rectangle { horizontal-stretch: 1; }
Caption {
// Says where the file is, because a settings page that
// saves silently gives the user nothing to point a backup
// or a support question at.
text: "Saved automatically";
vertical-alignment: center;
}
Button {
text: "Reset to defaults";
y: (parent.height - self.height) / 2;
clicked => { root.reset-defaults(); }
}
}
Rectangle {
y: parent.height - 1px;
height: 1px;
background: Theme.rule;
}
}
// A failed write, above the content: it applies to everything below
// and the user needs it before they keep editing into a file that is
// not being written.
if root.error != "": Rectangle {
height: 32px;
background: Theme.surface;
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
Caption { text: root.error; warn: true; overflow: elide; }
}
}
Flickable {
vertical-stretch: 1;
viewport-height: content.preferred-height;
content := VerticalLayout {
width: 100%;
padding: Theme.gap-lg;
spacing: Theme.gap-lg;
alignment: start;
// The column is capped rather than filling the window. A
// settings form stretched across a 2560px display puts its
// label at one edge and its control at the other; 680px is
// about 90 characters of `text`, which is a readable measure.
// `min` so a narrow window still uses what it has (FR-UI-1).
property <length> column: min(root.width - 2 * Theme.gap-lg, 680px);
// --- storage ---------------------------------------------
Rectangle {
width: content.column;
height: storage.preferred-height;
storage := Panel {
width: 100%;
PanelHeading { text: "STORAGE"; }
Caption {
text: "How much of this device's disk DarkRoom may use. "
+ "These are per-device and never travel with the library.";
wrap: word-wrap;
}
// What is actually held, before what is allowed:
// a ceiling means nothing without the current figure
// to judge it against.
if root.cache-usage != "": Value {
text: root.cache-usage;
compact: true;
}
Rectangle { height: Theme.gap-sm; }
EntryRow {
label: "Cached originals";
hint: "evicted oldest-first when full";
text <=> root.original-budget;
enabled: !root.original-unlimited;
placeholder: "8.0 GB";
accepted(t) => { root.original-budget-changed(t); }
}
Switch {
label: "No limit on cached originals";
hint: "Nothing is ever evicted for space. "
+ "Pinned photographs are kept regardless.";
checked: root.original-unlimited;
toggled(on) => { root.original-unlimited-toggled(on); }
}
Rectangle { height: Theme.gap-sm; }
EntryRow {
label: "Thumbnails and previews";
hint: "what the grid draws from";
text <=> root.thumbnail-budget;
enabled: !root.thumbnail-unlimited;
placeholder: "2.0 GB";
accepted(t) => { root.thumbnail-budget-changed(t); }
}
Switch {
label: "No limit on thumbnails";
checked: root.thumbnail-unlimited;
toggled(on) => { root.thumbnail-unlimited-toggled(on); }
}
Rectangle { height: Theme.gap-sm; }
Switch {
label: "Keep originals after opening them";
hint: "The file was downloaded anyway, so keeping it "
+ "costs no bandwidth and saves the transfer next time.";
checked: root.keep-opened;
toggled(on) => { root.keep-opened-toggled(on); }
}
}
}
// --- export ----------------------------------------------
Rectangle {
width: content.column;
height: export-panel.preferred-height;
export-panel := Panel {
width: 100%;
spacing: Theme.gap;
PanelHeading { text: "EXPORT DEFAULTS"; }
Caption {
text: "What an export starts from. Every one of these "
+ "is still changeable per export.";
wrap: word-wrap;
}
ChoiceRow {
label: "Format";
options: root.format-labels;
selected: root.format-selected;
picked(i) => { root.format-picked(i); }
}
EntryRow {
label: "Quality";
// Says why it is greyed rather than leaving the
// user to work out that PNG has no quality.
hint: root.quality-enabled ? "1 to 100"
: "the chosen format is lossless";
text: root.quality;
enabled: root.quality-enabled;
field-width: 90px;
accepted(t) => { root.quality-changed(t.to-float()); }
}
ChoiceRow {
label: "Colour space";
hint: "profile embedded on export";
options: root.colour-labels;
selected: root.colour-selected;
picked(i) => { root.colour-picked(i); }
}
ChoiceRow {
label: "Size";
options: root.sizing-labels;
selected: root.sizing-selected;
picked(i) => { root.sizing-picked(i); }
}
// Only where the chosen mode carries a number:
// "Original" has none, and a field showing 0 beside it
// would invite the reading "zero pixels".
if root.sizing-has-value: EntryRow {
label: "Size value";
text: root.sizing-value;
unit: root.sizing-unit;
field-width: 90px;
accepted(t) => { root.sizing-value-changed(t); }
}
Switch {
label: "Allow upscaling";
hint: "Off, a request larger than the source exports "
+ "at source size rather than failing.";
checked: root.allow-upscaling;
toggled(on) => { root.upscaling-toggled(on); }
}
ChoiceRow {
label: "Output sharpening";
hint: "scaled by the resize factor";
options: root.sharpening-labels;
selected: root.sharpening-selected;
picked(i) => { root.sharpening-picked(i); }
}
}
}
// --- naming and destination ------------------------------
//
// Its own panel rather than more of the export one: format and
// size describe the image, these describe the file. The export
// panel was long enough that the boundary was worth drawing.
Rectangle {
width: content.column;
height: naming.preferred-height;
naming := Panel {
width: 100%;
spacing: Theme.gap;
PanelHeading { text: "FILES AND METADATA"; }
EntryRow {
label: "Filename template";
hint: "{name} {seq} {date} {dimensions} {preset}";
text <=> root.filename-template;
field-width: 260px;
placeholder: "{name}";
accepted(t) => { root.template-changed(t); }
}
ChoiceRow {
label: "If the file exists";
options: root.collision-labels;
selected: root.collision-selected;
picked(i) => { root.collision-picked(i); }
}
EntryRow {
label: "Destination";
hint: "empty asks each time";
text <=> root.destination;
field-width: 320px;
placeholder: "Ask each time";
accepted(t) => { root.destination-changed(t); }
}
Switch {
label: "Strip location and personal metadata";
hint: "On. An export is usually the copy that leaves "
+ "this machine, and a location embedded in a "
+ "published photograph cannot be recalled.";
checked: root.strip-location;
toggled(on) => { root.strip-location-toggled(on); }
}
}
}
}
}
}
}