Add the library, collections, and trash views; theme from style.yaml
The UI gains the views the catalog work was building toward: a windowed
library grid with ratings and flags, the collection tree with drag-to-add,
and trash with restore. derived_sync pushes thumbnail shards and the catalog
snapshot to the server's derived folder.
Tokens now have one source of truth. build.rs reads style.yaml and generates
theme.slint into OUT_DIR, which answers every existing
`import { Theme } from "theme.slint"` unchanged, because Slint resolves
imports against the importing file's directory first and the include paths
after. Generating into OUT_DIR rather than beside the hand-written Slint is
the point: a generated file sitting in ui/ looks exactly like the files
around it that are meant to be edited, and an edit to it would survive until
the next touch of style.yaml — a bug that hides for weeks. build.rs fails
loudly if a stale ui/theme.slint exists, which would otherwise shadow the
generated one silently and make every palette change vanish with no error.
The palette moves to near-neutral dark with achromatic signalling, so the
accent means "modified" or "active" rather than "heading". Shared components
land in widgets.slint: a token that binds several values into one concept is
a component, not a row in a YAML file.
Adds an optional live-style feature that makes the tokens in-out so they can
be written at startup — a feature rather than the default because it stops
the properties being constant-folded.
serde_norway is the YAML crate: serde_yaml and serde_yml are both deprecated,
and its mappings preserve insertion order, which is what lets the generated
Slint keep the token ordering the author chose.
Assisted-by: LLM
This commit is contained in:
@@ -0,0 +1,600 @@
|
||||
// Shared chrome primitives and the style layer.
|
||||
//
|
||||
// Before this file every button was a Rectangle + TouchArea written out where
|
||||
// it was needed, at 64×20, 110×28 and 88×28 with three near-identical
|
||||
// hover/press treatments. Any consistency was coincidental. These are the
|
||||
// pieces that make it deliberate; nothing here draws a colour literal.
|
||||
//
|
||||
// **The rule this file establishes.** Screen files consume components; raw
|
||||
// `Theme.*` is for *composing* a component, not for styling a call site. A
|
||||
// bare `Theme.ink-faint` or a font-size in `app.slint` means a component is
|
||||
// missing, not that a screen needs an exception. `theme.slint` says what
|
||||
// `surface` is; only this file says what a *panel heading* is — and until it
|
||||
// did, four screens each re-derived one, which is exactly how a single accent
|
||||
// colour reached forty call sites with no single place to change it.
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
|
||||
// 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 a glyph
|
||||
/// toggle read alike.
|
||||
in property <bool> active: false;
|
||||
|
||||
callback clicked();
|
||||
|
||||
height: Theme.control-height;
|
||||
// A minimum rather than a fixed width: callers that set `width` or hand
|
||||
// this to a stretching layout still win, and a long label is not clipped.
|
||||
min-width: Theme.control-min-width;
|
||||
horizontal-stretch: 0;
|
||||
|
||||
border-radius: Theme.radius;
|
||||
border-width: root.primary ? 0px : 1px;
|
||||
border-color: root.active ? Theme.active : Theme.rule;
|
||||
|
||||
// A primary button is filled rather than outlined, and with no hue left to
|
||||
// fill it with the fill is near-white — so it moves the opposite way to a
|
||||
// secondary button: hover *brightens* toward `active` and press sinks to
|
||||
// `active-pressed`, where the neutral variant lifts from `surface-raised`.
|
||||
background: root.primary
|
||||
? (touch.pressed ? Theme.active-pressed
|
||||
: (touch.has-hover ? Theme.active : Theme.active-dim))
|
||||
: (touch.pressed ? Theme.pressed
|
||||
: (touch.has-hover ? Theme.hover : Theme.surface-raised));
|
||||
|
||||
// Disabled reads as "not now", not as a second kind of button — the shape
|
||||
// stays and only the contrast drops.
|
||||
opacity: root.enabled ? 1.0 : 0.45;
|
||||
|
||||
touch := TouchArea {
|
||||
enabled: root.enabled;
|
||||
// Explicit geometry: a TouchArea with none collapses to zero and only
|
||||
// catches the events that happen to land on it.
|
||||
width: 100%;
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
y: (parent.height - self.height) / 2;
|
||||
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap;
|
||||
padding-right: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: root.text;
|
||||
// Dark on the primary fill: that fill is now near-white, and the
|
||||
// white label this carried when the fill was a saturated red is
|
||||
// unreadable against it.
|
||||
color: root.primary ? Theme.ground
|
||||
: (root.active ? Theme.active : Theme.ink);
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 600;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A square button carrying a single glyph.
|
||||
//
|
||||
// Square because it has no label to size against: a toolbar affordance whose
|
||||
// width tracked its glyph would jitter as the glyph changed.
|
||||
export component IconButton inherits Rectangle {
|
||||
in property <string> glyph;
|
||||
in property <bool> enabled: true;
|
||||
/// Sustained state — a toggle that is currently on, not a press.
|
||||
in property <bool> active: false;
|
||||
|
||||
callback clicked();
|
||||
|
||||
width: Theme.control-height;
|
||||
height: Theme.control-height;
|
||||
horizontal-stretch: 0;
|
||||
|
||||
border-radius: Theme.radius;
|
||||
border-width: 1px;
|
||||
border-color: root.active ? Theme.active : Theme.rule;
|
||||
|
||||
background: touch.pressed ? Theme.pressed
|
||||
: (touch.has-hover ? Theme.hover : Theme.surface-raised);
|
||||
opacity: root.enabled ? 1.0 : 0.45;
|
||||
|
||||
touch := TouchArea {
|
||||
enabled: root.enabled;
|
||||
width: max(parent.width, Theme.touch-target);
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default;
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.glyph;
|
||||
color: root.active ? Theme.active : Theme.ink;
|
||||
font-size: Theme.text;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
callback clicked();
|
||||
|
||||
height: Theme.control-height - 4px;
|
||||
// A floor on a content-sized chip, expressed as one property: Slint rejects
|
||||
// `width` and `min-width` together, and the floor is what keeps a chip
|
||||
// labelled "3" from being a sliver too small to hit.
|
||||
width: max(34px, row.preferred-width + 2 * Theme.gap-sm);
|
||||
horizontal-stretch: 0;
|
||||
|
||||
border-radius: Theme.radius;
|
||||
border-width: 1px;
|
||||
border-color: root.active ? Theme.active : Theme.rule;
|
||||
background: root.active
|
||||
? (touch.pressed ? Theme.active-pressed : Theme.active-dim)
|
||||
: (touch.pressed ? Theme.pressed
|
||||
: (touch.has-hover ? Theme.hover : Theme.surface-raised));
|
||||
|
||||
touch := TouchArea {
|
||||
width: 100%;
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
y: (parent.height - self.height) / 2;
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
|
||||
row := HorizontalLayout {
|
||||
padding-left: Theme.gap-sm;
|
||||
padding-right: Theme.gap-sm;
|
||||
spacing: 4px;
|
||||
|
||||
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 glyphs differ in advance
|
||||
// width, so a row that sized to its own arrow would shift its label sideways
|
||||
// as it opened and closed — the one movement that makes a static list look
|
||||
// like it is being redrawn. Rotating a single glyph would need a transform on
|
||||
// a Text; two characters render identically and cost nothing.
|
||||
export component Disclosure inherits Text {
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
horizontal-alignment: center;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
// A collapsible group with a header that reports whether anything inside has
|
||||
// been touched.
|
||||
//
|
||||
// `expanded` is in-out so a caller can key collapse state by something stable
|
||||
// (an operation index, never a label) and drive it from outside; left alone it
|
||||
// works standalone as a self-toggling disclosure.
|
||||
//
|
||||
// **Collapsing is fiddlier than it looks.** `@children` cannot appear inside
|
||||
// a conditional element — Slint rejects it outright — so the body cannot be
|
||||
// dropped from the tree with `if root.expanded`. And `visible: false` alone
|
||||
// only hides the ink: the element keeps its layout slot, so a stack of
|
||||
// collapsed sections would be a column of gaps.
|
||||
//
|
||||
// So the body is a plain Rectangle that is both hidden *and* clamped to zero
|
||||
// height when collapsed, with `clip: true` so children taller than the clamp
|
||||
// cannot paint outside it. The clamp reads `body-inner.preferred-height`,
|
||||
// which is a *preferred* size — an input to layout, never a result of it —
|
||||
// so `expanded` feeding the height does not loop back.
|
||||
export component Section inherits Rectangle {
|
||||
in property <string> title;
|
||||
/// Anything inside differs from its default. The caller computes this —
|
||||
/// the section cannot see into `@children`.
|
||||
in property <bool> modified: false;
|
||||
in-out property <bool> expanded: true;
|
||||
|
||||
/// Fired after `expanded` has already been flipped, for callers that
|
||||
/// persist the state rather than letting this component own it.
|
||||
callback toggled(bool);
|
||||
|
||||
/// Undo everything inside. The affordance only appears once `modified` is
|
||||
/// true — a reset on an untouched group is a control that cannot do
|
||||
/// anything, and a header carrying one permanently is a header that reads
|
||||
/// as busy rather than as a name.
|
||||
///
|
||||
/// Sections whose contents have nothing to undo simply leave this
|
||||
/// unconnected, and `has-reset` off.
|
||||
callback op-reset();
|
||||
/// Whether this section's contents can be reset at all.
|
||||
in property <bool> has-reset: true;
|
||||
|
||||
background: transparent;
|
||||
// Own height comes from the layout below, so a collapsed section shrinks
|
||||
// to its header.
|
||||
height: body.preferred-height;
|
||||
|
||||
body := VerticalLayout {
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
|
||||
header := Rectangle {
|
||||
height: Theme.control-height;
|
||||
background: header-touch.pressed ? Theme.pressed
|
||||
: (header-touch.has-hover ? Theme.hover : transparent);
|
||||
border-radius: Theme.radius-sm;
|
||||
|
||||
header-touch := TouchArea {
|
||||
width: 100%;
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
y: (parent.height - self.height) / 2;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.expanded = !root.expanded;
|
||||
root.toggled(root.expanded);
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap-sm;
|
||||
padding-right: Theme.gap-sm;
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
Disclosure { text: root.expanded ? "▾" : "▸"; }
|
||||
|
||||
Text {
|
||||
text: root.title;
|
||||
color: header-touch.has-hover ? Theme.ink : Theme.ink-dim;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
vertical-alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
// The modified dot: the one thing that survives collapsing,
|
||||
// so a closed section still says whether it holds an edit.
|
||||
Rectangle {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
y: (parent.height - self.height) / 2;
|
||||
border-radius: 3px;
|
||||
background: Theme.modified;
|
||||
visible: root.modified;
|
||||
}
|
||||
|
||||
// The group's reset. Shown only when there is something to
|
||||
// undo *and* the pointer is on the header, so a panel at rest
|
||||
// is a column of names rather than a column of buttons.
|
||||
//
|
||||
// It declares its width whether or not it is visible: a
|
||||
// control that appeared on hover and *also* widened the row
|
||||
// would shift the title sideways under the pointer, which
|
||||
// reads as the panel flinching away from the cursor.
|
||||
Rectangle {
|
||||
width: 28px;
|
||||
|
||||
reset-touch := TouchArea {
|
||||
// Sits after `header-touch` in the tree, so it takes
|
||||
// the press first and the section does not toggle out
|
||||
// from under a reset.
|
||||
width: 100%;
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
y: (parent.height - self.height) / 2;
|
||||
enabled: root.has-reset && root.modified;
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.op-reset(); }
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "reset";
|
||||
color: reset-touch.has-hover ? Theme.ink : Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
horizontal-alignment: right;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
visible: root.has-reset && root.modified
|
||||
&& (header-touch.has-hover || reset-touch.has-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
// Collapsed by height plus clip, deliberately *not* by `visible`:
|
||||
// Slint treats a visibility-guarded element as conditional, and
|
||||
// `@children` cannot appear inside one. Zero height with clipping
|
||||
// hides the body just as completely.
|
||||
height: root.expanded ? body-inner.preferred-height : 0px;
|
||||
clip: true;
|
||||
|
||||
body-inner := VerticalLayout {
|
||||
spacing: 0px;
|
||||
alignment: start;
|
||||
@children
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the style layer ---------------------------------------------------
|
||||
//
|
||||
// Text roles. Four components rather than one with a `role` enum, because a
|
||||
// role is chosen once at the call site and never switched at runtime — an
|
||||
// enum would buy nothing and cost a qualified name at every use.
|
||||
|
||||
// The name of a panel or a form section: `IMAGE`, `ADJUST`, `SERVER`.
|
||||
//
|
||||
// Caps-with-tracking rather than a larger size: these sit directly above the
|
||||
// content they name, in a column only 280px wide, and a heading that grew the
|
||||
// row would push the photograph over for the sake of a label. Tracking does
|
||||
// the same separating work in the same height.
|
||||
//
|
||||
// `ink-faint` rather than the accent these all carried. A heading is a label,
|
||||
// not a state — it is true whatever the panel is doing, so it has no business
|
||||
// competing with the slider that *is* doing something. It is also read once
|
||||
// and then skipped, which is what the faintest ink is for.
|
||||
export component PanelHeading inherits Text {
|
||||
/// A heading *inside* a panel that already has one — an operation group
|
||||
/// under `ADJUST`. Tighter tracking, so the two levels are distinguishable
|
||||
/// where they stack without either needing a second colour or size.
|
||||
in property <bool> sub: false;
|
||||
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: root.sub ? 0.8px : 1.2px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// The name of a thing whose value sits beside it: a parameter name, a form
|
||||
// field's caption. Dimmer than its value on purpose — the label is constant
|
||||
// and the value is what changed.
|
||||
export component Label inherits Text {
|
||||
/// Lit, for a label under the pointer or one whose value has moved off its
|
||||
/// default. The caller supplies the condition; this only decides what
|
||||
/// "lit" looks like.
|
||||
in property <bool> emphasised: false;
|
||||
/// Body size rather than the chrome's `text-sm`. For a label the user is
|
||||
/// reading rather than scanning past — a row in a picker, a tick-box in a
|
||||
/// form — where the panel is a page rather than an instrument.
|
||||
in property <bool> body: false;
|
||||
|
||||
color: root.emphasised ? Theme.ink : Theme.ink-dim;
|
||||
font-size: root.body ? Theme.text : Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// A datum: a camera name, a file path, a slider's readout.
|
||||
//
|
||||
// **`modified` is the reason this is a component.** A value differing from its
|
||||
// default is the single thing a photographer scans a panel for, and with hue
|
||||
// gone from the palette the only signal left is luminance — so the gap has to
|
||||
// be large and it has to be identical everywhere, or it stops reading as a
|
||||
// signal at all and becomes texture. One definition, one gap.
|
||||
export component Value inherits Text {
|
||||
/// Differs from its default.
|
||||
in property <bool> modified: false;
|
||||
/// No value yet — a placeholder standing in for one, not a value that
|
||||
/// happens to be empty.
|
||||
in property <bool> placeholder: false;
|
||||
/// The compact readout that sits on a control's own row, rather than a
|
||||
/// datum on a line of its own.
|
||||
in property <bool> compact: false;
|
||||
|
||||
color: root.modified ? Theme.modified
|
||||
: (root.placeholder ? Theme.ink-faint : Theme.ink);
|
||||
font-size: root.compact ? Theme.text-sm : Theme.text;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// Supporting text: a hint under a field, a count beside a title, an empty
|
||||
// state's second line. The faintest ink, because it is there for the reader
|
||||
// who stopped to look and should not catch the eye of the one who did not.
|
||||
export component Caption inherits Text {
|
||||
/// A caution. The one place hue survives in the chrome — a warning is a
|
||||
/// different kind of thing from an active state, and saying so instantly
|
||||
/// is worth the exception (see the theme preamble).
|
||||
in property <bool> warn: false;
|
||||
/// Lit, for supporting text the pointer is currently over. Mirrors
|
||||
/// `Label.emphasised` from one step further down, so the two roles brighten
|
||||
/// to the same ink and a hover reads identically wherever it lands.
|
||||
in property <bool> emphasised: false;
|
||||
|
||||
color: root.warn ? Theme.warn-ink
|
||||
: (root.emphasised ? Theme.ink : Theme.ink-faint);
|
||||
font-size: Theme.text-sm;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
|
||||
// A region of surface holding a **column** of controls.
|
||||
//
|
||||
// Two shapes, because the call sites are two shapes. An inset box on the
|
||||
// launch screen is bordered and rounded — it sits on the ground with air
|
||||
// around it and needs its own edge. A panel in the develop column is `flat`:
|
||||
// it abuts its neighbours, so the divider between them belongs to the column
|
||||
// that stacks them, and a border here would double up with it.
|
||||
//
|
||||
// **Not every bordered box is a Panel.** The folder picker's list is the same
|
||||
// surface and rule but overlays three mutually exclusive states — loading, the
|
||||
// list, "nothing here" — each filling the box. This stacks its children, so it
|
||||
// would lay those three out in a row; that site draws its own Rectangle and
|
||||
// says why. A component that covered both would need a bool selecting between
|
||||
// a layout and an overlay, which is two components wearing one name.
|
||||
//
|
||||
// `@children` goes in a plain VerticalLayout for the same reason `Section`'s
|
||||
// body does: Slint rejects `@children` inside anything conditional, so the
|
||||
// two shapes differ only in properties, never in structure.
|
||||
export component Panel inherits Rectangle {
|
||||
/// Abuts its neighbours: no border, no radius. The stacking parent draws
|
||||
/// the dividing rule.
|
||||
in property <bool> flat: false;
|
||||
in property <length> spacing: Theme.gap-sm;
|
||||
/// Named `inset` rather than `padding`: a Rectangle already reserves
|
||||
/// `padding` for the layout it may contain, and redeclaring it is a
|
||||
/// compile error rather than an override.
|
||||
in property <length> inset: Theme.gap;
|
||||
|
||||
background: Theme.surface;
|
||||
border-radius: root.flat ? 0px : Theme.radius;
|
||||
border-width: root.flat ? 0px : 1px;
|
||||
border-color: Theme.rule;
|
||||
|
||||
VerticalLayout {
|
||||
padding: root.inset;
|
||||
spacing: root.spacing;
|
||||
alignment: start;
|
||||
@children
|
||||
}
|
||||
}
|
||||
|
||||
// A single-line text entry.
|
||||
//
|
||||
// **The placeholder is a sibling Text, not a property.** Slint's `TextInput`
|
||||
// has none of its own, and the alternative — seeding `text` and clearing it on
|
||||
// focus — loses whatever the user typed if focus arrives before a keystroke.
|
||||
// A Text underneath, hidden the moment anything is entered, cannot.
|
||||
//
|
||||
// The focus border is `active`: focus is a live state of the control, the one
|
||||
// place in a form where something is *engaged*, which is precisely what that
|
||||
// token is for.
|
||||
export component Field inherits Rectangle {
|
||||
in-out property <string> text;
|
||||
in property <string> placeholder;
|
||||
/// 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);
|
||||
|
||||
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;
|
||||
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;
|
||||
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 == "";
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user