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:
parent
1a194efc3d
commit
e1902ad047
|
|
@ -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;
|
||||
|
|
|
|||
508
crates/openpencil-shell-core/src/document/align.rs
Normal file
508
crates/openpencil-shell-core/src/document/align.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
291
crates/openpencil-shell-core/src/widgets/align_toolbar.rs
Normal file
291
crates/openpencil-shell-core/src/widgets/align_toolbar.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue