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
|
||||
|
||||
+120
-7
@@ -1,5 +1,5 @@
|
||||
import { Theme } from "theme.slint";
|
||||
import { AdjustPanel, ParamRow } from "adjust.slint";
|
||||
import { AdjustPanel, GeometryPanel, ParamRow } from "adjust.slint";
|
||||
import { LaunchScreen } from "launch.slint";
|
||||
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
|
||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState } from "widgets.slint";
|
||||
@@ -137,6 +137,14 @@ export component AppWindow inherits Window {
|
||||
in property <float> zoom: 1.0;
|
||||
in property <bool> zoomed: false;
|
||||
|
||||
/// Whether one source pixel now covers more than one screen pixel.
|
||||
///
|
||||
/// Drives the canvas's filtering, and nothing else. Rust decides it rather
|
||||
/// than the `zoom` property above, because the two are not the same
|
||||
/// question: whether the image is magnified depends on the source's
|
||||
/// resolution against the viewport's, which Slint does not know.
|
||||
in property <bool> magnified: false;
|
||||
|
||||
/// Whether the crop overlay is active. While it is, the canvas shows the
|
||||
/// *whole* frame — otherwise the area being cropped away would not be on
|
||||
/// screen to drag across — and the surround is greyed.
|
||||
@@ -152,6 +160,29 @@ export component AppWindow inherits Window {
|
||||
callback crop-mode-toggled(bool);
|
||||
/// A dragged crop rect, in fractions of the frame.
|
||||
callback crop-changed(float, float, float, float);
|
||||
|
||||
// --- rotation, flips and straightening (FR-DEV-3) ---
|
||||
//
|
||||
// Mirrored from the session rather than held here, for the same reason the
|
||||
// crop rect is: Rust clamps and wraps these, so the panel must show what
|
||||
// was actually applied and not what the click asked for.
|
||||
in property <float> straighten: 0.0;
|
||||
/// The straighten slider's travel either way, from the descriptor.
|
||||
in property <float> max-straighten: 45.0;
|
||||
in property <bool> flip-h: false;
|
||||
in property <bool> flip-v: false;
|
||||
/// Any framing edit is in effect — what lights the section's dot and
|
||||
/// enables its reset. Distinct from the crop rect being non-full: a
|
||||
/// rotation or a flip is an edit with the crop still full.
|
||||
in property <bool> framing-modified: false;
|
||||
|
||||
/// Quarter turns, positive clockwise.
|
||||
callback rotate-quarters(int);
|
||||
callback flip-h-toggled();
|
||||
callback flip-v-toggled();
|
||||
callback straighten-changed(float);
|
||||
/// Crop, angle, rotation and flips back to neutral, leaving colour alone.
|
||||
callback framing-reset();
|
||||
/// Scroll-to-zoom: factor, and the anchor in fractions of the visible area.
|
||||
callback zoom-at(float, float, float);
|
||||
callback pan-by(float, float);
|
||||
@@ -204,6 +235,29 @@ export component AppWindow inherits Window {
|
||||
in property <int> library-thumbs-done: 0;
|
||||
in property <int> library-thumbs-total: 0;
|
||||
|
||||
// --- offline mode (FR-CAT-9) ---
|
||||
//
|
||||
// The library keeps working from local data when the server cannot be
|
||||
// reached: thumbnails come from the shards, and rating and sorting are
|
||||
// catalog operations that never needed the network. What stops is opening
|
||||
// an original that was never stored locally.
|
||||
//
|
||||
// `offline-reason` carries the transport's own message ("connection
|
||||
// refused", "dns error") because it is usually specific enough to act on,
|
||||
// where a bare "offline" leaves the user guessing whether it is their wifi
|
||||
// or the server.
|
||||
in property <bool> library-offline: false;
|
||||
in property <string> library-offline-reason: "";
|
||||
in property <string> library-offline-since: "";
|
||||
// Narrows the grid to images whose RAW is stored locally — the ones that
|
||||
// can actually be opened while offline. Off by default: the catalog is the
|
||||
// library, and hiding most of it the moment a connection drops would read
|
||||
// as data loss rather than as a filter.
|
||||
in-out property <bool> library-local-only: false;
|
||||
in property <int> library-local-count: 0;
|
||||
callback library-toggle-local-only();
|
||||
callback library-retry-connection();
|
||||
|
||||
in property <string> library-root-label: "";
|
||||
in-out property <[TimelineBar]> library-timeline;
|
||||
in property <string> library-timeline-label: "";
|
||||
@@ -456,6 +510,14 @@ export component AppWindow inherits Window {
|
||||
scan-error: root.library-error;
|
||||
thumbs-done: root.library-thumbs-done;
|
||||
thumbs-total: root.library-thumbs-total;
|
||||
|
||||
offline: root.library-offline;
|
||||
offline-reason: root.library-offline-reason;
|
||||
offline-since: root.library-offline-since;
|
||||
retry-connection() => { root.library-retry-connection(); }
|
||||
local-only: root.library-local-only;
|
||||
local-count: root.library-local-count;
|
||||
toggle-local-only() => { root.library-toggle-local-only(); }
|
||||
root-label: root.library-root-label;
|
||||
timeline: root.library-timeline;
|
||||
timeline-label: root.library-timeline-label;
|
||||
@@ -565,6 +627,20 @@ export component AppWindow inherits Window {
|
||||
height: 100%;
|
||||
source: root.canvas;
|
||||
image-fit: contain;
|
||||
// Past 1:1 there is no detail left to reconstruct, so
|
||||
// smoothing only invents values between real pixels — and
|
||||
// inspecting focus or noise is the whole reason to zoom in
|
||||
// that far. Below 1:1 it stays smooth, where filtering is
|
||||
// what keeps the image from aliasing.
|
||||
//
|
||||
// This matters even at moderate zoom on a HiDPI display:
|
||||
// the canvas is rendered at *logical* size and Slint scales
|
||||
// it up by the device pixel ratio, so the buffer is
|
||||
// resampled on its way to the screen whatever the pipeline
|
||||
// did.
|
||||
image-rendering: root.magnified
|
||||
? ImageRendering.pixelated
|
||||
: ImageRendering.smooth;
|
||||
visible: root.total > 0 && root.load-error == "";
|
||||
}
|
||||
|
||||
@@ -851,17 +927,27 @@ export component AppWindow inherits Window {
|
||||
}
|
||||
}
|
||||
|
||||
// Crop and zoom controls, floating over the canvas so they are
|
||||
// reachable whether or not the side panel is showing.
|
||||
// Zoom controls, floating over the canvas.
|
||||
//
|
||||
// Zoom stays here rather than moving to the sidebar with the
|
||||
// crop: it is a property of *looking*, it is driven by the
|
||||
// scroll wheel on the image itself, and its readout belongs
|
||||
// beside what it is reporting on. Crop moved to the panel
|
||||
// because it is an edit, and edits live with the other edits.
|
||||
//
|
||||
// A crop exit is still reachable from here while cropping —
|
||||
// the panel may be collapsed on a narrow window, and stranding
|
||||
// the user in a mode with no visible way out is worse than one
|
||||
// duplicated control.
|
||||
if root.total > 0 && root.load-error == "": HorizontalLayout {
|
||||
x: 12px;
|
||||
y: parent.height - self.preferred-height - 12px;
|
||||
spacing: 6px;
|
||||
|
||||
Button {
|
||||
text: root.crop-mode ? "Done" : "Crop";
|
||||
active: root.crop-mode;
|
||||
clicked => { root.crop-mode-toggled(!root.crop-mode); }
|
||||
if root.crop-mode: Button {
|
||||
text: "Done";
|
||||
active: true;
|
||||
clicked => { root.crop-mode-toggled(false); }
|
||||
}
|
||||
|
||||
// Zoom is a view state, so its readout doubles as the
|
||||
@@ -907,6 +993,33 @@ export component AppWindow inherits Window {
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
// Framing above the colour work, matching how the edit is
|
||||
// made rather than how it is applied: the frame is decided
|
||||
// by eye first and the pipeline runs it last (see
|
||||
// `dr_pipeline::framing` on the coordinate order).
|
||||
GeometryPanel {
|
||||
enabled: root.adjust-enabled;
|
||||
crop-mode: root.crop-mode;
|
||||
angle: root.straighten;
|
||||
max-straighten: root.max-straighten;
|
||||
flip-h: root.flip-h;
|
||||
flip-v: root.flip-v;
|
||||
modified: root.framing-modified;
|
||||
|
||||
crop-toggled(on) => { root.crop-mode-toggled(on); }
|
||||
rotate(turns) => { root.rotate-quarters(turns); }
|
||||
flip-h-toggled => { root.flip-h-toggled(); }
|
||||
flip-v-toggled => { root.flip-v-toggled(); }
|
||||
angle-changed(v) => { root.straighten-changed(v); }
|
||||
angle-reset => { root.straighten-changed(0); }
|
||||
reset => { root.framing-reset(); }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
AdjustPanel {
|
||||
vertical-stretch: 1;
|
||||
rows: root.adjust-rows;
|
||||
|
||||
@@ -636,6 +636,21 @@ export component LibraryGrid inherits Rectangle {
|
||||
callback filter-unjudged-toggled(bool);
|
||||
callback filter-flag-changed(int);
|
||||
|
||||
// --- offline (FR-CAT-9) -------------------------------------------------
|
||||
//
|
||||
// Offline is a banner rather than a modal or an empty state, because most
|
||||
// of the library still works: the shards hold the thumbnails, and rating,
|
||||
// flagging, sorting and collecting are catalog writes that never touched
|
||||
// the network. Only opening an un-cached original actually fails.
|
||||
in property <bool> offline: false;
|
||||
in property <string> offline-reason: "";
|
||||
in property <string> offline-since: "";
|
||||
callback retry-connection();
|
||||
/// Narrow to images whose RAW is stored locally — the ones openable now.
|
||||
in property <bool> local-only: false;
|
||||
in property <int> local-count: 0;
|
||||
callback toggle-local-only();
|
||||
|
||||
/// How many images are selected, for the header's count.
|
||||
in property <int> selected-count: 0;
|
||||
/// Which collection scopes the grid, for the header. Empty means all.
|
||||
@@ -850,6 +865,21 @@ export component LibraryGrid inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { width: Theme.gap; }
|
||||
|
||||
// Locally-stored originals. Always offered, not only when
|
||||
// offline: "what can I actually work on right now" is a fair
|
||||
// question on a slow connection too, and a control that
|
||||
// appears only in the failure case is one the user has to
|
||||
// discover at the worst moment.
|
||||
FilterChip {
|
||||
label: "On this device";
|
||||
count: root.local-count;
|
||||
active: root.local-only;
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => { root.toggle-local-only(); }
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
// What the filter is currently hiding. Without this a narrowed
|
||||
@@ -857,7 +887,7 @@ export component LibraryGrid inherits Rectangle {
|
||||
// single most confusing state a filter can leave behind.
|
||||
Caption {
|
||||
text: (root.filter-min-rating > 0 || root.filter-unjudged
|
||||
|| root.filter-flag > 0)
|
||||
|| root.filter-flag > 0 || root.local-only)
|
||||
? "filtered" : "";
|
||||
emphasised: true;
|
||||
vertical-alignment: center;
|
||||
@@ -886,8 +916,55 @@ export component LibraryGrid inherits Rectangle {
|
||||
: 0;
|
||||
}
|
||||
|
||||
// --- offline ------------------------------------------------------
|
||||
//
|
||||
// Above the scan error, and it suppresses it: when the server is
|
||||
// unreachable the scan failure is a *consequence*, and showing both
|
||||
// reports one problem twice while implying two.
|
||||
if root.offline: Rectangle {
|
||||
height: 34px;
|
||||
background: Theme.surface;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap;
|
||||
padding-right: Theme.gap;
|
||||
spacing: Theme.gap;
|
||||
|
||||
Caption {
|
||||
text: "Offline — showing what is stored on this device"
|
||||
+ (root.offline-since != "" ? " (" + root.offline-since + ")" : "");
|
||||
warn: true;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
// The transport's own words. Usually specific enough to act on
|
||||
// — "connection refused" and "dns error" send the user to
|
||||
// different places — where a bare "offline" leaves them
|
||||
// guessing whether it is their wifi or the server.
|
||||
Caption {
|
||||
text: root.offline-reason;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Retry";
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => { root.retry-connection(); }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
y: parent.height - 1px;
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
}
|
||||
|
||||
// --- error --------------------------------------------------------
|
||||
if root.scan-error != "": Rectangle {
|
||||
if root.scan-error != "" && !root.offline: Rectangle {
|
||||
height: 34px;
|
||||
background: Theme.surface;
|
||||
Caption {
|
||||
@@ -1064,14 +1141,24 @@ export component LibraryGrid inherits Rectangle {
|
||||
// *loaded window* while the viewport stays where it was, so
|
||||
// the cells are drawn thousands of rows away and the grid
|
||||
// looks empty until the user scrolls to find them.
|
||||
property <int> token: root.scroll-token;
|
||||
changed token => {
|
||||
function seek() {
|
||||
self.viewport-y = -min(
|
||||
max(0px, self.viewport-height - self.height),
|
||||
floor(root.scroll-to / max(1, root.columns))
|
||||
* (root.cell-size + Theme.gap));
|
||||
}
|
||||
|
||||
property <int> token: root.scroll-token;
|
||||
changed token => { self.seek(); }
|
||||
|
||||
// Also on creation, which is what returning from the develop
|
||||
// view needs. `show-library` gates an `if`, so the grid is built
|
||||
// anew and `token` is *initialised* to the already-bumped value
|
||||
// rather than changing to it — no `changed` handler fires, and
|
||||
// without this the restored position would be dropped and the
|
||||
// view would sit at the top.
|
||||
init => { self.seek(); }
|
||||
|
||||
// Sized to the **whole library**, not the loaded window. The
|
||||
// scrollbar has to represent 23,971 images or there is no way to
|
||||
// reach image 20,000 — dragging it must be a real address, and the
|
||||
|
||||
Reference in New Issue
Block a user