Wire the develop panels through globals, so a second copy is one line

Every panel in the develop column declared its inputs and its callbacks and
had `app.slint` bind each one to a property or a callback on the window root.
That is fine while a panel is drawn once. N9 draws them a second time, in the
portrait dock, and the wiring is what would have to be copied: `MaskPanel`
alone ran to forty lines of forwarding, and a callback added to one copy and
not the other compiles, renders, and simply does nothing on the layout nobody
was looking at.

So the wiring moved to Slint globals. A panel reads the global and calls the
global; Rust hooks the global instead of the window; and the instantiation in
the column is now the panel's name and a pair of braces — every one of the ten
children of the column, with no property that differs by placement left to
supply.

There is a global per panel family rather than one for all of them, and the
reason is an import cycle. Each panel's model struct — `ParamRow`, `MaskRow`,
`HistogramView` — is declared in the panel's own file, so a single global
holding `[MaskRow]` and `[ParamRow]` would have to live in a file importing
`masks.slint` and `adjust.slint` while both imported the global back, which
Slint rejects. Breaking that needs six model declarations relocated, which is a
change to the data model and not to the plumbing this is about. A global beside
the panel it serves also lets each name drop the prefix it was carrying only
because the window root is one flat namespace: `root.spot-radius` is
`Repair.radius`, and `root.peaking-on` is `Peaking.showing`.

`session.slint` is new and holds the two facts every family needs and none of
them owns: whether there is an open photograph to edit, and which mode the view
is in, with the three readings of the mode derived once instead of at each of
the dozen places that tested one. `ViewMode` moves there from `adjust.slint`,
where it was only ever a lodger.

Nothing on screen changes. What is not here: the tool rail and the status strip
still take their properties at the instantiation, because they are drawn once
and N9 does not copy them; the preset sheet's own state stays on the window,
because the library grid opens the same sheet and a global cannot bind the
window's state — which is why `Transfer.open-presets` is handled in
`presets.rs`, beside the summary it already had to compute.
This commit is contained in:
2026-09-07 20:01:01 +02:00
parent dd14243dba
commit 0a5eab0487
17 changed files with 1245 additions and 1341 deletions
+98 -76
View File
@@ -7,6 +7,7 @@
// added to `ops/` would need a local twin.
import { Theme } from "theme.slint";
import { Develop } from "session.slint";
import { Button, Caption, IconButton, Label, PanelHeading, Value } from "widgets.slint";
import { Segmented, SliderRow } from "controls.slint";
@@ -550,8 +551,22 @@ component MaskEntry inherits Rectangle {
}
}
export component MaskPanel inherits Rectangle {
in property <bool> enabled: true;
/// TRACES: FR-DEV-3
/// The mask stack, what the model found, and everything that can be done to a
/// layer.
///
/// A global rather than eight properties and eighteen callbacks on the panel.
/// `session.slint` gives the general argument; this panel is the case that
/// made it, because its instantiation in `app.slint` ran to forty lines of
/// forwarding — the longest in the file, and every line of it saying the same
/// thing twice.
///
/// The overlay is the other half of the reason. `overlay-hidden` was a
/// two-way binding between the panel and the window because the panel is where
/// it is toggled and the canvas is what obeys it; neither owned it, so it was
/// declared twice and tied together at the instantiation. Here it is declared
/// once and both read it, which is what a two-way binding was standing in for.
export global Masking {
in property <[MaskRow]> masks;
/// TRACES: FR-DEV-19a
/// The parts of the selected layer. Empty when none is selected, or when
@@ -575,12 +590,17 @@ export component MaskPanel inherits Rectangle {
/// A refine pass is running for one subject layer.
in property <bool> refining: false;
/// Whether the photographer has hidden the overlay to see the photograph.
/// Two-way bound to the window's own, so the canvas and this control never
/// disagree about it.
/// TRACES: FR-DEV-3
/// The photographer's switch for the region overlay, kept separate from
/// the window's `overlay-on` because the two answer different questions:
/// `overlay-on` is "is there a mask to draw", which Rust decides, and this
/// is "do I want to look at it right now", which only they can.
///
/// Folding them into one property would mean the next recompute — any
/// mask edit — silently switched the overlay back on under someone who had
/// just turned it off to check their work against the photograph.
in-out property <bool> overlay-hidden: false;
/// `true` asks for the slower, tiled pass.
callback segment(bool);
@@ -614,7 +634,9 @@ export component MaskPanel inherits Rectangle {
callback add-range(bool);
callback add-subject(int);
callback add-category(string);
}
export component MaskPanel inherits Rectangle {
background: Theme.surface;
// **Flat, deliberately.** An earlier version grouped each state into its
@@ -632,11 +654,11 @@ export component MaskPanel inherits Rectangle {
HorizontalLayout {
PanelHeading { text: "MASKS"; }
Rectangle { horizontal-stretch: 1; }
if root.segmented: Value { text: root.subjects.length + (root.subjects.length == 1 ? " subject" : " subjects");
if Masking.segmented: Value { text: Masking.subjects.length + (Masking.subjects.length == 1 ? " subject" : " subjects");
}
}
if !root.enabled: Caption { text: "No image"; }
if !Develop.enabled: Caption { text: "No image"; }
// TRACES: FR-DEV-19b
// **In the panel and not in the tool rail.** The rail's entries arm a
@@ -644,43 +666,43 @@ export component MaskPanel inherits Rectangle {
// mask to paint into, and a rail entry that lit up with nothing
// selected would either do nothing or silently make a layer. Here the
// strip can simply be absent until there is something to paint on.
if root.enabled && root.parts.length > 0: Segmented {
if Develop.enabled && Masking.parts.length > 0: Segmented {
options: ["Select", "Paint", "Erase"];
selected: root.tool;
picked(i) => { root.tool-picked(i); }
selected: Masking.tool;
picked(i) => { Masking.tool-picked(i); }
}
if root.enabled && root.tool > 0: SliderRow {
if Develop.enabled && Masking.tool > 0: SliderRow {
label: "Size";
value: root.brush-radius;
value: Masking.brush-radius;
default-value: 0.05;
minimum: 0.005;
maximum: 0.4;
precision: 3;
changed(v) => { root.brush-changed(v, root.brush-hardness, root.brush-flow); }
reset => { root.brush-changed(0.05, root.brush-hardness, root.brush-flow); }
changed(v) => { Masking.brush-changed(v, Masking.brush-hardness, Masking.brush-flow); }
reset => { Masking.brush-changed(0.05, Masking.brush-hardness, Masking.brush-flow); }
}
if root.enabled && root.tool > 0: SliderRow {
if Develop.enabled && Masking.tool > 0: SliderRow {
label: "Hardness";
value: root.brush-hardness;
value: Masking.brush-hardness;
default-value: 0.5;
minimum: 0.0;
maximum: 1.0;
precision: 2;
changed(v) => { root.brush-changed(root.brush-radius, v, root.brush-flow); }
reset => { root.brush-changed(root.brush-radius, 0.5, root.brush-flow); }
changed(v) => { Masking.brush-changed(Masking.brush-radius, v, Masking.brush-flow); }
reset => { Masking.brush-changed(Masking.brush-radius, 0.5, Masking.brush-flow); }
}
if root.enabled && root.tool > 0: SliderRow {
if Develop.enabled && Masking.tool > 0: SliderRow {
label: "Flow";
value: root.brush-flow;
value: Masking.brush-flow;
default-value: 1.0;
minimum: 0.05;
maximum: 1.0;
precision: 2;
changed(v) => { root.brush-changed(root.brush-radius, root.brush-hardness, v); }
reset => { root.brush-changed(root.brush-radius, root.brush-hardness, 1.0); }
changed(v) => { Masking.brush-changed(Masking.brush-radius, Masking.brush-hardness, v); }
reset => { Masking.brush-changed(Masking.brush-radius, Masking.brush-hardness, 1.0); }
}
// --- the region map ---------------------------------------------
@@ -688,7 +710,7 @@ export component MaskPanel inherits Rectangle {
// Segmentation is a thing the user starts. Half a second of watershed
// and inference is too long to spend on every photograph opened, and
// most edits never need one.
if root.enabled && !root.segmented: Caption {
if Develop.enabled && !Masking.segmented: Caption {
text: "Find the subjects in this photograph, so a mask can follow one.";
// Wrapped, or this sentence's single-line width becomes the
// panel's minimum and levers the fixed 280px develop column open,
@@ -696,11 +718,11 @@ export component MaskPanel inherits Rectangle {
wrap: word-wrap;
}
if root.enabled && !root.segmented: Button {
text: root.segmenting ? "Looking…" : "Find subjects";
enabled: !root.segmenting;
if Develop.enabled && !Masking.segmented: Button {
text: Masking.segmenting ? "Looking…" : "Find subjects";
enabled: !Masking.segmenting;
primary: true;
clicked => { root.segment(false); }
clicked => { Masking.segment(false); }
}
// No "Overlay" button and no "Select" button. Both switched on things
@@ -708,7 +730,7 @@ export component MaskPanel inherits Rectangle {
// region map and makes a click on the photograph mean "select". A mode
// whose behaviour has to be armed separately is a mode that can be
// entered and still do nothing, which is what these two allowed.
if root.enabled && root.segmented: Caption {
if Develop.enabled && Masking.segmented: Caption {
text: "Click a subject in the photograph to mask it.";
wrap: word-wrap;
}
@@ -724,9 +746,9 @@ export component MaskPanel inherits Rectangle {
//
// Reads as its own action rather than its state: "Show the mask" is
// what pressing it will do, not what is currently true.
if root.enabled && root.segmented: Button {
text: root.overlay-hidden ? "Show the mask" : "Hide the mask";
clicked => { root.overlay-hidden = !root.overlay-hidden; }
if Develop.enabled && Masking.segmented: Button {
text: Masking.overlay-hidden ? "Show the mask" : "Hide the mask";
clicked => { Masking.overlay-hidden = !Masking.overlay-hidden; }
}
// The second pass, offered rather than taken automatically.
@@ -743,17 +765,17 @@ export component MaskPanel inherits Rectangle {
// photograph, where the subject fills much of the frame and the first
// pass is already the best answer available — so it is a button and
// not a default, and it says what it costs.
if root.enabled && root.segmented: Button {
text: root.segmenting ? "Looking closer…" : "Look closer (slower)";
enabled: !root.segmenting;
clicked => { root.segment(true); }
if Develop.enabled && Masking.segmented: Button {
text: Masking.segmenting ? "Looking closer…" : "Look closer (slower)";
enabled: !Masking.segmenting;
clicked => { Masking.segment(true); }
}
// Granularity, labelled by what it does rather than by its number:
// "detail" is what a photographer is choosing between, where "300
// regions" is an implementation detail they would have to learn.
// --- what the model found ----------------------------------------
if root.enabled && root.segmented && root.subjects.length > 0: Caption {
if Develop.enabled && Masking.segmented && Masking.subjects.length > 0: Caption {
// Says where these came from and how they differ from a region.
// A list of four beside a count of three thousand invites exactly
// one question, and the panel should answer it rather than
@@ -762,12 +784,12 @@ export component MaskPanel inherits Rectangle {
wrap: word-wrap;
}
if root.enabled && root.segmented: VerticalLayout {
if Develop.enabled && Masking.segmented: VerticalLayout {
spacing: 0px;
for subject in root.subjects: subject-row := TouchArea {
for subject in Masking.subjects: subject-row := TouchArea {
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { root.add-subject(subject.index); }
clicked => { Masking.add-subject(subject.index); }
HorizontalLayout {
spacing: Theme.gap-sm;
@@ -813,17 +835,17 @@ export component MaskPanel inherits Rectangle {
// clicking the photograph is how a local adjustment usually starts, so
// the things that were *found* come first. Categories are the move you
// reach for deliberately — grade the sky, not this one bird.
if root.enabled && root.segmented && root.categories.length > 0: Caption {
if Develop.enabled && Masking.segmented && Masking.categories.length > 0: Caption {
text: "Or grade a whole category. One adjustment covers every pixel of it.";
wrap: word-wrap;
}
if root.enabled && root.segmented: VerticalLayout {
if Develop.enabled && Masking.segmented: VerticalLayout {
spacing: 0px;
for category in root.categories: category-row := TouchArea {
for category in Masking.categories: category-row := TouchArea {
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { root.add-category(category.name); }
clicked => { Masking.add-category(category.name); }
HorizontalLayout {
spacing: Theme.gap-sm;
@@ -856,7 +878,7 @@ export component MaskPanel inherits Rectangle {
}
}
if root.enabled && root.segmented && root.subjects.length == 0: Caption {
if Develop.enabled && Masking.segmented && Masking.subjects.length == 0: Caption {
text: "Nothing recognised. The model knows people, animals and vehicles — "
+ "a landscape has no subject for it to find. Add a gradient instead.";
wrap: word-wrap;
@@ -878,13 +900,13 @@ export component MaskPanel inherits Rectangle {
Button {
text: "Linear";
enabled: root.enabled;
clicked => { root.add-gradient(false); }
enabled: Develop.enabled;
clicked => { Masking.add-gradient(false); }
}
Button {
text: "Radial";
enabled: root.enabled;
clicked => { root.add-gradient(true); }
enabled: Develop.enabled;
clicked => { Masking.add-gradient(true); }
}
}
@@ -901,51 +923,51 @@ export component MaskPanel inherits Rectangle {
Button {
text: "Tone";
enabled: root.enabled;
clicked => { root.add-range(false); }
enabled: Develop.enabled;
clicked => { Masking.add-range(false); }
}
Button {
text: "Colour";
enabled: root.enabled;
clicked => { root.add-range(true); }
enabled: Develop.enabled;
clicked => { Masking.add-range(true); }
}
}
if root.enabled: Caption {
if Develop.enabled: Caption {
text: "A tone or colour mask follows the photograph's own values, "
+ "so the edit blends where the picture does.";
wrap: word-wrap;
}
if root.enabled && root.masks.length == 0: Caption {
if Develop.enabled && Masking.masks.length == 0: Caption {
text: "No local adjustments. Select a region, or add a gradient.";
wrap: word-wrap;
}
for mask in root.masks: MaskEntry {
for mask in Masking.masks: MaskEntry {
data: mask;
enabled: root.enabled;
refining: root.refining;
selected(extend) => { root.mask-selected(mask.id, extend); }
removed => { root.mask-removed(mask.id); }
refined => { root.mask-refined(mask.id); }
toggled(on) => { root.mask-toggled(mask.id, on); }
invert-toggled(on) => { root.mask-invert-toggled(mask.id, on); }
opacity-changed(v) => { root.mask-opacity-changed(mask.id, v); }
feather-changed(v) => { root.mask-feather-changed(mask.id, v); }
refine-changed(v) => { root.mask-refine-changed(mask.id, v); }
falloff-picked(i) => { root.mask-falloff-picked(mask.id, i); }
morphology-picked(i) => { root.mask-morphology-picked(mask.id, i); }
morph-radius-changed(v) => { root.mask-morph-radius-changed(mask.id, v); }
band-changed(lo, hi, soft) => { root.mask-band-changed(mask.id, lo, hi, soft); }
hue-changed(h, w) => { root.mask-hue-changed(mask.id, h, w); }
enabled: Develop.enabled;
refining: Masking.refining;
selected(extend) => { Masking.mask-selected(mask.id, extend); }
removed => { Masking.mask-removed(mask.id); }
refined => { Masking.mask-refined(mask.id); }
toggled(on) => { Masking.mask-toggled(mask.id, on); }
invert-toggled(on) => { Masking.mask-invert-toggled(mask.id, on); }
opacity-changed(v) => { Masking.mask-opacity-changed(mask.id, v); }
feather-changed(v) => { Masking.mask-feather-changed(mask.id, v); }
refine-changed(v) => { Masking.mask-refine-changed(mask.id, v); }
falloff-picked(i) => { Masking.mask-falloff-picked(mask.id, i); }
morphology-picked(i) => { Masking.mask-morphology-picked(mask.id, i); }
morph-radius-changed(v) => { Masking.mask-morph-radius-changed(mask.id, v); }
band-changed(lo, hi, soft) => { Masking.mask-band-changed(mask.id, lo, hi, soft); }
hue-changed(h, w) => { Masking.mask-hue-changed(mask.id, h, w); }
// Only the selected row is given any, so seven unselected stacks
// of parts are not built and drawn behind the one being worked on.
parts: mask.selected ? root.parts : [];
part-selected(i) => { root.part-selected(mask.id, i); }
part-join-picked(i, j) => { root.part-join-picked(mask.id, i, j); }
part-removed(i) => { root.part-removed(mask.id, i); }
part-added(j) => { root.part-added(mask.id, j); }
parts: mask.selected ? Masking.parts : [];
part-selected(i) => { Masking.part-selected(mask.id, i); }
part-join-picked(i, j) => { Masking.part-join-picked(mask.id, i, j); }
part-removed(i) => { Masking.part-removed(mask.id, i); }
part-added(j) => { Masking.part-added(mask.id, j); }
}
// No caption saying which chain the sliders below are pointed at.