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