Skip to main content

boxmux_lib/components/dimensions/
text_dimensions.rs

1use super::FontMetrics;
2use crate::Bounds;
3
4/// TextDimensions - Centralizes ALL text layout and wrapping mathematical operations
5///
6/// Eliminates ad hoc text math like:
7/// - width.saturating_sub(border_width)
8/// - title_start_position = x1 + (width.saturating_sub(title_length)) / 2
9/// - Text wrapping and line counting scattered throughout draw_utils.rs
10/// - Character-aware truncation with ellipsis
11///
12/// Replaces scattered text layout logic from draw_utils.rs, components/text_content.rs
13#[derive(Debug, Clone)]
14pub struct TextDimensions {
15    /// Available area for text rendering
16    bounds: Bounds,
17    /// Font metrics for calculations
18    font_metrics: FontMetrics,
19    /// Text alignment preferences
20    alignment: TextAlignment,
21    /// Whether to wrap text or truncate
22    wrap_behavior: WrapBehavior,
23}
24
25impl TextDimensions {
26    /// Create new text dimensions
27    pub fn new(bounds: Bounds) -> Self {
28        Self {
29            bounds,
30            font_metrics: FontMetrics::default(),
31            alignment: TextAlignment::Left,
32            wrap_behavior: WrapBehavior::Wrap,
33        }
34    }
35
36    /// Builder methods
37    pub fn with_alignment(mut self, alignment: TextAlignment) -> Self {
38        self.alignment = alignment;
39        self
40    }
41
42    pub fn with_wrap_behavior(mut self, behavior: WrapBehavior) -> Self {
43        self.wrap_behavior = behavior;
44        self
45    }
46
47    pub fn with_font_metrics(mut self, metrics: FontMetrics) -> Self {
48        self.font_metrics = metrics;
49        self
50    }
51
52    /// Get available text width (accounting for UI chrome)
53    pub fn available_width(&self) -> usize {
54        self.bounds.width()
55    }
56
57    /// Get available text height (accounting for UI chrome)
58    pub fn available_height(&self) -> usize {
59        self.bounds.height()
60    }
61
62    /// Wrap text to fit within available width
63    /// Centralizes text wrapping logic from draw_utils.rs
64    pub fn wrap_text(&self, text: &str) -> Vec<String> {
65        let max_width = self.available_width();
66
67        if max_width == 0 {
68            return vec![];
69        }
70
71        match self.wrap_behavior {
72            WrapBehavior::Wrap => self.wrap_text_to_width(text, max_width),
73            WrapBehavior::Truncate => {
74                vec![self.truncate_with_ellipsis(text, max_width)]
75            }
76            WrapBehavior::Clip => {
77                vec![text.chars().take(max_width).collect()]
78            }
79        }
80    }
81
82    /// Internal text wrapping implementation
83    /// Handles word boundaries and preserves formatting
84    fn wrap_text_to_width(&self, text: &str, width: usize) -> Vec<String> {
85        if width == 0 {
86            return vec![];
87        }
88
89        let mut lines = Vec::new();
90
91        for line in text.lines() {
92            if line.chars().count() <= width {
93                lines.push(line.to_string());
94                continue;
95            }
96
97            // Handle long lines that need wrapping
98            let wrapped = self.wrap_long_line(line, width);
99            lines.extend(wrapped);
100        }
101
102        lines
103    }
104
105    /// Wrap a single long line, respecting word boundaries when possible
106    fn wrap_long_line(&self, line: &str, width: usize) -> Vec<String> {
107        let mut lines = Vec::new();
108        let mut current_line = String::new();
109        let mut current_width = 0;
110
111        for word in line.split_whitespace() {
112            let word_width = word.chars().count();
113
114            // If word alone is too long, break it up
115            if word_width > width {
116                // Finish current line if it has content
117                if !current_line.is_empty() {
118                    lines.push(current_line);
119                    current_line = String::new();
120                    current_width = 0;
121                }
122
123                // Break up the long word
124                let broken_word = self.break_long_word(word, width);
125                lines.extend(broken_word);
126                continue;
127            }
128
129            // Check if adding word would exceed width
130            let space_needed = if current_line.is_empty() { 0 } else { 1 }; // Space before word
131            if current_width + space_needed + word_width > width {
132                // Start new line with this word
133                lines.push(current_line);
134                current_line = word.to_string();
135                current_width = word_width;
136            } else {
137                // Add word to current line
138                if !current_line.is_empty() {
139                    current_line.push(' ');
140                    current_width += 1;
141                }
142                current_line.push_str(word);
143                current_width += word_width;
144            }
145        }
146
147        // Add final line if it has content
148        if !current_line.is_empty() {
149            lines.push(current_line);
150        }
151
152        lines
153    }
154
155    /// Break up a word that's too long for a single line
156    fn break_long_word(&self, word: &str, width: usize) -> Vec<String> {
157        let mut lines = Vec::new();
158        let chars: Vec<char> = word.chars().collect();
159
160        for chunk in chars.chunks(width) {
161            lines.push(chunk.iter().collect());
162        }
163
164        lines
165    }
166
167    /// Truncate text with ellipsis if it exceeds width
168    /// Centralizes ellipsis logic from various components
169    pub fn truncate_with_ellipsis(&self, text: &str, max_width: usize) -> String {
170        let char_count = text.chars().count();
171
172        if char_count <= max_width {
173            return text.to_string();
174        }
175
176        if max_width <= 1 {
177            return if max_width == 1 {
178                "…".to_string()
179            } else {
180                String::new()
181            };
182        }
183
184        let truncate_at = max_width - 1; // Leave room for ellipsis
185        let mut result: String = text.chars().take(truncate_at).collect();
186        result.push('…');
187        result
188    }
189
190    /// Calculate text bounds (width, height) for given text
191    pub fn calculate_text_bounds(&self, text: &str) -> (usize, usize) {
192        let wrapped_lines = self.wrap_text(text);
193        let width = wrapped_lines
194            .iter()
195            .map(|line| line.chars().count())
196            .max()
197            .unwrap_or(0);
198        let height = wrapped_lines.len();
199
200        (width, height)
201    }
202
203    /// Calculate positioning for centered text within bounds
204    /// Centralizes center positioning logic from draw_utils.rs
205    pub fn center_text_in_bounds(&self, text: &str) -> TextLayout {
206        let wrapped_lines = self.wrap_text(text);
207        let text_width = wrapped_lines
208            .iter()
209            .map(|line| line.chars().count())
210            .max()
211            .unwrap_or(0);
212        let text_height = wrapped_lines.len();
213
214        let available_width = self.available_width();
215        let available_height = self.available_height();
216
217        // Calculate starting position for centering
218        let start_x = if text_width < available_width {
219            self.bounds.x1 + (available_width - text_width) / 2
220        } else {
221            self.bounds.x1
222        };
223
224        let start_y = if text_height < available_height {
225            self.bounds.y1 + (available_height - text_height) / 2
226        } else {
227            self.bounds.y1
228        };
229
230        TextLayout {
231            lines: wrapped_lines,
232            start_x,
233            start_y,
234            text_width,
235            text_height,
236            alignment: self.alignment,
237        }
238    }
239
240    /// Calculate text layout based on alignment
241    pub fn layout_text(&self, text: &str) -> TextLayout {
242        let wrapped_lines = self.wrap_text(text);
243        let text_width = wrapped_lines
244            .iter()
245            .map(|line| line.chars().count())
246            .max()
247            .unwrap_or(0);
248        let text_height = wrapped_lines.len();
249
250        let (start_x, start_y) = match self.alignment {
251            TextAlignment::Left => (self.bounds.x1, self.bounds.y1),
252            TextAlignment::Center => {
253                let x = self.bounds.x1 + (self.available_width().saturating_sub(text_width)) / 2;
254                let y = self.bounds.y1 + (self.available_height().saturating_sub(text_height)) / 2;
255                (x, y)
256            }
257            TextAlignment::Right => {
258                let x = self.bounds.x2.saturating_sub(text_width);
259                (x, self.bounds.y1)
260            }
261            TextAlignment::Justify => (self.bounds.x1, self.bounds.y1), // TODO: Implement justify
262        };
263
264        TextLayout {
265            lines: wrapped_lines,
266            start_x,
267            start_y,
268            text_width,
269            text_height,
270            alignment: self.alignment,
271        }
272    }
273
274    /// Calculate line positions for each wrapped line
275    pub fn calculate_line_positions(&self, layout: &TextLayout) -> Vec<LinePosition> {
276        let mut positions = Vec::new();
277
278        for (line_index, line) in layout.lines.iter().enumerate() {
279            let line_width = line.chars().count();
280            let y = layout.start_y + line_index;
281
282            let x = match layout.alignment {
283                TextAlignment::Left => layout.start_x,
284                TextAlignment::Center => {
285                    if line_width < self.available_width() {
286                        self.bounds.x1 + (self.available_width() - line_width) / 2
287                    } else {
288                        self.bounds.x1
289                    }
290                }
291                TextAlignment::Right => self.bounds.x2.saturating_sub(line_width),
292                TextAlignment::Justify => {
293                    // TODO: Implement proper justification with word spacing
294                    layout.start_x
295                }
296            };
297
298            positions.push(LinePosition {
299                x,
300                y,
301                width: line_width,
302                content: line.clone(),
303            });
304        }
305
306        positions
307    }
308
309    /// Count visible lines that fit within available height
310    pub fn count_visible_lines(&self, text: &str) -> usize {
311        let wrapped_lines = self.wrap_text(text);
312        wrapped_lines.len().min(self.available_height())
313    }
314
315    /// Get visible text portion for scrolling
316    /// Centralizes scrolling text logic
317    pub fn get_visible_lines(&self, text: &str, scroll_offset: usize) -> Vec<String> {
318        let wrapped_lines = self.wrap_text(text);
319        let available_height = self.available_height();
320
321        if scroll_offset >= wrapped_lines.len() {
322            return vec![];
323        }
324
325        wrapped_lines
326            .into_iter()
327            .skip(scroll_offset)
328            .take(available_height)
329            .collect()
330    }
331
332    /// Calculate maximum scroll offset for text
333    pub fn max_scroll_offset(&self, text: &str) -> usize {
334        let wrapped_lines = self.wrap_text(text);
335        let available_height = self.available_height();
336
337        if wrapped_lines.len() <= available_height {
338            0
339        } else {
340            wrapped_lines.len() - available_height
341        }
342    }
343
344    /// Validate text fits within dimensions
345    pub fn validate_text_fit(&self, text: &str) -> TextFitResult {
346        let available_width = self.available_width();
347        let available_height = self.available_height();
348
349        // Calculate unwrapped text dimensions to check if it needs wrapping
350        let unwrapped_lines: Vec<&str> = text.lines().collect();
351        let unwrapped_width = unwrapped_lines
352            .iter()
353            .map(|line| line.chars().count())
354            .max()
355            .unwrap_or(0);
356        let unwrapped_height = unwrapped_lines.len();
357
358        // Check if original text fits without modification
359        let fits_width = unwrapped_width <= available_width;
360        let _fits_height = unwrapped_height <= available_height;
361
362        // If text doesn't fit width-wise, check wrapped dimensions for height validation
363        let final_height = if !fits_width {
364            let wrapped_lines = self.wrap_text(text);
365            wrapped_lines.len()
366        } else {
367            unwrapped_height
368        };
369
370        let fits_height_after_wrap = final_height <= available_height;
371
372        TextFitResult {
373            fits_width,
374            fits_height: fits_height_after_wrap,
375            text_width: unwrapped_width,
376            text_height: final_height,
377            available_width,
378            available_height,
379            requires_wrapping: !fits_width,
380            requires_scrolling: !fits_height_after_wrap,
381        }
382    }
383}
384
385#[derive(Debug, Clone, Copy, PartialEq)]
386pub enum TextAlignment {
387    Left,
388    Center,
389    Right,
390    Justify,
391}
392
393#[derive(Debug, Clone, PartialEq)]
394pub enum WrapBehavior {
395    /// Wrap text to multiple lines
396    Wrap,
397    /// Truncate with ellipsis
398    Truncate,
399    /// Hard clip at boundary
400    Clip,
401}
402
403#[derive(Debug, Clone, PartialEq)]
404pub struct TextLayout {
405    pub lines: Vec<String>,
406    pub start_x: usize,
407    pub start_y: usize,
408    pub text_width: usize,
409    pub text_height: usize,
410    pub alignment: TextAlignment,
411}
412
413#[derive(Debug, Clone, PartialEq)]
414pub struct LinePosition {
415    pub x: usize,
416    pub y: usize,
417    pub width: usize,
418    pub content: String,
419}
420
421#[derive(Debug, Clone, PartialEq)]
422pub struct TextFitResult {
423    pub fits_width: bool,
424    pub fits_height: bool,
425    pub text_width: usize,
426    pub text_height: usize,
427    pub available_width: usize,
428    pub available_height: usize,
429    pub requires_wrapping: bool,
430    pub requires_scrolling: bool,
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn test_text_wrapping() {
439        let bounds = Bounds::new(0, 0, 9, 4); // 10 chars wide, 5 lines high
440        let text_dims = TextDimensions::new(bounds);
441
442        let text = "This is a long line that should be wrapped";
443        let wrapped = text_dims.wrap_text(text);
444
445        // Should wrap into multiple lines
446        assert!(wrapped.len() > 1);
447
448        // Each line should fit within width
449        for line in &wrapped {
450            assert!(line.chars().count() <= 10);
451        }
452    }
453
454    #[test]
455    fn test_text_truncation() {
456        let bounds = Bounds::new(0, 0, 9, 4); // 10 chars wide
457        let text_dims = TextDimensions::new(bounds).with_wrap_behavior(WrapBehavior::Truncate);
458
459        let text = "This is a very long line that should be truncated";
460        let wrapped = text_dims.wrap_text(text);
461
462        // Should be single truncated line
463        assert_eq!(wrapped.len(), 1);
464        assert!(wrapped[0].ends_with('…'));
465        assert!(wrapped[0].chars().count() <= 10);
466    }
467
468    #[test]
469    fn test_center_alignment() {
470        let bounds = Bounds::new(0, 0, 19, 4); // 20 chars wide, 5 lines high
471        let text_dims = TextDimensions::new(bounds).with_alignment(TextAlignment::Center);
472
473        let text = "Hello"; // 5 chars
474        let layout = text_dims.center_text_in_bounds(text);
475
476        // Should be centered: (20 - 5) / 2 = 7.5 ≈ 7
477        assert_eq!(layout.start_x, 7);
478    }
479
480    #[test]
481    fn test_text_bounds_calculation() {
482        let bounds = Bounds::new(0, 0, 9, 9); // 10x10
483        let text_dims = TextDimensions::new(bounds);
484
485        let text = "Hello\nWorld\nTest";
486        let (width, height) = text_dims.calculate_text_bounds(text);
487
488        assert_eq!(width, 5); // "Hello" and "World" are 5 chars
489        assert_eq!(height, 3); // 3 lines
490    }
491
492    #[test]
493    fn test_visible_lines_with_scroll() {
494        let bounds = Bounds::new(0, 0, 9, 2); // 3 lines visible
495        let text_dims = TextDimensions::new(bounds);
496
497        let text = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
498
499        // No scroll - should show first 3 lines
500        let visible = text_dims.get_visible_lines(text, 0);
501        assert_eq!(visible.len(), 3);
502        assert_eq!(visible[0], "Line 1");
503        assert_eq!(visible[2], "Line 3");
504
505        // Scroll by 2 - should show lines 3-5
506        let scrolled = text_dims.get_visible_lines(text, 2);
507        assert_eq!(scrolled.len(), 3);
508        assert_eq!(scrolled[0], "Line 3");
509        assert_eq!(scrolled[2], "Line 5");
510    }
511
512    #[test]
513    fn test_max_scroll_offset() {
514        let bounds = Bounds::new(0, 0, 9, 2); // 3 lines visible
515        let text_dims = TextDimensions::new(bounds);
516
517        let text = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"; // 5 lines total
518        let max_offset = text_dims.max_scroll_offset(text);
519
520        // Should be 5 - 3 = 2 (can scroll down 2 lines)
521        assert_eq!(max_offset, 2);
522    }
523
524    #[test]
525    fn test_text_fit_validation() {
526        let bounds = Bounds::new(0, 0, 9, 2); // 10 chars wide, 3 lines high
527        let text_dims = TextDimensions::new(bounds);
528
529        // Text that fits
530        let short_text = "Hello";
531        let fit_result = text_dims.validate_text_fit(short_text);
532        assert!(fit_result.fits_width);
533        assert!(fit_result.fits_height);
534        assert!(!fit_result.requires_wrapping);
535        assert!(!fit_result.requires_scrolling);
536
537        // Text that needs wrapping
538        let long_text = "This is a very long line";
539        let wrap_result = text_dims.validate_text_fit(long_text);
540        assert!(!wrap_result.fits_width);
541        assert!(wrap_result.requires_wrapping);
542    }
543
544    #[test]
545    fn test_ellipsis_truncation() {
546        let bounds = Bounds::new(0, 0, 4, 0); // 5 chars wide
547        let text_dims = TextDimensions::new(bounds);
548
549        let text = "Hello World";
550        let truncated = text_dims.truncate_with_ellipsis(text, 5);
551
552        assert_eq!(truncated, "Hell…");
553        assert_eq!(truncated.chars().count(), 5);
554
555        // Edge case: width = 1
556        let tiny = text_dims.truncate_with_ellipsis(text, 1);
557        assert_eq!(tiny, "…");
558    }
559}