1pub 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#[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
36pub 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(default)]
53pub struct LayoutItem {
54 pub id: String,
56 pub x: i32,
58 pub y: i32,
60 pub w: i32,
62 pub h: i32,
64 pub min_w: Option<i32>,
66 pub max_w: Option<i32>,
68 pub min_h: Option<i32>,
70 pub max_h: Option<i32>,
72 pub aspect_ratio: Option<f32>,
74 pub is_static: bool,
76 pub is_draggable: bool,
78 pub is_resizable: bool,
80 pub resize_handles: HashSet<ResizeHandle>,
82}
83
84impl LayoutItem {
85 #[inline]
87 pub fn can_drag(&self) -> bool {
88 !self.is_static && self.is_draggable
89 }
90
91 #[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
121pub trait Compactor {
124 fn compact(&self, layout: &mut Vec<LayoutItem>, cols: i32);
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
130pub enum CompactionType {
131 #[default]
133 Gravity,
134 FreePlacement,
136}
137
138pub struct RisingTideCompactor;
140
141impl Compactor for RisingTideCompactor {
142 fn compact(&self, layout: &mut Vec<LayoutItem>, cols: i32) {
143 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 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 item.y = max_y;
163
164 let new_y_plus_h = item.y + item.h;
166 waterline[start_col..end_col].fill(new_y_plus_h);
167 } else {
168 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 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 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
205pub struct LayoutEngine {
207 pub compactor: Box<dyn Compactor>,
209 pub collision: Box<dyn CollisionResolver>,
211 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 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
302pub 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
356pub 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
364pub 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}