Let a part be left out of a mask without being taken out of it

A layer built from parts was missing the one control a correction most
often wants: seeing what it did. The question a subtracted gradient
raises is whether it took only the sky, and the question a stroke raises
is whether it filled the shoulder — and the only way to ask either was
to remove the part and look, which answered the question and lost the
part. The layer's own ring answers a different question, about the
adjustment, and hiding eight layers to check one correction is not an
A/B anybody performs.

So a part carries `hidden`. It is an edit and a history step, as the
layer's switch is, and it is folded into the render fingerprint because
hiding a part changes the mask as surely as removing it does. Where the
mask is built the shown parts are walked rather than the parts, which
is what makes a hidden base hand the fold to the first part that is
shown — and a revealed layer whose every part is hidden clears its slice
rather than leaving whatever the last rasterisation put there to be
read back. `covers` asks the same shown parts, so a layer whose only
adding part is hidden costs no slice at all.

In the sidecar the key is `hidden`, in the part's block or, for the
base, in the mask block — under a word that cannot be confused with the
layer's `enabled`, which has always meant the layer. Absent means shown,
so no file written before the switch existed reads any differently.

The row wears the same ring the layer does, one row down, because it is
the same question about a smaller thing.
This commit is contained in:
2026-09-12 01:08:10 +02:00
parent 9cc52fd72b
commit 4574c35236
12 changed files with 298 additions and 51 deletions
+21 -2
View File
@@ -2938,7 +2938,7 @@ impl DevelopSession {
/// The join of the first is meaningless — there is nothing before it to
/// join to — and the panel shows it as the selection the layer *is*
/// rather than as a row with a chip that does nothing.
pub fn mask_parts(&self, id: &str) -> Vec<(String, String, usize)> {
pub fn mask_parts(&self, id: &str) -> Vec<(String, String, usize, bool)> {
let Some(layer) = self.graph.masks().get(id) else {
return Vec::new();
};
@@ -2950,11 +2950,30 @@ impl DevelopSession {
.iter()
.position(|&j| j == p.join)
.unwrap_or(0);
(p.id.clone(), p.source.kind().to_string(), join)
(p.id.clone(), p.source.kind().to_string(), join, p.hidden)
})
.collect()
}
/// TRACES: FR-DEV-19a
/// Leave one part out of the build, or put it back. An edit, and one
/// history step, for the same reason the layer's own switch is: the part
/// really is out until it is switched back.
pub fn set_mask_part_hidden(&mut self, id: &str, index: usize, hidden: bool) {
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
return;
};
let Some(part) = layer.part_mut(index) else {
return;
};
if part.hidden == hidden {
return;
}
part.hidden = hidden;
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_PART_TOGGLED));
}
/// TRACES: FR-DEV-19a
/// Join a fresh painted part to a layer, returning its index.
///
+7
View File
@@ -127,6 +127,13 @@ pub const GESTURES: &[Gesture] = &[
pointer: "Click the eye on its row",
keys: "",
},
Gesture {
title: "Leave one part out of a mask, and put it back",
section: "Develop",
touch: "Tap the ring on the part's row",
pointer: "Click the ring on the part's row",
keys: "",
},
Gesture {
title: "Pick a collection up to rearrange the tree",
section: "Collections sidebar",
+3
View File
@@ -71,6 +71,7 @@ pub mod step {
/// A selection joined to a mask, taken out of it, or joined the other way.
pub const MASK_PART_ADDED: LocalizedKey = LocalizedKey("history.mask_part_added");
pub const MASK_PART_REMOVED: LocalizedKey = LocalizedKey("history.mask_part_removed");
pub const MASK_PART_TOGGLED: LocalizedKey = LocalizedKey("history.mask_part_toggled");
pub const MASK_JOINED: LocalizedKey = LocalizedKey("history.mask_joined");
/// TRACES: FR-DEV-8
@@ -121,6 +122,7 @@ pub mod step {
MASK_ERASED,
MASK_PART_ADDED,
MASK_PART_REMOVED,
MASK_PART_TOGGLED,
MASK_JOINED,
SPOT,
SPOT_PLACED,
@@ -315,6 +317,7 @@ fn catalogued(key: &str) -> Option<&'static str> {
"history.mask_erased" => "Erase Mask",
"history.mask_part_added" => "Add To Mask",
"history.mask_part_removed" => "Remove From Mask",
"history.mask_part_toggled" => "Show Or Hide Part Of A Mask",
"history.mask_joined" => "Change How A Mask Joins",
"history.spot" => "Adjust Repair",
"history.spot_placed" => "Add Repair",
+22 -2
View File
@@ -275,12 +275,13 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
.mask_parts(id)
.into_iter()
.enumerate()
.map(|(i, (part_id, label, join))| PartRow {
.map(|(i, (part_id, label, join, hidden))| PartRow {
id: part_id.into(),
label: label.into(),
join: join as i32,
selected: i == s.active_part(),
base: i == 0,
hidden,
})
.collect(),
_ => Vec::new(),
@@ -665,7 +666,6 @@ pub(crate) fn wire(
redraw(&w);
});
}
{
{
// TRACES: FR-DEV-16
// The keyboard's copy of the ring. A mixed selection goes to shown:
@@ -694,6 +694,7 @@ pub(crate) fn wire(
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
@@ -1084,6 +1085,25 @@ pub(crate) fn wire(
redraw(&w);
});
}
{
// TRACES: FR-DEV-19a
// A part left out of the build. The picture changes and the row's
// eye does, so both are redrawn; the adjust rows are not, because
// the layer's chain is not what moved.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window
.global::<Masking>()
.on_part_hidden_toggled(move |id, index, hidden| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_part_hidden(&id, index.max(0) as usize, hidden);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
+40 -6
View File
@@ -96,6 +96,8 @@ export struct PartRow {
/// The selection the layer began as. It joins nothing and cannot be
/// removed — removing it is removing the layer.
base: bool,
/// Left out of the build, and kept. The row's eye.
hidden: bool,
}
/// Which part of a gradient a canvas handle drags.
@@ -174,6 +176,7 @@ component MaskEntry inherits Rectangle {
/// Point the tools at one part, change how it joins, or take it out.
callback part-selected(int);
callback part-join-picked(int, int);
callback part-hidden-toggled(int, bool);
callback part-removed(int);
/// Join a fresh painted correction: 0 adds, 1 subtracts.
callback part-added(int);
@@ -210,6 +213,7 @@ component MaskEntry inherits Rectangle {
// where: Develop
// touch: Tap the ring at the head of its row
// pointer: Click the ring at the head of its row
// keys: H, for the selected layer
// why: Disabling a layer is the before-and-after a local
// edit constantly wants, so it is one press away rather
// than inside the row. It is an edit and does take a
@@ -387,6 +391,33 @@ component MaskEntry inherits Rectangle {
horizontal-stretch: 1;
}
// GESTURE: Leave one part out of a mask, and put it back
// where: Develop
// touch: Tap the ring on the part's row
// pointer: Click the ring on the part's row
// why: The question a correction raises is whether
// it did what it was for — whether the stroke
// filled the shoulder, whether the subtracted
// gradient took only the sky. Removing it
// answers that and loses it. The same ring the
// layer wears, one row down, because it is the
// same question about a smaller thing.
//
// TRACES: FR-DEV-19a
part-eye := TouchArea {
width: Theme.touch-target;
mouse-cursor: pointer;
enabled: root.enabled;
clicked => { root.part-hidden-toggled(i, !part.hidden); }
Label {
text: part.hidden ? "○" : "◉";
emphasised: part-eye.has-hover || !part.hidden;
horizontal-alignment: center;
vertical-alignment: center;
}
}
if !part.base: cut := TouchArea {
width: Theme.touch-target;
mouse-cursor: pointer;
@@ -790,6 +821,12 @@ export global Masking {
callback mask-removed(string);
callback mask-refined(string);
callback mask-toggled(string, bool);
/// TRACES: FR-DEV-16
/// The same switch for whichever layers are selected, from the keyboard.
/// No id, because the selection is the session's and this file cannot
/// see it; Rust answers by flipping each selected layer through the same
/// path the ring takes, so it is one history step per layer either way.
callback selected-toggled();
callback mask-invert-toggled(string, bool);
callback mask-opacity-changed(string, float);
callback mask-feather-changed(string, float);
@@ -810,6 +847,8 @@ export global Masking {
/// TRACES: FR-DEV-19a
callback part-selected(string, int);
callback part-join-picked(string, int, int);
/// Whether the part at this index is left out of the build.
callback part-hidden-toggled(string, int, bool);
callback part-removed(string, int);
callback part-added(string, int);
@@ -820,12 +859,6 @@ export global Masking {
callback add-range(bool);
callback add-subject(int);
callback add-category(string);
/// TRACES: FR-DEV-16
/// The same switch for whichever layers are selected, from the keyboard.
/// No id, because the selection is the session's and this file cannot
/// see it; Rust answers by flipping each selected layer through the same
/// path the ring takes, so it is one history step per layer either way.
callback selected-toggled();
}
export component MaskPanel inherits Rectangle {
@@ -1155,6 +1188,7 @@ export component MaskPanel inherits Rectangle {
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-hidden-toggled(i, on) => { Masking.part-hidden-toggled(mask.id, i, on); }
part-removed(i) => { Masking.part-removed(mask.id, i); }
part-added(j) => { Masking.part-added(mask.id, j); }
shown-toggled(on) => { Masking.mask-shown-toggled(mask.id, on); }