feat(editor): add boolean actions to selection toolbar

This commit is contained in:
Kayshen-X 2026-05-31 20:23:27 +08:00
parent 4ce5040f3d
commit ebd855bc92
6 changed files with 339 additions and 61 deletions

View file

@ -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<AlignAction> {
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<AlignToolbarHit> {
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<OpNodeId> = (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);

View file

@ -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,

View file

@ -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<BooleanResult> {
let page = scene.active_page()?;
let path_ids: Vec<String> = 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<SkPath> = 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<SkPath> {
match node.kind {
NodeKind::Frame | NodeKind::Rect => build_rect_path(node.bounds),
NodeKind::Ellipse => build_oval_path(node.bounds),
NodeKind::Polygon => {
build_polyline_path(&regular_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<SkPath> {
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<SkPath> {
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<SkPath> {
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());
}
}

View file

@ -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<op_editor_ui::widgets::AlignToolbarHit> {
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<op_editor_core::AlignAction> {
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,
}
}
}

View file

@ -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

View file

@ -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;
}
}