Files
DarkRoom/ui/dr-ui/ui/icons.slint
T
dtourolleandClaude Opus 5 ef07e6ca3e
Build and test / Desktop (Linux) (push) Failing after 1h14m38s
Build and test / Layer separation (push) Successful in 48s
🐳 Android image / Build and push (push) Successful in 16m30s
Build and test / android-image (push) Successful in 16m31s
Traceability / Requirement traces (push) Successful in 1m47s
Build and test / Android (aarch64) (push) Successful in 1h0m21s
Give the canvas tools a rail of their own, and the column one width
Crop, Local and Repair were chips at the head of the develop column, sharing a
row with the adjustment groups and told apart from them by the shape of their
highlight. Three things followed from that, and only the last is cosmetic: the
column closes, so the way out of a mode went away with the way in — hence the
duplicate "Done Cropping" over the canvas; the chips are generated from the
operation set, so the widest thing in the sidebar was a row nobody had chosen
the contents of; and a mode and a filter are different kinds of state wearing
one control.

They are a fixed 60px rail down the left now, generated from a single table in
toolrail.slint. A tool is one row of it plus a drawing plus a ViewMode variant;
nothing in app.slint is touched to add one. What is left of the strip is the
group filters, so it is GroupStrip.

The column stops measuring itself. Every panel published a content-width and
declared it as min-width, and the column took the largest — which spent the
photograph's pixels on whatever happened to be widest, and moved the image
sideways when switching tools swapped one set of panels for another. It is
panel-width now, one number in style.yaml.

That number is 360 and it is measured, not picked: the contents report a
minimum of 344 in every mode, and they do not compress below it because a Text
that does not elide reports the same minimum as preferred. 320 was tried and
sliced Paste down the middle. The Flickable's viewport is floored at the
layout's minimum rather than its preferred width for the same reason — content
that is never told how much room it has cannot adapt to having less.

Removing the eight content-width declarations repairs three comments an
earlier edit had spliced sentences into. The raw histogram's note on keeping
its hint short is rewritten rather than dropped: an over-long hint no longer
widens the column, it pushes the column's minimum past the width it has and
clips the panel, which makes that constraint sharper rather than obsolete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:59:50 +02:00

350 lines
14 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
// photo crop mask repair
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";
}
// --- 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-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";
}
}