//! Wiring the local-adjustment panel to the develop session. //! //! Everything here is translation: Slint rows in one direction, callbacks in //! the other. The decisions all live in [`crate::develop::DevelopSession`] and //! [`crate::segmentation`], which is the same split every other `*_ui` module //! in this crate draws. //! //! # The things this module does decide //! //! Selecting a mask layer re-scopes the adjust panel to that layer's chain. //! That happens because [`crate::develop::DevelopSession::rows`] answers //! differently once a layer is active, so the sync below only has to call //! `sync_rows` afterwards — there is no second panel and no duplicated //! control-building code. It is worth stating plainly because the absence of //! code is easy to mistake for an omission. //! //! And whether a segmentation that has finished is still wanted. Finding the //! subjects takes most of a second on a 22 MP frame, so it runs on a worker //! and the window polls for the answer — which means the answer can arrive //! for a photograph the user has left. [`delivery`] is that rule, kept as a //! named function with tests because both of its ways of being wrong are //! silent: applied to the wrong image it draws outlines that follow a subject //! which is not in the picture, and discarded too eagerly it throws away work //! the user waited for. use std::cell::RefCell; use std::rc::Rc; use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel}; use crate::develop::{Abandon, DevelopSession, RefinedInstance, Segmented, SessionId}; use crate::segmentation; use crate::{ sync_rows, Adjustments, AppWindow, CategoryRow, GradientHandle, MaskRow, Masking, ParamRow, PartRow, Steps, SubjectRow, }; /// What the adjust panel's heading says when the controls are global. /// /// The panel's own default too, and named because the two have to agree: a /// literal in both would eventually be a literal in one. pub(crate) const GLOBAL_SCOPE: &str = "ADJUST"; /// TRACES: FR-DEV-3 /// What the adjust panel is pointed at, for its heading. /// /// **The layer's name, not the word "adjust".** Selecting a layer re-points /// every control in that panel at that layer's chain, and the heading is the /// one piece of text a photographer cannot avoid reading on the way to a /// slider. Upper case because the heading style is, and it is the *same* /// string the row in the stack above shows — one name for one thing, so the /// selected row and the panel it scopes cannot appear to disagree. pub(crate) fn scope_label(session: &DevelopSession) -> String { match session.active_masks() { [] => GLOBAL_SCOPE.to_string(), [id] => session .mask_layers() .into_iter() .find(|(layer_id, ..)| layer_id == id) .map_or_else( || GLOBAL_SCOPE.to_string(), |(_, label, ..)| label.to_uppercase(), ), many => format!("{} LAYERS", many.len()), } } /// How often the window looks to see whether the model has finished. /// /// The interval `apply_when_ready` polls a sidecar fetch at, for the same /// reason: a tick that finds nothing costs a `try_recv` on an empty channel, /// and twenty a second is imperceptible against a result that took most of a /// second to produce. const POLL: std::time::Duration = std::time::Duration::from_millis(50); /// What to do with a segmentation that has finished. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Delivery { Apply, Discard, } /// Does this answer still belong to the photograph on screen? /// /// The session a job was taken from names the photograph it is about, and /// sessions are never reused — so re-opening the same file is a different /// answer to this, correctly: the second session has no segmentation and its /// panel says so. fn delivery(computed_for: SessionId, open: Option) -> Delivery { match open { Some(id) if id == computed_for => Delivery::Apply, _ => Delivery::Discard, } } fn open_session(session: &Rc>>) -> Option { session.borrow().as_ref().map(|s| s.id()) } /// The one segmentation that may be in flight. /// /// One at a time. Two runs would be two model loads, two proxy readbacks and /// two cores for a single answer, and the second to land would overwrite the /// first — so the cost buys nothing. The button is already insensitive while /// `segmenting` is set, but that is a property of the window; this is the /// invariant, so it lives where it can be tested. #[derive(Default)] struct Running { /// The photograph the running job was started for. started_for: Option, /// Tells that job nobody wants its answer. abandon: Abandon, /// Held here rather than by its own closure. A timer that kept itself /// alive to be able to stop itself would be an `Rc` cycle — one leaked /// timer and one leaked channel per photograph segmented — and owning it /// here also means starting the next job drops the previous timer instead /// of leaving it polling a channel nothing will send on. poll: Option>, } impl Running { /// Claim the slot for `id`, or refuse because a run for that same /// photograph is already under way. /// /// A job left over from a photograph the user has since left is *not* a /// reason to refuse: it is abandoned and displaced. Refusing would leave /// the next photograph's "Find subjects" doing nothing for as long as a /// run nobody wants takes to finish, which is exactly the wait this whole /// change exists to remove. fn start(&mut self, id: SessionId, abandon: Abandon) -> bool { if self.started_for == Some(id) { return false; } self.abandon.now(); self.started_for = Some(id); self.abandon = abandon; true } /// Give the slot up, if `id` still holds it. /// /// Abandoning on the way out covers the discard case and costs nothing in /// the success case, where the run it names has already finished. fn finish(&mut self, id: SessionId) { if self.started_for == Some(id) { self.abandon.now(); self.started_for = None; } } } /// 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` /// are already the words a photographer would use — "sky", "vegetation" — and /// a lookup table would be a second place to edit every time one is added. /// Title case on a multi-word name would be wrong anyway: "Swimming pool", not /// "Swimming Pool". fn category_label(name: &str) -> String { let mut chars = name.chars(); match chars.next() { Some(first) => first.to_uppercase().collect::() + chars.as_str(), None => String::new(), } } /// Push every mask-related property from the session into the window. pub(crate) fn sync(window: &AppWindow, session: &Rc>>) { let slot = session.borrow(); let masking = window.global::(); let Some(s) = slot.as_ref() else { masking.set_masks(ModelRc::new(VecModel::::default())); masking.set_subjects(ModelRc::new(VecModel::::default())); masking.set_categories(ModelRc::new(VecModel::::default())); masking.set_segmented(false); window .global::() .set_scope(GLOBAL_SCOPE.into()); clear_handles(window); window.set_overlay_on(false); return; }; let rows: Vec = s .mask_layers() .into_iter() .map(|(id, label, enabled, selected)| { // Read once each: both answer with a tuple, and asking three // times for three fields of one would walk the stack three times // per row per sync. let (band_lo, band_hi, band_softness) = s.mask_band(&id); let (hue, hue_width) = s.mask_hue(&id); MaskRow { stale: s.mask_is_stale(&id), adjusted: s.mask_is_adjusted(&id), kind: s.mask_kind(&id).into(), inverted: s.mask_inverted(&id), opacity: s.mask_opacity(&id), feather: s.mask_feather(&id), falloff: s.mask_falloff(&id) as i32, morphology: s.mask_morphology(&id) as i32, morph_radius: s.mask_morph_radius(&id), refine: s.mask_refine(&id), refinable: s.mask_is_refinable(&id), shapeable: s.mask_is_shapeable(&id), ranged: s.mask_is_ranged(&id), chromatic: s.mask_is_chromatic(&id), band_lo, band_hi, 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, selected, } }) .collect(); masking.set_masks(ModelRc::new(VecModel::from(rows))); let subjects: Vec = s .detected_subjects() .into_iter() .enumerate() .map(|(i, (label, score))| SubjectRow { index: i as i32, label: label.into(), score, }) .collect(); masking.set_subjects(ModelRc::new(VecModel::from(subjects))); // Already largest-first from the precompute, and already filtered to the // ones with enough coverage to be worth a control. let categories: Vec = s .categories() .iter() .map(|c| CategoryRow { name: c.name.as_ref().into(), label: category_label(&c.name).into(), coverage: c.coverage, }) .collect(); masking.set_categories(ModelRc::new(VecModel::from(categories))); // TRACES: FR-DEV-19a // Only the selected layer's parts, and only when exactly one is selected: // a stroke has one target, and so does the edge the sliders shape. let parts: Vec = match s.active_masks() { [id] => s .mask_parts(id) .into_iter() .enumerate() .map(|(i, (part_id, label, join, hidden))| PartRow { id: part_id.into(), label: label.into(), join: join as i32, selected: i == s.active_part(), base: i == 0, hidden, }) .collect(), _ => Vec::new(), }; masking.set_parts(ModelRc::new(VecModel::from(parts))); // TRACES: FR-DEV-19c // 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::>(), ))); let (radius, hardness, flow) = s.brush(); masking.set_brush_radius(radius); masking.set_brush_hardness(hardness); masking.set_brush_flow(flow); masking.set_segmented(s.has_segmentation()); window .global::() .set_scope(scope_label(s).into()); sync_handles(window, s); // The overlay is regenerated only when there is one to draw. It is a // proxy-sized RGBA buffer — a megabyte or so — and rebuilding it on every // slider event would be a memcpy per frame for a picture that changes only // when the level does. // // TRACES: FR-DEV-19c // **Stood down while a mask is being shown.** The two overlays answer // different questions — this one is what the model *detected*, the reveal // is what a layer resolves to — and both at once is a false-coloured // picture over a tinted one, through which neither can be read. The one // 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.any_mask_shown()) { Some(image) => { window.set_region_overlay(image); window.set_overlay_on(true); } None => window.set_overlay_on(false), } // Which part of it the view is showing. Pushed on every sync *and* on // every redraw, because zooming and panning change this without changing // anything else the panel shows. sync_overlay_view(window, s); } /// TRACES: FR-DEV-3 | FR-UI-3 /// Move the canvas handles to where the selected gradient now is. /// /// # Why this is not `set_gradient_handles(VecModel::from(…))` /// /// **A fresh model kills the gesture that is moving them.** The handles are a /// repeater over this model, and handing Slint a new `ModelRc` makes it throw /// the repeated items away and build new ones — including the `TouchArea` /// holding the pointer. A drag therefore moved the handle exactly once, on the /// first pointer event, and then went dead under the finger with the button /// still down. Seen on screen and invisible in the source; `develop.rs` carries /// the same warning about the parameter rows, where it broke slider drags. /// /// So the model is kept and its rows are rewritten in place. Slint updates the /// existing item rather than replacing it, and the handle stays under the /// pointer for the whole drag. /// /// Split out from [`sync`] for a second reason too: a drag emits a pointer /// event a frame, and rebuilding the mask stack and the subject list on each of /// them would be a model rewrite per frame for lists that did not change. pub(crate) fn sync_handles(window: &AppWindow, session: &DevelopSession) { let next = session.gradient_handles(); let model = handle_model(); while model.row_count() > next.len() { model.remove(model.row_count() - 1); } for (i, handle) in next.into_iter().enumerate() { if i < model.row_count() { // Only where it actually moved: an unchanged row written back is // still a change notification, and the point of this function is // to emit as few of those as the truth allows. if model.row_data(i).as_ref() != Some(&handle) { model.set_row_data(i, handle); } } else { model.push(handle); } } window.set_gradient_handles(model.into()); } /// The handles' model, held for the life of the process. /// /// One shared identity, for the reason [`sync_handles`] gives. A thread-local /// because the interface is single-threaded and this is the same shape /// `develop.rs` uses for its shared empty models. fn handle_model() -> Rc> { thread_local! { static HANDLES: Rc> = Rc::new(VecModel::default()); } HANDLES.with(Clone::clone) } /// Take the handles off the canvas, emptying the held model rather than /// replacing it — see [`sync_handles`] for why the identity is kept. fn clear_handles(window: &AppWindow) { let model = handle_model(); while model.row_count() > 0 { model.remove(model.row_count() - 1); } window.set_gradient_handles(model.into()); } /// Push the overlay's clip rectangle and angle. /// /// Separate from [`sync`] because it is called from the render path too: a pan /// changes no mask and no row, so nothing else in `sync` needs to run, and /// rebuilding the row models on every frame of a drag would be wasteful. pub(crate) fn sync_overlay_view(window: &AppWindow, session: &DevelopSession) { let (x, y, w, h) = session.overlay_clip(); window.set_overlay_clip_x(x); window.set_overlay_clip_y(y); window.set_overlay_clip_w(w); window.set_overlay_clip_h(h); window.set_overlay_angle(session.angle()); } /// Install the panel's callbacks. pub(crate) fn wire( window: &AppWindow, session: &Rc>>, rows: &Rc>, redraw: &Rc, ) { wire_segment(window, session, rows, redraw); wire_mask_refine(window, session, rows, redraw); wire_gradient_drag(window, session, redraw); wire_region_select(window, session, rows, redraw); wire_stack(window, session, rows, redraw); wire_edge_treatment(window, session, redraw); wire_layers_paint(window, session, rows, redraw); wire_layers_parts(window, session, rows, redraw); } /// --- computing the region map ----------------------------------------- fn wire_segment( window: &AppWindow, session: &Rc>>, rows: &Rc>, redraw: &Rc, ) { let running: Rc> = Rc::default(); { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); let running = running.clone(); window.global::().on_segment(move |fine| { let Some(w) = weak.upgrade() else { return }; // Both the photograph and the device come out of the session, so // no session is nothing to look at and nothing to look with. let Some(job) = session.borrow().as_ref().map(|s| s.segmentation_job()) else { return; }; let id = job.session(); let claimed = running.borrow_mut().start(id, job.abandon()); if !claimed { return; } // Set before the thread rather than by it, so there is no moment // in which the press has been taken and nothing on screen says so. // The button reads "Looking…" and goes insensitive off this. w.global::().set_segmenting(true); let (tx, rx) = std::sync::mpsc::channel(); // The only thing the button decides. Everything else about the // run is the same, which is what makes the second pass a genuine // re-run of the first rather than a different feature. let options = segmentation::Options { fine, ..segmentation::Options::default() }; std::thread::spawn(move || { // A failed send means the window stopped waiting — the user // moved on, or the app is closing. Neither is worth reporting: // the answer was unwanted before it existed. let _ = tx.send(job.run(&options)); }); watch(&w, id, rx, &session, &rows, &redraw, &running); }); } } /// --- refining one subject's mask --------------------------------------- /// /// Its own `Running` slot rather than sharing the segmentation one: the /// two answer different questions (the whole frame's subjects versus one /// already-found instance) and there is no reason a refine in flight /// should block a fresh "Find subjects", or the reverse. fn wire_mask_refine( window: &AppWindow, session: &Rc>>, rows: &Rc>, redraw: &Rc, ) { let refining: Rc> = Rc::default(); { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); let running = refining.clone(); window.global::().on_mask_refined(move |id| { let Some(w) = weak.upgrade() else { return }; let Some(index) = session .borrow() .as_ref() .and_then(|s| s.subject_instance_index(&id)) else { return; }; let Some(job) = session.borrow().as_ref().and_then(|s| s.refine_job(index)) else { return; }; let session_id = job.session(); let claimed = running.borrow_mut().start(session_id, job.abandon()); if !claimed { return; } w.global::().set_refining(true); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { let _ = tx.send(job.run()); }); watch_refine(&w, session_id, rx, &session, &rows, &redraw, &running); }); } } /// --- dragging a gradient on the photograph ---------------------------- /// /// The geometry the gesture started from, held for its duration. /// /// **A drag is applied to where the mask was when the press landed**, not /// to where it was one frame ago. Accumulating frame by frame would let the /// clamps compound — a radius dragged past its limit and back would not /// return to where it started — and would make the result depend on how /// many events the pointer happened to deliver. fn wire_gradient_drag( window: &AppWindow, session: &Rc>>, redraw: &Rc, ) { let dragging: Rc>> = Rc::new(RefCell::new(None)); { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let dragging = dragging.clone(); window.on_gradient_handle_dragged(move |role, from_x, from_y, to_x, to_y| { let Some(w) = weak.upgrade() else { return }; let origin = dragging.borrow().clone(); let started = session.borrow_mut().as_mut().and_then(|s| { s.drag_gradient_handle(role, origin.as_ref(), (from_x, from_y), (to_x, to_y)) }); if started.is_none() { return; } *dragging.borrow_mut() = started; // Only the handles, not the whole panel: nothing in the mask stack // or the subject list changed, and rewriting those models on every // frame of a drag is work for no difference. See `sync_handles` for // the sharper reason — a full `sync` would also take the gesture // out from under the finger. if let Some(s) = session.borrow().as_ref() { sync_handles(&w, s); } redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let dragging = dragging.clone(); window.on_gradient_handle_released(move || { // Forgotten on release, so the next gesture measures from wherever // this one left the mask rather than from where this one began. if dragging.borrow_mut().take().is_none() { // A press with no movement. Nothing changed, so recording a // step would put an identical snapshot on the undo stack. return; } if let Some(s) = session.borrow_mut().as_mut() { s.commit_gradient_drag(); } if let Some(w) = weak.upgrade() { w.global::() .set_can_undo(session.borrow().as_ref().is_some_and(|s| s.can_undo())); w.global::() .set_can_redo(session.borrow().as_ref().is_some_and(|s| s.can_redo())); } }); } } /// --- selecting on the photograph -------------------------------------- fn wire_region_select( window: &AppWindow, session: &Rc>>, rows: &Rc>, redraw: &Rc, ) { { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.on_region_picked(move |x, y, _add| { let Some(w) = weak.upgrade() else { return }; let picked = session .borrow_mut() .as_mut() .and_then(|s| s.select_region_at(x, y)); if picked.is_none() { // A click that hit no region is not an error and must not // clear the selection: the most likely cause is the letterbox // margin, and losing a selection to a near-miss is the kind of // thing that makes a tool feel hostile. return; } sync(&w, &session); sync_rows(&w, &rows, &session); redraw(&w); }); } } /// --- the stack --------------------------------------------------------- fn wire_stack( window: &AppWindow, session: &Rc>>, rows: &Rc>, redraw: &Rc, ) { { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window .global::() .on_mask_selected(move |id, extend| { let Some(w) = weak.upgrade() else { return }; { let mut slot = session.borrow_mut(); let Some(s) = slot.as_mut() else { return }; if extend { // Control/command-click: add or remove this one layer, // keeping whatever else was already selected. s.toggle_active_mask(&id); } else if s.active_masks() == [id.to_string()] { // Clicking the sole selected layer again deselects it, // which is how the panel gets back to the whole // photograph without a separate "edit globally" control. // Only when it is the *only* one selected — a plain click // on one row of a multi-selection collapses to just that // row rather than clearing everything, which is the more // useful reading of "I clicked a specific layer". s.set_active_mask(None); } else { s.set_active_mask(Some(&id)); } } sync(&w, &session); // The scope changed, so the adjust panel below is now describing a // different chain. sync_rows(&w, &rows, &session); // TRACES: FR-DEV-19c // And so has the picture, when a mask is being shown: the // reveal follows the selection, so choosing another layer // draws another mask. redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.global::().on_mask_removed(move |id| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.remove_mask(&id); } sync(&w, &session); sync_rows(&w, &rows, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window.global::().on_mask_toggled(move |id, on| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_mask_enabled(&id, on); } sync(&w, &session); redraw(&w); }); } { // TRACES: FR-DEV-16 // The keyboard's copy of the ring. A mixed selection goes to shown: // a layer nobody can see is the one being asked about, and turning // the rest off to match it would hide work in the name of hiding it. let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window.global::().on_selected_toggled(move || { let Some(w) = weak.upgrade() else { return }; { let mut slot = session.borrow_mut(); let Some(s) = slot.as_mut() else { return }; let ids: Vec = s.active_masks().to_vec(); if ids.is_empty() { return; } let all_shown = ids .iter() .all(|id| s.masks().get(id).is_some_and(|l| l.enabled)); for id in &ids { s.set_mask_enabled(id, !all_shown); } } sync(&w, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window .global::() .on_mask_invert_toggled(move |id, on| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_mask_invert(&id, on); } sync(&w, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window .global::() .on_mask_opacity_changed(move |id, value| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_mask_opacity(&id, value); } sync(&w, &session); redraw(&w); }); } } /// --- the edge treatment ------------------------------------------------- /// /// All four read one distance field, so all four are live: nothing here /// rebuilds anything except a compound morphology, which `develop` keys on /// separately. fn wire_edge_treatment( window: &AppWindow, session: &Rc>>, redraw: &Rc, ) { { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window .global::() .on_mask_feather_changed(move |id, value| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_mask_feather(&id, value); } sync(&w, &session); redraw(&w); }); } // TRACES: FR-DEV-10 // A range mask's band, and a colour range's arc. // // Two handlers rather than five, because a band is one control: moving its // lower bound past its upper swaps them, and a callback per field would // have to answer that with a third of the answer. Each carries the whole // of the thing it changes and the model puts it back in order. // // Cheap, unlike the refine below: a band is three floats in a uniform, so // the mask redraws in the rasterisation the redraw was going to run // anyway and nothing is rebuilt on the CPU at all. { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window .global::() .on_mask_band_changed(move |id, lo, hi, softness| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_mask_band(&id, lo, hi, softness); } sync(&w, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window .global::() .on_mask_hue_changed(move |id, hue, width| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_mask_hue(&id, hue, width); } sync(&w, &session); redraw(&w); }); } // The refine control. Same shape as the feather's handler, and a separate // one for the same reason every other mask control has its own: the // callback carries the layer id, so there is nothing to share. // // `SliderRow::changed` fires once per completed gesture rather than once // per movement, which is what this control needs — it rebuilds the // layer's distance field, where a feather only re-reads one. { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window .global::() .on_mask_refine_changed(move |id, value| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_mask_refine(&id, value); } sync(&w, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window .global::() .on_mask_falloff_picked(move |id, index| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_mask_falloff(&id, index.max(0) as usize); } sync(&w, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window .global::() .on_mask_morphology_picked(move |id, index| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_mask_morphology(&id, index.max(0) as usize); } sync(&w, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window .global::() .on_mask_morph_radius_changed(move |id, value| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_mask_morph_radius(&id, value); } sync(&w, &session); redraw(&w); }); } } /// --- adding layers ------------------------------------------------------ /// TRACES: FR-DEV-19b /// Painting. Three handlers for one gesture, and the middle one runs per /// pointer report — so it does the least it can: map the point, record it, /// redraw. No sync, because nothing the panel shows changes while a finger /// is down, and rebuilding eight rows a frame is how a brush starts to lag. fn wire_layers_paint( window: &AppWindow, session: &Rc>>, rows: &Rc>, redraw: &Rc, ) { { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window.on_paint_began(move |x, y| { let Some(w) = weak.upgrade() else { return }; let erase = w.global::().get_tool() == 2; if let Some(s) = session.borrow_mut().as_mut() { s.begin_mask_stroke(x, y, erase); } redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window.on_paint_moved(move |x, y| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.extend_mask_stroke(x, y); } redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.on_paint_ended(move || { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.end_mask_stroke(); } // Now the panel is worth rebuilding: the gesture may have joined a // part to the mask, and the history has a step in it. sync(&w, &session); sync_rows(&w, &rows, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); window .global::() .on_brush_changed(move |radius, hardness, flow| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_brush(radius, hardness, flow); } sync(&w, &session); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window.global::().on_tool_picked(move |tool| { // **The tool is written back here, and that is the whole of this // handler.** `Masking.tool` is an `in` property: the panel reads // it to light the right chip and `app.slint` reads it to decide // whether a drag on the photograph paints, but only Rust may write // it. So a version of this that recorded nothing left the strip // reporting "Select" however many times "Paint" was pressed, and // the paint area was never armed — the brush, the parts, the whole // of FR-DEV-19b, reachable from no control in the application. // // It still changes no pixel, which is what the previous note was // getting at: the tool arms a gesture and belongs to the // interface, not to the edit, so it takes no history step and is // not stored. let Some(w) = weak.upgrade() else { return }; let tool = tool.clamp(0, 2); w.global::().set_tool(tool); // TRACES: FR-DEV-19c // Arming a brush shows the mask, if nothing was showing it. The // same nudge `on_part_added` makes and on the same argument: a // photographer about to correct an edge by hand needs to see the // edge, and "the tool did nothing" is what a stroke into an // invisible mask looks like. // // A nudge on an explicit action, never a standing rule. Turning // the view off and then picking the eraser leaves it off — the // trap `overlay-hidden` documents is that an automatic reveal // which re-arms a switch somebody turned off is worse than no // automatic reveal at all. if tool > 0 { if let Some(s) = session.borrow_mut().as_mut() { if let Some(id) = s.active_mask().map(str::to_owned) { if !s.mask_shown(&id) { s.set_mask_shown(&id, true); } } } } // Re-synced because arming the brush is what makes the parts of a // mask worth showing. sync(&w, &session); redraw(&w); }); } // 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::() .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::() .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::() .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); }); } } /// Parts of the active mask, and the buttons that add a new layer — /// gradient, brush, range, category or subject. fn wire_layers_parts( window: &AppWindow, session: &Rc>>, rows: &Rc>, redraw: &Rc, ) { // TRACES: FR-DEV-19a { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window .global::() .on_part_selected(move |_id, index| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_active_part(index.max(0) as usize); } sync(&w, &session); sync_rows(&w, &rows, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.global::().on_part_added(move |id, join| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.add_mask_part(&id, join.max(0) as usize); } // Armed, because adding an empty painted part and leaving the // pointer in "select" would look like the button did nothing — // the part covers no pixels until something is painted into it. if w.global::().get_tool() == 0 { w.global::().set_tool(1); } sync(&w, &session); sync_rows(&w, &rows, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window .global::() .on_part_removed(move |id, index| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.remove_mask_part(&id, index.max(0) as usize); } sync(&w, &session); sync_rows(&w, &rows, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); window .global::() .on_part_join_picked(move |id, index, join| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_mask_part_join(&id, index.max(0) as usize, join.max(0) as usize); } sync(&w, &session); 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::() .on_part_hidden_toggled(move |id, index, hidden| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.set_mask_part_hidden(&id, index.max(0) as usize, hidden); } sync(&w, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.global::().on_add_gradient(move |radial| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.add_gradient_mask(radial); } sync(&w, &session); sync_rows(&w, &rows, &session); redraw(&w); }); } // TRACES: FR-DEV-19b { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.global::().on_add_brush(move || { let Some(w) = weak.upgrade() else { return }; let made = session .borrow_mut() .as_mut() .and_then(|s| s.add_brush_mask()) .is_some(); // Armed, and this is not a convenience: the layer covers no pixel // until something is painted into it, so a press that made a row // and left the pointer in "select" would be indistinguishable from // a button that did nothing. The same reasoning as `on_part_added` // below. Showing the mask is `DevelopSession::show_new_mask`'s // job and every route to a new layer goes through it, so only the // tool is set here. if made { w.global::().set_tool(1); } sync(&w, &session); sync_rows(&w, &rows, &session); redraw(&w); }); } // TRACES: FR-DEV-10 { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.global::().on_add_range(move |chromatic| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.add_range_mask(chromatic); } sync(&w, &session); sync_rows(&w, &rows, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.global::().on_add_category(move |name| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.add_category_mask(&name); } sync(&w, &session); sync_rows(&w, &rows, &session); redraw(&w); }); } { let weak = window.as_weak(); let session = session.clone(); let redraw = redraw.clone(); let rows = rows.clone(); window.global::().on_add_subject(move |index| { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { s.add_subject_mask(index.max(0) as usize); } sync(&w, &session); sync_rows(&w, &rows, &session); redraw(&w); }); } } /// Wait for a segmentation without the window waiting with it. /// /// A repeating timer rather than a callback from the worker, because Slint /// properties may only be touched from the thread that owns the event loop and /// this is the shape `apply_when_ready` already uses for the sidecar fetch. /// /// Two things end the wait, and only one of them is the answer arriving. The /// other is the photograph changing underneath: that is checked *first*, on /// every tick, so the abandonment reaches the worker while it may still be in /// the proxy — and so the next photograph's "Find subjects" is available /// within a tick rather than at the end of a run nobody wants. fn watch( window: &AppWindow, id: SessionId, rx: std::sync::mpsc::Receiver, String>>, session: &Rc>>, rows: &Rc>, redraw: &Rc, running: &Rc>, ) { let weak = window.as_weak(); let session = session.clone(); let rows = rows.clone(); let redraw = redraw.clone(); let slot = running.clone(); let timer = Rc::new(slint::Timer::default()); let stop = Rc::downgrade(&timer); timer.start(slint::TimerMode::Repeated, POLL, move || { let done = || { if let Some(t) = stop.upgrade() { t.stop(); } }; if delivery(id, open_session(&session)) == Delivery::Discard { // Nothing is touched on the way out. `segmenting` belongs to the // photograph now open — which may well have a run of its own going // — and the only route to here is through `reset`, which cleared // it for that image already. slot.borrow_mut().finish(id); done(); return; } let Ok(answer) = rx.try_recv() else { return }; slot.borrow_mut().finish(id); done(); let Some(w) = weak.upgrade() else { return }; match answer { Ok(Some(found)) => { if let Some(s) = session.borrow_mut().as_mut() { s.adopt_segmentation(found); } } // Abandoned. Unreachable from here in practice — the check above // catches every case that raises the flag — and handled rather // than asserted, because the cost of being wrong is one wasted // sync against a panic in a photographer's hands. Ok(None) => {} Err(e) => log::warn!("segmentation failed: {e}"), } w.global::().set_segmenting(false); sync(&w, &session); sync_rows(&w, &rows, &session); redraw(&w); }); running.borrow_mut().poll = Some(timer); } /// [`watch`]'s counterpart for a refine pass — same shape, same reason: the /// window polls rather than being called back from the worker, and a /// photograph the user has left discards its answer instead of applying it. fn watch_refine( window: &AppWindow, id: SessionId, rx: std::sync::mpsc::Receiver, String>>, session: &Rc>>, rows: &Rc>, redraw: &Rc, running: &Rc>, ) { let weak = window.as_weak(); let session = session.clone(); let rows = rows.clone(); let redraw = redraw.clone(); let slot = running.clone(); let timer = Rc::new(slint::Timer::default()); let stop = Rc::downgrade(&timer); timer.start(slint::TimerMode::Repeated, POLL, move || { let done = || { if let Some(t) = stop.upgrade() { t.stop(); } }; if delivery(id, open_session(&session)) == Delivery::Discard { slot.borrow_mut().finish(id); done(); return; } let Ok(answer) = rx.try_recv() else { return }; slot.borrow_mut().finish(id); done(); let Some(w) = weak.upgrade() else { return }; match answer { Ok(Some(refined)) => { if let Some(s) = session.borrow_mut().as_mut() { s.adopt_refined(refined); } } // Abandoned, or the model found nothing of the same class in the // padded crop — both leave the instance exactly as it was. Ok(None) => {} Err(e) => log::warn!("refine failed: {e}"), } w.global::().set_refining(false); sync(&w, &session); sync_rows(&w, &rows, &session); redraw(&w); }); running.borrow_mut().poll = Some(timer); } /// Clear the panel when the open image changes. /// /// Its own function rather than a call to [`sync`] with an empty session, /// because the *window* state has to be reset too: the overlay and the scope /// are properties of looking at one photograph, and carrying them to the next /// one would draw a region map over an image that has none and name a heading /// after a layer that is not there. Picking is not among them any more — it /// follows the view mode, which `reset_view_state` returns to `photo`. pub(crate) fn reset(window: &AppWindow) { window.set_overlay_on(false); let masking = window.global::(); masking.set_segmenting(false); masking.set_refining(false); masking.set_segmented(false); // TRACES: FR-DEV-19b | FR-DEV-19c // The tool and the mask view are both about one selected layer, and the // 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_style(0); masking.set_masks(ModelRc::new(VecModel::::default())); masking.set_subjects(ModelRc::new(VecModel::::default())); masking.set_categories(ModelRc::new(VecModel::::default())); window .global::() .set_scope(GLOBAL_SCOPE.into()); clear_handles(window); } #[cfg(test)] mod tests { use super::*; /// A session over a flat frame. No segmentation, which is deliberate: a /// gradient needs none, and the tests below are about scope rather than /// about what the model found. fn session() -> Option { let ctx = pollster::block_on(dr_gpu::GpuContext::new_headless()).ok()?; let rgba: Vec = (0..32 * 32).flat_map(|_| [128u8, 128, 128, 255]).collect(); DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL).ok() } /// TRACES: FR-DEV-19b /// Pressing Paint on a mask the model made must not answer with a /// refusal about a subject not being a brush. The correction the /// photographer is about to make *is* a new part, so it is made. #[test] fn painting_on_a_mask_that_cannot_hold_a_stroke_joins_a_part_that_can() { let Some(mut s) = session() else { eprintln!("no adapter; skipping"); return; }; let id = s.add_gradient_mask(true).expect("radial"); s.set_active_mask(Some(&id)); assert_eq!(s.mask_parts(&id).len(), 1, "one selection to begin with"); assert!(s.begin_mask_stroke(0.5, 0.5, false), "the stroke started"); s.extend_mask_stroke(0.55, 0.5); s.end_mask_stroke(); let parts = s.mask_parts(&id); assert_eq!(parts.len(), 2, "a painted part was joined"); assert_eq!(parts[1].1, "brush"); assert_eq!(s.active_part(), 1, "and the tools point at it"); } /// A stroke is one decision. Recording per pointer report would make /// taking a mark back cost as many presses as making it did. #[test] fn a_whole_stroke_is_one_step_in_the_history() { let Some(mut s) = session() else { eprintln!("no adapter; skipping"); return; }; let id = s.add_gradient_mask(true).expect("radial"); s.set_active_mask(Some(&id)); let before = s.history_rows().len(); assert!(s.begin_mask_stroke(0.4, 0.4, false)); for i in 0..12 { s.extend_mask_stroke(0.4 + i as f32 * 0.02, 0.5); } s.end_mask_stroke(); // Two: joining the part is its own step, and the stroke is the other. assert_eq!( s.history_rows().len(), before + 2, "a stroke recorded more than one step" ); } /// A gesture the window cancels leaves nothing behind — including in the /// history, because as far as the photographer is concerned nothing /// happened. #[test] fn a_cancelled_stroke_leaves_no_mark() { let Some(mut s) = session() else { eprintln!("no adapter; skipping"); return; }; let id = s.add_gradient_mask(true).expect("radial"); s.set_active_mask(Some(&id)); assert!(s.begin_mask_stroke(0.4, 0.4, false)); s.extend_mask_stroke(0.5, 0.5); let steps = s.history_rows().len(); s.cancel_mask_stroke(); assert_eq!(s.history_rows().len(), steps, "nothing was recorded"); assert!( s.masks() .get(&id) .expect("layer") .part(1) .expect("part") .strokes() .is_empty(), "and the abandoned stroke is not in the mask" ); } /// TRACES: FR-DEV-19a /// The edge controls point at the part that is selected, so that dragging /// the feather while a correction is selected does not silently soften the /// model's mask underneath it. #[test] fn the_edge_controls_shape_the_selected_part() { let Some(mut s) = session() else { eprintln!("no adapter; skipping"); return; }; let id = s.add_gradient_mask(true).expect("radial"); s.set_active_mask(Some(&id)); s.set_mask_feather(&id, 0.03); let index = s.add_mask_part(&id, 0).expect("a part was joined"); assert_eq!(s.active_part(), index); s.set_mask_feather(&id, 0.0); assert_eq!(s.mask_feather(&id), 0.0, "the selected part was shaped"); s.set_active_part(0); assert_eq!(s.mask_feather(&id), 0.03, "and the base was left alone"); } /// TRACES: FR-DEV-19a /// The panel's third join reaches the layer: the "∩ Intersect" button /// joins an intersecting part, the part row reports it as the chip's third /// state, and cycling the chip on from there comes back round to a union. #[test] fn the_intersect_button_joins_a_part_that_intersects() { use dr_pipeline::mask::Join; let Some(mut s) = session() else { eprintln!("no adapter; skipping"); return; }; let id = s.add_gradient_mask(true).expect("radial"); s.set_active_mask(Some(&id)); let index = s.add_mask_part(&id, 2).expect("a part was joined"); let join = |s: &DevelopSession| { s.masks() .get(&id) .expect("layer") .part(index) .expect("part") .join }; assert_eq!(join(&s), Join::Intersect); assert_eq!(s.mask_parts(&id)[index].2, 2, "the chip shows ∩"); s.set_mask_part_join(&id, index, (2 + 1) % 3); assert_eq!(join(&s), Join::Union, "and the chip cycles back to +"); } /// TRACES: FR-DEV-3 | FR-UI-1 /// The fault this pass exists for, stated as a test. /// /// Selecting a mask layer re-points every control in the adjust panel at /// that layer's chain. Before this, the only thing that said so was a /// caption in a *different* panel, which a photographer reaching for the /// exposure slider has no reason to read. The heading of the panel that /// changed now carries the answer, so the two cannot be read apart — and /// this asserts they cannot come apart either. #[test] fn the_heading_says_which_chain_the_controls_are_pointed_at() { let Some(mut s) = session() else { eprintln!("no adapter; skipping"); return; }; assert_eq!(scope_label(&s), GLOBAL_SCOPE, "nothing selected"); let id = s .add_gradient_mask(false) .expect("a gradient needs no model"); assert_ne!( scope_label(&s), GLOBAL_SCOPE, "adding a layer selects it, so the panel is already scoped to it \ and must already say so" ); // And the name is the one the row in the stack shows. Two names for // one layer would let the selected row and the panel it scopes appear // to disagree. let row = s .mask_layers() .into_iter() .find(|(layer_id, ..)| *layer_id == id) .expect("the layer is in the stack"); assert_eq!(scope_label(&s), row.1.to_uppercase()); s.set_active_mask(None); assert_eq!( scope_label(&s), GLOBAL_SCOPE, "clearing the selection must put the heading back, or the panel \ would go on naming a layer it is no longer editing" ); } /// TRACES: FR-UI-5 /// Leaving local mode is what clears the selection, and this is the half /// of it that can be tested without a window. /// /// The mode handler in `lib.rs` calls `set_active_mask(None)`; what has to /// be true afterwards is that the controls are global *and say so*. A mode /// that was left with a layer still selected would leave thirty sliders /// pointed at a region of the photograph with nothing on screen saying it. #[test] fn clearing_the_selection_returns_the_rows_to_the_whole_photograph() { let Some(mut s) = session() else { eprintln!("no adapter; skipping"); return; }; // The scope is invisible in the *shape* of the panel — a layer holds // the same chain the frame does, so both produce the same rows in the // same order. It is only visible in what those rows read, which is // precisely why the fault was silent: the panel looks identical either // way and means something different. // // Addressed by index rather than by name, because no part of the // frontend may route by a parameter's identity (FR-DEV-3a). let first = s.rows()[0].clone(); s.set_param(first.op_index, first.param_index, first.maximum); assert_eq!(s.rows()[0].value, first.maximum, "the global chain moved"); // Adding a layer selects it, so the same row is now the layer's. s.add_gradient_mask(true).expect("gradient"); assert_eq!( s.rows()[0].value, first.default_value, "the same control, pointed somewhere else and reading its own \ value — the whole hazard, in one row" ); s.set_active_mask(None); assert_eq!( s.rows()[0].value, first.maximum, "and leaving the layer puts the frame's value back" ); assert_eq!(scope_label(&s), GLOBAL_SCOPE); } /// TRACES: FR-UI-3 /// The handles' model keeps one identity for the life of the process. /// /// **This is what makes a drag last longer than one frame.** The handles /// are a repeater over this model, and a *new* `ModelRc` makes Slint throw /// the repeated items away and build fresh ones — taking the `TouchArea` /// that holds the pointer with them. The symptom is precise and was seen /// on screen before it was understood: the handle jumps once, on the first /// pointer event, and then sits dead under a finger that is still down. /// /// It cannot be asserted through a window without a Slint backend, so it is /// asserted where it is decided. Every path that touches the handles — /// `sync_handles` and `clear_handles` — goes through this one model. #[test] fn the_handles_are_one_model_rewritten_rather_than_a_new_one_each_time() { assert!( Rc::ptr_eq(&handle_model(), &handle_model()), "a fresh model per sync destroys the gesture that is moving the \ handles" ); } /// TRACES: FR-DEV-3 | FR-UI-3 /// A gradient offers handles; a mask with nothing to drag offers none. /// /// This is the panel's whole test for whether to draw anything on the /// canvas, so it is worth pinning: handles over a subject mask would /// suggest an outline that cannot be moved can be. #[test] fn only_a_selected_gradient_puts_handles_on_the_canvas() { let Some(mut s) = session() else { eprintln!("no adapter; skipping"); return; }; assert!(s.gradient_handles().is_empty(), "nothing selected"); s.add_gradient_mask(false).expect("linear"); assert_eq!(s.gradient_handles().len(), 3, "centre, width and rotation"); s.add_gradient_mask(true).expect("radial"); assert_eq!( s.gradient_handles().len(), 3, "centre and two semi-axes — the major one carries the angle, so \ an ellipse needs no fourth handle to say it twice" ); s.set_active_mask(None); assert!( s.gradient_handles().is_empty(), "a gradient nobody has selected is not being edited" ); } /// The ordinary case: the answer comes back to the photograph that asked. #[test] fn a_result_for_the_open_photograph_is_applied() { let open = SessionId::next(); assert_eq!(delivery(open, Some(open)), Delivery::Apply); } /// The bug this rule exists for. Two thirds of a second is long enough to /// press "Find subjects", think better of it and swipe to the next frame — /// and the result that lands then describes a picture nobody is looking at. #[test] fn a_result_for_a_photograph_the_user_has_left_is_discarded() { let asked = SessionId::next(); let now_open = SessionId::next(); assert_eq!(delivery(asked, Some(now_open)), Delivery::Discard); } /// Back to the library, or a frame that failed to decode: there is no /// session to apply anything to. #[test] fn a_result_arriving_with_nothing_open_is_discarded() { assert_eq!(delivery(SessionId::next(), None), Delivery::Discard); } /// Re-opening the same file is a new session, so a result outstanding from /// the previous visit does not land in it. Conservative on purpose: the /// alternative is keying on the path, and the second visit's panel would /// then be filled from a proxy rendered before the first visit's edits. #[test] fn re_opening_the_same_file_does_not_inherit_a_result() { let first_visit = SessionId::next(); let second_visit = SessionId::next(); assert_ne!(first_visit, second_visit); assert_eq!(delivery(first_visit, Some(second_visit)), Delivery::Discard); } /// Pressing the button twice must not put two model runs on two cores for /// one answer. #[test] fn one_photograph_cannot_start_two_segmentations() { let mut running = Running::default(); let id = SessionId::next(); assert!(running.start(id, Abandon::default())); assert!(!running.start(id, Abandon::default()), "already looking"); } /// And the other half of that rule: a run left over from a photograph the /// user has left must not hold the slot against the one now on screen. #[test] fn a_new_photograph_displaces_a_run_nobody_is_waiting_for() { let mut running = Running::default(); let stale = Abandon::default(); assert!(running.start(SessionId::next(), stale.clone())); assert!(running.start(SessionId::next(), Abandon::default())); assert!( stale.asked(), "the displaced run is told its answer is unwanted" ); } /// A run that finishes releases the slot, so the same photograph can be /// segmented again — after a failed attempt worth retrying, say. #[test] fn finishing_frees_the_slot() { let mut running = Running::default(); let id = SessionId::next(); assert!(running.start(id, Abandon::default())); running.finish(id); assert!(running.start(id, Abandon::default())); } /// A timer left over from an earlier job reports in after the slot has /// moved on. It must not cancel the run that now holds it, or the user /// would be able to start a third while the second is still going. #[test] fn a_late_finish_does_not_release_someone_elses_slot() { let mut running = Running::default(); let departed = SessionId::next(); assert!(running.start(departed, Abandon::default())); let now_open = SessionId::next(); let live = Abandon::default(); assert!(running.start(now_open, live.clone())); running.finish(departed); assert!(!live.asked(), "the live run is left alone"); assert!( !running.start(now_open, Abandon::default()), "and it still holds the slot" ); } } /// TRACES: FR-DEV-19b | FR-DEV-19c /// The order the canvas handlers are declared in, which decides which of them /// receives a press. /// /// Not a unit test of anything this file does, and here rather than nowhere /// because there is nowhere better: it guards a fault that compiles, passes /// every other test, and takes four separate tools out of the application at /// once. /// /// Slint hit-tests siblings front-to-back and a `TouchArea` grabs the first /// press it is offered, so **the last handler declared is the one that wins**. /// The full-canvas pan/zoom area must therefore come *first*, and `app.slint` /// had it last — with each of the four handlers behind it carrying a comment /// claiming it sat "above the pan/zoom area" because it was written earlier in /// the file. #[cfg(test)] mod canvas_order { /// Where each canvas handler is declared, by byte offset. fn at(needle: &str) -> usize { let source = include_str!("../ui/app.slint"); source .find(needle) .unwrap_or_else(|| panic!("app.slint no longer contains `{needle}`")) } #[test] fn the_pan_backstop_is_declared_before_every_tool_it_would_swallow() { let pan = at("--- the pan/zoom backstop ---"); for tool in [ "pick := TouchArea", "paint := TouchArea", "Develop.repairing && root.total > 0", "root.sampling && root.total > 0", ] { assert!( pan < at(tool), "`{tool}` is declared before the pan/zoom area, so the pan area \ is in front of it and will take every press it was meant to get" ); } } /// The other end of the same rule: a handle drawn on the photograph has to /// beat the tool armed over it, or a gradient cannot be moved while one is. #[test] fn the_gradient_handles_are_declared_last() { let handles = at("GradientHandles {"); for behind in ["pick := TouchArea", "paint := TouchArea"] { assert!( handles > at(behind), "`{behind}` is declared after GradientHandles and would swallow \ a press meant for a handle" ); } } }