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