// 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 text; in property 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 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: ;` — /// which Slint permits because the role below is inherited. in property 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 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 label; in property 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 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 label; in property active: false; /// Images behind this term, or -1 where the count is not known. in property 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 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 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 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 title; /// Anything inside differs from its default. The caller computes this — /// the section cannot see into `@children`. in property modified: false; in-out property 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 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 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 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 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 modified: false; /// No value yet — a placeholder standing in for one, not a value that /// happens to be empty. in property 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 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 hue; /// Muted from a full-strength hue, so twelve of these read as a scale /// rather than as a palette. in property 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 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 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 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 flat: false; in property 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 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 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 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 label; in property 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 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 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 fraction: 0; in property 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 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 headline; in property 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 headline; /// Empty while the wait is too short to talk about. in property detail; /// 0..1, or below zero while the size is not known. in property fraction: -1; /// What the bar is announced as. in property what; in property preview; in property 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 visible-width <=> flick.width; out property visible-height <=> flick.height; in-out property viewport-width <=> flick.viewport-width; in-out property viewport-height <=> flick.viewport-height; in-out property viewport-x <=> flick.viewport-x; in-out property 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 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 viewport-y; /// The Flickable's `viewport-height`: how tall the content is. in property viewport-height; /// The Flickable's own height: how much of the content is on screen. in property visible-height; /// How far the content can move. property range: max(0px, root.viewport-height - root.visible-height); property live: Scrolling.bars && root.range > 0.5px; /// In proportion to the share on screen, and never too small to grab. property thumb-height: min(root.height, max(24px, root.height * root.visible-height / max(1px, root.viewport-height))); property travel: max(1px, root.height - root.thumb-height); property thumb-y: root.travel * clamp(-root.viewport-y / max(1px, root.range), 0, 1); property 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 grip: 0px; property 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 } } }