From 1a5dd46acdf761aaea0f69f033cbff2012627119 Mon Sep 17 00:00:00 2001 From: ihavecoke Date: Tue, 11 Nov 2025 11:56:58 +0800 Subject: [PATCH] tiles: Add to support panel auto-snap while drag movement (#1552) Before: https://github.com/user-attachments/assets/b9171a47-e21b-4132-8b13-7d9bf06f083a After: https://github.com/user-attachments/assets/e06c8353-41f8-4124-b9f4-1c44e462ab72 --- crates/story/examples/tiles.rs | 59 +++++----- crates/ui/src/dock/tiles.rs | 193 +++++++++++++++++++++++++++------ 2 files changed, 192 insertions(+), 60 deletions(-) diff --git a/crates/story/examples/tiles.rs b/crates/story/examples/tiles.rs index dea66f4a..414522a2 100644 --- a/crates/story/examples/tiles.rs +++ b/crates/story/examples/tiles.rs @@ -310,37 +310,42 @@ impl StoryTiles { window: &mut Window, cx: &mut App, ) -> DockItem { - DockItem::tiles( - vec![ + const PANELS: usize = 4; + let panels = (0..PANELS) + .map(|i| { + let story = if i % 2 == 0 { + Arc::new(StoryContainer::panel::(window, cx)) + } else { + Arc::new(StoryContainer::panel::(window, cx)) + }; DockItem::tab( - ContainerPanel::new( - Arc::new(StoryContainer::panel::(window, cx)), - window, - cx, - ), + ContainerPanel::new(story, window, cx), dock_area, window, cx, - ), - DockItem::tab( - ContainerPanel::new( - Arc::new(StoryContainer::panel::(window, cx)), - window, - cx, - ), - dock_area, - window, - cx, - ), - ], - vec![ - Bounds::new(point(px(10.), px(10.)), size(px(610.), px(190.))), - Bounds::new(point(px(120.), px(10.)), size(px(650.), px(300.))), - ], - dock_area, - window, - cx, - ) + ) + }) + .collect::>(); + + // Panel size: 380x280, Gap: 20px, Starting position: (20, 20) + let panel_width = px(380.); + let panel_height = px(280.); + let gap = px(20.); + let start_x = px(20.); + let start_y = px(20.); + let cols = 4; + + let bounds = (0..PANELS) + .map(|i| { + let row = i / cols; + let col = i % cols; + let x = start_x + (panel_width + gap) * col as f32; + let y = start_y + (panel_height + gap) * row as f32; + Bounds::new(point(x, y), size(panel_width, panel_height)) + }) + .collect::>(); + + DockItem::tiles(panels, bounds, dock_area, window, cx) } pub fn new_local(cx: &mut App) -> Task>> { diff --git a/crates/ui/src/dock/tiles.rs b/crates/ui/src/dock/tiles.rs index d015e52b..5ffa2ca4 100644 --- a/crates/ui/src/dock/tiles.rs +++ b/crates/ui/src/dock/tiles.rs @@ -240,53 +240,174 @@ impl Tiles { } } - fn update_position( - &mut self, - mouse_position: Point, - _: &mut Window, - cx: &mut Context<'_, Self>, - ) { + /// Calculate magnetic snap position for the dragging panel + fn calculate_magnetic_snap( + &self, + dragging_bounds: Bounds, + item_ix: usize, + snap_threshold: Pixels, + ) -> (Option, Option) { + // Only check nearby panels + let search_bounds = Bounds { + origin: Point { + x: dragging_bounds.left() - snap_threshold, + y: dragging_bounds.top() - snap_threshold, + }, + size: Size { + width: dragging_bounds.size.width + snap_threshold * 2.0, + height: dragging_bounds.size.height + snap_threshold * 2.0, + }, + }; + + let mut snap_x: Option = None; + let mut snap_y: Option = None; + let mut min_x_dist = snap_threshold; + let mut min_y_dist = snap_threshold; + + // Pre-calculate dragging bounds edges to avoid repeated method calls + let drag_left = dragging_bounds.left(); + let drag_right = dragging_bounds.right(); + let drag_top = dragging_bounds.top(); + let drag_bottom = dragging_bounds.bottom(); + let drag_width = dragging_bounds.size.width; + let drag_height = dragging_bounds.size.height; + + for (ix, other) in self.panels.iter().enumerate() { + if ix == item_ix { + continue; + } + + // Pre-calculate other bounds edges + let other_left = other.bounds.left(); + let other_right = other.bounds.right(); + let other_top = other.bounds.top(); + let other_bottom = other.bounds.bottom(); + + // Skip panels that are far away + if other_right < search_bounds.left() + || other_left > search_bounds.right() + || other_bottom < search_bounds.top() + || other_top > search_bounds.bottom() + { + continue; + } + + // Horizontal snapping (X axis) - find closest snap point + if snap_x.is_none() { + let candidates = [ + ((drag_left - other_left).abs(), other_left), + ((drag_left - other_right).abs(), other_right), + ((drag_right - other_left).abs(), other_left - drag_width), + ((drag_right - other_right).abs(), other_right - drag_width), + ]; + + for (dist, snap_pos) in candidates { + if dist < min_x_dist { + min_x_dist = dist; + snap_x = Some(snap_pos); + } + } + } + + // Vertical snapping (Y axis) - find closest snap point + if snap_y.is_none() { + let candidates = [ + ((drag_top - other_top).abs(), other_top), + ((drag_top - other_bottom).abs(), other_bottom), + ((drag_bottom - other_top).abs(), other_top - drag_height), + ( + (drag_bottom - other_bottom).abs(), + other_bottom - drag_height, + ), + ]; + + for (dist, snap_pos) in candidates { + if dist < min_y_dist { + min_y_dist = dist; + snap_y = Some(snap_pos); + } + } + } + + // Early exit if both axes are snapped + if snap_x.is_some() && snap_y.is_some() { + break; + } + } + + (snap_x, snap_y) + } + + /// Apply boundary constraints to the panel origin + fn apply_boundary_constraints(&self, mut origin: Point) -> Point { + // Top boundary + if origin.y < px(0.) { + origin.y = px(0.); + } + + // Left boundary (allow partial off-screen but keep 64px visible) + let min_left = -self.dragging_initial_bounds.size.width + px(64.); + if origin.x < min_left { + origin.x = min_left; + } + + origin + } + + fn update_position(&mut self, mouse_position: Point, cx: &mut Context) { let Some(dragging_id) = self.dragging_id else { return; }; - let Some(item) = self.panels.iter_mut().find(|p| p.id == dragging_id) else { + let Some(item_ix) = self.panels.iter().position(|p| p.id == dragging_id) else { return; }; - let previous_bounds = item.bounds; + let previous_bounds = self.panels[item_ix].bounds; let adjusted_position = mouse_position - self.bounds.origin; let delta = adjusted_position - self.dragging_initial_mouse; let mut new_origin = self.dragging_initial_bounds.origin + delta; - // Avoid out of bounds - if new_origin.y < px(0.) { - new_origin.y = px(0.); + // Apply magnetic snap before boundary checks + let snap_threshold = cx.theme().tile_grid_size; + let dragging_bounds = Bounds { + origin: new_origin, + size: self.dragging_initial_bounds.size, + }; + + let (snap_x, snap_y) = + self.calculate_magnetic_snap(dragging_bounds, item_ix, snap_threshold); + + // Apply snapping + if let Some(x) = snap_x { + new_origin.x = x; } - let min_left = -self.dragging_initial_bounds.size.width + px(64.); - if new_origin.x < min_left { - new_origin.x = min_left; + if let Some(y) = snap_y { + new_origin.y = y; } - let final_origin = round_point_to_nearest_ten(new_origin, cx); - // Only push to history if bounds have changed - if final_origin != previous_bounds.origin { - item.bounds.origin = final_origin; + // Apply boundary constraints after snapping + new_origin = self.apply_boundary_constraints(new_origin); + + // Update position without grid rounding (smooth dragging) + if new_origin != previous_bounds.origin { + self.panels[item_ix].bounds.origin = new_origin; + let item = &self.panels[item_ix]; + let bounds = item.bounds; + let entity_id = item.panel.view().entity_id(); - // Only push if not during history operations if !self.history.ignore { self.history.push(TileChange { - tile_id: item.panel.view().entity_id(), + tile_id: entity_id, old_bounds: Some(previous_bounds), - new_bounds: Some(item.bounds), + new_bounds: Some(bounds), old_order: None, new_order: None, version: 0, }); } + cx.notify(); } - - cx.notify(); } fn resize( @@ -868,16 +989,16 @@ impl Tiles { cx.new(|_| drag.clone()) }) .on_drag_move( - cx.listener(move |this, e: &DragMoveEvent, window, cx| { - match e.drag(cx) { + cx.listener( + move |this, e: &DragMoveEvent, _, cx| match e.drag(cx) { DragMoving(id) => { if *id != entity_id { return; } - this.update_position(e.event.position, window, cx); + this.update_position(e.event.position, cx); } - } - }), + }, + ), ) .into_any_element() } @@ -936,16 +1057,22 @@ impl Tiles { // Handle dragging if let Some(dragging_id) = self.dragging_id { - if let Some(item) = self.panel(&dragging_id) { + if let Some(idx) = self.panels.iter().position(|p| p.id == dragging_id) { let initial_bounds = self.dragging_initial_bounds; - let current_bounds = item.bounds; - if initial_bounds.origin != current_bounds.origin + let current_bounds = self.panels[idx].bounds; + + // Apply grid alignment to final position + let aligned_origin = round_point_to_nearest_ten(current_bounds.origin, cx); + + if initial_bounds.origin != aligned_origin || initial_bounds.size != current_bounds.size { + self.panels[idx].bounds.origin = aligned_origin; + changes_to_push.push(TileChange { - tile_id: item.panel.view().entity_id(), + tile_id: self.panels[idx].panel.view().entity_id(), old_bounds: Some(initial_bounds), - new_bounds: Some(current_bounds), + new_bounds: Some(self.panels[idx].bounds), old_order: None, new_order: None, version: 0,