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>
This commit is contained in:
2026-08-16 18:32:09 +02:00
co-authored by Claude Opus 5
parent 70435b712e
commit 65e6a96a65
27 changed files with 3873 additions and 236 deletions
+127 -23
View File
@@ -14,6 +14,9 @@
// colour reached forty call sites with no single place to change it.
import { Theme } from "theme.slint";
import { Icon } from "icons.slint";
export { Icon }
// A text button.
//
@@ -36,7 +39,7 @@ export component Button inherits Rectangle {
/// emphasis stops meaning anything (see the theme preamble).
in property <bool> primary: false;
/// Sustained state — a toggle that is currently on, not a press. The same
/// meaning [`IconButton`] gives it, so a labelled toggle and a glyph
/// meaning [`IconButton`] gives it, so a labelled toggle and an icon
/// toggle read alike.
in property <bool> active: false;
@@ -97,12 +100,13 @@ export component Button inherits Rectangle {
}
}
// A square button carrying a single glyph.
// A square button carrying a single icon.
//
// Square because it has no label to size against: a toolbar affordance whose
// width tracked its glyph would jitter as the glyph changed.
// width tracked its drawing would jitter as the drawing changed.
export component IconButton inherits Rectangle {
in property <string> glyph;
/// A name from the [`Icon`] vocabulary.
in property <string> icon;
in property <bool> enabled: true;
/// Sustained state — a toggle that is currently on, not a press.
in property <bool> active: false;
@@ -131,14 +135,15 @@ export component IconButton inherits Rectangle {
clicked => { root.clicked(); }
}
Text {
text: root.glyph;
color: root.active ? Theme.active : Theme.ink;
font-size: Theme.text;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
Icon {
name: root.icon;
ink: root.active ? Theme.active : Theme.ink;
// Half the button, near enough: the icon carries the whole meaning of
// the control, so it wants more of the square than a glyph set at
// body size used to take, but it must not touch the border.
size: root.height / 2;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
@@ -164,6 +169,11 @@ export component FilterChip inherits Rectangle {
in property <bool> active: false;
/// Images behind this term, or -1 where the count is not known.
in property <int> count: -1;
/// A mark before the label, naming what the term filters on — a star for a
/// rating, a tick for picks. Empty for chips whose word says it already.
/// Drawn rather than prefixed to `label`, because a symbol inside a string
/// is exactly the thing [`Icon`] exists to stop (see icons.slint).
in property <string> icon;
callback clicked();
@@ -195,6 +205,16 @@ export component FilterChip inherits Rectangle {
padding-right: Theme.gap-sm;
spacing: 4px;
if root.icon != "": Icon {
name: root.icon;
// Takes the label's colour, including the inversion on an active
// chip: a mark that stayed light on the near-white fill would be
// the one invisible thing in the row.
ink: root.active ? Theme.ground : Theme.ink;
size: 11px;
y: (parent.height - self.height) / 2;
}
Text {
text: root.label;
// Dark on the active fill, which is near-white — the same
@@ -221,17 +241,26 @@ export component FilterChip inherits Rectangle {
// The arrow beside a row that opens into something: a disclosure triangle on
// a section, an "into this folder" marker in the picker.
//
// **Fixed width, and that is the whole point.** The glyphs differ in advance
// width, so a row that sized to its own arrow would shift its label sideways
// as it opened and closed — the one movement that makes a static list look
// like it is being redrawn. Rotating a single glyph would need a transform on
// a Text; two characters render identically and cost nothing.
export component Disclosure inherits Text {
color: Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
horizontal-alignment: center;
// **Fixed width, and that is the whole point.** The drawings differ in extent,
// so a row that sized to its own arrow would shift its label sideways as it
// opened and closed — the one movement that makes a static list look like it
// is being redrawn. The box stays 14px whatever is in it, and the icon centres
// inside.
export component Disclosure inherits Rectangle {
/// A name from the [`Icon`] vocabulary — `chevron-right` closed,
/// `chevron-down` open, or `arrow-up` for a row that leads back out.
in property <string> icon: "chevron-right";
width: 14px;
horizontal-stretch: 0;
Icon {
name: root.icon;
ink: Theme.ink-faint;
size: 10px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
}
// A collapsible group with a header that reports whether anything inside has
@@ -305,7 +334,9 @@ export component Section inherits Rectangle {
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Disclosure { text: root.expanded ? "▾" : "▸"; }
Disclosure {
icon: root.expanded ? "chevron-down" : "chevron-right";
}
Text {
text: root.title;
@@ -609,6 +640,79 @@ export component Field inherits Rectangle {
}
}
// A horizontal progress bar with two modes.
//
// Determinate where a real denominator exists (thumbnails: we know how many
// cells we asked for). Indeterminate where one does not — a directory walk
// discovers its own extent, so any percentage would be invented, and inventing
// one is worse than admitting the work is unbounded.
//
// Lives here rather than in library.slint, where it started, because the grid
// is no longer the only view that reports progress: the shell draws one across
// the top of every view and the settings page draws one per running job.
export component ProgressBar inherits Rectangle {
in property <float> fraction: 0;
in property <bool> indeterminate: false;
height: 3px;
background: Theme.rule;
// The sweep below is positioned outside these bounds for half its cycle.
// Without clipping it paints over whatever sits beside the bar, which at
// the top of the shell is the whole window.
clip: true;
// Determinate: a bar proportional to real progress.
Rectangle {
x: 0;
width: parent.width * clamp(root.fraction, 0, 1);
height: parent.height;
background: Theme.active-dim;
visible: !root.indeterminate;
}
// Indeterminate: a sweep that says "working" without claiming a position.
Rectangle {
width: parent.width * 25%;
height: parent.height;
background: Theme.active-dim;
visible: root.indeterminate;
x: root.indeterminate ? -self.width : 0;
animate x {
duration: 1200ms;
iteration-count: -1;
easing: ease-in-out;
}
states [
running when root.indeterminate: { x: parent.width; }
]
}
}
// One background job, as the interface sees it.
//
// Built in Rust by `activity.rs`, which is the only thing that knows a scan
// from a download. Everything here is already a sentence or a number ready to
// draw: the page decides where a row goes, never what it means.
export struct ActivityRow {
// "Scanning Photos", "Keeping 40 photographs offline".
title: string,
// Whatever the job last said about itself — counts, a byte figure, or the
// error where it failed.
detail: string,
// 0..1, meaningless unless `determinate`.
fraction: float,
// Whether this job knows its own extent. A scan does not.
determinate: bool,
running: bool,
// Kept apart from `running`: a finished job and a failed one are both
// stopped, and only one of them is worth the user's attention.
failed: bool,
// Moves bytes over the network, so it is one of the "transfers" the user
// asks about when the connection is slow (FR-NC-6c).
transfer: bool,
}
// What a view says when it has nothing to show.
//
// Not in the S3 brief, but `app.slint` and `library.slint` had the same two