boxmux_lib/components/dimensions/
component_dimensions.rs1use super::{HitRegion, Padding};
2use crate::Bounds;
3
4#[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 pub fn new(bounds: Bounds) -> Self {
25 Self {
26 bounds,
27 border_thickness: 1, padding: Padding::uniform(1), has_scrollbar_vertical: false,
30 has_scrollbar_horizontal: false,
31 }
32 }
33
34 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 pub fn outer_bounds(&self) -> Bounds {
53 self.bounds
54 }
55
56 pub fn border_bounds(&self) -> Bounds {
58 self.bounds
59 }
60
61 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 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 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 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 pub fn content_width(&self) -> usize {
104 let content = self.content_bounds();
105 content.width()
106 }
107
108 pub fn content_height(&self) -> usize {
111 let content = self.content_bounds();
112 content.height()
113 }
114
115 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 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 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 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 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 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 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 pub fn is_near_corner(&self, x: usize, y: usize, tolerance: usize) -> Option<Corner> {
239 let bounds = &self.bounds;
240
241 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 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 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 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 pub fn available_child_space(&self) -> (usize, usize) {
274 let content = self.content_bounds();
275 (content.width(), content.height())
276 }
277
278 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), 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 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 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); assert_eq!(content.y1, 2);
372 assert_eq!(content.x2, 8); assert_eq!(content.y2, 3); }
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); assert_eq!(cy, 2); }
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 assert_eq!(dims.hit_test(15, 15), HitRegion::Outside);
393
394 let content = dims.content_bounds();
396 assert_eq!(dims.hit_test(content.x1, content.y1), HitRegion::Content);
397
398 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 assert_eq!(layout.title_x, 8);
411 assert_eq!(layout.title_end_x, 11); }
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 let v_track = dims.vertical_scrollbar_track_bounds();
421 assert_eq!(v_track.x1, 20); assert_eq!(v_track.y1, 1); assert_eq!(v_track.y2, 9); let h_track = dims.horizontal_scrollbar_track_bounds();
427 assert_eq!(h_track.y1, 10); assert_eq!(h_track.x1, 1); assert_eq!(h_track.x2, 19); }
431}