Merge pull request #12894 from rmsff/fix/warp-handles-always-visible-12497

Warp: show the Default grid's handles and split-grid anchors without hovering
This commit is contained in:
David Mann authored and GitHub committed 2026-11-10 15:45:41 +01:00
commit 16d091c522
3 files changed
+163 -10

No files matched your search

@@ -457,7 +457,7 @@ fn clip_to_canvas(mut mesh: egui::Mesh, size: [f32; 2], to_screen: impl Fn(Pos2)
/// 3×3, 4×4 and 5×5 are just those even cells, with an anchor at every intersection. `guide` is
/// the split line following the pointer.
/// How near the pointer (screen px) a handle's anchor or the handle itself must be for the
/// handle to show. Everything else stays out of the way: only anchors are always drawn.
/// handle of a multi-patch grid to show. Default's twelve points are always drawn.
const HANDLE_REVEAL_PX: f32 = 56.0;
/// The anchor a handle belongs to, as `(i, j)` in the control grid (an anchor is its own).
@@ -474,8 +474,10 @@ pub(super) fn handle_anchor(i: usize, j: usize) -> (usize, usize) {
}
}
/// Anchors always, small and quiet. A handle and its arm show only while the pointer is close to
/// them or they are being dragged, so a dense grid does not bury the picture in dots.
/// Default (one patch) draws its 4 corners and 8 edge handles always, and a split grid its
/// anchors as hollow circles, as the reference does (#12497). On a split or 3×3–5×5 grid a handle
/// and its arm show only while the pointer is close to them or they are being dragged, so a
/// dense grid does not bury the picture in dots; the presets keep their square anchors.
fn draw_warp_points(painter: &egui::Painter, m: &BezierMesh, scr: impl Fn([f64; 2]) -> Pos2, pv: &TransformPreview, line: Stroke) {
let (nx, ny) = (m.nx(), m.ny());
let hover = painter.ctx().input(|i| i.pointer.hover_pos());
@@ -483,6 +485,9 @@ fn draw_warp_points(painter: &egui::Painter, m: &BezierMesh, scr: impl Fn([f64;
Some(WarpDrag::Point { index, .. }) => Some(handle_anchor(index % nx, index / nx)),
_ => None,
};
const POINT_R: f32 = 2.75;
let all_shown = single_patch(m);
let round_anchors = !matches!(super::options_bar::warp_grid_id(m), "3" | "4" | "5");
let near = |p: Pos2| hover.is_some_and(|h| h.distance(p) <= HANDLE_REVEAL_PX);
let faint = Stroke::new(0.75, line.color.gamma_multiply(0.8));
for j in 0..ny {
@@ -492,19 +497,24 @@ fn draw_warp_points(painter: &egui::Painter, m: &BezierMesh, scr: impl Fn([f64;
}
let p = scr(m.point(i, j));
if i.is_multiple_of(3) && j.is_multiple_of(3) {
let r = egui::Rect::from_center_size(p, vec2(5.0, 5.0));
painter.rect_filled(r, 0.0, Color32::WHITE);
painter.rect_stroke(r, 0.0, faint, egui::StrokeKind::Inside);
if round_anchors {
painter.circle_stroke(p, POINT_R, Stroke::new(line.width, Color32::WHITE));
painter.circle_stroke(p, POINT_R + line.width, faint);
} else {
let r = egui::Rect::from_center_size(p, vec2(5.0, 5.0));
painter.rect_filled(r, 0.0, Color32::WHITE);
painter.rect_stroke(r, 0.0, faint, egui::StrokeKind::Inside);
}
continue;
}
let (ai, aj) = handle_anchor(i, j);
let a = scr(m.point(ai, aj));
if !(near(p) || near(a) || dragged == Some((ai, aj))) {
if !(all_shown || near(p) || near(a) || dragged == Some((ai, aj))) {
continue;
}
painter.line_segment([a, p], faint);
painter.circle_filled(p, 2.75, Color32::WHITE);
painter.circle_stroke(p, 2.75, faint);
painter.circle_filled(p, POINT_R, Color32::WHITE);
painter.circle_stroke(p, POINT_R, faint);
}
}
}
@@ -618,3 +628,7 @@ fn draw_warp_overlay(painter: &egui::Painter, xf: &ViewXform, w: &Warp, pv: &Tra
}
}
}
#[cfg(test)]
#[path = "warp_handles_visible_tests.rs"]
mod warp_handles_visible_tests;
@@ -2147,7 +2147,7 @@ fn right_click_menu_offers_warp_for_a_path() {
assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.transform.warp"));
}
fn app_with_square(size: u32, fill: photocraft_geom::Rect) -> PhotocraftApp {
pub(super) fn app_with_square(size: u32, fill: photocraft_geom::Rect) -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": size, "height": size})).unwrap();
app.sync_views();
@@ -0,0 +1,139 @@
//! #12497: Warp's points show without hovering — Default's 4 corners and 8 edge handles as
//! round points, a split grid's anchors as hollow circles.
use super::super::tests::app_with_square;
use super::super::warp::warp_pointer;
use super::*;
use crate::canvas::ToolEvent;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, PhotocraftApp>;
/// A 400 × 300 layer in Warp, entered with Edit › Transform › Warp, and no pointer anywhere.
fn warping() -> Harness {
let app = app_with_square(512, photocraft_geom::Rect::new(56, 56, 456, 356));
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
app
});
h.run_steps(4);
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, "edit.transform.warp", json!({})).unwrap();
h.run_steps(3);
assert!(h.ctx.input(|i| i.pointer.hover_pos()).is_none());
h
}
fn mesh(h: &Harness) -> BezierMesh {
h.state().ui.transform.as_ref().unwrap().warp.as_ref().unwrap().mesh.clone().unwrap()
}
/// The screen positions of the grid's anchors and of the handles on its section lines.
fn points(h: &Harness) -> (Vec<Pos2>, Vec<Pos2>) {
let m = mesh(h);
let xf = ViewXform::active(h.state()).unwrap();
let (mut anchors, mut handles) = (Vec::new(), Vec::new());
for j in 0..m.ny() {
for i in 0..m.nx() {
let p = m.point(i, j);
let p = xf.to_screen(p[0] as f32, p[1] as f32);
if i % 3 == 0 && j % 3 == 0 {
anchors.push(p);
} else if i % 3 == 0 || j % 3 == 0 {
handles.push(p);
}
}
}
(anchors, handles)
}
/// The circles drawn centred on `p`, as `(radius, filled)`.
fn circles_at(h: &Harness, p: Pos2) -> Vec<(f32, bool)> {
h.output()
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Circle(c) if c.center.distance(p) < 0.5 => Some((c.radius, c.fill != Color32::TRANSPARENT)),
_ => None,
})
.collect()
}
/// A filled rectangle centred on `p` (the old square anchor).
fn square_at(h: &Harness, p: Pos2) -> bool {
h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.rect.center().distance(p) < 0.5 && r.fill != Color32::TRANSPARENT))
}
#[test]
fn default_grid_draws_its_corners_and_edge_handles_without_hovering() {
let h = warping();
let (anchors, handles) = points(&h);
assert_eq!((anchors.len(), handles.len()), (4, 8), "the Default grid");
for p in anchors {
assert!(!circles_at(&h, p).is_empty() && !square_at(&h, p), "corner {p:?} is a round point");
}
for p in handles {
assert!(circles_at(&h, p).iter().any(|(_, filled)| *filled), "edge handle {p:?} is drawn with no pointer near");
}
}
#[test]
fn split_grid_anchors_are_hollow_circles_without_hovering() {
let mut h = warping();
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, "edit.transform.splitWarpHorizontally", json!({"at": [256.0, 320.0]})).unwrap();
h.run_steps(3);
let (anchors, handles) = points(&h);
assert!(anchors.len() > 4, "the grid was split: {} anchors", anchors.len());
// Not measured in the reference: a split grid's handles still show only near the pointer.
for p in handles {
assert!(circles_at(&h, p).is_empty(), "handle {p:?} waits for the pointer");
}
for p in anchors {
let c = circles_at(&h, p);
assert!(!c.is_empty() && c.iter().all(|(_, filled)| !filled), "anchor {p:?} is a hollow circle: {c:?}");
assert!(!square_at(&h, p), "anchor {p:?} is not a square");
}
}
#[test]
fn a_three_by_three_preset_keeps_square_anchors_and_hidden_handles() {
let mut h = warping();
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, "edit.transform.warpGrid", json!({"size": 3})).unwrap();
h.run_steps(3);
let (anchors, handles) = points(&h);
assert_eq!(anchors.len(), 16, "the 3 x 3 preset");
for p in anchors {
assert!(square_at(&h, p) && circles_at(&h, p).is_empty(), "anchor {p:?} stays a square");
}
for p in handles {
assert!(circles_at(&h, p).is_empty(), "handle {p:?} waits for the pointer");
}
}
#[test]
fn dragging_the_top_third_handle_up_bends_the_edge_and_draws_its_arm() {
let mut h = warping();
// The top edge's point at 1/3 (index 1) is grabbed where it is drawn.
let at = [56.0 + 400.0 / 3.0, 56.0];
let none = egui::Modifiers::NONE;
warp_pointer(h.state_mut(), ToolEvent::Down { x: at[0], y: at[1], pressure: 1.0 }, 2.0, none);
warp_pointer(h.state_mut(), ToolEvent::Move { x: at[0], y: at[1] - 50.0, pressure: 1.0 }, 2.0, none);
warp_pointer(h.state_mut(), ToolEvent::Up { x: at[0], y: at[1] - 50.0 }, 2.0, none);
h.run_steps(3);
let m = mesh(&h);
let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
let moved = m.point(1, 0);
assert!(near(moved, [at[0], at[1] - 50.0]), "the handle moved: {moved:?}");
assert!(near(m.point(0, 0), [56.0, 56.0]), "the corner stays: {:?}", m.point(0, 0));
assert!(m.eval(0.5, 0.0)[1] < 56.0 - 1.0, "the top edge bends up: {:?}", m.eval(0.5, 0.0));
let xf = ViewXform::active(h.state()).unwrap();
let (corner, handle) = (xf.to_screen(56.0, 56.0), xf.to_screen(moved[0] as f32, moved[1] as f32));
let arm = h
.output()
.shapes
.iter()
.any(|s| matches!(&s.shape, egui::Shape::LineSegment { points: [a, b], .. } if a.distance(corner) < 0.5 && b.distance(handle) < 0.5));
assert!(arm, "a handle line runs from the top-left corner to the handle");
}