Files
DarkRoom/ui/dr-ui/ui/widgets.slint
T
dtourolleandClaude Opus 5 57c0cc0d35 Keep the name typed into a cluster when the next one is opened
Naming a cluster and moving straight to the next is the gesture this screen
exists for, and it discarded the name every time. Two causes, both in the same
four lines.

`Field.text` is two-way bound to its `TextInput`. Binding it to `selected-name`
therefore works exactly once: the first keystroke writes through the `<=>` and
**replaces** the declarative binding, after which the field follows nothing.
Switching clusters left the previous cluster's half-typed text on screen,
attached to the new person.

And `Field` only reported `accepted`, which is Enter. A name typed and then
abandoned by clicking the next face never reached Rust at all.

So `Field` gains an `edited` callback, the screen keeps the draft with the
person it was typed for, and the draft is written when the selection moves or
the screen closes. The field is then reset from a revision counter the screen
watches.

A counter rather than `changed selected-name`, because the name is not a key:
naming six clusters "Anna" in a row never changes `selected-name`, and the field
would keep the half-typed text from the cluster before. Nor `changed
selected-person`, since accepting a namesake merge lands the user back on a
person they may already have been on.

The draft carries its `PersonId`. A reload can move the selection out from under
a half-typed name — a merge arriving through a sync, a deletion — and applying
it to whoever is selected now would rename a stranger. If the person is gone
when the draft lands, it is dropped rather than resurrecting a row the rail no
longer shows.

`None` and `Some("")` are kept distinct. A user who cleared the field means to
clear the name; a user who never touched it means to leave it alone. Collapsing
those two erases names by walking past them.

An implicit commit does not raise the namesake merge offer. That question is
about a screen the user has already left, and answering it on their behalf while
they look at the next cluster is not a question at all — the offer stays on the
explicit submit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:32:00 +02:00

758 lines
31 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Shared chrome primitives and the style layer.
//
// Before this file every button was a Rectangle + TouchArea written out where
// it was needed, at 64×20, 110×28 and 88×28 with three near-identical
// hover/press treatments. Any consistency was coincidental. These are the
// pieces that make it deliberate; nothing here draws a colour literal.
//
// **The rule this file establishes.** Screen files consume components; raw
// `Theme.*` is for *composing* a component, not for styling a call site. A
// bare `Theme.ink-faint` or a font-size in `app.slint` means a component is
// missing, not that a screen needs an exception. `theme.slint` says what
// `surface` is; only this file says what a *panel heading* is — and until it
// did, four screens each re-derived one, which is exactly how a single accent
// 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.
//
// **The drawn box and the hit target are separate.** FR-UI-3 asks for a 44pt
// minimum target under touch, but a 44px-tall button in a 44px-tall grid
// header leaves no room for the header, and the chrome would grow to meet a
// requirement that is about the *finger*, not the ink. So the rectangle is
// `Theme.control-height` and the TouchArea is grown to `Theme.touch-target`
// and centred over it, exactly as `FormatCheck` in launch.slint does. Callers
// laying these out horizontally get the compact size they expect; a thumb
// still gets 44px.
//
// The overhang is deliberately allowed to spill outside the parent's bounds.
// It only matters when two buttons sit within 8px vertically of each other,
// which no current layout does — buttons live in single rows.
export component Button inherits Rectangle {
in property <string> text;
in property <bool> enabled: true;
/// The one affirmative action in a group. At most one per group, or the
/// 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 an icon
/// toggle read alike.
in property <bool> active: false;
callback clicked();
height: Theme.control-height;
// A minimum rather than a fixed width: callers that set `width` or hand
// this to a stretching layout still win, and a long label is not clipped.
min-width: Theme.control-min-width;
horizontal-stretch: 0;
border-radius: Theme.radius;
border-width: root.primary ? 0px : 1px;
border-color: root.active ? Theme.active : Theme.rule;
// A primary button is filled rather than outlined, and with no hue left to
// fill it with the fill is near-white — so it moves the opposite way to a
// secondary button: hover *brightens* toward `active` and press sinks to
// `active-pressed`, where the neutral variant lifts from `surface-raised`.
background: root.primary
? (touch.pressed ? Theme.active-pressed
: (touch.has-hover ? Theme.active : Theme.active-dim))
: (touch.pressed ? Theme.pressed
: (touch.has-hover ? Theme.hover : Theme.surface-raised));
// Disabled reads as "not now", not as a second kind of button — the shape
// stays and only the contrast drops.
opacity: root.enabled ? 1.0 : 0.45;
touch := TouchArea {
enabled: root.enabled;
// Explicit geometry: a TouchArea with none collapses to zero and only
// catches the events that happen to land on it.
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
clicked => { root.clicked(); }
}
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
Text {
text: root.text;
// Dark on the primary fill: that fill is now near-white, and the
// white label this carried when the fill was a saturated red is
// unreadable against it.
color: root.primary ? Theme.ground
: (root.active ? Theme.active : Theme.ink);
font-size: Theme.text-sm;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
overflow: elide;
}
}
}
// A square button carrying a single icon.
//
// Square because it has no label to size against: a toolbar affordance whose
// width tracked its drawing would jitter as the drawing changed.
export component IconButton inherits Rectangle {
/// 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;
callback clicked();
width: Theme.control-height;
height: Theme.control-height;
horizontal-stretch: 0;
border-radius: Theme.radius;
border-width: 1px;
border-color: root.active ? Theme.active : Theme.rule;
background: touch.pressed ? Theme.pressed
: (touch.has-hover ? Theme.hover : Theme.surface-raised);
opacity: root.enabled ? 1.0 : 0.45;
touch := TouchArea {
enabled: root.enabled;
width: max(parent.width, Theme.touch-target);
height: max(parent.height, Theme.touch-target);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
clicked => { root.clicked(); }
}
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;
}
}
// A toggle in a row of toggles: one term of a filter.
//
// Distinct from `Button` because it is *state*, not an action — it stays on
// after the click, and a row of them says what the grid is currently showing.
// A `Button` that happened to be styled differently would drift the moment
// either changed.
//
// **Active reads as filled, not merely outlined.** These sit in a row where
// several look alike, so an inactive-versus-active difference carried by a
// border alone is invisible at a glance across six chips. The active one
// inverts — near-white fill, dark text — which is the same treatment
// `Button.primary` uses for "this is the one".
//
// **The count is optional and never fabricated.** `-1` means "not known yet",
// which is different from zero: a filter with no images behind it should say
// `0` so the user knows narrowing to it will empty the grid, but one whose
// count has not been computed must not claim zero.
export component FilterChip inherits Rectangle {
in property <string> label;
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();
height: Theme.control-height - 4px;
// A floor on a content-sized chip, expressed as one property: Slint rejects
// `width` and `min-width` together, and the floor is what keeps a chip
// labelled "3" from being a sliver too small to hit.
width: max(34px, row.preferred-width + 2 * Theme.gap-sm);
horizontal-stretch: 0;
border-radius: Theme.radius;
border-width: 1px;
border-color: root.active ? Theme.active : Theme.rule;
background: root.active
? (touch.pressed ? Theme.active-pressed : Theme.active-dim)
: (touch.pressed ? Theme.pressed
: (touch.has-hover ? Theme.hover : Theme.surface-raised));
touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
row := HorizontalLayout {
padding-left: Theme.gap-sm;
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
// inversion `Button.primary` makes for the same reason.
color: root.active ? Theme.ground : Theme.ink;
font-size: Theme.text-sm;
font-weight: root.active ? 700 : 500;
vertical-alignment: center;
}
Text {
text: root.count >= 0 ? root.count : "";
// Dimmer than the label on both grounds: the count is supporting
// detail, and a chip whose number shouted louder than its name
// would read as a number with a caption.
color: root.active ? Theme.ground : Theme.ink-faint;
opacity: root.active ? 0.7 : 1.0;
font-size: Theme.text-sm;
vertical-alignment: center;
}
}
}
// 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 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
// been touched.
//
// `expanded` is in-out so a caller can key collapse state by something stable
// (an operation index, never a label) and drive it from outside; left alone it
// works standalone as a self-toggling disclosure.
//
// **Collapsing is fiddlier than it looks.** `@children` cannot appear inside
// a conditional element — Slint rejects it outright — so the body cannot be
// dropped from the tree with `if root.expanded`. And `visible: false` alone
// only hides the ink: the element keeps its layout slot, so a stack of
// collapsed sections would be a column of gaps.
//
// So the body is a plain Rectangle that is both hidden *and* clamped to zero
// height when collapsed, with `clip: true` so children taller than the clamp
// cannot paint outside it. The clamp reads `body-inner.preferred-height`,
// which is a *preferred* size — an input to layout, never a result of it —
// so `expanded` feeding the height does not loop back.
export component Section inherits Rectangle {
in property <string> title;
/// Anything inside differs from its default. The caller computes this —
/// the section cannot see into `@children`.
in property <bool> modified: false;
in-out property <bool> expanded: true;
/// Fired after `expanded` has already been flipped, for callers that
/// persist the state rather than letting this component own it.
callback toggled(bool);
/// Undo everything inside. The affordance only appears once `modified` is
/// true — a reset on an untouched group is a control that cannot do
/// anything, and a header carrying one permanently is a header that reads
/// as busy rather than as a name.
///
/// Sections whose contents have nothing to undo simply leave this
/// unconnected, and `has-reset` off.
callback op-reset();
/// Whether this section's contents can be reset at all.
in property <bool> has-reset: true;
background: transparent;
// Own height comes from the layout below, so a collapsed section shrinks
// to its header.
height: body.preferred-height;
body := VerticalLayout {
spacing: 0px;
alignment: start;
header := Rectangle {
height: Theme.control-height;
background: header-touch.pressed ? Theme.pressed
: (header-touch.has-hover ? Theme.hover : transparent);
border-radius: Theme.radius-sm;
header-touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
mouse-cursor: pointer;
clicked => {
root.expanded = !root.expanded;
root.toggled(root.expanded);
}
}
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Disclosure {
icon: root.expanded ? "chevron-down" : "chevron-right";
}
Text {
text: root.title;
color: header-touch.has-hover ? Theme.ink : Theme.ink-dim;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: 0.8px;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
// The modified dot: the one thing that survives collapsing,
// so a closed section still says whether it holds an edit.
Rectangle {
width: 6px;
height: 6px;
y: (parent.height - self.height) / 2;
border-radius: 3px;
background: Theme.modified;
visible: root.modified;
}
// The group's reset. Shown only when there is something to
// undo *and* the pointer is on the header, so a panel at rest
// is a column of names rather than a column of buttons.
//
// It declares its width whether or not it is visible: a
// control that appeared on hover and *also* widened the row
// would shift the title sideways under the pointer, which
// reads as the panel flinching away from the cursor.
Rectangle {
width: 28px;
reset-touch := TouchArea {
// Sits after `header-touch` in the tree, so it takes
// the press first and the section does not toggle out
// from under a reset.
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.has-reset && root.modified;
mouse-cursor: pointer;
clicked => { root.op-reset(); }
}
Text {
text: "reset";
color: reset-touch.has-hover ? Theme.ink : Theme.ink-faint;
font-size: Theme.text-sm;
vertical-alignment: center;
horizontal-alignment: right;
width: 100%;
height: 100%;
visible: root.has-reset && root.modified
&& (header-touch.has-hover || reset-touch.has-hover);
}
}
}
}
Rectangle {
// Collapsed by height plus clip, deliberately *not* by `visible`:
// Slint treats a visibility-guarded element as conditional, and
// `@children` cannot appear inside one. Zero height with clipping
// hides the body just as completely.
height: root.expanded ? body-inner.preferred-height : 0px;
clip: true;
body-inner := VerticalLayout {
spacing: 0px;
alignment: start;
@children
}
}
}
}
// --- the style layer ---------------------------------------------------
//
// Text roles. Four components rather than one with a `role` enum, because a
// role is chosen once at the call site and never switched at runtime — an
// enum would buy nothing and cost a qualified name at every use.
// The name of a panel or a form section: `IMAGE`, `ADJUST`, `SERVER`.
//
// Caps-with-tracking rather than a larger size: these sit directly above the
// content they name, in a column only 280px wide, and a heading that grew the
// row would push the photograph over for the sake of a label. Tracking does
// the same separating work in the same height.
//
// `ink-faint` rather than the accent these all carried. A heading is a label,
// not a state — it is true whatever the panel is doing, so it has no business
// competing with the slider that *is* doing something. It is also read once
// and then skipped, which is what the faintest ink is for.
export component PanelHeading inherits Text {
/// A heading *inside* a panel that already has one — an operation group
/// under `ADJUST`. Tighter tracking, so the two levels are distinguishable
/// where they stack without either needing a second colour or size.
in property <bool> sub: false;
color: Theme.ink-faint;
font-size: Theme.text-sm;
font-weight: 700;
letter-spacing: root.sub ? 0.8px : 1.2px;
vertical-alignment: center;
}
// The name of a thing whose value sits beside it: a parameter name, a form
// field's caption. Dimmer than its value on purpose — the label is constant
// and the value is what changed.
export component Label inherits Text {
/// Lit, for a label under the pointer or one whose value has moved off its
/// default. The caller supplies the condition; this only decides what
/// "lit" looks like.
in property <bool> emphasised: false;
/// Body size rather than the chrome's `text-sm`. For a label the user is
/// reading rather than scanning past — a row in a picker, a tick-box in a
/// form — where the panel is a page rather than an instrument.
in property <bool> body: false;
color: root.emphasised ? Theme.ink : Theme.ink-dim;
font-size: root.body ? Theme.text : Theme.text-sm;
vertical-alignment: center;
}
// A datum: a camera name, a file path, a slider's readout.
//
// **`modified` is the reason this is a component.** A value differing from its
// default is the single thing a photographer scans a panel for, and with hue
// gone from the palette the only signal left is luminance — so the gap has to
// be large and it has to be identical everywhere, or it stops reading as a
// signal at all and becomes texture. One definition, one gap.
export component Value inherits Text {
/// Differs from its default.
in property <bool> modified: false;
/// No value yet — a placeholder standing in for one, not a value that
/// happens to be empty.
in property <bool> placeholder: false;
/// The compact readout that sits on a control's own row, rather than a
/// datum on a line of its own.
in property <bool> compact: false;
color: root.modified ? Theme.modified
: (root.placeholder ? Theme.ink-faint : Theme.ink);
font-size: root.compact ? Theme.text-sm : Theme.text;
vertical-alignment: center;
}
// The colour a row edits, as a small square: the label for a control whose
// subject is a hue rather than a word.
//
// **The one place hue enters the chrome, and it is not an exception to the
// palette rule so much as outside it.** The rule forbids colour used as
// decoration — an accent on a heading, a tinted border — because a saturated
// patch beside the photograph shifts how the photograph reads. This is the
// same kind of thing as the image itself: data. A row that edits the 30° band
// has to say *which* band, and no achromatic treatment can say "orange".
//
// **The core supplies degrees; everything else is decided here.** The
// operation knows its band is centred at 30° because that is the number it
// weights pixels around (ARCH §4.3a). Saturation and brightness are this
// file's to choose, and they are chosen well short of full: a fully saturated
// row of twelve squares is a paintbox sitting next to a print. Held back far
// enough to read as instrument markings, and no further, since a swatch that
// cannot be told from its neighbour has stopped identifying anything.
export component Swatch inherits Rectangle {
/// Where the subject sits on the hue wheel, in degrees.
in property <float> hue;
/// Muted from a full-strength hue, so twelve of these read as a scale
/// rather than as a palette.
in property <float> saturation: 0.55;
/// Bright enough to separate from the surface it sits on, which is dark;
/// a swatch at full value would be the brightest thing in the panel and
/// outrank the modified marker.
in property <float> brightness: 0.85;
width: Theme.swatch;
height: Theme.swatch;
horizontal-stretch: 0;
border-radius: Theme.radius-sm;
background: hsv(root.hue, root.saturation, root.brightness);
// A hairline, so a dark swatch (a deep blue at this brightness) still has
// an edge against the surface and reads as a square rather than a smudge.
border-width: 1px;
border-color: Theme.rule;
}
// Supporting text: a hint under a field, a count beside a title, an empty
// state's second line. The faintest ink, because it is there for the reader
// who stopped to look and should not catch the eye of the one who did not.
export component Caption inherits Text {
/// A caution. The one place hue survives in the chrome — a warning is a
/// different kind of thing from an active state, and saying so instantly
/// is worth the exception (see the theme preamble).
in property <bool> warn: false;
/// Lit, for supporting text the pointer is currently over. Mirrors
/// `Label.emphasised` from one step further down, so the two roles brighten
/// to the same ink and a hover reads identically wherever it lands.
in property <bool> emphasised: false;
color: root.warn ? Theme.warn-ink
: (root.emphasised ? Theme.ink : Theme.ink-faint);
font-size: Theme.text-sm;
vertical-alignment: center;
}
// A region of surface holding a **column** of controls.
//
// Two shapes, because the call sites are two shapes. An inset box on the
// launch screen is bordered and rounded — it sits on the ground with air
// around it and needs its own edge. A panel in the develop column is `flat`:
// it abuts its neighbours, so the divider between them belongs to the column
// that stacks them, and a border here would double up with it.
//
// **Not every bordered box is a Panel.** The folder picker's list is the same
// surface and rule but overlays three mutually exclusive states — loading, the
// list, "nothing here" — each filling the box. This stacks its children, so it
// would lay those three out in a row; that site draws its own Rectangle and
// says why. A component that covered both would need a bool selecting between
// a layout and an overlay, which is two components wearing one name.
//
// `@children` goes in a plain VerticalLayout for the same reason `Section`'s
// body does: Slint rejects `@children` inside anything conditional, so the
// two shapes differ only in properties, never in structure.
export component Panel inherits Rectangle {
/// Abuts its neighbours: no border, no radius. The stacking parent draws
/// the dividing rule.
in property <bool> flat: false;
in property <length> spacing: Theme.gap-sm;
/// Named `inset` rather than `padding`: a Rectangle already reserves
/// `padding` for the layout it may contain, and redeclaring it is a
/// compile error rather than an override.
in property <length> inset: Theme.gap;
background: Theme.surface;
border-radius: root.flat ? 0px : Theme.radius;
border-width: root.flat ? 0px : 1px;
border-color: Theme.rule;
VerticalLayout {
padding: root.inset;
spacing: root.spacing;
alignment: start;
@children
}
}
// A single-line text entry.
//
// **The placeholder is a sibling Text, not a property.** Slint's `TextInput`
// has none of its own, and the alternative — seeding `text` and clearing it on
// focus — loses whatever the user typed if focus arrives before a keystroke.
// A Text underneath, hidden the moment anything is entered, cannot.
//
// The focus border is `active`: focus is a live state of the control, the one
// place in a form where something is *engaged*, which is precisely what that
// token is for.
export component Field inherits Rectangle {
in-out property <string> text;
in property <string> placeholder;
/// Masks the entry, for a credential that should not be readable over the
/// user's shoulder. The placeholder still shows while the field is empty.
in property <bool> secret: false;
/// Whether the entry currently holds focus, so a caller can enable its
/// submit button from the same fact the border is drawn from.
out property <bool> has-focus: input.has-focus;
callback accepted(string);
/// Every keystroke, not just Enter.
///
/// `text` is two-way bound to the entry below, which means the first
/// keystroke **replaces** whatever declarative binding a caller put on it.
/// A caller that binds `text` to a selection and expects it to follow that
/// selection afterwards is therefore wrong, and silently so. This callback
/// is how a caller keeps the draft instead — and `Theme` cannot help it,
/// because the breakage is in Slint's binding model, not the styling.
callback edited(string);
height: Theme.touch-target;
border-radius: Theme.radius;
border-width: 1px;
border-color: input.has-focus ? Theme.active : Theme.rule;
background: Theme.surface;
input := TextInput {
text <=> root.text;
edited => { root.edited(self.text); }
color: Theme.ink;
font-size: Theme.text;
vertical-alignment: center;
// Inset by hand rather than by a layout: a TextInput inside a
// HorizontalLayout is sized by the layout and stops scrolling its own
// content once the text is longer than the box.
x: Theme.gap;
width: parent.width - 2 * Theme.gap;
height: 100%;
single-line: true;
input-type: root.secret ? InputType.password : InputType.text;
accepted => { root.accepted(self.text); }
}
Text {
text: root.placeholder;
color: Theme.ink-faint;
font-size: Theme.text;
vertical-alignment: center;
x: Theme.gap;
height: 100%;
visible: input.text == "";
}
}
// 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
// centred lines — a `text-lg` headline over a `text-sm` explanation — and the
// distinction they draw is the load-bearing one: "still working" and "finished
// and found nothing" are different answers, and a view that conflates them
// makes a working scan look broken. One component, so neither view can drift
// into answering only half of it.
//
// The headline is the only place `text-lg` appears outside a masthead, which
// is why it is here rather than as a `Label` variant: it is a size this file
// otherwise does not hand out.
export component EmptyState inherits VerticalLayout {
in property <string> headline;
in property <string> detail;
alignment: center;
spacing: Theme.gap;
Text {
text: root.headline;
color: Theme.ink-dim;
font-size: Theme.text-lg;
horizontal-alignment: center;
}
Caption {
text: root.detail;
horizontal-alignment: center;
wrap: word-wrap;
}
}