boxmux_lib/components/dimensions/
text_dimensions.rs1use super::FontMetrics;
2use crate::Bounds;
3
4#[derive(Debug, Clone)]
14pub struct TextDimensions {
15 bounds: Bounds,
17 font_metrics: FontMetrics,
19 alignment: TextAlignment,
21 wrap_behavior: WrapBehavior,
23}
24
25impl TextDimensions {
26 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 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 pub fn available_width(&self) -> usize {
54 self.bounds.width()
55 }
56
57 pub fn available_height(&self) -> usize {
59 self.bounds.height()
60 }
61
62 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 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 let wrapped = self.wrap_long_line(line, width);
99 lines.extend(wrapped);
100 }
101
102 lines
103 }
104
105 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_width > width {
116 if !current_line.is_empty() {
118 lines.push(current_line);
119 current_line = String::new();
120 current_width = 0;
121 }
122
123 let broken_word = self.break_long_word(word, width);
125 lines.extend(broken_word);
126 continue;
127 }
128
129 let space_needed = if current_line.is_empty() { 0 } else { 1 }; if current_width + space_needed + word_width > width {
132 lines.push(current_line);
134 current_line = word.to_string();
135 current_width = word_width;
136 } else {
137 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 if !current_line.is_empty() {
149 lines.push(current_line);
150 }
151
152 lines
153 }
154
155 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 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; let mut result: String = text.chars().take(truncate_at).collect();
186 result.push('…');
187 result
188 }
189
190 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 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 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 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), };
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 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 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 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 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 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 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 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 let fits_width = unwrapped_width <= available_width;
360 let _fits_height = unwrapped_height <= available_height;
361
362 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,
397 Truncate,
399 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); 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 assert!(wrapped.len() > 1);
447
448 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); 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 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); let text_dims = TextDimensions::new(bounds).with_alignment(TextAlignment::Center);
472
473 let text = "Hello"; let layout = text_dims.center_text_in_bounds(text);
475
476 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); 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); assert_eq!(height, 3); }
491
492 #[test]
493 fn test_visible_lines_with_scroll() {
494 let bounds = Bounds::new(0, 0, 9, 2); let text_dims = TextDimensions::new(bounds);
496
497 let text = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5";
498
499 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 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); let text_dims = TextDimensions::new(bounds);
516
517 let text = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"; let max_offset = text_dims.max_scroll_offset(text);
519
520 assert_eq!(max_offset, 2);
522 }
523
524 #[test]
525 fn test_text_fit_validation() {
526 let bounds = Bounds::new(0, 0, 9, 2); let text_dims = TextDimensions::new(bounds);
528
529 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 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); 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 let tiny = text_dims.truncate_with_ellipsis(text, 1);
557 assert_eq!(tiny, "…");
558 }
559}