diff --git a/crates/openpencil-shell-core/src/document.rs b/crates/openpencil-shell-core/src/document.rs index ebb6b96a7..153b8a15c 100644 --- a/crates/openpencil-shell-core/src/document.rs +++ b/crates/openpencil-shell-core/src/document.rs @@ -326,10 +326,9 @@ pub struct UiState { /// Row currently hovered while the file menu is open — drives /// the per-row tint so the user can see which action will fire. pub file_menu_hover: Option, - /// Locale picker hover — same shape as `file_menu_hover`. pub locale_picker_hover: Option, - /// Shape picker hover — same shape, keyed by `ShapeChoice`. pub shape_picker_hover: Option, + pub align_toolbar_hover: Option, /// Pending file-menu action. pub pending_file_action: Option, /// Recent files (head = newest, cap 10). @@ -553,6 +552,7 @@ impl Default for UiState { file_menu_hover: None, locale_picker_hover: None, shape_picker_hover: None, + align_toolbar_hover: None, pending_file_action: None, recent_files: Vec::new(), file_name_display: None, @@ -785,15 +785,15 @@ impl Tool { } } +mod align; mod color_picker; mod grouping; mod mutators; mod page_mutators; mod pen; mod walkers; +pub use align::AlignAction; pub use walkers::ReorderDirection; -#[cfg(test)] -mod tests_geometry; -#[cfg(test)] -mod tests_mutators; +#[cfg(test)] mod tests_geometry; +#[cfg(test)] mod tests_mutators; diff --git a/crates/openpencil-shell-core/src/document/align.rs b/crates/openpencil-shell-core/src/document/align.rs new file mode 100644 index 000000000..bc7830573 --- /dev/null +++ b/crates/openpencil-shell-core/src/document/align.rs @@ -0,0 +1,508 @@ +//! Align / distribute mutators backing the PropertyPanel's Align +//! section. Reference frame: +//! - 2+ selected → union of selection bounds. +//! - 1 selected → parent container's `aggregate_bounds` (top-level +//! nodes have no useful reference and silently no-op). +//! Distribute requires 3+ nodes; <3 silently no-ops. +//! +//! All deltas use `translate_walk`, so containers cascade to their +//! descendants the same way drag-move does. + +use super::walkers::*; +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AlignAction { + Left, + CenterH, + Right, + Top, + CenterV, + Bottom, + DistributeH, + DistributeV, +} + +impl AlignAction { + fn is_distribute(self) -> bool { + matches!(self, Self::DistributeH | Self::DistributeV) + } + fn is_horizontal(self) -> bool { + matches!( + self, + Self::Left | Self::CenterH | Self::Right | Self::DistributeH + ) + } +} + +impl Document { + /// Apply alignment or distribution to the active selection. + /// Returns true when at least one node moved; pushes history + /// only on real motion. + pub fn align_selected(&mut self, action: AlignAction) -> bool { + let editable: Vec = self + .selected_set + .iter() + .copied() + .filter(|id| self.is_editable(*id)) + .collect(); + if editable.is_empty() { + return false; + } + if action.is_distribute() && editable.len() < 3 { + return false; + } + let reference = { + let Some(page) = self.active_page() else { + return false; + }; + if editable.len() >= 2 { + match union_aggregate_bounds(page, &editable) { + Some(r) => r, + None => return false, + } + } else { + match parent_aggregate_bounds_walk(&page.children, editable[0]) { + Some(r) => r, + None => return false, + } + } + }; + let pre = self.snapshot_for_history(); + let active = self.active_page_index; + let Some(page) = self.pages.get_mut(active) else { + return false; + }; + let any_change = if action.is_distribute() { + apply_distribute(page, &editable, action) + } else { + apply_align(page, &editable, reference, action) + }; + if any_change { + self.history_push_past(pre); + } + any_change + } +} + +/// Move each editable node so its (left / center-h / right / top / +/// center-v / bottom) edge matches the reference rect. +/// +/// Ancestor-in-set dedup: when both an ancestor and its descendant +/// are selected, only the ancestor moves — the descendant cascades +/// via `translate_walk`. Same invariant as `translate_selected` so +/// `align_selected(Left)` matches `translate_selected(dx, 0)` for +/// the same delta. +fn apply_align( + page: &mut Page, + editable: &[NodeId], + reference: crate::Rect, + action: AlignAction, +) -> bool { + let ref_min_x = reference.origin.x; + let ref_max_x = ref_min_x + reference.size.x; + let ref_mid_x = ref_min_x + reference.size.x / 2.0; + let ref_min_y = reference.origin.y; + let ref_max_y = ref_min_y + reference.size.y; + let ref_mid_y = ref_min_y + reference.size.y / 2.0; + let mut moved = false; + for id in editable { + if is_ancestor_in_set(&page.children, *id, editable) { + continue; + } + let Some(cur) = page.find(*id).map(Node::aggregate_bounds) else { + continue; + }; + let (cx, cy, cw, ch) = (cur.origin.x, cur.origin.y, cur.size.x, cur.size.y); + let (dx, dy) = match action { + AlignAction::Left => (ref_min_x - cx, 0.0), + AlignAction::Right => (ref_max_x - (cx + cw), 0.0), + AlignAction::CenterH => (ref_mid_x - (cx + cw / 2.0), 0.0), + AlignAction::Top => (0.0, ref_min_y - cy), + AlignAction::Bottom => (0.0, ref_max_y - (cy + ch)), + AlignAction::CenterV => (0.0, ref_mid_y - (cy + ch / 2.0)), + AlignAction::DistributeH | AlignAction::DistributeV => unreachable!(), + }; + if dx == 0.0 && dy == 0.0 { + continue; + } + for child in page.children.iter_mut() { + if translate_walk(child, *id, dx, dy) { + moved = true; + break; + } + } + } + moved +} + +/// Sort by center along the distribution axis, then redistribute +/// the inner nodes so their centers are equally spaced between the +/// outermost two. Endpoints stay put. +fn apply_distribute(page: &mut Page, editable: &[NodeId], action: AlignAction) -> bool { + let horizontal = action.is_horizontal(); + // Ancestor-in-set dedup before sorting: a selected ancestor + + // descendant must not contribute two anchors to the spacing + // calculation. Same invariant as `apply_align`. + let filtered: Vec = editable + .iter() + .copied() + .filter(|id| !is_ancestor_in_set(&page.children, *id, editable)) + .collect(); + let mut sorted: Vec<(NodeId, crate::Rect)> = filtered + .iter() + .filter_map(|id| page.find(*id).map(|n| (*id, n.aggregate_bounds()))) + .collect(); + if sorted.len() < 3 { + return false; + } + sorted.sort_by(|a, b| { + let (ac, bc) = if horizontal { + ( + a.1.origin.x + a.1.size.x / 2.0, + b.1.origin.x + b.1.size.x / 2.0, + ) + } else { + ( + a.1.origin.y + a.1.size.y / 2.0, + b.1.origin.y + b.1.size.y / 2.0, + ) + }; + ac.partial_cmp(&bc).unwrap_or(std::cmp::Ordering::Equal) + }); + let n = sorted.len(); + let (first_c, last_c) = if horizontal { + ( + sorted[0].1.origin.x + sorted[0].1.size.x / 2.0, + sorted[n - 1].1.origin.x + sorted[n - 1].1.size.x / 2.0, + ) + } else { + ( + sorted[0].1.origin.y + sorted[0].1.size.y / 2.0, + sorted[n - 1].1.origin.y + sorted[n - 1].1.size.y / 2.0, + ) + }; + let step = (last_c - first_c) / (n - 1) as f32; + let mut moved = false; + for i in 1..n - 1 { + let (id, cur) = sorted[i]; + let cur_c = if horizontal { + cur.origin.x + cur.size.x / 2.0 + } else { + cur.origin.y + cur.size.y / 2.0 + }; + let target_c = first_c + step * i as f32; + let delta = target_c - cur_c; + if delta == 0.0 { + continue; + } + let (dx, dy) = if horizontal { + (delta, 0.0) + } else { + (0.0, delta) + }; + for child in page.children.iter_mut() { + if translate_walk(child, id, dx, dy) { + moved = true; + break; + } + } + } + moved +} + +/// Walk `children` looking for the node whose own `children` vec +/// contains `target`. Returns that parent's `aggregate_bounds`. +/// None when `target` is top-level or absent. +fn parent_aggregate_bounds_walk(children: &[Node], target: NodeId) -> Option { + for child in children { + if child.children.iter().any(|c| c.id == target) { + return Some(child.aggregate_bounds()); + } + if let Some(rect) = parent_aggregate_bounds_walk(&child.children, target) { + return Some(rect); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::*; + + fn rect_at(x: f32, y: f32, w: f32, h: f32) -> Rect { + Rect::xywh(x, y, w, h) + } + + /// Builds a fresh page with three rectangles at known offsets. + fn three_rects(positions: &[(f32, f32, f32, f32)]) -> Document { + let mut doc = Document::empty(); + let page = doc.pages.get_mut(0).unwrap(); + page.children.clear(); + for (i, &(x, y, w, h)) in positions.iter().enumerate() { + let mut node = Node::leaf(10 + i as u64, NodeKind::Rect, "r"); + node.bounds = rect_at(x, y, w, h); + page.children.push(node); + } + let ids: Vec = positions + .iter() + .enumerate() + .map(|(i, _)| NodeId::new(10 + i as u64)) + .collect(); + doc.selected_set = ids.clone(); + doc.selected = *ids.last().unwrap(); + doc + } + + fn bounds_of(doc: &Document, id: NodeId) -> Rect { + doc.active_page().unwrap().find(id).unwrap().aggregate_bounds() + } + + #[test] + fn align_left_snaps_to_union_min_x() { + let mut doc = three_rects(&[(10.0, 0.0, 40.0, 20.0), (50.0, 100.0, 30.0, 20.0)]); + assert!(doc.align_selected(AlignAction::Left)); + assert_eq!(bounds_of(&doc, NodeId::new(10)).origin.x, 10.0); + assert_eq!(bounds_of(&doc, NodeId::new(11)).origin.x, 10.0); + // History captured. + assert_eq!(doc.history.past.len(), 1); + } + + #[test] + fn align_right_snaps_to_union_max_x() { + let mut doc = three_rects(&[(0.0, 0.0, 40.0, 20.0), (50.0, 100.0, 30.0, 20.0)]); + assert!(doc.align_selected(AlignAction::Right)); + // Union max-x = 80; first node moves so its right=80 → x=40. + assert_eq!(bounds_of(&doc, NodeId::new(10)).origin.x, 40.0); + assert_eq!(bounds_of(&doc, NodeId::new(11)).origin.x, 50.0); + } + + #[test] + fn align_center_h_snaps_to_union_mid_x() { + let mut doc = three_rects(&[(0.0, 0.0, 40.0, 20.0), (60.0, 100.0, 20.0, 20.0)]); + // Union x=[0..80], mid=40. Rect1 (w=40) → x=20. Rect2 (w=20) → x=30. + assert!(doc.align_selected(AlignAction::CenterH)); + assert_eq!(bounds_of(&doc, NodeId::new(10)).origin.x, 20.0); + assert_eq!(bounds_of(&doc, NodeId::new(11)).origin.x, 30.0); + } + + #[test] + fn align_top_snaps_to_union_min_y() { + let mut doc = three_rects(&[(0.0, 10.0, 20.0, 20.0), (50.0, 80.0, 20.0, 20.0)]); + assert!(doc.align_selected(AlignAction::Top)); + assert_eq!(bounds_of(&doc, NodeId::new(10)).origin.y, 10.0); + assert_eq!(bounds_of(&doc, NodeId::new(11)).origin.y, 10.0); + } + + #[test] + fn align_bottom_snaps_to_union_max_y() { + let mut doc = three_rects(&[(0.0, 0.0, 20.0, 20.0), (50.0, 50.0, 20.0, 30.0)]); + // Union y=[0..80], max=80. Rect1 (h=20) → y=60. Rect2 (h=30) → y=50. + assert!(doc.align_selected(AlignAction::Bottom)); + assert_eq!(bounds_of(&doc, NodeId::new(10)).origin.y, 60.0); + assert_eq!(bounds_of(&doc, NodeId::new(11)).origin.y, 50.0); + } + + #[test] + fn align_center_v_snaps_to_union_mid_y() { + let mut doc = three_rects(&[(0.0, 0.0, 20.0, 40.0), (50.0, 60.0, 20.0, 20.0)]); + // Union y=[0..80], mid=40. Rect1 (h=40) → y=20. Rect2 (h=20) → y=30. + assert!(doc.align_selected(AlignAction::CenterV)); + assert_eq!(bounds_of(&doc, NodeId::new(10)).origin.y, 20.0); + assert_eq!(bounds_of(&doc, NodeId::new(11)).origin.y, 30.0); + } + + #[test] + fn distribute_h_equal_spacing_between_centers() { + // Centers at x = 10, 25, 90. After distribute the middle + // moves so step = (90 - 10) / 2 = 40 → middle center = 50. + let mut doc = three_rects(&[ + (0.0, 0.0, 20.0, 20.0), + (20.0, 0.0, 10.0, 20.0), + (80.0, 0.0, 20.0, 20.0), + ]); + assert!(doc.align_selected(AlignAction::DistributeH)); + // Middle node center → 50; w=10 → x=45. + assert_eq!(bounds_of(&doc, NodeId::new(11)).origin.x, 45.0); + // Endpoints unchanged. + assert_eq!(bounds_of(&doc, NodeId::new(10)).origin.x, 0.0); + assert_eq!(bounds_of(&doc, NodeId::new(12)).origin.x, 80.0); + } + + #[test] + fn distribute_v_equal_spacing_between_centers() { + let mut doc = three_rects(&[ + (0.0, 0.0, 20.0, 20.0), + (0.0, 30.0, 20.0, 10.0), + (0.0, 80.0, 20.0, 20.0), + ]); + // Centers y = 10, 35, 90 → step = 40 → middle center = 50. + // Middle h=10 → y=45. + assert!(doc.align_selected(AlignAction::DistributeV)); + assert_eq!(bounds_of(&doc, NodeId::new(11)).origin.y, 45.0); + } + + #[test] + fn distribute_under_three_is_no_op() { + let mut doc = three_rects(&[(0.0, 0.0, 20.0, 20.0), (40.0, 0.0, 20.0, 20.0)]); + // Only two selected — distribute returns false. + assert!(!doc.align_selected(AlignAction::DistributeH)); + // History untouched. + assert_eq!(doc.history.past.len(), 0); + } + + #[test] + fn empty_selection_no_ops() { + let mut doc = three_rects(&[(0.0, 0.0, 20.0, 20.0)]); + doc.selected_set.clear(); + doc.selected = NodeId::NONE; + assert!(!doc.align_selected(AlignAction::Left)); + assert_eq!(doc.history.past.len(), 0); + } + + #[test] + fn already_aligned_skips_history() { + // Both already at x=10 → align-left is a 0-delta no-op. + let mut doc = three_rects(&[(10.0, 0.0, 20.0, 20.0), (10.0, 50.0, 30.0, 20.0)]); + assert!(!doc.align_selected(AlignAction::Left)); + assert_eq!(doc.history.past.len(), 0); + } + + #[test] + fn single_select_aligns_to_parent_frame() { + // Frame parent at (0, 0, 200, 100) with a 20x20 child at (50, 50). + let mut doc = Document::empty(); + let page = doc.pages.get_mut(0).unwrap(); + page.children.clear(); + let mut child = Node::leaf(20, NodeKind::Rect, "c"); + child.bounds = rect_at(50.0, 50.0, 20.0, 20.0); + let mut frame = Node::with_children(10, NodeKind::Frame, "f", vec![child]); + frame.bounds = rect_at(0.0, 0.0, 200.0, 100.0); + page.children.push(frame); + doc.selected_set = vec![NodeId::new(20)]; + doc.selected = NodeId::new(20); + // Align-left → child.x = 0 (parent's left edge). + assert!(doc.align_selected(AlignAction::Left)); + assert_eq!(bounds_of(&doc, NodeId::new(20)).origin.x, 0.0); + } + + #[test] + fn selection_mutators_clear_align_toolbar_hover() { + // Codex CONCERN-4: dropping below 2 selected must clear + // align_toolbar_hover so no stale tint survives until the + // next cursor move. set_single_selection / toggle_selection / + // clear_selection all need this. + let mut doc = three_rects(&[(0.0, 0.0, 20.0, 20.0), (40.0, 0.0, 20.0, 20.0)]); + doc.ui.align_toolbar_hover = Some(AlignAction::Left); + doc.set_single_selection(NodeId::new(10)); + assert_eq!(doc.ui.align_toolbar_hover, None); + + doc.selected_set = vec![NodeId::new(10), NodeId::new(11)]; + doc.selected = NodeId::new(11); + doc.ui.align_toolbar_hover = Some(AlignAction::Right); + doc.toggle_selection(NodeId::new(11)); + // selection_count now 1 → hover must clear. + assert_eq!(doc.ui.align_toolbar_hover, None); + + doc.selected_set = vec![NodeId::new(10), NodeId::new(11)]; + doc.selected = NodeId::new(11); + doc.ui.align_toolbar_hover = Some(AlignAction::CenterH); + doc.clear_selection(); + assert_eq!(doc.ui.align_toolbar_hover, None); + } + + #[test] + fn toggle_adding_third_node_keeps_hover() { + // Codex CONCERN-4 inverse: when toggle KEEPS count >= 2 (or + // grows it), the hover should NOT be cleared — losing it + // would break the visual feedback for a hovered button. + let mut doc = three_rects(&[ + (0.0, 0.0, 20.0, 20.0), + (40.0, 0.0, 20.0, 20.0), + (80.0, 0.0, 20.0, 20.0), + ]); + // Pre-select 2 nodes, then toggle a 3rd in. + doc.selected_set = vec![NodeId::new(10), NodeId::new(11)]; + doc.selected = NodeId::new(11); + doc.ui.align_toolbar_hover = Some(AlignAction::DistributeH); + doc.toggle_selection(NodeId::new(12)); + // Count goes to 3, hover persists. + assert_eq!(doc.ui.align_toolbar_hover, Some(AlignAction::DistributeH)); + } + + #[test] + fn ancestor_in_set_skips_descendant_align() { + // Frame at (0, 0, 200, 200) with a 20x20 child at (150, 50). + // Both Frame and child are in the selection. After align-left, + // ONLY the Frame should move (delta = 0); the child cascades + // along. The descendant's own apply_align entry must be + // skipped or the child would move twice. (codex CONCERN-1) + let mut doc = Document::empty(); + let page = doc.pages.get_mut(0).unwrap(); + page.children.clear(); + let mut child = Node::leaf(20, NodeKind::Rect, "c"); + child.bounds = Rect::xywh(150.0, 50.0, 20.0, 20.0); + let mut frame = Node::with_children(10, NodeKind::Frame, "f", vec![child]); + frame.bounds = Rect::xywh(0.0, 0.0, 200.0, 200.0); + // Separate sibling so the union has something to align against. + let mut sibling = Node::leaf(30, NodeKind::Rect, "s"); + sibling.bounds = Rect::xywh(400.0, 0.0, 100.0, 100.0); + page.children.push(frame); + page.children.push(sibling); + doc.selected_set = vec![NodeId::new(10), NodeId::new(20), NodeId::new(30)]; + doc.selected = NodeId::new(30); + // Union x = [0, 500]. Align-left snaps everything to x=0. + assert!(doc.align_selected(AlignAction::Left)); + // Frame already at x=0 — no delta needed; but the child + // would be at x=350 if it had been moved twice, OR at x=150 + // if it had been moved once standalone (independent shift). + // Correct cascade keeps child relative-to-parent: x=150. + assert_eq!(bounds_of(&doc, NodeId::new(10)).origin.x, 0.0); + assert_eq!(bounds_of(&doc, NodeId::new(20)).origin.x, 150.0); + // Sibling does move: from x=400 to x=0. + assert_eq!(bounds_of(&doc, NodeId::new(30)).origin.x, 0.0); + } + + #[test] + fn distribute_dedups_ancestor_in_set() { + // Three top-level Frames each with a child. All 6 nodes in + // selection. Distribute-H should sort by the THREE Frames' + // centers, not by 6 anchors — descendants ride along. + let mut doc = Document::empty(); + let page = doc.pages.get_mut(0).unwrap(); + page.children.clear(); + for i in 0..3 { + let cx = i as f32 * 40.0 + 10.0; + let mut child = Node::leaf(200 + i as u64, NodeKind::Rect, "c"); + child.bounds = Rect::xywh(cx, 0.0, 20.0, 20.0); + let mut frame = + Node::with_children(100 + i as u64, NodeKind::Frame, "f", vec![child]); + frame.bounds = Rect::xywh(i as f32 * 80.0, 0.0, 40.0, 40.0); + page.children.push(frame); + } + // Mess up middle frame: push x=20 so its center is 40 (not 100). + // Frame centers: 20, 40, 200 → step (200-20)/2 = 90 → middle → 110. + page.children[1].bounds = Rect::xywh(20.0, 0.0, 40.0, 40.0); + page.children[2].bounds = Rect::xywh(180.0, 0.0, 40.0, 40.0); + doc.selected_set = vec![ + NodeId::new(100), NodeId::new(101), NodeId::new(102), + NodeId::new(200), NodeId::new(201), NodeId::new(202), + ]; + doc.selected = NodeId::new(102); + assert!(doc.align_selected(AlignAction::DistributeH)); + // Middle frame center should be 110 → frame.x = 90. + assert_eq!(bounds_of(&doc, NodeId::new(101)).origin.x, 90.0); + // Endpoints untouched. + assert_eq!(bounds_of(&doc, NodeId::new(100)).origin.x, 0.0); + assert_eq!(bounds_of(&doc, NodeId::new(102)).origin.x, 180.0); + } + + #[test] + fn single_select_top_level_no_ops() { + // Single top-level node has no parent reference → no-op. + let mut doc = three_rects(&[(50.0, 50.0, 20.0, 20.0)]); + assert!(!doc.align_selected(AlignAction::Left)); + assert_eq!(doc.history.past.len(), 0); + } +} diff --git a/crates/openpencil-shell-core/src/document/mutators.rs b/crates/openpencil-shell-core/src/document/mutators.rs index d1cdfe17b..319835b22 100644 --- a/crates/openpencil-shell-core/src/document/mutators.rs +++ b/crates/openpencil-shell-core/src/document/mutators.rs @@ -193,6 +193,7 @@ impl Document { } else { self.clear_selection(); } + self.ui.align_toolbar_hover = None; } /// Shift-click semantics: if `id` is already in the set, @@ -204,19 +205,19 @@ impl Document { } if let Some(pos) = self.selected_set.iter().position(|n| *n == id) { self.selected_set.remove(pos); - // Anchor needs a new home. Last entry (most-recently - // added survivor) is the natural choice. self.selected = self.selected_set.last().copied().unwrap_or(NodeId::NONE); } else { self.selected_set.push(id); self.selected = id; } + if self.selected_set.len() < 2 { self.ui.align_toolbar_hover = None; } } /// Clear both anchor + set. Idempotent. pub fn clear_selection(&mut self) { self.selected_set.clear(); self.selected = NodeId::NONE; + self.ui.align_toolbar_hover = None; } /// Whether `id` resolves to a node that can be mutated via diff --git a/crates/openpencil-shell-core/src/widgets/align_toolbar.rs b/crates/openpencil-shell-core/src/widgets/align_toolbar.rs new file mode 100644 index 000000000..237ccca39 --- /dev/null +++ b/crates/openpencil-shell-core/src/widgets/align_toolbar.rs @@ -0,0 +1,291 @@ +//! Floating align / distribute toolbar — appears above the canvas +//! whenever the active selection has 2+ nodes (distribute also +//! requires 3+; the 2-node case still shows the buttons, but the +//! distribute presses no-op gracefully via `align_selected`). +//! +//! Layout: three button groups separated by ~12 px gutters: +//! - Align L / Center-H / Right +//! - Align Top / Center-V / Bottom +//! - Distribute H / Distribute V +//! Anchored to the horizontal center of the canvas region, ~16 px +//! below the canvas top edge. + +use crate::document::{AlignAction, Document}; +use crate::theme::Theme; +use crate::widgets::icons::{draw_icon, Icon}; +use crate::{Point2D, Rect, RenderBackend}; + +pub const ALIGN_TOOLBAR_HEIGHT: f32 = 36.0; +const BUTTON_SIZE: f32 = 28.0; +const ICON_SIZE: f32 = 16.0; +const INNER_GAP: f32 = 2.0; +const GROUP_GAP: f32 = 10.0; +const SIDE_PAD: f32 = 6.0; +const CORNER_RADIUS: f32 = 8.0; + +/// (8 buttons × 28) + (6 inner gaps × 2) + (2 group gaps × 10) + (2 sides × 6). +pub const ALIGN_TOOLBAR_WIDTH: f32 = + BUTTON_SIZE * 8.0 + INNER_GAP * 6.0 + GROUP_GAP * 2.0 + SIDE_PAD * 2.0; + +/// Pixels reserved on the canvas-left edge for the vertical Toolbar +/// column. Mirrors paint-side geometry: `TOOLBAR_INSET_X` (12 in +/// shell-native `widget_host/helpers.rs` and shell-web `widget_host.rs`) +/// + `TOOLBAR_WIDTH` (44) = 56 — leaves the tool column unobscured. +/// Has to be a shell-core local because shell-core can't depend on +/// either host crate's helpers. Keep in sync if either constant moves. +const VERTICAL_TOOLBAR_RESERVE: f32 = 56.0; + +const ITEMS: &[(AlignAction, Icon)] = &[ + (AlignAction::Left, Icon::AlignLeft), + (AlignAction::CenterH, Icon::AlignCenterH), + (AlignAction::Right, Icon::AlignRight), + (AlignAction::Top, Icon::AlignTop), + (AlignAction::CenterV, Icon::AlignCenterV), + (AlignAction::Bottom, Icon::AlignBottom), + (AlignAction::DistributeH, Icon::DistributeH), + (AlignAction::DistributeV, Icon::DistributeV), +]; + +/// 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]; + +pub struct AlignToolbar { + rect: Rect, +} + +impl AlignToolbar { + /// Build a toolbar centered horizontally inside `canvas_region` + /// when `doc.selection_count() >= 2`. Returns `None` otherwise + /// so the host can skip paint + hit-test entirely. + pub fn for_canvas_region(canvas_region: Rect, doc: &Document) -> Option { + if doc.selection_count() < 2 { + return None; + } + // Center horizontally, then clamp into + // [canvas_left + VERTICAL_TOOLBAR_RESERVE, canvas_right - W]. + // The min_x reserve keeps the floating toolbar from overlapping + // the vertical Toolbar's column on the canvas-left edge; the + // 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 min_x = canvas_region.origin.x + VERTICAL_TOOLBAR_RESERVE; + let max_x = canvas_region.origin.x + canvas_region.size.x - ALIGN_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; + if x < min_x { + x = min_x; + } + if x > max_x { + x = max_x; + } + let y = canvas_region.origin.y + 16.0; + Some(Self { + rect: Rect::xywh(x, y, ALIGN_TOOLBAR_WIDTH, ALIGN_TOOLBAR_HEIGHT), + }) + } + + pub fn rect(&self) -> Rect { + self.rect + } + + /// Paint the toolbar background + 8 buttons. `hovered` tints the + /// matching button with `theme.muted`. + pub fn paint( + &self, + backend: &mut dyn RenderBackend, + theme: &Theme, + hovered: Option, + ) { + backend.fill_round_rect(self.rect, CORNER_RADIUS, theme.popover); + backend.stroke_round_rect(self.rect, CORNER_RADIUS, theme.border, 1.0); + for (i, (action, icon)) in ITEMS.iter().enumerate() { + let r = self.button_rect(i); + if hovered == Some(*action) { + backend.fill_round_rect(r, 5.0, theme.muted); + } + 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; + draw_icon( + backend, + *icon, + 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 { + 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); + } + } + None + } + + fn button_rect(&self, index: usize) -> Rect { + let mut x = self.rect.origin.x + SIDE_PAD; + for i in 0..index { + x += BUTTON_SIZE; + x += if GROUP_BREAKS.contains(&(i + 1)) { + GROUP_GAP + } else { + INNER_GAP + }; + } + let y = self.rect.origin.y + (self.rect.size.y - BUTTON_SIZE) / 2.0; + Rect::xywh(x, y, BUTTON_SIZE, BUTTON_SIZE) + } +} + +fn rect_contains(r: Rect, p: Point2D) -> bool { + p.x >= r.origin.x + && p.x < r.origin.x + r.size.x + && p.y >= r.origin.y + && p.y < r.origin.y + r.size.y +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::{Node, NodeId, NodeKind}; + + fn doc_with_n_selected(n: usize) -> Document { + let mut doc = Document::empty(); + let page = doc.pages.get_mut(0).unwrap(); + page.children.clear(); + for i in 0..n { + let mut node = Node::leaf(100 + i as u64, NodeKind::Rect, "r"); + node.bounds = Rect::xywh(i as f32 * 50.0, 0.0, 40.0, 20.0); + page.children.push(node); + } + let ids: Vec = (0..n).map(|i| NodeId::new(100 + i as u64)).collect(); + doc.selected_set = ids.clone(); + doc.selected = ids.last().copied().unwrap_or(NodeId::NONE); + doc + } + + fn canvas() -> Rect { + Rect::xywh(0.0, 0.0, 1000.0, 600.0) + } + + #[test] + fn single_select_hides_toolbar() { + let doc = doc_with_n_selected(1); + assert!(AlignToolbar::for_canvas_region(canvas(), &doc).is_none()); + } + + #[test] + fn empty_selection_hides_toolbar() { + let mut doc = doc_with_n_selected(2); + doc.selected_set.clear(); + doc.selected = NodeId::NONE; + assert!(AlignToolbar::for_canvas_region(canvas(), &doc).is_none()); + } + + #[test] + fn two_selected_shows_toolbar_centered() { + let doc = doc_with_n_selected(2); + let tb = AlignToolbar::for_canvas_region(canvas(), &doc).unwrap(); + // Horizontally centered in canvas (cx = 500). + let expected_x = 500.0 - ALIGN_TOOLBAR_WIDTH / 2.0; + assert!((tb.rect.origin.x - expected_x).abs() < 0.5); + // Sits 16 px below canvas top. + assert_eq!(tb.rect.origin.y, 16.0); + assert_eq!(tb.rect.size.x, ALIGN_TOOLBAR_WIDTH); + assert_eq!(tb.rect.size.y, ALIGN_TOOLBAR_HEIGHT); + } + + #[test] + fn hit_test_maps_buttons_to_actions() { + let doc = doc_with_n_selected(3); + let tb = AlignToolbar::for_canvas_region(canvas(), &doc).unwrap(); + for (i, (action, _)) in ITEMS.iter().enumerate() { + let r = tb.button_rect(i); + let center = Point2D::new( + r.origin.x + r.size.x / 2.0, + r.origin.y + r.size.y / 2.0, + ); + assert_eq!(tb.hit_test(center), Some(*action), "button {i}"); + } + } + + #[test] + fn hit_test_misses_outside_toolbar() { + let doc = doc_with_n_selected(2); + let tb = AlignToolbar::for_canvas_region(canvas(), &doc).unwrap(); + assert_eq!(tb.hit_test(Point2D::new(-10.0, -10.0)), None); + assert_eq!(tb.hit_test(Point2D::new(500.0, 500.0)), None); + } + + #[test] + fn narrow_canvas_hides_toolbar() { + // Codex CONCERN-3: hide when canvas can't fit both the + // align toolbar AND the vertical Toolbar reserve on the + // left. Threshold = ALIGN_TOOLBAR_WIDTH + 56. + let doc = doc_with_n_selected(2); + let too_narrow = Rect::xywh( + 100.0, + 0.0, + ALIGN_TOOLBAR_WIDTH + VERTICAL_TOOLBAR_RESERVE - 1.0, + 600.0, + ); + assert!(AlignToolbar::for_canvas_region(too_narrow, &doc).is_none()); + // At exactly the threshold the toolbar appears, pinned to + // canvas_left + reserve. + let exact = Rect::xywh( + 100.0, + 0.0, + ALIGN_TOOLBAR_WIDTH + VERTICAL_TOOLBAR_RESERVE, + 600.0, + ); + let tb = AlignToolbar::for_canvas_region(exact, &doc).unwrap(); + assert_eq!(tb.rect.origin.x, 100.0 + VERTICAL_TOOLBAR_RESERVE); + } + + #[test] + fn toolbar_clamp_reserves_vertical_toolbar_column() { + // Codex stop-gate CONCERN: the centered candidate would + // push past the vertical Toolbar (occupying ~56 px on the + // canvas-left edge). Clamp pins min_x to canvas_left + 56 + // so the two widgets never visually overlap. Otherwise a + // user-visible align button could be eaten by the + // already-painted vertical Toolbar's hit-test region. + let doc = doc_with_n_selected(2); + // Just enough width to require clamping. Centered candidate + // = canvas_left + 50 (overlaps with the Toolbar column). + let canvas = Rect::xywh(0.0, 0.0, ALIGN_TOOLBAR_WIDTH + 100.0, 600.0); + let tb = AlignToolbar::for_canvas_region(canvas, &doc).unwrap(); + // Centered would be x=50; clamp pushes to 56. + assert_eq!(tb.rect.origin.x, VERTICAL_TOOLBAR_RESERVE); + } + + #[test] + fn buttons_grouped_with_wider_gap_at_breaks() { + let doc = doc_with_n_selected(2); + let tb = AlignToolbar::for_canvas_region(canvas(), &doc).unwrap(); + // Distance between button 2 (Right, last in group 1) and + // button 3 (Top, first in group 2) is GROUP_GAP, not INNER_GAP. + let b2 = tb.button_rect(2); + let b3 = tb.button_rect(3); + let gap = b3.origin.x - (b2.origin.x + b2.size.x); + assert!((gap - GROUP_GAP).abs() < 0.01); + // Distance between button 0 (Left) and 1 (CenterH) is INNER_GAP. + let b0 = tb.button_rect(0); + let b1 = tb.button_rect(1); + let gap = b1.origin.x - (b0.origin.x + b0.size.x); + assert!((gap - INNER_GAP).abs() < 0.01); + } +} diff --git a/crates/openpencil-shell-core/src/widgets/icons.rs b/crates/openpencil-shell-core/src/widgets/icons.rs index d898333a4..f8ea761b3 100644 --- a/crates/openpencil-shell-core/src/widgets/icons.rs +++ b/crates/openpencil-shell-core/src/widgets/icons.rs @@ -209,6 +209,22 @@ pub enum Icon { XCircle, /// Lucide `file-text.svg` — recent file rows. FileText, + /// Lucide `align-start-vertical` — align selection's left edges. + AlignLeft, + /// Lucide `align-center-vertical` — align selection horizontal centers. + AlignCenterH, + /// Lucide `align-end-vertical` — align selection's right edges. + AlignRight, + /// Lucide `align-start-horizontal` — align selection's top edges. + AlignTop, + /// Lucide `align-center-horizontal` — align selection vertical centers. + AlignCenterV, + /// Lucide `align-end-horizontal` — align selection's bottom edges. + AlignBottom, + /// Lucide `align-horizontal-distribute-center` — equal center spacing on X. + DistributeH, + /// Lucide `align-vertical-distribute-center` — equal center spacing on Y. + DistributeV, } impl Icon { @@ -329,6 +345,14 @@ impl Icon { Icon::Menu => MENU, Icon::CreditCard => CREDIT_CARD, Icon::XCircle => X_CIRCLE, + Icon::AlignLeft => ALIGN_LEFT, + Icon::AlignCenterH => ALIGN_CENTER_H, + Icon::AlignRight => ALIGN_RIGHT, + Icon::AlignTop => ALIGN_TOP, + Icon::AlignCenterV => ALIGN_CENTER_V, + Icon::AlignBottom => ALIGN_BOTTOM, + Icon::DistributeH => DISTRIBUTE_H, + Icon::DistributeV => DISTRIBUTE_V, } } @@ -440,6 +464,14 @@ impl Icon { "menu" | "hamburger" => Icon::Menu, "credit-card" | "card" => Icon::CreditCard, "x-circle" | "cancel" => Icon::XCircle, + "align-left" | "align-start-vertical" => Icon::AlignLeft, + "align-center-h" | "align-center-vertical" | "align-horizontal-center" => Icon::AlignCenterH, + "align-right" | "align-end-vertical" => Icon::AlignRight, + "align-top" | "align-start-horizontal" => Icon::AlignTop, + "align-center-v" | "align-center-horizontal" | "align-vertical-center" => Icon::AlignCenterV, + "align-bottom" | "align-end-horizontal" => Icon::AlignBottom, + "distribute-h" | "align-horizontal-distribute-center" | "distribute-horizontal" => Icon::DistributeH, + "distribute-v" | "align-vertical-distribute-center" | "distribute-vertical" => Icon::DistributeV, _ => return None, }) } diff --git a/crates/openpencil-shell-core/src/widgets/icons_data.rs b/crates/openpencil-shell-core/src/widgets/icons_data.rs index 33c6b9b34..b6b00ce3d 100644 --- a/crates/openpencil-shell-core/src/widgets/icons_data.rs +++ b/crates/openpencil-shell-core/src/widgets/icons_data.rs @@ -619,3 +619,72 @@ pub(super) const SETTINGS2: &[&str] = &[ "M14 17a3 3 0 1 0 6 0 3 3 0 0 0-6 0z", "M4 7a3 3 0 1 0 6 0 3 3 0 0 0-6 0z", ]; + +// === Align toolbar icons (lucide@0.545.0) === +// Rounded rects expanded to "M…H…A…V…A…H…A…V…A…Z" paths. + +pub(super) const ALIGN_LEFT: &[&str] = &[ + // align-start-vertical: small + wide rect snapped to x=6, vert. spine at x=2. + "M8 14 H13 A2 2 0 0 1 15 16 V18 A2 2 0 0 1 13 20 H8 A2 2 0 0 1 6 18 V16 A2 2 0 0 1 8 14 Z", + "M8 4 H20 A2 2 0 0 1 22 6 V8 A2 2 0 0 1 20 10 H8 A2 2 0 0 1 6 8 V6 A2 2 0 0 1 8 4 Z", + "M2 2v20", +]; + +pub(super) const ALIGN_CENTER_H: &[&str] = &[ + // align-center-vertical: center spine + two pill clips. + "M12 2v20", + "M8 10H4a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h4", + "M16 10h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-4", + "M8 20H7a2 2 0 0 1-2-2v-2c0-1.1.9-2 2-2h1", + "M16 14h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-1", +]; + +pub(super) const ALIGN_RIGHT: &[&str] = &[ + // align-end-vertical: rects flush to spine on right (x=22). + "M4 4 H16 A2 2 0 0 1 18 6 V8 A2 2 0 0 1 16 10 H4 A2 2 0 0 1 2 8 V6 A2 2 0 0 1 4 4 Z", + "M11 14 H16 A2 2 0 0 1 18 16 V18 A2 2 0 0 1 16 20 H11 A2 2 0 0 1 9 18 V16 A2 2 0 0 1 11 14 Z", + "M22 22V2", +]; + +pub(super) const ALIGN_TOP: &[&str] = &[ + // align-start-horizontal: tall + short rect snapped to y=6, horiz. spine at y=2. + "M6 6 H8 A2 2 0 0 1 10 8 V20 A2 2 0 0 1 8 22 H6 A2 2 0 0 1 4 20 V8 A2 2 0 0 1 6 6 Z", + "M16 6 H18 A2 2 0 0 1 20 8 V13 A2 2 0 0 1 18 15 H16 A2 2 0 0 1 14 13 V8 A2 2 0 0 1 16 6 Z", + "M22 2H2", +]; + +pub(super) const ALIGN_CENTER_V: &[&str] = &[ + // align-center-horizontal: center spine + two pill clips top/bottom. + "M2 12h20", + "M10 16v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-4", + "M10 8V4a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v4", + "M20 16v1a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-1", + "M14 8V7c0-1.1.9-2 2-2h2a2 2 0 0 1 2 2v1", +]; + +pub(super) const ALIGN_BOTTOM: &[&str] = &[ + // align-end-horizontal: rects flush to spine on bottom (y=22). + "M6 2 H8 A2 2 0 0 1 10 4 V16 A2 2 0 0 1 8 18 H6 A2 2 0 0 1 4 16 V4 A2 2 0 0 1 6 2 Z", + "M16 9 H18 A2 2 0 0 1 20 11 V16 A2 2 0 0 1 18 18 H16 A2 2 0 0 1 14 16 V11 A2 2 0 0 1 16 9 Z", + "M22 22H2", +]; + +pub(super) const DISTRIBUTE_H: &[&str] = &[ + // align-horizontal-distribute-center: two rects + tick marks above/below. + "M6 5 H8 A2 2 0 0 1 10 7 V17 A2 2 0 0 1 8 19 H6 A2 2 0 0 1 4 17 V7 A2 2 0 0 1 6 5 Z", + "M16 7 H18 A2 2 0 0 1 20 9 V15 A2 2 0 0 1 18 17 H16 A2 2 0 0 1 14 15 V9 A2 2 0 0 1 16 7 Z", + "M17 22v-5", + "M17 7V2", + "M7 22v-3", + "M7 5V2", +]; + +pub(super) const DISTRIBUTE_V: &[&str] = &[ + // align-vertical-distribute-center: two rects + tick marks left/right. + "M22 17h-3", + "M22 7h-5", + "M5 17H2", + "M7 7H2", + "M7 14 H17 A2 2 0 0 1 19 16 V18 A2 2 0 0 1 17 20 H7 A2 2 0 0 1 5 18 V16 A2 2 0 0 1 7 14 Z", + "M9 4 H15 A2 2 0 0 1 17 6 V8 A2 2 0 0 1 15 10 H9 A2 2 0 0 1 7 8 V6 A2 2 0 0 1 9 4 Z", +]; diff --git a/crates/openpencil-shell-core/src/widgets/mod.rs b/crates/openpencil-shell-core/src/widgets/mod.rs index 56d8082fb..40f48e95a 100644 --- a/crates/openpencil-shell-core/src/widgets/mod.rs +++ b/crates/openpencil-shell-core/src/widgets/mod.rs @@ -71,6 +71,7 @@ pub mod agent_settings_mcp; pub mod agent_settings_panel; pub mod agent_settings_system; pub mod ai_chat_panel; +pub mod align_toolbar; pub mod color_picker; pub mod figma_import; pub mod file_menu; @@ -98,6 +99,7 @@ pub use ai_chat_panel::{ AIChatHit, 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 locale_picker::{LocalePicker, LOCALE_PICKER_WIDTH}; pub use shape_picker::{ShapeChoice, ShapePicker, SHAPE_PICKER_WIDTH}; pub use status_bar::{StatusBar, STATUS_BAR_HEIGHT, STATUS_BAR_WIDTH}; diff --git a/crates/openpencil-shell-native/src/widget_host/geometry.rs b/crates/openpencil-shell-native/src/widget_host/geometry.rs index 05d4df5b6..b9a8e0737 100644 --- a/crates/openpencil-shell-native/src/widget_host/geometry.rs +++ b/crates/openpencil-shell-native/src/widget_host/geometry.rs @@ -367,4 +367,27 @@ impl WidgetHostNative { size: Point2D::new(panel_w, panel_h), }) } + + /// Resolve a screen point to an `AlignAction` if it lands on the + /// floating align toolbar (visible when 2+ selected). Returns + /// None when the toolbar isn't shown or the cursor misses every + /// button. Used by press dispatch + cursor-move hover sync so + /// the geometry stays in one place. + pub(in crate::widget_host) fn align_toolbar_hit( + &self, + x: f32, + y: f32, + viewport_w: f32, + viewport_h: f32, + ) -> Option { + use openpencil_shell_core::widgets::AlignToolbar; + use openpencil_shell_core::widgets::TOP_BAR_HEIGHT; + 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.document)? + .hit_test(Point2D::new(x, y)) + } } diff --git a/crates/openpencil-shell-native/src/widget_host/input.rs b/crates/openpencil-shell-native/src/widget_host/input.rs index 240093987..9cc125efd 100644 --- a/crates/openpencil-shell-native/src/widget_host/input.rs +++ b/crates/openpencil-shell-native/src/widget_host/input.rs @@ -281,10 +281,22 @@ impl WidgetHostNative { drag.last_x = x; drag.last_y = y; self.document.viewport.pan(dx, dy); - true - } else { - false + return true; } + // No drag active — sync align toolbar hover. Lives AFTER + // drag detection (codex CONCERN: an active drag's cursor + // sweeping over the toolbar must not be intercepted by a + // hover update). + let new_hover = if self.document.selection_count() >= 2 { + self.align_toolbar_hit(x, y, self.last_viewport_w, self.last_viewport_h) + } else { + None + }; + if new_hover != self.document.ui.align_toolbar_hover { + self.document.ui.align_toolbar_hover = new_hover; + return true; + } + false } /// Mouse-release — ends active drag; chat-panel snaps corner. diff --git a/crates/openpencil-shell-native/src/widget_host/input_tests.rs b/crates/openpencil-shell-native/src/widget_host/input_tests.rs index e1438743f..be781f517 100644 --- a/crates/openpencil-shell-native/src/widget_host/input_tests.rs +++ b/crates/openpencil-shell-native/src/widget_host/input_tests.rs @@ -295,3 +295,54 @@ fn layer_drag_below_activation_threshold_is_a_click_not_a_reorder() { assert_eq!(order, vec![80, 81]); assert_eq!(host.document.selected, NodeId::new(80)); } + +#[test] +fn node_drag_not_intercepted_by_align_toolbar_hover() { + // Codex CONCERN: with 2+ selected, an active node-drag must + // continue moving the nodes when the cursor sweeps over the + // floating align toolbar's hit region. Earlier code's early + // return on hover-state change would have stolen the drag's + // delta for that frame. + use openpencil_shell_core::document::{Node, NodeKind}; + use openpencil_shell_core::widgets::TOP_BAR_HEIGHT; + use openpencil_shell_core::Rect; + let mut host = WidgetHostNative::new(); + let page_idx = host.document.active_page_index; + host.document.pages[page_idx].children = vec![ + Node::leaf(90, NodeKind::Rect, "a").with_bounds(Rect::xywh(50.0, 200.0, 20.0, 20.0)), + Node::leaf(91, NodeKind::Rect, "b").with_bounds(Rect::xywh(120.0, 200.0, 20.0, 20.0)), + ]; + // Two-node selection so the align toolbar is shown. + host.document.selected_set = vec![NodeId::new(90), NodeId::new(91)]; + host.document.selected = NodeId::new(91); + let viewport_w = 1440.0; + let viewport_h = 900.0; + let (cx0, cy0, _cw, _ch) = host.canvas_region(viewport_w, viewport_h); + // Press on node "a" — promotes to a node-drag. + let press_x = cx0 + 60.0; + let press_y = cy0 + 210.0; + host.apply_press(press_x, press_y, viewport_w, viewport_h); + assert!(host.node_drag.is_some(), "node_drag must seed on press"); + // Move the cursor toward the canvas-top center — the align + // toolbar's hit region sits there (y = TOP_BAR_HEIGHT + 16). + let zoom = host.document.viewport.zoom.max(0.0001); + let target_x = host.document.ui.layer_panel_width + 400.0; + let target_y = TOP_BAR_HEIGHT + 24.0; // inside align toolbar y-band + let expected_dx = (target_x - press_x) / zoom; + let expected_dy = (target_y - press_y) / zoom; + host.apply_cursor_move(target_x, target_y); + // Nodes must have translated by (expected_dx, expected_dy). + let bounds_a = host.document.pages[page_idx].children[0].bounds; + let bounds_b = host.document.pages[page_idx].children[1].bounds; + assert!( + (bounds_a.origin.x - (50.0 + expected_dx)).abs() < 0.5 + && (bounds_a.origin.y - (200.0 + expected_dy)).abs() < 0.5, + "node-drag delta lost on a; got {:?}, expected start+delta", + bounds_a + ); + assert!( + (bounds_b.origin.x - (120.0 + expected_dx)).abs() < 0.5, + "node-drag delta lost on b; got {:?}", + bounds_b + ); +} diff --git a/crates/openpencil-shell-native/src/widget_host/paint.rs b/crates/openpencil-shell-native/src/widget_host/paint.rs index 72ddcaf81..a1a8520be 100644 --- a/crates/openpencil-shell-native/src/widget_host/paint.rs +++ b/crates/openpencil-shell-native/src/widget_host/paint.rs @@ -6,9 +6,9 @@ use super::frame_backend::NativeFrameBackend; use super::helpers::{STATUS_INSET, TOOLBAR_INSET_X, TOOLBAR_INSET_Y}; use super::WidgetHostNative; use openpencil_shell_core::widgets::{ - AIChatPlaceholder, CanvasViewport, LayerPanel, LayoutCx, LocalePicker, PaintCx, PropertyPanel, - ShapePicker, StatusBar, Toolbar, TopBar, Widget, STATUS_BAR_HEIGHT, STATUS_BAR_WIDTH, - TOOLBAR_WIDTH, TOP_BAR_HEIGHT, + AIChatPlaceholder, AlignToolbar, CanvasViewport, LayerPanel, LayoutCx, LocalePicker, PaintCx, + PropertyPanel, ShapePicker, StatusBar, Toolbar, TopBar, Widget, STATUS_BAR_HEIGHT, + STATUS_BAR_WIDTH, TOOLBAR_WIDTH, TOP_BAR_HEIGHT, }; use openpencil_shell_core::{Point2D, Rect, RenderBackend}; @@ -179,6 +179,21 @@ impl WidgetHostNative { status.paint(&mut cx, status_rect); } + // 8.4. Floating align/distribute toolbar — visible whenever + // 2+ nodes are selected. Sits above the canvas but + // below status / modal overlays. + let canvas_region = Rect { + origin: Point2D::new(canvas_left, TOP_BAR_HEIGHT), + size: Point2D::new(canvas_w, canvas_h), + }; + if let Some(toolbar) = AlignToolbar::for_canvas_region(canvas_region, &self.document) { + toolbar.paint( + &mut *frame, + &self.theme, + self.document.ui.align_toolbar_hover, + ); + } + // 8.5. Marquee selection rect — painted above canvas but // below the floating pickers / status. Visible only // while the user is dragging a rect-select on empty diff --git a/crates/openpencil-shell-native/src/widget_host/press.rs b/crates/openpencil-shell-native/src/widget_host/press.rs index 70788aa9a..fb0b44032 100644 --- a/crates/openpencil-shell-native/src/widget_host/press.rs +++ b/crates/openpencil-shell-native/src/widget_host/press.rs @@ -283,11 +283,7 @@ impl WidgetHostNative { return rename_committed || text_edit_committed; } - // 0c0. Fill-type picker: any click that isn't a row / - // dropdown toggle dismisses the picker. Same pattern - // as locale / shape pickers — overlay swallows ANY - // click and closes; row hits also dispatch SetFillType - // via the action branch below. + // 0c0. Fill-type picker — outside-click dismiss. if self.document.ui.fill_type_picker_open { if let Some(panel) = PropertyPanel::for_selection(&self.document) { let property_rect = Rect { @@ -466,6 +462,10 @@ impl WidgetHostNative { return rename_committed || text_edit_committed; } + if let Some(a) = self.align_toolbar_hit(x, y, viewport_width, viewport_height) { + self.document.align_selected(a); + return true; + } // 3. apply_click — LayerPanel + chat-defocus. // Before forwarding to apply_click, peek at the LayerPanel // hit-test ourselves: if the press lands on a Layer row diff --git a/crates/openpencil-shell-web/src/widget_host.rs b/crates/openpencil-shell-web/src/widget_host.rs index 15bd3fba3..33156bd54 100644 --- a/crates/openpencil-shell-web/src/widget_host.rs +++ b/crates/openpencil-shell-web/src/widget_host.rs @@ -77,6 +77,11 @@ pub struct WidgetHost { /// Host clock in ms — set by `lib.rs` on each event from /// `performance.now()`. Used for double-click detection. pub(in crate::widget_host) now_ms: u64, + /// Most recent viewport size seen via `apply_press` etc. — cached + /// so `apply_cursor_move(x, y)` can rebuild the canvas region + /// when its signature can't carry viewport dims (mirrors native). + pub(in crate::widget_host) last_viewport_w: f32, + pub(in crate::widget_host) last_viewport_h: f32, } impl WidgetHost { @@ -189,6 +194,8 @@ impl WidgetHost { next_node_id: 100, shift_held: false, now_ms: 0, + last_viewport_w: 0.0, + last_viewport_h: 0.0, } } @@ -245,6 +252,8 @@ impl WidgetHost { viewport_width: f32, viewport_height: f32, ) -> bool { + self.last_viewport_w = viewport_width; + self.last_viewport_h = viewport_height; if !self.over_canvas(x, y, viewport_width, viewport_height) { return false; } @@ -384,10 +393,29 @@ impl WidgetHost { drag.last_x = x; drag.last_y = y; self.document.viewport.pan(dx, dy); - true - } else { - false + return true; } + // No drag active — sync align toolbar hover. AFTER all drag + // branches so an active drag isn't intercepted (codex CONCERN + // — mirrors native widget_host/input.rs ordering). + let new_hover = if self.document.selection_count() >= 2 { + use openpencil_shell_core::widgets::{AlignToolbar, TOP_BAR_HEIGHT}; + let (cx, _, cw, ch) = + self.canvas_region(self.last_viewport_w, self.last_viewport_h); + let canvas_region = openpencil_shell_core::Rect { + origin: Point2D::new(cx, TOP_BAR_HEIGHT), + size: Point2D::new(cw, ch), + }; + AlignToolbar::for_canvas_region(canvas_region, &self.document) + .and_then(|tb| tb.hit_test(Point2D::new(x, y))) + } else { + None + }; + if new_hover != self.document.ui.align_toolbar_hover { + self.document.ui.align_toolbar_hover = new_hover; + return true; + } + false } /// Convert a marquee drag (screen-space) into a doc-space @@ -440,6 +468,8 @@ impl WidgetHost { /// chat-panel drag to the nearest corner, or ends the canvas /// pan-drag. pub fn apply_release_with_viewport(&mut self, viewport_w: f32, viewport_h: f32) -> bool { + self.last_viewport_w = viewport_w; + self.last_viewport_h = viewport_h; if let Some(m) = self.marquee_drag.take() { self.commit_marquee_selection(m, viewport_w, viewport_h); return true; diff --git a/crates/openpencil-shell-web/src/widget_host/paint.rs b/crates/openpencil-shell-web/src/widget_host/paint.rs index e832eae2a..346ae26e2 100644 --- a/crates/openpencil-shell-web/src/widget_host/paint.rs +++ b/crates/openpencil-shell-web/src/widget_host/paint.rs @@ -165,6 +165,24 @@ impl WidgetHost { status.paint(&mut cx, status_rect); } + // Floating align/distribute toolbar — visible whenever 2+ + // nodes are selected. Sits above the canvas but below + // marquee / pickers / modals. + { + use openpencil_shell_core::widgets::AlignToolbar; + let canvas_region = Rect { + origin: Point2D::new(canvas_left, TOP_BAR_HEIGHT), + size: Point2D::new(canvas_w, canvas_h), + }; + if let Some(tb) = AlignToolbar::for_canvas_region(canvas_region, &self.document) { + tb.paint( + &mut *backend, + &self.theme, + self.document.ui.align_toolbar_hover, + ); + } + } + // Marquee selection rect — between StatusBar and the // floating pickers in z-order, only while a marquee // drag is active. diff --git a/crates/openpencil-shell-web/src/widget_host/press.rs b/crates/openpencil-shell-web/src/widget_host/press.rs index ff1471233..b307aa9b4 100644 --- a/crates/openpencil-shell-web/src/widget_host/press.rs +++ b/crates/openpencil-shell-web/src/widget_host/press.rs @@ -104,6 +104,12 @@ impl WidgetHost { viewport_width: f32, viewport_height: f32, ) -> bool { + // Cache the viewport dims so `apply_cursor_move(x, y)` (no + // viewport params in signature) can rebuild the canvas region + // for the floating align toolbar's hover sync. Mirrors the + // native host's `last_viewport_w` / `_h` cache. + self.last_viewport_w = viewport_width; + self.last_viewport_h = viewport_height; // 0-pre. Commit any in-flight rename + canvas text-edit on // first press anywhere. Tracked so the final return reports // the visible change. @@ -279,6 +285,25 @@ impl WidgetHost { }); } } + // 2.5. Floating align/distribute toolbar — visible when + // 2+ nodes are selected. Hit-tested before apply_click + // so the visible button always wins over a layer row + // that happens to share screen y (matches native order). + { + use openpencil_shell_core::widgets::AlignToolbar; + let (acx, _, acw, ach) = self.canvas_region(viewport_width, viewport_height); + let canvas_region = openpencil_shell_core::Rect { + origin: Point2D::new(acx, openpencil_shell_core::widgets::TOP_BAR_HEIGHT), + size: Point2D::new(acw, ach), + }; + if let Some(tb) = AlignToolbar::for_canvas_region(canvas_region, &self.document) { + if let Some(action) = tb.hit_test(Point2D::new(x, y)) { + self.document.align_selected(action); + return true; + } + } + } + if self.apply_click(x, y, viewport_width, viewport_height) { return true; }