/home/runner/work/retroglyph/retroglyph/crates/ui/src/style.rs
Line | Count | Source |
1 | | //! [`BoxStyle`]: a Lip-Gloss-style box model (padding, border, margin). |
2 | | //! |
3 | | //! Renders content into a standalone [`Grid`], independent of any |
4 | | //! [`Backend`](retroglyph_core::backend::Backend)/[`Terminal`](retroglyph_core::terminal::Terminal). |
5 | | //! |
6 | | //! `BoxStyle` does not word-wrap: it lays out already-broken lines (only |
7 | | //! `'\n'` is treated specially). |
8 | | //! |
9 | | //! For word-wrapping text to a width first, use `Paragraph`, then hand the |
10 | | //! wrapped result to `BoxStyle::render`. Keeping wrapping and box-model |
11 | | //! layout separate avoids tying every consumer of this module to `Paragraph` |
12 | | //! or the `egc` feature. |
13 | | use alloc::vec::Vec; |
14 | | |
15 | | use retroglyph_core::color::Style; |
16 | | use retroglyph_core::grid::Grid; |
17 | | use retroglyph_core::text::{char_width, width_usize as measured_width}; |
18 | | use retroglyph_core::tile::Tile; |
19 | | // `Rect` and `HasSize` are only named by the `egc` content-measuring path below and by this |
20 | | // module's tests. |
21 | | #[cfg(feature = "egc")] |
22 | | use retroglyph_core::grid::{HasSize, Rect}; |
23 | | |
24 | | use crate::Surface; |
25 | | use crate::text::truncate; |
26 | | use crate::widget::Widget; |
27 | | use retroglyph_core::symbols::border::PLAIN; |
28 | | |
29 | | /// CSS-style box-model sides: top/right/bottom/left, in terminal cells. |
30 | | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
31 | | pub struct Sides { |
32 | | /// Cells above. |
33 | | pub top: u16, |
34 | | /// Cells to the right. |
35 | | pub right: u16, |
36 | | /// Cells below. |
37 | | pub bottom: u16, |
38 | | /// Cells to the left. |
39 | | pub left: u16, |
40 | | } |
41 | | |
42 | | impl Sides { |
43 | | /// No space on any side. |
44 | | pub const ZERO: Self = Self { |
45 | | top: 0, |
46 | | right: 0, |
47 | | bottom: 0, |
48 | | left: 0, |
49 | | }; |
50 | | |
51 | | /// The same number of cells on all four sides. |
52 | | #[must_use] |
53 | 6 | pub const fn all(n: u16) -> Self { |
54 | 6 | Self { |
55 | 6 | top: n, |
56 | 6 | right: n, |
57 | 6 | bottom: n, |
58 | 6 | left: n, |
59 | 6 | } |
60 | 6 | } |
61 | | |
62 | | /// `vertical` cells top/bottom, `horizontal` cells left/right (CSS |
63 | | /// `padding: v h` shorthand). |
64 | | #[must_use] |
65 | 4 | pub const fn symmetric(vertical: u16, horizontal: u16) -> Self { |
66 | 4 | Self { |
67 | 4 | top: vertical, |
68 | 4 | right: horizontal, |
69 | 4 | bottom: vertical, |
70 | 4 | left: horizontal, |
71 | 4 | } |
72 | 4 | } |
73 | | |
74 | | /// Returns `self` with `top` replaced. |
75 | | #[must_use] |
76 | 0 | pub const fn top(mut self, top: u16) -> Self { |
77 | 0 | self.top = top; |
78 | 0 | self |
79 | 0 | } |
80 | | |
81 | | /// Returns `self` with `right` replaced. |
82 | | #[must_use] |
83 | 0 | pub const fn right(mut self, right: u16) -> Self { |
84 | 0 | self.right = right; |
85 | 0 | self |
86 | 0 | } |
87 | | |
88 | | /// Returns `self` with `bottom` replaced. |
89 | | #[must_use] |
90 | 0 | pub const fn bottom(mut self, bottom: u16) -> Self { |
91 | 0 | self.bottom = bottom; |
92 | 0 | self |
93 | 0 | } |
94 | | |
95 | | /// Returns `self` with `left` replaced. |
96 | | #[must_use] |
97 | 0 | pub const fn left(mut self, left: u16) -> Self { |
98 | 0 | self.left = left; |
99 | 0 | self |
100 | 0 | } |
101 | | |
102 | 32 | const fn horizontal(self) -> u16 { |
103 | 32 | self.left.saturating_add(self.right) |
104 | 32 | } |
105 | | |
106 | 32 | const fn vertical(self) -> u16 { |
107 | 32 | self.top.saturating_add(self.bottom) |
108 | 32 | } |
109 | | } |
110 | | |
111 | | /// A box-model wrapper: content, padding, an optional single-line border, |
112 | | /// and margin, rendered into a standalone [`Grid`] via [`BoxStyle::render`]. |
113 | | /// |
114 | | /// Layers from the inside out: content -> padding -> border -> margin. |
115 | | /// Margin cells are left empty (transparent, per [`Grid::new`]'s default |
116 | | /// tiles), matching CSS margin being outside the box's own background. |
117 | | /// |
118 | | /// # Examples |
119 | | /// |
120 | | /// ``` |
121 | | /// use retroglyph_core::color::Style; |
122 | | /// use retroglyph_core::grid::Pos; |
123 | | /// use retroglyph_ui::{BoxStyle, Sides}; |
124 | | /// |
125 | | /// let grid = BoxStyle::new(Style::new()) |
126 | | /// .border(true) |
127 | | /// .padding(Sides::all(1)) |
128 | | /// .render("hi"); |
129 | | /// assert_eq!(grid[Pos::new(2, 2)].glyph(), 'h'); // 1 border + 1 padding cell in from the corner |
130 | | /// ``` |
131 | | #[derive(Clone, Copy, Debug)] |
132 | | pub struct BoxStyle { |
133 | | style: Style, |
134 | | padding: Sides, |
135 | | margin: Sides, |
136 | | border: bool, |
137 | | width: Option<u16>, |
138 | | height: Option<u16>, |
139 | | } |
140 | | |
141 | | impl BoxStyle { |
142 | | /// A borderless box with no padding/margin, in `style`, sized to fit its |
143 | | /// content. |
144 | | #[must_use] |
145 | 16 | pub const fn new(style: Style) -> Self { |
146 | 16 | Self { |
147 | 16 | style, |
148 | 16 | padding: Sides::ZERO, |
149 | 16 | margin: Sides::ZERO, |
150 | 16 | border: false, |
151 | 16 | width: None, |
152 | 16 | height: None, |
153 | 16 | } |
154 | 16 | } |
155 | | |
156 | | /// Sets the padding, between the border (if any) and the content. |
157 | | #[must_use] |
158 | 3 | pub const fn padding(mut self, padding: Sides) -> Self { |
159 | 3 | self.padding = padding; |
160 | 3 | self |
161 | 3 | } |
162 | | |
163 | | /// Sets the margin, outside the border (if any); left transparent. |
164 | | #[must_use] |
165 | 1 | pub const fn margin(mut self, margin: Sides) -> Self { |
166 | 1 | self.margin = margin; |
167 | 1 | self |
168 | 1 | } |
169 | | |
170 | | /// Draws a single-line border, in `style`, around the padding. |
171 | | #[must_use] |
172 | 4 | pub const fn border(mut self, border: bool) -> Self { |
173 | 4 | self.border = border; |
174 | 4 | self |
175 | 4 | } |
176 | | |
177 | | /// Sets an explicit content width (excludes padding/border/margin). |
178 | | /// |
179 | | /// Lines wider than this are clipped; without this, the box sizes to |
180 | | /// its widest content line. |
181 | | #[must_use] |
182 | 3 | pub const fn width(mut self, width: u16) -> Self { |
183 | 3 | self.width = Some(width); |
184 | 3 | self |
185 | 3 | } |
186 | | |
187 | | /// Sets an explicit content height (excludes padding/border/margin). |
188 | | /// |
189 | | /// Lines past this are dropped; without this, the box sizes to the |
190 | | /// number of lines in the content. |
191 | | #[must_use] |
192 | 1 | pub const fn height(mut self, height: u16) -> Self { |
193 | 1 | self.height = Some(height); |
194 | 1 | self |
195 | 1 | } |
196 | | |
197 | | /// Renders `text` into a standalone [`Grid`]: content, padding, border, |
198 | | /// and margin, in that order from the inside out. |
199 | | /// |
200 | | /// `text` is split only on `'\n'`; it is not word-wrapped (see the |
201 | | /// module docs). |
202 | | /// |
203 | | /// Content is positioned by display column (via `retroglyph_core::text`), so a |
204 | | /// wide (2-column) character correctly pushes later characters on the |
205 | | /// same line over by 2 columns rather than 1, and gets a proper |
206 | | /// `WIDE_CHAR_SPACER` reservation on the cell to its right, courtesy of |
207 | | /// `retroglyph_core::grid::Grid::put_tile` (wide-char aware on every feature |
208 | | /// combination, not just `egc`; this module still does not depend on it). |
209 | | #[must_use] |
210 | 13 | pub fn render(&self, text: &str) -> Grid { |
211 | 13 | let lines: Vec<&str> = text.split('\n').collect(); |
212 | 13 | let content_w = self.width.unwrap_or_else(|| {12 |
213 | 16 | u16::try_from12 (lines.iter()12 .map12 (|l| measured_width(l)).max12 ().unwrap_or12 (0)) |
214 | 12 | .unwrap_or(u16::MAX) |
215 | 12 | }); |
216 | 13 | let content_h = self |
217 | 13 | .height |
218 | 13 | .unwrap_or_else(|| u16::try_from12 (lines12 .len12 ()).unwrap_or12 (u16::MAX)); |
219 | | |
220 | 13 | let (mut grid, content_x, content_y) = self.scaffold(content_w, content_h); |
221 | 15 | for (row, line) in lines.iter()13 .take13 (usize::from13 (content_h13 )).enumerate13 () { |
222 | 15 | let Ok(row) = u16::try_from(row) else { break0 }; |
223 | 15 | let clipped = truncate(line, content_w); |
224 | 15 | let mut col = 0u16; |
225 | 65.5k | for ch in clipped15 .chars15 () { |
226 | 65.5k | let w = char_width(ch); |
227 | 65.5k | if col.saturating_add(w) > content_w { |
228 | 0 | break; |
229 | 65.5k | } |
230 | 65.5k | grid.put_tile( |
231 | | 0, |
232 | 65.5k | (content_x.saturating_add(col), content_y.saturating_add(row)), |
233 | 65.5k | Tile::new(ch, self.style), |
234 | | ); |
235 | 65.5k | col = col.saturating_add(w); |
236 | | } |
237 | | } |
238 | 13 | grid |
239 | 13 | } |
240 | | |
241 | | /// Word-wraps `text` to this box's content width, then renders it the |
242 | | /// same way as [`render`](Self::render): content, padding, border, and |
243 | | /// margin, from the inside out. |
244 | | /// |
245 | | /// Requires the `egc` feature: wrapping is delegated to |
246 | | /// `retroglyph_core::layout::TextLayout`, which (unlike `render`) also |
247 | | /// places wide characters correctly, with a proper `WIDE_CHAR_SPACER`. |
248 | | /// If no explicit width was set via [`BoxStyle::width`], `text` is |
249 | | /// measured but not wrapped (there is no width to wrap to), matching |
250 | | /// `render`'s own natural-width fallback. |
251 | | #[cfg(feature = "egc")] |
252 | | #[must_use] |
253 | 3 | pub fn render_wrapped(&self, text: &str) -> Grid { |
254 | | use retroglyph_core::layout::TextLayout; |
255 | | use retroglyph_core::text::{Line, Span}; |
256 | | |
257 | 3 | let content_w = self.width.unwrap_or_else(|| {1 |
258 | 1 | u16::try_from(text.split('\n').map(measured_width).max().unwrap_or(0)) |
259 | 1 | .unwrap_or(u16::MAX) |
260 | 1 | }); |
261 | 3 | let line = Line::from(Span::styled(text, self.style)); |
262 | 3 | let content_h = self.height.unwrap_or_else(|| { |
263 | 3 | TextLayout::new(&line) |
264 | 3 | .rect(Rect::new(0, 0, content_w, u16::MAX)) |
265 | 3 | .measure() |
266 | 3 | .height() |
267 | 3 | }); |
268 | | |
269 | 3 | let (mut grid, content_x, content_y) = self.scaffold(content_w, content_h); |
270 | 3 | TextLayout::new(&line) |
271 | 3 | .rect(Rect::new(content_x, content_y, content_w, content_h)) |
272 | 3 | .render_to_grid(&mut grid, 0); |
273 | | |
274 | 3 | grid |
275 | 3 | } |
276 | | |
277 | | /// Builds the padding/border/margin scaffold for a `content_w`x`content_h` |
278 | | /// content area: a fresh [`Grid`] with the box's background (and border, |
279 | | /// if any) already drawn, plus the `(x, y)` offset where content should |
280 | | /// be written. |
281 | 16 | fn scaffold(&self, content_w: u16, content_h: u16) -> (Grid, u16, u16) { |
282 | 16 | let border_wh = u16::from(self.border) * 2; |
283 | 16 | let inner_w = content_w |
284 | 16 | .saturating_add(self.padding.horizontal()) |
285 | 16 | .saturating_add(border_wh); |
286 | 16 | let inner_h = content_h |
287 | 16 | .saturating_add(self.padding.vertical()) |
288 | 16 | .saturating_add(border_wh); |
289 | 16 | let outer_w = inner_w.saturating_add(self.margin.horizontal()).max(1); |
290 | 16 | let outer_h = inner_h.saturating_add(self.margin.vertical()).max(1); |
291 | | |
292 | 16 | let mut grid = Grid::new(outer_w, outer_h); |
293 | 16 | let box_x = self.margin.left; |
294 | 16 | let box_y = self.margin.top; |
295 | | |
296 | 16 | fill_rect(&mut grid, box_x, box_y, inner_w, inner_h, self.style); |
297 | 16 | if self.border { |
298 | 4 | // `inner_w`/`inner_h` already include the border's own 2 cells |
299 | 4 | // (`border_wh` above), so both are always >= 2 here. |
300 | 4 | draw_border(&mut grid, box_x, box_y, inner_w, inner_h, self.style); |
301 | 12 | } |
302 | | |
303 | 16 | let content_x = box_x |
304 | 16 | .saturating_add(u16::from(self.border)) |
305 | 16 | .saturating_add(self.padding.left); |
306 | 16 | let content_y = box_y |
307 | 16 | .saturating_add(u16::from(self.border)) |
308 | 16 | .saturating_add(self.padding.top); |
309 | 16 | (grid, content_x, content_y) |
310 | 16 | } |
311 | | } |
312 | | |
313 | | /// Pairs a [`BoxStyle`] with the text it should render, so the pair can |
314 | | /// implement [`Widget`] (which has no room for a text parameter). Build one |
315 | | /// via [`BoxStyle::text`]. |
316 | | /// |
317 | | /// [`Widget::render`] places the box at `area`'s top-left corner, sized to |
318 | | /// the style's own explicit-or-content-fit dimensions: it does not stretch |
319 | | /// or clip to fill `area`. It always uses [`BoxStyle::render`] (not |
320 | | /// `BoxStyle::render_wrapped`, behind the `egc` feature); for wrapped |
321 | | /// content, call `render_wrapped` directly and |
322 | | /// [`Surface::blit`](retroglyph_core::surface::Surface::blit) the result yourself. |
323 | | #[derive(Clone, Copy, Debug)] |
324 | | pub struct Boxed<'a> { |
325 | | style: BoxStyle, |
326 | | text: &'a str, |
327 | | } |
328 | | |
329 | | impl BoxStyle { |
330 | | /// Pairs this style with `text`, ready to draw via [`Widget::render`]. |
331 | | #[must_use] |
332 | 2 | pub const fn text(self, text: &str) -> Boxed<'_> { |
333 | 2 | Boxed { style: self, text } |
334 | 2 | } |
335 | | } |
336 | | |
337 | | impl Widget for Boxed<'_> { |
338 | 2 | fn render(&self, surface: &mut Surface<'_>) { |
339 | 2 | let grid = self.style.render(self.text); |
340 | | // `(0, 0)` in this surface's own local coordinates is its area's own top-left corner. |
341 | 2 | surface.blit(&grid, 0, 0); |
342 | 2 | } |
343 | | } |
344 | | |
345 | | /// Fill `w`×`h` starting at `(x, y)` with a `style`d space. |
346 | 16 | fn fill_rect(grid: &mut Grid, x: u16, y: u16, w: u16, h: u16, style: Style) { |
347 | 36 | for dy in 0..h16 { |
348 | 327k | for dx in 0..w36 { |
349 | 327k | grid.put_tile(0, (x + dx, y + dy), Tile::new(' ', style)); |
350 | 327k | } |
351 | | } |
352 | 16 | } |
353 | | |
354 | | /// Draw a single-line border around the `w`×`h` rect at `(x, y)`, in |
355 | | /// `style`. Caller must ensure `w >= 2 && h >= 2`. |
356 | 4 | fn draw_border(grid: &mut Grid, x: u16, y: u16, w: u16, h: u16, style: Style) { |
357 | 4 | let right = x + w - 1; |
358 | 4 | let bottom = y + h - 1; |
359 | | |
360 | 4 | grid.put_tile(0, (x, y), Tile::new(PLAIN.top_left, style)); |
361 | 4 | grid.put_tile(0, (right, y), Tile::new(PLAIN.top_right, style)); |
362 | 4 | grid.put_tile(0, (x, bottom), Tile::new(PLAIN.bottom_left, style)); |
363 | 4 | grid.put_tile(0, (right, bottom), Tile::new(PLAIN.bottom_right, style)); |
364 | 8 | for cx in (x + 1)..right4 { |
365 | 8 | grid.put_tile(0, (cx, y), Tile::new(PLAIN.horizontal, style)); |
366 | 8 | grid.put_tile(0, (cx, bottom), Tile::new(PLAIN.horizontal, style)); |
367 | 8 | } |
368 | 6 | for cy in (y + 1)..bottom4 { |
369 | 6 | grid.put_tile(0, (x, cy), Tile::new(PLAIN.vertical, style)); |
370 | 6 | grid.put_tile(0, (right, cy), Tile::new(PLAIN.vertical, style)); |
371 | 6 | } |
372 | 4 | } |
373 | | |
374 | | #[cfg(test)] |
375 | | mod tests { |
376 | | use alloc::string::String; |
377 | | |
378 | | use super::*; |
379 | | use retroglyph_core::grid::{Pos, Rect}; |
380 | | |
381 | 3 | fn glyphs(grid: &Grid) -> Vec<String> { |
382 | 3 | (0..grid.height()) |
383 | 9 | .map3 (|y| { |
384 | 9 | (0..grid.width()) |
385 | 45 | .map9 (|x| grid[Pos::new(x, y)].glyph()) |
386 | 9 | .collect() |
387 | 9 | }) |
388 | 3 | .collect() |
389 | 3 | } |
390 | | |
391 | | #[test] |
392 | 1 | fn boxed_render_draws_on_a_surface_that_is_not_on_layer_zero() { |
393 | | // The retroglyph#824 regression: `Boxed` renders its `BoxStyle` into a standalone, |
394 | | // layer-0-only `Grid` and stamps it onto the caller's surface, which must still work |
395 | | // when that surface is on a non-zero layer (e.g. `surface.on_tier(Layer::Overlay)`, as |
396 | | // `Modal`'s own docs recommend for overlay content). |
397 | | use retroglyph_core::surface::{Layer, Surface}; |
398 | | |
399 | 1 | let mut grid = Grid::new(6, 3); |
400 | 1 | let mut surface = Surface::new(&mut grid, Rect::new(0, 0, 6, 3), Layer::World.as_u8()); |
401 | 1 | let boxed = BoxStyle::new(Style::default()).text("hi"); |
402 | 1 | boxed.render(&mut surface.on_tier(Layer::Overlay)); |
403 | | |
404 | 1 | assert_eq!( |
405 | 1 | grid.tile(Layer::Overlay.as_u8(), (0, 0)).map(Tile::glyph), |
406 | | Some('h') |
407 | | ); |
408 | 1 | assert_eq!( |
409 | 1 | grid.tile(Layer::Overlay.as_u8(), (1, 0)).map(Tile::glyph), |
410 | | Some('i') |
411 | | ); |
412 | 1 | } |
413 | | |
414 | | #[test] |
415 | 1 | fn sides_helpers() { |
416 | 1 | assert_eq!( |
417 | 1 | Sides::all(2), |
418 | | Sides { |
419 | | top: 2, |
420 | | right: 2, |
421 | | bottom: 2, |
422 | | left: 2 |
423 | | } |
424 | | ); |
425 | 1 | assert_eq!( |
426 | 1 | Sides::symmetric(1, 3), |
427 | | Sides { |
428 | | top: 1, |
429 | | right: 3, |
430 | | bottom: 1, |
431 | | left: 3 |
432 | | } |
433 | | ); |
434 | 1 | } |
435 | | |
436 | | #[test] |
437 | 1 | fn sizes_to_content_with_no_padding_or_border() { |
438 | 1 | let grid = BoxStyle::new(Style::default()).render("hi"); |
439 | 1 | assert_eq!((grid.width(), grid.height()), (2, 1)); |
440 | 1 | assert_eq!(grid[Pos::new(0, 0)].glyph(), 'h'); |
441 | 1 | assert_eq!(grid[Pos::new(1, 0)].glyph(), 'i'); |
442 | 1 | } |
443 | | |
444 | | #[test] |
445 | 1 | fn sizes_to_the_widest_of_multiple_lines() { |
446 | 1 | let grid = BoxStyle::new(Style::default()).render("a\nbcd\nef"); |
447 | 1 | assert_eq!((grid.width(), grid.height()), (3, 3)); |
448 | 1 | assert_eq!(grid[Pos::new(0, 0)].glyph(), 'a'); |
449 | 1 | assert_eq!(grid[Pos::new(1, 0)].glyph(), ' '); // shorter line padded with blanks |
450 | 1 | assert_eq!(grid[Pos::new(0, 1)].glyph(), 'b'); |
451 | 1 | assert_eq!(grid[Pos::new(2, 1)].glyph(), 'd'); |
452 | 1 | } |
453 | | |
454 | | #[test] |
455 | 1 | fn explicit_width_clips_longer_lines_and_pads_shorter_ones() { |
456 | 1 | let grid = BoxStyle::new(Style::default()).width(3).render("hello"); |
457 | 1 | assert_eq!(grid.width(), 3); |
458 | 3 | let row1 : String1 = (0..31 ).map1 (|x| grid[Pos::new(x, 0)].glyph()).collect1 (); |
459 | 1 | assert_eq!(row, "hel"); |
460 | 1 | } |
461 | | |
462 | | #[test] |
463 | 1 | fn explicit_height_drops_extra_lines() { |
464 | 1 | let grid = BoxStyle::new(Style::default()).height(1).render("a\nb\nc"); |
465 | 1 | assert_eq!(grid.height(), 1); |
466 | 1 | assert_eq!(grid[Pos::new(0, 0)].glyph(), 'a'); |
467 | 1 | } |
468 | | |
469 | | #[test] |
470 | 1 | fn padding_surrounds_content_with_the_box_style() { |
471 | 1 | let grid = BoxStyle::new(Style::default()) |
472 | 1 | .padding(Sides::all(1)) |
473 | 1 | .render("x"); |
474 | | // 1 content col/row + 1 padding on each side = 3x3. |
475 | 1 | assert_eq!((grid.width(), grid.height()), (3, 3)); |
476 | 1 | assert_eq!(grid[Pos::new(1, 1)].glyph(), 'x'); |
477 | 1 | assert_eq!(grid[Pos::new(0, 0)].glyph(), ' '); |
478 | 1 | } |
479 | | |
480 | | #[test] |
481 | 1 | fn render_does_not_overflow_on_a_near_u16_max_line_with_padding() { |
482 | | // retroglyph#729: `content_x + col` used to overflow `u16` once padding pushed the write |
483 | | // position past `u16::MAX` for a line wide enough to fill the content area to its edge. |
484 | 1 | let text = "a".repeat(65_535); |
485 | 1 | let grid = BoxStyle::new(Style::default()) |
486 | 1 | .padding(Sides::all(2)) |
487 | 1 | .render(&text); |
488 | 1 | assert_eq!(grid[Pos::new(2, 2)].glyph(), 'a'); |
489 | 1 | } |
490 | | |
491 | | #[test] |
492 | 1 | fn border_draws_a_box_around_padding_and_content() { |
493 | 1 | let grid = BoxStyle::new(Style::default()).border(true).render("x"); |
494 | | // 1 content col/row + 2 border = 3x3. |
495 | 1 | assert_eq!((grid.width(), grid.height()), (3, 3)); |
496 | 1 | let rows = glyphs(&grid); |
497 | 1 | assert_eq!(rows[0], "┌─┐"); |
498 | 1 | assert_eq!(rows[1], "│x│"); |
499 | 1 | assert_eq!(rows[2], "└─┘"); |
500 | 1 | } |
501 | | |
502 | | #[test] |
503 | 1 | fn margin_is_left_transparent_outside_the_border() { |
504 | 1 | let grid = BoxStyle::new(Style::default()) |
505 | 1 | .margin(Sides::all(1)) |
506 | 1 | .render("x"); |
507 | | // 1x1 content, 1 margin on each side = 3x3; margin cells are never |
508 | | // written, so they keep Grid::new's default "empty" tile, which |
509 | | // Grid::blit treats as transparent. |
510 | 1 | assert_eq!((grid.width(), grid.height()), (3, 3)); |
511 | 1 | assert!(grid[Pos::new(0, 0)].is_empty()); |
512 | 1 | assert_eq!(grid[Pos::new(1, 1)].glyph(), 'x'); |
513 | 1 | } |
514 | | |
515 | | #[test] |
516 | 1 | fn wide_characters_push_later_columns_over_by_their_width() { |
517 | | use retroglyph_core::tile::TileFlags; |
518 | | |
519 | | // "あ" (HIRAGANA A) is 2 columns wide: width("aあb") == 4, and 'b' |
520 | | // must land at column 3, not column 2 (its char index), or it would |
521 | | // collide with あ's second visual column. |
522 | 1 | let grid = BoxStyle::new(Style::default()).render("aあb"); |
523 | 1 | assert_eq!(grid.width(), 4); |
524 | 1 | assert_eq!(grid[Pos::new(0, 0)].glyph(), 'a'); |
525 | 1 | assert_eq!(grid[Pos::new(1, 0)].glyph(), 'あ'); |
526 | | // `put_tile` reserves a proper `WIDE_CHAR_SPACER` at the wide glyph's right half. |
527 | 1 | assert!( |
528 | 1 | grid[Pos::new(2, 0)] |
529 | 1 | .flags() |
530 | 1 | .contains(TileFlags::WIDE_CHAR_SPACER) |
531 | | ); |
532 | 1 | assert_eq!(grid[Pos::new(3, 0)].glyph(), 'b'); |
533 | 1 | } |
534 | | |
535 | | #[test] |
536 | 1 | fn control_characters_occupy_one_column_matching_core_text_char_width() { |
537 | | // retroglyph#760: this crate's own char-width loop used to answer `0` columns for a |
538 | | // control character (`ch.width().unwrap_or(0)`), disagreeing with `Surface`/`Tile`, which |
539 | | // both already advance one column when a control character is drawn. Routing through |
540 | | // `retroglyph_core::text::char_width` (now `unwrap_or(1)`) makes a BEL take up a column |
541 | | // here too, so "a\u{7}b" is 3 columns wide, not 2. |
542 | 1 | let grid = BoxStyle::new(Style::default()).render("a\u{7}b"); |
543 | 1 | assert_eq!(grid.width(), 3); |
544 | 1 | assert_eq!(grid[Pos::new(0, 0)].glyph(), 'a'); |
545 | 1 | assert_eq!(grid[Pos::new(2, 0)].glyph(), 'b'); |
546 | 1 | } |
547 | | |
548 | | #[test] |
549 | 1 | fn border_with_empty_content_is_still_at_least_a_2x2_box() { |
550 | | // No content, no padding: inner size is exactly the border's own 2 |
551 | | // cells in each axis (content_w = 0, content_h = 1 line of ""). |
552 | 1 | let grid = BoxStyle::new(Style::default()).border(true).render(""); |
553 | 1 | assert_eq!((grid.width(), grid.height()), (2, 3)); |
554 | 1 | let rows = glyphs(&grid); |
555 | 1 | assert_eq!(rows[0], "┌┐"); |
556 | 1 | assert_eq!(rows[2], "└┘"); |
557 | 1 | } |
558 | | |
559 | | #[test] |
560 | | #[cfg(feature = "egc")] |
561 | 1 | fn render_wrapped_word_wraps_to_the_explicit_width() { |
562 | | // Same text/width Paragraph's own tests use (see widget/paragraph.rs), |
563 | | // so this is exercising the same, already-verified TextLayout wrap. |
564 | 1 | let grid = BoxStyle::new(Style::default()) |
565 | 1 | .width(10) |
566 | 1 | .render_wrapped("the quick brown fox jumps"); |
567 | 1 | assert_eq!(grid.width(), 10); |
568 | 1 | let rows = glyphs(&grid); |
569 | 1 | assert_eq!(rows[0].trim_end(), "the quick"); |
570 | 1 | assert_eq!(rows[1].trim_end(), "brown fox"); |
571 | 1 | assert_eq!(rows[2].trim_end(), "jumps"); |
572 | 1 | } |
573 | | |
574 | | #[test] |
575 | | #[cfg(feature = "egc")] |
576 | 1 | fn render_wrapped_without_an_explicit_width_measures_but_does_not_wrap() { |
577 | | // No width set: same natural-width fallback as `render`, so nothing |
578 | | // is short enough to need wrapping. |
579 | 1 | let grid = BoxStyle::new(Style::default()).render_wrapped("hi"); |
580 | 1 | assert_eq!((grid.width(), grid.height()), (2, 1)); |
581 | 1 | assert_eq!(grid[Pos::new(0, 0)].glyph(), 'h'); |
582 | 1 | assert_eq!(grid[Pos::new(1, 0)].glyph(), 'i'); |
583 | 1 | } |
584 | | |
585 | | #[test] |
586 | | #[cfg(feature = "egc")] |
587 | 1 | fn render_wrapped_respects_padding_and_border_like_render() { |
588 | 1 | let grid = BoxStyle::new(Style::default()) |
589 | 1 | .border(true) |
590 | 1 | .padding(Sides::all(1)) |
591 | 1 | .width(3) |
592 | 1 | .render_wrapped("hi"); |
593 | | // 3 content cols + 2 padding + 2 border = 7; 1 content row + 2 |
594 | | // padding + 2 border = 5. |
595 | 1 | assert_eq!((grid.width(), grid.height()), (7, 5)); |
596 | 1 | assert_eq!(grid[Pos::new(2, 2)].glyph(), 'h'); |
597 | 1 | assert_eq!(grid[Pos::new(3, 2)].glyph(), 'i'); |
598 | 1 | } |
599 | | |
600 | | #[test] |
601 | 1 | fn boxed_widget_places_the_box_at_the_areas_top_left() { |
602 | 1 | let styled = BoxStyle::new(Style::default()).border(true).text("hi"); |
603 | 1 | let area = Rect::new(2, 1, 10, 6); |
604 | 1 | let mut grid = Grid::new(12, 7); |
605 | 1 | styled.render(&mut Surface::new(&mut grid, area, 0)); |
606 | | |
607 | | // 2 content cols + 2 border = 4 wide, 1 content row + 2 border = 3 |
608 | | // tall, anchored at (2, 1) regardless of the much larger area. |
609 | 1 | assert_eq!(grid[Pos::new(2, 1)].glyph(), '┌'); |
610 | 1 | assert_eq!(grid[Pos::new(3, 2)].glyph(), 'h'); |
611 | 1 | assert_eq!(grid[Pos::new(4, 2)].glyph(), 'i'); |
612 | 1 | assert_eq!(grid[Pos::new(5, 3)].glyph(), '┘'); |
613 | 1 | } |
614 | | } |