diff --git a/crates/op-editor-ui/src/widgets/align_toolbar.rs b/crates/op-editor-ui/src/widgets/align_toolbar.rs index de59956ac..a71561f3a 100644 --- a/crates/op-editor-ui/src/widgets/align_toolbar.rs +++ b/crates/op-editor-ui/src/widgets/align_toolbar.rs @@ -14,8 +14,9 @@ use crate::theme::Theme; use crate::widgets::icons::{draw_icon, Icon}; use crate::{Point2D, Rect, RenderBackend}; -use op_editor_core::AlignAction; -use op_editor_core::EditorState; +use jian_ops_schema::node::PenNode; +use op_editor_core::walkers::find_node; +use op_editor_core::{AlignAction, BooleanOp, EditorState}; pub const ALIGN_TOOLBAR_HEIGHT: f32 = 36.0; const BUTTON_SIZE: f32 = 28.0; @@ -49,13 +50,49 @@ const ITEMS: &[(AlignAction, Icon)] = &[ (AlignAction::DistributeV, Icon::DistributeV), ]; +const SQUARES_UNITE: &[&str] = &["M4 16a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v3a1 1 0 0 0 1 1h3a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2v-3a1 1 0 0 0-1-1z"]; +const SQUARES_SUBTRACT: &[&str] = &[ + "M10 22a2 2 0 0 1-2-2", + "M16 22h-2", + "M16 4a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h3a1 1 0 0 0 1-1v-5a2 2 0 0 1 2-2h5a1 1 0 0 0 1-1z", + "M20 8a2 2 0 0 1 2 2", + "M22 14v2", + "M22 20a2 2 0 0 1-2 2", +]; +const SQUARES_INTERSECT: &[&str] = &[ + "M10 22a2 2 0 0 1-2-2", + "M14 2a2 2 0 0 1 2 2", + "M16 22h-2", + "M2 10V8", + "M2 4a2 2 0 0 1 2-2", + "M20 8a2 2 0 0 1 2 2", + "M22 14v2", + "M22 20a2 2 0 0 1-2 2", + "M4 16a2 2 0 0 1-2-2", + "M8 10a2 2 0 0 1 2-2h5a1 1 0 0 1 1 1v5a2 2 0 0 1-2 2H9a1 1 0 0 1-1-1z", + "M8 2h2", +]; + +const BOOLEAN_ITEMS: &[(BooleanOp, &[&str])] = &[ + (BooleanOp::Union, SQUARES_UNITE), + (BooleanOp::Subtract, SQUARES_SUBTRACT), + (BooleanOp::Intersect, SQUARES_INTERSECT), +]; + /// Group divider indices (after these positions, insert a `GROUP_GAP` /// instead of the default `INNER_GAP`). Two dividers split the 8 /// buttons into [3, 3, 2]. const GROUP_BREAKS: &[usize] = &[3, 6]; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AlignToolbarHit { + Align(AlignAction), + Boolean(BooleanOp), +} + pub struct AlignToolbar { rect: Rect, + boolean_ops: bool, } impl AlignToolbar { @@ -73,13 +110,15 @@ impl AlignToolbar { // max_x clamp keeps it inside the right rail. When the canvas // can't fit both, hide entirely — never render a clipped or // out-of-region pill with stale hit-test geometry. + let boolean_ops = can_boolean_op(state); + let toolbar_width = toolbar_width(boolean_ops); let min_x = canvas_region.origin.x + VERTICAL_TOOLBAR_RESERVE; - let max_x = canvas_region.origin.x + canvas_region.size.x - ALIGN_TOOLBAR_WIDTH; + let max_x = canvas_region.origin.x + canvas_region.size.x - toolbar_width; if max_x < min_x { return None; } let cx = canvas_region.origin.x + canvas_region.size.x / 2.0; - let mut x = cx - ALIGN_TOOLBAR_WIDTH / 2.0; + let mut x = cx - toolbar_width / 2.0; if x < min_x { x = min_x; } @@ -88,7 +127,8 @@ impl AlignToolbar { } let y = canvas_region.origin.y + 16.0; Some(Self { - rect: Rect::xywh(x, y, ALIGN_TOOLBAR_WIDTH, ALIGN_TOOLBAR_HEIGHT), + rect: Rect::xywh(x, y, toolbar_width, ALIGN_TOOLBAR_HEIGHT), + boolean_ops, }) } @@ -122,17 +162,50 @@ impl AlignToolbar { 1.5, ); } + if self.boolean_ops { + for (i, (_, paths)) in BOOLEAN_ITEMS.iter().enumerate() { + let r = self.button_rect(ITEMS.len() + i); + let icon_x = r.origin.x + (r.size.x - ICON_SIZE) / 2.0; + let icon_y = r.origin.y + (r.size.y - ICON_SIZE) / 2.0; + for path in *paths { + backend.stroke_svg_path( + path, + Point2D::new(icon_x, icon_y), + ICON_SIZE, + theme.foreground, + 1.5, + ); + } + } + } } /// Map a screen point to an `AlignAction`. None when the point /// lands outside the toolbar or in a gutter. pub fn hit_test(&self, point: Point2D) -> Option { + match self.hit_test_action(point) { + Some(AlignToolbarHit::Align(action)) => Some(action), + _ => None, + } + } + + /// Map a screen point to either an align/distribute action or a + /// boolean operation. None when the point lands outside the + /// toolbar or in a gutter. + pub fn hit_test_action(&self, point: Point2D) -> Option { if !rect_contains(self.rect, point) { return None; } for (i, (action, _)) in ITEMS.iter().enumerate() { if rect_contains(self.button_rect(i), point) { - return Some(*action); + return Some(AlignToolbarHit::Align(*action)); + } + } + if self.boolean_ops { + for (i, (op, _)) in BOOLEAN_ITEMS.iter().enumerate() { + if rect_contains(self.button_rect(ITEMS.len() + i), point) { + return Some(AlignToolbarHit::Boolean(*op)); + } } } None @@ -142,7 +215,7 @@ impl AlignToolbar { let mut x = self.rect.origin.x + SIDE_PAD; for i in 0..index { x += BUTTON_SIZE; - x += if GROUP_BREAKS.contains(&(i + 1)) { + x += if is_group_break_after(i, self.boolean_ops) { GROUP_GAP } else { INNER_GAP @@ -153,6 +226,45 @@ impl AlignToolbar { } } +fn toolbar_width(boolean_ops: bool) -> f32 { + if !boolean_ops { + return ALIGN_TOOLBAR_WIDTH; + } + ALIGN_TOOLBAR_WIDTH + + GROUP_GAP + + BUTTON_SIZE * BOOLEAN_ITEMS.len() as f32 + + INNER_GAP * BOOLEAN_ITEMS.len().saturating_sub(1) as f32 +} + +fn is_group_break_after(index: usize, boolean_ops: bool) -> bool { + let after = index + 1; + GROUP_BREAKS.contains(&after) || (boolean_ops && after == ITEMS.len()) +} + +fn can_boolean_op(state: &EditorState) -> bool { + if state.selection.len() < 2 { + return false; + } + let children = state.active_children(); + state.selection.set.iter().all(|id| { + find_node(children, id) + .map(boolean_supported_node) + .unwrap_or(false) + }) +} + +fn boolean_supported_node(node: &PenNode) -> bool { + matches!( + node, + PenNode::Frame(_) + | PenNode::Rectangle(_) + | PenNode::Ellipse(_) + | PenNode::Polygon(_) + | PenNode::Path(_) + | PenNode::Line(_) + ) +} + fn rect_contains(r: Rect, p: Point2D) -> bool { p.x >= r.origin.x && p.x < r.origin.x + r.size.x @@ -163,18 +275,58 @@ fn rect_contains(r: Rect, p: Point2D) -> bool { #[cfg(test)] mod tests { use super::*; + use jian_ops_schema::node::{ContainerProps, PenNode, PenNodeBase, RectangleNode}; + use jian_ops_schema::sizing::SizingBehavior; use op_editor_core::node_id::NodeId as OpNodeId; fn doc_with_n_selected(n: usize) -> EditorState { let mut state = EditorState::new(); let ids: Vec = (0..n) - .map(|i| OpNodeId::new(format!("n{}", 100 + i))) + .map(|i| OpNodeId::new(format!("missing-align-test-{i}"))) .collect(); state.selection.anchor = ids.last().cloned().unwrap_or_default(); state.selection.set = ids; state } + fn rect_node(id: &str, x: f64, y: f64, w: f64, h: f64) -> PenNode { + PenNode::Rectangle(RectangleNode { + base: PenNodeBase { + id: id.to_string(), + name: Some(id.to_string()), + x: Some(x), + y: Some(y), + ..Default::default() + }, + container: ContainerProps { + width: Some(SizingBehavior::Number(w)), + height: Some(SizingBehavior::Number(h)), + ..Default::default() + }, + children: None, + state: None, + bindings: None, + events: None, + lifecycle: None, + semantics: None, + gestures: None, + route: None, + }) + } + + fn doc_with_two_rects_selected() -> EditorState { + let mut state = EditorState::new(); + state + .active_children_mut() + .push(rect_node("n10", 0.0, 0.0, 100.0, 80.0)); + state + .active_children_mut() + .push(rect_node("n11", 40.0, 0.0, 100.0, 80.0)); + state.selection.set = vec![OpNodeId::new("n10"), OpNodeId::new("n11")]; + state.selection.anchor = OpNodeId::new("n11"); + state + } + fn canvas() -> Rect { Rect::xywh(0.0, 0.0, 1000.0, 600.0) } @@ -206,6 +358,23 @@ mod tests { assert_eq!(tb.rect.size.y, ALIGN_TOOLBAR_HEIGHT); } + #[test] + fn boolean_compatible_selection_appends_boolean_buttons() { + let doc = doc_with_two_rects_selected(); + let tb = AlignToolbar::for_canvas_region(canvas(), &doc).unwrap(); + assert!(tb.rect.size.x > ALIGN_TOOLBAR_WIDTH); + let first_boolean = tb.button_rect(ITEMS.len()); + let center = Point2D::new( + first_boolean.origin.x + first_boolean.size.x / 2.0, + first_boolean.origin.y + first_boolean.size.y / 2.0, + ); + assert_eq!( + tb.hit_test_action(center), + Some(AlignToolbarHit::Boolean(BooleanOp::Union)) + ); + assert_eq!(tb.hit_test(center), None); + } + #[test] fn hit_test_maps_buttons_to_actions() { let doc = doc_with_n_selected(3); diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 6eeaad7d8..36a2a7f3d 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -172,7 +172,7 @@ pub use ai_chat_panel::{ AIChatPlaceholder, AI_CHAT_COLLAPSED_HEIGHT, AI_CHAT_COLLAPSED_WIDTH, AI_CHAT_HEIGHT, AI_CHAT_WIDTH, }; -pub use align_toolbar::{AlignToolbar, ALIGN_TOOLBAR_HEIGHT, ALIGN_TOOLBAR_WIDTH}; +pub use align_toolbar::{AlignToolbar, AlignToolbarHit, ALIGN_TOOLBAR_HEIGHT, ALIGN_TOOLBAR_WIDTH}; pub use component_browser_panel::{ ComponentBrowserHit, ComponentBrowserPanel, COMPONENT_BROWSER_PANEL_H, COMPONENT_BROWSER_PANEL_W, diff --git a/crates/op-host-native/src/boolean_ops.rs b/crates/op-host-native/src/boolean_ops.rs index cc06b18ac..ac1782491 100644 --- a/crates/op-host-native/src/boolean_ops.rs +++ b/crates/op-host-native/src/boolean_ops.rs @@ -1,22 +1,21 @@ //! Path boolean ops (Union / Subtract / Intersect / Exclude) for //! the selection. Backed by skia's built-in `Path::op` so the -//! implementation is short + correct for the polyline path-points +//! implementation is short + correct for the layout-resolved shape //! model the editor uses today. Mirrors the four shortcuts TS //! exposes via Paper.js (`use-edit-shortcuts.ts` Ctrl+Alt+U/S/I). //! //! Lives in `shell-native` (not shell-core) so shell-core stays //! skia-free. This module is a pure *computation*: given the //! layout-resolved `LayoutScene` + the editor's selection set it -//! returns the source path ids + the result polyline. The host +//! returns the source shape ids + the result polyline. The host //! commits that result back through an `EditorState` mutator //! (`replace_paths_with_polyline`) so the canonical tree is never //! edited directly. use op_editor_core::BooleanOp; -use op_editor_ui::layout_scene::LayoutScene; -use op_editor_ui::layout_scene::NodeKind; -use op_editor_ui::Point2D; -use skia_safe::{Path as SkPath, PathBuilder, PathOp}; +use op_editor_ui::layout_scene::{regular_polygon_points, LayoutScene, NodeKind, SceneNode}; +use op_editor_ui::{Point2D, Rect}; +use skia_safe::{Path as SkPath, PathBuilder, PathOp, Rect as SkRect}; /// Result of a boolean-op computation — the source path ids to /// remove + the new polyline (doc-space `(x, y)` pairs) to commit. @@ -25,10 +24,11 @@ pub struct BooleanResult { pub points: Vec<(f64, f64)>, } -/// Compute `op` over the selected Path nodes. `selected` is the -/// editor's selection set (scene-space string ids). Requires 2+ -/// Path nodes among the selection; returns `None` when fewer than -/// two paths are selected or the result polyline is empty. The +/// Compute `op` over the selected boolean-compatible shape nodes. +/// `selected` is the editor's selection set (scene-space string ids). +/// Requires every selected node to be a supported shape, and at least +/// two operands; returns `None` when the selection is unsupported or +/// the result polyline is empty. The /// returned `BooleanResult` is the input the host feeds to /// `EditorState::replace_paths_with_polyline`. pub fn compute_boolean_op( @@ -37,22 +37,14 @@ pub fn compute_boolean_op( op: BooleanOp, ) -> Option { let page = scene.active_page()?; - let path_ids: Vec = selected - .iter() - .filter(|id| { - page.find(id) - .map(|n| matches!(n.kind, NodeKind::Path)) - .unwrap_or(false) - }) - .cloned() - .collect(); - if path_ids.len() < 2 { - return None; + let mut source_ids = Vec::with_capacity(selected.len()); + let mut sk_paths = Vec::with_capacity(selected.len()); + for id in selected { + let node = page.find(id)?; + let path = build_node_path(node)?; + source_ids.push(id.clone()); + sk_paths.push(path); } - let sk_paths: Vec = path_ids - .iter() - .filter_map(|id| page.find(id).map(|n| build_skia_path(&n.points))) - .collect(); if sk_paths.len() < 2 { return None; } @@ -71,7 +63,7 @@ pub fn compute_boolean_op( return None; } Some(BooleanResult { - source_ids: path_ids, + source_ids, points: result_points .iter() .map(|p| (p.x as f64, p.y as f64)) @@ -79,7 +71,71 @@ pub fn compute_boolean_op( }) } -fn build_skia_path(points: &[Point2D]) -> SkPath { +fn build_node_path(node: &SceneNode) -> Option { + match node.kind { + NodeKind::Frame | NodeKind::Rect => build_rect_path(node.bounds), + NodeKind::Ellipse => build_oval_path(node.bounds), + NodeKind::Polygon => { + build_polyline_path(®ular_polygon_points(node.bounds, node.polygon_sides)) + } + NodeKind::Path => build_polyline_path(&node.points), + NodeKind::Line => { + if node.points.len() >= 2 { + build_polyline_path(&node.points) + } else { + build_polyline_path(&[ + node.bounds.origin, + Point2D::new( + node.bounds.origin.x + node.bounds.size.x, + node.bounds.origin.y + node.bounds.size.y, + ), + ]) + } + } + NodeKind::Group | NodeKind::Text | NodeKind::Other(_) => None, + } +} + +fn build_rect_path(bounds: Rect) -> Option { + if bounds.size.x <= 0.0 || bounds.size.y <= 0.0 { + return None; + } + let mut b = PathBuilder::new(); + b.add_rect( + SkRect::from_xywh( + bounds.origin.x, + bounds.origin.y, + bounds.size.x, + bounds.size.y, + ), + None, + None, + ); + Some(b.detach()) +} + +fn build_oval_path(bounds: Rect) -> Option { + if bounds.size.x <= 0.0 || bounds.size.y <= 0.0 { + return None; + } + let mut b = PathBuilder::new(); + b.add_oval( + SkRect::from_xywh( + bounds.origin.x, + bounds.origin.y, + bounds.size.x, + bounds.size.y, + ), + None, + None, + ); + Some(b.detach()) +} + +fn build_polyline_path(points: &[Point2D]) -> Option { + if points.len() < 2 { + return None; + } let mut b = PathBuilder::new(); if let Some(first) = points.first() { b.move_to((first.x, first.y)); @@ -88,7 +144,7 @@ fn build_skia_path(points: &[Point2D]) -> SkPath { } b.close(); } - b.detach() + Some(b.detach()) } /// Walk the result Path and yield a flat polyline. Curves (Quad / @@ -150,6 +206,21 @@ mod tests { } } + fn scene_with_two_rectangles() -> LayoutScene { + let mut a = SceneNode::leaf("n10", NodeKind::Rect); + a.bounds = Rect::xywh(0.0, 0.0, 20.0, 20.0); + let mut b = SceneNode::leaf("n11", NodeKind::Rect); + b.bounds = Rect::xywh(10.0, 0.0, 20.0, 20.0); + LayoutScene { + pages: vec![ScenePage { + id: "p".into(), + name: "P".into(), + children: vec![a, b], + }], + active_page_index: 0, + } + } + #[test] fn union_of_two_overlapping_squares_yields_a_polyline() { let scene = scene_with_two_squares(); @@ -174,6 +245,23 @@ mod tests { assert!((max_x - min_x - 10.0).abs() < 0.5); } + #[test] + fn subtract_accepts_two_rectangles() { + let scene = scene_with_two_rectangles(); + let sel = vec!["n10".to_string(), "n11".to_string()]; + let r = compute_boolean_op(&scene, &sel, BooleanOp::Subtract).expect("subtract computes"); + assert_eq!(r.source_ids, sel); + assert!(!r.points.is_empty(), "subtract must yield points"); + let min_x = r.points.iter().map(|p| p.0).fold(f64::INFINITY, f64::min); + let max_x = r + .points + .iter() + .map(|p| p.0) + .fold(f64::NEG_INFINITY, f64::max); + assert!((min_x - 0.0).abs() < 0.5); + assert!((max_x - 10.0).abs() < 0.5); + } + #[test] fn boolean_op_requires_two_path_nodes() { let scene = scene_with_two_squares(); @@ -182,14 +270,12 @@ mod tests { } #[test] - fn boolean_op_skips_non_path_nodes_in_selection() { + fn boolean_op_rejects_unsupported_nodes_in_selection() { let mut scene = scene_with_two_squares(); - let mut r = SceneNode::leaf("n12", NodeKind::Rect); - r.bounds = Rect::xywh(0.0, 0.0, 10.0, 10.0); - scene.pages[0].children.push(r); + let mut text = SceneNode::leaf("n12", NodeKind::Text); + text.bounds = Rect::xywh(0.0, 0.0, 10.0, 10.0); + scene.pages[0].children.push(text); let sel = vec!["n10".to_string(), "n11".to_string(), "n12".to_string()]; - // Still has 2 Path nodes — should succeed; Rect is ignored. - let res = compute_boolean_op(&scene, &sel, BooleanOp::Union).expect("union computes"); - assert_eq!(res.source_ids.len(), 2); + assert!(compute_boolean_op(&scene, &sel, BooleanOp::Union).is_none()); } } diff --git a/crates/op-host-native/src/widget_host/geometry.rs b/crates/op-host-native/src/widget_host/geometry.rs index d2a80f768..2def108ef 100644 --- a/crates/op-host-native/src/widget_host/geometry.rs +++ b/crates/op-host-native/src/widget_host/geometry.rs @@ -635,6 +635,25 @@ impl WidgetHostNative { None } + /// Resolve a screen point to an align/distribute action or boolean + /// operation if it lands on the floating selection toolbar. + pub(in crate::widget_host) fn selection_toolbar_hit( + &self, + x: f32, + y: f32, + viewport_w: f32, + viewport_h: f32, + ) -> Option { + use op_editor_ui::widgets::AlignToolbar; + let (cx, _, cw, ch) = self.canvas_region(viewport_w, viewport_h); + let canvas_region = Rect { + origin: Point2D::new(cx, TOP_BAR_HEIGHT), + size: Point2D::new(cw, ch), + }; + AlignToolbar::for_canvas_region(canvas_region, &self.editor_state)? + .hit_test_action(Point2D::new(x, y)) + } + /// 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( @@ -644,13 +663,9 @@ impl WidgetHostNative { viewport_w: f32, viewport_h: f32, ) -> Option { - use op_editor_ui::widgets::AlignToolbar; - let (cx, _, cw, ch) = self.canvas_region(viewport_w, viewport_h); - let canvas_region = Rect { - origin: Point2D::new(cx, TOP_BAR_HEIGHT), - size: Point2D::new(cw, ch), - }; - AlignToolbar::for_canvas_region(canvas_region, &self.editor_state)? - .hit_test(Point2D::new(x, y)) + match self.selection_toolbar_hit(x, y, viewport_w, viewport_h) { + Some(op_editor_ui::widgets::AlignToolbarHit::Align(action)) => Some(action), + _ => None, + } } } diff --git a/crates/op-host-native/src/widget_host/press.rs b/crates/op-host-native/src/widget_host/press.rs index 74cb7ba15..c9f20ec43 100644 --- a/crates/op-host-native/src/widget_host/press.rs +++ b/crates/op-host-native/src/widget_host/press.rs @@ -595,9 +595,16 @@ impl WidgetHostNative { return rename_committed || text_edit_committed; } - if let Some(a) = self.align_toolbar_hit(x, y, viewport_width, viewport_height) { - self.editor_state.align_selected(a); - self.mark_dirty(); + if let Some(hit) = self.selection_toolbar_hit(x, y, viewport_width, viewport_height) { + match hit { + op_editor_ui::widgets::AlignToolbarHit::Align(action) => { + self.editor_state.align_selected(action); + self.mark_dirty(); + } + op_editor_ui::widgets::AlignToolbarHit::Boolean(op) => { + let _ = self.apply_boolean_op(op); + } + } return true; } // 3. apply_click — LayerPanel + chat-defocus. Peek the diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index edf65139b..ba8ddac6b 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -484,18 +484,19 @@ impl WidgetHost { // so the visible button always wins over a layer row // that happens to share screen y (matches native order). { - use op_editor_ui::widgets::AlignToolbar; + use op_editor_ui::widgets::{AlignToolbar, AlignToolbarHit}; let (acx, _, acw, ach) = self.canvas_region(viewport_width, viewport_height); let canvas_region = op_editor_ui::Rect { origin: Point2D::new(acx, TOP_BAR_HEIGHT), size: Point2D::new(acw, ach), }; - if let Some(action) = AlignToolbar::for_canvas_region(canvas_region, &self.editor_state) - .and_then(|tb| tb.hit_test(Point2D::new(x, y))) + if let Some(hit) = AlignToolbar::for_canvas_region(canvas_region, &self.editor_state) + .and_then(|tb| tb.hit_test_action(Point2D::new(x, y))) { - let ec_action = action; - self.editor_state.align_selected(ec_action); - self.mark_dirty(); + if let AlignToolbarHit::Align(action) = hit { + self.editor_state.align_selected(action); + self.mark_dirty(); + } return true; } }