mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 19:17:06 -06:00
Canvas: the resize cursor follows the handle's diagonal, whatever the shape (#745)
Two gaps in the resize cursor, both found by a review of the fix: - The handle's direction was taken from the box centre to the handle, so a wide rectangle's corner pointed almost along an edge: a 400x80 shape's NW corner showed a horizontal cursor. A corner drags along the diagonal whatever the shape's proportions, so the direction is now normalised - the diagonal at 45 degrees - and only the shape's rotation and flips turn it. - The cursor read the shape's own box, but the handles are drawn from the effective one, which a placeholder inherits from its layout. Such a placeholder showed the default arrow instead of a resize cursor. The tests cover both: a wide shape keeps its corner cursors diagonal, and a placeholder that inherits its box has a resize cursor. Red-proof: with the aspect ratio back in the direction, the wide shape's NW corner shows a horizontal cursor; with the shape's own box, the placeholder shows the default arrow.
This commit is contained in:
@@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) |
|
||||
| 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) |
|
||||
| 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection |
|
||||
| 2026-10-15 | Canvas: the resize cursor follows the handle's rotated diagonal — rotated shapes showed the wrong diagonal cursor (#745) |
|
||||
| 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions |
|
||||
| 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) |
|
||||
| 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute |
|
||||
|
||||
@@ -758,12 +758,7 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
|
||||
(ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair,
|
||||
(ToolKind::TextBox, _) => egui::CursorIcon::Text,
|
||||
(ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair,
|
||||
(_, Some(Hit::Resize { handle, .. })) => match handle {
|
||||
0 | 4 => egui::CursorIcon::ResizeNwSe,
|
||||
2 | 6 => egui::CursorIcon::ResizeNeSw,
|
||||
1 | 5 => egui::CursorIcon::ResizeVertical,
|
||||
_ => egui::CursorIcon::ResizeHorizontal,
|
||||
},
|
||||
(_, Some(Hit::Resize { id, handle })) => resize_cursor(&app.session, *id, *handle),
|
||||
(_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias,
|
||||
(_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn,
|
||||
(_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow,
|
||||
@@ -1347,10 +1342,63 @@ fn grid_fits(span: f32, step: f32) -> bool {
|
||||
span.is_finite() && span > 0.0 && step >= 1.0 && span / step <= 800.0
|
||||
}
|
||||
|
||||
/// The resize cursor for `handle` of shape `id`: the handle's resize axis is computed in the
|
||||
/// shape's own space and mapped through its transform, so a rotated or flipped shape shows the
|
||||
/// diagonal that matches where the drag actually goes (#745). Without a transform the mapping
|
||||
/// reduces to the classic NW/NE/E/SE/S/SW/W assignment.
|
||||
fn resize_cursor(session: &deckcraft_engine::Session, id: deckcraft_model::ShapeId, handle: u8) -> egui::CursorIcon {
|
||||
// The resize handles are drawn from the shape's effective box (a placeholder inherits it
|
||||
// from its layout), so the cursor follows the same one.
|
||||
let Some(d) = session.active() else { return egui::CursorIcon::Default };
|
||||
let Some(sh) = d.shape(id) else { return egui::CursorIcon::Default };
|
||||
let x = deckcraft_engine::cmd::xfrm_of(&d.doc, &d.selection, sh);
|
||||
// The resize axis in the shape's own coordinates, as (dx, dy) from the box centre toward
|
||||
// the handle. Corners resize along the diagonal, edge handles along their edge. The
|
||||
// direction is normalised - the diagonal at 45 degrees whatever the shape's proportions -
|
||||
// so a wide rectangle still shows a diagonal cursor on its corners.
|
||||
let (dx, dy): (f64, f64) = match handle {
|
||||
0 => (-1.0, -1.0),
|
||||
1 => (0.0, -1.0),
|
||||
2 => (1.0, -1.0),
|
||||
3 => (1.0, 0.0),
|
||||
4 => (1.0, 1.0),
|
||||
5 => (0.0, 1.0),
|
||||
6 => (-1.0, 1.0),
|
||||
_ => (-1.0, 0.0),
|
||||
};
|
||||
// Map through the linear part of the transform (rotation, flips): two points along the
|
||||
// axis, the difference is the slide-space direction.
|
||||
let centre = Point::new(x.x + x.w / 2.0, x.y + x.h / 2.0);
|
||||
let a = x.affine();
|
||||
let p0 = a * centre;
|
||||
let p1 = a * Point::new(centre.x + dx, centre.y + dy);
|
||||
let v = p1 - p0;
|
||||
if !v.x.is_finite() || !v.y.is_finite() || (v.x.abs() < 1e-12 && v.y.abs() < 1e-12) {
|
||||
return egui::CursorIcon::Default;
|
||||
}
|
||||
// Angle in [0, 180): 0° = right (horizontal), 45° = down-right (NW-SE), 90° = down
|
||||
// (vertical), 135° = down-left (NE-SW). Pick the closest of the four.
|
||||
let mut deg = v.y.atan2(v.x).to_degrees();
|
||||
if deg < 0.0 {
|
||||
deg += 180.0;
|
||||
}
|
||||
if (22.5..67.5).contains(°) {
|
||||
egui::CursorIcon::ResizeNwSe
|
||||
} else if (67.5..112.5).contains(°) {
|
||||
egui::CursorIcon::ResizeVertical
|
||||
} else if (112.5..157.5).contains(°) {
|
||||
egui::CursorIcon::ResizeNeSw
|
||||
} else {
|
||||
egui::CursorIcon::ResizeHorizontal
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
@@ -1358,6 +1406,92 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
/// #745: the resize cursor follows the handle's on-screen diagonal. A 45°-rotated shape's
|
||||
/// NW corner sits at the top-right of the screen, where the drag axis is NE-SW — the old
|
||||
/// fixed handle-to-cursor mapping showed NW-SE there.
|
||||
#[test]
|
||||
fn resize_cursor_tracks_rotation() {
|
||||
let mut s = Session::with_new();
|
||||
// A square shape: the NW handle's diagonal is at 45° unrotated, so each 45° of shape
|
||||
// rotation cleanly moves the cursor to the next of the four resize directions.
|
||||
let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 100.0, 100.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap() as u32;
|
||||
let sid = deckcraft_model::ShapeId(id);
|
||||
// Unrotated: the NW handle (0) drags the NW-SE diagonal.
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe);
|
||||
assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe);
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw);
|
||||
assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw);
|
||||
assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical);
|
||||
assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal);
|
||||
// 45° rotation: the NW corner's drag axis rotates 45° with the shape — it is now vertical.
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let pres = Arc::make_mut(&mut doc.doc);
|
||||
let slide = Arc::make_mut(&mut pres.slides[0]);
|
||||
if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid)
|
||||
&& let Some(x) = sh.xfrm.as_mut()
|
||||
{
|
||||
x.rot = 45.0;
|
||||
}
|
||||
}
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeVertical, "45°: NW handle drags vertically");
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeHorizontal, "45°: NE handle drags horizontally");
|
||||
// 90° rotation: the NW corner is at the top-right; the drag axis is NE-SW.
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let pres = Arc::make_mut(&mut doc.doc);
|
||||
let slide = Arc::make_mut(&mut pres.slides[0]);
|
||||
if let Some(sh) = slide.shapes.iter_mut().find(|x| x.id == sid)
|
||||
&& let Some(x) = sh.xfrm.as_mut()
|
||||
{
|
||||
x.rot = 90.0;
|
||||
}
|
||||
}
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNeSw, "90°: the diagonal is NE-SW");
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNwSe, "90°: NE diagonal is NW-SE");
|
||||
assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeHorizontal, "90°: the top edge is horizontal");
|
||||
}
|
||||
|
||||
/// #745: a corner handle shows a diagonal cursor whatever the shape's proportions. The
|
||||
/// corner's direction is the diagonal, not the line to the box's centre, so a wide
|
||||
/// rectangle's NW corner still drags along the NW-SE diagonal.
|
||||
#[test]
|
||||
fn a_wide_shape_keeps_its_corner_cursors_diagonal() {
|
||||
let mut s = Session::with_new();
|
||||
let id = s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [100.0, 100.0, 400.0, 80.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap() as u32;
|
||||
let sid = deckcraft_model::ShapeId(id);
|
||||
assert_eq!(resize_cursor(&s, sid, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE");
|
||||
assert_eq!(resize_cursor(&s, sid, 4), egui::CursorIcon::ResizeNwSe, "the SE corner drags NW-SE");
|
||||
assert_eq!(resize_cursor(&s, sid, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW");
|
||||
assert_eq!(resize_cursor(&s, sid, 6), egui::CursorIcon::ResizeNeSw, "the SW corner drags NE-SW");
|
||||
// The edges keep their own cursors.
|
||||
assert_eq!(resize_cursor(&s, sid, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically");
|
||||
assert_eq!(resize_cursor(&s, sid, 3), egui::CursorIcon::ResizeHorizontal, "the right edge drags horizontally");
|
||||
}
|
||||
|
||||
/// #745: a placeholder that inherits its box from the layout still shows a resize cursor.
|
||||
/// Its handles are drawn from the inherited box, so the cursor follows that one.
|
||||
#[test]
|
||||
fn a_placeholder_that_inherits_its_box_has_a_resize_cursor() {
|
||||
let s = Session::with_new();
|
||||
let st = s.doc().unwrap();
|
||||
let ph = st
|
||||
.current_slide()
|
||||
.unwrap()
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|sh| sh.xfrm.is_none() && sh.ph.is_some())
|
||||
.expect("the new slide has a placeholder without a box of its own")
|
||||
.id;
|
||||
assert_eq!(resize_cursor(&s, ph, 0), egui::CursorIcon::ResizeNwSe, "the NW corner drags NW-SE");
|
||||
assert_eq!(resize_cursor(&s, ph, 2), egui::CursorIcon::ResizeNeSw, "the NE corner drags NE-SW");
|
||||
assert_eq!(resize_cursor(&s, ph, 1), egui::CursorIcon::ResizeVertical, "the top edge drags vertically");
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
|
||||
Reference in New Issue
Block a user