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 ### 10.1 The mode
A third chip in `ModeStrip`, beside Crop and Local, and a `ViewMode::spots`. A `ViewMode::spots`, and a row in `ToolRail`'s table beside Crop and Local.
The strip's own documentation already predicted this shape for the brush; the That table's own documentation already predicts this shape for the brush; the
spot tool is the same shape and arrives first. 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 ### 10.2 Gestures
| Gesture | Effect | | 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. /// compact layout exactly as a tablet in portrait would.
const EXPANDED_MIN_WIDTH: f32 = 820.0; 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 largest share of the window the develop column may take.
/// ///
/// The column sizes itself to the widest thing it holds — the generated mode /// A policy, not a size. The column is a fixed `panel-width` (`style.yaml`),
/// strip, the Copy/Paste pair, the histogram's axis labels — so on a rich /// so on any window with room for it this does nothing at all — it bites only
/// operation set it would otherwise keep growing. This is where that stops. /// where 360px would be most of the screen, and what it says there is that the
/// The majority of the window stays with the photograph, which is what the /// majority of the window stays with the photograph the column exists to
/// column is there to serve. /// 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; const PANEL_MAX_FRACTION: f32 = 0.45;
/// The floor under that share, so a narrow window still gets a usable column /// 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. /// 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 /// Under `panel-width` by design: between the two the column narrows from 360
/// be doing the clipping the cap exists to avoid. /// 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; const PANEL_MIN_WIDTH: f32 = 280.0;
/// How long after the last change a draft frame is replaced by a sharp one. /// 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_expanded(expanded);
window.set_layout_class(if expanded { "expanded" } else { "compact" }.into()); 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 // reason the class is — a width that both derives from and feeds the
// layout is a binding loop in Slint. // layout is a binding loop in Slint.
window.set_panel_max_width((width * PANEL_MAX_FRACTION).max(PANEL_MIN_WIDTH)); 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 Floor on button width, so a one-word label is still a comfortable
target and a row of buttons has an even rhythm. 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: swatch:
value: 12 value: 12
note: | note: |
+42 -132
View File
@@ -541,21 +541,6 @@ export component GeometryPanel inherits Rectangle {
callback portrait-toggled(); callback portrait-toggled();
callback reset(); 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; height: layout.preferred-height;
layout := VerticalLayout { layout := VerticalLayout {
@@ -738,15 +723,6 @@ export component TransferPanel inherits VerticalLayout {
/// same thought given a name — this edit, kept. /// same thought given a name — this edit, kept.
callback open-presets(); 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; padding: Theme.gap;
spacing: Theme.gap-sm; spacing: Theme.gap-sm;
@@ -817,24 +793,22 @@ export enum ViewMode {
spots, 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 /// A group is something you are *looking at*, not something you are *in*, and
/// are drawn as chips and lit with the accent, which means *active* everywhere /// that distinction is now made by the two controls being in different places
/// else in this interface and means exactly that here. /// 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
/// **Groups** — the rest — filter the adjustments to one kind. They are /// layer's chain and does not put the tool down, so "Light" means the same
/// underlined instead, because the accent is already spoken for and because /// thing wherever it is pressed — the alternative, where a group press
/// they are a different sort of state: a mode is something you are *in*, a /// silently dropped the scope, would be the §1.1 fault from the other end.
/// 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.
/// ///
/// **Pinned above the scrolling column, not inside a panel.** It began inside /// **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 /// `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 /// **This file names no group.** The strings arrive already resolved from
/// whatever the operations declared themselves to be about, so a new operation /// 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 /// joins a group without an edit here (FR-DEV-3a). With the tools gone there is
/// operations — crop is a gesture on the canvas and local is a scope — so /// no longer any hand-written word in this row at all, save "All".
/// naming them breaks nothing. export component GroupStrip inherits Rectangle {
export component ModeStrip inherits Rectangle {
in property <[string]> tabs; in property <[string]> tabs;
/// Index into `tabs`, or -1 for "everything". /// Index into `tabs`, or -1 for "everything".
in property <int> active-tab: -1; in property <int> active-tab: -1;
in property <ViewMode> mode: ViewMode.photo;
in property <bool> enabled: true; in property <bool> enabled: true;
callback picked(int); 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; background: Theme.surface;
height: root.enabled ? layout.preferred-height : 0px; 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 // Scrolls rather than overflowing, exactly as the develop status strip
// does and for the same reason: a `HorizontalLayout` given less width than // 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 // its children need does not shrink them, it runs off the end.
// plus however many groups the operation set declares is already more than //
// a 280px column holds, and the column is where this is pinned. // 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 { Flickable {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -890,67 +856,17 @@ export component ModeStrip inherits Rectangle {
spacing: Theme.gap-sm; spacing: Theme.gap-sm;
alignment: start; alignment: start;
// A chip per mode. Drawn as an outline that fills when active rather // **Where a canvas tool goes: not here.** A mode that arms a gesture
// than as an underline, so at a glance the strip reads as two runs // on the photograph is a row in `ToolRail`'s table, one file over.
// and it is never ambiguous which half a lit entry belongs to.
// //
// **Where a canvas tool goes.** A mode that arms a gesture on the // Three of them used to sit at the head of this row as filled chips,
// photograph and scopes the column joins this list and the `ViewMode` // with a one-pixel divider after them. Both are gone, and nothing has
// enum, and needs nothing else here. *Repair* is the first to arrive // replaced them — no heading, no lead-in. A run of words with one
// that way (FR-DEV-8); painting a mask is the same shape and still to // underlined is a tab bar, which is exactly what this is, and it needs
// come — `MaskSource::Brush` and the stroke calls on `MaskLayer` // saying only while there is something else in the row to be told
// already exist in the core, and what is missing on this side is only // apart from. The width that buys back is the point: this row pans
// the canvas interaction the repairs now have a pattern for. // when the operation set is rich, so anything permanently occupying
for entry in [ // its left-hand end is paid for by every group after the third.
{ 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;
}
all := TouchArea { all := TouchArea {
width: 34px; width: 34px;
height: Theme.touch-target; 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. /// scrolls as one, so the panel reports the drag and the column obeys it.
out property <bool> slider-dragging: false; out property <bool> slider-dragging: false;
/// TRACES: FR-UI-2 // **The sliders are what `panel-width` was chosen against.** No panel gets
/// How wide this panel has to be before it starts clipping itself. The // a say in the column's width any more — it is one number in `style.yaml`
/// develop column is the largest of these and nothing else; publishing it // — but this is the panel that number has to be right for. Each row is a
/// as `min-width` too is what makes the enclosing layout aggregate them // label, a value and a track, and a track squeezed below the width its
/// without this panel having to be referenced by name. // 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.
/// 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;
layout := VerticalLayout { layout := VerticalLayout {
padding: Theme.gap; padding: Theme.gap;
spacing: Theme.gap-sm; spacing: Theme.gap-sm;
+119 -61
View File
@@ -1,5 +1,5 @@
import { Theme } from "theme.slint"; 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 { GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
import { SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint"; import { SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint";
import { CropOverlay } from "crop.slint"; import { CropOverlay } from "crop.slint";
@@ -16,6 +16,7 @@ import { FocusMarks, FocusPanel } from "peaking.slint";
import { SettingsPage } from "settings.slint"; import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint"; import { ImportPage } from "import.slint";
import { StatusBar, InfoPanel } from "develop.slint"; import { StatusBar, InfoPanel } from "develop.slint";
import { ToolRail } from "toolrail.slint";
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip } export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip }
export { GestureRow } export { GestureRow }
@@ -1675,6 +1676,31 @@ in property <bool> panel-visible: true;
} }
HorizontalLayout { 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 // The canvas: compute output composited directly. No CPU
// round-trip anywhere in this path (ARCH §6.1). // round-trip anywhere in this path (ARCH §6.1).
canvas-area := Rectangle { canvas-area := Rectangle {
@@ -2052,12 +2078,19 @@ in property <bool> panel-visible: true;
// beside what it is reporting on. Crop moved to the panel // beside what it is reporting on. Crop moved to the panel
// because it is an edit, and edits live with the other edits. // because it is an edit, and edits live with the other edits.
// //
// A way out of whichever mode is on is still reachable from // **"Done" is no longer here because it has to be.** It was:
// here — the develop column may be closed on a narrow // the control that entered a mode was pinned inside the
// window, and the strip that enters a mode is pinned inside // develop column, the column closes, and stranding someone
// it. Stranding the user in a mode with no visible way out // in crop with no visible way out was worse than one
// is worse than one duplicated control, and it is worse // duplicated control. The tool rail does not close, so that
// still now that there are two modes to be stranded in. // 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 { if root.total > 0 && root.load-error == "": HorizontalLayout {
x: 12px; x: 12px;
y: parent.height - self.preferred-height - 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 // width, which the layout then influences. Slint flags it, and
// it can panic at runtime. // it can panic at runtime.
develop-column := Rectangle { develop-column := Rectangle {
// TRACES: FR-UI-2 // TRACES: FR-UI-1
// **As wide as what it holds.** // **One width, stated once, and not measured.**
// //
// It was 280px, chosen for a tablet where the column is a // This column used to size itself from its contents: every
// large fraction of the screen and every pixel of it is // panel published a `content-width`, declared it as its own
// taken from the photograph. That was too narrow on a // `min-width`, and the column took the largest. That was an
// desktop, so a second number — 380px — was added for the // answer to a real problem — before it, the width was two
// expanded class. Both were guesses at how much room the // guessed constants (280px for a tablet, 380px for a
// widest row in this column needs, and a guess is exactly // desktop) and the generated chip row outgrew both, so a
// what cannot be right here: the mode strip is two modes, a // control ended up somewhere you had to pan to find.
// 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.
// //
// **So the column asks instead.** Every panel that can // **It solved that by making the photograph pay.** The
// appear in it publishes a `content-width` — how wide it // pixels a sidebar takes come out of the picture beside it,
// has to be before it clips itself — and also declares that // and a measured sidebar spends them on whatever happens to
// as its `min-width`. The `min-width` is what makes this // be widest: an axis label gaining a digit made the image
// aggregate on its own: `column` below is a layout, so it // smaller, and switching tools swapped one set of panels for
// already reports the largest minimum among its children, // another and shifted the image sideways under the eye
// and it does so for the panels that come and go with the // that was judging it. In an application whose entire job is
// mode as well, which sit inside `if`s and cannot be named // showing you a photograph accurately, the frame around it
// from out here. Grep `content-width` in this directory to // must not move because a caption changed.
// see every panel that has a say.
// //
// The mode strip is named explicitly only because it is // So the number is mandated: `panel-width`, in
// pinned *outside* that layout, so nothing else would // `style.yaml`, next to the reasoning and next to the rail
// measure it. // 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 // The generated row that started all this is not a problem
// panels state their own minimums now. The one thing left // any more either, because it is no longer in the column's
// over the measurement is `panel-max-width` — not a size // way: the tools moved to `ToolRail` and what is left is
// but a policy, that a column may not take the window from // group filters, which pan within their own strip without
// the photograph it exists to serve — and past that the // anything else having to move.
// Flickables inside are the fallback, as they have always //
// been. It comes from Rust rather than from `root.width` // `panel-max-width` survives, and it is the one thing here
// because reading the window width inside the layout that // that is not a size: it is the policy that a column may
// sets it is the binding loop the comment on `expanded` // not take the window from the photograph it exists to
// above describes; Rust already measures the window for // serve, and it bites only on a window narrow enough that
// `layout-class`, so this is one more thing said in the // 360px would. It comes from Rust rather than from
// same breath. // `root.width` because reading the window width inside the
property <length> content-width: max( // layout that sets it is the binding loop the comment on
develop-strip.content-width, // `expanded` above describes; Rust already measures the
column.preferred-width, // window for `layout-class`, so this is one more thing said
); // in the same breath.
width: root.panel-visible width: root.panel-visible
? min(root.panel-max-width, self.content-width) ? min(root.panel-max-width, Theme.panel-width)
: 0px; : 0px;
visible: root.panel-visible; visible: root.panel-visible;
background: Theme.surface; 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 // would both sit at its origin and overlap. The strip is pinned by
// being outside the Flickable rather than by any coordinate. // being outside the Flickable rather than by any coordinate.
VerticalLayout { VerticalLayout {
develop-strip := ModeStrip { GroupStrip {
enabled: root.adjust-enabled; enabled: root.adjust-enabled;
mode: root.view-mode;
tabs: root.adjust-tabs; tabs: root.adjust-tabs;
active-tab: root.adjust-active-tab; active-tab: root.adjust-active-tab;
picked(i) => { root.adjust-tab-picked(i); } picked(i) => { root.adjust-tab-picked(i); }
mode-picked(m) => { root.mode-picked(m); }
} }
Flickable { Flickable {
@@ -2226,12 +2256,40 @@ in property <bool> panel-visible: true;
// the far side. It looked like a rendering fault // the far side. It looked like a rendering fault
// and it was an alignment one. // and it was an alignment one.
// //
// Floored at the Flickable's own width so the // **`min-width`, not `preferred-width`, and that is
// ordinary case — a column sized to fit, which is // what makes a mandated column work at all.**
// now every case the cap does not bite — puts the //
// content at x: 0 and stretches it, rather than // With the preferred width here, the content laid
// leaving it preferred-width and adrift. // itself out at whatever size it would have liked
viewport-width: max(self.width, column.preferred-width); // 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; interactive: !adjust.slider-dragging;
// **What the column holds is the mode's answer.** // **What the column holds is the mode's answer.**
@@ -2569,7 +2627,7 @@ in property <bool> panel-visible: true;
// thing the dialogue covers. // thing the dialogue covers.
// TRACES: FR-DEV-6 // TRACES: FR-DEV-6
// Over the shell rather than inside a view, because both views open // 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. // enough to list presets and rename one in.
if root.presets-open: PresetSheet { if root.presets-open: PresetSheet {
width: 100%; 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 // **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 // `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 // bites, `self.width` here is the width that was *asked for* and the visible
// column is narrower. // column is narrower.
export component ChipGrid inherits Rectangle { export component ChipGrid inherits Rectangle {
-8
View File
@@ -220,14 +220,6 @@ export component InfoPanel inherits Rectangle {
in property <string> exposure; in property <string> exposure;
in property <string> dimensions; 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; background: transparent;
height: panel.preferred-height; height: panel.preferred-height;
+20 -18
View File
@@ -77,8 +77,12 @@ export struct HistogramView {
// FR-CULL-3 is written for. // FR-CULL-3 is written for.
// //
// Kept short in Rust, and that is a layout constraint: `FieldRow` draws // 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 // the hint as an unwrapped Text, and a Text that does not elide reports
// preferred width and the develop column takes the largest of those. // 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, hint: string,
// What the plot says when there is nothing to draw. Never "0%" and never // 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. /// two above it, so there is exactly one place the choice is made.
property <HistogramView> shown: root.mode == 1 ? root.raw-data : root.data; property <HistogramView> shown: root.mode == 1 ? root.raw-data : root.data;
// **This panel does not get a say in how wide the column is.**
/// TRACES: FR-UI-2 //
/// How wide this panel has to be before it starts clipping itself. // It used to: every panel in the develop column published a
/// // `content-width`, the column took the largest, and that was the whole of
/// Every panel in the develop column declares one, and the column takes // how the column was sized. The trouble is what the column is next to. A
/// the largest — that is the whole of how the column is sized. It replaced // sidebar measured from its contents takes its width out of the
/// two guessed constants (280px for a tablet, 380px for a desktop) that // photograph, so an axis label gaining a digit made the picture smaller,
/// could not track a panel gaining a control, and did not. // and switching tools swapped one set of panels for another and moved the
/// // image sideways on the screen.
/// Published as `min-width` as well as read by name: the first is what //
/// makes the enclosing layout aggregate these automatically, including for // The column is `panel-width` now, stated once in `style.yaml`. A panel
/// the panels that come and go with the mode and so cannot be referenced // that wants more than that clips, and the Flickable the column puts
/// from outside their `if`. // around this is what makes the rest reachable. See the `_develop` note in
out property <length> content-width: panel.preferred-width; // `style.yaml` for the trade.
min-width: root.content-width;
background: transparent; background: transparent;
height: panel.preferred-height; 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 // No explicit height either, for the same reason `AdjustPanel` and
// `MaskPanel` declare none: the row count changes as the photographer // `MaskPanel` declare none: the row count changes as the photographer
// works, and a height pinned to a layout's preferred size is one more // 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. // thing that has to keep up with a repeater.
layout := VerticalLayout { layout := VerticalLayout {
padding: Theme.gap; padding: Theme.gap;
+61
View File
@@ -59,6 +59,7 @@ component Ink inherits Path {
// rotate-ccw rotate-cw flip-h flip-v // rotate-ccw rotate-cw flip-h flip-v
// collection collection-smart // collection collection-smart
// offline offline-held // offline offline-held
// photo crop mask repair
export component Icon inherits Rectangle { export component Icon inherits Rectangle {
in property <string> name; in property <string> name;
/// The single colour the whole drawing takes. Named `ink` rather than /// The single colour the whole drawing takes. Named `ink` rather than
@@ -285,4 +286,64 @@ export component Icon inherits Rectangle {
stroke-width: root.weight; 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"; 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 // the "Find regions" button — the sort of fault that is invisible in the
// source and obvious the moment anyone looks at the panel. Conditioning // 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, // 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. // which is the layout that measures them.
layout := VerticalLayout { layout := VerticalLayout {
padding: Theme.gap; padding: Theme.gap;
+11 -15
View File
@@ -79,18 +79,12 @@ export component FocusPanel inherits Rectangle {
background: Theme.surface; background: Theme.surface;
/// TRACES: FR-UI-2 // Both chip rows wrap at three rather than running four abreast. That was
/// How wide this panel has to be before it clips itself. The develop // once about not setting the sidebar's width for every other panel in it;
/// column is the largest of these and nothing else; see `SpotPanel` and // the column is a mandated `panel-width` now and no panel sets it, so what
/// `HistogramPanel` for the whole protocol. // the wrap buys today is simply that these fit inside it rather than
/// // needing to be panned to.
/// 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;
// Flat rather than nested, for the reason `SpotPanel` and `MaskPanel` both // Flat rather than nested, for the reason `SpotPanel` and `MaskPanel` both
// give: a nested conditional layout under-reports its height here and the // 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 // 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`). // frame cannot be measured for sharpness (see `FocusPeakPass`).
// //
// Kept to one short sentence on purpose. A wrapping Text reports // Kept to one short sentence on purpose. A wrapping Text reports
// its *unwrapped* width as its preferred one, and this panel's // its *unwrapped* width as its preferred one, so a paragraph here
// `content-width` is what the develop column sizes itself from — // makes this panel's preferred width the length of the paragraph.
// a paragraph here would hold the whole sidebar open. // 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."; text: "Marks pause while a control is dragged.";
wrap: word-wrap; wrap: word-wrap;
} }
-8
View File
@@ -85,14 +85,6 @@ export component SpotPanel inherits Rectangle {
background: Theme.surface; 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 // Flat rather than nested, for the reason `MaskPanel` gives: a nested
// conditional layout under-reported its height and drew rows on top of one // conditional layout under-reported its height and drew rows on top of one
// another. // 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;
}
}