feat(canvas): ellipse arc drag handles

Adds the canvas drag handles that author an ellipse arc — start
angle, sweep (end) angle, and the donut inner-radius.

- op-editor-ui: `ArcHandle` enum + `arc_handle_positions` (doc-space
  positions of the 3 handles for an Ellipse scene node), re-exported
  from `widgets`. The canvas overlay paints them as filled
  primary-tinted dots for a single-selected Ellipse with the Select
  tool.
- op-host-native: `ArcHandleDragState` + `arc_handle_hit` (checked
  before the resize handles since the sweep grip can overlap the
  right-mid resize handle). Press starts the drag with a history
  snapshot; each move recomputes start/sweep/inner from the cursor
  via `arc_drag_command` and re-applies `SetEllipseArc` (no-history
  apply); release commits the snapshot only when the arc changed.
  Dragging the start handle keeps the end fixed; a sweep collapsing
  to 0 snaps to a full 360° circle.

op-editor-ui 145 / op-host-native 64 / op-host-desktop 21 tests
green (+2 arc-handle-position units).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Kayshen-X 2026-05-17 17:26:37 +08:00
parent bfbe3b161d
commit b7bcd9db12
7 changed files with 275 additions and 1 deletions

View file

@ -24,6 +24,7 @@
use crate::layout_scene::LayoutScene;
use crate::layout_scene::NodeKind;
use crate::layout_scene::SceneNode;
use crate::theme::Theme;
use crate::widgets::editor_state_ext::theme_for;
use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
@ -52,6 +53,48 @@ pub enum SelectionHandle {
/// 4 selection corners. Matches the TS `ROTATE_OUTER_RADIUS`.
const ROTATE_OUTER_RADIUS: f32 = 16.0;
/// The three arc-edit handles on a selected Ellipse — start angle,
/// sweep (end) angle, and the donut inner-radius.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArcHandle {
/// Perimeter handle at the arc's start angle.
Start,
/// Perimeter handle at the arc's end angle (start + sweep).
Sweep,
/// Radial handle controlling the donut inner-radius fraction.
Inner,
}
/// Doc-space positions of the three arc handles for an Ellipse
/// `SceneNode`. `None` for non-Ellipse kinds or a zero-size node.
/// Shared by the overlay painter and the host's arc-handle hit-test
/// so both agree on handle placement.
pub fn arc_handle_positions(node: &SceneNode) -> Option<[(ArcHandle, Point2D); 3]> {
if !matches!(node.kind, NodeKind::Ellipse) {
return None;
}
let b = node.bounds;
if b.size.x <= 0.0 || b.size.y <= 0.0 {
return None;
}
let cx = b.origin.x + b.size.x / 2.0;
let cy = b.origin.y + b.size.y / 2.0;
let rx = b.size.x / 2.0;
let ry = b.size.y / 2.0;
let start = node.arc_start_angle.unwrap_or(0.0);
let sweep = node.arc_sweep_angle.unwrap_or(360.0);
let inner = node.arc_inner_radius.unwrap_or(0.0).clamp(0.0, 1.0);
let at = |deg: f32, scale: f32| -> Point2D {
let a = deg.to_radians();
Point2D::new(cx + rx * scale * a.cos(), cy + ry * scale * a.sin())
};
Some([
(ArcHandle::Start, at(start, 1.0)),
(ArcHandle::Sweep, at(start + sweep, 1.0)),
(ArcHandle::Inner, at(start, inner)),
])
}
/// The single resolved scene node the editor's selection anchor
/// points at, or `None` when the selection isn't a single node that
/// resolves on the active page. Shared by the two selection-overlay
@ -464,6 +507,34 @@ impl<'a> Widget for CanvasViewport<'a> {
}
}
// 4c. Arc-edit handles for a single-selected Ellipse with the
// Select tool — start / sweep / inner-radius grab dots.
if matches!(self.tool, op_editor_core::Tool::Select) && self.selected_set.len() == 1 {
if let Some(node) = self
.scene
.active_page()
.and_then(|p| p.find(&self.selected))
{
if let Some(handles) = arc_handle_positions(node) {
let r = 4.5; // screen-px radius
for (_, p) in handles {
let center = Point2D::new(
rect.origin.x + viewport.pan_x + p.x * viewport.zoom,
rect.origin.y + viewport.pan_y + p.y * viewport.zoom,
);
let bounds = Rect {
origin: Point2D::new(center.x - r, center.y - r),
size: Point2D::new(r * 2.0, r * 2.0),
};
// Filled primary dot — distinct from the white
// square resize handles.
cx.backend.fill_oval(bounds, self.theme.primary);
cx.backend.stroke_oval(bounds, self.theme.background, 1.5);
}
}
}
}
cx.backend.restore();
}

View file

@ -329,3 +329,33 @@ fn rotation_corner_hit_tests_the_outer_annulus() {
Some(SelectionHandle::TopLeft),
);
}
#[test]
fn arc_handle_positions_places_three_handles() {
use super::{arc_handle_positions, ArcHandle};
// 100×100 ellipse at origin → centre (50, 50), radii 50.
let mut node = SceneNode::leaf("e1", NodeKind::Ellipse);
node.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0);
node.arc_start_angle = Some(0.0);
node.arc_sweep_angle = Some(90.0);
node.arc_inner_radius = Some(0.5);
let handles = arc_handle_positions(&node).expect("ellipse yields handles");
// Start handle at 0° → +X perimeter (100, 50).
assert_eq!(handles[0].0, ArcHandle::Start);
assert!((handles[0].1.x - 100.0).abs() < 0.01);
assert!((handles[0].1.y - 50.0).abs() < 0.01);
// Sweep handle at 90° → +Y perimeter (50, 100).
assert_eq!(handles[1].0, ArcHandle::Sweep);
assert!((handles[1].1.x - 50.0).abs() < 0.01);
assert!((handles[1].1.y - 100.0).abs() < 0.01);
// Inner handle at start angle, half radius → (75, 50).
assert_eq!(handles[2].0, ArcHandle::Inner);
assert!((handles[2].1.x - 75.0).abs() < 0.01);
}
#[test]
fn arc_handle_positions_none_for_non_ellipse() {
let mut node = SceneNode::leaf("r1", NodeKind::Rect);
node.bounds = Rect::xywh(0.0, 0.0, 100.0, 100.0);
assert!(super::arc_handle_positions(&node).is_none());
}

View file

@ -101,7 +101,8 @@ pub use property_panel::{PropertyPanel, PropertyPanelAction};
pub use toolbar::Toolbar;
pub use canvas_viewport::{
rotation_corner_at_point, selection_handle_at_point, CanvasViewport, SelectionHandle,
arc_handle_positions, rotation_corner_at_point, selection_handle_at_point, ArcHandle,
CanvasViewport, SelectionHandle,
};
pub use icons::{draw_icon, Icon};

View file

@ -148,6 +148,10 @@ pub struct WidgetHostNative {
/// Each `apply_cursor_move` snaps the anchor to the current
/// document-space cursor; release commits a history snapshot.
pub(in crate::widget_host) path_anchor_drag: Option<PathAnchorDragState>,
/// Active ellipse arc-handle drag — set when the user presses on
/// a start / sweep / inner-radius handle of a selected Ellipse.
/// Each move re-applies `SetEllipseArc`; release commits history.
pub(in crate::widget_host) arc_handle_drag: Option<ArcHandleDragState>,
/// Counter for minting fresh `NodeId`s for newly-created nodes.
/// Bumped past the highest sample id so new + sample nodes
/// never collide on the same key.
@ -294,6 +298,19 @@ pub(in crate::widget_host) struct PathAnchorDragState {
pub(in crate::widget_host) pre_drag_snapshot: op_editor_core::EditorSnapshot,
}
/// Ellipse arc-handle drag — tracks which arc handle of which
/// Ellipse is being dragged. Move re-applies `SetEllipseArc`;
/// release commits a history snapshot only when the arc changed.
#[derive(Debug, Clone)]
pub(in crate::widget_host) struct ArcHandleDragState {
pub(in crate::widget_host) node_id: op_editor_core::NodeId,
pub(in crate::widget_host) handle: op_editor_ui::widgets::ArcHandle,
/// Set true on the first cursor-move that mutates the arc.
pub(in crate::widget_host) moved: bool,
/// Snapshot captured at drag-start; pushed only if `moved`.
pub(in crate::widget_host) pre_drag_snapshot: op_editor_core::EditorSnapshot,
}
#[derive(Debug, Clone, Copy)]
pub(in crate::widget_host) struct ChatDragState {
/// Pointer offset within the panel rect when the drag began.
@ -322,6 +339,7 @@ impl WidgetHostNative {
panel_resize: None,
node_drag: None,
path_anchor_drag: None,
arc_handle_drag: None,
handle_drag: None,
rotate_drag: None,
create_drag: None,

View file

@ -404,6 +404,41 @@ impl WidgetHostNative {
None
}
/// When a single Ellipse is selected with the Select tool,
/// hit-test whether `(x, y)` lands on one of its three arc
/// handles. Returns the node id + which handle on a hit.
pub(in crate::widget_host) fn arc_handle_hit(
&self,
x: f32,
y: f32,
viewport_w: f32,
viewport_h: f32,
) -> Option<(String, op_editor_ui::widgets::ArcHandle)> {
if !matches!(self.editor_state.tool, op_editor_core::Tool::Select) {
return None;
}
if self.editor_state.selection_count() != 1 {
return None;
}
let sel = self.editor_state.selection.anchor.as_str().to_string();
let node = self.layout_scene.active_page()?.find(&sel)?;
let handles = op_editor_ui::widgets::arc_handle_positions(node)?;
let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_w, viewport_h);
let zoom = self.editor_state.viewport.zoom.max(0.0001);
let canvas_local = Point2D::new(x - cx0, y - cy0);
let doc_point = self.editor_state.viewport.to_document(canvas_local);
// ~7 screen-px grab radius, expressed in doc space.
let r2 = 49.0 / (zoom * zoom);
for (handle, p) in handles {
let dx = doc_point.x - p.x;
let dy = doc_point.y - p.y;
if dx * dx + dy * dy <= r2 {
return Some((sel, handle));
}
}
None
}
/// Resolve a screen point to an `AlignAction` if it lands on the
/// floating align toolbar (visible when 2+ selected).
pub(in crate::widget_host) fn align_toolbar_hit(

View file

@ -344,6 +344,26 @@ impl WidgetHostNative {
}
return true;
}
// Ellipse arc-handle drag — recompute the arc geometry from
// the cursor and re-apply `SetEllipseArc` each move.
if self.arc_handle_drag.is_some() {
let (cx0, cy0) = self.canvas_origin();
let canvas_local = Point2D::new(x - cx0, y - cy0);
let doc = self.editor_state.viewport.to_document(canvas_local);
let (id, handle) = {
let d = self.arc_handle_drag.as_ref().unwrap();
(d.node_id.clone(), d.handle)
};
if let Some(cmd) = self.arc_drag_command(&id, handle, doc) {
if self.editor_state.apply(cmd) {
self.mark_dirty();
if let Some(d) = self.arc_handle_drag.as_mut() {
d.moved = true;
}
}
}
return true;
}
if let Some(m) = self.marquee_drag.as_mut() {
m.current_screen_x = x;
m.current_screen_y = y;
@ -457,6 +477,14 @@ impl WidgetHostNative {
}
return false;
}
if let Some(drag) = self.arc_handle_drag.take() {
// Commit history only when the arc actually changed.
if drag.moved {
self.editor_state.history_push_past(drag.pre_drag_snapshot);
return true;
}
return false;
}
if let Some(m) = self.marquee_drag.take() {
self.commit_marquee_selection(m, viewport_w, viewport_h);
return true;
@ -519,6 +547,83 @@ impl WidgetHostNative {
self.drag = None;
was_dragging
}
/// Build the `SetEllipseArc` command for an in-progress arc-handle
/// drag — converts the cursor doc point into start / sweep / inner
/// geometry for the dragged handle. `None` for a missing or
/// zero-size ellipse.
fn arc_drag_command(
&self,
id: &op_editor_core::NodeId,
handle: op_editor_ui::widgets::ArcHandle,
doc: Point2D,
) -> Option<op_editor_core::EditorCommand> {
use op_editor_core::EditorCommand;
use op_editor_ui::widgets::ArcHandle;
let node = self.layout_scene.active_page()?.find(id.as_str())?;
let b = node.bounds;
if b.size.x <= 0.0 || b.size.y <= 0.0 {
return None;
}
// Cursor offset from the ellipse centre, normalised by the
// radii so the angle is the same convention the painter uses.
let nx = (doc.x - (b.origin.x + b.size.x / 2.0)) / (b.size.x / 2.0);
let ny = (doc.y - (b.origin.y + b.size.y / 2.0)) / (b.size.y / 2.0);
let old_start = node.arc_start_angle.unwrap_or(0.0);
let old_sweep = node.arc_sweep_angle.unwrap_or(360.0);
Some(match handle {
ArcHandle::Start => {
// Dragging the start handle keeps the end fixed.
let new_start = norm360(ny.atan2(nx).to_degrees());
let new_sweep = norm_sweep(old_start + old_sweep - new_start);
EditorCommand::SetEllipseArc {
node_id: id.clone(),
start_angle: Some(new_start as f64),
sweep_angle: Some(new_sweep as f64),
inner_radius: None,
}
}
ArcHandle::Sweep => {
let new_sweep = norm_sweep(ny.atan2(nx).to_degrees() - old_start);
EditorCommand::SetEllipseArc {
node_id: id.clone(),
start_angle: None,
sweep_angle: Some(new_sweep as f64),
inner_radius: None,
}
}
ArcHandle::Inner => {
let frac = (nx * nx + ny * ny).sqrt().clamp(0.0, 1.0);
EditorCommand::SetEllipseArc {
node_id: id.clone(),
start_angle: None,
sweep_angle: None,
inner_radius: Some(frac as f64),
}
}
})
}
}
/// Normalise an angle into `[0, 360)` degrees.
fn norm360(deg: f32) -> f32 {
let s = deg % 360.0;
if s < 0.0 {
s + 360.0
} else {
s
}
}
/// Normalise a sweep into `(0, 360]` — a sweep that collapses to 0
/// snaps to a full 360° circle.
fn norm_sweep(deg: f32) -> f32 {
let s = norm360(deg);
if s <= 0.0001 {
360.0
} else {
s
}
}
/// Convert a shell-core `Rect` (screen / doc px) into op-editor-core's

View file

@ -561,6 +561,20 @@ impl WidgetHostNative {
// The selected anchor's resolved scene node — shared
// by the handle-drag + rotation-drag branches below.
let selected_anchor = self.editor_state.selection.anchor.as_str().to_string();
// Arc handles take priority over the 8 resize handles —
// the sweep handle can overlap the right-mid resize grip.
if let Some((node_id, handle)) =
self.arc_handle_hit(x, y, viewport_width, viewport_height)
{
let pre = self.editor_state.snapshot_for_history();
self.arc_handle_drag = Some(super::ArcHandleDragState {
node_id: op_editor_core::NodeId::new(&node_id),
handle,
moved: false,
pre_drag_snapshot: pre,
});
return true;
}
if let Some(handle) = selection_handle_at_point(
canvas_rect,
&self.layout_scene,