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/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..91cae97 --- /dev/null +++ b/crates/ui/src/components/grid/core/collision/item_aabb.rs @@ -0,0 +1,30 @@ +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; + +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)) +} 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/drop_placement.rs b/crates/ui/src/components/grid/core/drop_placement.rs new file mode 100644 index 0000000..536fddf --- /dev/null +++ b/crates/ui/src/components/grid/core/drop_placement.rs @@ -0,0 +1,309 @@ +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 +/// 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, + }, +} + +/// 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 +/// 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, + } + } +} + +#[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 new file mode 100644 index 0000000..976055f --- /dev/null +++ b/crates/ui/src/components/grid/core/drop_preview.rs @@ -0,0 +1,138 @@ +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; + +/// 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, + } + } + + /// 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) { + ( + 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, + }, + ) + } +} + +#[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/layout.rs b/crates/ui/src/components/grid/core/layout.rs index 98d9349..65549ee 100644 --- a/crates/ui/src/components/grid/core/layout.rs +++ b/crates/ui/src/components/grid/core/layout.rs @@ -1,3 +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}, @@ -7,11 +9,13 @@ use leptos::{ logging::log, prelude::{GetUntracked, RwSignal, Set, Update}, }; +use leptos_use::core::Position; use ndarray::{concatenate, Array2, Axis}; -use std::collections::HashMap; +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>, @@ -26,106 +30,349 @@ pub struct Layout { } impl Layout { - /// 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); - } - } + /// 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; } + + 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; } - /// 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; + /// 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); - 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; + GridPosition { + row_start: row, + col_start: col.min(max_col), + } + } - 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; - } + /// 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, + old_position: GridPosition, + colliding_ids: &[u32], + ) -> Option { + if colliding_ids.len() != 1 { + return None; + } + + let colliding_id = colliding_ids[0]; + 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 { + 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 item_aabb::items_overlap(&moved_swap_item, &colliding_item) { + return None; + } + + 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; + } + + 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); + collision_grid::set_item(&mut self.collision_grid, &colliding_item); + + 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, + 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, + ) + }) + .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 } + }) + } + + /// 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, + 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) + }); + + for (_, item_signal, mut item) in items { + 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 !collision_grid::item_fits_ignoring(&self.collision_grid, &candidate, &[item.id]) + { + break; + } + + item = candidate; } + + item.grid_to_pixels(self.cell_size, Axes::XY); + item_signal.set(item); + collision_grid::set_item(&mut self.collision_grid, &item); } } - /// Update an item's position in the collision grid (clear old, set new) + /// Collects items affected by a vertical push in the scanned columns. /// - /// 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); - } + /// 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, + 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; + } - /// 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); - } + 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_ids.into_iter().collect() + 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)) + }); + 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; + } + + 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::>(); + + for (_, _, item) in &items_to_shift { + collision_grid::clear_item(&mut self.collision_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); + collision_grid::set_item(&mut self.collision_grid, &item); + } + } + + /// 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, + mut row_start: usize, + mut by_rows: usize, + ) { + self.ensure_rows(item.grid_pos.row_start + item.span.row_span); + + 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, + 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; + } + + 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 { + collision_grid::item_ids_for_item(&self.collision_grid, 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) @@ -145,27 +392,24 @@ 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 - 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); + collision_grid::set_item(&mut self.collision_grid, &untracked_item); // 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) @@ -185,7 +429,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 +451,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,110 +492,209 @@ 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, 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; - - // If position hasn't changed, nothing to do - if old_position.row_start == new_row_start && old_position.col_start == new_col_start { + let new_position = + self.clamp_position_for_item(&untracked_item, new_row_start, new_col_start); + + // 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; } // 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 = 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; - } - - // 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); - } - } + self.ensure_rows(untracked_item.grid_pos.row_start + untracked_item.span.row_span); + + 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 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 = + 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]) + }); + 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; + } + DropPlacement::Swap { moved_position, .. } => { + untracked_item.grid_pos = moved_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; + } + 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; } } } - // 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 - - for colliding_item_signal in &colliding_items { - let colliding_item = colliding_item_signal.get_untracked(); - - // Clear the colliding item from its old position - self.clear_item_from_grid(&colliding_item); - - // Calculate new position (push below the moved item) - let colliding_new_row = push_to_row + 1; - - // Update the colliding item's position - colliding_item_signal.update(|item| { - item.grid_pos.row_start = colliding_new_row; - }); - } + // 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, + ); + } + 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, + ); + } - // Ensure grid has enough capacity after pushing items - self.ensure_grid_capacity(); + /// 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, + 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, + }; - // 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); - } + 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 = + 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; + 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) + }); + let (row_start, 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)) + }; + self.set_item_after_local_push(&untracked_item, row_start, by_rows); + placed_in_grid = true; } - // 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); + 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 + /// 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 @@ -377,7 +719,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,64 +732,109 @@ 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. } + /// 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(); // 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); } + /// 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) { - 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 !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); + } + + 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); + collision_grid::set_item(&mut self.collision_grid, &item); } } } #[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::); diff --git a/crates/ui/src/components/grid/core/mod.rs b/crates/ui/src/components/grid/core/mod.rs index d79083a..598b40b 100644 --- a/crates/ui/src/components/grid/core/mod.rs +++ b/crates/ui/src/components/grid/core/mod.rs @@ -1,4 +1,8 @@ +pub mod collision; +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..31e321e --- /dev/null +++ b/crates/ui/src/components/grid/core/resize_preview.rs @@ -0,0 +1,163 @@ +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; + +/// 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, + } + } + + /// 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) { + ( + 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, + }, + ) + } +} + +/// 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) +} + +#[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); + } +} diff --git a/crates/ui/src/components/grid/grid_item.rs b/crates/ui/src/components/grid/grid_item.rs index d1056fb..b698783 100644 --- a/crates/ui/src/components/grid/grid_item.rs +++ b/crates/ui/src/components/grid/grid_item.rs @@ -1,5 +1,7 @@ +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::{ @@ -13,11 +15,10 @@ 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; -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( @@ -33,8 +34,11 @@ 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 resize_preview = use_context::>>() + .expect("Resize preview context must be provided"); 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(); @@ -87,24 +91,19 @@ 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(); + drop_preview.set(Some(DropPreview::from_drag(&item, drag_px_pos, &layout))); + 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| { - 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, drag_px_pos| { + drop_preview.set(None); 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, drag_px_pos); }); }), ..Default::default() @@ -118,16 +117,10 @@ 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); - // 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), @@ -137,18 +130,22 @@ 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(); + resize_preview.set(Some(ResizePreview::from_resize(&item, size, &layout))); + grid_item_data.update(|item| { item.size = size; }); }), 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); + resize_preview.set(None); + 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 +156,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(); @@ -203,6 +167,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}"); @@ -212,7 +177,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..edbcd27 100644 --- a/crates/ui/src/components/grid/grid_layout.rs +++ b/crates/ui/src/components/grid/grid_layout.rs @@ -1,10 +1,13 @@ +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; +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,11 +24,15 @@ pub fn GridLayout(children: Children, columns: usize, display_grid: bool) -> imp .cell_size(100., 100.) .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(1u32); + let next_id = RwSignal::new(10_000u32); let grid_items = RwSignal::new(Vec::::new()); // Handler to add new items @@ -114,7 +121,53 @@ pub fn GridLayout(children: Children, columns: usize, display_grid: bool) -> imp } } } - // {children()} + {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! { +
+ } + }) + } + } + { + 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! { +
+ } + }) + } + } 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..470aca4 100644 --- a/crates/ui/src/components/grid/utils/draggable_item.rs +++ b/crates/ui/src/components/grid/utils/draggable_item.rs @@ -2,10 +2,22 @@ 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; + +// 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), @@ -28,7 +40,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 +53,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 +64,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 +74,10 @@ 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 drag_pointer_start = RwSignal::new(None::<(i32, i32)>); + let drag_threshold_reached = RwSignal::new(false); let UseDraggableGridItemOptions { handle, col_start, @@ -102,7 +120,31 @@ 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(); 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 +153,23 @@ 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| { + 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(); + 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 +193,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 +231,6 @@ pub fn use_draggable_grid_item( UseDraggableGridItemReturn { transition, position, + is_dragging: is_dragging.into(), } } diff --git a/crates/ui/src/components/grid/utils/resizable_item.rs b/crates/ui/src/components/grid/utils/resizable_item.rs index 6abbafe..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 { @@ -128,6 +129,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 +149,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| { @@ -197,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(), @@ -215,16 +214,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, }, );