Skip to main content

retroglyph_ui/widget/
panel.rs

1//! [`Panel`]: a bordered, titled panel.
2use alloc::vec::Vec;
3
4use retroglyph_core::color::{Color, Style};
5use retroglyph_core::grid::Rect;
6use retroglyph_core::text::truncate_measured;
7
8use super::{BorderType, BoxBorder, Measure, Widget};
9use crate::Surface;
10use crate::draw::fill_rect;
11use crate::style::Sides;
12use crate::text::draw_clipped;
13use crate::{Align, Theme};
14
15/// Which border edge a [`PanelTitle`] is drawn into.
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
17pub enum TitlePosition {
18    /// The top border row, the same edge [`Panel::title`]'s sugar title is drawn into.
19    #[default]
20    Top,
21    /// The bottom border row.
22    Bottom,
23}
24
25/// One title added via [`Panel::add_title`]: its text, which edge it's drawn on, and its
26/// alignment.
27///
28/// Not constructed directly; built up through [`Panel::add_title`]'s arguments instead, the same
29/// as [`Panel::title`]/[`Panel::title_align`] build the single implicit top title.
30#[derive(Clone, Copy, Debug)]
31pub struct PanelTitle<'a> {
32    text: &'a str,
33    position: TitlePosition,
34    align: Align,
35}
36
37/// A bordered panel: a filled background with a box border and, optionally, one or more titles
38/// along the top and/or bottom edge.
39///
40/// `border_style` (the box outline and titles) and `fill_style` (the
41/// interior background) both default to [`Theme::DARK`] (as if [`Panel::theme`] had been called);
42/// there is no title by default, and the title set by [`Panel::title`] (if any) defaults to
43/// [`Align::Center`]. Set whichever of these a caller needs via
44/// [`Panel::border_style`]/[`Panel::fill_style`]/[`Panel::title`]/[`Panel::title_align`].
45///
46/// [`Panel::title`] is sugar for the common case: one top, centered (by default) title. For
47/// anything past that (a bottom title, more than one title on an edge, or a title aligned other
48/// than via `title_align`), use [`Panel::add_title`], which is fully additive: it never disturbs
49/// `title`/`title_align`, and multiple `add_title` calls stack rather than overwrite each other.
50///
51/// # Examples
52///
53/// ```
54/// use retroglyph_core::grid::{Grid, Rect};
55/// use retroglyph_ui::{Align, Panel, Surface, TitlePosition, Widget};
56///
57/// let area = Rect::new(0, 0, 20, 5);
58/// let mut grid = Grid::new(20, 5);
59/// Panel::new()
60///     .title("Status")
61///     .add_title("3 / 10", TitlePosition::Bottom, Align::Right)
62///     .render(&mut Surface::new(&mut grid, area, 0));
63/// ```
64///
65/// Not [`Copy`] (unlike most other widgets here): [`Panel::add_title`] stores its titles in a
66/// `Vec`, so an unbounded number of them is exactly as cheap, and as fallible in the same ways
67/// (only an allocation failure, never a silently dropped title), as pushing onto any other `Vec`.
68#[derive(Clone, Debug, Default)]
69pub struct Panel<'a> {
70    title: Option<&'a str>,
71    title_align: Align,
72    titles: Vec<PanelTitle<'a>>,
73    border_style: Style,
74    fill_style: Style,
75    border_type: BorderType,
76    padding: Sides,
77}
78
79impl<'a> Panel<'a> {
80    /// A plain, untitled panel, styled from [`Theme::DARK`] (as if [`Panel::theme`] had been
81    /// called).
82    #[must_use]
83    pub fn new() -> Self {
84        Self {
85            title_align: Align::Center,
86            ..Self::default()
87        }
88        .theme(Theme::DARK)
89    }
90
91    /// Set the panel's title.
92    #[must_use]
93    pub const fn title(mut self, title: &'a str) -> Self {
94        self.title = Some(title);
95        self
96    }
97
98    /// Set how the title is aligned along the top border. Defaults to
99    /// [`Align::Center`].
100    #[must_use]
101    pub const fn title_align(mut self, align: Align) -> Self {
102        self.title_align = align;
103        self
104    }
105
106    /// Add a title to `position`'s edge, aligned per `align`. Additive and independent of
107    /// [`Panel::title`] and of every other `add_title` call: nothing here overwrites another
108    /// title, so a top-left title plus a top-right status, or a top title plus a bottom hint
109    /// bar, is two calls (or three, alongside `.title(...)`) rather than two overlapping
110    /// `Panel`s.
111    ///
112    /// Multiple titles are allowed on the same edge. Each is drawn in declaration order:
113    /// `.title(...)`'s implicit top title first (if set), then `add_title` calls in the order
114    /// they were made, truncated to whatever room is left on that title's edge after the
115    /// titles declared before it on the same edge have claimed theirs, the same way a single
116    /// title already truncates to fit the whole edge. A title that has no room left once earlier
117    /// titles on its edge are placed is clipped down to nothing rather than overdrawing them or
118    /// panicking. Because a [`Align::Center`] title claims its edge's entire remaining span, a
119    /// title declared after a centered one on the same edge always has no room left; put
120    /// non-centered titles first if a centered one needs to share an edge.
121    ///
122    /// Unbounded: every call is kept (this is what makes [`Panel`] not [`Copy`]; see its own doc
123    /// comment), there is no cap to silently drop past.
124    #[must_use]
125    pub fn add_title(mut self, title: &'a str, position: TitlePosition, align: Align) -> Self {
126        self.titles.push(PanelTitle {
127            text: title,
128            position,
129            align,
130        });
131        self
132    }
133
134    /// Set the box outline and title's style.
135    #[must_use]
136    pub const fn border_style(mut self, style: Style) -> Self {
137        self.border_style = style;
138        self
139    }
140
141    /// Set the interior background's style.
142    #[must_use]
143    pub const fn fill_style(mut self, style: Style) -> Self {
144        self.fill_style = style;
145        self
146    }
147
148    /// Set which box-drawing glyphs the border is drawn with. Defaults to
149    /// [`BorderType::Plain`], the same as [`BoxBorder::border_type`].
150    #[must_use]
151    pub const fn border_type(mut self, border_type: BorderType) -> Self {
152        self.border_type = border_type;
153        self
154    }
155
156    /// Reserve `padding` between the border and the rect [`Panel::inner`] returns.
157    ///
158    /// Padding is not painted specially: [`Panel::render`] still fills the whole area inside the
159    /// border with `fill_style` (padding cells included), the same as [`Panel::inner`]'s caller
160    /// would see if they filled `area` themselves before drawing into the smaller inner rect.
161    /// Defaults to [`Sides::ZERO`] (no padding beyond the 1-cell border).
162    #[must_use]
163    pub const fn padding(mut self, padding: Sides) -> Self {
164        self.padding = padding;
165        self
166    }
167
168    /// The content rect inside `area`'s border and padding, ready to hand to another widget.
169    ///
170    /// Derived from the same 1-cell border inset [`Panel::render`] uses plus this panel's
171    /// [`Panel::padding`], so the two can't drift. Saturates to a zero-sized rect (at `area`'s
172    /// origin) rather than underflowing when `area` is too small to hold the border and padding.
173    ///
174    /// # Examples
175    ///
176    /// ```
177    /// use retroglyph_core::grid::Rect;
178    /// use retroglyph_ui::{Panel, Sides};
179    ///
180    /// let panel = Panel::new().padding(Sides::symmetric(0, 1));
181    /// let area = Rect::new(0, 0, 20, 5);
182    /// assert_eq!(panel.inner(area), Rect::new(2, 1, 16, 3));
183    /// ```
184    #[must_use]
185    pub const fn inner(&self, area: Rect) -> Rect {
186        let left = 1 + self.padding.left;
187        let top = 1 + self.padding.top;
188        let horizontal = 2 + self.padding.left + self.padding.right;
189        let vertical = 2 + self.padding.top + self.padding.bottom;
190        Rect::new(
191            area.left().saturating_add(left),
192            area.top().saturating_add(top),
193            area.width().saturating_sub(horizontal),
194            area.height().saturating_sub(vertical),
195        )
196    }
197
198    /// Applies `theme`'s named roles to this panel's border and fill: `border_style` becomes
199    /// `theme.border` on `theme.title_bg` (the same background the title, if any, is drawn on),
200    /// and `fill_style` becomes `theme.panel_bg`.
201    ///
202    /// Like every other builder method here, whichever call comes last wins: call `.theme(...)`
203    /// before any manual [`Panel::border_style`]/[`Panel::fill_style`] override you want to keep.
204    #[must_use]
205    pub fn theme(self, theme: Theme) -> Self {
206        self.theme_on(theme, theme.panel_bg)
207    }
208
209    /// Same as [`Panel::theme`], but `fill_style` is drawn on `bg` instead of `theme.panel_bg` --
210    /// for a panel whose interior should read as a different surface than `theme.panel_bg`
211    /// (`border_style` still uses `theme.title_bg`, unaffected by `bg`). [`Panel::theme`] is
212    /// exactly `theme_on(theme, theme.panel_bg)`.
213    #[must_use]
214    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
215        self.border_style = Style::new().fg(theme.border).bg(theme.title_bg);
216        self.fill_style = Style::new().bg(bg);
217        self
218    }
219}
220
221impl Measure for Panel<'_> {
222    /// The 1-cell border on each edge plus this panel's [`Panel::padding`] (the height a
223    /// zero-height inner content area would need), matching [`Panel::inner`]'s own vertical inset.
224    /// Every title (the one set by [`Panel::title`], and any added by [`Panel::add_title`],
225    /// whichever edge it's on) is drawn into its border row rather than adding one of its own, so
226    /// none of them add to this count. `width` is unused: `Panel` never wraps content of its own,
227    /// only whatever a caller renders into [`Panel::inner`], so it has nothing to measure against
228    /// `width` yet.
229    fn height_for(&self, _width: u16) -> u16 {
230        2u16.saturating_add(self.padding.top)
231            .saturating_add(self.padding.bottom)
232    }
233}
234
235impl Widget for Panel<'_> {
236    fn render(&self, surface: &mut Surface<'_>) {
237        let (width, height) = (surface.width(), surface.height());
238        if width < 2 || height < 2 {
239            return;
240        }
241
242        // Fill interior (inside the border), in this surface's own local coordinates.
243        let inner = Rect::new(1, 1, width.saturating_sub(2), height.saturating_sub(2));
244        fill_rect(surface, inner, ' ', self.fill_style);
245
246        BoxBorder::new()
247            .style(self.border_style)
248            .border_type(self.border_type)
249            .render(surface);
250
251        // Top edge: `.title(...)`'s implicit title (if any) claims space first, then any
252        // `add_title(..., TitlePosition::Top, ...)` titles in declaration order.
253        let mut top = TitleCursor::new(width);
254        if let Some(t) = self.title {
255            top.draw(surface, 0, t, self.title_align, self.border_style);
256        }
257        for title in &self.titles {
258            if title.position == TitlePosition::Top {
259                top.draw(surface, 0, title.text, title.align, self.border_style);
260            }
261        }
262
263        // Bottom edge: `add_title(..., TitlePosition::Bottom, ...)` titles in declaration order.
264        // `height >= 2` (checked above) means `height - 1 >= 1`, always a different row than the
265        // top edge's row `0`.
266        let mut bottom = TitleCursor::new(width);
267        for title in &self.titles {
268            if title.position == TitlePosition::Bottom {
269                bottom.draw(
270                    surface,
271                    height - 1,
272                    title.text,
273                    title.align,
274                    self.border_style,
275                );
276            }
277        }
278    }
279}
280
281/// Tracks how much of one border edge's span (the columns strictly between its two corners) is
282/// still free while [`Panel::render`] draws that edge's titles in declaration order, so a title
283/// that would overlap an earlier one on the same edge is truncated down to whatever room is left
284/// instead of overdrawing it.
285struct TitleCursor {
286    /// Leftmost free column (inclusive).
287    lo: u16,
288    /// Rightmost free column (exclusive).
289    hi: u16,
290}
291
292impl TitleCursor {
293    /// A cursor over the whole span between `width`'s two corners: columns `1..width - 1`.
294    const fn new(width: u16) -> Self {
295        Self {
296            lo: 1,
297            hi: width.saturating_sub(1),
298        }
299    }
300
301    /// Draw one title into whatever of this cursor's span is still free, then shrink the span so
302    /// a later call on the same edge doesn't overdraw it. Mirrors the truncate-then-pad sequence
303    /// a single top title always used: [`truncate_measured`] up front (the padding spaces flank
304    /// the title, so their position depends on the truncated title's own width, not the other
305    /// way around) then a leading/trailing space either side of it.
306    ///
307    /// A title with no room left (`lo >= hi`, or fewer than 2 free columns, not even enough for
308    /// the two padding spaces) is dropped entirely, drawing nothing, rather than panicking.
309    /// [`Align::Center`] claims this cursor's whole remaining span regardless of how much of it
310    /// the title itself actually used, so a title declared after a centered one on the same edge
311    /// always finds `lo >= hi` and is dropped.
312    fn draw(&mut self, surface: &mut Surface<'_>, y: u16, text: &str, align: Align, style: Style) {
313        if self.lo >= self.hi {
314            return;
315        }
316        let avail = self.hi - self.lo;
317        let Some(max_title_w) = avail.checked_sub(2) else {
318            return;
319        };
320        let (t, t_w) = truncate_measured(text, max_title_w);
321        let padded = t_w + 2;
322        let title_x = self.lo + align.offset(avail, padded);
323        surface.put((title_x, y), ' ', style);
324        let _ = draw_clipped(surface, (title_x + 1, y), t_w, t, Align::Left, style);
325        surface.put((title_x + 1 + t_w, y), ' ', style);
326
327        match align {
328            Align::Left => self.lo = title_x + padded,
329            Align::Right => self.hi = title_x,
330            Align::Center => self.hi = self.lo,
331        }
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use alloc::string::String;
338
339    use retroglyph_core::color::Color;
340    use retroglyph_core::grid::{Grid, Pos};
341
342    use super::*;
343
344    #[test]
345    fn draws_border_fill_and_title() {
346        let area = Rect::new(0, 0, 10, 4);
347        let border = Style::new().fg(Color::WHITE);
348        let fill = Style::new();
349
350        let mut grid = Grid::new(10, 4);
351        Panel::new()
352            .border_style(border)
353            .fill_style(fill)
354            .title("hi")
355            .render(&mut Surface::new(&mut grid, area, 0));
356
357        assert_eq!(grid[Pos::new(0, 0)].glyph(), '┌');
358        assert_eq!(grid[Pos::new(1, 1)].glyph(), ' '); // interior filled
359        // Title centred in the top border somewhere.
360        let top_row: String = (0..10).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
361        assert!(top_row.contains("hi"));
362    }
363
364    #[test]
365    fn long_title_is_truncated_to_fit() {
366        let area = Rect::new(0, 0, 8, 3); // max_title_w = 8 - 4 = 4
367        let mut grid = Grid::new(8, 3);
368        Panel::new()
369            .title("a very long title")
370            .render(&mut Surface::new(&mut grid, area, 0));
371
372        let top_row: String = (0..8).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
373        assert!(!top_row.contains("a very long title"));
374    }
375
376    #[test]
377    fn theme_maps_named_roles_onto_border_and_fill() {
378        let area = Rect::new(0, 0, 10, 4);
379        let mut grid = Grid::new(10, 4);
380        Panel::new()
381            .theme(Theme::DARK)
382            .render(&mut Surface::new(&mut grid, area, 0));
383
384        assert_eq!(
385            grid[Pos::new(0, 0)].style().foreground(),
386            Theme::DARK.border
387        );
388        assert_eq!(
389            grid[Pos::new(0, 0)].style().background(),
390            Theme::DARK.title_bg
391        );
392        assert_eq!(
393            grid[Pos::new(1, 1)].style().background(),
394            Theme::DARK.panel_bg
395        );
396    }
397
398    #[test]
399    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
400        let area = Rect::new(0, 0, 10, 4);
401        let mut grid = Grid::new(10, 4);
402        Panel::new()
403            .theme_on(Theme::DARK, Color::Default)
404            .render(&mut Surface::new(&mut grid, area, 0));
405
406        assert_eq!(
407            grid[Pos::new(0, 0)].style().foreground(),
408            Theme::DARK.border
409        );
410        assert_eq!(
411            grid[Pos::new(0, 0)].style().background(),
412            Theme::DARK.title_bg
413        );
414        assert_eq!(grid[Pos::new(1, 1)].style().background(), Color::Default);
415    }
416
417    #[test]
418    fn left_aligned_title_starts_after_the_corner() {
419        let area = Rect::new(0, 0, 12, 3);
420        let mut grid = Grid::new(12, 3);
421        Panel::new()
422            .title("hi")
423            .title_align(Align::Left)
424            .render(&mut Surface::new(&mut grid, area, 0));
425
426        // Padded title " hi " starts at column 1 (just inside the corner):
427        // space at 1, text at 2..4, trailing space at 4.
428        assert_eq!(grid[Pos::new(1, 0)].glyph(), ' ');
429        assert_eq!(grid[Pos::new(2, 0)].glyph(), 'h');
430        assert_eq!(grid[Pos::new(3, 0)].glyph(), 'i');
431    }
432
433    #[test]
434    fn right_aligned_title_ends_before_the_corner() {
435        let area = Rect::new(0, 0, 12, 3);
436        let mut grid = Grid::new(12, 3);
437        Panel::new()
438            .title("hi")
439            .title_align(Align::Right)
440            .render(&mut Surface::new(&mut grid, area, 0));
441
442        // Padded title " hi " (4 cols) ends against the right corner at
443        // column 11: trailing space at 10, text at 8..10.
444        assert_eq!(grid[Pos::new(8, 0)].glyph(), 'h');
445        assert_eq!(grid[Pos::new(9, 0)].glyph(), 'i');
446        assert_eq!(grid[Pos::new(10, 0)].glyph(), ' ');
447    }
448
449    #[test]
450    fn height_for_is_the_border_plus_padding() {
451        assert_eq!(Panel::new().height_for(80), 2);
452        let padded = Panel::new().padding(Sides::symmetric(1, 0));
453        assert_eq!(padded.height_for(80), 4); // 2 border + 1 top + 1 bottom
454    }
455
456    #[test]
457    fn inner_insets_by_the_one_cell_border() {
458        let area = Rect::new(0, 0, 20, 5);
459        assert_eq!(Panel::new().inner(area), Rect::new(1, 1, 18, 3));
460    }
461
462    #[test]
463    fn inner_also_insets_by_padding() {
464        let area = Rect::new(0, 0, 20, 5);
465        let panel = Panel::new().padding(Sides::symmetric(0, 1));
466        assert_eq!(panel.inner(area), Rect::new(2, 1, 16, 3));
467    }
468
469    #[test]
470    fn inner_saturates_instead_of_underflowing_when_area_is_too_small() {
471        let area = Rect::new(3, 4, 1, 1);
472        let panel = Panel::new().padding(Sides::all(2));
473        assert_eq!(panel.inner(area), Rect::new(6, 7, 0, 0));
474    }
475
476    #[test]
477    fn too_small_is_a_no_op() {
478        let area = Rect::new(0, 0, 1, 1);
479        let mut grid = Grid::new(1, 1);
480        Panel::new().render(&mut Surface::new(&mut grid, area, 0));
481        assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
482    }
483
484    #[test]
485    fn border_type_selects_the_glyph_set() {
486        let area = Rect::new(0, 0, 10, 4);
487        let mut grid = Grid::new(10, 4);
488        Panel::new()
489            .border_type(BorderType::Double)
490            .render(&mut Surface::new(&mut grid, area, 0));
491
492        assert_eq!(grid[Pos::new(0, 0)].glyph(), '╔');
493        assert_eq!(grid[Pos::new(9, 0)].glyph(), '╗');
494        assert_eq!(grid[Pos::new(0, 3)].glyph(), '╚');
495        assert_eq!(grid[Pos::new(9, 3)].glyph(), '╝');
496    }
497
498    #[test]
499    fn wide_char_title_is_centred_by_display_width_not_byte_length() {
500        // "あ" is 1 char, 3 bytes (UTF-8), 2 display columns. A byte-length
501        // title width (the pre-fix bug) would reserve 3 columns for it and
502        // miscentre the title, and would place the trailing space one
503        // column further right than it should be.
504        let area = Rect::new(0, 0, 10, 3); // max_title_w = 10 - 4 = 6
505        let mut grid = Grid::new(10, 3);
506        Panel::new()
507            .title("あ")
508            .render(&mut Surface::new(&mut grid, area, 0));
509
510        // title_x = 0 + (10 - 2 - 2) / 2 = 3; title glyph at 4, trailing
511        // space at 5. With the pre-fix byte-length bug (width 3) this would
512        // compute title_x = (10 - 3 - 2) / 2 = 2, off by one.
513        assert_eq!(grid[Pos::new(3, 0)].glyph(), ' ');
514        assert_eq!(grid[Pos::new(4, 0)].glyph(), 'あ');
515
516        // Column 5 is where a wide char's spacer column sits, reserved on every feature
517        // combination: `Grid::put_tile` (which `put` uses without `egc`, and `write_grapheme`
518        // uses with it) always writes a real spacer there (retroglyph#869), so it reads back as
519        // the trailing pad space this widget also explicitly writes at column 6.
520        assert_eq!(grid[Pos::new(5, 0)].glyph(), ' ');
521        assert_eq!(grid[Pos::new(6, 0)].glyph(), ' ');
522    }
523
524    #[test]
525    fn add_title_draws_into_the_bottom_border_row() {
526        let area = Rect::new(0, 0, 12, 4);
527        let mut grid = Grid::new(12, 4);
528        Panel::new()
529            .add_title("hint", TitlePosition::Bottom, Align::Center)
530            .render(&mut Surface::new(&mut grid, area, 0));
531
532        let bottom_row: String = (0..12).map(|x| grid[Pos::new(x, 3)].glyph()).collect();
533        assert!(bottom_row.contains("hint"));
534        // Top row is untouched: no title was set for it.
535        let top_row: String = (0..12).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
536        assert!(!top_row.contains("hint"));
537    }
538
539    #[test]
540    fn title_and_add_title_coexist_on_different_edges() {
541        let area = Rect::new(0, 0, 20, 4);
542        let mut grid = Grid::new(20, 4);
543        Panel::new()
544            .title("Inventory")
545            .add_title("3 / 10 items", TitlePosition::Bottom, Align::Right)
546            .render(&mut Surface::new(&mut grid, area, 0));
547
548        let top_row: String = (0..20).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
549        assert!(top_row.contains("Inventory"));
550        let bottom_row: String = (0..20).map(|x| grid[Pos::new(x, 3)].glyph()).collect();
551        assert!(bottom_row.contains("3 / 10 items"));
552    }
553
554    #[test]
555    fn two_titles_on_the_same_edge_are_left_and_right_aligned() {
556        let area = Rect::new(0, 0, 20, 3);
557        let mut grid = Grid::new(20, 3);
558        Panel::new()
559            .add_title("left", TitlePosition::Top, Align::Left)
560            .add_title("right", TitlePosition::Top, Align::Right)
561            .render(&mut Surface::new(&mut grid, area, 0));
562
563        let top_row: String = (0..20).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
564        assert!(top_row.contains("left"));
565        assert!(top_row.contains("right"));
566        assert!(top_row.find("left").unwrap() < top_row.find("right").unwrap());
567    }
568
569    #[test]
570    fn a_later_title_overlapping_an_earlier_one_is_clipped_instead_of_overdrawing_it() {
571        // Only 10 columns wide: an 8-column left title (" long L ") leaves no room for a
572        // right title declared after it, so the right title must be dropped rather than
573        // overdrawing the left one or panicking.
574        let area = Rect::new(0, 0, 10, 3);
575        let mut grid = Grid::new(10, 3);
576        Panel::new()
577            .add_title("long L", TitlePosition::Top, Align::Left)
578            .add_title("R", TitlePosition::Top, Align::Right)
579            .render(&mut Surface::new(&mut grid, area, 0));
580
581        let top_row: String = (0..10).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
582        assert!(top_row.contains("long L"));
583        assert!(!top_row.contains('R'));
584    }
585
586    #[test]
587    fn a_title_after_a_centered_title_on_the_same_edge_finds_no_room() {
588        let area = Rect::new(0, 0, 20, 3);
589        let mut grid = Grid::new(20, 3);
590        Panel::new()
591            .add_title("centered", TitlePosition::Top, Align::Center)
592            .add_title("dropped", TitlePosition::Top, Align::Right)
593            .render(&mut Surface::new(&mut grid, area, 0));
594
595        let top_row: String = (0..20).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
596        assert!(top_row.contains("centered"));
597        assert!(!top_row.contains("dropped"));
598    }
599
600    #[test]
601    fn a_fifth_add_title_call_still_renders() {
602        // `Panel::add_title` has no cap (unlike an earlier fixed-slot design): a fifth call
603        // (across both edges) must still draw, not silently drop.
604        let area = Rect::new(0, 0, 40, 3);
605        let mut grid = Grid::new(40, 3);
606        Panel::new()
607            .add_title("a", TitlePosition::Top, Align::Left)
608            .add_title("b", TitlePosition::Bottom, Align::Left)
609            .add_title("c", TitlePosition::Top, Align::Right)
610            .add_title("d", TitlePosition::Bottom, Align::Right)
611            .add_title("e", TitlePosition::Top, Align::Center)
612            .render(&mut Surface::new(&mut grid, area, 0));
613
614        let top_row: String = (0..40).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
615        assert!(top_row.contains('a'));
616        assert!(top_row.contains('c'));
617        assert!(top_row.contains('e'));
618    }
619
620    #[test]
621    fn renders_identically_at_the_origin_and_at_a_scoped_offset() {
622        // #738: a widget correct only by not touching `surface.area()`'s absolute `left()`/
623        // `top()` should draw the same relative to its own area regardless of where that area
624        // sits on the grid. `Panel::render` is written that way (`surface.width()`/`height()`,
625        // never `area().left()`/`top()`), so this must hold for it already.
626        let (width, height) = (10, 4);
627        let mut origin_grid = Grid::new(width, height);
628        Panel::new().title("hi").render(&mut Surface::new(
629            &mut origin_grid,
630            Rect::new(0, 0, width, height),
631            0,
632        ));
633
634        let (ox, oy) = (3, 2);
635        let mut offset_grid = Grid::new(width + ox, height + oy);
636        let mut root = Surface::new(
637            &mut offset_grid,
638            Rect::new(0, 0, width + ox, height + oy),
639            0,
640        );
641        Panel::new()
642            .title("hi")
643            .render(&mut root.scope(Rect::new(ox, oy, width, height)));
644
645        for y in 0..height {
646            for x in 0..width {
647                assert_eq!(
648                    origin_grid[Pos::new(x, y)].glyph(),
649                    offset_grid[Pos::new(x + ox, y + oy)].glyph(),
650                    "mismatch at local ({x}, {y})"
651                );
652            }
653        }
654    }
655}