//! Looking at masks: view style, the segmentation overlay, the layer list, //! and editing a mask by hand with the brush. #[cfg(test)] use dr_gpu::GpuContext; use dr_pipeline::mask::MaskSource; use crate::labels; use dr_pipeline::Edit; use super::session::DevelopSession; /// TRACES: FR-DEV-19c /// How one layer's mask is shown on the canvas. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) 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], ]; impl DevelopSession { // ---------------------------------------------------------------------- // Seeing the mask (FR-DEV-19c) // ---------------------------------------------------------------------- /// TRACES: FR-DEV-19c /// What the canvas should draw over the photograph, if anything. /// /// 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 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 { use dr_pipeline::mask::{Reveal, RevealedLayer}; // Only while masking. The eyes are per layer and outlive the mode, // so a photographer coming back finds the layers they were looking // at still lit — but a tint is a way of looking at a *mask*, and // outside Local there is no mask being looked at. Without this the // sky stayed red through Repair and back in Photo, a mode that had // been left leaving its overlay behind (ui-navigation.md D-N1). if !self.show_overlay { return None; } let layers: Vec = 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(Reveal { layers, style: self.reveal_style, }) } /// 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_style(&self) -> usize { dr_pipeline::mask::RevealStyle::ALL .iter() .position(|&a| a == self.reveal_style) .unwrap_or(0) } /// 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_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 = 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 /// Show the mask of a layer that has just been made. /// /// **Making a mask is asking what it selected**, and for a subject or a /// category that question has no other answer: the model's outline is not /// 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 — its eye open, in the next /// colour nothing else is using. /// /// 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. pub(super) 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, }, ); } // ---------------------------------------------------------------------- // The region overlay // ---------------------------------------------------------------------- pub fn overlay_enabled(&self) -> bool { self.show_overlay } pub fn set_overlay(&mut self, on: bool) { self.show_overlay = on; } /// The part of the overlay the view is currently showing, in overlay /// pixels: `(x, y, width, height)`. /// /// The overlay is a **source-space** picture, and the canvas beside it /// shows whatever the crop, the zoom and the pan selected out of that same /// space. Drawn whole, it stays the size of the frame while the photograph /// moves underneath — which is exactly the fault this exists to fix. /// /// Reported as a clip rectangle rather than resampled here: the compositor /// crops and scales a texture for nothing, where doing it on the CPU would /// mean rebuilding a megapixel image on every frame of a drag. /// /// **Known gap.** A quarter turn or a flip permutes the axes, and a clip /// rectangle cannot express that — the straightening angle is handled /// alongside this, but a quarter-turned frame shows the overlay unturned, /// and a keystoned one (FR-DEV-20) shows it unwarped. /// Fixing it properly means running the overlay through the same shader /// prologue the image goes through, which is the right answer and a larger /// one than this. pub fn overlay_clip(&self) -> (i32, i32, i32, i32) { let Some(seg) = self.segmentation.as_ref() else { return (0, 0, 0, 0); }; // **Shown pixels, matching `overlay_image`.** The crop and the // viewport are fractions of the photograph as the user sees it — the // prologue maps an output pixel through `crop_rect` *before* it // unturns the frame — so measuring them against the sensor's width // and height puts the clip on the wrong axis the moment the two // differ. That is the same confusion as the overlay itself had, one // layer down, and it is silent for exactly the images where it is // wrong: a landscape frame has nothing to notice. let (sw, sh) = seg.proxy_size(); let (w, h) = self .graph .framing() .effective_orientation() .oriented_size(sw as u32, sh as u32); let rect = self.graph.framing().visible_rect(); // Rounded outward, so half a pixel of rounding never shows as a strip // of missing overlay along an edge. let x = (rect.x * w as f32).floor().max(0.0) as i32; let y = (rect.y * h as f32).floor().max(0.0) as i32; let right = ((rect.x + rect.width) * w as f32).ceil().min(w as f32) as i32; let bottom = ((rect.y + rect.height) * h as f32).ceil().min(h as f32) as i32; (x, y, (right - x).max(1), (bottom - y).max(1)) } /// TRACES: FR-DEV-3 /// A false-coloured picture of what a click can select, for the canvas. /// /// Returned as a CPU image rather than a texture, and deliberately: it is /// regenerated only when the segmentation changes, it is proxy-sized /// rather than viewport-sized, and the compositor scales and clips it for /// free. Putting it on the GPU would buy nothing and add a second texture /// to keep in step with the view. /// /// `None` when the overlay is off or nothing has been segmented, so the /// caller can bind this straight to an image source. pub fn overlay_image(&self) -> Option { if !self.show_overlay { return None; } let (rgba, w, h) = self.segmentation.as_ref()?.overlay_rgba(); // TRACES: FR-DEV-3h // **Turned the right way up before it is drawn.** Instance masks live // in sensor space, because the generated shader samples them after // the framing map (`uv_src`) — but this is not sampled by that shader. // It is a flat image handed to the compositor to lay over a // photograph that *has* been through the framing map, so it has to // arrive in the same space the photograph is in. // // Without this the outlines are drawn in the sensor's orientation over // an upright picture: on a portrait frame the colour sits nowhere near // the subject, which reads as the detector having failed rather than // as the overlay being turned. Nothing announces it, and it is // invisible on landscape frames, which is most of them. let (rgba, w, h) = self .graph .framing() .effective_orientation() .into_shown(&rgba, w, h, 4); let buffer = slint::SharedPixelBuffer::::clone_from_slice(&rgba, w, h); Some(slint::Image::from_rgba8(buffer)) } // ---------------------------------------------------------------------- // Mask layers // ---------------------------------------------------------------------- /// The layers, as `(id, name, enabled, is_active_selection)`. pub fn mask_layers(&self) -> Vec<(String, String, bool, bool)> { self.graph .masks() .layers() .iter() .map(|l| { ( l.id.clone(), l.display_name().to_string(), l.enabled, self.active_masks.iter().any(|a| a == &l.id), ) }) .collect() } /// What kind of mask a layer is — "regions", "linear", "radial". pub fn mask_kind(&self, id: &str) -> &'static str { self.part_of(id).map_or("", |p| p.source.kind()) } pub fn mask_inverted(&self, id: &str) -> bool { self.graph.masks().get(id).is_some_and(|l| l.invert) } pub fn mask_opacity(&self, id: &str) -> f32 { self.graph.masks().get(id).map_or(1.0, |l| l.opacity) } /// Whether a layer has any adjustment on it yet. /// /// Distinct from `is_active`, which also asks whether the layer is enabled /// and visible. The panel wants specifically "you have made a selection /// and not yet done anything with it", because that state looks identical /// to a broken mask and is the most likely thing a first-time user hits. pub fn mask_is_adjusted(&self, id: &str) -> bool { self.graph .masks() .get(id) .is_some_and(|l| l.active_ops().next().is_some()) } /// The panel's representative selection — see [`Self::active_layer`] for /// what "representative" means once more than one layer is selected. pub fn active_mask(&self) -> Option<&str> { self.active_masks.first().map(String::as_str) } /// Every selected layer's id, in selection order. pub fn active_masks(&self) -> &[String] { &self.active_masks } /// TRACES: FR-DEV-3 | FR-UI-3 /// The selected gradient's handles, in fractions of the shown image. /// /// Empty unless **exactly one** gradient layer is selected. Dragging a /// shared handle for several gradients at once has no single geometry to /// move — each one's centre, angle and extent differ — so multi-select /// simply offers no handles rather than moving one layer's shape while /// silently leaving the others behind. /// /// Recomputed on every redraw rather than cached, because the answer /// changes with the *view* and not only with the mask: a pan moves every /// handle and touches no geometry. Four handles through an affine map is /// not work worth caching, and a cache keyed on the wrong thing is how a /// handle comes to sit where the mask used to be. pub fn gradient_handles(&self) -> Vec { if self.active_masks.len() != 1 { return Vec::new(); } let Some(layer) = self.active_layer() else { return Vec::new(); }; let (sw, sh) = self.demosaiced.size(); crate::gradient::handles(&layer.base().source, self.graph.framing(), (sw, sh)) } /// Drag one handle of the selected gradient, from `press` to `now`, both /// in fractions of the shown image. /// /// `origin` is the geometry the gesture started from — see /// [`crate::gradient::drag`] for why a drag is applied to that rather than /// accumulated. Returns it, so the caller can hold it for the rest of the /// gesture; `None` when there is no gradient selected to drag. pub fn drag_gradient_handle( &mut self, role: crate::HandleRole, origin: Option<&MaskSource>, press: (f32, f32), now: (f32, f32), ) -> Option { if self.active_masks.len() != 1 { return None; } let (sw, sh) = self.demosaiced.size(); let framing = *self.graph.framing(); let id = self.active_masks.first()?.clone(); let start = match origin { Some(s) => s.clone(), None => self.graph.masks().get(&id)?.base().source.clone(), }; let moved = crate::gradient::drag(&start, role, press, now, &framing, (sw, sh)); self.graph.masks_mut().get_mut(&id)?.base_mut().source = moved; // **Nothing recorded here.** A drag delivers a pointer event a frame, // and a history step per frame would make undo walk a gesture back // pixel by pixel. `Edit` coalesces by operation id and a mask's shape // is not an operation, so there is no key to coalesce under — the // honest answer is to record once, on release. Some(start) } /// A handle drag finished: one history step for the whole gesture. /// /// Called on the pointer's release rather than on each move, which is what /// makes a drag one decision in the undo stack however many frames it took. pub fn commit_gradient_drag(&mut self) { self.history .record(&self.graph, Edit::Action(labels::step::MASK_MOVED)); } // --- editing a mask by hand (FR-DEV-19) -------------------------------- /// TRACES: FR-DEV-19a /// Which part of `id` the edge controls act on. /// /// The selected part when this is the layer being edited, and the base /// otherwise — because a panel that is not showing a layer's parts has not /// offered anybody a way to choose one, and answering with a part they /// cannot see would make the same slider mean different things depending /// on what was selected a moment ago. pub(super) fn shaped_part(&self, id: &str) -> usize { match self.active_masks.as_slice() { [only] if only == id => self.active_part, _ => 0, } } /// The part of `id` the edge controls read. pub(super) fn part_of(&self, id: &str) -> Option<&dr_pipeline::mask::MaskPart> { let index = self.shaped_part(id); self.graph.masks().get(id)?.part(index) } /// The same, to write through. pub(super) fn part_of_mut(&mut self, id: &str) -> Option<&mut dr_pipeline::mask::MaskPart> { let index = self.shaped_part(id); self.graph.masks_mut().get_mut(id)?.part_mut(index) } /// Which part of the selected layer the tools point at. pub fn active_part(&self) -> usize { self.active_part } /// Point the tools at one part, or at the base when the index is past the /// end — which is what a part being removed under the selection leaves. pub fn set_active_part(&mut self, index: usize) { let parts = self.active_layer().map_or(1, |l| l.parts().len()); self.active_part = if index < parts { index } else { 0 }; } /// The parts of a layer: id, what to call it, and how it joins. /// /// 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, bool)> { let Some(layer) = self.graph.masks().get(id) else { return Vec::new(); }; layer .parts() .iter() .map(|p| { let join = dr_pipeline::mask::Join::ALL .iter() .position(|&j| j == p.join) .unwrap_or(0); (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. /// /// Painted, because that is the correction a photographer reaches for /// first and the only source that needs nothing found for it. The other /// sources arrive when a part can carry its own distance field. pub fn add_mask_part(&mut self, id: &str, join: usize) -> Option { use dr_pipeline::mask::{Join, MaskPart}; let &join = Join::ALL.get(join)?; let layer = self.graph.masks_mut().get_mut(id)?; let part_id = layer.next_part_id(); if !layer.push_part(MaskPart::painted(part_id, join)) { return None; } let index = layer.parts().len() - 1; self.active_part = index; self.history .record(&self.graph, Edit::Action(labels::step::MASK_PART_ADDED)); Some(index) } /// Take a part back out of a layer. The base is not removable — removing /// the selection a layer *is* is removing the layer. pub fn remove_mask_part(&mut self, id: &str, index: usize) { let Some(layer) = self.graph.masks_mut().get_mut(id) else { return; }; if layer.remove_part(index).is_none() { return; } self.set_active_part(self.active_part.min(index.saturating_sub(1))); self.history .record(&self.graph, Edit::Action(labels::step::MASK_PART_REMOVED)); } /// TRACES: FR-DEV-19a /// Change how a part joins: added to the mask, taken out of it, or kept /// only where the mask already was. `join` indexes `Join::ALL`. pub fn set_mask_part_join(&mut self, id: &str, index: usize, join: usize) { use dr_pipeline::mask::Join; let Some(&join) = Join::ALL.get(join) else { return; }; let Some(layer) = self.graph.masks_mut().get_mut(id) else { return; }; // The first part joins nothing, so saying how it joins would be a // control that moves and changes no pixel. if index == 0 { return; } let Some(part) = layer.part_mut(index) else { return; }; part.join = join; self.history .record(&self.graph, Edit::Action(labels::step::MASK_JOINED)); } /// The brush: radius, hardness, flow. pub fn brush(&self) -> (f32, f32, f32) { self.brush } /// Set the brush. Radius is a fraction of the frame's shorter edge, so it /// means the same thing on the phone and on the desktop and at any zoom. pub fn set_brush(&mut self, radius: f32, hardness: f32, flow: f32) { self.brush = ( radius.clamp(0.002, 0.5), hardness.clamp(0.0, 1.0), flow.clamp(0.01, 1.0), ); } /// How many stroke points the selected layer may still record. /// /// Asked by the panel so that a mask approaching its budget can say so /// before a gesture is refused mid-stroke — which is the moment the /// refusal is least explicable. pub fn mask_room(&self) -> usize { self.active_layer().map_or(0, |l| l.room()) } /// TRACES: FR-DEV-19b /// Begin a stroke at a point in fractions of the shown image. /// /// **Paints into the active part, or joins one if that part cannot hold a /// stroke.** Pressing Paint on a mask the model made is the ordinary way /// this is reached, and it must not answer with a refusal explaining that /// a subject is not a brush: the correction the photographer is about to /// make *is* a new part, so it is made. /// /// Returns whether a stroke was started. `false` means the layer is full /// or there is nothing selected, and the caller should not send moves. pub fn begin_mask_stroke(&mut self, x: f32, y: f32, erase: bool) -> bool { let Some(id) = self.active_masks.first().cloned() else { return false; }; if self.active_masks.len() != 1 { // Several layers share the slider drags; a stroke has one target // and guessing which of three it is would be worse than refusing. return false; } let paintable = self .graph .masks() .get(&id) .and_then(|l| l.part(self.active_part)) .is_some_and(|p| matches!(p.source, MaskSource::Brush { .. })); if !paintable { use dr_pipeline::mask::Join; let join = if erase { Join::Subtract } else { Join::Union }; let position = Join::ALL.iter().position(|&j| j == join).unwrap_or(0); if self.add_mask_part(&id, position).is_none() { return false; } } let part = self.active_part; let (radius, hardness, flow) = self.brush; // An erase stroke inside a part that subtracts would take away from // what the part removes, which reads backwards. In a subtracting part // the brush's two modes are already the right way round. let subtracting = self .graph .masks() .get(&id) .and_then(|l| l.part(part)) .is_some_and(|p| p.join == dr_pipeline::mask::Join::Subtract); let erase = erase && !subtracting; let Some(layer) = self.graph.masks_mut().get_mut(&id) else { return false; }; if !layer.begin_stroke(part, erase, radius, hardness, flow) { return false; } self.painting = Some((id, part)); self.extend_mask_stroke(x, y); true } /// Carry the stroke to another point, in fractions of the shown image. /// /// The point is mapped into normalised **source** coordinates on the way /// in, through the same framing map the shader applies — so a stroke stays /// on the thing it was painted on through a zoom, a pan, a crop and a /// straighten, and lands in an export at any size where it was drawn. pub fn extend_mask_stroke(&mut self, x: f32, y: f32) { let Some((id, part)) = self.painting.clone() else { return; }; let (sw, sh) = self.demosaiced.size(); let (sx, sy) = self.graph.framing().source_at((x, y), sw, sh); if let Some(layer) = self.graph.masks_mut().get_mut(&id) { layer.extend_stroke(part, sx, sy); } } /// Finish the stroke: one history step for the whole gesture. /// /// One step, on release, for the reason a handle drag records once — a /// stroke is a decision, and undo that walked it back dab by dab would /// make taking a mark back cost as many presses as making it did. pub fn end_mask_stroke(&mut self) { let Some((id, part)) = self.painting.take() else { return; }; let erased = self .graph .masks() .get(&id) .and_then(|l| l.part(part)) .and_then(|p| p.strokes().last()) .is_some_and(|s| s.erase); if let Some(layer) = self.graph.masks_mut().get_mut(&id) { layer.end_stroke(part); } let step = if erased { labels::step::MASK_ERASED } else { labels::step::MASK_PAINTED }; self.history.record(&self.graph, Edit::Action(step)); } /// Abandon a stroke that turned out to be something else — a pinch, or a /// gesture the window cancelled. Nothing is recorded, because nothing /// happened as far as the photographer is concerned. pub fn cancel_mask_stroke(&mut self) { let Some((id, part)) = self.painting.take() else { return; }; if let Some(layer) = self.graph.masks_mut().get_mut(&id) { layer.drop_last_stroke(part); } } } #[cfg(test)] mod tests { use super::*; use crate::develop::test_support::*; // ---------------------------------------------------------------------- // The overlay's clip rectangle // ---------------------------------------------------------------------- // // The overlay is a source-space picture and the canvas shows whatever the // crop, the zoom and the pan selected out of that space. Drawn whole it // stays frame-sized while the photograph moves underneath, which is what // these pin down. /// A session with a segmentation, so the clip has a proxy to measure /// against. /// /// The model finds nothing in flat grey, and that is fine: the clip is /// computed from the framing and the proxy size, neither of which depends /// on what was detected. fn segmented_session(ctx: &GpuContext) -> Option { let rgba: Vec = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect(); let mut session = DevelopSession::open_rgb(ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL) .expect("session"); session .segment(&crate::segmentation::Options::default()) .ok()?; Some(session) } /// TRACES: FR-DEV-3 | FR-CAT-8 /// The whole claim, end to end: what is stored renders what was rendered. /// /// A session with a model's coverage in hand draws the mask; the stack it /// hands the sidecar writer goes through the file and into a session with /// no model at all; and the two frames must be the same. Anything weaker /// — that the coverage is present, that it round-trips as bytes — would /// still pass if the raster came back at the wrong scale, upside down, or /// a threshold out. #[test] fn a_shown_mask_is_only_shown_while_masking() { let Some(ctx) = headless() else { return }; let mut s = session_with_a_left_half_subject(&ctx); let id = s.add_subject_mask(0).expect("a subject layer"); s.set_overlay(true); s.set_mask_shown(&id, true); assert!(s.any_mask_shown(), "lit, in Local mode"); // Leaving the mode — what `on_mode_picked` does for Photo and Spots. s.set_overlay(false); assert!( !s.any_mask_shown(), "the tint belongs to the mode, not to the photograph" ); assert!(s.mask_shown(&id), "the eye itself is remembered"); s.set_overlay(true); assert!(s.any_mask_shown(), "and is lit again on return"); } /// TRACES: FR-DEV-3 /// Multi-select: one slider, applied to every selected layer. /// /// `toggle_active_mask` builds the selection a control-click makes, and /// `set_param`/`reset_op` are what a drag and a reset call — this pins /// down that both fan out to every layer in it rather than only the /// first, which is the whole point of selecting more than one. #[test] fn a_slider_moved_with_two_layers_selected_moves_both() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) .expect("session"); let a = session.add_gradient_mask(true).expect("first gradient"); let b = session.add_gradient_mask(false).expect("second gradient"); // Adding `b` selected it alone — build the multi-selection a // control-click would, starting from that single-layer state. session.toggle_active_mask(&a); assert_eq!(session.active_masks(), [b.clone(), a.clone()].as_slice()); assert!( !session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b), "neither layer has been touched yet" ); let row = session.rows()[0].clone(); session.set_param(row.op_index, row.param_index, row.maximum); assert!( session.mask_is_adjusted(&a) && session.mask_is_adjusted(&b), "one slider, both layers selected, both layers must show the edit" ); // And a reset walks the same set. session.reset_op(row.op_index); assert!( !session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b), "resetting with both selected must clear both, not just the one \ the panel happens to read values from" ); } /// A control-click twice — once to add, once to remove — is a no-op on /// the selection, which is the sanity check for `toggle_active_mask` /// itself before trusting anything built on it. #[test] fn toggling_a_layer_twice_returns_to_the_starting_selection() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) .expect("session"); let a = session.add_gradient_mask(true).expect("gradient"); assert_eq!(session.active_masks(), [a.clone()].as_slice()); session.toggle_active_mask(&a); assert!(session.active_masks().is_empty(), "removed by the toggle"); session.toggle_active_mask(&a); assert_eq!(session.active_masks(), [a.clone()].as_slice(), "added back"); } #[test] fn an_unzoomed_overlay_shows_the_whole_frame() { let Some(ctx) = headless() else { return }; let Some(session) = segmented_session(&ctx) else { eprintln!("no model; skipping"); return; }; let (x, y, w, h) = session.overlay_clip(); assert_eq!((x, y), (0, 0)); assert!(w > 1 && h > 1, "the whole proxy: {w}x{h}"); } /// The bug this exists for: zooming must narrow the clip, or the overlay /// keeps showing the whole picture at frame size while the canvas shows a /// detail of it. #[test] fn zooming_narrows_the_overlay_to_what_is_visible() { let Some(ctx) = headless() else { return }; let Some(mut session) = segmented_session(&ctx) else { eprintln!("no model; skipping"); return; }; let (_, _, full_w, full_h) = session.overlay_clip(); session.zoom_about(4.0, 0.5, 0.5); let (_, _, zoomed_w, zoomed_h) = session.overlay_clip(); assert!( zoomed_w < full_w && zoomed_h < full_h, "zoomed in, the overlay should show less: {zoomed_w}x{zoomed_h} \ against {full_w}x{full_h}" ); } #[test] fn panning_moves_the_overlay_with_the_photograph() { let Some(ctx) = headless() else { return }; let Some(mut session) = segmented_session(&ctx) else { eprintln!("no model; skipping"); return; }; session.zoom_about(4.0, 0.5, 0.5); let (before_x, _, _, _) = session.overlay_clip(); session.pan_by(0.3, 0.0); let (after_x, _, _, _) = session.overlay_clip(); assert!( after_x > before_x, "panning right moves the visible window right: {before_x} then {after_x}" ); } #[test] fn cropping_narrows_the_overlay_too() { let Some(ctx) = headless() else { return }; let Some(mut session) = segmented_session(&ctx) else { eprintln!("no model; skipping"); return; }; let (_, _, full_w, _) = session.overlay_clip(); session.set_crop(dr_pipeline::CropRect { x: 0.25, y: 0.25, width: 0.5, height: 0.5, }); let (x, y, w, _) = session.overlay_clip(); assert!(w < full_w, "a half-width crop shows half the overlay"); assert!(x > 0 && y > 0, "and it starts inside the frame"); } /// Nothing segmented means no overlay, and no rectangle a caller might /// divide by. #[test] fn no_segmentation_means_no_clip() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect(); let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) .expect("session"); assert_eq!(session.overlay_clip(), (0, 0, 0, 0)); } }