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}