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