diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport.rs b/crates/op-editor-ui/src/widgets/canvas_viewport.rs index 372ace4d0..8004caeaa 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport.rs @@ -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(); } diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs index 4cf48352c..1f43632eb 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_tests.rs @@ -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()); +} diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 149b45ed1..6b2a13064 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -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}; diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index 8108b5ab9..79396ca1f 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -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, + /// 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, /// 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, diff --git a/crates/op-host-native/src/widget_host/geometry.rs b/crates/op-host-native/src/widget_host/geometry.rs index 975e749dd..0b1afa3e1 100644 --- a/crates/op-host-native/src/widget_host/geometry.rs +++ b/crates/op-host-native/src/widget_host/geometry.rs @@ -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( diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index 0913b1c8b..046263927 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -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 { + 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 diff --git a/crates/op-host-native/src/widget_host/press.rs b/crates/op-host-native/src/widget_host/press.rs index 713d3206a..6ca86733f 100644 --- a/crates/op-host-native/src/widget_host/press.rs +++ b/crates/op-host-native/src/widget_host/press.rs @@ -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,