Skip to main content

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