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