FIX-1621: let no-fill interiors pass clicks to underlying objects

This commit is contained in:
Elliot Graham
2026-10-31 19:38:08 +00:00
parent a3fbf02952
commit cd19752bb5
6 changed files with 136 additions and 7 deletions
+114
View File
@@ -5572,6 +5572,120 @@ fn edit_points_conversion_is_one_undo_step() {
assert!(preset(&s), "the only step was the conversion");
}
// #1621: No Fill changes hit-testing as well as rendering, even with a quick style.
fn no_fill_overlap(s: &mut Session, text: bool) -> (ShapeId, ShapeId) {
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let under = s
.execute(
if text { "insert.textBox" } else { "shape.insert" },
&json!({"preset": "rect", "rect": [100, 300, 300, 60], "text": if text { "UNDERLYING TEXT" } else { "" }}),
)
.unwrap()["id"]
.as_u64()
.unwrap();
let over = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80, 270, 350, 140]})).unwrap()["id"].as_u64().unwrap();
(ShapeId(under as u32), ShapeId(over as u32))
}
#[test]
fn no_fill_shape_allows_selecting_and_editing_underlying_text() {
let mut s = session();
let (under, over) = no_fill_overlap(&mut s, true);
s.execute("shape.fill", &json!({"none": true})).unwrap();
assert!(s.doc().unwrap().shape(over).unwrap().style.is_some(), "the ordinary quick style remains");
s.execute("edit.deselect", &json!({})).unwrap();
let p = deckcraft_geom::Point::new(200.0, 310.0);
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under));
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(tev(kind, p.x, p.y)).unwrap();
}
assert_eq!(s.doc().unwrap().selection.shapes, vec![under]);
assert_eq!(text_sel(&s).unwrap().shape, under);
// The transparent foreground also leaves an already-active text editor's caret alone.
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(tev(kind, p.x + 20.0, p.y)).unwrap();
}
assert_eq!(text_sel(&s).unwrap().shape, under);
s.execute("edit.deselect", &json!({})).unwrap();
// The rectangle's outline is still a target, and selecting it does not remove its style.
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(tev(kind, 80.0, 330.0)).unwrap();
}
assert_eq!(s.doc().unwrap().selection.shapes, vec![over]);
assert!(s.doc().unwrap().shape(over).unwrap().style.is_some());
// A real fill still catches clicks, even with 100% transparency (distinct from No Fill).
for transparency in [0.0, 1.0] {
s.execute("edit.select", &json!({"ids": [over.0]})).unwrap();
s.execute("shape.fill", &json!({"color": "#336699", "transparency": transparency})).unwrap();
s.execute("edit.deselect", &json!({})).unwrap();
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over));
}
}
#[test]
fn no_fill_shape_hit_testing_respects_rotation_and_pointer_tolerance() {
let mut s = session();
let (under, over) = no_fill_overlap(&mut s, false);
s.execute("shape.fill", &json!({"none": true})).unwrap();
s.execute("shape.rotate", &json!({"deg": 30})).unwrap();
let x = s.doc().unwrap().shape(over).unwrap().xfrm.unwrap();
let p = x.affine() * deckcraft_geom::Point::new(x.w / 2.0, x.h / 2.0);
let edge = x.affine() * deckcraft_geom::Point::new(0.0, x.h / 4.0);
for tol in [0.5, 3.0, 8.0] {
s.execute("edit.deselect", &json!({})).unwrap();
assert!(matches!(s.hit_test(p, tol), Some(tools::Hit::Shape { id, .. }) if id == under));
assert!(matches!(s.hit_test(edge, tol), Some(tools::Hit::Shape { id, .. }) if id == over));
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(PointerEvent { tol, ..tev(kind, p.x, p.y) }).unwrap();
}
assert_eq!(s.doc().unwrap().selection.shapes, vec![under]);
}
}
#[test]
fn no_fill_hit_testing_resolves_style_and_theme_fills() {
use std::sync::Arc;
for source in ["style", "theme", "absent"] {
let mut s = session();
let (under, over) = no_fill_overlap(&mut s, true);
{
let st = s.doc_mut().unwrap();
let doc = Arc::make_mut(&mut st.doc);
if source == "theme" {
Arc::make_mut(&mut doc.masters[0]).theme.format.fills[0] = model::Fill::None;
}
let sh = Arc::make_mut(&mut doc.slides[st.selection.slide]).shapes.iter_mut().find(|sh| sh.id == over).unwrap();
assert!(sh.fill.is_none(), "the shape has no explicit fill override");
if source == "absent" {
sh.style = None;
} else {
sh.style.as_mut().unwrap().fill_ref.0 = if source == "style" { 0 } else { 1 };
}
}
s.execute("edit.deselect", &json!({})).unwrap();
let p = deckcraft_geom::Point::new(200.0, 310.0);
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under), "{source}");
// An explicit fill takes precedence over the no-fill style/theme.
s.execute("edit.select", &json!({"ids": [over.0]})).unwrap();
s.execute("shape.fill", &json!({"color": "#336699"})).unwrap();
s.execute("edit.deselect", &json!({})).unwrap();
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over), "{source}");
}
}
#[test]
fn no_fill_text_shapes_keep_their_interior_text_target() {
let mut s = session();
let (_, over) = no_fill_overlap(&mut s, true);
s.execute("text.set", &json!({"id": over.0, "text": "FOREGROUND TEXT"})).unwrap();
s.execute("shape.fill", &json!({"none": true})).unwrap();
s.execute("edit.deselect", &json!({})).unwrap();
let p = deckcraft_geom::Point::new(200.0, 310.0);
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, text: true }) if id == over));
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
assert_eq!(text_sel(&s).unwrap().shape, over);
}
/// #931: while the text of a placeholder is being edited, a shape in front of it still takes a
/// click (as in PowerPoint); a click on the text where nothing is in front still moves the caret.
#[test]
+5 -2
View File
@@ -267,8 +267,11 @@ pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) ->
return None;
}
let has_text = matches!(sh.kind, ShapeKind::Shape) && (sh.text.as_ref().is_some_and(|t| !t.is_empty()) || sh.ph.is_some() || sh.text_box);
// Empty interiors of unfilled, text-less shapes don't catch clicks.
let filled = !matches!(sh.fill, Some(deckcraft_model::Fill::None)) || sh.style.is_some();
// Match rendering: an explicit No Fill overrides the style, and style/theme fills
// can themselves be No Fill. Empty, text-less interiors then let clicks through.
let fill =
cmd::text::ctx_for(&st.doc, &st.selection).map(|ctx| deckcraft_model::resolve::fill(&ctx, sh).0).unwrap_or_else(|| sh.fill.clone());
let filled = !matches!(fill, None | Some(deckcraft_model::Fill::None));
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
let in_geo = {
use deckcraft_geom::KurboShape;