Files
DarkRoom/ui/dr-ui/ui/toolrail.slint
T
dtourolle 6b1aac477d Put the developer docs under docs/dev and index the folder for users first
docs/ had 26 developer documents flat beside the manual, and the two
audiences are very differently sized: most readers want the manual and
the gesture reference, a few want the register, the designs and the
measurements. The manual and gestures.md stay at the top; everything for
someone changing the code moves to docs/dev/, and the two documents that
name their own successors — the v0.1 milestone and the UI-refinement plan
— go to docs/dev/archive/ rather than being deleted, since both are still
cited. docs/README.md is the index, users first.

Every reference follows: code comments, Cargo manifests, the workflows,
the pre-commit hook, the bench and traceability tools (which locate the
repo root by docs/dev/requirements.md now), packaging, the Docker READMEs,
CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level
deeper and is regenerated. Links out of the moved documents into the tree
gain a level; a link checker over every Markdown file finds none broken.
2026-09-20 16:20:15 +02:00

419 lines
20 KiB
Plaintext

// The develop view's tool rail: which tool the photographer is holding, and —
// where the interface is driven by a finger — which group of adjustments they
// are looking at.
//
// **Two sections, two sources, one rule between them.**
//
// The tools are the array literal in `tools` below. A tool is a row in it — an
// icon name, a word, and the `ViewMode` it arms — and adding one is that row
// plus a drawing in `icons.slint` plus a variant on the enum. Nothing in
// `app.slint` is touched and there is no second list that could fall out of
// step with it.
//
// The groups are not written here at all, and must not be: they are whatever
// the operation set declares itself to be about, resolved in Rust and handed
// over as `tabs` (FR-DEV-3a). This file names no group, exactly as
// `GroupStrip` names none — it takes the same model, because the two controls
// answer the same question and only one of them is on screen at a time.
//
// **Why a rail is the right shape for a finger and the wrong one for a mouse.**
// See `groups-in-rail` below, and D-N6 in `docs/dev/ui-navigation.md` for the
// decision it reverses and the half of that decision that still stands.
//
// **Why it left the chip strip.** These four used to be chips at the top of
// the develop column, sharing a row with the adjustment groups — two kinds of
// state in one strip, told apart by the shape of their highlight. Three things
// were wrong with that, and only the third is about tidiness:
//
// 1. The column can be put away. The strip went with it, so the way *out* of
// crop went away with the way in, and the fix was a second "Done
// Cropping" button floating over the canvas — one control duplicated
// because the first one was reachable only sometimes.
// 2. The strip sized the column. Its chips are generated from the operation
// set, so the widest thing in the develop sidebar was a row nobody had
// chosen the contents of, and a richer operation set silently took width
// from the photograph.
// 3. A mode and a filter are not the same kind of thing. "Crop" changes what
// a click on the photograph does; "Light" changes which sliders are on
// screen. Putting them in one row and distinguishing them by underline
// versus fill asks the eye to carry a distinction the layout could just
// make.
//
// The rail is always up, so (1) is gone; it is a fixed width that no operation
// set can influence, so (2) is gone; and it is somewhere else entirely, so (3)
// is gone. What is left in the column is a row of group filters and nothing
// else — see `GroupStrip` in `adjust.slint`.
//
// **Down the left, not the right.** The develop column is on the right and
// holds the *consequences* of a choice — the sliders the tool exposes. The
// choice itself goes on the far side, so the eye's path across the window is
// tool, photograph, adjustment, in that order, and the rail never moves when
// the column opens and closes beside it.
import { Theme } from "theme.slint";
import { Icon } from "icons.slint";
import { ViewMode } from "session.slint";
// One tool. A struct rather than four parallel arrays so a row cannot be
// half-added — the compiler will not let a new entry omit its icon.
struct Tool {
/// A name from `icons.slint`'s vocabulary. A typo here draws nothing,
/// which is loud: the entry becomes a label with a hole above it.
icon: string,
/// What it is called. One word — see `rail-width` in `style.yaml`.
label: string,
/// What arming it puts the canvas into.
mode: ViewMode,
}
export component ToolRail inherits Rectangle {
/// Which tool is held. Owned by Rust, like every other piece of session
/// state: the rail asks for a mode and is told what the mode became, so a
/// change made anywhere else — the keyboard, the back gesture, the button
/// over the canvas — lights the same entry.
in property <ViewMode> mode: ViewMode.photo;
/// Whether there is a photograph to point a tool at. The rail collapses
/// rather than greying out: an empty develop view has no tools, and four
/// dead icons beside a blank canvas suggest otherwise.
in property <bool> enabled: true;
/// The adjustment groups, already resolved — the same model `GroupStrip`
/// takes, because it is the same list answering the same question.
///
/// Empty unless this rail is carrying them. Nothing here names a group:
/// the strings arrive from whatever the operations declared themselves to
/// be about (FR-DEV-3a).
in property <[string]> tabs;
/// Index into `tabs`, or -1 for "everything".
in property <int> active-tab: -1;
/// TRACES: FR-UI-1 | FR-UI-7
/// Whether the groups live in this rail or in the strip above the develop
/// column.
///
/// **The one property that makes this rail two different controls**, and
/// it is set from how the interface is being *driven* rather than from
/// which binary is running — see `input_class` in `lib.rs`.
///
/// With a mouse, a horizontal run of words above the column is a tab bar,
/// which is what a pointer is good at: it is one gesture to a target the
/// eye has already found, and the strip costs a row of a column that has
/// plenty of height. With a finger it is the wrong control twice over. The
/// strip pans when the operation set is rich, so a group can be off the
/// end of a row with nothing saying so; and it sits at the top of a
/// column, which on a tablet held in two hands is the furthest point from
/// either thumb.
///
/// Down the rail the same list is a column of finger-sized targets, all of
/// them visible at once, on the edge of the screen a hand is already at.
in property <bool> groups-in-rail: false;
callback picked(ViewMode);
/// A group was chosen: an index into `tabs`, or -1 for "everything".
///
/// Deliberately the same signature `GroupStrip` emits, and routed to the
/// same callback in `app.slint`. The two controls are alternatives, not
/// peers — only one is on screen at a time — and giving them one contract
/// means Rust cannot tell which of them the user pressed, and has no
/// reason to want to.
callback group-picked(int);
/// The groups this rail actually draws.
///
/// A conditional model rather than an `if` wrapped around the repeater:
/// Slint has no way to nest one inside the other, and putting the
/// condition on the model keeps the entries as direct children of the
/// layout below — which is the shape that matters here. A nested layout
/// under-reports its height and its siblings get drawn on top of each
/// other; `app.slint`'s develop column carries the same note.
private property <[string]> rail-tabs: root.groups-in-rail ? root.tabs : [];
// **The table.** Add a row to get a tool.
//
// Order is the order they appear, and it is not arbitrary: `photo` first
// because it is the resting state and the way back from everywhere else,
// then the three that arm a gesture on the canvas, roughly in the order a
// photograph is worked — frame it, then adjust parts of it, then clean it
// up.
//
// `MaskSource::Brush` is the next one to land here. The core already has
// the stroke calls; what is missing is the canvas interaction, and when it
// arrives this file's share of the work is one line.
private property <[Tool]> tools: [
{ icon: "photo", label: "Photo", mode: ViewMode.photo },
// "Compose", not "Crop", and the panel it opens says the same. The
// tool arms a crop gesture, but what it is *for* is deciding the
// frame — straightening, the quarter turns and the flips are in that
// panel too, and perspective will be. Naming the tool after one of its
// controls was what made cropping look like a category of its own.
//
// `ViewMode.crop` keeps its name: it identifies a canvas interaction,
// which is exactly what it still is.
{ icon: "crop", label: "Compose", mode: ViewMode.crop },
{ icon: "mask", label: "Local", mode: ViewMode.local },
{ icon: "repair", label: "Repair", mode: ViewMode.spots },
];
// TRACES: FR-UI-1
// Fixed, and the point of the exercise. This rail flanks the photograph,
// so its width is taken out of the picture — and a rail measured from its
// contents would hand that decision to whichever tool label happens to be
// longest. `style.yaml` carries the number and the reasoning.
width: root.enabled ? Theme.rail-width : 0px;
visible: root.enabled;
background: Theme.surface;
clip: true;
// **A Flickable now, and this comment used to argue the opposite.** It
// said the rail held four entries written in this file, on an axis with
// room for fourteen, and that a rail which scrolls is the answer only if
// that stops being true. It has stopped being true: with `groups-in-rail`
// the list is four tools plus one entry per group the operation set
// declares, and an operation set is exactly the "something the user's data
// decides" the old note excluded this control from.
//
// Four tools, "All" and today's five groups is ten entries — comfortable
// on any supported screen. The point is not today's count but that the
// count is no longer written here, and a rail that overflows loses its
// last entries silently, on the one control the develop view is navigated
// by.
// **How tall the entries actually are, counted rather than measured.**
//
// This used to read `max(self.height, layout.preferred-height)`, with the
// layout below taking its height from the viewport in turn. That is a
// cycle — the viewport asks the layout how tall it wants to be, and the
// layout has already been told — and Slint settles it by handing back the
// height it was given. The viewport then never exceeded the visible area,
// so there was nothing to scroll and everything past the fold was clipped
// in silence: at a 680px window the rail stopped after "All" and the five
// groups below it could not be reached by any means.
//
// Counting works because every entry in this rail is a fixed height by
// construction — a tool is `rail-entry-height` and a group is a touch
// target, both stated a few lines below — so this is exact rather than an
// estimate, and it depends on nothing that depends on it.
private property <length> content-height:
Theme.gap-sm
+ root.tools.length * Theme.rail-entry-height
+ (root.groups-in-rail
? Theme.gap + Theme.touch-target * (1 + root.rail-tabs.length)
: 0px);
Flickable {
width: 100%;
height: 100%;
viewport-width: self.width;
viewport-height: max(self.height, root.content-height);
layout := VerticalLayout {
height: parent.viewport-height;
padding-top: Theme.gap-sm;
spacing: 0px;
alignment: start;
for tool in root.tools: entry := TouchArea {
height: Theme.rail-entry-height;
mouse-cursor: pointer;
property <bool> on: root.mode == tool.mode;
// Pressing the tool you are holding puts it down, exactly as the
// chips did: the same control both directions. `photo` is the
// exception — it *is* putting the tool down, so pressing it while
// it is lit is a no-op rather than a toggle into itself.
clicked => {
root.picked(entry.on ? ViewMode.photo : tool.mode);
}
// **The word below is drawn; this is what makes it a control.**
// Without these the rail reaches a screen reader as four pieces of
// static text — the labels get through, because a `Text` announces
// itself, and nothing says any of them can be pressed. That is the
// develop view's primary navigation reduced to a caption.
//
// `checkable` unconditionally, unlike `Button`'s: a rail entry is
// always a held-or-not state, so an unheld one should say "not
// pressed" rather than pass for an ordinary button. The action
// repeats the click handler rather than calling it, because a
// `TouchArea`'s `clicked` is raised by the pointer and cannot be
// raised from here.
accessible-role: button;
accessible-label: tool.label;
accessible-checkable: true;
accessible-checked: entry.on;
accessible-action-default => {
root.picked(entry.on ? ViewMode.photo : tool.mode);
}
// The lit tile, and the only marker there is. Inset from the
// rail's edges so the run of four reads as four things rather than
// as one striped column.
//
// A bright bar against the outer edge was tried alongside it, on
// the theory that a rail is scanned from the side and needs
// something unambiguous. It is not needed and it is not
// unambiguous: `active-dim` is near-white and `hover` is a shade
// above the surface, so the held tool and a tool under the pointer
// are not two similar greys — they are opposite ends of the
// palette. The bar sat against the lit tile and merged with it.
Rectangle {
x: Theme.gap-sm / 2;
width: parent.width - Theme.gap-sm;
height: parent.height - 2px;
y: 1px;
border-radius: Theme.radius;
background: entry.on
? Theme.active-dim
: (entry.has-hover ? Theme.hover : transparent);
}
VerticalLayout {
alignment: center;
spacing: 3px;
HorizontalLayout {
alignment: center;
Icon {
name: tool.icon;
size: 20px;
// Dark on the lit tile, which is near-white: the same
// inversion `Button`'s primary state makes, and the
// same one the chips made before this.
ink: entry.on
? Theme.ground
: (entry.has-hover ? Theme.ink : Theme.ink-dim);
}
}
// Named, not just drawn. An icon-only rail is a quiz — these
// four are conventional enough to guess and not conventional
// enough to be sure of, and "sure" is what a tool that changes
// what a click does has to be. The word is `text-sm` and dim,
// so it reads as the icon's caption rather than as a button in
// its own right.
Text {
text: tool.label;
font-size: Theme.text-sm;
color: entry.on
? Theme.ground
: (entry.has-hover ? Theme.ink : Theme.ink-faint);
horizontal-alignment: center;
// Elided rather than wrapped: a two-line label would make
// this entry taller than the three beside it, and a rail
// whose rows are different heights reads as a list of
// unrelated things.
overflow: elide;
}
}
}
// **The seam between the two kinds of entry.**
//
// A rule and a gap, because above it are things that change what a
// click on the photograph *does* and below it are things that change
// which sliders are on screen. `ui-navigation.md` §N1 made that
// distinction by drawing a mode and a group differently in one strip;
// it holds here by separating them, which is the cheaper signal when
// the axis is vertical and there is a whole rail's width to draw a
// line across.
if root.groups-in-rail: Rectangle {
height: Theme.gap;
background: transparent;
Rectangle {
x: Theme.gap-sm;
width: parent.width - 2 * Theme.gap-sm;
height: 1px;
y: (parent.height - 1px) / 2;
background: Theme.rule;
}
}
// "All", and it is not decoration. A group filter that cannot be
// cleared is a way to make controls unreachable, and this is the only
// entry in the run below that is not generated.
if root.groups-in-rail: all := TouchArea {
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { root.group-picked(-1); }
accessible-role: button;
accessible-label: "All adjustments";
accessible-checkable: true;
accessible-checked: root.active-tab == -1;
accessible-action-default => { root.group-picked(-1); }
Rectangle {
x: 0;
width: 2px;
height: parent.height;
background: root.active-tab == -1 ? Theme.ink : transparent;
}
Text {
text: "All";
font-size: Theme.text-sm;
color: root.active-tab == -1
? Theme.ink
: (all.has-hover ? Theme.ink : Theme.ink-faint);
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
}
// **A bar down the leading edge, not a filled tile.**
//
// The tools above fill with `active-dim` and invert their ink; these
// do not, and the difference is the one §N1 insisted on — a mode and a
// filter are not the same kind of state, and a reader should not have
// to remember which section a lit entry was in to know which they are
// looking at. An underline is what said so when the groups were a
// horizontal strip; turned ninety degrees, that is a bar down the
// edge.
for tab[i] in root.rail-tabs: group := TouchArea {
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { root.group-picked(i); }
accessible-role: button;
accessible-label: tab;
accessible-checkable: true;
accessible-checked: root.active-tab == i;
accessible-action-default => { root.group-picked(i); }
Rectangle {
x: 0;
width: 2px;
height: parent.height;
background: root.active-tab == i ? Theme.ink : transparent;
}
Text {
text: tab;
font-size: Theme.text-sm;
color: root.active-tab == i
? Theme.ink
: (group.has-hover ? Theme.ink : Theme.ink-faint);
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
// A group's name comes from the operation set and this rail is
// a mandated width, so a long one has to give somewhere.
overflow: elide;
}
}
}
}
// The rail's own edge. Drawn here rather than by whatever contains it, so
// the rail is a complete thing wherever it is put — and on the right,
// where it meets the canvas.
Rectangle {
x: parent.width - 1px;
width: 1px;
background: Theme.rule;
}
}