From 5967598a8a54f3659e35c37b4d83295d6b891fb9 Mon Sep 17 00:00:00 2001 From: rmsff <17902130+rmsff@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:13:40 +0300 Subject: [PATCH 1/2] Red Eye: a press outside the selection does nothing The press point now decides: a click (or the first point of a batch) on an unselected pixel is refused like a miss, with no pixel change and no History state, instead of being clipped to the selection. Presses inside the selection and with no selection are unchanged (#12576). --- crates/engine/src/cmd/redeye_cmds.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/engine/src/cmd/redeye_cmds.rs b/crates/engine/src/cmd/redeye_cmds.rs index 01131b5340..23d57c9e8a 100644 --- a/crates/engine/src/cmd/redeye_cmds.rs +++ b/crates/engine/src/cmd/redeye_cmds.rs @@ -255,6 +255,12 @@ fn red_eye(s: &mut Session, p: &Value) -> Result { let layer = id.or(*active); let (surf, lock) = crate::channel_cmds::target_surface(doc, layer, p)?; let Some(&first) = pts.first() else { return Err(EngineError::Other(MISS.into())) }; + // The press point decides (#12576): a press outside the selection is refused like a miss, + // not clipped. Feathered (partly selected) and off-canvas presses were not measured and keep + // today's behaviour: any value above 0 counts as inside, and off-canvas presses are not checked. + if canvas.contains(first.0, first.1) && sel.as_ref().is_some_and(|m| m.sample_channel(first.0, first.1, 0) <= 0.0) { + return Err(EngineError::Other(MISS.into())); + } // The Background always locks transparency, so only a click on a clear pixel counts. let on_locked_clear = lock && surf.rgba(first.0, first.1)[3] <= 1.0e-5; let mut total = 0u32; From 0df74d493d44d2f37ff4f38d755dfad29c4ed054 Mon Sep 17 00:00:00 2001 From: rmsff <17902130+rmsff@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:13:40 +0300 Subject: [PATCH 2/2] Test the Red Eye press outside the selection --- crates/engine/src/cmd/redeye_cmds.rs | 4 + .../src/cmd/redeye_outside_selection_tests.rs | 93 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 crates/engine/src/cmd/redeye_outside_selection_tests.rs diff --git a/crates/engine/src/cmd/redeye_cmds.rs b/crates/engine/src/cmd/redeye_cmds.rs index 23d57c9e8a..305b094846 100644 --- a/crates/engine/src/cmd/redeye_cmds.rs +++ b/crates/engine/src/cmd/redeye_cmds.rs @@ -485,3 +485,7 @@ mod redeye_history_state_tests; #[cfg(test)] #[path = "redeye_color_mode_tests.rs"] mod redeye_color_mode_tests; + +#[cfg(test)] +#[path = "redeye_outside_selection_tests.rs"] +mod redeye_outside_selection_tests; diff --git a/crates/engine/src/cmd/redeye_outside_selection_tests.rs b/crates/engine/src/cmd/redeye_outside_selection_tests.rs new file mode 100644 index 0000000000..8bc173e223 --- /dev/null +++ b/crates/engine/src/cmd/redeye_outside_selection_tests.rs @@ -0,0 +1,93 @@ +//! #12576: a Red Eye press outside the selection does nothing and adds no History state; a press +//! inside it is clipped to the selection as before, and with no selection nothing changes. + +use serde_json::{Value, json}; + +use super::*; + +/// 800 × 600 RGB/8 with eye A at (200, 200) and eye B at (500, 200): a white disc of radius 40 +/// with a red pupil (210, 40, 40) of radius 30, on a skin tone. +fn eyes() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 800, "height": 600, "mode": "rgb", "depth": 8})).unwrap(); + s.execute("edit.fill", json!({"color": "#c08a68"})).unwrap(); + for cx in [200, 500] { + for (r, color) in [(40, "#ffffff"), (30, "#d22828")] { + s.execute("select.rect", json!({"x": cx - r, "y": 200 - r, "width": 2 * r, "height": 2 * r, "ellipse": true})).unwrap(); + s.execute("edit.fill", json!({"color": color})).unwrap(); + } + } + s.execute("select.deselect", json!({})).unwrap(); + s +} + +/// The left half of eye B: x 450–499. +fn select_left_half_of_eye_b(s: &mut Session) { + s.execute("select.rect", json!({"x": 450, "y": 0, "width": 50, "height": 600})).unwrap(); +} + +/// History length, and the Background's pixels as one value per channel. +fn snapshot(s: &Session) -> (usize, Vec) { + let d = s.active().unwrap(); + let surf = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap(); + (d.history.past_len(), surf.read_region(d.doc.bounds())) +} + +/// Runs a press and returns the states it added, the first and last columns whose pixels +/// changed (`None` if none did) and the command's result. +fn press(s: &mut Session, params: Value) -> (usize, Option<(usize, usize)>, std::result::Result) { + let (n, before) = snapshot(s); + let r = s.execute("paint.redEye", params).map_err(|e| e.to_string()); + let (m, after) = snapshot(s); + let channels = before.len() / (800 * 600); + let cols: Vec = before.chunks(channels).zip(after.chunks(channels)).enumerate().filter(|(_, (a, b))| a != b).map(|(i, _)| i % 800).collect(); + let span = cols.iter().min().zip(cols.iter().max()).map(|(a, b)| (*a, *b)); + (m - n, span, r) +} + +#[test] +fn a_press_outside_the_selection_changes_nothing_and_adds_no_state() { + let mut s = eyes(); + select_left_half_of_eye_b(&mut s); + for params in [json!({"x": 500, "y": 200}), json!({"points": [[500, 200], [490, 200]]})] { + let (states, cols, r) = press(&mut s, params.clone()); + assert_eq!(cols, None, "{params}: pixels changed"); + assert_eq!(states, 0, "{params}: a history state was added"); + assert_eq!(r, Err(MISS.to_string()), "{params}"); + } +} + +#[test] +fn a_press_inside_the_selection_is_clipped_and_adds_one_state() { + // A batch whose first point is inside runs, and x 499.5 is still on the last selected column. + for params in [json!({"x": 490, "y": 200}), json!({"points": [[490, 200], [500, 200]]}), json!({"x": 499.5, "y": 200})] { + let mut s = eyes(); + select_left_half_of_eye_b(&mut s); + let (states, cols, r) = press(&mut s, params.clone()); + assert!(r.unwrap()["pixels"].as_u64().unwrap() > 0, "{params}"); + assert_eq!(states, 1, "{params}"); + let (first, last) = cols.expect("the pupil changed"); + assert!(first >= 450 && last <= 499, "{params}: clipped to the selection: columns {first}..={last}"); + } +} + +#[test] +fn with_no_selection_a_press_on_eye_b_corrects_both_halves() { + let mut s = eyes(); + let (states, cols, r) = press(&mut s, json!({"x": 500, "y": 200})); + assert!(r.unwrap()["pixels"].as_u64().unwrap() > 0); + assert_eq!(states, 1); + let (first, last) = cols.expect("the pupil changed"); + assert!(first < 500 && last > 500, "columns {first}..={last}"); +} + +#[test] +fn a_selection_covering_eye_a_gives_the_same_result_as_none() { + let mut plain = eyes(); + plain.execute("paint.redEye", json!({"x": 200, "y": 200})).unwrap(); + let mut selected = eyes(); + selected.execute("select.rect", json!({"x": 140, "y": 140, "width": 120, "height": 120})).unwrap(); + selected.execute("paint.redEye", json!({"x": 200, "y": 200})).unwrap(); + assert!(snapshot(&plain).1 != snapshot(&eyes()).1, "the press corrects eye A"); + assert!(snapshot(&selected).1 == snapshot(&plain).1, "the same pixels"); +}