feat(shell): align + distribute toolbar (multi-select-aware)

Document::align_selected covers 6 align actions (left / center-h / right
/ top / center-v / bottom) and 2 distribute actions (horizontal /
vertical center-spacing). Reference frame is the union of selection
bounds for 2+ nodes, the parent container for a single selection
(top-level no-ops). Ancestor-in-set dedup mirrors translate_selected so
a frame + child selection only shifts the frame; descendants cascade.
History pushes only when at least one node actually moved.

Floating AlignToolbar widget appears when selection_count >= 2; centered
horizontally above the canvas with a 56-px reserve so it never overlaps
the vertical Toolbar column. Hidden entirely when the canvas can't host
both. Hover state lives on Document.ui.align_toolbar_hover and clears on
every selection-count drop. Hit-test sits before apply_click on both
native + web so visible buttons always win clicks. Hover sync runs AFTER
all drag branches in cursor_move so an active node-drag isn't stolen by
a hover update.

8 lucide d-strings (align-start/center/end-vertical/horizontal +
horizontal/vertical-distribute-center) added to icons_data.rs from
lucide-react@0.545.0. Codex stop-gate reviewed three times to BLOCK-free.

Tests: 25 align (mutator + widget) + 1 native drag-interception
regression. Closes the v0.8.0 align/distribute roadmap item.
This commit is contained in:
Kayshen-X 2026-05-14 13:27:44 +08:00
parent 1a194efc3d
commit e1902ad047
15 changed files with 1099 additions and 22 deletions

View file

@ -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<crate::widgets::file_menu::FileMenuChoice>,
/// Locale picker hover — same shape as `file_menu_hover`.
pub locale_picker_hover: Option<Locale>,
/// Shape picker hover — same shape, keyed by `ShapeChoice`.
pub shape_picker_hover: Option<crate::widgets::shape_picker::ShapeChoice>,
pub align_toolbar_hover: Option<AlignAction>,
/// Pending file-menu action.
pub pending_file_action: Option<FileAction>,
/// 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;

View file

@ -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<NodeId> = 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<NodeId> = 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<crate::Rect> {
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<NodeId> = 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);
}
}

View file

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

View file

@ -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<Self> {
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<AlignAction>,
) {
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<AlignAction> {
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<NodeId> = (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);
}
}

View file

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

View file

@ -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",
];

View file

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

View file

@ -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<openpencil_shell_core::document::AlignAction> {
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))
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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