Give every mask an eye and a colour, and put the brush where the mask is

The first build of seeing a mask showed the selected layer's, in one global
style, from a strip at the top of the panel. It answered the wrong question and
answered it somewhere nobody looked. What a photographer asks of two masks is
how they meet — where the sky's edge sits against the building's — and that
needs both on screen at once, in colours that can be told apart.

So each row of the stack has an eye, drawn in the colour its mask is shown in,
and each mask has six swatches to choose that colour from. Several can be open
at once; a new one comes up open, in the first colour nothing else is using.
The style — tint, alpha, outline — is the one setting that stays global, above
the stack, because three styles at once are three pictures that cannot be read
against each other. Alpha now draws every shown mask, each in its colour, on
black. In the pipeline a `Reveal` is a list of `(layer, colour)` rather than
one layer, and every reveal block carries its own colour.

The brush moves too. Select, Paint and Erase and the three sliders under them
sat at the top of the panel, appeared only once a row was selected, and said
nothing about which mask they acted on — so "how do I paint" and "how do I
correct the model's outline" both had the same answer and nobody found it.
They sit under the selected mask's parts now, beside the swatches, and on a
subject or a category the hint says what a stroke there does: it becomes a
part of this mask, joined to the model's, and can be taken out again.

Eyes and colours are viewing state, on the session and not on the layer, so a
photograph reopened has every eye closed — the stored-mask round-trip test
asserts it.
This commit is contained in:
2026-09-11 19:03:51 +02:00
parent 936490880b
commit a87139b838
12 changed files with 759 additions and 287 deletions
+183 -64
View File
@@ -683,6 +683,32 @@ impl CropAspect {
}
}
/// TRACES: FR-DEV-19c
/// How one layer's mask is shown on the canvas.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct MaskView {
shown: bool,
/// Index into [`MASK_COLOURS`].
colour: usize,
}
/// TRACES: FR-DEV-19c
/// The colours a mask may be shown in, in linear sRGB.
///
/// Six, chosen to be told apart at half strength over a photograph rather
/// than to be pretty: red and green and blue at the corners, and the three
/// between them. Exposed so the panel draws its swatches from the same table
/// the shader is handed, and a seventh colour is one line here and nowhere
/// else.
pub const MASK_COLOURS: [[f32; 3]; 6] = [
[0.85, 0.10, 0.15],
[0.15, 0.80, 0.25],
[0.20, 0.45, 1.00],
[0.95, 0.80, 0.10],
[0.90, 0.20, 0.85],
[0.15, 0.85, 0.90],
];
pub struct DevelopSession {
/// This session's name, for work that outlives the frame it started on.
id: SessionId,
@@ -866,19 +892,27 @@ pub struct DevelopSession {
/// Whether to draw the false-coloured region overlay.
show_overlay: bool,
/// TRACES: FR-DEV-19c
/// How the selected layer's mask is being shown, or `None` for not at all.
/// How shown masks are drawn — one style for all of them.
///
/// Interface state, like `show_overlay` beside it and `active_masks` above
/// — it changes no pixel of the photograph, it is not in the sidecar and
/// it is not on the undo stack. It reaches the pipeline as an argument to
/// the one composition that draws the canvas, which is what makes an
/// export structurally unable to carry it (`EditGraph::compose_revealing`).
reveal_style: dr_pipeline::mask::RevealStyle,
/// TRACES: FR-DEV-19c
/// Per layer: whether its mask is shown, and in what colour.
///
/// The *style* is stored and not the layer, because the layer is always
/// the selected one: a reveal that stayed pointed at a mask nobody was
/// working on would be describing the wrong thing every time the selection
/// moved, and there is no gesture that wants it.
reveal_style: Option<dr_pipeline::mask::RevealStyle>,
/// Per layer rather than "the selected one", because the question a
/// photographer asks of two masks is how they meet — where the sky's edge
/// sits against the building's — and that needs both on screen at once,
/// in colours that can be told apart. Keyed by id, and an id that is no
/// longer in the stack is simply never asked for; `reveal` walks the
/// stack, not this map.
///
/// Viewing state and not edit state, for the reason the style is: it does
/// not travel in a sidecar, so a photograph reopened has every eye closed.
mask_views: std::collections::HashMap<String, MaskView>,
/// Which attribute the panel is filtered to, or all of them.
///
/// `None` is "show everything" and is what a frontend that ignores
@@ -1034,7 +1068,8 @@ impl DevelopSession {
),
selected_spot: None,
show_overlay: false,
reveal_style: None,
reveal_style: dr_pipeline::mask::RevealStyle::Tint,
mask_views: std::collections::HashMap::new(),
active_tab: None,
curve_channel: 0,
display_space: dr_types::ColourSpace::Srgb,
@@ -1981,9 +2016,16 @@ impl DevelopSession {
// beneath it.
let reveal = self.reveal();
mix(reveal.as_ref().map_or(0, |r| {
// Every shown id, not only which are shown: a layer joining the
// shown set renumbers every slot after it, exactly as one joining
// the active set does. Colours are left out — they change what
// the shader draws, not which field it draws through.
let mut h: u64 = 1;
for b in r.layer.as_bytes() {
h = h.wrapping_mul(31).wrapping_add(*b as u64);
for l in &r.layers {
for b in l.layer.as_bytes() {
h = h.wrapping_mul(31).wrapping_add(*b as u64);
}
h = h.wrapping_mul(31).wrapping_add(0x1f);
}
h
}));
@@ -2476,51 +2518,125 @@ impl DevelopSession {
/// TRACES: FR-DEV-19c
/// What the canvas should draw over the photograph, if anything.
///
/// **Only when exactly one layer is selected**, on the same rule the part
/// list and the brush follow: a mask is one thing, and drawing the union
/// of three because three rows were control-clicked would answer a
/// question nobody asked. `None` there rather than picking the first, so
/// the reveal disappearing is itself the signal that the selection is not
/// what it needs to be.
/// Every layer whose eye is open, in stack order, each in its colour —
/// and `None` when no eye is, so the rasteriser and the composer can
/// take the path they always took.
///
/// Rebuilt per call rather than kept in step with the selection, because
/// it is two fields and a clone of one id — cheaper than the invalidation
/// a cached copy would need every time a layer is added, removed, renamed
/// Rebuilt per call rather than kept in step with the stack, because it
/// is a walk over at most eight layers — cheaper than the invalidation a
/// cached copy would need every time a layer is added, removed, renamed
/// or reordered.
pub(crate) fn reveal(&self) -> Option<dr_pipeline::mask::Reveal> {
let style = self.reveal_style?;
let [id] = self.active_masks.as_slice() else {
use dr_pipeline::mask::{Reveal, RevealedLayer};
let layers: Vec<RevealedLayer> = self
.graph
.masks()
.layers()
.iter()
.filter_map(|l| {
let view = self.mask_views.get(&l.id).filter(|v| v.shown)?;
Some(RevealedLayer {
layer: l.id.clone(),
colour: MASK_COLOURS[view.colour % MASK_COLOURS.len()],
})
})
.collect();
if layers.is_empty() {
return None;
};
Some(dr_pipeline::mask::Reveal {
layer: id.clone(),
style,
}
Some(Reveal {
layers,
style: self.reveal_style,
})
}
/// How the selected layer's mask is being shown, as an index into
/// [`dr_pipeline::mask::RevealStyle::ALL`], or `0` for not at all.
/// How shown masks are drawn, as an index into
/// [`dr_pipeline::mask::RevealStyle::ALL`].
///
/// An index because the panel offers it as a strip of chips and an index
/// is what a strip of chips reports. The enum stays the thing that is
/// stored, so a fourth style is a variant and a label rather than a number
/// two files have to agree on.
pub fn mask_view(&self) -> usize {
use dr_pipeline::mask::RevealStyle;
self.reveal_style
.and_then(|s| RevealStyle::ALL.iter().position(|&a| a == s))
.map_or(0, |i| i + 1)
pub fn mask_view_style(&self) -> usize {
dr_pipeline::mask::RevealStyle::ALL
.iter()
.position(|&a| a == self.reveal_style)
.unwrap_or(0)
}
/// Show the selected layer's mask, or stop.
/// Choose how shown masks are drawn.
///
/// Takes no history step and marks nothing dirty: this is how the
/// photograph is being *looked at*, not an edit to it.
pub fn set_mask_view(&mut self, view: usize) {
use dr_pipeline::mask::RevealStyle;
self.reveal_style = view
.checked_sub(1)
.and_then(|i| RevealStyle::ALL.get(i).copied());
pub fn set_mask_view_style(&mut self, style: usize) {
if let Some(&s) = dr_pipeline::mask::RevealStyle::ALL.get(style) {
self.reveal_style = s;
}
}
/// Whether this layer's mask is drawn over the photograph.
pub fn mask_shown(&self, id: &str) -> bool {
self.mask_views.get(id).is_some_and(|v| v.shown)
}
/// Open or close one layer's eye.
pub fn set_mask_shown(&mut self, id: &str, shown: bool) {
let colour = self.next_mask_colour();
self.mask_views
.entry(id.to_string())
.or_insert(MaskView {
shown: false,
colour,
})
.shown = shown;
}
/// Whether any mask at all is being shown.
///
/// What the region overlay asks before drawing: two overlays that mean
/// different things, on top of each other, is neither.
pub fn any_mask_shown(&self) -> bool {
self.reveal().is_some()
}
/// Which of [`MASK_COLOURS`] this layer is shown in.
pub fn mask_colour(&self, id: &str) -> usize {
self.mask_views
.get(id)
.map_or(0, |v| v.colour % MASK_COLOURS.len())
}
/// Give this layer a colour from [`MASK_COLOURS`].
///
/// Choosing a colour is asking to see it: a swatch pressed on a layer
/// whose eye was closed opens the eye, because nothing else the press
/// could mean would change a pixel.
pub fn set_mask_colour(&mut self, id: &str, colour: usize) {
let colour = colour % MASK_COLOURS.len();
self.mask_views
.entry(id.to_string())
.and_modify(|v| {
v.colour = colour;
v.shown = true;
})
.or_insert(MaskView {
shown: true,
colour,
});
}
/// The colour the next layer to be shown should take: the first not
/// already in use, or round the palette again once all are.
///
/// So that two masks made one after the other come up in two colours
/// without anyone having to choose — which is the case that matters,
/// since "how do these two meet" is the question two masks are shown to
/// answer.
fn next_mask_colour(&self) -> usize {
let used: Vec<usize> = self.mask_views.values().map(|v| v.colour).collect();
(0..MASK_COLOURS.len())
.find(|c| !used.contains(c))
.unwrap_or(self.mask_views.len() % MASK_COLOURS.len())
}
/// TRACES: FR-DEV-19c
@@ -2531,18 +2647,22 @@ impl DevelopSession {
/// derivable from anything on screen, the layer carries no adjustment yet,
/// and the list it was chosen from says "architecture 23%" and nothing
/// about *which* 23%. So the mask appears with the layer rather than
/// waiting to be asked for a second time.
/// waiting to be asked for a second time — its eye open, in the next
/// colour nothing else is using.
///
/// Only from the resting position, and only on an explicit action — the
/// same rule arming a brush follows. Somebody who has switched to the
/// alpha or the outline keeps it, and this never re-arms in the background;
/// the trap `Masking.overlay-hidden` documents is an automatic reveal that
/// undoes a switch a photographer turned off, and every caller of this is
/// a press that asked for a new mask.
fn show_new_mask(&mut self) {
if self.reveal_style.is_none() {
self.reveal_style = Some(dr_pipeline::mask::RevealStyle::Tint);
}
/// Only this layer's eye. Every other layer keeps whatever the
/// photographer set it to, which is the trap `Masking.overlay-hidden`
/// documents: an automatic reveal that undoes a switch somebody turned
/// off is worse than none.
fn show_new_mask(&mut self, id: &str) {
let colour = self.next_mask_colour();
self.mask_views.insert(
id.to_string(),
MaskView {
shown: true,
colour,
},
);
}
// ----------------------------------------------------------------------
@@ -3378,7 +3498,7 @@ impl DevelopSession {
return None;
}
self.active_masks = vec![id.clone()];
self.show_new_mask();
self.show_new_mask(&id);
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
Some(id)
@@ -3422,7 +3542,7 @@ impl DevelopSession {
return None;
}
self.active_masks = vec![id.clone()];
self.show_new_mask();
self.show_new_mask(&id);
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
Some(id)
@@ -3453,7 +3573,7 @@ impl DevelopSession {
return None;
}
self.active_masks = vec![id.clone()];
self.show_new_mask();
self.show_new_mask(&id);
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
Some(id)
@@ -3485,7 +3605,7 @@ impl DevelopSession {
}
self.active_masks = vec![id.clone()];
self.active_part = 0;
self.show_new_mask();
self.show_new_mask(&id);
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
Some(id)
@@ -3512,7 +3632,7 @@ impl DevelopSession {
return None;
}
self.active_masks = vec![id.clone()];
self.show_new_mask();
self.show_new_mask(&id);
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
Some(id)
@@ -6571,15 +6691,15 @@ mod tests {
let mut live = session_with_a_left_half_subject(&ctx);
let id = live.add_subject_mask(0).expect("a subject layer");
// TRACES: FR-DEV-19c
// Making a mask shows it (`show_new_mask`), and this test is about the
// pixels the *edit* produces. Put away here rather than left on, and
// the asymmetry is the point rather than an inconvenience: the reveal
// is how somebody is looking at a photograph, so a session that has
// just made a layer legitimately draws a frame that a session which
// read the same layer out of a file does not. Both of those are
// Making a mask opens its eye (`show_new_mask`), and this test is
// about the pixels the *edit* produces. Closed here rather than left
// open, and the asymmetry is the point rather than an inconvenience:
// the reveal is how somebody is looking at a photograph, so a session
// that has just made a layer legitimately draws a frame that a session
// which read the same layer out of a file does not. Both of those are
// correct, and only one of them is what a stored raster has to
// reproduce.
live.set_mask_view(0);
live.set_mask_shown(&id, false);
live.graph
.masks_mut()
.get_mut(&id)
@@ -6611,10 +6731,9 @@ mod tests {
// reopened with its masks tinted red. Checked here because this is the
// one test that puts an edit through a file and renders both ends, so
// it is where the property would first go wrong.
assert_eq!(
reopened.mask_view(),
0,
"restoring an edit must not turn an overlay on"
assert!(
!reopened.any_mask_shown(),
"restoring an edit must not open an eye"
);
let from_the_file = read_back(&ctx, &reopened.render(64, 64).expect("render"));
+7
View File
@@ -120,6 +120,13 @@ pub const GESTURES: &[Gesture] = &[
pointer: "Click the ring at the head of its row",
keys: "",
},
Gesture {
title: "Show or hide one mask on the photograph",
section: "Develop",
touch: "Tap the eye on its row",
pointer: "Click the eye on its row",
keys: "",
},
Gesture {
title: "Pick a collection up to rearrange the tree",
section: "Collections sidebar",
+80 -19
View File
@@ -149,6 +149,26 @@ impl Running {
}
}
/// TRACES: FR-DEV-19c
/// One of [`crate::develop::MASK_COLOURS`] as the compositor draws it.
///
/// The table is linear sRGB because that is what the shader mixes in; the
/// swatch on the panel is display-encoded, so the conversion is here and not
/// in the table — a swatch that showed the linear values would be a darker,
/// duller colour than the one it promised.
fn mask_colour(index: usize) -> slint::Color {
let [r, g, b] = crate::develop::MASK_COLOURS[index % crate::develop::MASK_COLOURS.len()];
let encode = |v: f32| {
let v = v.clamp(0.0, 1.0);
if v <= 0.003_130_8 {
v * 12.92
} else {
1.055 * v.powf(1.0 / 2.4) - 0.055
}
};
slint::Color::from_rgb_f32(encode(r), encode(g), encode(b))
}
/// A descriptor name as a list label.
///
/// Only the first letter, because the names in `models/scene/categories.txt`
@@ -210,6 +230,9 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
band_softness,
hue,
hue_width,
shown: s.mask_shown(&id),
colour: mask_colour(s.mask_colour(&id)),
colour_index: s.mask_colour(&id) as i32,
id: id.into(),
label: label.into(),
enabled,
@@ -265,10 +288,14 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
masking.set_parts(ModelRc::new(VecModel::from(parts)));
// TRACES: FR-DEV-19c
// Pushed rather than assumed, because the session is where it lives and
// because it can change without the panel having asked: selecting a
// second layer takes the reveal away, since a mask is one thing.
masking.set_mask_view(s.mask_view() as i32);
// Pushed rather than assumed, because the session is where it lives. The
// palette goes with it so the swatches and the shader read one table.
masking.set_mask_view_style(s.mask_view_style() as i32);
masking.set_mask_colours(ModelRc::new(VecModel::from(
(0..crate::develop::MASK_COLOURS.len())
.map(mask_colour)
.collect::<Vec<_>>(),
)));
let (radius, hardness, flow) = s.brush();
masking.set_brush_radius(radius);
@@ -294,7 +321,7 @@ pub(crate) fn sync(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSessio
// describing the layer being worked on wins, because by the time a mask
// has been chosen the detections are what the photographer is choosing
// *between* rather than what they are looking at.
match s.overlay_image().filter(|_| s.mask_view() == 0) {
match s.overlay_image().filter(|_| !s.any_mask_shown()) {
Some(image) => {
window.set_region_overlay(image);
window.set_overlay_on(true);
@@ -895,8 +922,10 @@ pub(crate) fn wire(
// automatic reveal at all.
if tool > 0 {
if let Some(s) = session.borrow_mut().as_mut() {
if s.mask_view() == 0 {
s.set_mask_view(1);
if let Some(id) = s.active_mask().map(str::to_owned) {
if !s.mask_shown(&id) {
s.set_mask_shown(&id, true);
}
}
}
}
@@ -908,21 +937,53 @@ pub(crate) fn wire(
});
}
// TRACES: FR-DEV-19c
// Three handlers, one shape: change how the masks are looked at, then a
// redraw and not only a sync, because the reveal is in the composed
// shader and what changed is the picture rather than the panel.
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.global::<Masking>().on_mask_view_picked(move |view| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_view(view.max(0) as usize);
}
// A redraw and not only a sync: the reveal is in the composed
// shader, so what changed is the picture rather than the
// panel.
sync(&w, &session);
redraw(&w);
});
window
.global::<Masking>()
.on_mask_view_style_picked(move |style| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_view_style(style.max(0) as usize);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window
.global::<Masking>()
.on_mask_shown_toggled(move |id, shown| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_shown(&id, shown);
}
sync(&w, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window
.global::<Masking>()
.on_mask_colour_picked(move |id, colour| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_mask_colour(&id, colour.max(0) as usize);
}
sync(&w, &session);
redraw(&w);
});
}
// TRACES: FR-DEV-19a
{
@@ -1234,7 +1295,7 @@ pub(crate) fn reset(window: &AppWindow) {
// next photograph has none. Left standing, they would arm a brush over a
// photograph with nothing to paint into and claim a mask was being shown.
masking.set_tool(0);
masking.set_mask_view(0);
masking.set_mask_view_style(0);
masking.set_masks(ModelRc::new(VecModel::<MaskRow>::default()));
masking.set_subjects(ModelRc::new(VecModel::<SubjectRow>::default()));
masking.set_categories(ModelRc::new(VecModel::<CategoryRow>::default()));
+28
View File
@@ -60,6 +60,7 @@ component Ink inherits Path {
// collection collection-smart
// offline offline-held
// photo crop mask repair
// eye eye-closed
export component Icon inherits Rectangle {
in property <string> name;
/// The single colour the whole drawing takes. Named `ink` rather than
@@ -335,6 +336,33 @@ export component Icon inherits Rectangle {
commands: "M 12 3.6 A 8.4 8.4 0 0 1 12 20.4 Z";
}
// TRACES: FR-DEV-19c
// An eye, open: the almond and the pupil. The pupil is filled so that at
// 16px the open eye reads as a dot with a lid rather than as two arcs
// that could be anything, and it takes the same ink as the rest so the
// whole glyph can be drawn in the colour of the mask it shows.
if root.name == "eye": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 2.4 12 C 5.2 6.8 8.4 4.8 12 4.8 C 15.6 4.8 18.8 6.8 21.6 12 "
+ "C 18.8 17.2 15.6 19.2 12 19.2 C 8.4 19.2 5.2 17.2 2.4 12 Z";
}
if root.name == "eye": Ink {
fill: root.ink;
stroke-width: root.weight;
commands: "M 12 8.6 A 3.4 3.4 0 1 0 12 15.4 A 3.4 3.4 0 1 0 12 8.6 Z";
}
// An eye, closed: the lower lid alone, with three lashes. The lid is the
// same lower curve as the open eye's, so the two glyphs sit at the same
// height and toggling between them does not make the row jump.
if root.name == "eye-closed": Ink {
stroke: root.ink;
stroke-width: root.weight;
commands: "M 2.4 12 C 5.2 17.2 8.4 19.2 12 19.2 C 15.6 19.2 18.8 17.2 21.6 12 "
+ "M 12 19.2 L 12 22 M 6.6 18 L 5 20.4 M 17.4 18 L 19 20.4";
}
// 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
+185 -79
View File
@@ -9,6 +9,7 @@
import { Theme } from "theme.slint";
import { Develop } from "session.slint";
import { Button, Caption, IconButton, Label, PanelHeading, Value } from "widgets.slint";
import { Icon } from "icons.slint";
import { Segmented, SliderRow } from "controls.slint";
/// One layer in the stack.
@@ -66,6 +67,15 @@ export struct MaskRow {
/// Whether it currently changes any pixel. A bare selection does not, and
/// saying so is what stops it reading as broken.
adjusted: bool,
/// TRACES: FR-DEV-19c
/// Whether its mask is drawn over the photograph — the eye on its row.
shown: bool,
/// The colour it is drawn in, and which swatch that is. Both, because the
/// row draws the colour and the swatch strip lights the index, and
/// deriving one from the other here would mean a second copy of the
/// palette in this file.
colour: color,
colour-index: int,
}
/// One selection inside a layer's mask (FR-DEV-19a).
@@ -167,6 +177,9 @@ component MaskEntry inherits Rectangle {
callback part-removed(int);
/// Join a fresh painted correction: 0 adds, 1 subtracts.
callback part-added(int);
/// TRACES: FR-DEV-19c
callback shown-toggled(bool);
callback colour-picked(int);
height: layout.preferred-height;
background: root.data.selected ? Theme.surface-raised : transparent;
@@ -258,6 +271,41 @@ component MaskEntry inherits Rectangle {
}
}
// GESTURE: Show or hide one mask on the photograph
// where: Develop
// touch: Tap the eye on its row
// pointer: Click the eye on its row
// why: 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.
//
// TRACES: FR-DEV-19c
eye := TouchArea {
width: Theme.touch-target;
height: Theme.touch-target;
mouse-cursor: pointer;
enabled: root.enabled;
clicked => { root.shown-toggled(!root.data.shown); }
Icon {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
size: 18px;
name: root.data.shown ? "eye" : "eye-closed";
// Open, in the mask's own colour; closed, in the dim ink
// every idle control wears — so a glance down the stack
// says which masks are on the picture and in what.
ink: root.data.shown
? root.data.colour
: (eye.has-hover ? Theme.ink : Theme.ink-dim);
}
}
remove := TouchArea {
width: Theme.touch-target;
height: Theme.touch-target;
@@ -370,6 +418,109 @@ component MaskEntry inherits Rectangle {
}
}
// TRACES: FR-DEV-19b
// **The brush lives with the mask it paints into.** It used to sit
// at the top of the panel, a screen's height from the row that
// had just been made, and appeared only once a row was selected —
// so the whole of the answer to "how do I paint" was a strip
// somebody had to scroll up to find and had no reason to look
// for. Here it is under the parts it adds to, which is what a
// stroke does.
//
// In the panel and not in the tool rail, still: the rail's
// entries arm a gesture for the whole photograph, and a brush has
// no meaning without a mask to paint into.
Segmented {
label: "Brush";
// Said on the model's own masks especially: a subject or a
// category arrives approximately right, and the correction
// the photographer reaches for is a stroke where it stopped
// short and an erase where it leaked — which becomes a part
// of this mask, joined to the model's, and can be taken out
// again from the list above.
hint: (root.data.kind == "subject" || root.data.kind == "category")
? "Correct the model's outline by hand: Paint adds to this "
+ "mask under the pointer, Erase takes away. Each becomes "
+ "a part above, and a stroke is one step in the history."
: "Paint adds to this mask under the pointer; Erase takes "
+ "away. A stroke is one step in the history.";
options: ["Select", "Paint", "Erase"];
selected: Masking.tool;
picked(i) => { Masking.tool-picked(i); }
}
if Masking.tool > 0: SliderRow {
label: "Size";
value: Masking.brush-radius;
default-value: 0.05;
minimum: 0.005;
maximum: 0.4;
precision: 3;
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 Masking.tool > 0: SliderRow {
label: "Hardness";
value: Masking.brush-hardness;
default-value: 0.5;
minimum: 0.0;
maximum: 1.0;
precision: 2;
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 Masking.tool > 0: SliderRow {
label: "Flow";
value: Masking.brush-flow;
default-value: 1.0;
minimum: 0.05;
maximum: 1.0;
precision: 2;
changed(v) => { Masking.brush-changed(Masking.brush-radius, Masking.brush-hardness, v); }
reset => { Masking.brush-changed(Masking.brush-radius, Masking.brush-hardness, 1.0); }
}
// TRACES: FR-DEV-19c
// The colour this mask is shown in. Swatches rather than a picker:
// six colours that can be told apart at half strength over a
// photograph is the whole of the requirement, and a wheel would
// offer a thousand that cannot. Pressing one opens the eye, since
// choosing a colour for a mask nobody can see would be a control
// that changes no pixel.
VerticalLayout {
spacing: 4px;
Caption { text: "Shown in"; }
HorizontalLayout {
spacing: Theme.gap-sm;
alignment: start;
for c[i] in Masking.mask-colours: swatch := TouchArea {
width: Theme.control-height;
height: Theme.control-height;
mouse-cursor: pointer;
enabled: root.enabled;
clicked => { root.colour-picked(i); }
Rectangle {
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: 9px;
background: c;
// The chosen one is ringed rather than enlarged,
// so the strip does not shimmer as the choice
// moves along it.
border-width: (root.data.shown && root.data.colour-index == i) ? 2px : 0px;
border-color: Theme.ink;
opacity: swatch.has-hover || (root.data.shown && root.data.colour-index == i) ? 1.0 : 0.7;
}
}
}
}
SliderRow {
label: "Opacity";
value: root.data.opacity;
@@ -589,16 +740,20 @@ export global Masking {
/// the strip stuck on "Select" and the brush reachable from no control.
in property <int> tool: 0;
/// TRACES: FR-DEV-19c
/// How the selected layer's mask is drawn over the photograph: 0 not at
/// all, then tint, alpha and edge — `dr_pipeline::mask::RevealStyle::ALL`
/// shifted by one so that zero can mean off.
/// How shown masks are drawn over the photograph — tint, alpha or edge,
/// as an index into `dr_pipeline::mask::RevealStyle::ALL`. One style for
/// every shown mask; *which* masks are shown is each row's own eye.
///
/// **Not `overlay-hidden`.** That switch belongs to the region overlay,
/// which is a picture of what the *model detected*; this is the mask a
/// layer actually resolves to, feather, morphology, invert and all. Two
/// overlays that look alike and mean different things is worse than
/// either, so they are named apart and switched apart.
in property <int> mask-view: 0;
in property <int> mask-view-style: 0;
/// The colours a mask may be shown in — `MASK_COLOURS` in the session,
/// pushed rather than declared here so the swatch and the shader are
/// reading one table.
in property <[color]> mask-colours;
/// The brush: radius as a fraction of the frame's shorter edge, hardness,
/// and flow.
in property <float> brush-radius: 0.05;
@@ -647,7 +802,9 @@ export global Masking {
/// TRACES: FR-DEV-19b
callback tool-picked(int);
/// TRACES: FR-DEV-19c
callback mask-view-picked(int);
callback mask-view-style-picked(int);
callback mask-shown-toggled(string, bool);
callback mask-colour-picked(string, int);
callback brush-changed(float, float, float);
/// TRACES: FR-DEV-19a
callback part-selected(string, int);
@@ -688,77 +845,6 @@ export component MaskPanel inherits Rectangle {
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
// gesture for the whole photograph; a brush has no meaning without a
// 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 Develop.enabled && Masking.parts.length > 0: Segmented {
options: ["Select", "Paint", "Erase"];
selected: Masking.tool;
picked(i) => { Masking.tool-picked(i); }
}
// TRACES: FR-DEV-19c
// **Seeing the mask, which every tool above is unusable without.**
// Nobody can refine an edge they are not being shown, and until this
// existed the only thing drawn on the photograph was the model's
// detections — which know nothing of a layer's feather, its falloff,
// its morphology, its invert or its opacity, and nothing at all about
// a gradient or a stroke.
//
// Four chips rather than a toggle because the three styles answer
// three different questions and no one of them answers all three: a
// tint says whether the right thing is selected, an alpha says where
// the edge is, an outline says whether that edge is registered against
// detail the other two hide.
//
// Beside the tool strip and under the same condition, because both
// describe one selected mask — and `columns: 2` for the reason
// `ChipGrid` exists: four chips in a row would set the width of the
// develop column and take every other panel's controls off the edge.
if Develop.enabled && Masking.parts.length > 0: Segmented {
label: "Show mask";
options: ["Off", "Tint", "Alpha", "Edge"];
columns: 2;
selected: Masking.mask-view;
picked(i) => { Masking.mask-view-picked(i); }
}
if Develop.enabled && Masking.tool > 0: SliderRow {
label: "Size";
value: Masking.brush-radius;
default-value: 0.05;
minimum: 0.005;
maximum: 0.4;
precision: 3;
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 Develop.enabled && Masking.tool > 0: SliderRow {
label: "Hardness";
value: Masking.brush-hardness;
default-value: 0.5;
minimum: 0.0;
maximum: 1.0;
precision: 2;
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 Develop.enabled && Masking.tool > 0: SliderRow {
label: "Flow";
value: Masking.brush-flow;
default-value: 1.0;
minimum: 0.05;
maximum: 1.0;
precision: 2;
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 ---------------------------------------------
//
// Segmentation is a thing the user starts. Half a second of watershed
@@ -805,9 +891,9 @@ export component MaskPanel inherits Rectangle {
// **Named for what it actually hides**, which is not the mask. This
// said "Show the mask" / "Hide the mask" while switching the
// false-coloured picture of what the *model detected* — and now that
// there is a control which really does show a mask ("Show mask",
// above), two things called the same thing and meaning different ones
// would be worse than either.
// there is a control which really does show a mask (the eye on each
// row of the stack), two things called the same thing and meaning
// different ones would be worse than either.
if Develop.enabled && Masking.segmented: Button {
text: Masking.overlay-hidden ? "Show what was found" : "Hide what was found";
clicked => { Masking.overlay-hidden = !Masking.overlay-hidden; }
@@ -954,6 +1040,24 @@ export component MaskPanel inherits Rectangle {
// --- the stack -----------------------------------------------------
Caption { text: "Masks"; }
// TRACES: FR-DEV-19c
// How every shown mask is drawn. One strip for all of them, above
// the rows whose eyes decide which: a tint beside an outline beside an
// alpha would be three pictures that cannot be read against each
// other, where three tints in three colours are one.
//
// Three chips rather than a toggle because the three answer three
// different questions and no one of them answers all three: a tint
// says whether the right thing is selected, an alpha says where the
// edge is, an outline says whether that edge is registered against
// detail the other two hide.
if Develop.enabled && Masking.masks.length > 0: Segmented {
label: "Show masks as";
options: ["Tint", "Alpha", "Edge"];
selected: Masking.mask-view-style;
picked(i) => { Masking.mask-view-style-picked(i); }
}
// Gradients need no segmentation, so they are offered whether or not
// one has been computed — a graduated filter over a sky is a local
// adjustment that never needed to know what a sky is.
@@ -1046,6 +1150,8 @@ export component MaskPanel inherits Rectangle {
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); }
shown-toggled(on) => { Masking.mask-shown-toggled(mask.id, on); }
colour-picked(i) => { Masking.mask-colour-picked(mask.id, i); }
}
// No caption saying which chain the sliders below are pointed at.