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>
289 lines
11 KiB
Plaintext
289 lines
11 KiB
Plaintext
// 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";
|
||
}
|
||
}
|