Files
DarkRoom/ui/dr-ui/ui/widgets.slint
T
dtourolle 4bec01eaf1 Say a photograph is downloading, and how far, instead of failing
The develop view reported a remote original on its way through the
error message, so it read "Could not load image" over "Downloading…".
It did so on every step along the roll, including a cached frame that
was ready within a tick, so each step flashed the error.

Waiting is now its own state. On the step, the grid's thumbnail of the
photograph stands in at once. Only when a transfer is really on the
wire does it dim under "Not on this device yet", with a line like
"Downloading — 12.4 of 38.0 MB" and a progress bar.

The bytes come from a new RemoteBackend::get_reporting. The Nextcloud
backend overrides it to read the body chunk by chunk; the default
reports once at the end. Progress is kept in the in-flight registry by
path, because a step usually lands on a frame the prefetcher is already
fetching. The catalog's file length stands in when the server sends no
Content-Length.
2026-09-26 11:02:11 -04:00

1241 lines
54 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.
// TRACES: FR-UI-6
// 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.
//
// **The same argument decides where accessibility lives** (NFR-A11Y-2). What
// AT-SPI and TalkBack are handed — a role, a name, a value, and an action they
// can invoke — is a property of *what a control is*, not of where it happens
// to be used, so it is declared once here and at the call site only where the
// call site knows something the component cannot. A button annotated in forty
// places is a button unnamed in thirty-nine of them, which is the failure this
// file was written to stop, one layer down.
//
// Two Slint rules shape what that can look like. `accessible-role` must be a
// *constant* — a ternary over a runtime property is a compile error — so a
// component that would need two roles is two components. And every other
// `accessible-*` property is rejected unless a role is set beside it or on the
// element it inherits from; that inheritance is what lets a call site add
// `accessible-checkable` to a `Button` whose role was set here.
import { Theme } from "theme.slint";
import { Icon } from "icons.slint";
import { LabelMark } from "labels.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.
///
/// **Deliberately not exposed to assistive technology from here.** Only
/// the call site knows whether a button that is currently *not* active is
/// a toggle that is off or an ordinary button that has no such state, and
/// announcing every button in the application as an unpressed toggle is
/// worse than announcing none of them. A call site that means a toggle
/// says so — `accessible-checkable: true; accessible-checked: <state>;` —
/// which Slint permits because the role below is inherited.
in property <bool> active: false;
callback clicked();
/// TRACES: FR-DEV-7 | FR-UI-3
/// The button is down, or is no longer down.
///
/// Almost every button acts on release and ignores this. A *held* control
/// is the exception — "show me the original while I am holding this" — and
/// `clicked` describes only the end of that gesture, by which time the
/// thing being looked at has gone.
///
/// Here rather than in a second component because the two are one button
/// in every other respect: the same box, the same states, the same 44pt
/// target grown around a compact rectangle. A `HoldButton` beside this one
/// would be sixty duplicated lines and a second place for the press
/// treatment to drift, which is the failure this file's preamble is about.
///
/// Reported from the pointer rather than from `touch.pressed`, so that a
/// press cancelled by the system — a call arriving, a gesture claimed by
/// the shell — puts the button up. A held comparison that stuck on would
/// leave the photographer editing the original.
callback held(bool);
accessible-role: button;
accessible-label: root.text;
accessible-enabled: root.enabled;
// The action a screen reader invokes, and it goes around the TouchArea
// rather than through it — so the `enabled` gate the TouchArea applies to
// a pointer has to be applied again here, or a disabled button would be
// pressable by exactly the users who cannot see that it is greyed out.
accessible-action-default => {
if (root.enabled) {
root.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(); }
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
root.held(true);
}
if (ev.kind == PointerEventKind.up
|| ev.kind == PointerEventKind.cancel) {
root.held(false);
}
}
}
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;
/// What the drawing means, in words.
///
/// A `Button` gets its accessible name for nothing, out of the text it was
/// already drawing. This control draws no text at all, so the name has to
/// be given — and an icon button without one is not a degraded experience
/// for a screen-reader user, it is an unusable one.
///
/// Left empty it falls back to the icon's own name, which is a bad label
/// ("chevron-right") and still a better answer than silence. That is the
/// bargain `labels::resolve` already strikes for a key nobody has
/// catalogued, and it is struck here for the same reason: a control added
/// today should be reachable before someone has written its word.
in property <string> label;
in property <bool> enabled: true;
/// Sustained state — a toggle that is currently on, not a press.
///
/// Not announced from here, for the reason [`Button`]'s copy of this
/// property gives: the component cannot tell an off toggle from a button
/// with no state, so the call site that means a toggle sets
/// `accessible-checkable: true` and `accessible-checked` beside its
/// `active`.
in property <bool> active: false;
callback clicked();
accessible-role: button;
accessible-label: root.label != "" ? root.label : root.icon;
accessible-enabled: root.enabled;
accessible-action-default => {
if (root.enabled) {
root.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;
/// TRACES: FR-CAT-6 | NFR-A11Y-3
/// A colour label's mark before the word, for the label chips — the
/// same mark the grid cell carries, so the chip and the photographs it
/// narrows to are visibly one thing. 0 for none. The word stays: the
/// mark's letter is the fallback for the eye, the name is the chip.
in property <int> colour-label: 0;
callback clicked();
// Unlike [`Button`], this one *can* say it is a toggle without asking the
// call site, because being one is the whole of what distinguishes it —
// "it is *state*, not an action", two paragraphs up. So `checkable` is
// unconditional and an inactive chip announces as unpressed rather than
// as an ordinary button.
//
// The count is not folded into the name. It is drawn as a `Text`, which
// Slint already exposes as its own node, so a reader reaches it by moving
// one step further rather than by hearing a bare number glued to a word.
accessible-role: button;
accessible-label: root.label;
accessible-checkable: true;
accessible-checked: root.active;
accessible-action-default => { root.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;
}
if root.colour-label > 0: LabelMark {
code: root.colour-label;
size: 14px;
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;
accessible-role: groupbox;
accessible-label: root.title;
accessible-expandable: true;
accessible-expanded: root.expanded;
accessible-action-expand => {
root.expanded = !root.expanded;
root.toggled(root.expanded);
}
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;
// The reset is drawn only under the pointer, which
// ui-navigation.md D-N2 rules out as the *only* route to
// a control. Announcing it whenever it is live gives a
// screen reader the route the ink withholds — so this is
// not a translation of the visual affordance so much as
// the honest version of it, and the gate is `has-reset &&
// modified` rather than the hover the `Text` below adds.
accessible-role: button;
accessible-label: "Reset";
accessible-enabled: root.has-reset && root.modified;
accessible-action-default => {
if (root.has-reset && root.modified) {
root.op-reset();
}
}
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;
/// Let the contents grow into the panel's full height.
///
/// The default packs them at the top, which is right for the stacks of
/// controls this was written for: a column of sliders should sit under its
/// heading, not spread out to meet the bottom edge.
///
/// **A panel holding something that scrolls needs the other answer**, and
/// needs it stated. `alignment: start` gives every child its *preferred*
/// height, and a scrolling view has no preferred height worth the name —
/// its whole purpose is to be smaller than what it holds. So it is given
/// nothing and draws nothing, with no error and no clue: the People rail
/// came out blank this way, its model full and its list 0px tall.
in property <bool> fill: false;
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: root.fill ? LayoutAlignment.stretch : LayoutAlignment.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;
/// What this entry is for, in words.
///
/// The visible caption belongs to whatever row wraps the field — `TextRow`
/// draws one above, the launch screen draws one beside — and none of those
/// is a thing Slint associates with the entry on its own. So the name is
/// carried a second time, here, where the accessibility tree can attach it
/// to the control the user is actually typing into.
///
/// **Anything wrapping this sizes itself from `height`, not
/// `preferred-height`.** The height is set outright, so there is no layout
/// inside to report a preferred one and it reads zero. `TextRow` sized its
/// box from it from 0.9.0 to 0.15.0, the field centred itself half a field
/// above that empty box, and every `TextRow` label drew behind its entry.
in property <string> label;
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);
/// Take the keyboard, and select what is already there.
///
/// For a sheet whose field is the only thing to do in it: the field arrives
/// with the sheet, nothing else on the card can sensibly hold focus, and
/// asking the user to tap a box that is the only box is a step with no
/// decision in it. On a tablet it is also what raises the on-screen
/// keyboard, which is the actual point.
///
/// A function rather than a property, because focus is an event and not a
/// state: bound to a property it would fight anything else that took focus
/// afterwards, and re-take it on every unrelated re-evaluation.
public function take-focus() {
input.focus();
input.select-all();
}
/// Give the keyboard back.
///
/// The other half of `take-focus`, and the one a field that *submits*
/// needs: pressing Enter on a name has finished with the name, but Slint
/// leaves the entry focused, so on a tablet the on-screen keyboard stays
/// up covering the very thing the user just named. Nothing else on those
/// screens takes focus on its own, so the field has to let go itself.
///
/// A function and not a property, for the reason `take-focus` gives.
public function release-focus() {
input.clear-focus();
}
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); }
// Slint gives a TextInput its role, its value, its enabled state and
// its set-value action for free; the name and the placeholder are the
// two it cannot guess. They go on the entry rather than on the box
// around it so there is one node in the tree and not a nameless
// rectangle wrapping a nameless input.
accessible-label: root.label;
accessible-placeholder-text: root.placeholder;
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 == "";
// Drawn text, not content. The same words already reach the tree as
// the entry's `accessible-placeholder-text`, where a reader can
// announce them as a prompt rather than as a value the field holds —
// which is what a second text node beside an empty entry would look
// like. `lineedit-base.slint` in Slint's own widgets does exactly this.
accessible-role: none;
}
}
// 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;
/// What is progressing. A bar with no name announces as "progress
/// indicator, 40%", which says how far along an unnamed something is.
in property <string> label;
// An indeterminate bar reports no value at all rather than 0%. It has one
// — the sweep — but it is not a position, and a reader that announced 0%
// for a directory walk that is half done would be stating a falsehood in
// the one place the interface was careful not to (see the two modes
// above). Silence is the honest answer to "how far".
accessible-role: progress-indicator;
accessible-label: root.label;
accessible-value: root.indeterminate
? ""
: Math.round(clamp(root.fraction, 0, 1) * 100) + "%";
accessible-value-minimum: 0;
accessible-value-maximum: 100;
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;
}
}
// What a view shows while the thing it will show is still on its way.
//
// The third answer beside `EmptyState`'s two. A photograph being downloaded is
// neither missing nor broken, and the develop view used to put it under the
// error heading — "Could not load image", then "Downloading…" — which reads as
// a failure followed by a retry. This one shows what is already in hand (the
// grid's thumbnail) so the step lands on *this* photograph at once, and, only
// once there is a real wait (`detail` set), dims it under a headline, how far
// along, and a bar. A read from disk never gets that far, so it never
// flashes text.
export component WaitingState inherits Rectangle {
in property <string> headline;
/// Empty while the wait is too short to talk about.
in property <string> detail;
/// 0..1, or below zero while the size is not known.
in property <float> fraction: -1;
/// What the bar is announced as.
in property <string> what;
in property <image> preview;
in property <bool> has-preview: false;
if root.has-preview: Image {
width: 100%;
height: 100%;
source: root.preview;
image-fit: contain;
opacity: root.detail != "" ? 0.4 : 1;
}
if root.detail != "": VerticalLayout {
alignment: center;
spacing: Theme.gap;
Text {
text: root.headline;
color: Theme.ink;
font-size: Theme.text-lg;
horizontal-alignment: center;
}
Caption {
text: root.detail;
horizontal-alignment: center;
}
HorizontalLayout {
alignment: center;
ProgressBar {
width: min(240px, root.width * 60%);
fraction: root.fraction;
indeterminate: root.fraction < 0;
label: root.what;
}
}
}
}
// A scrolling list that only builds the rows you can see.
//
// # Why this exists rather than `Flickable { VerticalLayout { for … } }`
//
// That spelling instantiates every row. It is invisible on a list of forty and
// it is the whole cost on a list of fourteen thousand: the Identity rail built
// 14,268 row subtrees on every rebuild, which measures at 4.2 seconds before a
// pixel is drawn — and it paid that again on every action, because every
// action reloads the model.
//
// Slint's compiler has a virtualising path for a `for`, and it is what makes
// `std-widgets`' `ListView` cheap. It keys on the parent element's base being
// *named* `ListView` and exposing the five lengths its layouting code writes
// back — see `parent_is_listview` in `i-slint-compiler`'s `object_tree`. A
// custom base is explicitly allowed, and that is what this is: the
// optimisation without `std-widgets`, whose `ListView` inherits its own
// `ScrollView` and would bring a second style into the file that establishes
// ours.
//
// # The rule a caller must keep
//
// **Every row must be the same, constant height, and that height must not read
// the model.** The virtualisation places row N at `N × height` without
// building rows 0..N, so a height the model can change is a height the layout
// cannot know in advance. Slint's answer to that is to build all of them
// anyway — silently, and at the full cost this component exists to avoid. A
// row that hides itself with `height: cond ? 52px : 0px` is that mistake
// wearing a conditional: to leave a row out, leave it out of the model.
//
// # Why it wraps a `Flickable` instead of being one
//
// A `Flickable` takes its preferred *and maximum* height from its viewport, and
// the viewport of a virtualising list is written by the layouting pass — which
// has not run at the moment the enclosing layout asks how tall this wants to
// be. Inheriting `Flickable` therefore answers "nothing", truthfully, and gets
// nothing: an empty rail with every person still in the model, which is exactly
// what the first attempt at this shipped into a screenshot.
//
// So the sizing is declared here and the `Flickable` is held inside, filling
// it — the same shape, and the same six lines, as `std-widgets`' own
// `ScrollView`. Its scrollbars are the part left out: the rails that use this
// draw their own chrome, or none.
export component ListView {
// Aliases rather than bindings, because the list-view layouting writes
// through them. The compiler requires all five, as lengths, to recognise
// this as a list view at all.
out property <length> visible-width <=> flick.width;
out property <length> visible-height <=> flick.height;
in-out property <length> viewport-width <=> flick.viewport-width;
in-out property <length> viewport-height <=> flick.viewport-height;
in-out property <length> viewport-x <=> flick.viewport-x;
in-out property <length> viewport-y <=> flick.viewport-y;
callback scrolled <=> flick.flicked;
min-width: 0px;
min-height: 0px;
horizontal-stretch: 1;
vertical-stretch: 1;
preferred-width: 100%;
preferred-height: 100%;
flick := Flickable {
width: parent.width;
height: parent.height;
@children
}
}
// TRACES: FR-UI-1
// Whether this build draws scrollbars.
//
// Set once by Rust from `dr_plat::is_touch_first()` — the same answer that
// puts the adjustment groups in the tool rail, and for the same reason: it
// says what the user points with, which is the only thing a scrollbar is
// about. A pointer needs one, to see that a list goes on past its edge, how
// far, and where it is in it, and to get there without a wheel. A finger
// does not: it flicks, and a thin bar along the edge of a tablet is a target
// no finger hits on purpose and one a scrolling thumb hits by accident.
export global Scrolling {
in property <bool> bars: true;
}
// GESTURE: Scroll by the scrollbar
// where: Everywhere
// pointer: Drag the bar along the right-hand edge of a list, or click the
// track above or below it to move by a page
// why: A list cut off at its edge looks, to a mouse, like a list that
// ends there — the film stocks past the tenth read as deleted.
// The bar says there is more and where the view is in it, and it
// is the one way to scroll that needs neither a wheel nor a drag
// on the content, which may be a row that would take the click.
//
// A vertical scrollbar for a `Flickable`, drawn over its right-hand edge.
//
// **A sibling of the Flickable, not a wrapper around it.** Every scroller in
// this application has a note on why its viewport is spelled the way it is,
// and a wrapper would have to re-expose all of it. The bar instead binds to
// the Flickable the caller already has — `viewport-y <=> flick.viewport-y`
// and the two heights — so the scroller keeps its sizing and gains a bar.
// Put both in a plain `Rectangle` the Flickable fills, and place the bar at
// its right edge: `x: parent.width - self.width; y: 0;`.
//
// **Over the content, not beside it.** Beside it would take its width from
// every column it is added to, and some of those widths are mandated
// (`panel-width` in `style.yaml`). Over it, it costs the strip it sits on,
// and only while there is somewhere to scroll to: with nothing overflowing it
// is not drawn and takes no input at all.
//
// **Not in a scroller that scrolls itself.** A bar inside another Flickable
// is inside that Flickable's arbitration, and the outer one claims a vertical
// drag before the bar sees the press — the reason the film list is a popup
// and not a list inside the develop column.
export component ScrollBar {
/// The Flickable's `viewport-y`, two-way: zero at the top, negative below.
in-out property <length> viewport-y;
/// The Flickable's `viewport-height`: how tall the content is.
in property <length> viewport-height;
/// The Flickable's own height: how much of the content is on screen.
in property <length> visible-height;
/// How far the content can move.
property <length> range: max(0px, root.viewport-height - root.visible-height);
property <bool> live: Scrolling.bars && root.range > 0.5px;
/// In proportion to the share on screen, and never too small to grab.
property <length> thumb-height:
min(root.height, max(24px, root.height * root.visible-height / max(1px, root.viewport-height)));
property <length> travel: max(1px, root.height - root.thumb-height);
property <length> thumb-y:
root.travel * clamp(-root.viewport-y / max(1px, root.range), 0, 1);
property <bool> lit: area.has-hover || area.pressed;
width: 10px;
height: root.visible-height;
visible: root.live;
function scroll-to(top: length) {
root.viewport-y = -clamp(top, 0px, root.range);
}
// The track, drawn only while the pointer is on it: at rest a scroller
// shows only its thumb, which is all the "there is more" it needs to say.
Rectangle {
background: root.lit ? Theme.hover : transparent;
border-radius: 3px;
}
Rectangle {
x: root.lit ? 2px : 4px;
y: root.thumb-y;
width: parent.width - self.x - 2px;
height: root.thumb-height;
border-radius: self.width / 2;
background: area.pressed ? Theme.ink : (area.has-hover ? Theme.ink-dim : Theme.rule);
}
area := TouchArea {
enabled: root.live;
/// Where on the thumb the press landed, so the thumb does not jump to
/// centre itself under the pointer when it is grabbed off-centre.
property <length> grip: 0px;
property <bool> on-thumb: false;
pointer-event(event) => {
if (event.button == PointerEventButton.left && event.kind == PointerEventKind.down) {
if (self.mouse-y >= root.thumb-y && self.mouse-y <= root.thumb-y + root.thumb-height) {
self.on-thumb = true;
self.grip = self.mouse-y - root.thumb-y;
} else {
// The track: a page toward the click, as a desktop
// scrollbar does. A page and not a jump to the point,
// because a page keeps a line of what was on screen in
// view, and a jump loses the reader's place.
self.on-thumb = false;
root.scroll-to(-root.viewport-y
+ (self.mouse-y < root.thumb-y ? -1 : 1) * root.visible-height * 0.9);
}
}
}
moved => {
if (self.pressed && self.on-thumb) {
root.scroll-to((self.mouse-y - self.grip) / root.travel * root.range);
}
}
// The wheel over the bar scrolls what it is the bar of. The bar is
// the Flickable's sibling rather than its child, so without this a
// wheel here would reach nothing.
scroll-event(event) => {
root.scroll-to(-root.viewport-y - event.delta-y);
accept
}
}
}