feat(editor): add boolean actions to selection toolbar
This commit is contained in:
parent
4ce5040f3d
commit
ebd855bc92
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(®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<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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue