From 6f4a4219914327fb8f2003cd3865b3baa3e1f947 Mon Sep 17 00:00:00 2001 From: theredfish Date: Wed, 29 Apr 2026 02:48:16 +0200 Subject: [PATCH 01/12] Grid collision system draft --- crates/ui/src/components/grid/core/layout.rs | 384 ++++++++++++------- crates/ui/src/components/grid/grid_item.rs | 67 +--- 2 files changed, 257 insertions(+), 194 deletions(-) diff --git a/crates/ui/src/components/grid/core/layout.rs b/crates/ui/src/components/grid/core/layout.rs index 98d9349..9b2a94e 100644 --- a/crates/ui/src/components/grid/core/layout.rs +++ b/crates/ui/src/components/grid/core/layout.rs @@ -8,7 +8,7 @@ use leptos::{ prelude::{GetUntracked, RwSignal, Set, Update}, }; use ndarray::{concatenate, Array2, Axis}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; #[derive(Clone, Debug, Default)] pub struct Layout { @@ -26,6 +26,174 @@ pub struct Layout { } impl Layout { + fn ensure_rows(&mut self, required_rows: usize) { + if required_rows <= self.rows { + return; + } + + let rows_to_add = required_rows - self.rows; + let empty_rows = Array2::from_elem((rows_to_add, self.columns), None::); + self.collision_grid = + concatenate(Axis(0), &[self.collision_grid.view(), empty_rows.view()]) + .expect("Failed to concatenate empty rows at bottom"); + self.rows = required_rows; + } + + fn rebuild_collision_grid(&mut self) { + self.collision_grid = Array2::from_elem((self.rows, self.columns), None::); + let items = self + .items + .values() + .map(|item| item.get_untracked()) + .collect::>(); + + for item in items { + self.ensure_rows(item.grid_pos.row_start + item.span.row_span); + self.set_item_in_grid(&item); + } + } + + fn clamp_position_for_item(&self, item: &GridItemData, row: usize, col: usize) -> GridPosition { + let max_col = self.columns.saturating_sub(item.span.col_span); + + GridPosition { + row_start: row, + col_start: col.min(max_col), + } + } + + fn col_ranges_overlap(a_start: usize, a_span: usize, b_start: usize, b_span: usize) -> bool { + a_start < b_start + b_span && b_start < a_start + a_span + } + + fn colliding_item_ids(&self, item: &GridItemData) -> Vec { + let mut colliding_ids = HashSet::new(); + let row_end = item.grid_pos.row_start + item.span.row_span; + let col_end = item.grid_pos.col_start + item.span.col_span; + + for row_idx in item.grid_pos.row_start..row_end.min(self.collision_grid.nrows()) { + for col_idx in item.grid_pos.col_start..col_end.min(self.collision_grid.ncols()) { + if let Some(occupant_id) = self.collision_grid[[row_idx, col_idx]] { + if occupant_id != item.id { + colliding_ids.insert(occupant_id); + } + } + } + } + + colliding_ids.into_iter().collect() + } + + fn item_fits_ignoring(&self, item: &GridItemData, ignored_ids: &[u32]) -> bool { + if item.grid_pos.col_start + item.span.col_span > self.columns { + return false; + } + + let ignored_ids = ignored_ids.iter().copied().collect::>(); + let row_end = item.grid_pos.row_start + item.span.row_span; + let col_end = item.grid_pos.col_start + item.span.col_span; + + for row_idx in item.grid_pos.row_start..row_end.min(self.collision_grid.nrows()) { + for col_idx in item.grid_pos.col_start..col_end.min(self.collision_grid.ncols()) { + if let Some(occupant_id) = self.collision_grid[[row_idx, col_idx]] { + if !ignored_ids.contains(&occupant_id) { + return false; + } + } + } + } + + true + } + + fn try_swap_items( + &mut self, + moved_item: &GridItemData, + old_position: GridPosition, + colliding_ids: &[u32], + ) -> bool { + if colliding_ids.len() != 1 { + return false; + } + + let colliding_id = colliding_ids[0]; + let Some(&colliding_item_signal) = self.items.get(&colliding_id) else { + return false; + }; + + let mut colliding_item = colliding_item_signal.get_untracked(); + let has_vertical_neighbor = self.items.values().any(|item_signal| { + let item = item_signal.get_untracked(); + + item.id != moved_item.id + && item.id != colliding_id + && Self::col_ranges_overlap( + item.grid_pos.col_start, + item.span.col_span, + colliding_item.grid_pos.col_start, + colliding_item.span.col_span, + ) + && (item.grid_pos.row_start + item.span.row_span + == colliding_item.grid_pos.row_start + || colliding_item.grid_pos.row_start + colliding_item.span.row_span + == item.grid_pos.row_start) + }); + + if has_vertical_neighbor { + return false; + } + + colliding_item.grid_pos = old_position; + + if !self.item_fits_ignoring(&colliding_item, &[moved_item.id, colliding_id]) { + return false; + } + + colliding_item_signal.update(|item| { + item.grid_pos = old_position; + item.grid_to_pixels(self.cell_size, Axes::XY); + }); + + true + } + + fn push_items_below(&mut self, moved_item: &GridItemData, row_start: usize, by_rows: usize) { + if by_rows == 0 { + return; + } + + let mut items_to_move = self + .items + .values() + .filter_map(|item_signal| { + let item = item_signal.get_untracked(); + let item_row_end = item.grid_pos.row_start + item.span.row_span; + + if item.id == moved_item.id + || !Self::col_ranges_overlap( + item.grid_pos.col_start, + item.span.col_span, + moved_item.grid_pos.col_start, + moved_item.span.col_span, + ) + || item_row_end <= row_start + { + return None; + } + + Some((item.grid_pos.row_start, item.id, *item_signal)) + }) + .collect::>(); + + items_to_move.sort_by_key(|(row_start, id, _)| (*row_start, *id)); + for (_, _, item_signal) in items_to_move { + item_signal.update(|item| { + item.grid_pos.row_start += by_rows; + item.grid_to_pixels(self.cell_size, Axes::XY); + }); + } + } + /// Set an item in the collision grid based on its position and span fn set_item_in_grid(&mut self, item: &GridItemData) { let row_start = item.grid_pos.row_start; @@ -63,69 +231,27 @@ impl Layout { } } - /// Update an item's position in the collision grid (clear old, set new) - /// - /// Note: Currently unused in add_item (which uses concatenation for efficiency), - /// but will be essential for drag/resize operations where items move without - /// adding new rows to the grid. - fn update_item_in_grid(&mut self, old_item: &GridItemData, new_item: &GridItemData) { - self.clear_item_from_grid(old_item); - self.set_item_in_grid(new_item); - } - /// Check for collisions with other items at a given position and span /// Returns a Vec of item IDs that would collide pub fn check_collision(&self, item: &GridItemData) -> Vec { - let mut colliding_ids = std::collections::HashSet::new(); - - let row_start = item.grid_pos.row_start; - let col_start = item.grid_pos.col_start; - - for row_offset in 0..item.span.row_span { - for col_offset in 0..item.span.col_span { - let row_idx = row_start + row_offset; - let col_idx = col_start + col_offset; - - if row_idx < self.collision_grid.nrows() && col_idx < self.collision_grid.ncols() { - if let Some(occupant_id) = self.collision_grid[[row_idx, col_idx]] { - // Don't consider collision with itself - if occupant_id != item.id { - colliding_ids.insert(occupant_id); - } - } - } - } - } - - colliding_ids.into_iter().collect() + self.colliding_item_ids(item) } /// Ensure the grid has enough rows to accommodate all items /// Adds empty rows at the bottom if any item exceeds current grid bounds fn ensure_grid_capacity(&mut self) { // Find the maximum row end among all items - let max_row_end = self + let required_rows = self .items .values() .map(|item_signal| { let item = item_signal.get_untracked(); - // Calculate the last row this item occupies (1-indexed) - item.grid_pos.row_start + item.span.row_span - 1 + item.grid_pos.row_start + item.span.row_span }) .max() .unwrap_or(0); - // If items exceed current grid, add rows at the bottom - if max_row_end > self.rows { - let rows_to_add = max_row_end - self.rows; - let empty_rows = Array2::from_elem((rows_to_add, self.columns), None::); - - self.collision_grid = - concatenate(Axis(0), &[self.collision_grid.view(), empty_rows.view()]) - .expect("Failed to concatenate empty rows at bottom"); - - self.rows = max_row_end; - } + self.ensure_rows(required_rows); } /// Register an item at its specified position (for declarative items from JSX) @@ -149,17 +275,8 @@ impl Layout { } // Ensure grid has enough rows for this item - let item_end_row = untracked_item.grid_pos.row_start + untracked_item.span.row_span - 1; - if item_end_row > self.rows { - let rows_to_add = item_end_row - self.rows; - let empty_rows = Array2::from_elem((rows_to_add, self.columns), None::); - - self.collision_grid = - concatenate(Axis(0), &[self.collision_grid.view(), empty_rows.view()]) - .expect("Failed to concatenate empty rows at bottom"); - - self.rows = item_end_row; - } + let item_end_row = untracked_item.grid_pos.row_start + untracked_item.span.row_span; + self.ensure_rows(item_end_row); // Set item in collision grid at new position self.set_item_in_grid(&untracked_item); @@ -185,7 +302,7 @@ impl Layout { self.columns ); - // Force position to top-left (1-indexed: row 1, col 1) + // Force position to top-left. untracked_item.grid_pos = GridPosition { row_start: 0, col_start: 0, @@ -207,8 +324,7 @@ impl Layout { &items_to_push, 0, untracked_item.span.row_span, - // FIXME: this is just for debugging - (1, untracked_item.span.col_span), + (0, untracked_item.span.col_span), ); // Ensure grid has enough rows after pushing items down self.ensure_grid_capacity(); @@ -249,12 +365,12 @@ impl Layout { self.items.insert(untracked_item.id, item); } - /// Move an item to a new position, pushing colliding items down + /// Move an item to a new position, swapping when possible or pushing colliding items down. /// /// # Arguments /// * `item` - The item to move - /// * `new_row_start` - New row position (1-indexed) - /// * `new_col_start` - New column position (1-indexed) + /// * `new_row_start` - New row position (0-indexed) + /// * `new_col_start` - New column position (0-indexed) pub fn move_item_with_collision( &mut self, item: RwSignal, @@ -263,9 +379,13 @@ impl Layout { ) { let mut untracked_item = item.get_untracked(); let old_position = untracked_item.grid_pos; + let new_position = + self.clamp_position_for_item(&untracked_item, new_row_start, new_col_start); // If position hasn't changed, nothing to do - if old_position.row_start == new_row_start && old_position.col_start == new_col_start { + if old_position.row_start == new_position.row_start + && old_position.col_start == new_position.col_start + { return; } @@ -273,83 +393,83 @@ impl Layout { self.clear_item_from_grid(&untracked_item); // Update item's position - untracked_item.grid_pos = GridPosition { - row_start: new_row_start, - col_start: new_col_start, - }; - - // Check for collisions at new position - let item_end_row = new_row_start + untracked_item.span.row_span - 1; - let item_end_col = new_col_start + untracked_item.span.col_span - 1; + untracked_item.grid_pos = new_position; + untracked_item.grid_to_pixels(self.cell_size, Axes::XY); // Ensure grid has enough capacity for the new position - if item_end_row > self.rows { - let rows_to_add = item_end_row - self.rows; - let empty_rows = Array2::from_elem((rows_to_add, self.columns), None::); - self.collision_grid = - concatenate(Axis(0), &[self.collision_grid.view(), empty_rows.view()]) - .expect("Failed to concatenate empty rows"); - self.rows = item_end_row; - } + self.ensure_rows(untracked_item.grid_pos.row_start + untracked_item.span.row_span); - // Find all items that collide with the new position - let mut colliding_items = Vec::new(); - for row in (new_row_start - 1)..item_end_row.min(self.rows) { - for col in (new_col_start - 1)..item_end_col.min(self.columns) { - if let Some(colliding_id) = self.collision_grid[[row, col]] { - // Don't consider the item itself as a collision - if colliding_id != untracked_item.id { - if let Some(&colliding_item_signal) = self.items.get(&colliding_id) { - if !colliding_items - .iter() - .any(|&item_sig: &RwSignal| { - item_sig.get_untracked().id == colliding_id - }) - { - colliding_items.push(colliding_item_signal); - } - } - } - } - } - } + let colliding_ids = self.colliding_item_ids(&untracked_item); - // Calculate how far down to push colliding items - // They need to be pushed to at least below the new item's bottom edge - if !colliding_items.is_empty() { - // Push colliding items down to make room - let push_to_row = item_end_row; // 1-indexed position where colliding items should start + if !colliding_ids.is_empty() + && !self.try_swap_items(&untracked_item, old_position, &colliding_ids) + { + self.push_items_below( + &untracked_item, + untracked_item.grid_pos.row_start, + untracked_item.span.row_span, + ); + } - for colliding_item_signal in &colliding_items { - let colliding_item = colliding_item_signal.get_untracked(); + // Update the moved item's signal + item.set(untracked_item); - // Clear the colliding item from its old position - self.clear_item_from_grid(&colliding_item); + self.ensure_grid_capacity(); + self.rebuild_collision_grid(); + } - // Calculate new position (push below the moved item) - let colliding_new_row = push_to_row + 1; + pub fn resize_item_with_collision( + &mut self, + item: RwSignal, + col_span: usize, + row_span: usize, + ) { + let mut untracked_item = item.get_untracked(); + let col_span = col_span.max(1).min(self.columns); + let row_span = row_span.max(1); + let old_item = untracked_item; + + untracked_item.span.col_span = col_span; + untracked_item.span.row_span = row_span; + untracked_item.grid_pos = self.clamp_position_for_item( + &untracked_item, + untracked_item.grid_pos.row_start, + untracked_item.grid_pos.col_start, + ); + untracked_item.size = Size { + width: col_span as f64 * self.cell_size.width, + height: row_span as f64 * self.cell_size.height, + }; - // Update the colliding item's position - colliding_item_signal.update(|item| { - item.grid_pos.row_start = colliding_new_row; - }); - } + self.clear_item_from_grid(&old_item); + self.ensure_rows(untracked_item.grid_pos.row_start + untracked_item.span.row_span); + + let colliding_ids = self.colliding_item_ids(&untracked_item); + if !colliding_ids.is_empty() { + let old_row_end = old_item.grid_pos.row_start + old_item.span.row_span; + let new_row_end = untracked_item.grid_pos.row_start + untracked_item.span.row_span; + let has_side_collision = colliding_ids.iter().any(|colliding_id| { + self.items + .get(colliding_id) + .map(|item| item.get_untracked().grid_pos.row_start < old_row_end) + .unwrap_or(false) + }); - // Ensure grid has enough capacity after pushing items - self.ensure_grid_capacity(); + let (push_from_row, push_by_rows) = if has_side_collision { + ( + untracked_item.grid_pos.row_start, + untracked_item.span.row_span, + ) + } else { + (old_row_end, new_row_end.saturating_sub(old_row_end)) + }; - // Re-register all colliding items at their new positions - for colliding_item_signal in &colliding_items { - let colliding_item = colliding_item_signal.get_untracked(); - self.set_item_in_grid(&colliding_item); - } + self.push_items_below(&untracked_item, push_from_row, push_by_rows); } - // Update the moved item's signal item.set(untracked_item); - - // Place the moved item in the collision grid at its new position - self.set_item_in_grid(&untracked_item); + self.ensure_grid_capacity(); + self.rebuild_collision_grid(); } /// Push items down by a certain number of rows @@ -377,7 +497,6 @@ impl Layout { if (curr_col_start >= start_col && curr_col_start < end_col) || (curr_col_end > start_col && curr_col_end <= end_col) { - // FIXME: there is a bug where curr_col_end isn't updated after drag event. log!( "({}: {curr_col_start} >= {start_col} && {curr_col_start} < {end_col}) || ({curr_col_end} > {start_col} && {curr_col_end} <= {end_col})", @@ -391,10 +510,7 @@ impl Layout { }); } - // Note: For add_item, the collision_grid update happens via concatenation, - // so we don't manually update it here. For drag/resize events, use - // update_item_in_grid() or clear_item_from_grid() + set_item_in_grid() - // to handle collision_grid updates explicitly. + // For add_item, the collision_grid update happens via row concatenation. } pub fn remove_item(&mut self, item: RwSignal) { diff --git a/crates/ui/src/components/grid/grid_item.rs b/crates/ui/src/components/grid/grid_item.rs index d1056fb..cb1cca4 100644 --- a/crates/ui/src/components/grid/grid_item.rs +++ b/crates/ui/src/components/grid/grid_item.rs @@ -13,7 +13,6 @@ use leptos::html::Div; use leptos::logging::log; use leptos::prelude::*; use leptos_use::core::Position; -use leptos_use::{use_element_bounding, UseElementBoundingReturn}; use std::sync::Arc; const GRID_ITEM_GAP_PX: f64 = 12.0; @@ -34,7 +33,6 @@ pub fn GridItem( ) -> impl IntoView { let layout = use_context::>().expect("should retrieve the layout context"); let untracked_layout = layout.get_untracked(); - let window = window(); let grid_item_ref = NodeRef::
::new(); let drag_ref = NodeRef::
::new(); let resize_button_ref = NodeRef::
::new(); @@ -92,19 +90,9 @@ pub fn GridItem( log!("Update item with new px_pos: {drag_px_pos:?}"); }) }), - on_drag_end: Arc::new(move |col_start, row_start, snapped_px_pos| { - grid_item_data.update(|item| { - item.px_pos = snapped_px_pos; - item.grid_pos = GridPosition { - col_start, - row_start, - }; - }); - - // Move item with collision detection and push other items down + on_drag_end: Arc::new(move |col_start, row_start, _snapped_px_pos| { layout.update(|layout| { - // TODO: drag & detect collisions - // layout.move_item_with_collision(grid_item_data, new_row, new_col); + layout.move_item_with_collision(grid_item_data, row_start, col_start); }); }), ..Default::default() @@ -121,13 +109,6 @@ pub fn GridItem( .. } = use_draggable_grid_item(grid_item_ref, draggable_options); - // Absolute element width/height - let UseElementBoundingReturn { - width: item_width, - height: item_height, - .. - } = use_element_bounding(grid_item_ref); - // Grid item resize let resize_options = UseResizableGridItemOptions { handle: Some(resize_button_ref), @@ -142,13 +123,12 @@ pub fn GridItem( }); }), on_resize_end: Arc::new(move |size| { - let cell_size = layout.get_untracked().cell_size; - let col_span = ((size.width / cell_size.width).round() as usize).max(1); - let row_span = ((size.height / cell_size.height).round() as usize).max(1); + layout.update(|layout| { + let cell_size = layout.cell_size; + let col_span = (size.width / cell_size.width).round() as usize; + let row_span = (size.height / cell_size.height).round() as usize; - grid_item_data.update(|item| { - item.size = size; - item.span = Span { row_span, col_span }; + layout.resize_item_with_collision(grid_item_data, col_span, row_span); }); }), ..Default::default() @@ -159,39 +139,6 @@ pub fn GridItem( transition: resize_transition, } = use_resizable_grid_item(grid_item_ref, resize_options); - // TODO: Handle collisions - - // TODO: clamp dragging event. - // Avoid issues where min > max. - // let left = move || { - // // let x = metadata.get().position.col_start * layout.get().cell_size.width as u32; - // match drag_state.get() { - // DragState::Dragging(p) | DragState::DragEnded(p) => p.x, - // } - // // let grid_w = layout.get().size.width; - // // let max = if grid_w <= 0. { - // // 0. - // // } else { - // // grid_w - item_width.get() - // // }; - - // // x.clamp(0., max.round()) - // }; - // let top = move || { - // // let y = metadata.get().position.row_start * layout.get().cell_size.height as u32; - // match drag_state.get() { - // DragState::Dragging(p) | DragState::DragEnded(p) => p.y, - // } - // // let grid_h = layout.get().size.height; - // // let max = if grid_h <= 0. { - // // 0. - // // } else { - // // grid_h - item_height.get() - // // }; - - // // y.clamp(0.0, max.round()) - // }; - let style = move || { // let Size { width, height } = metadata.get().size; let drag_transition = drag_transition.get(); From 3fd0a7a52313db4e89b9baa285a62a30f336bf5f Mon Sep 17 00:00:00 2001 From: theredfish Date: Fri, 1 May 2026 04:32:42 +0200 Subject: [PATCH 02/12] Improve grid collision placement --- crates/ui/src/components/grid/core/layout.rs | 571 +++++++++++++++--- crates/ui/src/components/grid/grid_item.rs | 9 +- crates/ui/src/components/grid/grid_layout.rs | 5 +- .../components/grid/utils/draggable_item.rs | 37 +- 4 files changed, 524 insertions(+), 98 deletions(-) diff --git a/crates/ui/src/components/grid/core/layout.rs b/crates/ui/src/components/grid/core/layout.rs index 9b2a94e..2e9c186 100644 --- a/crates/ui/src/components/grid/core/layout.rs +++ b/crates/ui/src/components/grid/core/layout.rs @@ -7,9 +7,36 @@ use leptos::{ logging::log, prelude::{GetUntracked, RwSignal, Set, Update}, }; +use leptos_use::core::Position; use ndarray::{concatenate, Array2, Axis}; use std::collections::{HashMap, HashSet}; +const MIN_DROP_AXIS_OVERLAP_RATIO: f64 = 0.35; +const DOMINANT_DROP_OVERLAP_RATIO: f64 = 1.25; + +#[derive(Clone, Copy, Debug)] +struct ItemAabb { + left: f64, + top: f64, + right: f64, + bottom: f64, +} + +#[derive(Clone, Copy, Debug)] +struct DropCollision { + item_id: u32, + overlap_area: f64, + horizontal_overlap_ratio: f64, + vertical_overlap_ratio: f64, +} + +impl DropCollision { + fn is_actionable(self) -> bool { + self.horizontal_overlap_ratio >= MIN_DROP_AXIS_OVERLAP_RATIO + && self.vertical_overlap_ratio >= MIN_DROP_AXIS_OVERLAP_RATIO + } +} + #[derive(Clone, Debug, Default)] pub struct Layout { pub size: Size, @@ -39,20 +66,6 @@ impl Layout { self.rows = required_rows; } - fn rebuild_collision_grid(&mut self) { - self.collision_grid = Array2::from_elem((self.rows, self.columns), None::); - let items = self - .items - .values() - .map(|item| item.get_untracked()) - .collect::>(); - - for item in items { - self.ensure_rows(item.grid_pos.row_start + item.span.row_span); - self.set_item_in_grid(&item); - } - } - fn clamp_position_for_item(&self, item: &GridItemData, row: usize, col: usize) -> GridPosition { let max_col = self.columns.saturating_sub(item.span.col_span); @@ -66,6 +79,45 @@ impl Layout { a_start < b_start + b_span && b_start < a_start + a_span } + fn item_aabb(item: &GridItemData) -> ItemAabb { + let left = item.grid_pos.col_start as f64; + let top = item.grid_pos.row_start as f64; + + ItemAabb { + left, + top, + right: left + item.span.col_span as f64, + bottom: top + item.span.row_span as f64, + } + } + + fn drag_aabb(&self, item: &GridItemData, drag_px_pos: Position) -> ItemAabb { + let left = drag_px_pos.x / self.cell_size.width; + let top = drag_px_pos.y / self.cell_size.height; + + ItemAabb { + left, + top, + right: left + item.span.col_span as f64, + bottom: top + item.span.row_span as f64, + } + } + + fn aabb_overlap(a: ItemAabb, b: ItemAabb) -> Option<(f64, f64)> { + let width = a.right.min(b.right) - a.left.max(b.left); + let height = a.bottom.min(b.bottom) - a.top.max(b.top); + + if width <= 0.0 || height <= 0.0 { + return None; + } + + Some((width, height)) + } + + fn items_overlap(a: &GridItemData, b: &GridItemData) -> bool { + Self::aabb_overlap(Self::item_aabb(a), Self::item_aabb(b)).is_some() + } + fn colliding_item_ids(&self, item: &GridItemData) -> Vec { let mut colliding_ids = HashSet::new(); let row_end = item.grid_pos.row_start + item.span.row_span; @@ -84,6 +136,80 @@ impl Layout { colliding_ids.into_iter().collect() } + fn colliding_item_ids_in_aabb(&self, aabb: ItemAabb, excluded_id: u32) -> Vec { + let mut colliding_ids = HashSet::new(); + let row_start = aabb.top.floor().max(0.0) as usize; + let row_end = aabb.bottom.ceil().max(0.0) as usize; + let col_start = aabb.left.floor().max(0.0) as usize; + let col_end = aabb.right.ceil().max(0.0) as usize; + + for row_idx in row_start..row_end.min(self.collision_grid.nrows()) { + for col_idx in col_start..col_end.min(self.collision_grid.ncols()) { + if let Some(occupant_id) = self.collision_grid[[row_idx, col_idx]] { + if occupant_id != excluded_id { + colliding_ids.insert(occupant_id); + } + } + } + } + + colliding_ids.into_iter().collect() + } + + fn fine_drop_collisions( + &self, + moved_item: &GridItemData, + drag_px_pos: Position, + ) -> Vec { + let moved_aabb = self.drag_aabb(moved_item, drag_px_pos); + let mut collisions = self + .colliding_item_ids_in_aabb(moved_aabb, moved_item.id) + .into_iter() + .filter_map(|item_id| { + let item = self.items.get(&item_id)?.get_untracked(); + let item_aabb = Self::item_aabb(&item); + let (overlap_width, overlap_height) = Self::aabb_overlap(moved_aabb, item_aabb)?; + + Some(DropCollision { + item_id, + overlap_area: overlap_width * overlap_height, + horizontal_overlap_ratio: overlap_width + / (moved_item.span.col_span.min(item.span.col_span) as f64), + vertical_overlap_ratio: overlap_height + / (moved_item.span.row_span.min(item.span.row_span) as f64), + }) + }) + .filter(|collision| collision.is_actionable()) + .collect::>(); + + collisions.sort_by(|a, b| { + b.overlap_area + .partial_cmp(&a.overlap_area) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.item_id.cmp(&b.item_id)) + }); + collisions + } + + fn dominant_drop_collision(collisions: &[DropCollision]) -> Option { + let first = *collisions.first()?; + let Some(second) = collisions.get(1) else { + return Some(first); + }; + + if first.overlap_area >= second.overlap_area * DOMINANT_DROP_OVERLAP_RATIO { + Some(first) + } else { + None + } + } + + fn restore_item_position(&mut self, item: RwSignal, mut item_data: GridItemData) { + item_data.grid_to_pixels(self.cell_size, Axes::XY); + item.set(item_data); + self.set_item_in_grid(&item_data); + } + fn item_fits_ignoring(&self, item: &GridItemData, ignored_ids: &[u32]) -> bool { if item.grid_pos.col_start + item.span.col_span > self.columns { return false; @@ -111,89 +237,259 @@ impl Layout { moved_item: &GridItemData, old_position: GridPosition, colliding_ids: &[u32], - ) -> bool { + ) -> Option { if colliding_ids.len() != 1 { - return false; + return None; } let colliding_id = colliding_ids[0]; let Some(&colliding_item_signal) = self.items.get(&colliding_id) else { - return false; + return None; }; let mut colliding_item = colliding_item_signal.get_untracked(); - let has_vertical_neighbor = self.items.values().any(|item_signal| { - let item = item_signal.get_untracked(); + if old_position.col_start != moved_item.grid_pos.col_start { + return None; + } + + if !Self::col_ranges_overlap( + old_position.col_start, + moved_item.span.col_span, + colliding_item.grid_pos.col_start, + colliding_item.span.col_span, + ) { + return None; + } + + let colliding_position = colliding_item.grid_pos; + let (moved_swap_position, colliding_swap_position) = + if old_position.row_start < colliding_position.row_start { + ( + GridPosition { + row_start: old_position.row_start + colliding_item.span.row_span, + col_start: moved_item.grid_pos.col_start, + }, + GridPosition { + row_start: old_position.row_start, + col_start: colliding_position.col_start, + }, + ) + } else if old_position.row_start > colliding_position.row_start { + ( + GridPosition { + row_start: colliding_position.row_start, + col_start: moved_item.grid_pos.col_start, + }, + GridPosition { + row_start: colliding_position.row_start + moved_item.span.row_span, + col_start: colliding_position.col_start, + }, + ) + } else { + return None; + }; + + let mut moved_swap_item = *moved_item; + moved_swap_item.grid_pos = moved_swap_position; + colliding_item.grid_pos = colliding_swap_position; + + self.ensure_rows( + (moved_swap_item.grid_pos.row_start + moved_swap_item.span.row_span) + .max(colliding_item.grid_pos.row_start + colliding_item.span.row_span), + ); + + if Self::items_overlap(&moved_swap_item, &colliding_item) { + return None; + } + + if !self.item_fits_ignoring(&moved_swap_item, &[moved_item.id, colliding_id]) + || !self.item_fits_ignoring(&colliding_item, &[moved_item.id, colliding_id]) + { + return None; + } + + self.clear_item_from_grid(&colliding_item_signal.get_untracked()); + colliding_item.grid_to_pixels(self.cell_size, Axes::XY); + colliding_item_signal.set(colliding_item); + self.set_item_in_grid(&colliding_item); + + Some(moved_swap_position) + } - item.id != moved_item.id - && item.id != colliding_id - && Self::col_ranges_overlap( + fn insertion_row_for_collision( + &self, + moved_item: &GridItemData, + colliding_ids: &[u32], + drag_px_pos: Position, + ) -> Option { + let moved_top = drag_px_pos.y / self.cell_size.height; + + colliding_ids + .iter() + .filter_map(|id| self.items.get(id).map(|item| item.get_untracked())) + .filter(|item| { + Self::col_ranges_overlap( + moved_item.grid_pos.col_start, + moved_item.span.col_span, item.grid_pos.col_start, item.span.col_span, - colliding_item.grid_pos.col_start, - colliding_item.span.col_span, ) - && (item.grid_pos.row_start + item.span.row_span - == colliding_item.grid_pos.row_start - || colliding_item.grid_pos.row_start + colliding_item.span.row_span - == item.grid_pos.row_start) + }) + .min_by_key(|item| (item.grid_pos.row_start, item.grid_pos.col_start, item.id)) + .map(|item| { + let item_mid = item.grid_pos.row_start as f64 + item.span.row_span as f64 / 2.0; + + if moved_top >= item_mid { + item.grid_pos.row_start + item.span.row_span + } else { + item.grid_pos.row_start + } + }) + } + + fn compact_items_up_in_columns( + &mut self, + col_start: usize, + col_span: usize, + excluded_id: Option, + ) { + let item_ids = self.collect_item_ids_in_columns_from_row(0, col_start, col_span, u32::MAX); + let mut items = item_ids + .into_iter() + .filter(|item_id| Some(*item_id) != excluded_id) + .filter_map(|item_id| { + self.items + .get(&item_id) + .map(|item_signal| (item_id, *item_signal, item_signal.get_untracked())) + }) + .collect::>(); + + items.sort_by_key(|(_, _, item)| { + (item.grid_pos.row_start, item.grid_pos.col_start, item.id) }); - if has_vertical_neighbor { - return false; + for (_, item_signal, mut item) in items { + self.clear_item_from_grid(&item); + + while item.grid_pos.row_start > 0 { + let mut candidate = item; + candidate.grid_pos.row_start -= 1; + + if !self.item_fits_ignoring(&candidate, &[item.id]) { + break; + } + + item = candidate; + } + + item.grid_to_pixels(self.cell_size, Axes::XY); + item_signal.set(item); + self.set_item_in_grid(&item); } + } - colliding_item.grid_pos = old_position; + fn collect_item_ids_in_columns_from_row( + &self, + row_start: usize, + col_start: usize, + col_span: usize, + excluded_id: u32, + ) -> Vec { + let mut ids = HashSet::new(); + let mut ranges_to_scan = vec![(row_start, col_start, col_span)]; + + while let Some((row_start, col_start, col_span)) = ranges_to_scan.pop() { + let col_end = (col_start + col_span).min(self.columns); + + for row_idx in row_start..self.collision_grid.nrows() { + for col_idx in col_start..col_end { + let Some(item_id) = self.collision_grid[[row_idx, col_idx]] else { + continue; + }; + + if item_id == excluded_id || !ids.insert(item_id) { + continue; + } - if !self.item_fits_ignoring(&colliding_item, &[moved_item.id, colliding_id]) { - return false; + if let Some(item_signal) = self.items.get(&item_id) { + let item = item_signal.get_untracked(); + ranges_to_scan.push(( + item.grid_pos.row_start, + item.grid_pos.col_start, + item.span.col_span, + )); + } + } + } } - colliding_item_signal.update(|item| { - item.grid_pos = old_position; - item.grid_to_pixels(self.cell_size, Axes::XY); + let mut ids = ids.into_iter().collect::>(); + ids.sort_by_key(|id| { + self.items + .get(id) + .map(|item| { + let item = item.get_untracked(); + (item.grid_pos.row_start, item.grid_pos.col_start, item.id) + }) + .unwrap_or((usize::MAX, usize::MAX, *id)) }); - - true + ids } - fn push_items_below(&mut self, moved_item: &GridItemData, row_start: usize, by_rows: usize) { - if by_rows == 0 { + fn shift_items_down(&mut self, item_ids: Vec, by_rows: usize) { + if by_rows == 0 || item_ids.is_empty() { return; } - let mut items_to_move = self - .items - .values() - .filter_map(|item_signal| { - let item = item_signal.get_untracked(); - let item_row_end = item.grid_pos.row_start + item.span.row_span; - - if item.id == moved_item.id - || !Self::col_ranges_overlap( - item.grid_pos.col_start, - item.span.col_span, - moved_item.grid_pos.col_start, - moved_item.span.col_span, - ) - || item_row_end <= row_start - { - return None; - } - - Some((item.grid_pos.row_start, item.id, *item_signal)) + let items_to_shift = item_ids + .into_iter() + .filter_map(|item_id| { + self.items + .get(&item_id) + .map(|item_signal| (item_id, *item_signal, item_signal.get_untracked())) }) .collect::>(); - items_to_move.sort_by_key(|(row_start, id, _)| (*row_start, *id)); - for (_, _, item_signal) in items_to_move { - item_signal.update(|item| { - item.grid_pos.row_start += by_rows; - item.grid_to_pixels(self.cell_size, Axes::XY); - }); + for (_, _, item) in &items_to_shift { + self.clear_item_from_grid(item); + } + + for (_, item_signal, mut item) in items_to_shift { + item.grid_pos.row_start += by_rows; + item.grid_to_pixels(self.cell_size, Axes::XY); + self.ensure_rows(item.grid_pos.row_start + item.span.row_span); + item_signal.set(item); + self.set_item_in_grid(&item); } } + fn set_item_after_local_push( + &mut self, + item: &GridItemData, + mut row_start: usize, + mut by_rows: usize, + ) { + self.ensure_rows(item.grid_pos.row_start + item.span.row_span); + + while !self.colliding_item_ids(item).is_empty() { + let item_ids = self.collect_item_ids_in_columns_from_row( + row_start, + item.grid_pos.col_start, + item.span.col_span, + item.id, + ); + if item_ids.is_empty() || by_rows == 0 { + break; + } + self.shift_items_down(item_ids, by_rows); + + row_start = item.grid_pos.row_start; + by_rows = item.span.row_span; + } + + self.set_item_in_grid(item); + } + /// Set an item in the collision grid based on its position and span fn set_item_in_grid(&mut self, item: &GridItemData) { let row_start = item.grid_pos.row_start; @@ -283,6 +579,12 @@ impl Layout { // Add/update item in the items HashMap self.items.insert(untracked_item.id, item); + + self.compact_items_up_in_columns( + untracked_item.grid_pos.col_start, + untracked_item.span.col_span, + None, + ); } /// Add an item at the top-left, pushing all existing items down (for dynamic "Add Item" button) @@ -376,16 +678,20 @@ impl Layout { item: RwSignal, new_row_start: usize, new_col_start: usize, + drag_px_pos: Position, ) { let mut untracked_item = item.get_untracked(); let old_position = untracked_item.grid_pos; let new_position = self.clamp_position_for_item(&untracked_item, new_row_start, new_col_start); - // If position hasn't changed, nothing to do + // If the snapped grid position did not change, still restore the pixel + // position because dragging may have written a raw sub-cell offset. if old_position.row_start == new_position.row_start && old_position.col_start == new_position.col_start { + untracked_item.grid_to_pixels(self.cell_size, Axes::XY); + item.set(untracked_item); return; } @@ -401,21 +707,69 @@ impl Layout { let colliding_ids = self.colliding_item_ids(&untracked_item); - if !colliding_ids.is_empty() - && !self.try_swap_items(&untracked_item, old_position, &colliding_ids) - { - self.push_items_below( + let mut placed_in_grid = false; + if !colliding_ids.is_empty() { + let fine_collisions = self.fine_drop_collisions(&untracked_item, drag_px_pos); + let Some(drop_collision) = Self::dominant_drop_collision(&fine_collisions) else { + untracked_item.grid_pos = old_position; + self.restore_item_position(item, untracked_item); + return; + }; + + if let Some(swap_position) = + self.try_swap_items(&untracked_item, old_position, &[drop_collision.item_id]) + { + untracked_item.grid_pos = swap_position; + untracked_item.grid_to_pixels(self.cell_size, Axes::XY); + self.set_item_in_grid(&untracked_item); + placed_in_grid = true; + } else { + if old_position.col_start == untracked_item.grid_pos.col_start { + untracked_item.grid_pos = old_position; + self.restore_item_position(item, untracked_item); + return; + } + + if let Some(row_start) = self.insertion_row_for_collision( + &untracked_item, + &[drop_collision.item_id], + drag_px_pos, + ) { + untracked_item.grid_pos.row_start = row_start; + untracked_item.grid_to_pixels(self.cell_size, Axes::XY); + self.ensure_rows( + untracked_item.grid_pos.row_start + untracked_item.span.row_span, + ); + } + + self.set_item_after_local_push( + &untracked_item, + untracked_item.grid_pos.row_start, + untracked_item.span.row_span, + ); + placed_in_grid = true; + } + } + + // Update the moved item's signal + item.set(untracked_item); + if !placed_in_grid { + self.set_item_after_local_push( &untracked_item, untracked_item.grid_pos.row_start, untracked_item.span.row_span, ); } - - // Update the moved item's signal - item.set(untracked_item); - - self.ensure_grid_capacity(); - self.rebuild_collision_grid(); + self.compact_items_up_in_columns( + old_position.col_start, + untracked_item.span.col_span, + None, + ); + self.compact_items_up_in_columns( + untracked_item.grid_pos.col_start, + untracked_item.span.col_span, + None, + ); } pub fn resize_item_with_collision( @@ -445,6 +799,7 @@ impl Layout { self.ensure_rows(untracked_item.grid_pos.row_start + untracked_item.span.row_span); let colliding_ids = self.colliding_item_ids(&untracked_item); + let mut placed_in_grid = false; if !colliding_ids.is_empty() { let old_row_end = old_item.grid_pos.row_start + old_item.span.row_span; let new_row_end = untracked_item.grid_pos.row_start + untracked_item.span.row_span; @@ -454,8 +809,7 @@ impl Layout { .map(|item| item.get_untracked().grid_pos.row_start < old_row_end) .unwrap_or(false) }); - - let (push_from_row, push_by_rows) = if has_side_collision { + let (row_start, by_rows) = if has_side_collision { ( untracked_item.grid_pos.row_start, untracked_item.span.row_span, @@ -463,13 +817,24 @@ impl Layout { } else { (old_row_end, new_row_end.saturating_sub(old_row_end)) }; - - self.push_items_below(&untracked_item, push_from_row, push_by_rows); + self.set_item_after_local_push(&untracked_item, row_start, by_rows); + placed_in_grid = true; } item.set(untracked_item); - self.ensure_grid_capacity(); - self.rebuild_collision_grid(); + if !placed_in_grid { + self.set_item_after_local_push( + &untracked_item, + untracked_item.grid_pos.row_start, + untracked_item.span.row_span, + ); + } + self.compact_items_up_in_columns(old_item.grid_pos.col_start, old_item.span.col_span, None); + self.compact_items_up_in_columns( + untracked_item.grid_pos.col_start, + untracked_item.span.col_span, + None, + ); } /// Push items down by a certain number of rows @@ -524,12 +889,40 @@ impl Layout { } pub fn sync_items_to_grid(&mut self) { - for item in self.items.values() { - item.update(|item| { - let max_col_start = self.columns.saturating_sub(item.span.col_span); - item.grid_pos.col_start = item.grid_pos.col_start.min(max_col_start); - item.grid_to_pixels(self.cell_size, Axes::XY); - }); + self.collision_grid = Array2::from_elem((self.rows, self.columns), None::); + let mut items = self + .items + .values() + .map(|item_signal| { + let item = item_signal.get_untracked(); + ( + item.grid_pos.row_start, + item.grid_pos.col_start, + item.id, + *item_signal, + ) + }) + .collect::>(); + + items.sort_by_key(|(row_start, col_start, id, _)| (*row_start, *col_start, *id)); + + for (_, _, _, item_signal) in items { + let mut item = item_signal.get_untracked(); + item.grid_pos = self.clamp_position_for_item( + &item, + item.grid_pos.row_start, + item.grid_pos.col_start, + ); + + while !self.colliding_item_ids(&item).is_empty() { + item.grid_pos.row_start += 1; + self.ensure_rows(item.grid_pos.row_start + item.span.row_span); + } + + item.grid_to_pixels(self.cell_size, Axes::XY); + item_signal.set(item); + self.ensure_rows(item.grid_pos.row_start + item.span.row_span); + self.set_item_in_grid(&item); } } } diff --git a/crates/ui/src/components/grid/grid_item.rs b/crates/ui/src/components/grid/grid_item.rs index cb1cca4..fd592bc 100644 --- a/crates/ui/src/components/grid/grid_item.rs +++ b/crates/ui/src/components/grid/grid_item.rs @@ -90,9 +90,9 @@ pub fn GridItem( log!("Update item with new px_pos: {drag_px_pos:?}"); }) }), - on_drag_end: Arc::new(move |col_start, row_start, _snapped_px_pos| { + on_drag_end: Arc::new(move |col_start, row_start, _snapped_px_pos, drag_px_pos| { layout.update(|layout| { - layout.move_item_with_collision(grid_item_data, row_start, col_start); + layout.move_item_with_collision(grid_item_data, row_start, col_start, drag_px_pos); }); }), ..Default::default() @@ -106,6 +106,7 @@ pub fn GridItem( // TODO: see to remove this from the UseDraggableGridItemReturn? Or keep for API if open sourced? // position: drag_position, transition: drag_transition, + is_dragging, .. } = use_draggable_grid_item(grid_item_ref, draggable_options); @@ -150,6 +151,7 @@ pub fn GridItem( let visual_height = (height - GRID_ITEM_GAP_PX).max(0.0); let visual_left = left + GRID_ITEM_INSET_PX; let visual_top = top + GRID_ITEM_INSET_PX; + let z_index = if is_dragging.get() { 1000 } else { 1 }; log!("resize: {width};{height}"); @@ -159,7 +161,8 @@ pub fn GridItem( transition: {resize_transition}, {drag_transition}; touch-action: none; left: {visual_left}px; - top: {visual_top}px;"# + top: {visual_top}px; + z-index: {z_index};"# ) }; diff --git a/crates/ui/src/components/grid/grid_layout.rs b/crates/ui/src/components/grid/grid_layout.rs index 1f55503..f4785b3 100644 --- a/crates/ui/src/components/grid/grid_layout.rs +++ b/crates/ui/src/components/grid/grid_layout.rs @@ -25,7 +25,7 @@ pub fn GridLayout(children: Children, columns: usize, display_grid: bool) -> imp provide_context(layout); // Track dynamically added items - let next_id = RwSignal::new(1u32); + let next_id = RwSignal::new(10_000u32); let grid_items = RwSignal::new(Vec::::new()); // Handler to add new items @@ -114,7 +114,7 @@ pub fn GridLayout(children: Children, columns: usize, display_grid: bool) -> imp } } } - // {children()} + {children()} imp id=id col_span=3 row_span=3 - // FIXME: this is just for debugging col_start=0 row_start=0 label=format!("Item {}", id) diff --git a/crates/ui/src/components/grid/utils/draggable_item.rs b/crates/ui/src/components/grid/utils/draggable_item.rs index d57fb15..305f03b 100644 --- a/crates/ui/src/components/grid/utils/draggable_item.rs +++ b/crates/ui/src/components/grid/utils/draggable_item.rs @@ -2,10 +2,15 @@ use leptos::html::Div; use leptos::prelude::*; use leptos_use::{core::Position, use_draggable_with_options, UseDraggableOptions}; use std::sync::Arc; +use wasm_bindgen::closure::Closure; use wasm_bindgen::JsCast; use crate::components::grid::core::{layout::Layout, size::Size}; +// Keep this slightly above the drag transition duration so the released item +// stays above its neighbors until the snap-back animation has fully finished. +const DRAG_RELEASE_ELEVATION_MS: i32 = 280; + #[derive(Clone, Copy, Debug)] pub enum DragState { Dragging(Position), @@ -28,7 +33,7 @@ pub struct UseDraggableGridItemOptions { /// Callback during dragging pub on_drag_move: Arc, /// Callback when drag ends with final grid position - pub on_drag_end: Arc, + pub on_drag_end: Arc, } impl Default for UseDraggableGridItemOptions { @@ -41,7 +46,7 @@ impl Default for UseDraggableGridItemOptions { current_col_span: Arc::new(|| 1), on_drag_start: Arc::new(|_| {}), on_drag_move: Arc::new(|_| {}), - on_drag_end: Arc::new(|_, _, _| {}), + on_drag_end: Arc::new(|_, _, _, _| {}), } } } @@ -52,6 +57,8 @@ pub struct UseDraggableGridItemReturn { pub position: Signal, /// CSS transition string for drag animations pub transition: Signal<&'static str>, + /// Whether the item is actively being dragged + pub is_dragging: Signal, } pub fn use_draggable_grid_item( @@ -60,6 +67,8 @@ pub fn use_draggable_grid_item( ) -> UseDraggableGridItemReturn { let layout = use_context::>().expect("Layout context must be provided"); let drag_state = RwSignal::new(DragState::Dragging(Position::default())); + let is_dragging = RwSignal::new(false); + let drag_elevation_epoch = RwSignal::new(0_u32); let UseDraggableGridItemOptions { handle, col_start, @@ -103,6 +112,8 @@ pub fn use_draggable_grid_item( Position { x: pos.x, y: pos.y } }) .on_move(move |drag_event| { + drag_elevation_epoch.update(|epoch| *epoch = epoch.wrapping_add(1)); + let layout = layout.get_untracked(); let max_col_start = layout.columns.saturating_sub(current_col_span_for_move()); let max_x = max_col_start as f64 * layout.cell_size.width; @@ -111,10 +122,14 @@ pub fn use_draggable_grid_item( y: drag_event.position.y.max(0.0), }; + is_dragging.set(true); drag_state.set(DragState::Dragging(clamped_position)); on_drag_move(clamped_position); }) .on_end(move |drag_event| { + drag_elevation_epoch.update(|epoch| *epoch = epoch.wrapping_add(1)); + let drag_end_epoch = drag_elevation_epoch.get_untracked(); + let layout = layout.get_untracked(); let cell_size = layout.cell_size; let max_col_start = layout.columns.saturating_sub(current_col_span_for_end()); @@ -138,7 +153,22 @@ pub fn use_draggable_grid_item( drag_state.set(DragState::DragEnded(final_position)); - on_drag_end(col_start, row_start, final_position); + on_drag_end(col_start, row_start, final_position, drag_position); + + // Release drag elevation after the CSS transition, not on pointerup. + // Otherwise a snapping item can briefly pass behind another panel + // during the return animation and make the interaction look broken. + let release_drag_elevation = Closure::wrap(Box::new(move || { + if drag_elevation_epoch.get_untracked() == drag_end_epoch { + is_dragging.set(false); + } + }) as Box); + + let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0( + release_drag_elevation.as_ref().unchecked_ref(), + DRAG_RELEASE_ELEVATION_MS, + ); + release_drag_elevation.forget(); }) .target_offset(move |event_target: web_sys::EventTarget| { let target: web_sys::HtmlElement = event_target.unchecked_into(); @@ -161,5 +191,6 @@ pub fn use_draggable_grid_item( UseDraggableGridItemReturn { transition, position, + is_dragging: is_dragging.into(), } } From 3b4e568b6c265831f746bafde9d372573389e040 Mon Sep 17 00:00:00 2001 From: theredfish Date: Sun, 3 May 2026 00:38:50 +0200 Subject: [PATCH 03/12] Extract grid collision adapters --- .../components/grid/core/collision/aabb.rs | 29 ++ .../components/grid/core/collision/grid.rs | 116 +++++++ .../grid/core/collision/item_aabb.rs | 92 ++++++ .../src/components/grid/core/collision/mod.rs | 3 + crates/ui/src/components/grid/core/layout.rs | 290 ++++-------------- crates/ui/src/components/grid/core/mod.rs | 1 + 6 files changed, 294 insertions(+), 237 deletions(-) create mode 100644 crates/ui/src/components/grid/core/collision/aabb.rs create mode 100644 crates/ui/src/components/grid/core/collision/grid.rs create mode 100644 crates/ui/src/components/grid/core/collision/item_aabb.rs create mode 100644 crates/ui/src/components/grid/core/collision/mod.rs diff --git a/crates/ui/src/components/grid/core/collision/aabb.rs b/crates/ui/src/components/grid/core/collision/aabb.rs new file mode 100644 index 0000000..b099227 --- /dev/null +++ b/crates/ui/src/components/grid/core/collision/aabb.rs @@ -0,0 +1,29 @@ +#[derive(Clone, Copy, Debug)] +pub struct Aabb { + pub left: f64, + pub right: f64, + pub top: f64, + pub bottom: f64, +} + +impl Aabb { + pub fn new(left: f64, top: f64, width: f64, height: f64) -> Self { + Self { + left, + right: left + width, + top, + bottom: top + height, + } + } + + pub fn overlap(&self, other: &Self) -> Option<(f64, f64)> { + let width = self.right.min(other.right) - self.left.max(other.left); + let height = self.bottom.min(other.bottom) - self.top.max(other.top); + + if width <= 0.0 || height <= 0.0 { + return None; + } + + Some((width, height)) + } +} diff --git a/crates/ui/src/components/grid/core/collision/grid.rs b/crates/ui/src/components/grid/core/collision/grid.rs new file mode 100644 index 0000000..3c26088 --- /dev/null +++ b/crates/ui/src/components/grid/core/collision/grid.rs @@ -0,0 +1,116 @@ +use crate::components::grid::core::collision::aabb::Aabb; +use crate::components::grid::core::item::GridItemData; +use ndarray::Array2; +use std::collections::HashSet; + +pub fn item_ids_for_item(collision_grid: &Array2>, item: &GridItemData) -> Vec { + let row_end = item.grid_pos.row_start + item.span.row_span; + let col_end = item.grid_pos.col_start + item.span.col_span; + + collect_item_ids( + collision_grid, + item.grid_pos.row_start, + row_end, + item.grid_pos.col_start, + col_end, + item.id, + ) +} + +pub fn item_ids_for_aabb( + collision_grid: &Array2>, + aabb: Aabb, + excluded_id: u32, +) -> Vec { + let row_start = aabb.top.floor().max(0.0) as usize; + let row_end = aabb.bottom.ceil().max(0.0) as usize; + let col_start = aabb.left.floor().max(0.0) as usize; + let col_end = aabb.right.ceil().max(0.0) as usize; + + collect_item_ids( + collision_grid, + row_start, + row_end, + col_start, + col_end, + excluded_id, + ) +} + +pub fn item_fits_ignoring( + collision_grid: &Array2>, + item: &GridItemData, + ignored_ids: &[u32], +) -> bool { + let ignored_ids = ignored_ids.iter().copied().collect::>(); + let row_end = item.grid_pos.row_start + item.span.row_span; + let col_end = item.grid_pos.col_start + item.span.col_span; + + for row_idx in item.grid_pos.row_start..row_end.min(collision_grid.nrows()) { + for col_idx in item.grid_pos.col_start..col_end.min(collision_grid.ncols()) { + if let Some(occupant_id) = collision_grid[[row_idx, col_idx]] { + if !ignored_ids.contains(&occupant_id) { + return false; + } + } + } + } + + true +} + +pub fn set_item(collision_grid: &mut Array2>, item: &GridItemData) { + let row_start = item.grid_pos.row_start; + let row_end = row_start + item.span.row_span; + let col_start = item.grid_pos.col_start; + let col_end = col_start + item.span.col_span; + + for row_idx in row_start..row_end { + for col_idx in col_start..col_end { + if row_idx < collision_grid.nrows() && col_idx < collision_grid.ncols() { + collision_grid[[row_idx, col_idx]] = Some(item.id); + } + } + } +} + +pub fn clear_item(collision_grid: &mut Array2>, item: &GridItemData) { + let row_start = item.grid_pos.row_start; + let row_end = row_start + item.span.row_span; + let col_start = item.grid_pos.col_start; + let col_end = col_start + item.span.col_span; + + for row_idx in row_start..row_end { + for col_idx in col_start..col_end { + if row_idx < collision_grid.nrows() + && col_idx < collision_grid.ncols() + && collision_grid[[row_idx, col_idx]] == Some(item.id) + { + collision_grid[[row_idx, col_idx]] = None; + } + } + } +} + +fn collect_item_ids( + collision_grid: &Array2>, + row_start: usize, + row_end: usize, + col_start: usize, + col_end: usize, + excluded_id: u32, +) -> Vec { + let mut colliding_ids = HashSet::new(); + + for row_idx in row_start..row_end.min(collision_grid.nrows()) { + for col_idx in col_start..col_end.min(collision_grid.ncols()) { + if let Some(occupant_id) = collision_grid[[row_idx, col_idx]] { + if occupant_id != excluded_id { + colliding_ids.insert(occupant_id); + } + } + } + } + + colliding_ids.into_iter().collect() +} diff --git a/crates/ui/src/components/grid/core/collision/item_aabb.rs b/crates/ui/src/components/grid/core/collision/item_aabb.rs new file mode 100644 index 0000000..16778b3 --- /dev/null +++ b/crates/ui/src/components/grid/core/collision/item_aabb.rs @@ -0,0 +1,92 @@ +use crate::components::grid::core::collision::aabb::Aabb; +use crate::components::grid::core::item::GridItemData; +use crate::components::grid::core::size::Size; +use leptos_use::core::Position; + +const MIN_DROP_AXIS_OVERLAP_RATIO: f64 = 0.35; +const DOMINANT_DROP_OVERLAP_RATIO: f64 = 1.25; + +#[derive(Clone, Copy, Debug)] +pub struct DropCollision { + pub item_id: u32, + overlap_area: f64, + horizontal_overlap_ratio: f64, + vertical_overlap_ratio: f64, +} + +impl DropCollision { + fn is_actionable(self) -> bool { + self.horizontal_overlap_ratio >= MIN_DROP_AXIS_OVERLAP_RATIO + && self.vertical_overlap_ratio >= MIN_DROP_AXIS_OVERLAP_RATIO + } +} + +pub fn from_item(item: &GridItemData) -> Aabb { + Aabb::new( + item.grid_pos.col_start as f64, + item.grid_pos.row_start as f64, + item.span.col_span as f64, + item.span.row_span as f64, + ) +} + +pub fn from_drag(item: &GridItemData, drag_px_pos: Position, cell_size: Size) -> Aabb { + Aabb::new( + drag_px_pos.x / cell_size.width, + drag_px_pos.y / cell_size.height, + item.span.col_span as f64, + item.span.row_span as f64, + ) +} + +pub fn items_overlap(a: &GridItemData, b: &GridItemData) -> bool { + from_item(a).overlap(&from_item(b)).is_some() +} + +pub fn overlap_item(aabb: Aabb, item: &GridItemData) -> Option<(f64, f64)> { + aabb.overlap(&from_item(item)) +} + +pub fn drop_collisions( + moved_item: &GridItemData, + moved_aabb: Aabb, + candidates: impl IntoIterator, +) -> Vec { + let mut collisions = candidates + .into_iter() + .filter_map(|item| { + let (overlap_width, overlap_height) = overlap_item(moved_aabb, &item)?; + + Some(DropCollision { + item_id: item.id, + overlap_area: overlap_width * overlap_height, + horizontal_overlap_ratio: overlap_width + / (moved_item.span.col_span.min(item.span.col_span) as f64), + vertical_overlap_ratio: overlap_height + / (moved_item.span.row_span.min(item.span.row_span) as f64), + }) + }) + .filter(|collision| collision.is_actionable()) + .collect::>(); + + collisions.sort_by(|a, b| { + b.overlap_area + .partial_cmp(&a.overlap_area) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.item_id.cmp(&b.item_id)) + }); + collisions +} + +pub fn dominant_drop_collision(collisions: &[DropCollision]) -> Option { + let first = *collisions.first()?; + let Some(second) = collisions.get(1) else { + return Some(first); + }; + + if first.overlap_area >= second.overlap_area * DOMINANT_DROP_OVERLAP_RATIO { + Some(first) + } else { + None + } +} diff --git a/crates/ui/src/components/grid/core/collision/mod.rs b/crates/ui/src/components/grid/core/collision/mod.rs new file mode 100644 index 0000000..378b579 --- /dev/null +++ b/crates/ui/src/components/grid/core/collision/mod.rs @@ -0,0 +1,3 @@ +pub mod aabb; +pub mod grid; +pub mod item_aabb; diff --git a/crates/ui/src/components/grid/core/layout.rs b/crates/ui/src/components/grid/core/layout.rs index 2e9c186..7d3d8b9 100644 --- a/crates/ui/src/components/grid/core/layout.rs +++ b/crates/ui/src/components/grid/core/layout.rs @@ -1,3 +1,4 @@ +use crate::components::grid::core::collision::{grid as collision_grid, item_aabb}; use crate::components::grid::core::item::Axes; use crate::components::grid::core::{ item::{GridItemData, GridPosition}, @@ -11,32 +12,6 @@ use leptos_use::core::Position; use ndarray::{concatenate, Array2, Axis}; use std::collections::{HashMap, HashSet}; -const MIN_DROP_AXIS_OVERLAP_RATIO: f64 = 0.35; -const DOMINANT_DROP_OVERLAP_RATIO: f64 = 1.25; - -#[derive(Clone, Copy, Debug)] -struct ItemAabb { - left: f64, - top: f64, - right: f64, - bottom: f64, -} - -#[derive(Clone, Copy, Debug)] -struct DropCollision { - item_id: u32, - overlap_area: f64, - horizontal_overlap_ratio: f64, - vertical_overlap_ratio: f64, -} - -impl DropCollision { - fn is_actionable(self) -> bool { - self.horizontal_overlap_ratio >= MIN_DROP_AXIS_OVERLAP_RATIO - && self.vertical_overlap_ratio >= MIN_DROP_AXIS_OVERLAP_RATIO - } -} - #[derive(Clone, Debug, Default)] pub struct Layout { pub size: Size, @@ -79,157 +54,10 @@ impl Layout { a_start < b_start + b_span && b_start < a_start + a_span } - fn item_aabb(item: &GridItemData) -> ItemAabb { - let left = item.grid_pos.col_start as f64; - let top = item.grid_pos.row_start as f64; - - ItemAabb { - left, - top, - right: left + item.span.col_span as f64, - bottom: top + item.span.row_span as f64, - } - } - - fn drag_aabb(&self, item: &GridItemData, drag_px_pos: Position) -> ItemAabb { - let left = drag_px_pos.x / self.cell_size.width; - let top = drag_px_pos.y / self.cell_size.height; - - ItemAabb { - left, - top, - right: left + item.span.col_span as f64, - bottom: top + item.span.row_span as f64, - } - } - - fn aabb_overlap(a: ItemAabb, b: ItemAabb) -> Option<(f64, f64)> { - let width = a.right.min(b.right) - a.left.max(b.left); - let height = a.bottom.min(b.bottom) - a.top.max(b.top); - - if width <= 0.0 || height <= 0.0 { - return None; - } - - Some((width, height)) - } - - fn items_overlap(a: &GridItemData, b: &GridItemData) -> bool { - Self::aabb_overlap(Self::item_aabb(a), Self::item_aabb(b)).is_some() - } - - fn colliding_item_ids(&self, item: &GridItemData) -> Vec { - let mut colliding_ids = HashSet::new(); - let row_end = item.grid_pos.row_start + item.span.row_span; - let col_end = item.grid_pos.col_start + item.span.col_span; - - for row_idx in item.grid_pos.row_start..row_end.min(self.collision_grid.nrows()) { - for col_idx in item.grid_pos.col_start..col_end.min(self.collision_grid.ncols()) { - if let Some(occupant_id) = self.collision_grid[[row_idx, col_idx]] { - if occupant_id != item.id { - colliding_ids.insert(occupant_id); - } - } - } - } - - colliding_ids.into_iter().collect() - } - - fn colliding_item_ids_in_aabb(&self, aabb: ItemAabb, excluded_id: u32) -> Vec { - let mut colliding_ids = HashSet::new(); - let row_start = aabb.top.floor().max(0.0) as usize; - let row_end = aabb.bottom.ceil().max(0.0) as usize; - let col_start = aabb.left.floor().max(0.0) as usize; - let col_end = aabb.right.ceil().max(0.0) as usize; - - for row_idx in row_start..row_end.min(self.collision_grid.nrows()) { - for col_idx in col_start..col_end.min(self.collision_grid.ncols()) { - if let Some(occupant_id) = self.collision_grid[[row_idx, col_idx]] { - if occupant_id != excluded_id { - colliding_ids.insert(occupant_id); - } - } - } - } - - colliding_ids.into_iter().collect() - } - - fn fine_drop_collisions( - &self, - moved_item: &GridItemData, - drag_px_pos: Position, - ) -> Vec { - let moved_aabb = self.drag_aabb(moved_item, drag_px_pos); - let mut collisions = self - .colliding_item_ids_in_aabb(moved_aabb, moved_item.id) - .into_iter() - .filter_map(|item_id| { - let item = self.items.get(&item_id)?.get_untracked(); - let item_aabb = Self::item_aabb(&item); - let (overlap_width, overlap_height) = Self::aabb_overlap(moved_aabb, item_aabb)?; - - Some(DropCollision { - item_id, - overlap_area: overlap_width * overlap_height, - horizontal_overlap_ratio: overlap_width - / (moved_item.span.col_span.min(item.span.col_span) as f64), - vertical_overlap_ratio: overlap_height - / (moved_item.span.row_span.min(item.span.row_span) as f64), - }) - }) - .filter(|collision| collision.is_actionable()) - .collect::>(); - - collisions.sort_by(|a, b| { - b.overlap_area - .partial_cmp(&a.overlap_area) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.item_id.cmp(&b.item_id)) - }); - collisions - } - - fn dominant_drop_collision(collisions: &[DropCollision]) -> Option { - let first = *collisions.first()?; - let Some(second) = collisions.get(1) else { - return Some(first); - }; - - if first.overlap_area >= second.overlap_area * DOMINANT_DROP_OVERLAP_RATIO { - Some(first) - } else { - None - } - } - fn restore_item_position(&mut self, item: RwSignal, mut item_data: GridItemData) { item_data.grid_to_pixels(self.cell_size, Axes::XY); item.set(item_data); - self.set_item_in_grid(&item_data); - } - - fn item_fits_ignoring(&self, item: &GridItemData, ignored_ids: &[u32]) -> bool { - if item.grid_pos.col_start + item.span.col_span > self.columns { - return false; - } - - let ignored_ids = ignored_ids.iter().copied().collect::>(); - let row_end = item.grid_pos.row_start + item.span.row_span; - let col_end = item.grid_pos.col_start + item.span.col_span; - - for row_idx in item.grid_pos.row_start..row_end.min(self.collision_grid.nrows()) { - for col_idx in item.grid_pos.col_start..col_end.min(self.collision_grid.ncols()) { - if let Some(occupant_id) = self.collision_grid[[row_idx, col_idx]] { - if !ignored_ids.contains(&occupant_id) { - return false; - } - } - } - } - - true + collision_grid::set_item(&mut self.collision_grid, &item_data); } fn try_swap_items( @@ -298,20 +126,29 @@ impl Layout { .max(colliding_item.grid_pos.row_start + colliding_item.span.row_span), ); - if Self::items_overlap(&moved_swap_item, &colliding_item) { + if item_aabb::items_overlap(&moved_swap_item, &colliding_item) { return None; } - if !self.item_fits_ignoring(&moved_swap_item, &[moved_item.id, colliding_id]) - || !self.item_fits_ignoring(&colliding_item, &[moved_item.id, colliding_id]) - { + if !collision_grid::item_fits_ignoring( + &self.collision_grid, + &moved_swap_item, + &[moved_item.id, colliding_id], + ) || !collision_grid::item_fits_ignoring( + &self.collision_grid, + &colliding_item, + &[moved_item.id, colliding_id], + ) { return None; } - self.clear_item_from_grid(&colliding_item_signal.get_untracked()); + collision_grid::clear_item( + &mut self.collision_grid, + &colliding_item_signal.get_untracked(), + ); colliding_item.grid_to_pixels(self.cell_size, Axes::XY); colliding_item_signal.set(colliding_item); - self.set_item_in_grid(&colliding_item); + collision_grid::set_item(&mut self.collision_grid, &colliding_item); Some(moved_swap_position) } @@ -369,13 +206,14 @@ impl Layout { }); for (_, item_signal, mut item) in items { - self.clear_item_from_grid(&item); + collision_grid::clear_item(&mut self.collision_grid, &item); while item.grid_pos.row_start > 0 { let mut candidate = item; candidate.grid_pos.row_start -= 1; - if !self.item_fits_ignoring(&candidate, &[item.id]) { + if !collision_grid::item_fits_ignoring(&self.collision_grid, &candidate, &[item.id]) + { break; } @@ -384,7 +222,7 @@ impl Layout { item.grid_to_pixels(self.cell_size, Axes::XY); item_signal.set(item); - self.set_item_in_grid(&item); + collision_grid::set_item(&mut self.collision_grid, &item); } } @@ -451,7 +289,7 @@ impl Layout { .collect::>(); for (_, _, item) in &items_to_shift { - self.clear_item_from_grid(item); + collision_grid::clear_item(&mut self.collision_grid, item); } for (_, item_signal, mut item) in items_to_shift { @@ -459,7 +297,7 @@ impl Layout { item.grid_to_pixels(self.cell_size, Axes::XY); self.ensure_rows(item.grid_pos.row_start + item.span.row_span); item_signal.set(item); - self.set_item_in_grid(&item); + collision_grid::set_item(&mut self.collision_grid, &item); } } @@ -471,7 +309,7 @@ impl Layout { ) { self.ensure_rows(item.grid_pos.row_start + item.span.row_span); - while !self.colliding_item_ids(item).is_empty() { + while !collision_grid::item_ids_for_item(&self.collision_grid, item).is_empty() { let item_ids = self.collect_item_ids_in_columns_from_row( row_start, item.grid_pos.col_start, @@ -487,50 +325,13 @@ impl Layout { by_rows = item.span.row_span; } - self.set_item_in_grid(item); - } - - /// Set an item in the collision grid based on its position and span - fn set_item_in_grid(&mut self, item: &GridItemData) { - let row_start = item.grid_pos.row_start; - let col_start = item.grid_pos.col_start; - - for row_offset in 0..item.span.row_span { - for col_offset in 0..item.span.col_span { - let row_idx = row_start + row_offset; - let col_idx = col_start + col_offset; - - if row_idx < self.collision_grid.nrows() && col_idx < self.collision_grid.ncols() { - self.collision_grid[[row_idx, col_idx]] = Some(item.id); - } - } - } - } - - /// Clear an item from the collision grid based on its position and span - fn clear_item_from_grid(&mut self, item: &GridItemData) { - let row_start = item.grid_pos.row_start; - let col_start = item.grid_pos.col_start; - - for row_offset in 0..item.span.row_span { - for col_offset in 0..item.span.col_span { - let row_idx = row_start + row_offset; - let col_idx = col_start + col_offset; - - if row_idx < self.collision_grid.nrows() && col_idx < self.collision_grid.ncols() { - // Only clear if this cell actually contains this item - if self.collision_grid[[row_idx, col_idx]] == Some(item.id) { - self.collision_grid[[row_idx, col_idx]] = None; - } - } - } - } + collision_grid::set_item(&mut self.collision_grid, item); } /// Check for collisions with other items at a given position and span /// Returns a Vec of item IDs that would collide pub fn check_collision(&self, item: &GridItemData) -> Vec { - self.colliding_item_ids(item) + collision_grid::item_ids_for_item(&self.collision_grid, item) } /// Ensure the grid has enough rows to accommodate all items @@ -567,7 +368,7 @@ impl Layout { // If item is already registered, clear its old position from collision grid if let Some(old_item_signal) = self.items.get(&untracked_item.id) { let old_item = old_item_signal.get_untracked(); - self.clear_item_from_grid(&old_item); + collision_grid::clear_item(&mut self.collision_grid, &old_item); } // Ensure grid has enough rows for this item @@ -575,7 +376,7 @@ impl Layout { self.ensure_rows(item_end_row); // Set item in collision grid at new position - self.set_item_in_grid(&untracked_item); + collision_grid::set_item(&mut self.collision_grid, &untracked_item); // Add/update item in the items HashMap self.items.insert(untracked_item.id, item); @@ -696,7 +497,7 @@ impl Layout { } // Clear item from old position in collision grid - self.clear_item_from_grid(&untracked_item); + collision_grid::clear_item(&mut self.collision_grid, &untracked_item); // Update item's position untracked_item.grid_pos = new_position; @@ -705,12 +506,26 @@ impl Layout { // Ensure grid has enough capacity for the new position self.ensure_rows(untracked_item.grid_pos.row_start + untracked_item.span.row_span); - let colliding_ids = self.colliding_item_ids(&untracked_item); + let colliding_ids = + collision_grid::item_ids_for_item(&self.collision_grid, &untracked_item); let mut placed_in_grid = false; if !colliding_ids.is_empty() { - let fine_collisions = self.fine_drop_collisions(&untracked_item, drag_px_pos); - let Some(drop_collision) = Self::dominant_drop_collision(&fine_collisions) else { + let moved_aabb = item_aabb::from_drag(&untracked_item, drag_px_pos, self.cell_size); + let collision_candidates = collision_grid::item_ids_for_aabb( + &self.collision_grid, + moved_aabb, + untracked_item.id, + ) + .into_iter() + .filter_map(|item_id| { + self.items + .get(&item_id) + .map(|item_signal| item_signal.get_untracked()) + }); + let fine_collisions = + item_aabb::drop_collisions(&untracked_item, moved_aabb, collision_candidates); + let Some(drop_collision) = item_aabb::dominant_drop_collision(&fine_collisions) else { untracked_item.grid_pos = old_position; self.restore_item_position(item, untracked_item); return; @@ -721,7 +536,7 @@ impl Layout { { untracked_item.grid_pos = swap_position; untracked_item.grid_to_pixels(self.cell_size, Axes::XY); - self.set_item_in_grid(&untracked_item); + collision_grid::set_item(&mut self.collision_grid, &untracked_item); placed_in_grid = true; } else { if old_position.col_start == untracked_item.grid_pos.col_start { @@ -795,10 +610,11 @@ impl Layout { height: row_span as f64 * self.cell_size.height, }; - self.clear_item_from_grid(&old_item); + collision_grid::clear_item(&mut self.collision_grid, &old_item); self.ensure_rows(untracked_item.grid_pos.row_start + untracked_item.span.row_span); - let colliding_ids = self.colliding_item_ids(&untracked_item); + let colliding_ids = + collision_grid::item_ids_for_item(&self.collision_grid, &untracked_item); let mut placed_in_grid = false; if !colliding_ids.is_empty() { let old_row_end = old_item.grid_pos.row_start + old_item.span.row_span; @@ -882,7 +698,7 @@ impl Layout { let untracked_item = item.get_untracked(); // Clear the item from the collision grid - self.clear_item_from_grid(&untracked_item); + collision_grid::clear_item(&mut self.collision_grid, &untracked_item); // Remove from items HashMap self.items.remove(&untracked_item.id); @@ -914,7 +730,7 @@ impl Layout { item.grid_pos.col_start, ); - while !self.colliding_item_ids(&item).is_empty() { + while !collision_grid::item_ids_for_item(&self.collision_grid, &item).is_empty() { item.grid_pos.row_start += 1; self.ensure_rows(item.grid_pos.row_start + item.span.row_span); } @@ -922,7 +738,7 @@ impl Layout { item.grid_to_pixels(self.cell_size, Axes::XY); item_signal.set(item); self.ensure_rows(item.grid_pos.row_start + item.span.row_span); - self.set_item_in_grid(&item); + collision_grid::set_item(&mut self.collision_grid, &item); } } } diff --git a/crates/ui/src/components/grid/core/mod.rs b/crates/ui/src/components/grid/core/mod.rs index d79083a..039c9d1 100644 --- a/crates/ui/src/components/grid/core/mod.rs +++ b/crates/ui/src/components/grid/core/mod.rs @@ -1,3 +1,4 @@ +pub mod collision; pub mod item; pub mod layout; pub mod size; From 608700df7e027dd23793744ceab517138deeedd9 Mon Sep 17 00:00:00 2001 From: theredfish Date: Sun, 3 May 2026 00:41:35 +0200 Subject: [PATCH 04/12] Allow upward insertion after failed swap --- crates/ui/src/components/grid/core/layout.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/components/grid/core/layout.rs b/crates/ui/src/components/grid/core/layout.rs index 7d3d8b9..4578d6c 100644 --- a/crates/ui/src/components/grid/core/layout.rs +++ b/crates/ui/src/components/grid/core/layout.rs @@ -539,7 +539,10 @@ impl Layout { collision_grid::set_item(&mut self.collision_grid, &untracked_item); placed_in_grid = true; } else { - if old_position.col_start == untracked_item.grid_pos.col_start { + let same_column_drop = old_position.col_start == untracked_item.grid_pos.col_start; + let moving_up = untracked_item.grid_pos.row_start < old_position.row_start; + + if same_column_drop && !moving_up { untracked_item.grid_pos = old_position; self.restore_item_position(item, untracked_item); return; From 9e602ddab3db7fa9cb4ec9bccd894f61461dd1df Mon Sep 17 00:00:00 2001 From: theredfish Date: Sun, 3 May 2026 19:49:14 +0200 Subject: [PATCH 05/12] Extract drop placement resolution --- .../components/grid/core/drop_placement.rs | 66 +++++++++++ crates/ui/src/components/grid/core/layout.rs | 104 ++++++++++++------ crates/ui/src/components/grid/core/mod.rs | 1 + 3 files changed, 136 insertions(+), 35 deletions(-) create mode 100644 crates/ui/src/components/grid/core/drop_placement.rs diff --git a/crates/ui/src/components/grid/core/drop_placement.rs b/crates/ui/src/components/grid/core/drop_placement.rs new file mode 100644 index 0000000..121da33 --- /dev/null +++ b/crates/ui/src/components/grid/core/drop_placement.rs @@ -0,0 +1,66 @@ +use crate::components::grid::core::item::GridPosition; + +/// Decision returned after a drag drop collides with an existing grid item. +/// +/// This enum only describes the placement strategy. `Layout` remains +/// responsible for mutating item signals and the collision grid. +#[derive(Clone, Copy, Debug)] +pub enum DropPlacement { + /// Reject the drop and restore the moved item to its previous grid position. + Restore, + /// Place the moved item at its computed swap position and move the target + /// item into the space opened by the drag. + Swap { + /// The item that was selected as the unique swap target. + target_id: u32, + /// Final grid position for the moved item after the swap succeeds. + moved_position: GridPosition, + }, + /// Insert the moved item near the collided target and push the local + /// collision chain down until the moved item fits. + InsertWithPush { + /// The item that was selected as the insertion anchor. + target_id: u32, + /// Optional row chosen from the target midpoint. When absent, the + /// caller keeps the moved item's snapped row. + row_start: Option, + }, +} + +/// Resolves the high-level placement strategy for a colliding drag drop. +/// +/// The collision adapter chooses the dominant target, `Layout` tests whether a +/// strict vertical swap is possible, and this function decides whether the drop +/// should restore, swap, or insert with a local push. +pub fn resolve_collision_drop( + old_position: GridPosition, + moved_position: GridPosition, + target_id: Option, + swap_position: Option, + insertion_row: Option, +) -> DropPlacement { + let Some(target_id) = target_id else { + return DropPlacement::Restore; + }; + + if let Some(moved_position) = swap_position { + return DropPlacement::Swap { + target_id, + moved_position, + }; + } + + // Downward moves inside the same column must not push the target by default. + // Without a valid swap, they behave like Grafana-style rejected drops. + let same_column_drop = old_position.col_start == moved_position.col_start; + let moving_up = moved_position.row_start < old_position.row_start; + + if same_column_drop && !moving_up { + DropPlacement::Restore + } else { + DropPlacement::InsertWithPush { + target_id, + row_start: insertion_row, + } + } +} diff --git a/crates/ui/src/components/grid/core/layout.rs b/crates/ui/src/components/grid/core/layout.rs index 4578d6c..90f68ba 100644 --- a/crates/ui/src/components/grid/core/layout.rs +++ b/crates/ui/src/components/grid/core/layout.rs @@ -1,4 +1,5 @@ use crate::components::grid::core::collision::{grid as collision_grid, item_aabb}; +use crate::components::grid::core::drop_placement::{self, DropPlacement}; use crate::components::grid::core::item::Axes; use crate::components::grid::core::{ item::{GridItemData, GridPosition}, @@ -28,6 +29,8 @@ pub struct Layout { } impl Layout { + /// Grows the collision grid vertically so future writes can address every + /// required row without special-casing out-of-bounds placements. fn ensure_rows(&mut self, required_rows: usize) { if required_rows <= self.rows { return; @@ -41,6 +44,8 @@ impl Layout { self.rows = required_rows; } + /// Keeps an item inside the horizontal grid bounds while preserving the + /// requested row. Vertical growth is handled separately by `ensure_rows`. fn clamp_position_for_item(&self, item: &GridItemData, row: usize, col: usize) -> GridPosition { let max_col = self.columns.saturating_sub(item.span.col_span); @@ -50,16 +55,24 @@ impl Layout { } } + /// Tests whether two column spans share at least one grid column. fn col_ranges_overlap(a_start: usize, a_span: usize, b_start: usize, b_span: usize) -> bool { a_start < b_start + b_span && b_start < a_start + a_span } + /// Restores an item after a rejected drop and writes the restored state back + /// to both the item signal and the collision grid. fn restore_item_position(&mut self, item: RwSignal, mut item_data: GridItemData) { item_data.grid_to_pixels(self.cell_size, Axes::XY); item.set(item_data); collision_grid::set_item(&mut self.collision_grid, &item_data); } + /// Attempts a vertical swap between the moved item and one dominant target. + /// + /// Swaps are deliberately narrow: the item must stay in the same column + /// track, overlap the target horizontally, and both final positions must fit + /// without touching any third item. fn try_swap_items( &mut self, moved_item: &GridItemData, @@ -153,6 +166,8 @@ impl Layout { Some(moved_swap_position) } + /// Chooses whether a non-swapping drop should be inserted before or after + /// the collided item based on the dragged item's pixel position. fn insertion_row_for_collision( &self, moved_item: &GridItemData, @@ -184,6 +199,8 @@ impl Layout { }) } + /// Compacts every item that intersects the given column range as high as it + /// can go, while optionally leaving one active item untouched. fn compact_items_up_in_columns( &mut self, col_start: usize, @@ -226,6 +243,10 @@ impl Layout { } } + /// Collects items affected by a vertical push in the scanned columns. + /// + /// When an affected item spans additional columns, those columns are added + /// to the scan so connected collision chains move together. fn collect_item_ids_in_columns_from_row( &self, row_start: usize, @@ -274,6 +295,8 @@ impl Layout { ids } + /// Moves a prepared set of items down by a fixed number of rows and keeps + /// their signals, pixel positions, and collision cells synchronized. fn shift_items_down(&mut self, item_ids: Vec, by_rows: usize) { if by_rows == 0 || item_ids.is_empty() { return; @@ -301,6 +324,8 @@ impl Layout { } } + /// Places an item by repeatedly pushing only the local collision chain below + /// it until the item can be written into the collision grid. fn set_item_after_local_push( &mut self, item: &GridItemData, @@ -525,47 +550,52 @@ impl Layout { }); let fine_collisions = item_aabb::drop_collisions(&untracked_item, moved_aabb, collision_candidates); - let Some(drop_collision) = item_aabb::dominant_drop_collision(&fine_collisions) else { - untracked_item.grid_pos = old_position; - self.restore_item_position(item, untracked_item); - return; - }; - - if let Some(swap_position) = - self.try_swap_items(&untracked_item, old_position, &[drop_collision.item_id]) - { - untracked_item.grid_pos = swap_position; - untracked_item.grid_to_pixels(self.cell_size, Axes::XY); - collision_grid::set_item(&mut self.collision_grid, &untracked_item); - placed_in_grid = true; - } else { - let same_column_drop = old_position.col_start == untracked_item.grid_pos.col_start; - let moving_up = untracked_item.grid_pos.row_start < old_position.row_start; - - if same_column_drop && !moving_up { + let dominant_collision = item_aabb::dominant_drop_collision(&fine_collisions); + let target_id = dominant_collision.map(|collision| collision.item_id); + let swap_position = target_id.and_then(|target_id| { + self.try_swap_items(&untracked_item, old_position, &[target_id]) + }); + let insertion_row = + target_id + .filter(|_| swap_position.is_none()) + .and_then(|target_id| { + self.insertion_row_for_collision(&untracked_item, &[target_id], drag_px_pos) + }); + + match drop_placement::resolve_collision_drop( + old_position, + untracked_item.grid_pos, + target_id, + swap_position, + insertion_row, + ) { + DropPlacement::Restore => { untracked_item.grid_pos = old_position; self.restore_item_position(item, untracked_item); return; } - - if let Some(row_start) = self.insertion_row_for_collision( - &untracked_item, - &[drop_collision.item_id], - drag_px_pos, - ) { - untracked_item.grid_pos.row_start = row_start; + DropPlacement::Swap { moved_position, .. } => { + untracked_item.grid_pos = moved_position; untracked_item.grid_to_pixels(self.cell_size, Axes::XY); - self.ensure_rows( - untracked_item.grid_pos.row_start + untracked_item.span.row_span, - ); + collision_grid::set_item(&mut self.collision_grid, &untracked_item); + placed_in_grid = true; } + DropPlacement::InsertWithPush { row_start, .. } => { + if let Some(row_start) = row_start { + untracked_item.grid_pos.row_start = row_start; + untracked_item.grid_to_pixels(self.cell_size, Axes::XY); + self.ensure_rows( + untracked_item.grid_pos.row_start + untracked_item.span.row_span, + ); + } - self.set_item_after_local_push( - &untracked_item, - untracked_item.grid_pos.row_start, - untracked_item.span.row_span, - ); - placed_in_grid = true; + self.set_item_after_local_push( + &untracked_item, + untracked_item.grid_pos.row_start, + untracked_item.span.row_span, + ); + placed_in_grid = true; + } } } @@ -656,7 +686,11 @@ impl Layout { ); } - /// Push items down by a certain number of rows + /// Push items down by a certain number of rows. + /// + /// This legacy helper is currently used by `add_item_at_top`, where the + /// collision grid itself is shifted by row concatenation after item signals + /// have been moved. /// /// # Arguments /// * `items` - The items to push down diff --git a/crates/ui/src/components/grid/core/mod.rs b/crates/ui/src/components/grid/core/mod.rs index 039c9d1..187a1fe 100644 --- a/crates/ui/src/components/grid/core/mod.rs +++ b/crates/ui/src/components/grid/core/mod.rs @@ -1,4 +1,5 @@ pub mod collision; +pub mod drop_placement; pub mod item; pub mod layout; pub mod size; From ae420d61538cb241d6c60dcdeb7dc32d65a403a9 Mon Sep 17 00:00:00 2001 From: theredfish Date: Sun, 3 May 2026 22:22:27 +0200 Subject: [PATCH 06/12] Move drop collision scoring into placement module --- .../grid/core/collision/item_aabb.rs | 62 ------------- .../components/grid/core/drop_placement.rs | 89 +++++++++++++++++++ crates/ui/src/components/grid/core/layout.rs | 30 ++++++- 3 files changed, 117 insertions(+), 64 deletions(-) diff --git a/crates/ui/src/components/grid/core/collision/item_aabb.rs b/crates/ui/src/components/grid/core/collision/item_aabb.rs index 16778b3..91cae97 100644 --- a/crates/ui/src/components/grid/core/collision/item_aabb.rs +++ b/crates/ui/src/components/grid/core/collision/item_aabb.rs @@ -3,24 +3,6 @@ use crate::components::grid::core::item::GridItemData; use crate::components::grid::core::size::Size; use leptos_use::core::Position; -const MIN_DROP_AXIS_OVERLAP_RATIO: f64 = 0.35; -const DOMINANT_DROP_OVERLAP_RATIO: f64 = 1.25; - -#[derive(Clone, Copy, Debug)] -pub struct DropCollision { - pub item_id: u32, - overlap_area: f64, - horizontal_overlap_ratio: f64, - vertical_overlap_ratio: f64, -} - -impl DropCollision { - fn is_actionable(self) -> bool { - self.horizontal_overlap_ratio >= MIN_DROP_AXIS_OVERLAP_RATIO - && self.vertical_overlap_ratio >= MIN_DROP_AXIS_OVERLAP_RATIO - } -} - pub fn from_item(item: &GridItemData) -> Aabb { Aabb::new( item.grid_pos.col_start as f64, @@ -46,47 +28,3 @@ pub fn items_overlap(a: &GridItemData, b: &GridItemData) -> bool { pub fn overlap_item(aabb: Aabb, item: &GridItemData) -> Option<(f64, f64)> { aabb.overlap(&from_item(item)) } - -pub fn drop_collisions( - moved_item: &GridItemData, - moved_aabb: Aabb, - candidates: impl IntoIterator, -) -> Vec { - let mut collisions = candidates - .into_iter() - .filter_map(|item| { - let (overlap_width, overlap_height) = overlap_item(moved_aabb, &item)?; - - Some(DropCollision { - item_id: item.id, - overlap_area: overlap_width * overlap_height, - horizontal_overlap_ratio: overlap_width - / (moved_item.span.col_span.min(item.span.col_span) as f64), - vertical_overlap_ratio: overlap_height - / (moved_item.span.row_span.min(item.span.row_span) as f64), - }) - }) - .filter(|collision| collision.is_actionable()) - .collect::>(); - - collisions.sort_by(|a, b| { - b.overlap_area - .partial_cmp(&a.overlap_area) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.item_id.cmp(&b.item_id)) - }); - collisions -} - -pub fn dominant_drop_collision(collisions: &[DropCollision]) -> Option { - let first = *collisions.first()?; - let Some(second) = collisions.get(1) else { - return Some(first); - }; - - if first.overlap_area >= second.overlap_area * DOMINANT_DROP_OVERLAP_RATIO { - Some(first) - } else { - None - } -} diff --git a/crates/ui/src/components/grid/core/drop_placement.rs b/crates/ui/src/components/grid/core/drop_placement.rs index 121da33..13ca8e8 100644 --- a/crates/ui/src/components/grid/core/drop_placement.rs +++ b/crates/ui/src/components/grid/core/drop_placement.rs @@ -1,5 +1,41 @@ +use crate::components::grid::core::collision::{aabb::Aabb, item_aabb}; +use crate::components::grid::core::item::GridItemData; use crate::components::grid::core::item::GridPosition; +/// Minimum overlap needed on each axis before a dragged item can affect another +/// item. Small edge contacts are ignored so they do not accidentally push or +/// swap panels. +const MIN_DROP_AXIS_OVERLAP_RATIO: f64 = 0.35; + +/// Ratio required for the strongest collision to be treated as the only useful +/// target. Ambiguous overlaps restore the item instead of guessing. +const DOMINANT_DROP_OVERLAP_RATIO: f64 = 1.25; + +/// Fine collision result between the dragged item and one candidate item. +/// +/// The collision grid first limits candidates by occupied cells. This type +/// stores the AABB overlap details used to decide whether one candidate is +/// strong enough to drive a drop placement. +#[derive(Clone, Copy, Debug)] +pub struct DropCollision { + /// Candidate item touched by the dragged item. + pub item_id: u32, + /// AABB overlap area in grid-cell units. + overlap_area: f64, + /// Horizontal overlap normalized by the smaller item width. + horizontal_overlap_ratio: f64, + /// Vertical overlap normalized by the smaller item height. + vertical_overlap_ratio: f64, +} + +impl DropCollision { + /// Returns true when the overlap is intentional enough to affect layout. + fn is_actionable(self) -> bool { + self.horizontal_overlap_ratio >= MIN_DROP_AXIS_OVERLAP_RATIO + && self.vertical_overlap_ratio >= MIN_DROP_AXIS_OVERLAP_RATIO + } +} + /// Decision returned after a drag drop collides with an existing grid item. /// /// This enum only describes the placement strategy. `Layout` remains @@ -27,6 +63,59 @@ pub enum DropPlacement { }, } +/// Builds sorted, actionable drop collisions from already filtered candidates. +/// +/// Candidates should come from the collision grid, which keeps this pass small. +/// AABB is only used here to refine those candidates and rank the dominant +/// interaction. +pub fn drop_collisions( + moved_item: &GridItemData, + moved_aabb: Aabb, + candidates: impl IntoIterator, +) -> Vec { + let mut collisions = candidates + .into_iter() + .filter_map(|item| { + let (overlap_width, overlap_height) = item_aabb::overlap_item(moved_aabb, &item)?; + + Some(DropCollision { + item_id: item.id, + overlap_area: overlap_width * overlap_height, + horizontal_overlap_ratio: overlap_width + / (moved_item.span.col_span.min(item.span.col_span) as f64), + vertical_overlap_ratio: overlap_height + / (moved_item.span.row_span.min(item.span.row_span) as f64), + }) + }) + .filter(|collision| collision.is_actionable()) + .collect::>(); + + collisions.sort_by(|a, b| { + b.overlap_area + .partial_cmp(&a.overlap_area) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.item_id.cmp(&b.item_id)) + }); + collisions +} + +/// Selects the strongest collision only when it clearly dominates the next one. +/// +/// This prevents diagonal or broad overlaps from choosing an arbitrary target +/// when the dragged item is interacting with multiple panels at similar depth. +pub fn dominant_drop_collision(collisions: &[DropCollision]) -> Option { + let first = *collisions.first()?; + let Some(second) = collisions.get(1) else { + return Some(first); + }; + + if first.overlap_area >= second.overlap_area * DOMINANT_DROP_OVERLAP_RATIO { + Some(first) + } else { + None + } +} + /// Resolves the high-level placement strategy for a colliding drag drop. /// /// The collision adapter chooses the dominant target, `Layout` tests whether a diff --git a/crates/ui/src/components/grid/core/layout.rs b/crates/ui/src/components/grid/core/layout.rs index 90f68ba..2481007 100644 --- a/crates/ui/src/components/grid/core/layout.rs +++ b/crates/ui/src/components/grid/core/layout.rs @@ -15,6 +15,7 @@ use std::collections::{HashMap, HashSet}; #[derive(Clone, Debug, Default)] pub struct Layout { + /// Total rendered size available for the grid surface. pub size: Size, /// The collision grid storing the occupancy of each cell by item id pub collision_grid: Array2>, @@ -549,8 +550,8 @@ impl Layout { .map(|item_signal| item_signal.get_untracked()) }); let fine_collisions = - item_aabb::drop_collisions(&untracked_item, moved_aabb, collision_candidates); - let dominant_collision = item_aabb::dominant_drop_collision(&fine_collisions); + drop_placement::drop_collisions(&untracked_item, moved_aabb, collision_candidates); + let dominant_collision = drop_placement::dominant_drop_collision(&fine_collisions); let target_id = dominant_collision.map(|collision| collision.item_id); let swap_position = target_id.and_then(|target_id| { self.try_swap_items(&untracked_item, old_position, &[target_id]) @@ -620,6 +621,11 @@ impl Layout { ); } + /// Resizes an item, pushes any newly covered local collision chain down, + /// then compacts the affected columns back upward. + /// + /// Resizing is anchored at the item's current top-left grid position. Width + /// is clamped to the available columns and height can grow the grid. pub fn resize_item_with_collision( &mut self, item: RwSignal, @@ -731,6 +737,7 @@ impl Layout { // For add_item, the collision_grid update happens via row concatenation. } + /// Removes an item from both the collision grid and the item registry. pub fn remove_item(&mut self, item: RwSignal) { let untracked_item = item.get_untracked(); @@ -741,6 +748,12 @@ impl Layout { self.items.remove(&untracked_item.id); } + /// Rebuilds the collision grid from item signals and resolves any initial + /// overlaps by moving later items downward. + /// + /// This is the normalization pass used after responsive layout changes or + /// initial registration so the backend grid representation matches the + /// rendered item positions. pub fn sync_items_to_grid(&mut self) { self.collision_grid = Array2::from_elem((self.rows, self.columns), None::); let mut items = self @@ -782,35 +795,48 @@ impl Layout { #[derive(Clone, Debug, Default)] pub struct LayoutBuilder { + /// Initial rendered size available for the grid surface. pub size: Size, + /// Optional initial collision grid. `build` creates the final empty grid + /// from `rows` and `columns`. pub collision_grid: Array2>, + /// Optional initial item registry. pub items: HashMap>, + /// Number of grid columns to allocate. pub columns: usize, + /// Number of grid rows to allocate. pub rows: usize, + /// Initial rendered size of each grid cell. pub cell_size: Size, } impl LayoutBuilder { + /// Sets the number of rows allocated in the initial collision grid. pub fn rows(mut self, quantity: usize) -> Self { self.rows = quantity; self } + /// Sets the number of columns allocated in the initial collision grid. pub fn columns(mut self, quantity: usize) -> Self { self.columns = quantity; self } + /// Sets the total rendered grid size. pub fn size(mut self, width: f64, height: f64) -> Self { self.size = Size { width, height }; self } + /// Sets the rendered size of one grid cell. pub fn cell_size(mut self, width: f64, height: f64) -> Self { self.cell_size = Size { width, height }; self } + /// Builds a layout with an empty collision grid sized from the configured + /// row and column counts. pub fn build(self) -> Layout { let collision_grid = Array2::from_elem((self.rows, self.columns), None::); From 710b1c732861215b3920f812fb8199a96973b6fc Mon Sep 17 00:00:00 2001 From: theredfish Date: Sun, 3 May 2026 22:35:40 +0200 Subject: [PATCH 07/12] Ignore click-only drag gestures --- .../components/grid/utils/draggable_item.rs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/ui/src/components/grid/utils/draggable_item.rs b/crates/ui/src/components/grid/utils/draggable_item.rs index 305f03b..470aca4 100644 --- a/crates/ui/src/components/grid/utils/draggable_item.rs +++ b/crates/ui/src/components/grid/utils/draggable_item.rs @@ -11,6 +11,13 @@ use crate::components::grid::core::{layout::Layout, size::Size}; // stays above its neighbors until the snap-back animation has fully finished. const DRAG_RELEASE_ELEVATION_MS: i32 = 280; +// Clicks on the drag handle can still emit a draggable end event. Requiring a +// small pointer delta keeps simple clicks from snapping the item with a stale +// internal draggable position. +const DRAG_ACTIVATION_THRESHOLD_PX: f64 = 4.0; +const DRAG_ACTIVATION_THRESHOLD_SQUARED: f64 = + DRAG_ACTIVATION_THRESHOLD_PX * DRAG_ACTIVATION_THRESHOLD_PX; + #[derive(Clone, Copy, Debug)] pub enum DragState { Dragging(Position), @@ -69,6 +76,8 @@ pub fn use_draggable_grid_item( let drag_state = RwSignal::new(DragState::Dragging(Position::default())); let is_dragging = RwSignal::new(false); let drag_elevation_epoch = RwSignal::new(0_u32); + let drag_pointer_start = RwSignal::new(None::<(i32, i32)>); + let drag_threshold_reached = RwSignal::new(false); let UseDraggableGridItemOptions { handle, col_start, @@ -111,7 +120,29 @@ pub fn use_draggable_grid_item( }; Position { x: pos.x, y: pos.y } }) + .on_start(move |drag_event| { + drag_pointer_start.set(Some(( + drag_event.event.client_x(), + drag_event.event.client_y(), + ))); + drag_threshold_reached.set(false); + true + }) .on_move(move |drag_event| { + if !drag_threshold_reached.get_untracked() { + let Some((start_x, start_y)) = drag_pointer_start.get_untracked() else { + return; + }; + let delta_x = (drag_event.event.client_x() - start_x) as f64; + let delta_y = (drag_event.event.client_y() - start_y) as f64; + + if delta_x * delta_x + delta_y * delta_y < DRAG_ACTIVATION_THRESHOLD_SQUARED { + return; + } + + drag_threshold_reached.set(true); + } + drag_elevation_epoch.update(|epoch| *epoch = epoch.wrapping_add(1)); let layout = layout.get_untracked(); @@ -127,6 +158,15 @@ pub fn use_draggable_grid_item( on_drag_move(clamped_position); }) .on_end(move |drag_event| { + let drag_was_activated = drag_threshold_reached.get_untracked(); + drag_pointer_start.set(None); + drag_threshold_reached.set(false); + + if !drag_was_activated { + is_dragging.set(false); + return; + } + drag_elevation_epoch.update(|epoch| *epoch = epoch.wrapping_add(1)); let drag_end_epoch = drag_elevation_epoch.get_untracked(); From d2aa119ec68cf28b120a06789b95bac5329754a3 Mon Sep 17 00:00:00 2001 From: theredfish Date: Sun, 3 May 2026 22:42:08 +0200 Subject: [PATCH 08/12] Add grid drop preview --- .../src/components/grid/core/drop_preview.rs | 44 +++++++++++++++++++ crates/ui/src/components/grid/core/mod.rs | 1 + crates/ui/src/components/grid/grid_item.rs | 24 +++++++++- crates/ui/src/components/grid/grid_layout.rs | 31 ++++++++++++- 4 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 crates/ui/src/components/grid/core/drop_preview.rs diff --git a/crates/ui/src/components/grid/core/drop_preview.rs b/crates/ui/src/components/grid/core/drop_preview.rs new file mode 100644 index 0000000..5496066 --- /dev/null +++ b/crates/ui/src/components/grid/core/drop_preview.rs @@ -0,0 +1,44 @@ +use crate::components::grid::core::item::GridPosition; +use crate::components::grid::core::size::Size; +use crate::components::grid::core::span::Span; +use leptos_use::core::Position; + +/// Visual preview of the grid slot currently targeted by a drag gesture. +/// +/// The preview is UI-only: it does not reserve cells in the collision grid and +/// does not mutate item positions. The actual placement is still resolved by +/// `Layout` when the drag ends. +#[derive(Clone, Copy, Debug)] +pub struct DropPreview { + /// Item currently driving the preview. + pub item_id: u32, + /// Snapped grid position under the active drag. + pub grid_pos: GridPosition, + /// Span used to size the preview rectangle. + pub span: Span, +} + +impl DropPreview { + /// Creates a preview from an item id, snapped grid position, and current span. + pub fn new(item_id: u32, grid_pos: GridPosition, span: Span) -> Self { + Self { + item_id, + grid_pos, + span, + } + } + + /// Converts the snapped grid position to a pixel rectangle. + pub fn pixel_rect(self, cell_size: Size) -> (Position, Size) { + ( + Position { + x: self.grid_pos.col_start as f64 * cell_size.width, + y: self.grid_pos.row_start as f64 * cell_size.height, + }, + Size { + width: self.span.col_span as f64 * cell_size.width, + height: self.span.row_span as f64 * cell_size.height, + }, + ) + } +} diff --git a/crates/ui/src/components/grid/core/mod.rs b/crates/ui/src/components/grid/core/mod.rs index 187a1fe..237a60f 100644 --- a/crates/ui/src/components/grid/core/mod.rs +++ b/crates/ui/src/components/grid/core/mod.rs @@ -1,5 +1,6 @@ pub mod collision; pub mod drop_placement; +pub mod drop_preview; pub mod item; pub mod layout; pub mod size; diff --git a/crates/ui/src/components/grid/grid_item.rs b/crates/ui/src/components/grid/grid_item.rs index fd592bc..56c92c9 100644 --- a/crates/ui/src/components/grid/grid_item.rs +++ b/crates/ui/src/components/grid/grid_item.rs @@ -1,3 +1,4 @@ +use crate::components::grid::core::drop_preview::DropPreview; use crate::components::grid::core::item::{GridItemData, GridPosition}; use crate::components::grid::core::layout::Layout; use crate::components::grid::core::size::Size; @@ -15,8 +16,8 @@ use leptos::prelude::*; use leptos_use::core::Position; use std::sync::Arc; -const GRID_ITEM_GAP_PX: f64 = 12.0; -const GRID_ITEM_INSET_PX: f64 = GRID_ITEM_GAP_PX / 2.0; +pub(crate) const GRID_ITEM_GAP_PX: f64 = 12.0; +pub(crate) const GRID_ITEM_INSET_PX: f64 = GRID_ITEM_GAP_PX / 2.0; #[component] pub fn GridItem( @@ -32,6 +33,8 @@ pub fn GridItem( dynamic: bool, ) -> impl IntoView { let layout = use_context::>().expect("should retrieve the layout context"); + let drop_preview = use_context::>>() + .expect("Drop preview context must be provided"); let untracked_layout = layout.get_untracked(); let grid_item_ref = NodeRef::
::new(); let drag_ref = NodeRef::
::new(); @@ -85,12 +88,29 @@ pub fn GridItem( col_span, current_col_span: Arc::new(move || grid_item_data.get_untracked().span.col_span), on_drag_move: Arc::new(move |drag_px_pos| { + let layout = layout.get_untracked(); + let item = grid_item_data.get_untracked(); + let max_col_start = layout.columns.saturating_sub(item.span.col_span); + let col_start = + ((drag_px_pos.x / layout.cell_size.width).round() as usize).min(max_col_start); + let row_start = (drag_px_pos.y / layout.cell_size.height).round() as usize; + + drop_preview.set(Some(DropPreview::new( + item.id, + GridPosition { + col_start, + row_start, + }, + item.span, + ))); + grid_item_data.update(|item| { item.px_pos = drag_px_pos; log!("Update item with new px_pos: {drag_px_pos:?}"); }) }), on_drag_end: Arc::new(move |col_start, row_start, _snapped_px_pos, drag_px_pos| { + drop_preview.set(None); layout.update(|layout| { layout.move_item_with_collision(grid_item_data, row_start, col_start, drag_px_pos); }); diff --git a/crates/ui/src/components/grid/grid_layout.rs b/crates/ui/src/components/grid/grid_layout.rs index f4785b3..86ea344 100644 --- a/crates/ui/src/components/grid/grid_layout.rs +++ b/crates/ui/src/components/grid/grid_layout.rs @@ -1,10 +1,12 @@ +use crate::components::grid::core::drop_preview::DropPreview; use crate::components::grid::core::layout::LayoutBuilder; use crate::components::grid::core::size::Size; -use crate::components::grid::grid_item::GridItem; +use crate::components::grid::grid_item::{GridItem, GRID_ITEM_GAP_PX, GRID_ITEM_INSET_PX}; use crate::components::page_layout::PageLayout; use leptos::{html::Div, logging::log, prelude::*}; use leptos_use::{ - use_element_bounding_with_options, UseElementBoundingOptions, UseElementBoundingReturn, + core::Position, use_element_bounding_with_options, UseElementBoundingOptions, + UseElementBoundingReturn, }; #[component] @@ -21,8 +23,10 @@ pub fn GridLayout(children: Children, columns: usize, display_grid: bool) -> imp .cell_size(100., 100.) .build(), ); + let drop_preview = RwSignal::new(None::); provide_context(layout); + provide_context(drop_preview); // Track dynamically added items let next_id = RwSignal::new(10_000u32); @@ -115,6 +119,29 @@ pub fn GridLayout(children: Children, columns: usize, display_grid: bool) -> imp } } {children()} + { + move || { + drop_preview.get().map(|preview| { + let layout = layout.get(); + let (Position { x: left, y: top }, Size { width, height }) = + preview.pixel_rect(layout.cell_size); + let visual_left = left + GRID_ITEM_INSET_PX; + let visual_top = top + GRID_ITEM_INSET_PX; + let visual_width = (width - GRID_ITEM_GAP_PX).max(0.0); + let visual_height = (height - GRID_ITEM_GAP_PX).max(0.0); + + view! { +
+ } + }) + } + } Date: Sun, 3 May 2026 22:47:27 +0200 Subject: [PATCH 09/12] Add grid resize preview --- crates/ui/src/components/grid/core/mod.rs | 1 + .../components/grid/core/resize_preview.rs | 44 +++++++++++++++++++ crates/ui/src/components/grid/grid_item.rs | 25 ++++++++++- crates/ui/src/components/grid/grid_layout.rs | 26 +++++++++++ .../components/grid/utils/resizable_item.rs | 41 ++++++++++++++--- 5 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 crates/ui/src/components/grid/core/resize_preview.rs diff --git a/crates/ui/src/components/grid/core/mod.rs b/crates/ui/src/components/grid/core/mod.rs index 237a60f..598b40b 100644 --- a/crates/ui/src/components/grid/core/mod.rs +++ b/crates/ui/src/components/grid/core/mod.rs @@ -3,5 +3,6 @@ pub mod drop_placement; pub mod drop_preview; pub mod item; pub mod layout; +pub mod resize_preview; pub mod size; pub mod span; diff --git a/crates/ui/src/components/grid/core/resize_preview.rs b/crates/ui/src/components/grid/core/resize_preview.rs new file mode 100644 index 0000000..8f46339 --- /dev/null +++ b/crates/ui/src/components/grid/core/resize_preview.rs @@ -0,0 +1,44 @@ +use crate::components::grid::core::item::GridPosition; +use crate::components::grid::core::size::Size; +use crate::components::grid::core::span::Span; +use leptos_use::core::Position; + +/// Visual preview of the grid rectangle targeted by a resize gesture. +/// +/// The preview is UI-only. It mirrors the snapped size that will be submitted +/// to `Layout::resize_item_with_collision` when the resize ends, but it does +/// not update the collision grid while the pointer is still moving. +#[derive(Clone, Copy, Debug)] +pub struct ResizePreview { + /// Item currently being resized. + pub item_id: u32, + /// Fixed top-left position used as the resize anchor. + pub grid_pos: GridPosition, + /// Snapped target span under the active resize gesture. + pub span: Span, +} + +impl ResizePreview { + /// Creates a resize preview from an item id, anchor position, and target span. + pub fn new(item_id: u32, grid_pos: GridPosition, span: Span) -> Self { + Self { + item_id, + grid_pos, + span, + } + } + + /// Converts the anchored grid span to a pixel rectangle. + pub fn pixel_rect(self, cell_size: Size) -> (Position, Size) { + ( + Position { + x: self.grid_pos.col_start as f64 * cell_size.width, + y: self.grid_pos.row_start as f64 * cell_size.height, + }, + Size { + width: self.span.col_span as f64 * cell_size.width, + height: self.span.row_span as f64 * cell_size.height, + }, + ) + } +} diff --git a/crates/ui/src/components/grid/grid_item.rs b/crates/ui/src/components/grid/grid_item.rs index 56c92c9..99eb936 100644 --- a/crates/ui/src/components/grid/grid_item.rs +++ b/crates/ui/src/components/grid/grid_item.rs @@ -1,13 +1,15 @@ use crate::components::grid::core::drop_preview::DropPreview; use crate::components::grid::core::item::{GridItemData, GridPosition}; use crate::components::grid::core::layout::Layout; +use crate::components::grid::core::resize_preview::ResizePreview; use crate::components::grid::core::size::Size; use crate::components::grid::core::span::Span; use crate::components::grid::utils::draggable_item::{ use_draggable_grid_item, UseDraggableGridItemOptions, UseDraggableGridItemReturn, }; use crate::components::grid::utils::resizable_item::{ - use_resizable_grid_item, UseResizableGridItemOptions, UseResizableGridItemReturn, + directional_snap_span, use_resizable_grid_item, UseResizableGridItemOptions, + UseResizableGridItemReturn, }; use crate::components::heroicons::ResizeIcon; use leptos::html::Div; @@ -35,6 +37,8 @@ pub fn GridItem( let layout = use_context::>().expect("should retrieve the layout context"); let drop_preview = use_context::>>() .expect("Drop preview context must be provided"); + let resize_preview = use_context::>>() + .expect("Resize preview context must be provided"); let untracked_layout = layout.get_untracked(); let grid_item_ref = NodeRef::
::new(); let drag_ref = NodeRef::
::new(); @@ -139,11 +143,30 @@ pub fn GridItem( current_col_span: Arc::new(move || grid_item_data.get_untracked().span.col_span), current_row_span: Arc::new(move || grid_item_data.get_untracked().span.row_span), on_resize_move: Arc::new(move |size| { + let layout = layout.get_untracked(); + let item = grid_item_data.get_untracked(); + let max_col_span = layout + .columns + .saturating_sub(item.grid_pos.col_start) + .max(1); + let col_span = + directional_snap_span(size.width, item.span.col_span, layout.cell_size.width) + .min(max_col_span); + let row_span = + directional_snap_span(size.height, item.span.row_span, layout.cell_size.height); + + resize_preview.set(Some(ResizePreview::new( + item.id, + item.grid_pos, + Span { row_span, col_span }, + ))); + grid_item_data.update(|item| { item.size = size; }); }), on_resize_end: Arc::new(move |size| { + resize_preview.set(None); layout.update(|layout| { let cell_size = layout.cell_size; let col_span = (size.width / cell_size.width).round() as usize; diff --git a/crates/ui/src/components/grid/grid_layout.rs b/crates/ui/src/components/grid/grid_layout.rs index 86ea344..edbcd27 100644 --- a/crates/ui/src/components/grid/grid_layout.rs +++ b/crates/ui/src/components/grid/grid_layout.rs @@ -1,5 +1,6 @@ use crate::components::grid::core::drop_preview::DropPreview; use crate::components::grid::core::layout::LayoutBuilder; +use crate::components::grid::core::resize_preview::ResizePreview; use crate::components::grid::core::size::Size; use crate::components::grid::grid_item::{GridItem, GRID_ITEM_GAP_PX, GRID_ITEM_INSET_PX}; use crate::components::page_layout::PageLayout; @@ -24,9 +25,11 @@ pub fn GridLayout(children: Children, columns: usize, display_grid: bool) -> imp .build(), ); let drop_preview = RwSignal::new(None::); + let resize_preview = RwSignal::new(None::); provide_context(layout); provide_context(drop_preview); + provide_context(resize_preview); // Track dynamically added items let next_id = RwSignal::new(10_000u32); @@ -142,6 +145,29 @@ pub fn GridLayout(children: Children, columns: usize, display_grid: bool) -> imp }) } } + { + move || { + resize_preview.get().map(|preview| { + let layout = layout.get(); + let (Position { x: left, y: top }, Size { width, height }) = + preview.pixel_rect(layout.cell_size); + let visual_left = left + GRID_ITEM_INSET_PX; + let visual_top = top + GRID_ITEM_INSET_PX; + let visual_width = (width - GRID_ITEM_GAP_PX).max(0.0); + let visual_height = (height - GRID_ITEM_GAP_PX).max(0.0); + + view! { +
+ } + }) + } + } Size { } } +/// Snaps a resize dimension in the direction of the pointer movement. +/// +/// Growing targets the next grid cell and shrinking targets the previous one, +/// which makes previews and final sizes follow the resize direction instead of +/// waiting for a half-cell `round()` threshold. +pub fn directional_snap_span(raw_px: f64, current_span: usize, cell_px: f64) -> usize { + let raw_span = raw_px / cell_px; + let current_span = current_span.max(1) as f64; + + let snapped_span = if raw_span > current_span { + raw_span.ceil() + } else if raw_span < current_span { + raw_span.floor() + } else { + current_span + }; + + (snapped_span as usize).max(1) +} + #[derive(Clone, Copy, Debug)] pub enum ResizeState { Idle { @@ -128,6 +148,8 @@ pub fn use_resizable_grid_item( let current_col_start_for_size = Arc::clone(¤t_col_start); let current_col_span_for_layout = Arc::clone(¤t_col_span); let current_row_span_for_layout = Arc::clone(¤t_row_span); + let current_col_span_for_resize = Arc::clone(¤t_col_span); + let current_row_span_for_resize = Arc::clone(¤t_row_span); let _resize_starts = use_event_listener(handle, leptos::ev::pointerdown, move |evt| { evt.prevent_default(); @@ -146,6 +168,8 @@ pub fn use_resizable_grid_item( let on_resize_end = Arc::clone(&on_resize_end); let current_col_start_for_move = Arc::clone(¤t_col_start_for_resize); let current_col_start_for_end = Arc::clone(¤t_col_start_for_resize); + let current_col_span_for_end = Arc::clone(¤t_col_span_for_resize); + let current_row_span_for_end = Arc::clone(¤t_row_span_for_resize); let _resize_in_progress = use_event_listener(window(), leptos::ev::pointermove, move |evt| { @@ -215,16 +239,23 @@ pub fn use_resizable_grid_item( }, ); - let snapped_width = (raw_size.width / cell_size.width).round() * cell_size.width; - let snapped_height = - (raw_size.height / cell_size.height).round() * cell_size.height; + let snapped_col_span = directional_snap_span( + raw_size.width, + current_col_span_for_end(), + cell_size.width, + ); + let snapped_row_span = directional_snap_span( + raw_size.height, + current_row_span_for_end(), + cell_size.height, + ); let snapped_size = clamp_size_to_grid( &layout, current_col_start_for_end(), Size { - width: snapped_width, - height: snapped_height, + width: snapped_col_span as f64 * cell_size.width, + height: snapped_row_span as f64 * cell_size.height, }, ); From 5a680b2c86962cc1f90cca3afc49708602ddc8c7 Mon Sep 17 00:00:00 2001 From: theredfish Date: Sun, 3 May 2026 22:58:50 +0200 Subject: [PATCH 10/12] Move preview calculations into preview models --- .../src/components/grid/core/drop_preview.rs | 23 ++++++++++- .../components/grid/core/resize_preview.rs | 41 ++++++++++++++++++- crates/ui/src/components/grid/grid_item.rs | 33 ++------------- .../components/grid/utils/resizable_item.rs | 33 ++------------- 4 files changed, 69 insertions(+), 61 deletions(-) diff --git a/crates/ui/src/components/grid/core/drop_preview.rs b/crates/ui/src/components/grid/core/drop_preview.rs index 5496066..2d7ee80 100644 --- a/crates/ui/src/components/grid/core/drop_preview.rs +++ b/crates/ui/src/components/grid/core/drop_preview.rs @@ -1,4 +1,5 @@ -use crate::components::grid::core::item::GridPosition; +use crate::components::grid::core::item::{GridItemData, GridPosition}; +use crate::components::grid::core::layout::Layout; use crate::components::grid::core::size::Size; use crate::components::grid::core::span::Span; use leptos_use::core::Position; @@ -28,6 +29,26 @@ impl DropPreview { } } + /// Creates a preview from the current drag pixel position. + /// + /// The drag preview follows the currently hovered grid slot. It does not + /// try to predict whether the final placement will swap, push, or restore. + pub fn from_drag(item: &GridItemData, drag_px_pos: Position, layout: &Layout) -> Self { + let max_col_start = layout.columns.saturating_sub(item.span.col_span); + let col_start = + ((drag_px_pos.x / layout.cell_size.width).round() as usize).min(max_col_start); + let row_start = (drag_px_pos.y / layout.cell_size.height).round() as usize; + + Self::new( + item.id, + GridPosition { + col_start, + row_start, + }, + item.span, + ) + } + /// Converts the snapped grid position to a pixel rectangle. pub fn pixel_rect(self, cell_size: Size) -> (Position, Size) { ( diff --git a/crates/ui/src/components/grid/core/resize_preview.rs b/crates/ui/src/components/grid/core/resize_preview.rs index 8f46339..d283a81 100644 --- a/crates/ui/src/components/grid/core/resize_preview.rs +++ b/crates/ui/src/components/grid/core/resize_preview.rs @@ -1,4 +1,5 @@ -use crate::components::grid::core::item::GridPosition; +use crate::components::grid::core::item::{GridItemData, GridPosition}; +use crate::components::grid::core::layout::Layout; use crate::components::grid::core::size::Size; use crate::components::grid::core::span::Span; use leptos_use::core::Position; @@ -28,6 +29,24 @@ impl ResizePreview { } } + /// Creates a preview from the current resize pixel size. + /// + /// This keeps the preview math outside the component callbacks. The span is + /// snapped in the resize direction, then clamped to the remaining columns. + pub fn from_resize(item: &GridItemData, size: Size, layout: &Layout) -> Self { + let max_col_span = layout + .columns + .saturating_sub(item.grid_pos.col_start) + .max(1); + let col_span = + directional_snap_span(size.width, item.span.col_span, layout.cell_size.width) + .min(max_col_span); + let row_span = + directional_snap_span(size.height, item.span.row_span, layout.cell_size.height); + + Self::new(item.id, item.grid_pos, Span { row_span, col_span }) + } + /// Converts the anchored grid span to a pixel rectangle. pub fn pixel_rect(self, cell_size: Size) -> (Position, Size) { ( @@ -42,3 +61,23 @@ impl ResizePreview { ) } } + +/// Snaps a resize dimension in the direction of the pointer movement. +/// +/// Growing targets the next grid cell and shrinking targets the previous one, +/// which makes previews and final sizes follow the resize direction instead of +/// waiting for a half-cell `round()` threshold. +pub fn directional_snap_span(raw_px: f64, current_span: usize, cell_px: f64) -> usize { + let raw_span = raw_px / cell_px; + let current_span = current_span.max(1) as f64; + + let snapped_span = if raw_span > current_span { + raw_span.ceil() + } else if raw_span < current_span { + raw_span.floor() + } else { + current_span + }; + + (snapped_span as usize).max(1) +} diff --git a/crates/ui/src/components/grid/grid_item.rs b/crates/ui/src/components/grid/grid_item.rs index 99eb936..b698783 100644 --- a/crates/ui/src/components/grid/grid_item.rs +++ b/crates/ui/src/components/grid/grid_item.rs @@ -8,8 +8,7 @@ use crate::components::grid::utils::draggable_item::{ use_draggable_grid_item, UseDraggableGridItemOptions, UseDraggableGridItemReturn, }; use crate::components::grid::utils::resizable_item::{ - directional_snap_span, use_resizable_grid_item, UseResizableGridItemOptions, - UseResizableGridItemReturn, + use_resizable_grid_item, UseResizableGridItemOptions, UseResizableGridItemReturn, }; use crate::components::heroicons::ResizeIcon; use leptos::html::Div; @@ -94,19 +93,7 @@ pub fn GridItem( on_drag_move: Arc::new(move |drag_px_pos| { let layout = layout.get_untracked(); let item = grid_item_data.get_untracked(); - let max_col_start = layout.columns.saturating_sub(item.span.col_span); - let col_start = - ((drag_px_pos.x / layout.cell_size.width).round() as usize).min(max_col_start); - let row_start = (drag_px_pos.y / layout.cell_size.height).round() as usize; - - drop_preview.set(Some(DropPreview::new( - item.id, - GridPosition { - col_start, - row_start, - }, - item.span, - ))); + drop_preview.set(Some(DropPreview::from_drag(&item, drag_px_pos, &layout))); grid_item_data.update(|item| { item.px_pos = drag_px_pos; @@ -145,21 +132,7 @@ pub fn GridItem( on_resize_move: Arc::new(move |size| { let layout = layout.get_untracked(); let item = grid_item_data.get_untracked(); - let max_col_span = layout - .columns - .saturating_sub(item.grid_pos.col_start) - .max(1); - let col_span = - directional_snap_span(size.width, item.span.col_span, layout.cell_size.width) - .min(max_col_span); - let row_span = - directional_snap_span(size.height, item.span.row_span, layout.cell_size.height); - - resize_preview.set(Some(ResizePreview::new( - item.id, - item.grid_pos, - Span { row_span, col_span }, - ))); + resize_preview.set(Some(ResizePreview::from_resize(&item, size, &layout))); grid_item_data.update(|item| { item.size = size; diff --git a/crates/ui/src/components/grid/utils/resizable_item.rs b/crates/ui/src/components/grid/utils/resizable_item.rs index d885c12..54195f0 100644 --- a/crates/ui/src/components/grid/utils/resizable_item.rs +++ b/crates/ui/src/components/grid/utils/resizable_item.rs @@ -5,6 +5,7 @@ use std::default::Default; use std::sync::Arc; use crate::components::grid::core::layout::Layout; +use crate::components::grid::core::resize_preview::directional_snap_span; use crate::components::grid::core::size::Size; fn clamp_size_to_grid(layout: &Layout, col_start: usize, size: Size) -> Size { @@ -18,26 +19,6 @@ fn clamp_size_to_grid(layout: &Layout, col_start: usize, size: Size) -> Size { } } -/// Snaps a resize dimension in the direction of the pointer movement. -/// -/// Growing targets the next grid cell and shrinking targets the previous one, -/// which makes previews and final sizes follow the resize direction instead of -/// waiting for a half-cell `round()` threshold. -pub fn directional_snap_span(raw_px: f64, current_span: usize, cell_px: f64) -> usize { - let raw_span = raw_px / cell_px; - let current_span = current_span.max(1) as f64; - - let snapped_span = if raw_span > current_span { - raw_span.ceil() - } else if raw_span < current_span { - raw_span.floor() - } else { - current_span - }; - - (snapped_span as usize).max(1) -} - #[derive(Clone, Copy, Debug)] pub enum ResizeState { Idle { @@ -221,15 +202,9 @@ pub fn use_resizable_grid_item( let total_offset_x = last_client_pos.0 - start_pos.0; let total_offset_y = last_client_pos.1 - start_pos.1; - // Grid-snapping when resizing ends. - // - // If the last mouse position x is 253, and the resize started at 100px, then we get a movement - // of 153px. To stick the movement to the grid we need to know if we reached the middle of the - // last cell in which case we fill it, otherwise, we go back to the previous cell. - // - // Here the calcul for a grid cell width of 100px is: (153 / 100).round() -> 1.53.round() -> 2 - - // Calculate the raw new size (before snapping) + // Calculate the raw new size first, then snap in the resize + // direction so the final size matches the preview shown during + // pointer movement. let raw_size = clamp_size_to_grid( &layout, current_col_start_for_end(), From 6da22c86c29065465c0d77f5b878e886324235e2 Mon Sep 17 00:00:00 2001 From: theredfish Date: Sun, 3 May 2026 23:12:35 +0200 Subject: [PATCH 11/12] Add grid placement preview tests --- .../components/grid/core/drop_placement.rs | 154 ++++++++++++++++++ .../src/components/grid/core/drop_preview.rs | 73 +++++++++ .../components/grid/core/resize_preview.rs | 80 +++++++++ 3 files changed, 307 insertions(+) diff --git a/crates/ui/src/components/grid/core/drop_placement.rs b/crates/ui/src/components/grid/core/drop_placement.rs index 13ca8e8..536fddf 100644 --- a/crates/ui/src/components/grid/core/drop_placement.rs +++ b/crates/ui/src/components/grid/core/drop_placement.rs @@ -153,3 +153,157 @@ pub fn resolve_collision_drop( } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::components::grid::core::item::GridPosition; + use crate::components::grid::core::span::Span; + + fn item( + id: u32, + col_start: usize, + row_start: usize, + col_span: usize, + row_span: usize, + ) -> GridItemData { + GridItemData { + id, + grid_pos: GridPosition { + col_start, + row_start, + }, + span: Span { col_span, row_span }, + ..GridItemData::default() + } + } + + #[test] + fn drop_collisions_keep_only_actionable_overlaps_sorted_by_area() { + let moved_item = item(1, 0, 0, 4, 4); + let moved_aabb = Aabb::new(0.0, 0.0, 4.0, 4.0); + let candidates = [ + item(3, 3, 3, 4, 4), // 1x1 overlap: below the 35% axis threshold. + item(2, 1, 1, 4, 4), // 3x3 overlap: actionable and dominant. + item(4, 2, 1, 4, 4), // 2x3 overlap: actionable but smaller. + ]; + + let collisions = drop_collisions(&moved_item, moved_aabb, candidates); + + assert_eq!(collisions.len(), 2); + assert_eq!(collisions[0].item_id, 2); + assert_eq!(collisions[1].item_id, 4); + } + + #[test] + fn dominant_drop_collision_returns_none_for_ambiguous_overlaps() { + let moved_item = item(1, 0, 0, 4, 4); + let moved_aabb = Aabb::new(0.0, 0.0, 4.0, 4.0); + let candidates = [ + item(2, 1, 0, 4, 4), // 12 cells of overlap. + item(3, 0, 1, 4, 4), // 12 cells of overlap: no dominant target. + ]; + let collisions = drop_collisions(&moved_item, moved_aabb, candidates); + + assert!(dominant_drop_collision(&collisions).is_none()); + } + + #[test] + fn resolve_collision_drop_restores_when_there_is_no_target() { + let placement = resolve_collision_drop( + GridPosition { + col_start: 0, + row_start: 2, + }, + GridPosition { + col_start: 0, + row_start: 1, + }, + None, + None, + None, + ); + + assert!(matches!(placement, DropPlacement::Restore)); + } + + #[test] + fn resolve_collision_drop_prefers_successful_swap() { + let swap_position = GridPosition { + col_start: 0, + row_start: 2, + }; + let placement = resolve_collision_drop( + GridPosition { + col_start: 0, + row_start: 0, + }, + GridPosition { + col_start: 0, + row_start: 1, + }, + Some(42), + Some(swap_position), + Some(3), + ); + + let DropPlacement::Swap { + target_id, + moved_position, + } = placement + else { + panic!("expected swap placement"); + }; + + assert_eq!(target_id, 42); + assert_eq!(moved_position.col_start, swap_position.col_start); + assert_eq!(moved_position.row_start, swap_position.row_start); + } + + #[test] + fn resolve_collision_drop_restores_same_column_downward_moves_without_swap() { + let placement = resolve_collision_drop( + GridPosition { + col_start: 0, + row_start: 0, + }, + GridPosition { + col_start: 0, + row_start: 2, + }, + Some(42), + None, + Some(2), + ); + + assert!(matches!(placement, DropPlacement::Restore)); + } + + #[test] + fn resolve_collision_drop_inserts_when_moving_up_without_swap() { + let placement = resolve_collision_drop( + GridPosition { + col_start: 0, + row_start: 4, + }, + GridPosition { + col_start: 0, + row_start: 2, + }, + Some(42), + None, + Some(2), + ); + + let DropPlacement::InsertWithPush { + target_id, + row_start, + } = placement + else { + panic!("expected insert placement"); + }; + + assert_eq!(target_id, 42); + assert_eq!(row_start, Some(2)); + } +} diff --git a/crates/ui/src/components/grid/core/drop_preview.rs b/crates/ui/src/components/grid/core/drop_preview.rs index 2d7ee80..976055f 100644 --- a/crates/ui/src/components/grid/core/drop_preview.rs +++ b/crates/ui/src/components/grid/core/drop_preview.rs @@ -63,3 +63,76 @@ impl DropPreview { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::components::grid::core::layout::LayoutBuilder; + + fn layout() -> Layout { + LayoutBuilder::default() + .columns(4) + .rows(4) + .cell_size(100.0, 50.0) + .build() + } + + fn item() -> GridItemData { + GridItemData { + id: 7, + grid_pos: GridPosition { + col_start: 0, + row_start: 0, + }, + span: Span { + col_span: 2, + row_span: 1, + }, + ..GridItemData::default() + } + } + + #[test] + fn from_drag_follows_the_hovered_rounded_grid_slot() { + let preview = DropPreview::from_drag(&item(), Position { x: 149.0, y: 76.0 }, &layout()); + + assert_eq!(preview.item_id, 7); + assert_eq!(preview.grid_pos.col_start, 1); + assert_eq!(preview.grid_pos.row_start, 2); + assert_eq!(preview.span.col_span, 2); + assert_eq!(preview.span.row_span, 1); + } + + #[test] + fn from_drag_clamps_the_preview_inside_available_columns() { + let preview = DropPreview::from_drag(&item(), Position { x: 999.0, y: 0.0 }, &layout()); + + assert_eq!(preview.grid_pos.col_start, 2); + assert_eq!(preview.grid_pos.row_start, 0); + } + + #[test] + fn pixel_rect_converts_grid_preview_back_to_pixels() { + let preview = DropPreview::new( + 7, + GridPosition { + col_start: 2, + row_start: 3, + }, + Span { + col_span: 2, + row_span: 4, + }, + ); + + let (position, size) = preview.pixel_rect(Size { + width: 100.0, + height: 50.0, + }); + + assert_eq!(position.x, 200.0); + assert_eq!(position.y, 150.0); + assert_eq!(size.width, 200.0); + assert_eq!(size.height, 200.0); + } +} diff --git a/crates/ui/src/components/grid/core/resize_preview.rs b/crates/ui/src/components/grid/core/resize_preview.rs index d283a81..31e321e 100644 --- a/crates/ui/src/components/grid/core/resize_preview.rs +++ b/crates/ui/src/components/grid/core/resize_preview.rs @@ -81,3 +81,83 @@ pub fn directional_snap_span(raw_px: f64, current_span: usize, cell_px: f64) -> (snapped_span as usize).max(1) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::components::grid::core::layout::LayoutBuilder; + + fn layout() -> Layout { + LayoutBuilder::default() + .columns(4) + .rows(4) + .cell_size(100.0, 50.0) + .build() + } + + fn item() -> GridItemData { + GridItemData { + id: 11, + grid_pos: GridPosition { + col_start: 1, + row_start: 2, + }, + span: Span { + col_span: 2, + row_span: 3, + }, + ..GridItemData::default() + } + } + + #[test] + fn directional_snap_span_announces_growth_and_shrink_in_pointer_direction() { + assert_eq!(directional_snap_span(201.0, 2, 100.0), 3); + assert_eq!(directional_snap_span(199.0, 2, 100.0), 1); + assert_eq!(directional_snap_span(200.0, 2, 100.0), 2); + assert_eq!(directional_snap_span(1.0, 2, 100.0), 1); + } + + #[test] + fn from_resize_anchors_position_and_clamps_to_remaining_columns() { + let preview = ResizePreview::from_resize( + &item(), + Size { + width: 999.0, + height: 151.0, + }, + &layout(), + ); + + assert_eq!(preview.item_id, 11); + assert_eq!(preview.grid_pos.col_start, 1); + assert_eq!(preview.grid_pos.row_start, 2); + assert_eq!(preview.span.col_span, 3); + assert_eq!(preview.span.row_span, 4); + } + + #[test] + fn pixel_rect_converts_resize_preview_back_to_pixels() { + let preview = ResizePreview::new( + 11, + GridPosition { + col_start: 1, + row_start: 2, + }, + Span { + col_span: 3, + row_span: 4, + }, + ); + + let (position, size) = preview.pixel_rect(Size { + width: 100.0, + height: 50.0, + }); + + assert_eq!(position.x, 100.0); + assert_eq!(position.y, 100.0); + assert_eq!(size.width, 300.0); + assert_eq!(size.height, 200.0); + } +} From 84c31ffa799d376e5d6d0e618f7c37f928f249f9 Mon Sep 17 00:00:00 2001 From: theredfish Date: Sun, 3 May 2026 23:21:46 +0200 Subject: [PATCH 12/12] Fix wording and pattern matching lint --- crates/ui/src/app.rs | 2 +- crates/ui/src/components/grid/core/layout.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/ui/src/app.rs b/crates/ui/src/app.rs index 80623d3..d31e504 100644 --- a/crates/ui/src/app.rs +++ b/crates/ui/src/app.rs @@ -52,7 +52,7 @@ pub fn Home() -> impl IntoView { view! {

- "Centralisez les mesures, les alertes et les automatisations de votre station." + "Environmental monitoring and regulation system."

} diff --git a/crates/ui/src/components/grid/core/layout.rs b/crates/ui/src/components/grid/core/layout.rs index 2481007..65549ee 100644 --- a/crates/ui/src/components/grid/core/layout.rs +++ b/crates/ui/src/components/grid/core/layout.rs @@ -85,9 +85,7 @@ impl Layout { } let colliding_id = colliding_ids[0]; - let Some(&colliding_item_signal) = self.items.get(&colliding_id) else { - return None; - }; + let colliding_item_signal = *self.items.get(&colliding_id)?; let mut colliding_item = colliding_item_signal.get_untracked(); if old_position.col_start != moved_item.grid_pos.col_start {