1use crate::backend::Backend;
10use crate::grid::Rect;
11use crate::style::Style;
12use crate::terminal::Terminal;
13use crate::text::Line;
14use alloc::string::String;
15use alloc::vec::Vec;
16use unicode_segmentation::UnicodeSegmentation;
17use unicode_width::UnicodeWidthStr;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
21pub enum HAlign {
22 #[default]
24 Left,
25 Center,
27 Right,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
33pub enum VAlign {
34 #[default]
36 Top,
37 Middle,
39 Bottom,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
45pub struct TextMetrics {
46 pub width: u16,
48 pub height: u16,
50}
51
52struct WrappedGlyph {
58 grapheme: String,
60 style: Style,
62 width: u16,
64}
65
66struct WrappedLine {
68 glyphs: Vec<WrappedGlyph>,
69 width: u16,
71}
72
73fn wrap_line(line: &Line, max_width: u16) -> Vec<WrappedLine> {
87 let mut lines: Vec<WrappedLine> = alloc::vec![WrappedLine {
88 glyphs: Vec::new(),
89 width: 0,
90 }];
91 let mut col: u16 = 0;
92
93 for span in &line.spans {
94 for grapheme in span.content.graphemes(true) {
95 if grapheme == "\n" {
97 lines.push(WrappedLine {
98 glyphs: Vec::new(),
99 width: 0,
100 });
101 col = 0;
102 continue;
103 }
104
105 #[allow(clippy::cast_possible_truncation)]
106 let gw = grapheme.width() as u16;
107 if gw == 0 {
108 continue; }
110
111 if col + gw > max_width && col > 0 {
113 let current = lines.last_mut().expect("always at least one line");
114
115 if let Some(space_idx) = current.glyphs.iter().rposition(|g| g.grapheme == " ") {
117 let remainder: Vec<WrappedGlyph> =
119 current.glyphs.drain(space_idx + 1..).collect();
120 current.glyphs.pop();
122 current.width = current.glyphs.iter().map(|g| g.width).sum();
123
124 let new_width: u16 = remainder.iter().map(|g| g.width).sum();
125 col = new_width;
127 lines.push(WrappedLine {
128 glyphs: remainder,
129 width: new_width,
130 });
131 } else {
132 lines.push(WrappedLine {
134 glyphs: Vec::new(),
135 width: 0,
136 });
137 col = 0;
138 if grapheme == " " {
141 continue;
142 }
143 }
144 }
145
146 let current = lines.last_mut().expect("always at least one line");
147 current.width += gw;
148 current.glyphs.push(WrappedGlyph {
149 grapheme: String::from(grapheme),
150 style: span.style,
151 width: gw,
152 });
153 col += gw;
154 }
155 }
156
157 lines
158}
159
160pub struct TextLayout<'a> {
188 line: &'a Line,
189 rect: Rect,
190 h_align: HAlign,
191 v_align: VAlign,
192}
193
194impl<'a> TextLayout<'a> {
195 #[must_use]
201 pub const fn new(line: &'a Line) -> Self {
202 Self {
203 line,
204 rect: Rect::EMPTY,
205 h_align: HAlign::Left,
206 v_align: VAlign::Top,
207 }
208 }
209
210 #[must_use]
212 pub const fn rect(mut self, rect: Rect) -> Self {
213 self.rect = rect;
214 self
215 }
216
217 #[must_use]
219 pub const fn h_align(mut self, align: HAlign) -> Self {
220 self.h_align = align;
221 self
222 }
223
224 #[must_use]
226 pub const fn v_align(mut self, align: VAlign) -> Self {
227 self.v_align = align;
228 self
229 }
230
231 #[must_use]
235 pub fn measure(&self) -> TextMetrics {
236 let lines = wrap_line(self.line, self.rect.width());
237 let width = lines.iter().map(|l| l.width).max().unwrap_or(0);
238 #[allow(clippy::cast_possible_truncation)]
239 let height = lines.len().min(u16::MAX as usize) as u16;
240 TextMetrics { width, height }
241 }
242
243 pub fn render<B: Backend>(&self, terminal: &mut Terminal<B>) {
245 let lines = wrap_line(self.line, self.rect.width());
246 let rect = self.rect;
247
248 #[allow(clippy::cast_possible_truncation)]
249 let total_lines = lines.len().min(usize::from(rect.height())) as u16;
250
251 let y_offset = match self.v_align {
252 VAlign::Top => 0,
253 VAlign::Middle => rect.height().saturating_sub(total_lines) / 2,
254 VAlign::Bottom => rect.height().saturating_sub(total_lines),
255 };
256
257 for (line_idx, wrapped) in lines.into_iter().take(total_lines as usize).enumerate() {
258 let x_offset = match self.h_align {
259 HAlign::Left => 0,
260 HAlign::Center => rect.width().saturating_sub(wrapped.width) / 2,
261 HAlign::Right => rect.width().saturating_sub(wrapped.width),
262 };
263
264 #[allow(clippy::cast_possible_truncation)]
265 let row = rect.top() + y_offset + line_idx as u16;
266 let mut cx = rect.left() + x_offset;
267
268 for glyph in wrapped.glyphs {
269 if cx >= rect.right() {
270 break;
271 }
272 terminal
273 .grid_mut()
274 .write_grapheme(cx, row, &glyph.grapheme, glyph.style);
275 cx += glyph.width;
276 }
277 }
278 }
279}
280
281#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::color::Color;
289 use crate::style::Style;
290 use crate::text::{Line, Span};
291
292 fn red() -> Style {
293 Style::new().fg(Color::RED)
294 }
295
296 #[test]
299 fn test_wrap_no_wrap_needed() {
300 let line = Line::raw("hello");
301 let lines = wrap_line(&line, 10);
302 assert_eq!(lines.len(), 1);
303 assert_eq!(lines[0].width, 5);
304 }
305
306 #[test]
307 fn test_wrap_hard_newline() {
308 let line = Line::raw("hi\nthere");
309 let lines = wrap_line(&line, 20);
310 assert_eq!(lines.len(), 2);
311 assert_eq!(lines[0].width, 2);
312 assert_eq!(lines[1].width, 5);
313 }
314
315 #[test]
316 fn test_wrap_soft_break_on_space() {
317 let line = Line::raw("hello world");
319 let lines = wrap_line(&line, 7);
320 assert_eq!(lines.len(), 2);
321 assert_eq!(lines[0].width, 5); assert_eq!(lines[1].width, 5); }
324
325 #[test]
326 fn test_wrap_force_break_no_space() {
327 let line = Line::raw("abcdefgh");
328 let lines = wrap_line(&line, 4);
329 assert_eq!(lines.len(), 2);
330 assert_eq!(lines[0].width, 4);
331 assert_eq!(lines[1].width, 4);
332 }
333
334 #[test]
335 fn test_wrap_wide_chars() {
336 let line = Line::raw("中文中");
338 let lines = wrap_line(&line, 4);
339 assert_eq!(lines.len(), 2);
340 assert_eq!(lines[0].width, 4);
341 assert_eq!(lines[1].width, 2);
342 }
343
344 #[test]
345 fn test_wrap_multi_span() {
346 let line = Line::from(vec![Span::raw("foo "), Span::styled("bar", red())]);
347 let lines = wrap_line(&line, 20);
348 assert_eq!(lines.len(), 1);
349 assert_eq!(lines[0].width, 7);
350 let bar_count = lines[0].glyphs.iter().filter(|g| g.style == red()).count();
352 assert_eq!(bar_count, 3);
353 }
354
355 #[test]
358 fn test_measure_single_line() {
359 let line = Line::raw("hello");
360 let m = TextLayout::new(&line)
361 .rect(Rect::new(0, 0, 20, 5))
362 .measure();
363 assert_eq!(m.width, 5);
364 assert_eq!(m.height, 1);
365 }
366
367 #[test]
368 fn test_measure_wraps() {
369 let line = Line::raw("hello world");
370 let m = TextLayout::new(&line)
371 .rect(Rect::new(0, 0, 7, 10))
372 .measure();
373 assert_eq!(m.height, 2);
374 assert_eq!(m.width, 5);
375 }
376
377 #[test]
380 fn test_render_left_top() {
381 use crate::backend::Headless;
382 use crate::terminal::Terminal;
383
384 let mut term = Terminal::new(Headless::new(20, 5));
385 let line = Line::raw("hi");
386 TextLayout::new(&line)
387 .rect(Rect::new(2, 1, 10, 3))
388 .render(&mut term);
389
390 assert_eq!(term.grid().get(2, 1).glyph(), 'h');
391 assert_eq!(term.grid().get(3, 1).glyph(), 'i');
392 assert_eq!(term.grid().get(4, 1).glyph(), ' '); }
394
395 #[test]
396 fn test_render_center_h() {
397 use crate::backend::Headless;
398 use crate::terminal::Terminal;
399
400 let mut term = Terminal::new(Headless::new(20, 5));
402 let line = Line::raw("hi");
403 TextLayout::new(&line)
404 .rect(Rect::new(0, 0, 10, 3))
405 .h_align(HAlign::Center)
406 .render(&mut term);
407
408 assert_eq!(term.grid().get(4, 0).glyph(), 'h');
409 assert_eq!(term.grid().get(5, 0).glyph(), 'i');
410 }
411
412 #[test]
413 fn test_render_right_h() {
414 use crate::backend::Headless;
415 use crate::terminal::Terminal;
416
417 let mut term = Terminal::new(Headless::new(20, 5));
419 let line = Line::raw("hi");
420 TextLayout::new(&line)
421 .rect(Rect::new(0, 0, 10, 3))
422 .h_align(HAlign::Right)
423 .render(&mut term);
424
425 assert_eq!(term.grid().get(8, 0).glyph(), 'h');
426 assert_eq!(term.grid().get(9, 0).glyph(), 'i');
427 }
428
429 #[test]
430 fn test_render_middle_v() {
431 use crate::backend::Headless;
432 use crate::terminal::Terminal;
433
434 let mut term = Terminal::new(Headless::new(20, 10));
436 let line = Line::raw("hi");
437 TextLayout::new(&line)
438 .rect(Rect::new(0, 0, 10, 5))
439 .v_align(VAlign::Middle)
440 .render(&mut term);
441
442 assert_eq!(term.grid().get(0, 2).glyph(), 'h');
443 }
444
445 #[test]
446 fn test_render_bottom_v() {
447 use crate::backend::Headless;
448 use crate::terminal::Terminal;
449
450 let mut term = Terminal::new(Headless::new(20, 10));
452 let line = Line::raw("hi");
453 TextLayout::new(&line)
454 .rect(Rect::new(0, 0, 10, 5))
455 .v_align(VAlign::Bottom)
456 .render(&mut term);
457
458 assert_eq!(term.grid().get(0, 4).glyph(), 'h');
459 }
460
461 #[test]
462 fn test_render_clips_to_height() {
463 use crate::backend::Headless;
464 use crate::terminal::Terminal;
465
466 let mut term = Terminal::new(Headless::new(10, 10));
468 let line = Line::raw("a b c");
469 TextLayout::new(&line)
470 .rect(Rect::new(0, 0, 1, 2))
471 .render(&mut term);
472
473 assert_eq!(term.grid().get(0, 0).glyph(), 'a');
474 assert_eq!(term.grid().get(0, 1).glyph(), 'b');
475 assert_eq!(term.grid().get(0, 2).glyph(), ' '); }
477}