Skip to main content

boxmux_lib/components/dimensions/
layout_dimensions.rs

1use crate::model::common::Anchor;
2use crate::{Bounds, InputBounds};
3
4/// LayoutDimensions - Centralizes ALL layout and positioning mathematical operations
5///
6/// Eliminates ad hoc layout math like:
7/// - percentage parsing and normalization
8/// - bounds calculations from InputBounds
9/// - percentage.clamp(0.0, 100.0) / 100.0
10/// - (normalized * (total - 1) as f64).round() as usize
11/// - Anchor resolution and positioning
12///
13/// Replaces scattered layout logic from utils.rs, model/common.rs, and layout calculation code.
14#[derive(Debug, Clone)]
15pub struct LayoutDimensions {
16    /// Total available space for layout
17    total_bounds: Bounds,
18    /// Child constraints and positioning
19    child_constraints: Vec<InputBounds>,
20    /// Layout strategy (how children are arranged)
21    layout_strategy: LayoutStrategy,
22}
23
24impl LayoutDimensions {
25    /// Create new layout dimensions
26    pub fn new(total_bounds: Bounds) -> Self {
27        Self {
28            total_bounds,
29            child_constraints: Vec::new(),
30            layout_strategy: LayoutStrategy::Absolute,
31        }
32    }
33
34    /// Add child constraint
35    pub fn add_child(&mut self, input_bounds: InputBounds) {
36        self.child_constraints.push(input_bounds);
37    }
38
39    /// Set layout strategy
40    pub fn with_strategy(mut self, strategy: LayoutStrategy) -> Self {
41        self.layout_strategy = strategy;
42        self
43    }
44
45    /// Parse percentage string to normalized value (0.0-1.0)
46    /// Centralizes: percentage.clamp(0.0, 100.0) / 100.0
47    pub fn parse_percentage(percentage_str: &str) -> Result<f64, LayoutError> {
48        if let Some(percent_part) = percentage_str.strip_suffix('%') {
49            match percent_part.parse::<f64>() {
50                Ok(value) => Ok(value.clamp(0.0, 100.0) / 100.0),
51                Err(_) => Err(LayoutError::InvalidPercentage(percentage_str.to_string())),
52            }
53        } else {
54            Err(LayoutError::InvalidPercentage(percentage_str.to_string()))
55        }
56    }
57
58    /// Convert percentage to absolute coordinate within total bounds
59    /// Centralizes: (normalized * (total - 1) as f64).round() as usize
60    pub fn percentage_to_absolute(&self, percentage: f64, axis: Axis) -> usize {
61        let total = match axis {
62            Axis::X => self.total_bounds.width(),
63            Axis::Y => self.total_bounds.height(),
64        };
65
66        if total <= 1 {
67            return 0;
68        }
69
70        let normalized = percentage.clamp(0.0, 1.0);
71        ((normalized * (total - 1) as f64).round() as usize).min(total - 1)
72    }
73
74    /// Parse InputBounds into actual Bounds
75    /// Centralizes all InputBounds → Bounds conversion logic from utils.rs
76    pub fn resolve_input_bounds(&self, input: &InputBounds) -> Result<Bounds, LayoutError> {
77        let base = &self.total_bounds;
78
79        let x1 = if input.x1.is_empty() {
80            base.x1
81        } else if input.x1.ends_with('%') {
82            let percent = Self::parse_percentage(&input.x1)?;
83            base.x1 + self.percentage_to_absolute(percent, Axis::X)
84        } else {
85            base.x1
86                + input
87                    .x1
88                    .parse::<usize>()
89                    .map_err(|_| LayoutError::InvalidCoordinate(input.x1.clone()))?
90        };
91
92        let y1 = if input.y1.is_empty() {
93            base.y1
94        } else if input.y1.ends_with('%') {
95            let percent = Self::parse_percentage(&input.y1)?;
96            base.y1 + self.percentage_to_absolute(percent, Axis::Y)
97        } else {
98            base.y1
99                + input
100                    .y1
101                    .parse::<usize>()
102                    .map_err(|_| LayoutError::InvalidCoordinate(input.y1.clone()))?
103        };
104
105        let x2 = if input.x2.is_empty() {
106            base.x2
107        } else if input.x2.ends_with('%') {
108            let percent = Self::parse_percentage(&input.x2)?;
109            base.x1 + self.percentage_to_absolute(percent, Axis::X)
110        } else {
111            base.x1
112                + input
113                    .x2
114                    .parse::<usize>()
115                    .map_err(|_| LayoutError::InvalidCoordinate(input.x2.clone()))?
116        };
117
118        let y2 = if input.y2.is_empty() {
119            base.y2
120        } else if input.y2.ends_with('%') {
121            let percent = Self::parse_percentage(&input.y2)?;
122            base.y1 + self.percentage_to_absolute(percent, Axis::Y)
123        } else {
124            base.y1
125                + input
126                    .y2
127                    .parse::<usize>()
128                    .map_err(|_| LayoutError::InvalidCoordinate(input.y2.clone()))?
129        };
130
131        Ok(Bounds::new(x1, y1, x2, y2))
132    }
133
134    /// Resolve anchor positioning for a target size within bounds
135    /// Centralizes anchor resolution logic
136    pub fn resolve_anchor(
137        &self,
138        anchor: &Anchor,
139        target_size: (usize, usize),
140        container: &Bounds,
141    ) -> Bounds {
142        let (target_width, target_height) = target_size;
143        let container_width = container.width();
144        let container_height = container.height();
145
146        // Calculate position based on anchor
147        let (x1, y1) = match anchor {
148            Anchor::TopLeft => (container.x1, container.y1),
149            Anchor::CenterTop => (
150                container.x1 + container_width.saturating_sub(target_width) / 2,
151                container.y1,
152            ),
153            Anchor::TopRight => (
154                container.x1 + container_width.saturating_sub(target_width),
155                container.y1,
156            ),
157            Anchor::CenterLeft => (
158                container.x1,
159                container.y1 + container_height.saturating_sub(target_height) / 2,
160            ),
161            Anchor::Center => (
162                container.x1 + container_width.saturating_sub(target_width) / 2,
163                container.y1 + container_height.saturating_sub(target_height) / 2,
164            ),
165            Anchor::CenterRight => (
166                container.x1 + container_width.saturating_sub(target_width),
167                container.y1 + container_height.saturating_sub(target_height) / 2,
168            ),
169            Anchor::BottomLeft => (
170                container.x1,
171                container.y1 + container_height.saturating_sub(target_height),
172            ),
173            Anchor::CenterBottom => (
174                container.x1 + container_width.saturating_sub(target_width) / 2,
175                container.y1 + container_height.saturating_sub(target_height),
176            ),
177            Anchor::BottomRight => (
178                container.x1 + container_width.saturating_sub(target_width),
179                container.y1 + container_height.saturating_sub(target_height),
180            ),
181        };
182
183        Bounds::new(x1, y1, x1 + target_width - 1, y1 + target_height - 1)
184    }
185
186    /// Calculate all child bounds based on constraints and strategy
187    pub fn calculate_all_child_bounds(&self) -> Result<Vec<Bounds>, LayoutError> {
188        match &self.layout_strategy {
189            LayoutStrategy::Absolute => self.calculate_absolute_layout(),
190            LayoutStrategy::Grid { columns, rows } => self.calculate_grid_layout(*columns, *rows),
191            LayoutStrategy::Flex { direction } => self.calculate_flex_layout(*direction),
192            LayoutStrategy::Stack => self.calculate_stack_layout(),
193        }
194    }
195
196    /// Calculate absolute positioning layout
197    fn calculate_absolute_layout(&self) -> Result<Vec<Bounds>, LayoutError> {
198        let mut results = Vec::new();
199
200        for constraint in &self.child_constraints {
201            let bounds = self.resolve_input_bounds(constraint)?;
202            results.push(bounds);
203        }
204
205        Ok(results)
206    }
207
208    /// Calculate grid-based layout
209    fn calculate_grid_layout(
210        &self,
211        columns: usize,
212        rows: usize,
213    ) -> Result<Vec<Bounds>, LayoutError> {
214        if columns == 0 || rows == 0 {
215            return Err(LayoutError::InvalidGridDimensions { columns, rows });
216        }
217
218        let cell_width = self.total_bounds.width() / columns;
219        let cell_height = self.total_bounds.height() / rows;
220        let mut results = Vec::new();
221
222        for (index, _) in self.child_constraints.iter().enumerate() {
223            let col = index % columns;
224            let row = index / columns;
225
226            if row >= rows {
227                break; // Don't exceed grid
228            }
229
230            let x1 = self.total_bounds.x1 + col * cell_width;
231            let y1 = self.total_bounds.y1 + row * cell_height;
232            let x2 = x1 + cell_width - 1;
233            let y2 = y1 + cell_height - 1;
234
235            results.push(Bounds::new(x1, y1, x2, y2));
236        }
237
238        Ok(results)
239    }
240
241    /// Calculate flex-based layout  
242    fn calculate_flex_layout(&self, direction: FlexDirection) -> Result<Vec<Bounds>, LayoutError> {
243        let child_count = self.child_constraints.len();
244        if child_count == 0 {
245            return Ok(Vec::new());
246        }
247
248        let mut results = Vec::new();
249
250        match direction {
251            FlexDirection::Row => {
252                let child_width = self.total_bounds.width() / child_count;
253
254                for (index, _) in self.child_constraints.iter().enumerate() {
255                    let x1 = self.total_bounds.x1 + index * child_width;
256                    let x2 = x1 + child_width - 1;
257                    let bounds = Bounds::new(x1, self.total_bounds.y1, x2, self.total_bounds.y2);
258                    results.push(bounds);
259                }
260            }
261            FlexDirection::Column => {
262                let child_height = self.total_bounds.height() / child_count;
263
264                for (index, _) in self.child_constraints.iter().enumerate() {
265                    let y1 = self.total_bounds.y1 + index * child_height;
266                    let y2 = y1 + child_height - 1;
267                    let bounds = Bounds::new(self.total_bounds.x1, y1, self.total_bounds.x2, y2);
268                    results.push(bounds);
269                }
270            }
271        }
272
273        Ok(results)
274    }
275
276    /// Calculate stack layout (all children same bounds)
277    fn calculate_stack_layout(&self) -> Result<Vec<Bounds>, LayoutError> {
278        let mut results = Vec::new();
279
280        for _ in &self.child_constraints {
281            results.push(self.total_bounds);
282        }
283
284        Ok(results)
285    }
286
287    /// Calculate space distribution for responsive layouts
288    /// Handles percentage-based space allocation
289    pub fn distribute_space(&self, percentages: &[f64]) -> Result<Vec<usize>, LayoutError> {
290        let total_percent: f64 = percentages.iter().sum();
291        if total_percent > 100.01 {
292            // Allow small floating point error
293            return Err(LayoutError::PercentageOverflow {
294                total: total_percent,
295            });
296        }
297
298        let available_space = self.total_bounds.width();
299        let mut results = Vec::new();
300
301        for &percentage in percentages {
302            let space = ((percentage / 100.0) * available_space as f64).round() as usize;
303            results.push(space);
304        }
305
306        Ok(results)
307    }
308
309    /// Calculate minimum required space for all children
310    pub fn calculate_minimum_space(&self) -> (usize, usize) {
311        if self.child_constraints.is_empty() {
312            return (1, 1); // Minimum space for empty layout
313        }
314
315        match &self.layout_strategy {
316            LayoutStrategy::Absolute => {
317                // Find bounding box of all children
318                let mut min_width = 0;
319                let mut min_height = 0;
320
321                for constraint in &self.child_constraints {
322                    if let Ok(bounds) = self.resolve_input_bounds(constraint) {
323                        min_width = min_width.max(bounds.x2 + 1);
324                        min_height = min_height.max(bounds.y2 + 1);
325                    }
326                }
327
328                (min_width, min_height)
329            }
330            LayoutStrategy::Grid { columns, rows } => {
331                // Minimum cell size * grid dimensions
332                let min_cell_size = (8, 3); // Reasonable minimum for UI elements
333                (*columns * min_cell_size.0, *rows * min_cell_size.1)
334            }
335            LayoutStrategy::Flex { .. } | LayoutStrategy::Stack => {
336                // All children need to fit
337                let min_child_size = (8, 3);
338                (
339                    min_child_size.0,
340                    min_child_size.1 * self.child_constraints.len(),
341                )
342            }
343        }
344    }
345
346    /// Validate layout fits within available space
347    pub fn validate_layout(&self) -> Result<(), LayoutError> {
348        let (min_width, min_height) = self.calculate_minimum_space();
349
350        if self.total_bounds.width() < min_width {
351            return Err(LayoutError::InsufficientWidth {
352                available: self.total_bounds.width(),
353                required: min_width,
354            });
355        }
356
357        if self.total_bounds.height() < min_height {
358            return Err(LayoutError::InsufficientHeight {
359                available: self.total_bounds.height(),
360                required: min_height,
361            });
362        }
363
364        Ok(())
365    }
366}
367
368#[derive(Debug, Clone, PartialEq)]
369pub enum LayoutStrategy {
370    /// Absolute positioning using InputBounds
371    Absolute,
372    /// Grid layout with fixed columns and rows
373    Grid { columns: usize, rows: usize },
374    /// Flexible layout in one direction
375    Flex { direction: FlexDirection },
376    /// Stack layout (all children same bounds)
377    Stack,
378}
379
380#[derive(Debug, Clone, Copy, PartialEq)]
381pub enum FlexDirection {
382    Row,    // Arrange children horizontally
383    Column, // Arrange children vertically
384}
385
386#[derive(Debug, Clone, Copy, PartialEq)]
387pub enum Axis {
388    X,
389    Y,
390}
391
392#[derive(Debug, Clone, PartialEq)]
393pub enum LayoutError {
394    InvalidPercentage(String),
395    InvalidCoordinate(String),
396    InvalidGridDimensions { columns: usize, rows: usize },
397    PercentageOverflow { total: f64 },
398    InsufficientWidth { available: usize, required: usize },
399    InsufficientHeight { available: usize, required: usize },
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_percentage_parsing() {
408        assert_eq!(LayoutDimensions::parse_percentage("50%").unwrap(), 0.5);
409        assert_eq!(LayoutDimensions::parse_percentage("100%").unwrap(), 1.0);
410        assert_eq!(LayoutDimensions::parse_percentage("0%").unwrap(), 0.0);
411
412        // Test clamping
413        assert_eq!(LayoutDimensions::parse_percentage("150%").unwrap(), 1.0);
414        assert_eq!(LayoutDimensions::parse_percentage("-10%").unwrap(), 0.0);
415
416        // Test invalid
417        assert!(LayoutDimensions::parse_percentage("not_percent").is_err());
418        assert!(LayoutDimensions::parse_percentage("50").is_err());
419    }
420
421    #[test]
422    fn test_percentage_to_absolute() {
423        let total_bounds = Bounds::new(0, 0, 100, 50);
424        let layout = LayoutDimensions::new(total_bounds);
425
426        // 50% of width (101) should be around 50
427        let x = layout.percentage_to_absolute(0.5, Axis::X);
428        assert_eq!(x, 50);
429
430        // 50% of height (51) should be around 25
431        let y = layout.percentage_to_absolute(0.5, Axis::Y);
432        assert_eq!(y, 25);
433
434        // 100% should be max coordinate
435        let x_max = layout.percentage_to_absolute(1.0, Axis::X);
436        assert_eq!(x_max, 100); // total - 1
437    }
438
439    #[test]
440    fn test_input_bounds_resolution() {
441        let total_bounds = Bounds::new(0, 0, 100, 50);
442        let layout = LayoutDimensions::new(total_bounds);
443
444        let input = InputBounds {
445            x1: "25%".to_string(),
446            y1: "20%".to_string(),
447            x2: "75%".to_string(),
448            y2: "80%".to_string(),
449        };
450
451        let resolved = layout.resolve_input_bounds(&input).unwrap();
452
453        assert_eq!(resolved.x1, 25); // 25% of 100
454        assert_eq!(resolved.y1, 10); // 20% of 50
455        assert_eq!(resolved.x2, 75); // 75% of 100
456        assert_eq!(resolved.y2, 40); // 80% of 50
457    }
458
459    #[test]
460    fn test_anchor_resolution() {
461        let total_bounds = Bounds::new(0, 0, 100, 50);
462        let layout = LayoutDimensions::new(total_bounds);
463
464        let container = Bounds::new(10, 10, 90, 40);
465        let target_size = (20, 10);
466
467        // Test center anchor
468        let bounds = layout.resolve_anchor(&Anchor::Center, target_size, &container);
469
470        // Should be centered in container
471        // Container: 10,10 to 90,40 = 81x31
472        // Target: 20x10
473        // Center: 10 + (81-20)/2 = 10 + 30 = 40, 10 + (31-10)/2 = 10 + 10 = 20
474        assert_eq!(bounds.x1, 40);
475        assert_eq!(bounds.y1, 20);
476        assert_eq!(bounds.width(), 20);
477        assert_eq!(bounds.height(), 10);
478    }
479
480    #[test]
481    fn test_grid_layout() {
482        let total_bounds = Bounds::new(0, 0, 19, 11); // 20x12
483        let mut layout = LayoutDimensions::new(total_bounds);
484        layout = layout.with_strategy(LayoutStrategy::Grid {
485            columns: 2,
486            rows: 2,
487        });
488
489        // Add 4 children
490        for i in 0..4 {
491            layout.add_child(InputBounds {
492                x1: format!("{}%", i * 25),
493                y1: "0%".to_string(),
494                x2: format!("{}%", (i + 1) * 25),
495                y2: "100%".to_string(),
496            });
497        }
498
499        let bounds = layout.calculate_all_child_bounds().unwrap();
500        assert_eq!(bounds.len(), 4);
501
502        // Each cell should be 10x6
503        assert_eq!(bounds[0], Bounds::new(0, 0, 9, 5)); // Top-left
504        assert_eq!(bounds[1], Bounds::new(10, 0, 19, 5)); // Top-right
505        assert_eq!(bounds[2], Bounds::new(0, 6, 9, 11)); // Bottom-left
506        assert_eq!(bounds[3], Bounds::new(10, 6, 19, 11)); // Bottom-right
507    }
508
509    #[test]
510    fn test_flex_layout() {
511        let total_bounds = Bounds::new(0, 0, 29, 9); // 30x10
512        let mut layout = LayoutDimensions::new(total_bounds);
513        layout = layout.with_strategy(LayoutStrategy::Flex {
514            direction: FlexDirection::Row,
515        });
516
517        // Add 3 children
518        for i in 0..3 {
519            layout.add_child(InputBounds {
520                x1: format!("{}%", i * 30),
521                y1: "0%".to_string(),
522                x2: format!("{}%", (i + 1) * 30),
523                y2: "100%".to_string(),
524            });
525        }
526
527        let bounds = layout.calculate_all_child_bounds().unwrap();
528        assert_eq!(bounds.len(), 3);
529
530        // Each should be 10 wide (30/3)
531        assert_eq!(bounds[0], Bounds::new(0, 0, 9, 9)); // Left
532        assert_eq!(bounds[1], Bounds::new(10, 0, 19, 9)); // Center
533        assert_eq!(bounds[2], Bounds::new(20, 0, 29, 9)); // Right
534    }
535
536    #[test]
537    fn test_space_distribution() {
538        let total_bounds = Bounds::new(0, 0, 99, 9); // 100 wide
539        let layout = LayoutDimensions::new(total_bounds);
540
541        let percentages = vec![30.0, 50.0, 20.0];
542        let spaces = layout.distribute_space(&percentages).unwrap();
543
544        assert_eq!(spaces, vec![30, 50, 20]);
545
546        // Test overflow
547        let overflow = vec![60.0, 50.0, 20.0]; // 130% total
548        assert!(layout.distribute_space(&overflow).is_err());
549    }
550}