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