// 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 // photo crop mask repair // eye eye-closed // help export component Icon inherits Rectangle { in property 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 ink; /// Edge length. Icons are square; see the view-box note above. in property 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 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"; } // --- develop tools ----------------------------------------------------- // // One per entry in `ToolRail`'s table (toolrail.slint). They are drawn at // 20px in a rail that is nothing but icons, which is larger than anything // else in this file gets — and they need it. A rail is *scanned*, not // read: the label under each one confirms a guess the silhouette has // already made, and if the silhouette makes no guess the label is doing // all the work and the icon is decoration. // // So they are told apart by outline rather than by interior detail. Three // of the four deliberately break the frame the fourth sits inside. // The whole photograph: a frame with a horizon in it. The default tool, so // it is the most literal drawing here — it means "nothing is armed", and // the way to say that is to show the subject with nothing laid over it. if root.name == "photo": Ink { stroke: root.ink; stroke-width: root.weight; commands: "M 3.4 5.2 L 20.6 5.2 L 20.6 18.8 L 3.4 18.8 Z " + "M 3.4 15.4 L 8.8 10 L 13 14.2 L 15.8 11.4 L 20.6 16.2"; } // Crop marks: two overlapping right angles, the shape every editor uses. // Drawn out to the edge of the view box on purpose — a crop is the one // tool here that acts on the frame rather than within it. if root.name == "crop": Ink { stroke: root.ink; stroke-width: root.weight; commands: "M 7.4 2.6 L 7.4 16.6 L 21.4 16.6 " + "M 2.6 7.4 L 16.6 7.4 L 16.6 21.4"; } // A local adjustment: the half-filled circle that means "this side and not // that one" wherever tone is edited. The fill carries it — an outlined // circle is a shape, a half-filled one is a *selection* — and the two // halves are the same circle, so nothing shifts if the pair is ever drawn // in different colours. if root.name == "mask": Ink { stroke: root.ink; stroke-width: root.weight; commands: "M 12 3.6 A 8.4 8.4 0 1 0 12 20.4 A 8.4 8.4 0 1 0 12 3.6 Z"; } if root.name == "mask": Ink { fill: root.ink; stroke-width: root.weight; commands: "M 12 3.6 A 8.4 8.4 0 0 1 12 20.4 Z"; } // TRACES: FR-DEV-19c // An eye, open: the almond and the pupil. The pupil is filled so that at // 16px the open eye reads as a dot with a lid rather than as two arcs // that could be anything, and it takes the same ink as the rest so the // whole glyph can be drawn in the colour of the mask it shows. if root.name == "eye": Ink { stroke: root.ink; stroke-width: root.weight; commands: "M 2.4 12 C 5.2 6.8 8.4 4.8 12 4.8 C 15.6 4.8 18.8 6.8 21.6 12 " + "C 18.8 17.2 15.6 19.2 12 19.2 C 8.4 19.2 5.2 17.2 2.4 12 Z"; } if root.name == "eye": Ink { fill: root.ink; stroke-width: root.weight; commands: "M 12 8.6 A 3.4 3.4 0 1 0 12 15.4 A 3.4 3.4 0 1 0 12 8.6 Z"; } // An eye, closed: the lower lid alone, with three lashes. The lid is the // same lower curve as the open eye's, so the two glyphs sit at the same // height and toggling between them does not make the row jump. if root.name == "eye-closed": Ink { stroke: root.ink; stroke-width: root.weight; commands: "M 2.4 12 C 5.2 17.2 8.4 19.2 12 19.2 C 15.6 19.2 18.8 17.2 21.6 12 " + "M 12 19.2 L 12 22 M 6.6 18 L 5 20.4 M 17.4 18 L 19 20.4"; } // TRACES: FR-UI-4 // Help: a question mark, drawn rather than typed for the reason at the // top of this file. The dot is a zero-length stroke, which the round cap // turns into a disc the weight of the hook above it. if root.name == "help": Ink { stroke: root.ink; stroke-width: root.weight; commands: "M 8.2 8.6 C 8.2 6.2 9.9 4.6 12.1 4.6 C 14.3 4.6 15.9 6.1 15.9 8.1 " + "C 15.9 10.6 12.1 11.2 12.1 14.4 M 12.1 18.9 L 12.1 19"; } // TRACES: FR-DEV-8 // A repair: two circles, one sampling into the other. That is literally // what a spot is here — a source and a destination, which is why its // handles on the canvas come in pairs — so the icon is the tool's own data // rather than a metaphor borrowed from a plaster or a rubber stamp. if root.name == "repair": Ink { stroke: root.ink; stroke-width: root.weight; commands: "M 4.6 14.8 A 5 5 0 1 0 14.6 14.8 A 5 5 0 1 0 4.6 14.8 Z " + "M 11.8 9 A 4 4 0 1 0 19.8 9 A 4 4 0 1 0 11.8 9 Z"; } }