FR-DEV-7 asks for the current edit against the unedited original and nothing implemented it. What the develop view had was history navigation, which *changes* the edit rather than previewing against it — so the only way to look was to undo, look, and redo, and that puts two real steps on the stack at exactly the moment a photographer suspects they have overcooked a frame and is least sure of what they are doing. Holding the "Before" button, or backslash, renders the graph with every adjustment stripped and hands it straight back afterwards: the same suspend-render-restore shape the crop overlay already uses to show an uncropped frame and an export uses to suspend the zoom. Nothing is recorded, no rows are re-synced, and the photograph is still modified when the key comes up — the panel goes on describing the edit the photographer has, because only the canvas is answering a question. The framing deliberately stays on. A held comparison is a question about tone and colour, and re-cropping the canvas under someone's thumb would move the detail they are comparing; worse, the zoom is a rectangle of the *framed* image, so dropping the crop at 4× would quietly show a different part of the photograph rather than the same part unedited. What the crop took away is already compared in Compose, which shows the whole frame. Not a split screen: that halves the working image on the tablet this column was sized for, and the comparison photographers describe making is a flick back and forth rather than two pictures side by side. Press-and-hold is one gesture on a finger and on a mouse, which is what FR-DEV-3b's mapping wants, and it has no mode to be stranded in — the button reports both edges, so a press the system cancels puts the original down too.
1046 lines
46 KiB
Plaintext
1046 lines
46 KiB
Plaintext
// 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";
|
||
|
||
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;
|
||
|
||
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;
|
||
}
|
||
|
||
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.
|
||
///
|
||
/// That second copy is not redundant even where the caption renders. It is
|
||
/// the *only* copy where the caption does not: `TextRow`'s label draws
|
||
/// behind its own field on the settings page and has done since 0.9.0, so
|
||
/// a sighted user reading that page today has less to go on than a screen
|
||
/// reader does.
|
||
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;
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|