Say which photograph the sliders are pointed at
Selecting a mask layer silently re-points about thirty controls at that layer's chain. Same panel, same order, same sliders, different meaning — and the only thing that said so was a sentence in the panel above, which a photographer reaching for the exposure slider has no reason to read. An exposure change lands on the whole frame when it was meant for a face, or the reverse; both are silent, and both are discovered later. `ui-navigation.md` §1.1 calls it the dangerous one and it is: the others in that document cost time, this one costs work. The remedy is the classic one for a modal fault — make the mode visible — and the application already had the pattern. Crop arms a canvas interaction, draws an overlay, gives the column one job and is left by the control that entered it. Local masking is the same animal built as a peer panel, and that is what created the ambiguity. So `crop-mode` stops being a bare boolean and becomes one value of a three-state mode, which is the point: two modes could both be on before, and now that is not a state the interface can be in rather than one it is tested against. **One strip, not two.** The mode control was going to sit beside the group strip that filters the adjustments, which is two controls above one column answering the same question — what am I working on. They are one control now, `Crop · Local │ All · Light · Colour`, which is the shape Lightroom Mobile's bottom strip has for the same reason. The two halves are different kinds of state and are drawn differently: a mode is a chip that fills with the accent when it is on, a group is a word with a rule under it. That difference is what lets both be read at once, which they routinely are — picking Light while a mask is selected filters *that layer's* chain and does not leave the mode. Dropping the scope on a group press would be the same fault coming back from the other end, and would make Light mean two things depending on where it was pressed. The strip stays pinned above the develop column rather than moving to the top of the canvas as the document proposed. The half that filters the column belongs to the column, and the photograph is the subject. The canvas keeps one button, which now names the mode it leaves rather than saying "Done" — that was unambiguous with one mode and would not be with two — because the column can be closed on a narrow window and no mode may be inescapable. Entering a mode is a side effect, so Rust owns it rather than the strip writing the property: crop drops the zoom, local turns the overlay on, and leaving clears the selection. That last one is the fix. The "Overlay" and "Select" toggles are gone because they armed things that are simply what the mode *is* — a mode that has to be switched on separately is one you can enter and have do nothing. Escape and the Android back gesture join `back_step` as one `LeaveMode` rather than a second exit concept, and the mode is left before the zoom is: it was entered later, and it is the bigger step back. The heading is where the scope goes. Not a caption beside the panel, the heading *of* the panel that changed — `ADJUST` becomes the layer's name, the same string the selected row in the stack shows. That is the difference between describing a hazard and removing it. **Handles on the photograph.** A linear or radial mask could be created and then not moved, so a radial sat at the centre of the frame at its default size for ever. Three faults stood in the way of drawing one. The first is that a gradient did not render at all until the model had run. The rasteriser was built on the way out of `segment` and the array's size was read *off* the segmentation, so a gradient added to an unsegmented photograph produced nothing — silently, in the same way exports and thumbnails once did: the shader still emits the layer's block and the empty placeholder multiplies it by zero. The proxy size is a property of the photograph. Both are derived from it now, and deliberately at the same size rather than by coincidence, because a subject's distance field is sampled against that array. The second is hit-testing. A handle is drawn in output coordinates and stored in source ones, and between them lie the crop, the zoom, the pan, the straightening and the turns. `Framing::source_at` is `wgsl_prologue` evaluated on the CPU, kept in that file beside it so that keeping the two in step is one file's problem — a handle mapped through anything less drifts off the mask the moment the view moves, which is exactly what masks are rasterised in source space to avoid. The third is that a drag is a displacement, not a destination. Each handle answers to the movement of the pointer since the press, applied to where the mask was when the press landed. Snapping the handle to the pointer instead jerks it by up to half a touch target on the first press, and the target is finger-sized because a tablet has no hover to reveal a control and no modifier to qualify it. A ramp gets three handles — centre, width, angle. An ellipse gets three too: centre and one per semi-axis, the major one carrying the direction as well as the length, because where an axis is put says both. It had a fourth, and it is gone: standing off the shape by a fixed distance, the rotation arm began outside the photograph at the size a new radial is created at, so the first thing anyone saw was a control they could not reach without first shrinking the mask. Two faults here were found by looking at the screen rather than at the source, both of the kind that cannot be found any other way. A `1px` rule with a size and no position is *centred* by Slint, so the seam between the photograph and the column was a hairline down the middle of the panel, through the histogram and every slider under it — twice, once in `app.slint` and once in `AdjustPanel`. And handing Slint a fresh model for the handles on every pointer event made the repeater rebuild its items, taking the `TouchArea` holding the gesture with them: the handle jumped once and then went dead under a finger that was still down. `develop.rs` carries the same warning about the parameter rows, where it broke slider drags; the model is rewritten in place now. The tests worth having are the ones about ambiguity and about the map. That the same row reads the frame's value, then the layer's, then the frame's again is §1.1 in one assertion. That dragging a handle onto another gradient's matching handle *produces* that gradient closes the loop between the two directions of the framing map, through a view that is cropped, zoomed, panned, straightened and quarter-turned at once — a one-legged map is invisible when the framing is neutral, because then both legs are the identity. Not done here: the histogram still reports the whole frame while the sliders edit a layer. That disagreement is real and is N3's, which this unblocks. The strip has room for a Brush entry beside Crop and Local when the painted masks land in the core, and it needs nothing here but the canvas interaction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+155
-26
@@ -453,10 +453,6 @@ component ParamControl inherits Rectangle {
|
||||
// what a bespoke widget for a known stage is entitled to do.
|
||||
export component GeometryPanel inherits Rectangle {
|
||||
in property <bool> enabled: true;
|
||||
/// Whether the crop overlay is up. The button is a toggle, not an action:
|
||||
/// crop mode is sustained state, and the canvas looks different while it
|
||||
/// is on.
|
||||
in property <bool> crop-mode: false;
|
||||
in property <float> angle: 0.0;
|
||||
in property <float> max-straighten: 45.0;
|
||||
in property <bool> flip-h: false;
|
||||
@@ -464,7 +460,6 @@ export component GeometryPanel inherits Rectangle {
|
||||
/// Any of crop, angle, rotation or flips differs from neutral.
|
||||
in property <bool> modified: false;
|
||||
|
||||
callback crop-toggled(bool);
|
||||
callback rotate(int);
|
||||
callback flip-h-toggled();
|
||||
callback flip-v-toggled();
|
||||
@@ -497,13 +492,11 @@ export component GeometryPanel inherits Rectangle {
|
||||
spacing: Theme.gap-sm;
|
||||
padding-bottom: Theme.gap-sm;
|
||||
|
||||
// Crop first: it is the framing decision the others serve.
|
||||
Button {
|
||||
text: root.crop-mode ? "Done Cropping" : "Crop";
|
||||
active: root.crop-mode;
|
||||
enabled: root.enabled;
|
||||
clicked => { root.crop-toggled(!root.crop-mode); }
|
||||
}
|
||||
// No crop button. Crop is one value of the view mode now, entered
|
||||
// and left from the strip pinned at the top of this column — a
|
||||
// second control that entered the same mode would be a second
|
||||
// thing that has to agree about which mode the view is in, and
|
||||
// the whole point of the enum is that there is one answer.
|
||||
|
||||
// Rotation and flips. Icons rather than labels: four controls
|
||||
// named in words would wrap the 280px column, and each of
|
||||
@@ -629,7 +622,41 @@ export component TransferPanel inherits VerticalLayout {
|
||||
}
|
||||
|
||||
|
||||
/// The group strip: which kind of adjustment the panel is showing.
|
||||
/// Which mode the develop view is in.
|
||||
///
|
||||
/// `crop` was a bare `bool` on the window and local masking was a panel with
|
||||
/// two toggles, so nothing stopped both being on at once — and nothing on
|
||||
/// screen said which of them the canvas and the column were obeying. One value
|
||||
/// with three states cannot be in two of them, which is the whole reason this
|
||||
/// is an enum rather than a tidier pair of flags.
|
||||
export enum ViewMode {
|
||||
/// The whole photograph. Sliders are global, the canvas pans and zooms.
|
||||
photo,
|
||||
/// The crop overlay is up and the canvas shows the uncropped frame.
|
||||
crop,
|
||||
/// The region map is drawn, a click on the photograph selects, and the
|
||||
/// column is the mask stack and the selected layer's adjustments.
|
||||
local,
|
||||
}
|
||||
|
||||
/// The strip: what the photographer is working on.
|
||||
///
|
||||
/// Two kinds of entry, deliberately together.
|
||||
///
|
||||
/// **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.
|
||||
///
|
||||
/// **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
|
||||
@@ -639,25 +666,100 @@ export component TransferPanel inherits VerticalLayout {
|
||||
///
|
||||
/// **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).
|
||||
export component GroupStrip inherits Rectangle {
|
||||
/// 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 {
|
||||
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);
|
||||
|
||||
background: Theme.surface;
|
||||
height: root.enabled && root.tabs.length > 1 ? layout.preferred-height : 0px;
|
||||
visible: root.enabled && root.tabs.length > 1;
|
||||
height: root.enabled ? layout.preferred-height : 0px;
|
||||
visible: root.enabled;
|
||||
|
||||
// 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.
|
||||
Flickable {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
viewport-height: self.height;
|
||||
viewport-width: max(self.width, layout.preferred-width);
|
||||
|
||||
layout := HorizontalLayout {
|
||||
width: parent.viewport-width;
|
||||
padding-left: Theme.gap;
|
||||
padding-right: Theme.gap;
|
||||
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 brush goes.** Painting a mask is a third mode of exactly
|
||||
// this shape — it arms a canvas gesture and scopes the column — so it
|
||||
// joins this list and the `ViewMode` enum, and needs nothing else here.
|
||||
// `MaskSource::Brush` and the stroke calls on `MaskLayer` land in the
|
||||
// core separately; what is missing on this side is only the canvas
|
||||
// interaction, which is the gradient handles' neighbour.
|
||||
for entry in [
|
||||
{ label: "Crop", value: ViewMode.crop },
|
||||
{ label: "Local", value: ViewMode.local },
|
||||
]: 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 {
|
||||
width: 34px;
|
||||
height: Theme.touch-target;
|
||||
@@ -691,9 +793,8 @@ export component GroupStrip inherits Rectangle {
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
|
||||
// Underlined rather than filled: the accent means *modified*
|
||||
// everywhere else here, and spending it on "which group" would
|
||||
// blunt the one signal the panel has.
|
||||
// Underlined rather than filled: the accent is the mode chips'
|
||||
// now, and spending it on "which group" as well would blunt both.
|
||||
Rectangle {
|
||||
y: parent.height - 2px;
|
||||
height: 2px;
|
||||
@@ -702,11 +803,25 @@ export component GroupStrip inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component AdjustPanel inherits Rectangle {
|
||||
in property <[ParamRow]> rows;
|
||||
in property <bool> enabled: true;
|
||||
/// What these controls are pointed at — the whole photograph, or one mask
|
||||
/// layer by name.
|
||||
///
|
||||
/// **The heading, not a caption beside it.** Selecting a layer re-points
|
||||
/// every one of these controls at that layer's chain, and until now the
|
||||
/// only sign of it was a sentence in the panel above. Putting the answer
|
||||
/// in the heading of the thing that changed means the scope cannot be read
|
||||
/// without also reading what it applies to.
|
||||
///
|
||||
/// Supplied already resolved: whether a layer is selected and what it is
|
||||
/// called are session facts, and deriving them here would need this file
|
||||
/// to reason about the mask stack.
|
||||
in property <string> scope: "ADJUST";
|
||||
/// The tone curve's sampled shape, evaluated in Rust by the same spline
|
||||
/// the shader runs so the drawn line cannot disagree with the applied one.
|
||||
in property <[float]> curve-samples;
|
||||
@@ -737,8 +852,18 @@ export component AdjustPanel inherits Rectangle {
|
||||
alignment: start;
|
||||
|
||||
HorizontalLayout {
|
||||
PanelHeading { text: "ADJUST"; }
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
PanelHeading {
|
||||
text: root.scope;
|
||||
// Elided rather than wrapped. A layer's name is the user's and
|
||||
// can be any length; a heading that wrapped would change the
|
||||
// panel's height as the selection moved, and an unwrapped one
|
||||
// would set the 280px column's minimum width from it.
|
||||
overflow: elide;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
// No spacer: the heading takes the slack itself, so a long layer
|
||||
// name elides against the reset rather than pushing it off the
|
||||
// 280px column.
|
||||
reset := TouchArea {
|
||||
width: 44px;
|
||||
height: 20px;
|
||||
@@ -873,8 +998,12 @@ export component AdjustPanel inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
// No seam of its own.
|
||||
//
|
||||
// There was one — a 1px `rule` rectangle — and being a sized child of a
|
||||
// plain Rectangle with no position, Slint *centred* it: a hairline drawn
|
||||
// straight down the middle of the panel, through every slider in it. The
|
||||
// column that hosts this panel already draws the seam between itself and
|
||||
// the photograph, so the fix is one rule in one place rather than two that
|
||||
// were never both wanted.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user