1pub 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#[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
34pub 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(default)]
51pub struct LayoutItem {
52 pub id: String,
54 pub x: i32,
56 pub y: i32,
58 pub w: i32,
60 pub h: i32,
62 pub min_w: Option<i32>,
64 pub max_w: Option<i32>,
66 pub min_h: Option<i32>,
68 pub max_h: Option<i32>,
70 pub aspect_ratio: Option<f32>,
72 pub is_static: bool,
74 pub is_draggable: bool,
76 pub is_resizable: bool,
78 pub resize_handles: HashSet<ResizeHandle>,
80}
81
82impl LayoutItem {
83 #[inline]
85 pub fn can_drag(&self) -> bool {
86 !self.is_static && self.is_draggable
87 }
88
89 #[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
119pub trait Compactor {
122 fn compact(&self, layout: &mut Vec<LayoutItem>, cols: i32);
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
128pub enum CompactionType {
129 #[default]
131 Gravity,
132 FreePlacement,
134}
135
136pub struct RisingTideCompactor;
138
139impl Compactor for RisingTideCompactor {
140 fn compact(&self, layout: &mut Vec<LayoutItem>, cols: i32) {
141 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 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 item.y = max_y;
161
162 let new_y_plus_h = item.y + item.h;
164 waterline[start_col..end_col].fill(new_y_plus_h);
165 } else {
166 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 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 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
203pub struct LayoutEngine {
205 pub compactor: Box<dyn Compactor>,
207 pub collision: Box<dyn CollisionResolver>,
209 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 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
300pub 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
354pub 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
362pub 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}