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:
parent
bfbe3b161d
commit
b7bcd9db12
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue