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
+40 -2
View File
@@ -758,12 +758,24 @@ impl MaskPass {
// is done where it is read back rather than where it is drawn — // is done where it is read back rather than where it is drawn —
// a brush deposits dabs and cannot know what the rest of the // a brush deposits dabs and cannot know what the rest of the
// frame is. See `fs_combine`. // frame is. See `fs_combine`.
let direct = layer.parts().len() == 1 && !layer.base().invert; // TRACES: FR-DEV-19a
// The shown parts, not the parts: a hidden one is skipped here
// and nowhere else, and the first *shown* part is the one that
// opens the fold. Which can leave nothing — a revealed layer with
// every part hidden — and that clears the slice rather than
// leaving whatever the last rasterisation put there to be read
// back as this mask.
let shown: Vec<&dr_pipeline::mask::MaskPart> = layer.shown_parts().collect();
if shown.is_empty() {
self.clear_slice(&mut encoder, slot as u32);
continue;
}
let direct = shown.len() == 1 && !shown[0].invert;
if !direct { if !direct {
self.ensure_scratch(width, height)?; self.ensure_scratch(width, height)?;
} }
for (index, part) in layer.parts().iter().enumerate() { for (index, part) in shown.iter().copied().enumerate() {
let base = index == 0; let base = index == 0;
let field = match (&part.source, labels) { let field = match (&part.source, labels) {
(MaskSource::Regions { .. }, None) => { (MaskSource::Regions { .. }, None) => {
@@ -1369,6 +1381,32 @@ impl MaskPass {
pass.draw(0..3, 0..1); pass.draw(0..3, 0..1);
} }
/// Leave a layer's slice covering nothing.
///
/// A pass that clears and draws nothing, for the one case where a layer
/// reaches the array with no part to draw: every part hidden while the
/// layer is being revealed. The slice has to be written, because the
/// shader reads it whatever this function did.
fn clear_slice(&self, encoder: &mut wgpu::CommandEncoder, slot: u32) {
let target = self.slice_view(slot);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mask-clear-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
}
/// The texture a part is drawn in before it is joined. /// The texture a part is drawn in before it is joined.
/// ///
/// Allocated on the first mask that has more than one part and kept at the /// Allocated on the first mask that has more than one part and kept at the
+2
View File
@@ -1034,6 +1034,8 @@ impl EditGraph {
colour = hash_bytes(colour, part.join.name().as_bytes()); colour = hash_bytes(colour, part.join.name().as_bytes());
colour = hash_bytes(colour, format!("{:?}", part.source).as_bytes()); colour = hash_bytes(colour, format!("{:?}", part.source).as_bytes());
colour = mix(colour, u64::from(part.invert)); colour = mix(colour, u64::from(part.invert));
// Hiding a part changes the fold as surely as removing it.
colour = mix(colour, u64::from(part.hidden));
colour = hash_bytes(colour, part.falloff.name().as_bytes()); colour = hash_bytes(colour, part.falloff.name().as_bytes());
for v in [part.feather, part.morph_radius] { for v in [part.feather, part.morph_radius] {
colour = mix(colour, u64::from(crate::operation::canonical_bits(v))); colour = mix(colour, u64::from(crate::operation::canonical_bits(v)));
+59 -2
View File
@@ -984,6 +984,20 @@ pub struct MaskPart {
/// subtracted gradient keeps everything to one side of a line, where /// subtracted gradient keeps everything to one side of a line, where
/// inverting the layer keeps everything the layer did not select. /// inverting the layer keeps everything the layer did not select.
pub invert: bool, pub invert: bool,
/// TRACES: FR-DEV-19a
/// Left out of the build without being deleted.
///
/// The A/B a *part* wants is different from the layer's: the question is
/// not "what does this adjustment do" but "what did this correction do to
/// the selection" — whether the stroke that was meant to fill in a
/// shoulder did, or the subtracted gradient took the sky it was aimed at
/// and nothing else. Removing the part answers that and loses it; this
/// answers it and keeps it.
///
/// Hidden parts are skipped where the mask is built, so a hidden base
/// leaves the first shown part to open the fold — and a mask whose every
/// adding part is hidden covers nothing, exactly as if they were gone.
pub hidden: bool,
/// Half-width of the edge transition, as a fraction of the frame's /// Half-width of the edge transition, as a fraction of the frame's
/// **shorter edge**. /// **shorter edge**.
@@ -1066,6 +1080,7 @@ impl MaskPart {
join, join,
source, source,
invert: false, invert: false,
hidden: false,
// A small default rather than zero. A watershed boundary is exact // A small default rather than zero. A watershed boundary is exact
// to the pixel, and an adjustment that stops dead on one looks // to the pixel, and an adjustment that stops dead on one looks
// pasted on — the first thing anyone would reach for, so it is // pasted on — the first thing anyone would reach for, so it is
@@ -1280,6 +1295,7 @@ impl PartialEq for MaskPart {
&& self.join == other.join && self.join == other.join
&& self.source == other.source && self.source == other.source
&& self.invert == other.invert && self.invert == other.invert
&& self.hidden == other.hidden
&& self.feather == other.feather && self.feather == other.feather
&& self.falloff == other.falloff && self.falloff == other.falloff
&& self.morphology == other.morphology && self.morphology == other.morphology
@@ -1555,12 +1571,23 @@ impl MaskLayer {
/// whose other parts all subtract covers nothing however many of them /// whose other parts all subtract covers nothing however many of them
/// there are, and rasterising it would cost a slice to draw empty. /// there are, and rasterising it would cost a slice to draw empty.
fn covers(&self) -> bool { fn covers(&self) -> bool {
self.parts // A hidden part is not in the build, whichever way it joins — and a
.iter() // hidden base hands its role to the first part that is shown, which
// is why "adds" is asked of the shown parts rather than of index 0.
self.shown_parts()
.enumerate() .enumerate()
.any(|(i, p)| (i == 0 || p.join == Join::Union) && p.covers()) .any(|(i, p)| (i == 0 || p.join == Join::Union) && p.covers())
} }
/// TRACES: FR-DEV-19a
/// The parts that take part in the build, in order — every part that is
/// not [`MaskPart::hidden`]. The first of these opens the fold whatever
/// its index in [`Self::parts`], so a renderer walks this rather than
/// filtering for itself and getting the "first" wrong.
pub fn shown_parts(&self) -> impl Iterator<Item = &MaskPart> {
self.parts.iter().filter(|p| !p.hidden)
}
/// The operations in this layer's chain that reach the shader. /// The operations in this layer's chain that reach the shader.
/// ///
/// Neighbourhood operations are excluded, and not as an oversight. A /// Neighbourhood operations are excluded, and not as an oversight. A
@@ -2883,6 +2910,36 @@ mod tests {
// Parts // Parts
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// TRACES: FR-DEV-19a
/// A hidden part is out of the build and nothing else about it moves: it
/// is still there to be put back, and the parts that remain shown are
/// what the mask is made of — including which of them is first.
#[test]
fn a_hidden_part_leaves_the_build_and_stays_in_the_layer() {
let mut layer = MaskLayer::new("m1", MaskSource::brush());
layer.push_part(MaskPart::new("p2", Join::Union, MaskSource::highlights()));
layer.push_part(MaskPart::painted("p3", Join::Subtract));
layer.set_param("exposure", ParamId("exposure"), 0.5);
assert_eq!(layer.shown_parts().count(), 3);
assert!(layer.is_active(), "the range adds, so the mask covers");
layer.part_mut(1).expect("p2").hidden = true;
assert_eq!(layer.parts().len(), 3, "hidden is not removed");
let shown: Vec<&str> = layer.shown_parts().map(|p| p.id.as_str()).collect();
assert_eq!(shown, ["p1", "p3"]);
assert!(
!layer.is_active(),
"with the range out, an unpainted base and a subtraction cover nothing"
);
layer.part_mut(1).expect("p2").hidden = false;
layer.base_mut().hidden = true;
let shown: Vec<&str> = layer.shown_parts().map(|p| p.id.as_str()).collect();
assert_eq!(shown, ["p2", "p3"], "the range now opens the fold");
assert!(layer.is_active(), "and it still covers the picture");
}
/// The invariant everything else leans on: a layer always has a selection /// The invariant everything else leans on: a layer always has a selection
/// to be. Removing the base is removing the layer, and the panel must not /// to be. Removing the base is removing the layer, and the panel must not
/// be able to reach a state where a mask exists with nothing in it. /// be able to reach a state where a mask exists with nothing in it.
+16
View File
@@ -1121,6 +1121,13 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
if !layer.enabled { if !layer.enabled {
let _ = writeln!(out, "enabled = 0"); let _ = writeln!(out, "enabled = 0");
} }
// TRACES: FR-DEV-19a
// The base part's own switch, under a different word from the layer's:
// `enabled` in this block has always meant the layer, and a part that is
// out of the build is `hidden` wherever it is written, base or not.
if layer.base().hidden {
let _ = writeln!(out, "hidden = 1");
}
write_shaping(out, layer.base()); write_shaping(out, layer.base());
for (op, param, value) in layer.params() { for (op, param, value) in layer.params() {
let _ = writeln!(out, "{op}.{param} = {}", format_value(value)); let _ = writeln!(out, "{op}.{param} = {}", format_value(value));
@@ -1148,6 +1155,9 @@ fn write_part(out: &mut String, version: &str, layer: &str, part: &MaskPart) {
if part.invert { if part.invert {
let _ = writeln!(out, "invert = 1"); let _ = writeln!(out, "invert = 1");
} }
if part.hidden {
let _ = writeln!(out, "hidden = 1");
}
write_shaping(out, part); write_shaping(out, part);
write_coverage(out, part); write_coverage(out, part);
} }
@@ -1411,6 +1421,7 @@ struct PartialPart {
/// A colour range's arc: centre and half-width, in turns. /// A colour range's arc: centre and half-width, in turns.
hue: (f32, f32), hue: (f32, f32),
invert: bool, invert: bool,
hidden: bool,
/// The *part's* edge transition, distinct from the radial source's own /// The *part's* edge transition, distinct from the radial source's own
/// `feather` above — different quantity, different units, different key. /// `feather` above — different quantity, different units, different key.
edge_feather: f32, edge_feather: f32,
@@ -1446,6 +1457,7 @@ impl PartialPart {
band: (0.5, 1.0, crate::mask::DEFAULT_RANGE_SOFTNESS), band: (0.5, 1.0, crate::mask::DEFAULT_RANGE_SOFTNESS),
hue: (0.06, 0.05), hue: (0.06, 0.05),
invert: false, invert: false,
hidden: false,
edge_feather: DEFAULT_FEATHER, edge_feather: DEFAULT_FEATHER,
falloff: Falloff::default(), falloff: Falloff::default(),
morphology: Morphology::default(), morphology: Morphology::default(),
@@ -1510,6 +1522,9 @@ impl PartialPart {
// and the file's line order is the order they were painted in. // and the file's line order is the order they were painted in.
"stroke" => self.strokes.extend(parse_stroke(value)), "stroke" => self.strokes.extend(parse_stroke(value)),
"invert" => self.invert = value != "0", "invert" => self.invert = value != "0",
// Absent means shown, so a file from before the switch existed
// reads back with every part in the build, as it was written.
"hidden" => self.hidden = value != "0",
// Clamped, not trusted: a feather wider than the frame is not a // Clamped, not trusted: a feather wider than the frame is not a
// mask, and a negative one is a distance field read backwards. // mask, and a negative one is a distance field read backwards.
"edge-feather" => { "edge-feather" => {
@@ -1611,6 +1626,7 @@ impl PartialPart {
let mut part = MaskPart::new(self.id, self.join, source); let mut part = MaskPart::new(self.id, self.join, source);
part.invert = self.invert; part.invert = self.invert;
part.hidden = self.hidden;
part.feather = self.edge_feather; part.feather = self.edge_feather;
part.falloff = self.falloff; part.falloff = self.falloff;
part.morphology = self.morphology; part.morphology = self.morphology;
+42
View File
@@ -1344,6 +1344,48 @@ fn a_correction_painted_onto_a_subject_survives_a_round_trip() {
); );
} }
/// TRACES: FR-DEV-19a
/// A part left out of the build comes back left out, and the file says so
/// under a word that cannot be confused with the layer's own switch.
#[test]
fn a_hidden_part_stays_hidden_across_a_round_trip() {
let mut graph = EditGraph::default_chain();
let mut layer = corrected("m1", Join::Subtract);
layer.part_mut(1).expect("the correction").hidden = true;
graph.masks_mut().push(layer);
let mut sidecar = Sidecar::new();
sidecar.put(Version::from_graph("default", "Default", &graph));
let text = sidecar.to_text();
assert!(text.contains("hidden = 1"), "{text}");
assert!(
!text.contains("enabled = 0"),
"a hidden part must not read as a disabled layer:\n{text}"
);
let restored = round_trip(&graph);
let layer = &restored.masks().layers()[0];
assert!(layer.parts()[1].hidden, "the correction is still out");
assert!(!layer.parts()[0].hidden, "and the base is still in");
assert!(layer.enabled, "and the layer itself was never switched off");
}
/// TRACES: FR-DEV-19a
/// The base is a part like any other for this: it can be left out, and the
/// key lands in the mask block since that is where the base is written.
#[test]
fn a_hidden_base_is_written_into_the_mask_block() {
let mut graph = EditGraph::default_chain();
let mut layer = corrected("m1", Join::Union);
layer.base_mut().hidden = true;
graph.masks_mut().push(layer);
let restored = round_trip(&graph);
let layer = &restored.masks().layers()[0];
assert!(layer.parts()[0].hidden, "the base came back hidden");
assert!(!layer.parts()[1].hidden, "and only the base");
}
/// A part carries its own edge, which is the whole reason it is a part rather /// A part carries its own edge, which is the whole reason it is a part rather
/// than a second source on the layer: a model's soft coverage and a stroke /// than a second source on the layer: a model's soft coverage and a stroke
/// painted where it stopped short want different boundaries. /// painted where it stopped short want different boundaries.
+12 -3
View File
@@ -5,7 +5,7 @@
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it. Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
37 gestures, in 4 places. 38 gestures, in 4 places.
## Develop ## Develop
@@ -141,7 +141,7 @@ The column is 280px wide and the colour mixer alone puts thirty-six of these in
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 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
<sub>`ui/dr-ui/ui/masks.slint:209`</sub> <sub>`ui/dr-ui/ui/masks.slint:212`</sub>
### Show or hide one mask on the photograph ### Show or hide one mask on the photograph
@@ -150,7 +150,16 @@ Disabling a layer is the before-and-after a local edit constantly wants, so it i
A mask is judged by seeing where it falls, and two are judged by seeing where they meet — so each row has its own eye rather than the panel having one, and the eye is drawn in the colour the mask shows in, so the row says which shape on the picture is its. Nothing about the edit changes: this is how the photograph is looked at, and takes no history step. A mask is judged by seeing where it falls, and two are judged by seeing where they meet — so each row has its own eye rather than the panel having one, and the eye is drawn in the colour the mask shows in, so the row says which shape on the picture is its. Nothing about the edit changes: this is how the photograph is looked at, and takes no history step.
<sub>`ui/dr-ui/ui/masks.slint:275`</sub> <sub>`ui/dr-ui/ui/masks.slint:279`</sub>
### Leave one part out of a mask, and put it back
- **Touch** — Tap the ring on the part's row
- **Pointer** — Click the ring on the part's row
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.
<sub>`ui/dr-ui/ui/masks.slint:394`</sub>
## Collections sidebar ## Collections sidebar
+34 -34
View File
File diff suppressed because one or more lines are too long
+21 -2
View File
@@ -2938,7 +2938,7 @@ impl DevelopSession {
/// The join of the first is meaningless — there is nothing before it to /// 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* /// join to — and the panel shows it as the selection the layer *is*
/// rather than as a row with a chip that does nothing. /// 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 { let Some(layer) = self.graph.masks().get(id) else {
return Vec::new(); return Vec::new();
}; };
@@ -2950,11 +2950,30 @@ impl DevelopSession {
.iter() .iter()
.position(|&j| j == p.join) .position(|&j| j == p.join)
.unwrap_or(0); .unwrap_or(0);
(p.id.clone(), p.source.kind().to_string(), join) (p.id.clone(), p.source.kind().to_string(), join, p.hidden)
}) })
.collect() .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 /// TRACES: FR-DEV-19a
/// Join a fresh painted part to a layer, returning its index. /// 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", pointer: "Click the eye on its row",
keys: "", 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 { Gesture {
title: "Pick a collection up to rearrange the tree", title: "Pick a collection up to rearrange the tree",
section: "Collections sidebar", 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. /// 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_ADDED: LocalizedKey = LocalizedKey("history.mask_part_added");
pub const MASK_PART_REMOVED: LocalizedKey = LocalizedKey("history.mask_part_removed"); 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"); pub const MASK_JOINED: LocalizedKey = LocalizedKey("history.mask_joined");
/// TRACES: FR-DEV-8 /// TRACES: FR-DEV-8
@@ -121,6 +122,7 @@ pub mod step {
MASK_ERASED, MASK_ERASED,
MASK_PART_ADDED, MASK_PART_ADDED,
MASK_PART_REMOVED, MASK_PART_REMOVED,
MASK_PART_TOGGLED,
MASK_JOINED, MASK_JOINED,
SPOT, SPOT,
SPOT_PLACED, SPOT_PLACED,
@@ -315,6 +317,7 @@ fn catalogued(key: &str) -> Option<&'static str> {
"history.mask_erased" => "Erase Mask", "history.mask_erased" => "Erase Mask",
"history.mask_part_added" => "Add To Mask", "history.mask_part_added" => "Add To Mask",
"history.mask_part_removed" => "Remove From 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.mask_joined" => "Change How A Mask Joins",
"history.spot" => "Adjust Repair", "history.spot" => "Adjust Repair",
"history.spot_placed" => "Add 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) .mask_parts(id)
.into_iter() .into_iter()
.enumerate() .enumerate()
.map(|(i, (part_id, label, join))| PartRow { .map(|(i, (part_id, label, join, hidden))| PartRow {
id: part_id.into(), id: part_id.into(),
label: label.into(), label: label.into(),
join: join as i32, join: join as i32,
selected: i == s.active_part(), selected: i == s.active_part(),
base: i == 0, base: i == 0,
hidden,
}) })
.collect(), .collect(),
_ => Vec::new(), _ => Vec::new(),
@@ -665,7 +666,6 @@ pub(crate) fn wire(
redraw(&w); redraw(&w);
}); });
} }
{
{ {
// TRACES: FR-DEV-16 // TRACES: FR-DEV-16
// The keyboard's copy of the ring. A mixed selection goes to shown: // The keyboard's copy of the ring. A mixed selection goes to shown:
@@ -694,6 +694,7 @@ pub(crate) fn wire(
redraw(&w); redraw(&w);
}); });
} }
{
let weak = window.as_weak(); let weak = window.as_weak();
let session = session.clone(); let session = session.clone();
let redraw = redraw.clone(); let redraw = redraw.clone();
@@ -1084,6 +1085,25 @@ pub(crate) fn wire(
redraw(&w); 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 weak = window.as_weak();
let session = session.clone(); 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 /// The selection the layer began as. It joins nothing and cannot be
/// removed — removing it is removing the layer. /// removed — removing it is removing the layer.
base: bool, base: bool,
/// Left out of the build, and kept. The row's eye.
hidden: bool,
} }
/// Which part of a gradient a canvas handle drags. /// 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. /// Point the tools at one part, change how it joins, or take it out.
callback part-selected(int); callback part-selected(int);
callback part-join-picked(int, int); callback part-join-picked(int, int);
callback part-hidden-toggled(int, bool);
callback part-removed(int); callback part-removed(int);
/// Join a fresh painted correction: 0 adds, 1 subtracts. /// Join a fresh painted correction: 0 adds, 1 subtracts.
callback part-added(int); callback part-added(int);
@@ -210,6 +213,7 @@ component MaskEntry inherits Rectangle {
// where: Develop // where: Develop
// touch: Tap the ring at the head of its row // touch: Tap the ring at the head of its row
// pointer: Click 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 // why: Disabling a layer is the before-and-after a local
// edit constantly wants, so it is one press away rather // edit constantly wants, so it is one press away rather
// than inside the row. It is an edit and does take a // than inside the row. It is an edit and does take a
@@ -387,6 +391,33 @@ component MaskEntry inherits Rectangle {
horizontal-stretch: 1; 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 { if !part.base: cut := TouchArea {
width: Theme.touch-target; width: Theme.touch-target;
mouse-cursor: pointer; mouse-cursor: pointer;
@@ -790,6 +821,12 @@ export global Masking {
callback mask-removed(string); callback mask-removed(string);
callback mask-refined(string); callback mask-refined(string);
callback mask-toggled(string, bool); 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-invert-toggled(string, bool);
callback mask-opacity-changed(string, float); callback mask-opacity-changed(string, float);
callback mask-feather-changed(string, float); callback mask-feather-changed(string, float);
@@ -810,6 +847,8 @@ export global Masking {
/// TRACES: FR-DEV-19a /// TRACES: FR-DEV-19a
callback part-selected(string, int); callback part-selected(string, int);
callback part-join-picked(string, int, 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-removed(string, int);
callback part-added(string, int); callback part-added(string, int);
@@ -820,12 +859,6 @@ export global Masking {
callback add-range(bool); callback add-range(bool);
callback add-subject(int); callback add-subject(int);
callback add-category(string); 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 { export component MaskPanel inherits Rectangle {
@@ -1155,6 +1188,7 @@ export component MaskPanel inherits Rectangle {
parts: mask.selected ? Masking.parts : []; parts: mask.selected ? Masking.parts : [];
part-selected(i) => { Masking.part-selected(mask.id, i); } part-selected(i) => { Masking.part-selected(mask.id, i); }
part-join-picked(i, j) => { Masking.part-join-picked(mask.id, i, j); } 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-removed(i) => { Masking.part-removed(mask.id, i); }
part-added(j) => { Masking.part-added(mask.id, j); } part-added(j) => { Masking.part-added(mask.id, j); }
shown-toggled(on) => { Masking.mask-shown-toggled(mask.id, on); } shown-toggled(on) => { Masking.mask-shown-toggled(mask.id, on); }