Skip to main content

boxmux_lib/components/dimensions/
component_dimensions.rs

1use super::{HitRegion, Padding};
2use crate::Bounds;
3
4/// ComponentDimensions - Centralizes all UI component mathematical operations
5///
6/// Eliminates ad hoc math like:
7/// - bounds.left() + 1, bounds.right() - 1
8/// - width.saturating_sub(2)
9/// - (x1 + x2) / 2 for center calculations
10/// - Hit testing and coordinate validation
11///
12/// All component positioning, sizing, and geometric operations centralized here.
13#[derive(Debug, Clone)]
14pub struct ComponentDimensions {
15    bounds: Bounds,
16    border_thickness: usize,
17    padding: Padding,
18    has_scrollbar_vertical: bool,
19    has_scrollbar_horizontal: bool,
20}
21
22impl ComponentDimensions {
23    /// Create new component dimensions
24    pub fn new(bounds: Bounds) -> Self {
25        Self {
26            bounds,
27            border_thickness: 1,          // Default border thickness
28            padding: Padding::uniform(1), // Default padding
29            has_scrollbar_vertical: false,
30            has_scrollbar_horizontal: false,
31        }
32    }
33
34    /// Builder pattern for configuration
35    pub fn with_border_thickness(mut self, thickness: usize) -> Self {
36        self.border_thickness = thickness;
37        self
38    }
39
40    pub fn with_padding(mut self, padding: Padding) -> Self {
41        self.padding = padding;
42        self
43    }
44
45    pub fn with_scrollbars(mut self, vertical: bool, horizontal: bool) -> Self {
46        self.has_scrollbar_vertical = vertical;
47        self.has_scrollbar_horizontal = horizontal;
48        self
49    }
50
51    /// Get outer bounds (full component including border)
52    pub fn outer_bounds(&self) -> Bounds {
53        self.bounds
54    }
55
56    /// Get border area bounds (just the border region)
57    pub fn border_bounds(&self) -> Bounds {
58        self.bounds
59    }
60
61    /// Get content area bounds (inside border and padding)
62    /// Replaces: bounds.left() + 2, bounds.right() - 2, etc.
63    pub fn content_bounds(&self) -> Bounds {
64        let border = self.border_thickness;
65        let left_offset = border + self.padding.left;
66        let right_offset = border + self.padding.right;
67        let top_offset = border + self.padding.top;
68        let bottom_offset = border + self.padding.bottom;
69
70        // Handle scrollbar space
71        let scrollbar_vertical_space = if self.has_scrollbar_vertical { 1 } else { 0 };
72        let scrollbar_horizontal_space = if self.has_scrollbar_horizontal { 1 } else { 0 };
73
74        Bounds::new(
75            self.bounds.x1 + left_offset,
76            self.bounds.y1 + top_offset,
77            self.bounds
78                .x2
79                .saturating_sub(right_offset + scrollbar_vertical_space),
80            self.bounds
81                .y2
82                .saturating_sub(bottom_offset + scrollbar_horizontal_space),
83        )
84    }
85
86    /// Get center point of component
87    /// Replaces: (x1 + x2) / 2, (y1 + y2) / 2
88    pub fn center(&self) -> (usize, usize) {
89        (
90            (self.bounds.x1 + self.bounds.x2) / 2,
91            (self.bounds.y1 + self.bounds.y2) / 2,
92        )
93    }
94
95    /// Get content area center
96    pub fn content_center(&self) -> (usize, usize) {
97        let content = self.content_bounds();
98        ((content.x1 + content.x2) / 2, (content.y1 + content.y2) / 2)
99    }
100
101    /// Calculate available content width
102    /// Replaces: width.saturating_sub(4), bounds.width() - borders
103    pub fn content_width(&self) -> usize {
104        let content = self.content_bounds();
105        content.width()
106    }
107
108    /// Calculate available content height
109    /// Replaces: height.saturating_sub(4), bounds.height() - borders
110    pub fn content_height(&self) -> usize {
111        let content = self.content_bounds();
112        content.height()
113    }
114
115    /// Get border drawing coordinates for each side
116    pub fn border_coordinates(&self) -> BorderCoordinates {
117        BorderCoordinates {
118            top: (
119                self.bounds.x1,
120                self.bounds.y1,
121                self.bounds.x2,
122                self.bounds.y1,
123            ),
124            bottom: (
125                self.bounds.x1,
126                self.bounds.y2,
127                self.bounds.x2,
128                self.bounds.y2,
129            ),
130            left: (
131                self.bounds.x1,
132                self.bounds.y1,
133                self.bounds.x1,
134                self.bounds.y2,
135            ),
136            right: (
137                self.bounds.x2,
138                self.bounds.y1,
139                self.bounds.x2,
140                self.bounds.y2,
141            ),
142        }
143    }
144
145    /// Calculate inside border coordinates (border + 1)
146    /// Replaces: bounds.left() + 1, bounds.right() - 1, etc.
147    pub fn inside_border_bounds(&self) -> Bounds {
148        let thickness = self.border_thickness;
149        Bounds::new(
150            self.bounds.x1 + thickness,
151            self.bounds.y1 + thickness,
152            self.bounds.x2.saturating_sub(thickness),
153            self.bounds.y2.saturating_sub(thickness),
154        )
155    }
156
157    /// Hit test a point against component regions
158    /// Centralizes all coordinate hit testing logic
159    pub fn hit_test(&self, x: usize, y: usize) -> HitRegion {
160        if !self.bounds.contains_point(x, y) {
161            return HitRegion::Outside;
162        }
163
164        let content = self.content_bounds();
165        if content.contains_point(x, y) {
166            return HitRegion::Content;
167        }
168
169        // Check scrollbar regions
170        if self.has_scrollbar_vertical && x == self.bounds.x2 {
171            return HitRegion::ScrollbarVertical;
172        }
173
174        if self.has_scrollbar_horizontal && y == self.bounds.y2 {
175            return HitRegion::ScrollbarHorizontal;
176        }
177
178        // Check tab bar region (top inside border)
179        let inside = self.inside_border_bounds();
180        if y == inside.y1 && x >= inside.x1 && x <= inside.x2 {
181            return HitRegion::TabBar;
182        }
183
184        HitRegion::Border
185    }
186
187    /// Calculate title positioning within component
188    /// Centralizes title positioning math from draw_utils.rs
189    pub fn calculate_title_position(
190        &self,
191        title: &str,
192        position: &str,
193        padding: usize,
194    ) -> TitleLayout {
195        let width = self.bounds.width();
196        let title_length = title.chars().count();
197        let x1 = self.bounds.x1;
198        let x2 = self.bounds.x2;
199
200        let (title_start, line_before_length, line_after_length) = match position {
201            "left" | "start" => {
202                let title_start = x1 + padding;
203                let line_before = title_start.saturating_sub(x1);
204                let line_after = width.saturating_sub(line_before + title_length);
205                (title_start, line_before, line_after)
206            }
207            "center" => {
208                let title_start = x1 + (width.saturating_sub(title_length)) / 2;
209                let line_before = title_start.saturating_sub(x1);
210                let line_after = width.saturating_sub(line_before + title_length);
211                (title_start, line_before, line_after)
212            }
213            "right" | "end" => {
214                let title_start = x2.saturating_sub(title_length + padding);
215                let line_before = title_start.saturating_sub(x1);
216                let line_after = x2.saturating_sub(title_start + title_length);
217                (title_start, line_before, line_after)
218            }
219            _ => {
220                // Default to left
221                let title_start = x1 + padding;
222                let line_before = title_start.saturating_sub(x1);
223                let line_after = width.saturating_sub(line_before + title_length);
224                (title_start, line_before, line_after)
225            }
226        };
227
228        TitleLayout {
229            title_x: title_start,
230            title_end_x: title_start + title_length - 1,
231            line_before_length,
232            line_after_length,
233        }
234    }
235
236    /// Check if point is near corner (for resize detection)
237    /// Replaces: x >= bounds.x2.saturating_sub(corner_tolerance) && x <= bounds.x2
238    pub fn is_near_corner(&self, x: usize, y: usize, tolerance: usize) -> Option<Corner> {
239        let bounds = &self.bounds;
240
241        // Bottom-right corner
242        if (x >= bounds.x2.saturating_sub(tolerance) && x <= bounds.x2)
243            && (y >= bounds.y2.saturating_sub(tolerance) && y <= bounds.y2)
244        {
245            return Some(Corner::BottomRight);
246        }
247
248        // Top-right corner
249        if (x >= bounds.x2.saturating_sub(tolerance) && x <= bounds.x2)
250            && (y >= bounds.y1 && y <= bounds.y1 + tolerance)
251        {
252            return Some(Corner::TopRight);
253        }
254
255        // Bottom-left corner
256        if (x >= bounds.x1 && x <= bounds.x1 + tolerance)
257            && (y >= bounds.y2.saturating_sub(tolerance) && y <= bounds.y2)
258        {
259            return Some(Corner::BottomLeft);
260        }
261
262        // Top-left corner
263        if (x >= bounds.x1 && x <= bounds.x1 + tolerance)
264            && (y >= bounds.y1 && y <= bounds.y1 + tolerance)
265        {
266            return Some(Corner::TopLeft);
267        }
268
269        None
270    }
271
272    /// Calculate space available for child components
273    pub fn available_child_space(&self) -> (usize, usize) {
274        let content = self.content_bounds();
275        (content.width(), content.height())
276    }
277
278    /// Validate that dimensions are reasonable
279    pub fn validate(&self) -> Result<(), ComponentDimensionError> {
280        if self.bounds.width() < self.minimum_width() {
281            return Err(ComponentDimensionError::TooNarrow {
282                actual: self.bounds.width(),
283                minimum: self.minimum_width(),
284            });
285        }
286
287        if self.bounds.height() < self.minimum_height() {
288            return Err(ComponentDimensionError::TooShort {
289                actual: self.bounds.height(),
290                minimum: self.minimum_height(),
291            });
292        }
293
294        Ok(())
295    }
296
297    fn minimum_width(&self) -> usize {
298        2 * self.border_thickness + self.padding.horizontal_total() + 1
299    }
300
301    fn minimum_height(&self) -> usize {
302        2 * self.border_thickness + self.padding.vertical_total() + 1
303    }
304}
305
306#[derive(Debug, Clone, PartialEq)]
307pub struct BorderCoordinates {
308    pub top: (usize, usize, usize, usize), // (x1, y1, x2, y2)
309    pub bottom: (usize, usize, usize, usize),
310    pub left: (usize, usize, usize, usize),
311    pub right: (usize, usize, usize, usize),
312}
313
314#[derive(Debug, Clone, PartialEq)]
315pub struct TitleLayout {
316    pub title_x: usize,
317    pub title_end_x: usize,
318    pub line_before_length: usize,
319    pub line_after_length: usize,
320}
321
322#[derive(Debug, Clone, PartialEq)]
323pub enum Corner {
324    TopLeft,
325    TopRight,
326    BottomLeft,
327    BottomRight,
328}
329
330#[derive(Debug, Clone, PartialEq)]
331pub enum ComponentDimensionError {
332    TooNarrow { actual: usize, minimum: usize },
333    TooShort { actual: usize, minimum: usize },
334    InvalidPadding,
335    InvalidBorder,
336}
337
338impl ComponentDimensions {
339    /// Get vertical scrollbar track bounds
340    /// Replaces: bounds.top() + 1, bounds.bottom() - 1
341    pub fn vertical_scrollbar_track_bounds(&self) -> Bounds {
342        let x = self.bounds.right();
343        let start_y = self.bounds.top() + 1;
344        let end_y = self.bounds.bottom().saturating_sub(1);
345        Bounds::new(x, start_y, x, end_y)
346    }
347
348    /// Get horizontal scrollbar track bounds  
349    /// Replaces: bounds.left() + 1, bounds.right() - 1
350    pub fn horizontal_scrollbar_track_bounds(&self) -> Bounds {
351        let y = self.bounds.bottom();
352        let start_x = self.bounds.left() + 1;
353        let end_x = self.bounds.right().saturating_sub(1);
354        Bounds::new(start_x, y, end_x, y)
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn test_content_bounds_calculation() {
364        let bounds = Bounds::new(0, 0, 10, 5);
365        let dims = ComponentDimensions::new(bounds)
366            .with_border_thickness(1)
367            .with_padding(Padding::uniform(1));
368
369        let content = dims.content_bounds();
370        assert_eq!(content.x1, 2); // border + padding
371        assert_eq!(content.y1, 2);
372        assert_eq!(content.x2, 8); // 10 - border - padding
373        assert_eq!(content.y2, 3); // 5 - border - padding
374    }
375
376    #[test]
377    fn test_center_calculation() {
378        let bounds = Bounds::new(0, 0, 10, 4);
379        let dims = ComponentDimensions::new(bounds);
380
381        let (cx, cy) = dims.center();
382        assert_eq!(cx, 5); // (0 + 10) / 2
383        assert_eq!(cy, 2); // (0 + 4) / 2
384    }
385
386    #[test]
387    fn test_hit_testing() {
388        let bounds = Bounds::new(0, 0, 10, 5);
389        let dims = ComponentDimensions::new(bounds);
390
391        // Outside
392        assert_eq!(dims.hit_test(15, 15), HitRegion::Outside);
393
394        // Content area
395        let content = dims.content_bounds();
396        assert_eq!(dims.hit_test(content.x1, content.y1), HitRegion::Content);
397
398        // Border
399        assert_eq!(dims.hit_test(0, 0), HitRegion::Border);
400    }
401
402    #[test]
403    fn test_title_positioning() {
404        let bounds = Bounds::new(0, 0, 20, 5);
405        let dims = ComponentDimensions::new(bounds);
406
407        let layout = dims.calculate_title_position("Test", "center", 2);
408
409        // Title should be centered: width=21, title=4, so start at (21-4)/2 = 8.5 ≈ 8
410        assert_eq!(layout.title_x, 8);
411        assert_eq!(layout.title_end_x, 11); // 8 + 4 - 1
412    }
413
414    #[test]
415    fn test_scrollbar_track_bounds() {
416        let bounds = Bounds::new(0, 0, 20, 10);
417        let dims = ComponentDimensions::new(bounds);
418
419        // Vertical scrollbar should be at right edge
420        let v_track = dims.vertical_scrollbar_track_bounds();
421        assert_eq!(v_track.x1, 20); // right edge
422        assert_eq!(v_track.y1, 1); // top + 1
423        assert_eq!(v_track.y2, 9); // bottom - 1
424
425        // Horizontal scrollbar should be at bottom edge
426        let h_track = dims.horizontal_scrollbar_track_bounds();
427        assert_eq!(h_track.y1, 10); // bottom edge
428        assert_eq!(h_track.x1, 1); // left + 1
429        assert_eq!(h_track.x2, 19); // right - 1
430    }
431}