Skip to main content

retroglyph_ui/
style.rs

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.
13use alloc::vec::Vec;
14
15use retroglyph_core::color::Style;
16use retroglyph_core::grid::Grid;
17use retroglyph_core::text::{char_width, width_usize as measured_width};
18use 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")]
22use retroglyph_core::grid::{HasSize, Rect};
23
24use crate::Surface;
25use crate::text::truncate;
26use crate::widget::Widget;
27use 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)]
31pub 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
42impl 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    pub const fn all(n: u16) -> Self {
54        Self {
55            top: n,
56            right: n,
57            bottom: n,
58            left: n,
59        }
60    }
61
62    /// `vertical` cells top/bottom, `horizontal` cells left/right (CSS
63    /// `padding: v h` shorthand).
64    #[must_use]
65    pub const fn symmetric(vertical: u16, horizontal: u16) -> Self {
66        Self {
67            top: vertical,
68            right: horizontal,
69            bottom: vertical,
70            left: horizontal,
71        }
72    }
73
74    /// Returns `self` with `top` replaced.
75    #[must_use]
76    pub const fn top(mut self, top: u16) -> Self {
77        self.top = top;
78        self
79    }
80
81    /// Returns `self` with `right` replaced.
82    #[must_use]
83    pub const fn right(mut self, right: u16) -> Self {
84        self.right = right;
85        self
86    }
87
88    /// Returns `self` with `bottom` replaced.
89    #[must_use]
90    pub const fn bottom(mut self, bottom: u16) -> Self {
91        self.bottom = bottom;
92        self
93    }
94
95    /// Returns `self` with `left` replaced.
96    #[must_use]
97    pub const fn left(mut self, left: u16) -> Self {
98        self.left = left;
99        self
100    }
101
102    const fn horizontal(self) -> u16 {
103        self.left.saturating_add(self.right)
104    }
105
106    const fn vertical(self) -> u16 {
107        self.top.saturating_add(self.bottom)
108    }
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)]
132pub struct BoxStyle {
133    style: Style,
134    padding: Sides,
135    margin: Sides,
136    border: bool,
137    width: Option<u16>,
138    height: Option<u16>,
139}
140
141impl BoxStyle {
142    /// A borderless box with no padding/margin, in `style`, sized to fit its
143    /// content.
144    #[must_use]
145    pub const fn new(style: Style) -> Self {
146        Self {
147            style,
148            padding: Sides::ZERO,
149            margin: Sides::ZERO,
150            border: false,
151            width: None,
152            height: None,
153        }
154    }
155
156    /// Sets the padding, between the border (if any) and the content.
157    #[must_use]
158    pub const fn padding(mut self, padding: Sides) -> Self {
159        self.padding = padding;
160        self
161    }
162
163    /// Sets the margin, outside the border (if any); left transparent.
164    #[must_use]
165    pub const fn margin(mut self, margin: Sides) -> Self {
166        self.margin = margin;
167        self
168    }
169
170    /// Draws a single-line border, in `style`, around the padding.
171    #[must_use]
172    pub const fn border(mut self, border: bool) -> Self {
173        self.border = border;
174        self
175    }
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    pub const fn width(mut self, width: u16) -> Self {
183        self.width = Some(width);
184        self
185    }
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    pub const fn height(mut self, height: u16) -> Self {
193        self.height = Some(height);
194        self
195    }
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    pub fn render(&self, text: &str) -> Grid {
211        let lines: Vec<&str> = text.split('\n').collect();
212        let content_w = self.width.unwrap_or_else(|| {
213            u16::try_from(lines.iter().map(|l| measured_width(l)).max().unwrap_or(0))
214                .unwrap_or(u16::MAX)
215        });
216        let content_h = self
217            .height
218            .unwrap_or_else(|| u16::try_from(lines.len()).unwrap_or(u16::MAX));
219
220        let (mut grid, content_x, content_y) = self.scaffold(content_w, content_h);
221        for (row, line) in lines.iter().take(usize::from(content_h)).enumerate() {
222            let Ok(row) = u16::try_from(row) else { break };
223            let clipped = truncate(line, content_w);
224            let mut col = 0u16;
225            for ch in clipped.chars() {
226                let w = char_width(ch);
227                if col.saturating_add(w) > content_w {
228                    break;
229                }
230                grid.put_tile(
231                    0,
232                    (content_x.saturating_add(col), content_y.saturating_add(row)),
233                    Tile::new(ch, self.style),
234                );
235                col = col.saturating_add(w);
236            }
237        }
238        grid
239    }
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    pub fn render_wrapped(&self, text: &str) -> Grid {
254        use retroglyph_core::layout::TextLayout;
255        use retroglyph_core::text::{Line, Span};
256
257        let content_w = self.width.unwrap_or_else(|| {
258            u16::try_from(text.split('\n').map(measured_width).max().unwrap_or(0))
259                .unwrap_or(u16::MAX)
260        });
261        let line = Line::from(Span::styled(text, self.style));
262        let content_h = self.height.unwrap_or_else(|| {
263            TextLayout::new(&line)
264                .rect(Rect::new(0, 0, content_w, u16::MAX))
265                .measure()
266                .height()
267        });
268
269        let (mut grid, content_x, content_y) = self.scaffold(content_w, content_h);
270        TextLayout::new(&line)
271            .rect(Rect::new(content_x, content_y, content_w, content_h))
272            .render_to_grid(&mut grid, 0);
273
274        grid
275    }
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    fn scaffold(&self, content_w: u16, content_h: u16) -> (Grid, u16, u16) {
282        let border_wh = u16::from(self.border) * 2;
283        let inner_w = content_w
284            .saturating_add(self.padding.horizontal())
285            .saturating_add(border_wh);
286        let inner_h = content_h
287            .saturating_add(self.padding.vertical())
288            .saturating_add(border_wh);
289        let outer_w = inner_w.saturating_add(self.margin.horizontal()).max(1);
290        let outer_h = inner_h.saturating_add(self.margin.vertical()).max(1);
291
292        let mut grid = Grid::new(outer_w, outer_h);
293        let box_x = self.margin.left;
294        let box_y = self.margin.top;
295
296        fill_rect(&mut grid, box_x, box_y, inner_w, inner_h, self.style);
297        if self.border {
298            // `inner_w`/`inner_h` already include the border's own 2 cells
299            // (`border_wh` above), so both are always >= 2 here.
300            draw_border(&mut grid, box_x, box_y, inner_w, inner_h, self.style);
301        }
302
303        let content_x = box_x
304            .saturating_add(u16::from(self.border))
305            .saturating_add(self.padding.left);
306        let content_y = box_y
307            .saturating_add(u16::from(self.border))
308            .saturating_add(self.padding.top);
309        (grid, content_x, content_y)
310    }
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)]
324pub struct Boxed<'a> {
325    style: BoxStyle,
326    text: &'a str,
327}
328
329impl BoxStyle {
330    /// Pairs this style with `text`, ready to draw via [`Widget::render`].
331    #[must_use]
332    pub const fn text(self, text: &str) -> Boxed<'_> {
333        Boxed { style: self, text }
334    }
335}
336
337impl Widget for Boxed<'_> {
338    fn render(&self, surface: &mut Surface<'_>) {
339        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        surface.blit(&grid, 0, 0);
342    }
343}
344
345/// Fill `w`×`h` starting at `(x, y)` with a `style`d space.
346fn fill_rect(grid: &mut Grid, x: u16, y: u16, w: u16, h: u16, style: Style) {
347    for dy in 0..h {
348        for dx in 0..w {
349            grid.put_tile(0, (x + dx, y + dy), Tile::new(' ', style));
350        }
351    }
352}
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`.
356fn draw_border(grid: &mut Grid, x: u16, y: u16, w: u16, h: u16, style: Style) {
357    let right = x + w - 1;
358    let bottom = y + h - 1;
359
360    grid.put_tile(0, (x, y), Tile::new(PLAIN.top_left, style));
361    grid.put_tile(0, (right, y), Tile::new(PLAIN.top_right, style));
362    grid.put_tile(0, (x, bottom), Tile::new(PLAIN.bottom_left, style));
363    grid.put_tile(0, (right, bottom), Tile::new(PLAIN.bottom_right, style));
364    for cx in (x + 1)..right {
365        grid.put_tile(0, (cx, y), Tile::new(PLAIN.horizontal, style));
366        grid.put_tile(0, (cx, bottom), Tile::new(PLAIN.horizontal, style));
367    }
368    for cy in (y + 1)..bottom {
369        grid.put_tile(0, (x, cy), Tile::new(PLAIN.vertical, style));
370        grid.put_tile(0, (right, cy), Tile::new(PLAIN.vertical, style));
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use alloc::string::String;
377
378    use super::*;
379    use retroglyph_core::grid::{Pos, Rect};
380
381    fn glyphs(grid: &Grid) -> Vec<String> {
382        (0..grid.height())
383            .map(|y| {
384                (0..grid.width())
385                    .map(|x| grid[Pos::new(x, y)].glyph())
386                    .collect()
387            })
388            .collect()
389    }
390
391    #[test]
392    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        let mut grid = Grid::new(6, 3);
400        let mut surface = Surface::new(&mut grid, Rect::new(0, 0, 6, 3), Layer::World.as_u8());
401        let boxed = BoxStyle::new(Style::default()).text("hi");
402        boxed.render(&mut surface.on_tier(Layer::Overlay));
403
404        assert_eq!(
405            grid.tile(Layer::Overlay.as_u8(), (0, 0)).map(Tile::glyph),
406            Some('h')
407        );
408        assert_eq!(
409            grid.tile(Layer::Overlay.as_u8(), (1, 0)).map(Tile::glyph),
410            Some('i')
411        );
412    }
413
414    #[test]
415    fn sides_helpers() {
416        assert_eq!(
417            Sides::all(2),
418            Sides {
419                top: 2,
420                right: 2,
421                bottom: 2,
422                left: 2
423            }
424        );
425        assert_eq!(
426            Sides::symmetric(1, 3),
427            Sides {
428                top: 1,
429                right: 3,
430                bottom: 1,
431                left: 3
432            }
433        );
434    }
435
436    #[test]
437    fn sizes_to_content_with_no_padding_or_border() {
438        let grid = BoxStyle::new(Style::default()).render("hi");
439        assert_eq!((grid.width(), grid.height()), (2, 1));
440        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'h');
441        assert_eq!(grid[Pos::new(1, 0)].glyph(), 'i');
442    }
443
444    #[test]
445    fn sizes_to_the_widest_of_multiple_lines() {
446        let grid = BoxStyle::new(Style::default()).render("a\nbcd\nef");
447        assert_eq!((grid.width(), grid.height()), (3, 3));
448        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'a');
449        assert_eq!(grid[Pos::new(1, 0)].glyph(), ' '); // shorter line padded with blanks
450        assert_eq!(grid[Pos::new(0, 1)].glyph(), 'b');
451        assert_eq!(grid[Pos::new(2, 1)].glyph(), 'd');
452    }
453
454    #[test]
455    fn explicit_width_clips_longer_lines_and_pads_shorter_ones() {
456        let grid = BoxStyle::new(Style::default()).width(3).render("hello");
457        assert_eq!(grid.width(), 3);
458        let row: String = (0..3).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
459        assert_eq!(row, "hel");
460    }
461
462    #[test]
463    fn explicit_height_drops_extra_lines() {
464        let grid = BoxStyle::new(Style::default()).height(1).render("a\nb\nc");
465        assert_eq!(grid.height(), 1);
466        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'a');
467    }
468
469    #[test]
470    fn padding_surrounds_content_with_the_box_style() {
471        let grid = BoxStyle::new(Style::default())
472            .padding(Sides::all(1))
473            .render("x");
474        // 1 content col/row + 1 padding on each side = 3x3.
475        assert_eq!((grid.width(), grid.height()), (3, 3));
476        assert_eq!(grid[Pos::new(1, 1)].glyph(), 'x');
477        assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
478    }
479
480    #[test]
481    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        let text = "a".repeat(65_535);
485        let grid = BoxStyle::new(Style::default())
486            .padding(Sides::all(2))
487            .render(&text);
488        assert_eq!(grid[Pos::new(2, 2)].glyph(), 'a');
489    }
490
491    #[test]
492    fn border_draws_a_box_around_padding_and_content() {
493        let grid = BoxStyle::new(Style::default()).border(true).render("x");
494        // 1 content col/row + 2 border = 3x3.
495        assert_eq!((grid.width(), grid.height()), (3, 3));
496        let rows = glyphs(&grid);
497        assert_eq!(rows[0], "┌─┐");
498        assert_eq!(rows[1], "│x│");
499        assert_eq!(rows[2], "└─┘");
500    }
501
502    #[test]
503    fn margin_is_left_transparent_outside_the_border() {
504        let grid = BoxStyle::new(Style::default())
505            .margin(Sides::all(1))
506            .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        assert_eq!((grid.width(), grid.height()), (3, 3));
511        assert!(grid[Pos::new(0, 0)].is_empty());
512        assert_eq!(grid[Pos::new(1, 1)].glyph(), 'x');
513    }
514
515    #[test]
516    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        let grid = BoxStyle::new(Style::default()).render("aあb");
523        assert_eq!(grid.width(), 4);
524        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'a');
525        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        assert!(
528            grid[Pos::new(2, 0)]
529                .flags()
530                .contains(TileFlags::WIDE_CHAR_SPACER)
531        );
532        assert_eq!(grid[Pos::new(3, 0)].glyph(), 'b');
533    }
534
535    #[test]
536    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        let grid = BoxStyle::new(Style::default()).render("a\u{7}b");
543        assert_eq!(grid.width(), 3);
544        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'a');
545        assert_eq!(grid[Pos::new(2, 0)].glyph(), 'b');
546    }
547
548    #[test]
549    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        let grid = BoxStyle::new(Style::default()).border(true).render("");
553        assert_eq!((grid.width(), grid.height()), (2, 3));
554        let rows = glyphs(&grid);
555        assert_eq!(rows[0], "┌┐");
556        assert_eq!(rows[2], "└┘");
557    }
558
559    #[test]
560    #[cfg(feature = "egc")]
561    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        let grid = BoxStyle::new(Style::default())
565            .width(10)
566            .render_wrapped("the quick brown fox jumps");
567        assert_eq!(grid.width(), 10);
568        let rows = glyphs(&grid);
569        assert_eq!(rows[0].trim_end(), "the quick");
570        assert_eq!(rows[1].trim_end(), "brown fox");
571        assert_eq!(rows[2].trim_end(), "jumps");
572    }
573
574    #[test]
575    #[cfg(feature = "egc")]
576    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        let grid = BoxStyle::new(Style::default()).render_wrapped("hi");
580        assert_eq!((grid.width(), grid.height()), (2, 1));
581        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'h');
582        assert_eq!(grid[Pos::new(1, 0)].glyph(), 'i');
583    }
584
585    #[test]
586    #[cfg(feature = "egc")]
587    fn render_wrapped_respects_padding_and_border_like_render() {
588        let grid = BoxStyle::new(Style::default())
589            .border(true)
590            .padding(Sides::all(1))
591            .width(3)
592            .render_wrapped("hi");
593        // 3 content cols + 2 padding + 2 border = 7; 1 content row + 2
594        // padding + 2 border = 5.
595        assert_eq!((grid.width(), grid.height()), (7, 5));
596        assert_eq!(grid[Pos::new(2, 2)].glyph(), 'h');
597        assert_eq!(grid[Pos::new(3, 2)].glyph(), 'i');
598    }
599
600    #[test]
601    fn boxed_widget_places_the_box_at_the_areas_top_left() {
602        let styled = BoxStyle::new(Style::default()).border(true).text("hi");
603        let area = Rect::new(2, 1, 10, 6);
604        let mut grid = Grid::new(12, 7);
605        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        assert_eq!(grid[Pos::new(2, 1)].glyph(), '┌');
610        assert_eq!(grid[Pos::new(3, 2)].glyph(), 'h');
611        assert_eq!(grid[Pos::new(4, 2)].glyph(), 'i');
612        assert_eq!(grid[Pos::new(5, 3)].glyph(), '┘');
613    }
614}