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>
This commit is contained in:
+244
-1
@@ -8,7 +8,7 @@
|
||||
// (FR-DEV-3c).
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { PanelHeading, Label, Value, Caption, Section } from "widgets.slint";
|
||||
import { PanelHeading, Label, Value, Caption, Section, Button, IconButton } from "widgets.slint";
|
||||
|
||||
// One parameter, flattened for Slint's model system.
|
||||
//
|
||||
@@ -220,6 +220,249 @@ component ParamSlider inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// A slider not backed by a `ParamRow`.
|
||||
//
|
||||
// `ParamSlider` reads its geometry out of a model row, which is right for the
|
||||
// generated panel and wrong for a control the interface names itself — the
|
||||
// straighten angle is reached through the session's own accessor, not through
|
||||
// a row index, so there is no row to feed it. The track behaviour is the same
|
||||
// and deliberately so: a slider that dragged differently depending on which
|
||||
// panel it sat in would be a worse inconsistency than the duplication.
|
||||
component PlainSlider inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <float> value;
|
||||
in property <float> default-value: 0.0;
|
||||
in property <float> minimum: -1.0;
|
||||
in property <float> maximum: 1.0;
|
||||
in property <string> unit;
|
||||
|
||||
callback changed(float);
|
||||
callback reset();
|
||||
|
||||
height: 46px;
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 2px;
|
||||
|
||||
HorizontalLayout {
|
||||
Label {
|
||||
text: root.label;
|
||||
emphasised: root.value != root.default-value;
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
Value {
|
||||
text: (Math.round(root.value * 10) / 10) + root.unit;
|
||||
modified: root.value != root.default-value;
|
||||
placeholder: root.value == root.default-value;
|
||||
compact: true;
|
||||
}
|
||||
}
|
||||
|
||||
track := Rectangle {
|
||||
height: Theme.touch-target / 2;
|
||||
|
||||
Rectangle {
|
||||
y: (parent.height - 3px) / 2;
|
||||
height: 3px;
|
||||
background: Theme.surface-raised;
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
|
||||
// The neutral point. A straighten slider is symmetric and read as
|
||||
// a deviation from level, so where zero sits has to be visible.
|
||||
if root.minimum < root.default-value && root.default-value < root.maximum:
|
||||
Rectangle {
|
||||
x: (root.default-value - root.minimum)
|
||||
/ (root.maximum - root.minimum) * parent.width - 1px;
|
||||
y: (parent.height - 9px) / 2;
|
||||
width: 2px;
|
||||
height: 9px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
property <float> span: root.maximum - root.minimum;
|
||||
property <length> default-x:
|
||||
(root.default-value - root.minimum) / self.span * parent.width;
|
||||
property <length> value-x:
|
||||
(root.value - root.minimum) / self.span * parent.width;
|
||||
|
||||
x: min(self.default-x, self.value-x);
|
||||
width: abs(self.value-x / 1px - self.default-x / 1px) * 1px;
|
||||
y: (parent.height - 3px) / 2;
|
||||
height: 3px;
|
||||
background: Theme.active;
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
x: (root.value - root.minimum)
|
||||
/ (root.maximum - root.minimum) * parent.width - 6px;
|
||||
y: (parent.height - 12px) / 2;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: area.has-hover || area.pressed ? Theme.ink : Theme.ink-dim;
|
||||
}
|
||||
|
||||
area := TouchArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
property <float> span: root.maximum - root.minimum;
|
||||
// The same axis test `ParamSlider` makes, and for the same
|
||||
// reason: this sits in a scrolling panel, and claiming the
|
||||
// gesture on press-down would jump the angle every time the
|
||||
// user tried to scroll from it.
|
||||
property <bool> claimed: false;
|
||||
|
||||
function value-at(px: length) -> float {
|
||||
return clamp(
|
||||
root.minimum + (px / self.width) * self.span,
|
||||
root.minimum,
|
||||
root.maximum);
|
||||
}
|
||||
|
||||
moved => {
|
||||
if (!self.claimed
|
||||
&& abs(self.mouse-x - self.pressed-x)
|
||||
> abs(self.mouse-y - self.pressed-y)) {
|
||||
self.claimed = true;
|
||||
}
|
||||
if (self.claimed) {
|
||||
root.changed(self.value-at(self.mouse-x));
|
||||
}
|
||||
}
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.up
|
||||
|| ev.kind == PointerEventKind.cancel) {
|
||||
self.claimed = false;
|
||||
}
|
||||
if (ev.kind == PointerEventKind.down
|
||||
&& ev.button == PointerEventButton.right) {
|
||||
root.reset();
|
||||
}
|
||||
}
|
||||
clicked => {
|
||||
if (!self.claimed) {
|
||||
root.changed(self.value-at(self.mouse-x));
|
||||
}
|
||||
}
|
||||
double-clicked => { root.reset(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Crop, rotation, flips and straightening — the framing controls.
|
||||
//
|
||||
// **Why this is hand-built when the rest of the panel is generated.** The
|
||||
// generic path renders one slider per parameter, which for framing means eight
|
||||
// of them: four crop edges the user would have to type coordinates into, and a
|
||||
// "Rotate" slider running 0..3. Every one of those is a worse control than the
|
||||
// gesture it stands for — a crop is dragged on the photograph, and a quarter
|
||||
// turn is a button. So framing is presented rather than generated, and the
|
||||
// generic panel drops it (see `AdjustPanel.skip-op`).
|
||||
//
|
||||
// This does not weaken ARCH §4.3: nothing here reads a parameter *value* out
|
||||
// of a descriptor or routes by index. It calls named session actions, which is
|
||||
// what a bespoke widget for a known stage is entitled to do.
|
||||
export component GeometryPanel inherits Rectangle {
|
||||
in property <bool> enabled: true;
|
||||
/// Whether the crop overlay is up. The button is a toggle, not an action:
|
||||
/// crop mode is sustained state, and the canvas looks different while it
|
||||
/// is on.
|
||||
in property <bool> crop-mode: false;
|
||||
in property <float> angle: 0.0;
|
||||
in property <float> max-straighten: 45.0;
|
||||
in property <bool> flip-h: false;
|
||||
in property <bool> flip-v: false;
|
||||
/// Any of crop, angle, rotation or flips differs from neutral.
|
||||
in property <bool> modified: false;
|
||||
|
||||
callback crop-toggled(bool);
|
||||
callback rotate(int);
|
||||
callback flip-h-toggled();
|
||||
callback flip-v-toggled();
|
||||
callback angle-changed(float);
|
||||
callback angle-reset();
|
||||
callback reset();
|
||||
|
||||
height: layout.preferred-height;
|
||||
|
||||
layout := VerticalLayout {
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
|
||||
Section {
|
||||
title: "GEOMETRY";
|
||||
modified: root.modified;
|
||||
has-reset: root.modified;
|
||||
op-reset => { root.reset(); }
|
||||
|
||||
VerticalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
padding-top: Theme.gap-sm;
|
||||
padding-bottom: Theme.gap-sm;
|
||||
|
||||
// Crop first: it is the framing decision the others serve.
|
||||
Button {
|
||||
text: root.crop-mode ? "Done Cropping" : "Crop";
|
||||
active: root.crop-mode;
|
||||
enabled: root.enabled;
|
||||
clicked => { root.crop-toggled(!root.crop-mode); }
|
||||
}
|
||||
|
||||
// Rotation and flips. Glyphs rather than labels: four controls
|
||||
// named in words would wrap the 280px column, and each of
|
||||
// these shows its own result.
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
IconButton {
|
||||
glyph: "⟲";
|
||||
enabled: root.enabled;
|
||||
clicked => { root.rotate(-1); }
|
||||
}
|
||||
IconButton {
|
||||
glyph: "⟳";
|
||||
enabled: root.enabled;
|
||||
clicked => { root.rotate(1); }
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
IconButton {
|
||||
glyph: "⇔";
|
||||
active: root.flip-h;
|
||||
enabled: root.enabled;
|
||||
clicked => { root.flip-h-toggled(); }
|
||||
}
|
||||
IconButton {
|
||||
glyph: "⇕";
|
||||
active: root.flip-v;
|
||||
enabled: root.enabled;
|
||||
clicked => { root.flip-v-toggled(); }
|
||||
}
|
||||
}
|
||||
|
||||
PlainSlider {
|
||||
label: "Straighten";
|
||||
value: root.angle;
|
||||
default-value: 0.0;
|
||||
minimum: -root.max-straighten;
|
||||
maximum: root.max-straighten;
|
||||
unit: "°";
|
||||
changed(v) => { root.angle-changed(v); }
|
||||
reset => { root.angle-reset(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A tone curve editor: a square grid with draggable control points.
|
||||
//
|
||||
// The curve *line* is drawn from `samples`, which Rust evaluates with the
|
||||
|
||||
Reference in New Issue
Block a user