Give the canvas tools a rail of their own, and the column one width
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

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>
This commit is contained in:
2026-08-30 09:59:50 +02:00
co-authored by Claude Opus 5
parent 9e519eb8a6
commit ef07e6ca3e
15 changed files with 563 additions and 312 deletions
+8 -2
View File
@@ -399,10 +399,16 @@ what stops that from ever being possible.
### 10.1 The mode
A third chip in `ModeStrip`, beside Crop and Local, and a `ViewMode::spots`.
The strip's own documentation already predicted this shape for the brush; the
A `ViewMode::spots`, and a row in `ToolRail`'s table beside Crop and Local.
That table's own documentation already predicts this shape for the brush; the
spot tool is the same shape and arrives first.
(It landed as a third chip in `ModeStrip`, at the head of the develop column.
The three canvas tools have since moved out to a fixed rail down the left of
the develop view — `ui/dr-ui/ui/toolrail.slint` carries why — and what remains
of that strip is the adjustment-group filters, now `GroupStrip`. Nothing about
the mode itself changed in the move.)
### 10.2 Gestures
| Gesture | Effect |
+42 -42
View File
File diff suppressed because one or more lines are too long
+15 -9
View File
@@ -93,20 +93,26 @@ const MAX_DISPLAY_DIM: u32 = 2048;
/// compact layout exactly as a tablet in portrait would.
const EXPANDED_MIN_WIDTH: f32 = 820.0;
/// TRACES: FR-UI-2
/// TRACES: FR-UI-1
/// The largest share of the window the develop column may take.
///
/// The column sizes itself to the widest thing it holds — the generated mode
/// strip, the Copy/Paste pair, the histogram's axis labels — so on a rich
/// operation set it would otherwise keep growing. This is where that stops.
/// The majority of the window stays with the photograph, which is what the
/// column is there to serve.
/// A policy, not a size. The column is a fixed `panel-width` (`style.yaml`),
/// so on any window with room for it this does nothing at all — it bites only
/// where 360px would be most of the screen, and what it says there is that the
/// majority of the window stays with the photograph the column exists to
/// serve.
///
/// It used to do more, because the column used to size itself to the widest
/// thing it held and could therefore grow without limit on a rich operation
/// set. It cannot any more; this is the remaining half of that guard, kept for
/// the case the fixed width does not cover.
const PANEL_MAX_FRACTION: f32 = 0.45;
/// The floor under that share, so a narrow window still gets a usable column
/// rather than one squeezed below the width its own controls were drawn for.
/// Matches the 280px the column asks for at minimum: below this the cap would
/// be doing the clipping the cap exists to avoid.
/// Under `panel-width` by design: between the two the column narrows from 360
/// to 280 on a small window and stops, which is the range the sliders inside
/// it stay accurate over.
const PANEL_MIN_WIDTH: f32 = 280.0;
/// How long after the last change a draft frame is replaced by a sharp one.
@@ -3231,7 +3237,7 @@ fn apply_layout_class(window: &AppWindow, width: f32, panels: &PanelChoices) {
window.set_expanded(expanded);
window.set_layout_class(if expanded { "expanded" } else { "compact" }.into());
// FR-UI-2: the ceiling on the develop column, computed here for the same
// FR-UI-1: the ceiling on the develop column, computed here for the same
// reason the class is — a width that both derives from and feeds the
// layout is a binding loop in Slint.
window.set_panel_max_width((width * PANEL_MAX_FRACTION).max(PANEL_MIN_WIDTH));
+49
View File
@@ -187,6 +187,55 @@ lengths:
Floor on button width, so a one-word label is still a comfortable
target and a row of buttons has an even rhythm.
_develop:
section: the develop view's two fixed columns
note: |
Both are *mandated* sizes rather than measured ones, which is the
opposite of how the rest of this interface is laid out, and the reason
is that these two flank the photograph. Everything else may grow to fit
its contents; a column beside the image cannot, because the pixels it
takes come out of the picture and a control gaining a word is not a
reason to give the photograph less room.
What that costs is that a panel wider than the number below clips, and
the Flickables inside it are what make the overflow reachable. That is
the trade being made deliberately: a control you may have to scroll to
is worse than one you can see, and a photograph that changes size when
you switch tools is worse than both.
rail-width:
value: 60
doc: |
The tool rail down the left of the develop view. Wide enough for a 20px
icon over a `text-sm` label at the longest name in the table, and narrow
enough to stay a rail rather than becoming a third panel.
rail-entry-height:
value: 54
doc: |
One tool in that rail. Over `touch-target`, because unlike the controls
inside a panel these are not crowding anything — the rail holds four
entries on a screen with room for a dozen.
panel-width:
value: 360
doc: |
The develop column on the other side. One number for tablet and desktop
alike, replacing the 280 the first was drawn for and the 380 the second
was — the alternative is a column that changes width with the window,
which is a photograph that changes size when you resize by a pixel.
**Measured, not chosen.** The column's contents report a minimum width of
344 in every mode — photo, local and repair alike — and below that they
do not compress, they clip: a `Text` that does not elide reports the same
minimum as preferred, and most of this column is text. 320 was tried
first and sliced "Paste" down the middle. So this is that floor plus
enough not to be sitting on it.
To re-measure after changing a panel, bind a `Text` in `app.slint` to
`column.min-width` and read it off the running app; there is no way to
get the number out of the layout engine short of asking it.
swatch:
value: 12
note: |
+42 -132
View File
@@ -541,21 +541,6 @@ export component GeometryPanel inherits Rectangle {
callback portrait-toggled();
callback reset();
/// TRACES: FR-UI-2
/// How wide this panel has to be before it starts clipping itself.
///
/// Every panel in the develop column declares one, and the column takes
/// the largest — that is the whole of how the column is sized. It replaced
/// two guessed constants (280px for a tablet, 380px for a desktop) that
/// could not track a panel gaining a control, and did not.
///
/// Published as `min-width` as well as read by name: the first is what
/// makes the enclosing layout aggregate these automatically, including for
/// the panels that come and go with the mode and so cannot be referenced
/// from outside their `if`.
out property <length> content-width: layout.preferred-width;
min-width: root.content-width;
height: layout.preferred-height;
layout := VerticalLayout {
@@ -738,15 +723,6 @@ export component TransferPanel inherits VerticalLayout {
/// same thought given a name — this edit, kept.
callback open-presets();
/// TRACES: FR-UI-2
/// How wide this panel has to be before it starts clipping itself — the
/// same question every panel in the develop column answers.
///
/// No `min-width` beside it, unlike the others: this component *is* a
/// layout, so it already reports its own minimum to whatever contains it,
/// and a `min-width` bound to its own preferred size is a binding loop.
out property <length> content-width: self.preferred-width;
padding: Theme.gap;
spacing: Theme.gap-sm;
@@ -817,24 +793,22 @@ export enum ViewMode {
spots,
}
/// The strip: what the photographer is working on.
/// The group strip: which kind of adjustment the column is showing.
///
/// Two kinds of entry, deliberately together.
/// One kind of entry, which is the change. This was `ModeStrip` and carried
/// two: the canvas tools — crop, local, repair — as filled chips, and the
/// adjustment groups as underlined words, in a single row that asked the eye
/// to tell a mode from a filter by the shape of its highlight. The tools are
/// in `ToolRail` now (`toolrail.slint`, which carries the reasoning), and what
/// is left here is one row of one thing.
///
/// **Modes** — crop and local — change the canvas as well as the column. They
/// are drawn as chips and lit with the accent, which means *active* everywhere
/// else in this interface and means exactly that here.
///
/// **Groups** — the rest — filter the adjustments to one kind. They are
/// underlined instead, because the accent is already spoken for and because
/// they are a different sort of state: a mode is something you are *in*, a
/// group is something you are *looking at*.
///
/// Keeping them apart visually is what lets them be independent. Picking a
/// group in local mode filters the selected layer's chain and does not leave
/// the mode, so "Light" means the same thing wherever it is pressed — the
/// alternative, where a group press silently dropped the scope, would be the
/// §1.1 fault reintroduced from the other end.
/// A group is something you are *looking at*, not something you are *in*, and
/// that distinction is now made by the two controls being in different places
/// rather than by two treatments of one control. It is also why the two stay
/// independent: picking a group while a tool is held filters the selected
/// layer's chain and does not put the tool down, so "Light" means the same
/// thing wherever it is pressed — the alternative, where a group press
/// silently dropped the scope, would be the §1.1 fault from the other end.
///
/// **Pinned above the scrolling column, not inside a panel.** It began inside
/// `AdjustPanel`, which put it below five other panels and off the bottom of a
@@ -844,29 +818,15 @@ export enum ViewMode {
///
/// **This file names no group.** The strings arrive already resolved from
/// whatever the operations declared themselves to be about, so a new operation
/// joins a group without an edit here (FR-DEV-3a). The two modes are not
/// operations — crop is a gesture on the canvas and local is a scope — so
/// naming them breaks nothing.
export component ModeStrip inherits Rectangle {
/// joins a group without an edit here (FR-DEV-3a). With the tools gone there is
/// no longer any hand-written word in this row at all, save "All".
export component GroupStrip inherits Rectangle {
in property <[string]> tabs;
/// Index into `tabs`, or -1 for "everything".
in property <int> active-tab: -1;
in property <ViewMode> mode: ViewMode.photo;
in property <bool> enabled: true;
callback picked(int);
callback mode-picked(ViewMode);
/// How much width this row actually needs, for the column that pins it.
///
/// Read rather than guessed: the chips are one per mode plus one per group
/// the operation set declares, so the number is generated and no constant
/// in the column could stay in step with it. The Flickable below is still
/// the answer when the column cannot be given that much — it is the floor
/// under a control that must never become unreachable — but it is no longer
/// the *first* answer, which is what made a generated row into a row you
/// had to know to pan.
out property <length> content-width: layout.preferred-width;
background: Theme.surface;
height: root.enabled ? layout.preferred-height : 0px;
@@ -874,9 +834,15 @@ export component ModeStrip inherits Rectangle {
// Scrolls rather than overflowing, exactly as the develop status strip
// does and for the same reason: a `HorizontalLayout` given less width than
// its children need does not shrink them, it runs off the end. Two modes
// plus however many groups the operation set declares is already more than
// a 280px column holds, and the column is where this is pinned.
// its children need does not shrink them, it runs off the end.
//
// This is now the *only* answer, and it is the right one here. The column
// this sits in is a mandated width (`panel-width` in `style.yaml`) while
// the row's contents are one word per group the operation set declares —
// generated, and so unbounded in principle. It used to publish a
// `content-width` that the column sized itself from, which made a rich
// operation set quietly take width from the photograph. A row that pans is
// the price of a photograph that does not move.
Flickable {
width: 100%;
height: 100%;
@@ -890,67 +856,17 @@ export component ModeStrip inherits Rectangle {
spacing: Theme.gap-sm;
alignment: start;
// A chip per mode. Drawn as an outline that fills when active rather
// than as an underline, so at a glance the strip reads as two runs
// and it is never ambiguous which half a lit entry belongs to.
// **Where a canvas tool goes: not here.** A mode that arms a gesture
// on the photograph is a row in `ToolRail`'s table, one file over.
//
// **Where a canvas tool goes.** A mode that arms a gesture on the
// photograph and scopes the column joins this list and the `ViewMode`
// enum, and needs nothing else here. *Repair* is the first to arrive
// that way (FR-DEV-8); painting a mask is the same shape and still to
// come — `MaskSource::Brush` and the stroke calls on `MaskLayer`
// already exist in the core, and what is missing on this side is only
// the canvas interaction the repairs now have a pattern for.
for entry in [
{ label: "Crop", value: ViewMode.crop },
{ label: "Local", value: ViewMode.local },
{ label: "Repair", value: ViewMode.spots },
]: mode-chip := TouchArea {
width: mode-name.preferred-width + 2 * Theme.gap-sm;
height: Theme.touch-target;
mouse-cursor: pointer;
property <bool> on: root.mode == entry.value;
// Pressing the mode you are already in leaves it, which is what
// makes the strip the way out as well as the way in — the same
// control both directions, as the crop button was.
clicked => {
root.mode-picked(self.on ? ViewMode.photo : entry.value);
}
Rectangle {
y: (parent.height - self.height) / 2;
height: Theme.control-height;
width: parent.width;
border-radius: Theme.radius;
border-width: 1px;
border-color: mode-chip.on ? Theme.active : Theme.rule;
background: mode-chip.on
? Theme.active-dim
: (mode-chip.has-hover ? Theme.hover : transparent);
}
mode-name := Text {
text: entry.label;
// Dark on the lit fill, which is near-white: the same
// inversion `Button`'s primary state makes.
color: mode-chip.on ? Theme.ground : Theme.ink-dim;
font-size: Theme.text-sm;
font-weight: 600;
vertical-alignment: center;
horizontal-alignment: center;
}
}
// The divider between the two kinds. One pixel, and it is what stops
// the strip reading as one undifferentiated row of five words.
Rectangle {
width: 1px;
height: Theme.touch-target;
background: Theme.rule;
}
// Three of them used to sit at the head of this row as filled chips,
// with a one-pixel divider after them. Both are gone, and nothing has
// replaced them — no heading, no lead-in. A run of words with one
// underlined is a tab bar, which is exactly what this is, and it needs
// saying only while there is something else in the row to be told
// apart from. The width that buys back is the point: this row pans
// when the operation set is rich, so anything permanently occupying
// its left-hand end is paid for by every group after the third.
all := TouchArea {
width: 34px;
height: Theme.touch-target;
@@ -1069,18 +985,12 @@ export component AdjustPanel inherits Rectangle {
/// scrolls as one, so the panel reports the drag and the column obeys it.
out property <bool> slider-dragging: false;
/// TRACES: FR-UI-2
/// How wide this panel has to be before it starts clipping itself. The
/// develop column is the largest of these and nothing else; publishing it
/// as `min-width` too is what makes the enclosing layout aggregate them
/// without this panel having to be referenced by name.
///
/// The sliders are the part that matters. Each is a label, a value and a
/// track, and a track squeezed below the width its handle needs is a
/// control that cannot be set accurately — which is the whole job.
out property <length> content-width: layout.preferred-width;
min-width: root.content-width;
// **The sliders are what `panel-width` was chosen against.** No panel gets
// a say in the column's width any more — it is one number in `style.yaml`
// — but this is the panel that number has to be right for. Each row is a
// label, a value and a track, and a track squeezed below the width its
// handle needs is a control that cannot be set accurately, which is the
// whole job. If the column is ever narrowed, narrow it against this.
layout := VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
+119 -61
View File
@@ -1,5 +1,5 @@
import { Theme } from "theme.slint";
import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
import { AdjustPanel, GeometryPanel, GroupStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
import { GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
import { SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint";
import { CropOverlay } from "crop.slint";
@@ -16,6 +16,7 @@ import { FocusMarks, FocusPanel } from "peaking.slint";
import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint";
import { StatusBar, InfoPanel } from "develop.slint";
import { ToolRail } from "toolrail.slint";
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip }
export { GestureRow }
@@ -1675,6 +1676,31 @@ in property <bool> panel-visible: true;
}
HorizontalLayout {
// **The tools, on the far side from their consequences.**
//
// A fixed rail rather than a row of chips inside the develop
// column, which is where these three used to live. The column
// can be put away — that is the whole point of the toggle in
// the strip above — and putting the tools away with it meant
// the way *out* of crop disappeared along with the way in.
// Hence the "Done Cropping" button floating over the canvas
// below: one control duplicated to paper over the other being
// reachable only sometimes.
//
// The rail does not close, so a tool can always be put down
// where it was picked up. `toolrail.slint` has the rest of the
// reasoning and the table that generates it.
//
// Not conditioned on `total > 0` from out here: the rail
// collapses itself on `enabled`, and an `if` in this layout is
// the shape that has caused binding loops in this file before
// (see the develop column below).
ToolRail {
enabled: root.total > 0 && root.load-error == "";
mode: root.view-mode;
picked(m) => { root.mode-picked(m); }
}
// The canvas: compute output composited directly. No CPU
// round-trip anywhere in this path (ARCH §6.1).
canvas-area := Rectangle {
@@ -2052,12 +2078,19 @@ in property <bool> panel-visible: true;
// beside what it is reporting on. Crop moved to the panel
// because it is an edit, and edits live with the other edits.
//
// A way out of whichever mode is on is still reachable from
// here — the develop column may be closed on a narrow
// window, and the strip that enters a mode is pinned inside
// it. Stranding the user in a mode with no visible way out
// is worse than one duplicated control, and it is worse
// still now that there are two modes to be stranded in.
// **"Done" is no longer here because it has to be.** It was:
// the control that entered a mode was pinned inside the
// develop column, the column closes, and stranding someone
// in crop with no visible way out was worse than one
// duplicated control. The tool rail does not close, so that
// reason has gone.
//
// It stays because of where it is. Finishing a crop is a
// decision *about the photograph*, taken while looking at
// the photograph, and a confirmation the width of the
// window away from the thing being confirmed is a
// confirmation you take on trust. The rail is now the
// second way out rather than the first.
if root.total > 0 && root.load-error == "": HorizontalLayout {
x: 12px;
y: parent.height - self.preferred-height - 12px;
@@ -2126,55 +2159,54 @@ in property <bool> panel-visible: true;
// width, which the layout then influences. Slint flags it, and
// it can panic at runtime.
develop-column := Rectangle {
// TRACES: FR-UI-2
// **As wide as what it holds.**
// TRACES: FR-UI-1
// **One width, stated once, and not measured.**
//
// It was 280px, chosen for a tablet where the column is a
// large fraction of the screen and every pixel of it is
// taken from the photograph. That was too narrow on a
// desktop, so a second number — 380px — was added for the
// expanded class. Both were guesses at how much room the
// widest row in this column needs, and a guess is exactly
// what cannot be right here: the mode strip is two modes, a
// separator, "All", and one chip per attribute the
// operation set *declares*, so the row is generated and no
// constant in this file can track it. When the guess came
// up short the strip scrolled sideways, and a control you
// have to pan to reach is one you do not know is there.
// This column used to size itself from its contents: every
// panel published a `content-width`, declared it as its own
// `min-width`, and the column took the largest. That was an
// answer to a real problem — before it, the width was two
// guessed constants (280px for a tablet, 380px for a
// desktop) and the generated chip row outgrew both, so a
// control ended up somewhere you had to pan to find.
//
// **So the column asks instead.** Every panel that can
// appear in it publishes a `content-width` — how wide it
// has to be before it clips itself — and also declares that
// as its `min-width`. The `min-width` is what makes this
// aggregate on its own: `column` below is a layout, so it
// already reports the largest minimum among its children,
// and it does so for the panels that come and go with the
// mode as well, which sit inside `if`s and cannot be named
// from out here. Grep `content-width` in this directory to
// see every panel that has a say.
// **It solved that by making the photograph pay.** The
// pixels a sidebar takes come out of the picture beside it,
// and a measured sidebar spends them on whatever happens to
// be widest: an axis label gaining a digit made the image
// smaller, and switching tools swapped one set of panels for
// another and shifted the image sideways under the eye
// that was judging it. In an application whose entire job is
// showing you a photograph accurately, the frame around it
// must not move because a caption changed.
//
// The mode strip is named explicitly only because it is
// pinned *outside* that layout, so nothing else would
// measure it.
// So the number is mandated: `panel-width`, in
// `style.yaml`, next to the reasoning and next to the rail
// on the other side, which is fixed for the same reason. A
// panel that wants more than that clips, and the Flickable
// below is what makes the rest of it reachable — the same
// fallback as before, now the first answer rather than the
// last. `AdjustPanel` is the panel the number was chosen
// against; its note says why.
//
// There is no floor. A floor is another guess, and the
// panels state their own minimums now. The one thing left
// over the measurement is `panel-max-width` — not a size
// but a policy, that a column may not take the window from
// the photograph it exists to serve — and past that the
// Flickables inside are the fallback, as they have always
// been. It comes from Rust rather than from `root.width`
// because reading the window width inside the layout that
// sets it is the binding loop the comment on `expanded`
// above describes; Rust already measures the window for
// `layout-class`, so this is one more thing said in the
// same breath.
property <length> content-width: max(
develop-strip.content-width,
column.preferred-width,
);
// The generated row that started all this is not a problem
// any more either, because it is no longer in the column's
// way: the tools moved to `ToolRail` and what is left is
// group filters, which pan within their own strip without
// anything else having to move.
//
// `panel-max-width` survives, and it is the one thing here
// that is not a size: it is the policy that a column may
// not take the window from the photograph it exists to
// serve, and it bites only on a window narrow enough that
// 360px would. It comes from Rust rather than from
// `root.width` because reading the window width inside the
// layout that sets it is the binding loop the comment on
// `expanded` above describes; Rust already measures the
// window for `layout-class`, so this is one more thing said
// in the same breath.
width: root.panel-visible
? min(root.panel-max-width, self.content-width)
? min(root.panel-max-width, Theme.panel-width)
: 0px;
visible: root.panel-visible;
background: Theme.surface;
@@ -2201,13 +2233,11 @@ in property <bool> panel-visible: true;
// would both sit at its origin and overlap. The strip is pinned by
// being outside the Flickable rather than by any coordinate.
VerticalLayout {
develop-strip := ModeStrip {
GroupStrip {
enabled: root.adjust-enabled;
mode: root.view-mode;
tabs: root.adjust-tabs;
active-tab: root.adjust-active-tab;
picked(i) => { root.adjust-tab-picked(i); }
mode-picked(m) => { root.mode-picked(m); }
}
Flickable {
@@ -2226,12 +2256,40 @@ in property <bool> panel-visible: true;
// the far side. It looked like a rendering fault
// and it was an alignment one.
//
// Floored at the Flickable's own width so the
// ordinary case — a column sized to fit, which is
// now every case the cap does not bite — puts the
// content at x: 0 and stretches it, rather than
// leaving it preferred-width and adrift.
viewport-width: max(self.width, column.preferred-width);
// **`min-width`, not `preferred-width`, and that is
// what makes a mandated column work at all.**
//
// With the preferred width here, the content laid
// itself out at whatever size it would have liked
// and the column clipped the difference: Paste came
// out sliced down the middle and the panel's reset
// was over the window's edge. That is the same
// fault as above wearing a different hat — the
// content was never told how much room it had, so
// it could not adapt to having less.
//
// The floor is the layout's *minimum* instead. A
// preferred width is what a panel would enjoy; a
// minimum is what it cannot go below, and between
// the two the layout does the work it exists to do
// — rows tighten, stretches give way — so the
// column fits because it was asked to rather than
// by luck.
//
// That does less here than it sounds like, and it
// is worth knowing why: this column's minimum and
// its preferred width are within a few pixels of
// each other, because a `Text` that does not elide
// reports the same for both and most of what is in
// here is text. So `panel-width` still has to be a
// number the contents actually fit in — see its
// note in `style.yaml` for the measurement. What
// this binding buys is that the clipping, when it
// comes, is of a panel that genuinely cannot
// shrink rather than of one that simply was not
// asked to, and that panning is then a real
// fallback rather than a permanent condition.
viewport-width: max(self.width, column.min-width);
interactive: !adjust.slider-dragging;
// **What the column holds is the mode's answer.**
@@ -2569,7 +2627,7 @@ in property <bool> panel-visible: true;
// thing the dialogue covers.
// TRACES: FR-DEV-6
// Over the shell rather than inside a view, because both views open
// it — and because the develop column is 320px wide, which is not
// it — and because the develop column is 360px wide, which is not
// enough to list presets and rename one in.
if root.presets-open: PresetSheet {
width: 100%;
+1 -1
View File
@@ -492,7 +492,7 @@ export component FieldRow inherits HorizontalLayout {
//
// **The chip width is fixed, not a share of the container's.** Dividing
// `self.width` looks right and clips: the develop column sets its width to
// `min(panel-max-width, content-width)` with `clip: true`, so when the policy
// `min(panel-max-width, panel-width)` with `clip: true`, so when the policy
// bites, `self.width` here is the width that was *asked for* and the visible
// column is narrower.
export component ChipGrid inherits Rectangle {
-8
View File
@@ -220,14 +220,6 @@ export component InfoPanel inherits Rectangle {
in property <string> exposure;
in property <string> dimensions;
/// TRACES: FR-UI-2
/// How wide this panel has to be before it starts clipping itself. The
/// develop column is the largest of these and nothing else; publishing it
/// as `min-width` too is what makes the enclosing layout aggregate them
/// without this panel having to be referenced by name.
out property <length> content-width: panel.preferred-width;
min-width: root.content-width;
background: transparent;
height: panel.preferred-height;
+20 -18
View File
@@ -77,8 +77,12 @@ export struct HistogramView {
// FR-CULL-3 is written for.
//
// Kept short in Rust, and that is a layout constraint: `FieldRow` draws
// the hint as an unwrapped Text, so its natural width becomes this panel's
// preferred width and the develop column takes the largest of those.
// the hint as an unwrapped Text, and a Text that does not elide reports
// the same *minimum* width as preferred. The develop column no longer
// takes the largest of those — it is a mandated `panel-width` — which
// makes this constraint sharper rather than softer: an over-long hint no
// longer widens the column, it pushes the column's minimum past the width
// it has and clips the panel instead.
hint: string,
// What the plot says when there is nothing to draw. Never "0%" and never
@@ -183,22 +187,20 @@ export component HistogramPanel inherits Rectangle {
/// two above it, so there is exactly one place the choice is made.
property <HistogramView> shown: root.mode == 1 ? root.raw-data : root.data;
/// TRACES: FR-UI-2
/// How wide this panel has to be before it starts clipping itself.
///
/// Every panel in the develop column declares one, and the column takes
/// the largest — that is the whole of how the column is sized. It replaced
/// two guessed constants (280px for a tablet, 380px for a desktop) that
/// could not track a panel gaining a control, and did not.
///
/// Published as `min-width` as well as read by name: the first is what
/// makes the enclosing layout aggregate these automatically, including for
/// the panels that come and go with the mode and so cannot be referenced
/// from outside their `if`.
out property <length> content-width: panel.preferred-width;
min-width: root.content-width;
// **This panel does not get a say in how wide the column is.**
//
// It used to: every panel in the develop column published a
// `content-width`, the column took the largest, and that was the whole of
// how the column was sized. The trouble is what the column is next to. A
// sidebar measured from its contents takes its width out of the
// photograph, so an axis label gaining a digit made the picture smaller,
// and switching tools swapped one set of panels for another and moved the
// image sideways on the screen.
//
// The column is `panel-width` now, stated once in `style.yaml`. A panel
// that wants more than that clips, and the Flickable the column puts
// around this is what makes the rest reachable. See the `_develop` note in
// `style.yaml` for the trade.
background: transparent;
height: panel.preferred-height;
-8
View File
@@ -115,14 +115,6 @@ export component HistoryPanel inherits Rectangle {
// No explicit height either, for the same reason `AdjustPanel` and
// `MaskPanel` declare none: the row count changes as the photographer
// works, and a height pinned to a layout's preferred size is one more
/// TRACES: FR-UI-2
/// How wide this panel has to be before it starts clipping itself. The
/// develop column is the largest of these and nothing else; publishing it
/// as `min-width` too is what makes the enclosing layout aggregate them
/// without this panel having to be referenced by name.
out property <length> content-width: layout.preferred-width;
min-width: root.content-width;
// thing that has to keep up with a repeater.
layout := VerticalLayout {
padding: Theme.gap;
+61
View File
@@ -59,6 +59,7 @@ component Ink inherits Path {
// 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
@@ -285,4 +286,64 @@ export component Icon inherits Rectangle {
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";
}
}
-8
View File
@@ -311,14 +311,6 @@ export component MaskPanel inherits Rectangle {
// the "Find regions" button — the sort of fault that is invisible in the
// source and obvious the moment anyone looks at the panel. Conditioning
// each child on its own keeps every element a direct child of one layout,
/// TRACES: FR-UI-2
/// How wide this panel has to be before it starts clipping itself. The
/// develop column is the largest of these and nothing else; publishing it
/// as `min-width` too is what makes the enclosing layout aggregate them
/// without this panel having to be referenced by name.
out property <length> content-width: layout.preferred-width;
min-width: root.content-width;
// which is the layout that measures them.
layout := VerticalLayout {
padding: Theme.gap;
+11 -15
View File
@@ -79,18 +79,12 @@ export component FocusPanel inherits Rectangle {
background: Theme.surface;
/// TRACES: FR-UI-2
/// How wide this panel has to be before it clips itself. The develop
/// column is the largest of these and nothing else; see `SpotPanel` and
/// `HistogramPanel` for the whole protocol.
///
/// Both chip rows wrap at three, which is what keeps this number at the
/// narrowest column the application supports rather than at four chips
/// abreast — a single row of four would set the width of the entire
/// sidebar for every other panel in it.
out property <length> content-width: layout.preferred-width;
min-width: root.content-width;
// Both chip rows wrap at three rather than running four abreast. That was
// once about not setting the sidebar's width for every other panel in it;
// the column is a mandated `panel-width` now and no panel sets it, so what
// the wrap buys today is simply that these fit inside it rather than
// needing to be panned to.
//
// Flat rather than nested, for the reason `SpotPanel` and `MaskPanel` both
// give: a nested conditional layout under-reports its height here and the
// rows below it get drawn on top of one another. Every row carries its own
@@ -140,9 +134,11 @@ export component FocusPanel inherits Rectangle {
// frame cannot be measured for sharpness (see `FocusPeakPass`).
//
// Kept to one short sentence on purpose. A wrapping Text reports
// its *unwrapped* width as its preferred one, and this panel's
// `content-width` is what the develop column sizes itself from —
// a paragraph here would hold the whole sidebar open.
// its *unwrapped* width as its preferred one, so a paragraph here
// makes this panel's preferred width the length of the paragraph.
// That no longer drags the sidebar open with it — the column is a
// mandated width — but it does still decide how much of the row
// the panel wants, and a sentence is the honest amount to ask for.
text: "Marks pause while a control is dragged.";
wrap: word-wrap;
}
-8
View File
@@ -85,14 +85,6 @@ export component SpotPanel inherits Rectangle {
background: Theme.surface;
/// TRACES: FR-UI-2
/// How wide this panel has to be before it starts clipping itself. The
/// develop column is the largest of these and nothing else; publishing it
/// as `min-width` too is what makes the enclosing layout aggregate them
/// without this panel having to be referenced by name.
out property <length> content-width: layout.preferred-width;
min-width: root.content-width;
// Flat rather than nested, for the reason `MaskPanel` gives: a nested
// conditional layout under-reported its height and drew rows on top of one
// another.
+195
View File
@@ -0,0 +1,195 @@
// The develop view's tool rail: which tool the photographer is holding.
//
// **One table, one file.** Everything that decides what this rail contains is
// 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, nothing here is per-tool, and there is no second list anywhere that
// could fall out of step with this one. That is the whole design: the rail is
// generated, so it cannot be *partly* updated.
//
// **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 "adjust.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;
callback picked(ViewMode);
// **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 },
{ icon: "crop", label: "Crop", 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;
// No Flickable. Every other strip in this view has one, because every
// other strip is generated from something the user's data decides and can
// therefore outgrow its space. This list is four entries written in this
// file, running down an axis with a whole window of room — the shortest
// supported window fits fourteen. If that ever stops being true the answer
// is a rail that scrolls, not a rail that overflows, and this comment is
// where to start.
VerticalLayout {
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 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 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;
}
}