1use crate::grid::{Grid, Rect};
10use crate::style::Style;
11use crate::surface::Surface;
12use crate::text::Line;
13use alloc::string::String;
14use alloc::vec::Vec;
15use unicode_segmentation::UnicodeSegmentation;
16use unicode_width::UnicodeWidthStr;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
20pub enum HAlign {
21 #[default]
23 Left,
24 Center,
26 Right,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
32pub enum VAlign {
33 #[default]
35 Top,
36 Middle,
38 Bottom,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
44pub struct TextMetrics {
45 pub width: u16,
47 pub height: u16,
49}
50
51struct WrappedGlyph {
57 grapheme: String,
59 style: Style,
61 width: u16,
63}
64
65struct WrappedLine {
67 glyphs: Vec<WrappedGlyph>,
68 width: u16,
70}
71
72fn wrap_line(line: &Line, max_width: u16) -> Vec<WrappedLine> {
86 let mut lines: Vec<WrappedLine> = alloc::vec![WrappedLine {
87 glyphs: Vec::new(),
88 width: 0,
89 }];
90 let mut col: u16 = 0;
91
92 for span in &line.spans {
93 for grapheme in span.content.graphemes(true) {
94 if grapheme == "\n" {
96 lines.push(WrappedLine {
97 glyphs: Vec::new(),
98 width: 0,
99 });
100 col = 0;
101 continue;
102 }
103
104 #[allow(clippy::cast_possible_truncation)]
105 let gw = grapheme.width() as u16;
106 if gw == 0 {
107 continue; }
109
110 if col + gw > max_width && col > 0 {
112 let current = lines.last_mut().expect("always at least one line");
113
114 if let Some(space_idx) = current.glyphs.iter().rposition(|g| g.grapheme == " ") {
116 let remainder: Vec<WrappedGlyph> =
118 current.glyphs.drain(space_idx + 1..).collect();
119 current.glyphs.pop();
121 current.width = current.glyphs.iter().map(|g| g.width).sum();
122
123 let new_width: u16 = remainder.iter().map(|g| g.width).sum();
124 col = new_width;
126 lines.push(WrappedLine {
127 glyphs: remainder,
128 width: new_width,
129 });
130 } else {
131 lines.push(WrappedLine {
133 glyphs: Vec::new(),
134 width: 0,
135 });
136 col = 0;
137 if grapheme == " " {
140 continue;
141 }
142 }
143 }
144
145 let current = lines.last_mut().expect("always at least one line");
146 current.width += gw;
147 current.glyphs.push(WrappedGlyph {
148 grapheme: String::from(grapheme),
149 style: span.style,
150 width: gw,
151 });
152 col += gw;
153 }
154 }
155
156 lines
157}
158
159pub struct TextLayout<'a> {
187 line: &'a Line,
188 rect: Rect,
189 h_align: HAlign,
190 v_align: VAlign,
191}
192
193impl<'a> TextLayout<'a> {
194 #[must_use]
200 pub const fn new(line: &'a Line) -> Self {
201 Self {
202 line,
203 rect: Rect::EMPTY,
204 h_align: HAlign::Left,
205 v_align: VAlign::Top,
206 }
207 }
208
209 #[must_use]
211 pub const fn rect(mut self, rect: Rect) -> Self {
212 self.rect = rect;
213 self
214 }
215
216 #[must_use]
218 pub const fn h_align(mut self, align: HAlign) -> Self {
219 self.h_align = align;
220 self
221 }
222
223 #[must_use]
225 pub const fn v_align(mut self, align: VAlign) -> Self {
226 self.v_align = align;
227 self
228 }
229
230 #[must_use]
234 pub fn measure(&self) -> TextMetrics {
235 let lines = wrap_line(self.line, self.rect.width());
236 let width = lines.iter().map(|l| l.width).max().unwrap_or(0);
237 #[allow(clippy::cast_possible_truncation)]
238 let height = lines.len().min(u16::MAX as usize) as u16;
239 TextMetrics { width, height }
240 }
241
242 pub fn render_to_surface(&self, surface: &mut Surface<'_>) {
246 let clipped = Self {
247 line: self.line,
248 rect: self.rect.intersect(surface.area()),
249 h_align: self.h_align,
250 v_align: self.v_align,
251 };
252 let layer = surface.layer();
253 clipped.render_to_grid(surface.grid_mut(), layer);
254 }
255
256 pub fn render_to_grid(&self, grid: &mut Grid, layer: u8) {
261 let lines = wrap_line(self.line, self.rect.width());
262 let rect = self.rect;
263
264 #[allow(clippy::cast_possible_truncation)]
265 let total_lines = lines.len().min(usize::from(rect.height())) as u16;
266
267 let y_offset = match self.v_align {
268 VAlign::Top => 0,
269 VAlign::Middle => rect.height().saturating_sub(total_lines) / 2,
270 VAlign::Bottom => rect.height().saturating_sub(total_lines),
271 };
272
273 for (line_idx, wrapped) in lines.into_iter().take(total_lines as usize).enumerate() {
274 let x_offset = match self.h_align {
275 HAlign::Left => 0,
276 HAlign::Center => rect.width().saturating_sub(wrapped.width) / 2,
277 HAlign::Right => rect.width().saturating_sub(wrapped.width),
278 };
279
280 #[allow(clippy::cast_possible_truncation)]
281 let row = rect.top() + y_offset + line_idx as u16;
282 let mut cx = rect.left() + x_offset;
283
284 for glyph in wrapped.glyphs {
285 if cx >= rect.right() {
286 break;
287 }
288 grid.write_grapheme(layer, cx, row, &glyph.grapheme, glyph.style);
289 cx += glyph.width;
290 }
291 }
292 }
293}
294
295#[cfg(test)]
300mod tests {
301 use super::*;
302 use crate::color::Color;
303 use crate::grid::Pos;
304 use crate::style::Style;
305 use crate::text::{Line, Span};
306
307 fn red() -> Style {
308 Style::new().fg(Color::RED)
309 }
310
311 #[test]
314 fn test_wrap_no_wrap_needed() {
315 let line = Line::raw("hello");
316 let lines = wrap_line(&line, 10);
317 assert_eq!(lines.len(), 1);
318 assert_eq!(lines[0].width, 5);
319 }
320
321 #[test]
322 fn test_wrap_hard_newline() {
323 let line = Line::raw("hi\nthere");
324 let lines = wrap_line(&line, 20);
325 assert_eq!(lines.len(), 2);
326 assert_eq!(lines[0].width, 2);
327 assert_eq!(lines[1].width, 5);
328 }
329
330 #[test]
331 fn test_wrap_soft_break_on_space() {
332 let line = Line::raw("hello world");
334 let lines = wrap_line(&line, 7);
335 assert_eq!(lines.len(), 2);
336 assert_eq!(lines[0].width, 5); assert_eq!(lines[1].width, 5); }
339
340 #[test]
341 fn test_wrap_force_break_no_space() {
342 let line = Line::raw("abcdefgh");
343 let lines = wrap_line(&line, 4);
344 assert_eq!(lines.len(), 2);
345 assert_eq!(lines[0].width, 4);
346 assert_eq!(lines[1].width, 4);
347 }
348
349 #[test]
350 fn test_wrap_wide_chars() {
351 let line = Line::raw("中文中");
353 let lines = wrap_line(&line, 4);
354 assert_eq!(lines.len(), 2);
355 assert_eq!(lines[0].width, 4);
356 assert_eq!(lines[1].width, 2);
357 }
358
359 #[test]
360 fn test_wrap_multi_span() {
361 let line = Line::from(vec![Span::raw("foo "), Span::styled("bar", red())]);
362 let lines = wrap_line(&line, 20);
363 assert_eq!(lines.len(), 1);
364 assert_eq!(lines[0].width, 7);
365 let bar_count = lines[0].glyphs.iter().filter(|g| g.style == red()).count();
367 assert_eq!(bar_count, 3);
368 }
369
370 #[test]
373 fn test_measure_single_line() {
374 let line = Line::raw("hello");
375 let m = TextLayout::new(&line)
376 .rect(Rect::new(0, 0, 20, 5))
377 .measure();
378 assert_eq!(m.width, 5);
379 assert_eq!(m.height, 1);
380 }
381
382 #[test]
383 fn test_measure_wraps() {
384 let line = Line::raw("hello world");
385 let m = TextLayout::new(&line)
386 .rect(Rect::new(0, 0, 7, 10))
387 .measure();
388 assert_eq!(m.height, 2);
389 assert_eq!(m.width, 5);
390 }
391
392 #[test]
395 fn test_render_left_top() {
396 use crate::backend::Headless;
397 use crate::terminal::Terminal;
398
399 let mut term = Terminal::new(Headless::new(20, 5));
400 let line = Line::raw("hi");
401 TextLayout::new(&line)
402 .rect(Rect::new(2, 1, 10, 3))
403 .render_to_surface(&mut term.surface());
404
405 assert_eq!(term.grid()[Pos::new(2, 1)].glyph(), 'h');
406 assert_eq!(term.grid()[Pos::new(3, 1)].glyph(), 'i');
407 assert_eq!(term.grid()[Pos::new(4, 1)].glyph(), ' '); }
409
410 #[test]
411 fn test_render_center_h() {
412 use crate::backend::Headless;
413 use crate::terminal::Terminal;
414
415 let mut term = Terminal::new(Headless::new(20, 5));
417 let line = Line::raw("hi");
418 TextLayout::new(&line)
419 .rect(Rect::new(0, 0, 10, 3))
420 .h_align(HAlign::Center)
421 .render_to_surface(&mut term.surface());
422
423 assert_eq!(term.grid()[Pos::new(4, 0)].glyph(), 'h');
424 assert_eq!(term.grid()[Pos::new(5, 0)].glyph(), 'i');
425 }
426
427 #[test]
428 fn test_render_right_h() {
429 use crate::backend::Headless;
430 use crate::terminal::Terminal;
431
432 let mut term = Terminal::new(Headless::new(20, 5));
434 let line = Line::raw("hi");
435 TextLayout::new(&line)
436 .rect(Rect::new(0, 0, 10, 3))
437 .h_align(HAlign::Right)
438 .render_to_surface(&mut term.surface());
439
440 assert_eq!(term.grid()[Pos::new(8, 0)].glyph(), 'h');
441 assert_eq!(term.grid()[Pos::new(9, 0)].glyph(), 'i');
442 }
443
444 #[test]
445 fn test_render_middle_v() {
446 use crate::backend::Headless;
447 use crate::terminal::Terminal;
448
449 let mut term = Terminal::new(Headless::new(20, 10));
451 let line = Line::raw("hi");
452 TextLayout::new(&line)
453 .rect(Rect::new(0, 0, 10, 5))
454 .v_align(VAlign::Middle)
455 .render_to_surface(&mut term.surface());
456
457 assert_eq!(term.grid()[Pos::new(0, 2)].glyph(), 'h');
458 }
459
460 #[test]
461 fn test_render_bottom_v() {
462 use crate::backend::Headless;
463 use crate::terminal::Terminal;
464
465 let mut term = Terminal::new(Headless::new(20, 10));
467 let line = Line::raw("hi");
468 TextLayout::new(&line)
469 .rect(Rect::new(0, 0, 10, 5))
470 .v_align(VAlign::Bottom)
471 .render_to_surface(&mut term.surface());
472
473 assert_eq!(term.grid()[Pos::new(0, 4)].glyph(), 'h');
474 }
475
476 #[test]
477 fn test_render_clips_to_height() {
478 use crate::backend::Headless;
479 use crate::terminal::Terminal;
480
481 let mut term = Terminal::new(Headless::new(10, 10));
483 let line = Line::raw("a b c");
484 TextLayout::new(&line)
485 .rect(Rect::new(0, 0, 1, 2))
486 .render_to_surface(&mut term.surface());
487
488 assert_eq!(term.grid()[Pos::new(0, 0)].glyph(), 'a');
489 assert_eq!(term.grid()[Pos::new(0, 1)].glyph(), 'b');
490 assert_eq!(term.grid()[Pos::new(0, 2)].glyph(), ' '); }
492}