Skip to main content

hadrone_core/
lib.rs

1//! # Grid Layout Core
2//!
3//! The spatial engine for a cross-framework grid layout system.
4//! This crate contains the deterministic math for grid compaction, collision detection,
5//! and interaction sessions. It is designed to be headless and framework-agnostic.
6
7pub mod collision;
8pub mod events;
9pub mod interaction;
10pub mod responsive;
11pub mod validate;
12
13pub use collision::{CollisionResolver, CollisionStrategy, NoopCollisionResolver, PushDownResolver};
14pub use events::{InteractionPhase, LayoutEvent};
15pub use responsive::{BreakpointSpec, scale_layout_cols, select_breakpoint};
16pub use validate::{LayoutIssue, repair_layout, validate_layout};
17
18use serde::{Deserialize, Serialize};
19use std::collections::HashSet;
20
21/// Represents a handle for resizing a grid item.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
23pub enum ResizeHandle {
24    North,
25    South,
26    East,
27    West,
28    NorthEast,
29    NorthWest,
30    SouthEast,
31    SouthWest,
32}
33
34/// Human-readable label for assistive tech (e.g. `aria-label` on resize controls).
35pub fn resize_handle_aria_label(handle: ResizeHandle) -> &'static str {
36    match handle {
37        ResizeHandle::North => "Resize top edge",
38        ResizeHandle::South => "Resize bottom edge",
39        ResizeHandle::East => "Resize right edge",
40        ResizeHandle::West => "Resize left edge",
41        ResizeHandle::NorthEast => "Resize top-right corner",
42        ResizeHandle::NorthWest => "Resize top-left corner",
43        ResizeHandle::SouthEast => "Resize bottom-right corner",
44        ResizeHandle::SouthWest => "Resize bottom-left corner",
45    }
46}
47
48/// A single item within the grid layout.
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(default)]
51pub struct LayoutItem {
52    /// Unique identifier for the item.
53    pub id: String,
54    /// X coordinate in grid units.
55    pub x: i32,
56    /// Y coordinate in grid units.
57    pub y: i32,
58    /// Width in grid units.
59    pub w: i32,
60    /// Height in grid units.
61    pub h: i32,
62    /// Minimum allowed width.
63    pub min_w: Option<i32>,
64    /// Maximum allowed width.
65    pub max_w: Option<i32>,
66    /// Minimum allowed height.
67    pub min_h: Option<i32>,
68    /// Maximum allowed height.
69    pub max_h: Option<i32>,
70    /// Optional fixed aspect ratio **width / height** in grid units.
71    pub aspect_ratio: Option<f32>,
72    /// If true, the item cannot be dragged or resized and is fixed for compaction.
73    pub is_static: bool,
74    /// When `is_static` is false: allow dragging (subject to compaction).
75    pub is_draggable: bool,
76    /// When `is_static` is false: allow resizing.
77    pub is_resizable: bool,
78    /// Enabled resize handles for this item.
79    pub resize_handles: HashSet<ResizeHandle>,
80}
81
82impl LayoutItem {
83    /// User-driven drag allowed (keyboard / pointer).
84    #[inline]
85    pub fn can_drag(&self) -> bool {
86        !self.is_static && self.is_draggable
87    }
88
89    /// User-driven resize allowed.
90    #[inline]
91    pub fn can_resize(&self) -> bool {
92        !self.is_static && self.is_resizable
93    }
94}
95
96impl Default for LayoutItem {
97    fn default() -> Self {
98        let mut handles = HashSet::new();
99        handles.insert(ResizeHandle::SouthEast);
100        Self {
101            id: "".into(),
102            x: 0,
103            y: 0,
104            w: 1,
105            h: 1,
106            min_w: None,
107            max_w: None,
108            min_h: None,
109            max_h: None,
110            aspect_ratio: None,
111            is_static: false,
112            is_draggable: true,
113            is_resizable: true,
114            resize_handles: handles,
115        }
116    }
117}
118
119/// Trait for grid compaction strategies.
120/// Compaction is the process of resolving overlaps and settling items into a stable layout.
121pub trait Compactor {
122    /// Compacts the layout by resolving collisions and moving items.
123    fn compact(&self, layout: &mut Vec<LayoutItem>, cols: i32);
124}
125
126/// Supported compaction strategies.
127#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
128pub enum CompactionType {
129    /// Items settle towards the top of the grid (Y=0).
130    #[default]
131    Gravity,
132    /// Items stay at their requested positions unless they collide.
133    FreePlacement,
134}
135
136/// O(N * Cols) implementation using the "waterline" approach
137pub struct RisingTideCompactor;
138
139impl Compactor for RisingTideCompactor {
140    fn compact(&self, layout: &mut Vec<LayoutItem>, cols: i32) {
141        // Sort items by Y, then X
142        layout.sort_by(|a, b| a.y.cmp(&b.y).then(a.x.cmp(&b.x)));
143
144        let mut waterline = vec![0; cols as usize];
145        let mut new_layout = Vec::with_capacity(layout.len());
146
147        for mut item in layout.drain(..) {
148            if !item.is_static {
149                // Find max waterline in the range [x, x+w)
150                let start_col = item.x.max(0) as usize;
151                let end_col = (item.x + item.w).min(cols) as usize;
152
153                let max_y = waterline[start_col..end_col]
154                    .iter()
155                    .max()
156                    .copied()
157                    .unwrap_or(0);
158
159                // Set Y to the max waterline found
160                item.y = max_y;
161
162                // Update waterline for the range
163                let new_y_plus_h = item.y + item.h;
164                waterline[start_col..end_col].fill(new_y_plus_h);
165            } else {
166                // Static items update the waterline at their fixed position
167                let start_col = item.x.max(0) as usize;
168                let end_col = (item.x + item.w).min(cols) as usize;
169                let new_y_plus_h = item.y + item.h;
170                for val in &mut waterline[start_col..end_col] {
171                    *val = (*val).max(new_y_plus_h);
172                }
173            }
174            new_layout.push(item);
175        }
176
177        *layout = new_layout;
178    }
179}
180
181pub struct FreePlacementCompactor;
182
183impl Compactor for FreePlacementCompactor {
184    fn compact(&self, layout: &mut Vec<LayoutItem>, _cols: i32) {
185        // Sort items by Y, then X
186        layout.sort_by(|a, b| a.y.cmp(&b.y).then(a.x.cmp(&b.x)));
187
188        let mut processed: Vec<LayoutItem> = Vec::with_capacity(layout.len());
189
190        for mut item in layout.drain(..) {
191            // Keep bumping y if there's a collision with any already processed item
192            // This preserves the item's requested Y as much as possible without overlaps
193            while processed.iter().any(|other| collides(&item, other)) {
194                item.y += 1;
195            }
196            processed.push(item);
197        }
198
199        *layout = processed;
200    }
201}
202
203/// The main orchestration point for grid operations.
204pub struct LayoutEngine {
205    /// The compaction strategy to use.
206    pub compactor: Box<dyn Compactor>,
207    /// Displacement policy after overlaps.
208    pub collision: Box<dyn CollisionResolver>,
209    /// The number of columns in the grid.
210    pub cols: i32,
211}
212
213impl LayoutEngine {
214    pub fn new(
215        compactor: Box<dyn Compactor>,
216        collision: Box<dyn CollisionResolver>,
217        cols: i32,
218    ) -> Self {
219        Self {
220            compactor,
221            collision,
222            cols,
223        }
224    }
225
226    /// Gravity compaction with push-down collision resolution.
227    pub fn with_default_collision(compactor: Box<dyn Compactor>, cols: i32) -> Self {
228        Self::new(compactor, CollisionStrategy::PushDown.build(), cols)
229    }
230
231    pub fn compact(&self, layout: &mut Vec<LayoutItem>) {
232        self.compactor.compact(layout, self.cols);
233    }
234
235    pub fn move_element(&self, layout: &mut Vec<LayoutItem>, id: &str, x: i32, y: i32) {
236        if let Some(index) = layout.iter().position(|i| i.id == id) {
237            let mut item = layout[index].clone();
238            if !item.can_drag() {
239                return;
240            }
241
242            item.x = x.max(0).min(self.cols - item.w);
243            item.y = y.max(0);
244
245            layout[index] = item;
246            self.collision.resolve_collisions(layout, id);
247            self.compact(layout);
248        }
249    }
250
251    #[allow(clippy::too_many_arguments)]
252    pub fn resize_element(
253        &self,
254        layout: &mut Vec<LayoutItem>,
255        id: &str,
256        x: i32,
257        y: i32,
258        w: i32,
259        h: i32,
260        handle: Option<ResizeHandle>,
261    ) {
262        if let Some(index) = layout.iter().position(|i| i.id == id) {
263            let mut item = layout[index].clone();
264            if !item.can_resize() {
265                return;
266            }
267
268            let mut final_w = w;
269            let mut final_h = h;
270            if let Some(min) = item.min_w {
271                final_w = final_w.max(min);
272            }
273            if let Some(max) = item.max_w {
274                final_w = final_w.min(max);
275            }
276            if let Some(min) = item.min_h {
277                final_h = final_h.max(min);
278            }
279            if let Some(max) = item.max_h {
280                final_h = final_h.min(max);
281            }
282
283            let final_x = x.max(0).min(self.cols - final_w);
284            let final_y = y.max(0);
285
286            item.x = final_x;
287            item.y = final_y;
288            item.w = final_w.max(1);
289            item.h = final_h.max(1);
290
291            apply_aspect_and_clamp(&mut item, self.cols, handle);
292
293            layout[index] = item;
294            self.collision.resolve_collisions(layout, id);
295            self.compact(layout);
296        }
297    }
298}
299
300/// Applies `aspect_ratio` (w/h) plus min/max; re-clamps `x`/`w` to `cols`.
301pub fn apply_aspect_and_clamp(item: &mut LayoutItem, cols: i32, handle: Option<ResizeHandle>) {
302    let mut w = item.w.max(1);
303    let mut h = item.h.max(1);
304
305    if let Some(ar) = item.aspect_ratio.filter(|a| a.is_finite() && *a > 0.0) {
306        let prefer_width = handle.map(aspect_prefers_width).unwrap_or(true);
307        for _ in 0..6 {
308            if prefer_width {
309                h = (w as f32 / ar).round() as i32;
310            } else {
311                w = (h as f32 * ar).round() as i32;
312            }
313            h = h.max(1);
314            w = w.max(1);
315            if let Some(min) = item.min_h {
316                h = h.max(min);
317            }
318            if let Some(max) = item.max_h {
319                h = h.min(max);
320            }
321            if let Some(min) = item.min_w {
322                w = w.max(min);
323            }
324            if let Some(max) = item.max_w {
325                w = w.min(max);
326            }
327            if prefer_width {
328                w = (h as f32 * ar).round() as i32;
329            } else {
330                h = (w as f32 / ar).round() as i32;
331            }
332            w = w.max(1);
333            h = h.max(1);
334        }
335    }
336
337    item.w = w.min(cols).max(1);
338    item.h = h.max(1);
339    item.x = item.x.max(0).min((cols - item.w).max(0));
340    item.y = item.y.max(0);
341}
342
343fn aspect_prefers_width(handle: ResizeHandle) -> bool {
344    match handle {
345        ResizeHandle::East
346        | ResizeHandle::West
347        | ResizeHandle::NorthEast
348        | ResizeHandle::SouthEast => true,
349        ResizeHandle::North | ResizeHandle::South => false,
350        ResizeHandle::NorthWest | ResizeHandle::SouthWest => false,
351    }
352}
353
354/// Checks if two layout items overlap.
355pub fn collides(a: &LayoutItem, b: &LayoutItem) -> bool {
356    if a.id == b.id {
357        return false;
358    }
359    !(a.x + a.w <= b.x || a.x >= b.x + b.w || a.y + a.h <= b.y || a.y >= b.y + b.h)
360}
361
362/// Build an engine from compaction + collision strategy (common in interaction code).
363pub fn layout_engine(
364    compaction: CompactionType,
365    collision: CollisionStrategy,
366    cols: i32,
367) -> LayoutEngine {
368    let compactor: Box<dyn Compactor> = match compaction {
369        CompactionType::Gravity => Box::new(RisingTideCompactor),
370        CompactionType::FreePlacement => Box::new(FreePlacementCompactor),
371    };
372    LayoutEngine::new(compactor, collision.build(), cols)
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::interaction::{InteractionSession, InteractionType};
379    use crate::validate::LayoutIssue;
380    use std::collections::HashSet;
381
382    fn item(id: &str, x: i32, y: i32, w: i32, h: i32) -> LayoutItem {
383        LayoutItem {
384            id: id.into(),
385            x,
386            y,
387            w,
388            h,
389            ..Default::default()
390        }
391    }
392
393    #[test]
394    fn collides_false_for_same_id() {
395        let a = item("a", 0, 0, 2, 2);
396        assert!(!collides(&a, &a));
397    }
398
399    #[test]
400    fn collides_true_when_overlapping() {
401        let a = item("a", 0, 0, 2, 2);
402        let b = item("b", 1, 1, 2, 2);
403        assert!(collides(&a, &b));
404    }
405
406    #[test]
407    fn collides_false_when_adjacent() {
408        let a = item("a", 0, 0, 2, 2);
409        let b = item("b", 2, 0, 2, 2);
410        assert!(!collides(&a, &b));
411    }
412
413    #[test]
414    fn rising_tide_stacks_vertically() {
415        let mut layout = vec![item("a", 0, 5, 4, 2), item("b", 0, 0, 4, 2)];
416        RisingTideCompactor.compact(&mut layout, 12);
417        let a = layout.iter().find(|i| i.id == "a").unwrap();
418        let b = layout.iter().find(|i| i.id == "b").unwrap();
419        assert_eq!(b.y, 0);
420        assert_eq!(a.y, 2);
421        assert!(!collides(a, b));
422    }
423
424    #[test]
425    fn free_placement_pushes_second_item_down() {
426        let mut layout = vec![item("a", 0, 0, 4, 4), item("b", 1, 1, 2, 2)];
427        FreePlacementCompactor.compact(&mut layout, 12);
428        let b = layout.iter().find(|i| i.id == "b").unwrap();
429        assert_eq!(b.y, 4);
430    }
431
432    #[test]
433    fn move_element_clamps_to_grid_width() {
434        let engine = LayoutEngine::with_default_collision(Box::new(RisingTideCompactor), 6);
435        let mut layout = vec![item("w", 0, 0, 4, 1)];
436        engine.move_element(&mut layout, "w", 10, 0);
437        let w = layout.iter().find(|i| i.id == "w").unwrap();
438        assert_eq!(w.x, 2);
439    }
440
441    #[test]
442    fn static_item_does_not_move_in_compactor() {
443        let mut layout = vec![LayoutItem {
444            id: "s".into(),
445            x: 0,
446            y: 3,
447            w: 2,
448            h: 1,
449            is_static: true,
450            ..Default::default()
451        }];
452        RisingTideCompactor.compact(&mut layout, 12);
453        assert_eq!(layout[0].y, 3);
454    }
455
456    #[test]
457    fn resize_element_applies_min_width() {
458        let engine = LayoutEngine::with_default_collision(Box::new(RisingTideCompactor), 12);
459        let mut handles = HashSet::new();
460        handles.insert(ResizeHandle::SouthEast);
461        let mut layout = vec![LayoutItem {
462            id: "x".into(),
463            x: 0,
464            y: 0,
465            w: 4,
466            h: 2,
467            min_w: Some(3),
468            resize_handles: handles,
469            ..Default::default()
470        }];
471        engine.resize_element(&mut layout, "x", 0, 0, 1, 2, Some(ResizeHandle::East));
472        let x = layout.iter().find(|i| i.id == "x").unwrap();
473        assert_eq!(x.w, 3);
474    }
475
476    #[test]
477    fn interaction_drag_updates_position() {
478        let mut handles = HashSet::new();
479        handles.insert(ResizeHandle::SouthEast);
480        let mut layout = vec![LayoutItem {
481            id: "d".into(),
482            x: 0,
483            y: 0,
484            w: 2,
485            h: 2,
486            resize_handles: handles,
487            ..Default::default()
488        }];
489        let session = InteractionSession {
490            id: "d".into(),
491            interaction_type: InteractionType::Drag,
492            start_mouse: (0.0, 0.0),
493            start_rect: (0, 0, 2, 2),
494            handle: ResizeHandle::SouthEast,
495            col_width_px: 100.0,
496            row_height_px: 50.0,
497            margin: (0, 10),
498            container_padding: (0, 0),
499            compaction: CompactionType::Gravity,
500            collision: CollisionStrategy::PushDown,
501        };
502        session.update((200.0, 0.0), &mut layout, 12);
503        let d = layout.iter().find(|i| i.id == "d").unwrap();
504        assert_eq!(d.x, 2);
505        assert_eq!(d.y, 0);
506    }
507
508    #[test]
509    fn scale_layout_cols_halves_positions() {
510        let items = vec![item("a", 4, 1, 4, 2)];
511        let out = scale_layout_cols(&items, 12, 6);
512        let a = out.iter().find(|i| i.id == "a").unwrap();
513        assert_eq!(a.x, 2);
514        assert_eq!(a.w, 2);
515    }
516
517    #[test]
518    fn aspect_ratio_enforced_on_resize() {
519        let engine = LayoutEngine::with_default_collision(Box::new(RisingTideCompactor), 12);
520        let mut layout = vec![LayoutItem {
521            id: "ar".into(),
522            x: 0,
523            y: 0,
524            w: 4,
525            h: 2,
526            aspect_ratio: Some(2.0),
527            ..Default::default()
528        }];
529        engine.resize_element(&mut layout, "ar", 0, 0, 2, 2, Some(ResizeHandle::East));
530        let it = layout.iter().find(|i| i.id == "ar").unwrap();
531        assert_eq!(it.w, 2);
532        assert_eq!(it.h, 1);
533    }
534
535    #[test]
536    fn validate_layout_detects_duplicate_ids() {
537        let layout = vec![
538            item("dup", 0, 0, 2, 2),
539            LayoutItem {
540                id: "dup".into(),
541                x: 2,
542                y: 0,
543                w: 2,
544                h: 2,
545                ..Default::default()
546            },
547        ];
548        let err = validate_layout(&layout, 12).unwrap_err();
549        assert!(err.iter().any(|e| matches!(e, LayoutIssue::DuplicateId { .. })));
550    }
551}