Files
DarkRoom/ui/dr-ui/ui/icons.slint
T
dtourolleandClaude Opus 5 d913e50948 Select photographs with a finger, and take a collection with you
Two things a tablet could not do. Both existed for a pointer and had no
touch form at all, which on Android meant the collection sidebar was
somewhere to look at rather than somewhere to file into.

**Selecting more than one.** Ctrl-click and shift-click are the only ways
into a multi-selection, and touch has neither. Holding a cell now enters
selection mode, where a tap toggles — reported to Rust as a ctrl-press, so
it goes through the same `apply_press` as everything else rather than
growing a second copy of the selection rules. A double tap takes the run
between where selecting began and there: the touch form of shift-click,
and the reason the anchor from *before* the double tap has to be
remembered, since both of its taps move the anchor onto the cell being
tapped. A "Select" button does the same thing where a gesture would go
undiscovered (FR-UI-4).

**Filing without a drag.** A one-finger drag beginning in the grid belongs
to the Flickable that scrolls it — that is the arbitration working, not a
bug to route around — so the selection can now be filed from a sheet
listing the sidebar's own rows. Copy by default, as the drag has always
been; moving out of the collection being shown is a switch, because it is
the one that takes something away.

**Taking a collection offline.** The machinery was there and reachable only
by scoping the grid to a collection and finding a button behind a
disclosure. Holding a collection's name now asks the question directly, and
the tray on a row and the header button ask the same one — three
affordances doing two different things is how a user comes to avoid all
three. The question is asked rather than a toggle flipped because both
answers are expensive: one downloads gigabytes, the other deletes them, and
the counts and sizes go in the buttons where they are read before the tap.

`Cache::release` is new and is the destructive half `unpin` deliberately is
not. "Remove the local copies" is asked by someone whose device is full,
and withdrawing a promise while leaving the bytes for a future eviction to
notice is not an answer to it. It unpins before forgetting, or the next pin
fetch would dutifully download everything it just deleted.

The sidebar's trays read `tier_actual`, never `tier_desired`: the question
is whether these will open on the aeroplane, and a pin whose download has
not run yet answers no.

TRACES: FR-CAT-7 | FR-NC-6a | FR-NC-6b | FR-NC-6c | FR-UI-2 | FR-UI-3 | FR-UI-4

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:27:29 +02:00

289 lines
11 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The icon set, drawn rather than typed.
//
// **Why this file exists.** Every glyph in this UI used to be a character in a
// Text: `⟲` for rotate-left, `☰` for the sidebar, `★` for a rating, `✓` for a
// tick. That works on a desktop, where the font stack ends in something with
// full symbol coverage, and it fails on Android, where it does not: the system
// font is Roboto, fallback there is resolved per *script* rather than per
// character, and none of the fallback families claimed for the Common script
// carry the Dingbats, Arrows or Miscellaneous Symbols blocks these characters
// live in. The result is that the toolbar renders as a row of tofu — a box
// with an X — and the controls become unusable.
//
// No amount of picking "safer" characters fixes that class of bug, because the
// question "is this codepoint in the font the platform happens to pick" has a
// different answer on every device. So the icons stop being text. Each one is
// an SVG path drawn into a 24×24 view box and rendered by `Path`, which goes
// through the same renderer as every other shape on screen and cannot fall
// back to anything.
//
// **The 24×24 view box is the contract.** `Path` with an explicit view box
// fits *the box* to the element, not the ink inside it, so two icons drawn to
// the same box occupy the same optical size even when their drawings differ in
// extent — a star that reaches the edges and a chevron that does not still
// look like siblings. Icons are therefore always drawn square; a caller that
// wants a different aspect should lay out around the icon, not stretch it.
//
// **Stroke width is in screen pixels, not view-box units.** Slint applies the
// stroke after fitting, so a fixed `stroke-width` would thicken as the icon
// shrank. `weight` scales it with `size` instead, which keeps a 10px tick and
// a 16px sidebar bar at the same apparent weight. It is also why the filled
// paths carry a stroke-width with a transparent stroke: the fit shrinks the
// drawing by the stroke width, so a filled star with no stroke width would
// come out fractionally *larger* than the outlined one beside it, and the two
// would jump as a rating changed.
// One drawing. Everything an icon path shares lives here so the table below is
// nothing but geometry; `fill`, `stroke` and `stroke-width` come from the call
// site because they depend on the enclosing `Icon`'s properties.
component Ink inherits Path {
viewbox-width: 24;
viewbox-height: 24;
width: 100%;
height: 100%;
// Round joins throughout: at 12px a mitred corner on a 1.4px stroke is a
// single dark pixel that reads as dirt on the screen.
stroke-line-cap: round;
stroke-line-join: round;
}
// A single icon, named.
//
// Names are strings rather than an enum because Slint enums cannot be extended
// from another file and the set is consumed across six screens; a typo shows
// up as a blank box, which is loud enough in review. The vocabulary is:
//
// check cross menu trash plus
// star star-outline
// chevron-right chevron-down chevron-left arrow-up
// rotate-ccw rotate-cw flip-h flip-v
// collection collection-smart
// offline offline-held
export component Icon inherits Rectangle {
in property <string> name;
/// The single colour the whole drawing takes. Named `ink` rather than
/// `color` because Rectangle already has a legacy `color` alias for its
/// background and Slint refuses to shadow it. Icons are monochrome by
/// design — they sit in chrome that is already monochrome, and a two-tone
/// icon would be the only thing on screen carrying colour for decoration.
in property <brush> ink;
/// Edge length. Icons are square; see the view-box note above.
in property <length> size: 16px;
/// Stroke thickness, derived rather than set. The floor keeps the hairline
/// from disappearing entirely on a low-DPI desktop at the smallest sizes.
out property <length> weight: max(1.2px, root.size / 11);
width: root.size;
height: root.size;
horizontal-stretch: 0;
vertical-stretch: 0;
// --- marks -----------------------------------------------------------
if root.name == "check": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 4.2 12.6 L 9.6 18 L 19.8 6.4";
}
if root.name == "cross": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 6.2 6.2 L 17.8 17.8 M 17.8 6.2 L 6.2 17.8";
}
if root.name == "plus": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 12 4.5 L 12 19.5 M 4.5 12 L 19.5 12";
}
// --- rating ----------------------------------------------------------
//
// Solid and outline are the same ten points, so a filled star and an empty
// one sit at exactly the same size and position and the strip does not
// shimmer as a rating is dragged across it.
if root.name == "star": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 12 3.1 L 14.32 9.21 L 20.85 9.53 L 15.76 13.63 "
+ "L 17.46 19.94 L 12 16.35 L 6.54 19.94 L 8.24 13.63 "
+ "L 3.15 9.53 L 9.68 9.21 Z";
}
if root.name == "star-outline": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 12 3.1 L 14.32 9.21 L 20.85 9.53 L 15.76 13.63 "
+ "L 17.46 19.94 L 12 16.35 L 6.54 19.94 L 8.24 13.63 "
+ "L 3.15 9.53 L 9.68 9.21 Z";
}
// --- chrome ----------------------------------------------------------
if root.name == "menu": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 3.5 6.5 L 20.5 6.5 M 3.5 12 L 20.5 12 "
+ "M 3.5 17.5 L 20.5 17.5";
}
if root.name == "trash": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 9.2 6.2 L 9.2 3.8 L 14.8 3.8 L 14.8 6.2 "
+ "M 3.4 6.2 L 20.6 6.2 "
+ "M 5.6 6.2 L 6.7 20.2 L 17.3 20.2 L 18.4 6.2 "
+ "M 10.2 9.8 L 10.6 16.8 M 13.8 9.8 L 13.4 16.8";
}
// Disclosure arrows are solid triangles rather than open chevrons: they
// are the smallest thing drawn in this UI, and at 11px a stroked chevron
// is two anti-aliased diagonals with nothing left of the shape between.
if root.name == "chevron-right": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 9 5.5 L 16.5 12 L 9 18.5 Z";
}
if root.name == "chevron-left": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 15 5.5 L 7.5 12 L 15 18.5 Z";
}
if root.name == "chevron-down": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 5.5 9 L 18.5 9 L 12 16.5 Z";
}
if root.name == "arrow-up": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 12 20 L 12 5 M 5 12 L 12 5 L 19 12";
}
// --- framing ---------------------------------------------------------
//
// The rotation arrows are a 280° arc with a solid head at the end of
// travel. The gap sits at the top, where the eye looks first, so the two
// are told apart by which side the head is on rather than by following a
// curve — at 16px an arrowhead small enough to sit flush with a thin arc
// is a smudge, and both directions look like a plain circle.
if root.name == "rotate-ccw": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 10.82 6.5 A 6.8 6.8 0 1 0 18.39 10.87";
}
if root.name == "rotate-ccw": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 13.1 6.49 L 21.12 9.88 L 15.66 11.87 Z";
}
if root.name == "rotate-cw": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 13.18 6.5 A 6.8 6.8 0 1 1 5.61 10.87";
}
if root.name == "rotate-cw": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 10.9 6.49 L 2.88 9.88 L 8.34 11.87 Z";
}
// A flip is a mirror about an axis, so the icon is literally that: two
// arrowheads facing away from the line they would be reflected across.
if root.name == "flip-h": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 8.2 5.2 L 1.8 12 L 8.2 18.8 Z "
+ "M 15.8 5.2 L 22.2 12 L 15.8 18.8 Z";
}
if root.name == "flip-h": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 12 2.6 L 12 21.4";
}
if root.name == "flip-v": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 5.2 8.2 L 12 1.8 L 18.8 8.2 Z "
+ "M 5.2 15.8 L 12 22.2 L 18.8 15.8 Z";
}
if root.name == "flip-v": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 2.6 12 L 21.4 12";
}
// --- collections -----------------------------------------------------
//
// A manual collection is a list of rows someone put there; a smart one is
// a rule. The shapes are deliberately unalike rather than one shape with a
// badge, because the pair appears down a narrow sidebar at 12px where a
// badge would be three pixels.
//
// Both are drawn thinner than they want to be for the same reason. At 12px
// the interior of a shape is four or five pixels across, so every internal
// line spends one of them and its two neighbours to anti-aliasing: the
// three-rule box this started as, and the diamond with a fat centre, both
// filled in to a solid blob. One rule and a small centre survive.
if root.name == "collection": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 3.4 5.4 L 20.6 5.4 L 20.6 18.6 L 3.4 18.6 Z "
+ "M 3.4 10.2 L 20.6 10.2";
}
if root.name == "collection-smart": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 12 2.8 L 21.2 12 L 12 21.2 L 2.8 12 Z";
}
if root.name == "collection-smart": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 12 9 L 15 12 L 12 15 L 9 12 Z";
}
// --- availability ------------------------------------------------------
//
// TRACES: FR-NC-6a | FR-NC-6c
// "Kept on this device": an arrow coming down into a tray. The download
// shape rather than a pin, because what the gesture does is *fetch* — a
// pin reads as "stick this to the top of the list", which is what it means
// in every other sidebar the user has seen.
//
// Outline and solid are the same tray with the same arrow, so a collection
// that finishes downloading does not shift under the finger that started
// it. Solid fills the tray only: an arrow filled as well loses the shaft
// at 12px and reads as a blob.
if root.name == "offline": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 12 3.6 L 12 13.8 M 7.4 9.4 L 12 14 L 16.6 9.4 "
+ "M 4.2 15.4 L 4.2 19.8 L 19.8 19.8 L 19.8 15.4";
}
if root.name == "offline-held": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 12 3.6 L 12 13.8 M 7.4 9.4 L 12 14 L 16.6 9.4";
}
if root.name == "offline-held": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 4.2 15.4 L 19.8 15.4 L 19.8 19.8 L 4.2 19.8 Z";
}
}