retroglyph_ui/widget/
paragraph.rs1#[cfg(not(feature = "egc"))]
12use alloc::string::{String, ToString as _};
13#[cfg(not(feature = "egc"))]
14use alloc::vec;
15#[cfg(not(feature = "egc"))]
16use alloc::vec::Vec;
17
18use retroglyph_core::color::Style;
19#[cfg(feature = "egc")]
20use retroglyph_core::grid::HasSize;
21#[cfg(feature = "egc")]
22use retroglyph_core::grid::Rect;
23#[cfg(feature = "egc")]
24use retroglyph_core::layout::TextLayout;
25#[cfg(feature = "egc")]
26use retroglyph_core::text::{Line, Span};
27
28use super::{Measure, Widget};
29use crate::Surface;
30
31#[derive(Clone, Copy, Debug)]
66pub struct Paragraph<'a> {
67 text: &'a str,
68 style: Style,
69}
70
71impl<'a> Paragraph<'a> {
72 #[must_use]
74 pub fn new(text: &'a str) -> Self {
75 Self {
76 text,
77 style: Style::new(),
78 }
79 }
80
81 #[must_use]
83 pub const fn style(mut self, style: Style) -> Self {
84 self.style = style;
85 self
86 }
87
88 #[cfg(feature = "egc")]
89 fn line(&self) -> Line {
90 Line::from(Span::styled(self.text, self.style))
91 }
92}
93
94#[cfg(not(feature = "egc"))]
104fn wrap(text: &str, max_width: u16) -> Vec<String> {
105 use retroglyph_core::text::char_width;
106
107 let mut lines: Vec<String> = vec![String::new()];
108 let mut col: u16 = 0;
109
110 for ch in text.chars() {
111 if ch == '\n' {
112 lines.push(String::new());
113 col = 0;
114 continue;
115 }
116
117 let cw = char_width(ch);
118 if cw == 0 {
119 continue; }
121
122 if col + cw > max_width && col > 0 {
123 let current = lines.last_mut().expect("always at least one line");
124 if let Some(space_idx) = current.rfind(' ') {
125 let remainder = current[space_idx + 1..].to_string();
126 current.truncate(space_idx); col = remainder.chars().map(char_width).sum();
128 lines.push(remainder);
129 } else {
130 lines.push(String::new());
132 col = 0;
133 if ch == ' ' {
134 continue;
136 }
137 }
138 }
139
140 lines.last_mut().expect("always at least one line").push(ch);
141 col += cw;
142 }
143
144 lines
145}
146
147#[cfg(feature = "egc")]
148impl Measure for Paragraph<'_> {
149 fn height_for(&self, width: u16) -> u16 {
150 let line = self.line();
151 TextLayout::new(&line)
152 .rect(Rect::new(0, 0, width, u16::MAX))
153 .measure()
154 .height()
155 }
156}
157
158#[cfg(not(feature = "egc"))]
159impl Measure for Paragraph<'_> {
160 fn height_for(&self, width: u16) -> u16 {
161 let lines = wrap(self.text, width);
162 #[allow(clippy::cast_possible_truncation)]
163 let height = lines.len().min(usize::from(u16::MAX)) as u16;
164 height
165 }
166}
167
168#[cfg(feature = "egc")]
169impl Widget for Paragraph<'_> {
170 fn render(&self, surface: &mut Surface<'_>) {
171 let area = surface.area();
172 let line = self.line();
173 TextLayout::new(&line).rect(area).render_to_surface(surface);
174 }
175}
176
177#[cfg(not(feature = "egc"))]
178impl Widget for Paragraph<'_> {
179 fn render(&self, surface: &mut Surface<'_>) {
180 let width = surface.area().width();
181 let height = surface.area().height();
182 let lines = wrap(self.text, width);
183 for (row, line) in lines.into_iter().take(usize::from(height)).enumerate() {
184 #[allow(clippy::cast_possible_truncation)] surface.print((0, row as u16), &line, self.style);
186 }
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use alloc::string::String;
193
194 use retroglyph_core::grid::{Grid, Pos, Rect};
195
196 use super::*;
197
198 #[test]
199 fn height_for_matches_wrapped_line_count() {
200 let p = Paragraph::new("the quick brown fox jumps");
201 assert_eq!(p.height_for(10), 3); assert_eq!(p.height_for(100), 1);
203 }
204
205 #[test]
206 fn height_for_respects_hard_newlines() {
207 let p = Paragraph::new("first\nsecond\nthird");
210 assert_eq!(p.height_for(100), 3);
211 }
212
213 #[test]
214 fn render_draws_one_line_per_wrapped_row() {
215 let area = Rect::new(0, 0, 10, 5);
216 let mut grid = Grid::new(10, 5);
217 Paragraph::new("the quick brown fox jumps").render(&mut Surface::new(&mut grid, area, 0));
218
219 let row0: String = (0..10).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
220 let row1: String = (0..10).map(|x| grid[Pos::new(x, 1)].glyph()).collect();
221 let row2: String = (0..10).map(|x| grid[Pos::new(x, 2)].glyph()).collect();
222 assert!(row0.starts_with("the quick"));
223 assert!(row1.starts_with("brown fox"));
224 assert!(row2.starts_with("jumps"));
225 }
226
227 #[test]
228 fn render_stops_at_the_area_bottom() {
229 let area = Rect::new(0, 0, 10, 1);
231 let mut grid = Grid::new(10, 2);
232 Paragraph::new("the quick brown fox jumps").render(&mut Surface::new(&mut grid, area, 0));
233
234 let row1: String = (0..10).map(|x| grid[Pos::new(x, 1)].glyph()).collect();
235 assert_eq!(row1.trim(), "");
236 }
237
238 #[cfg(feature = "egc")]
239 #[test]
240 fn paragraph_honours_the_surface_clip() {
241 let area = Rect::new(0, 0, 10, 3);
245 let mut grid = Grid::new(10, 3);
246 {
247 let mut surface = Surface::new(&mut grid, area, 0);
248 let mut clipped = surface.clip(Rect::new(0, 0, 10, 1));
249 Paragraph::new("aaaa bbbb cccc").render(&mut clipped);
250 }
251
252 let row0: String = (0..10).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
253 assert_eq!(row0.trim_end(), "aaaa bbbb");
254 for row in 1..3 {
255 let r: String = (0..10).map(|x| grid[Pos::new(x, row)].glyph()).collect();
256 assert_eq!(r.trim(), "");
257 }
258 }
259}