1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
17pub enum TitlePosition {
18 #[default]
20 Top,
21 Bottom,
23}
24
25#[derive(Clone, Copy, Debug)]
31pub struct PanelTitle<'a> {
32 text: &'a str,
33 position: TitlePosition,
34 align: Align,
35}
36
37#[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 #[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 #[must_use]
93 pub const fn title(mut self, title: &'a str) -> Self {
94 self.title = Some(title);
95 self
96 }
97
98 #[must_use]
101 pub const fn title_align(mut self, align: Align) -> Self {
102 self.title_align = align;
103 self
104 }
105
106 #[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 #[must_use]
136 pub const fn border_style(mut self, style: Style) -> Self {
137 self.border_style = style;
138 self
139 }
140
141 #[must_use]
143 pub const fn fill_style(mut self, style: Style) -> Self {
144 self.fill_style = style;
145 self
146 }
147
148 #[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 #[must_use]
163 pub const fn padding(mut self, padding: Sides) -> Self {
164 self.padding = padding;
165 self
166 }
167
168 #[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 #[must_use]
205 pub fn theme(self, theme: Theme) -> Self {
206 self.theme_on(theme, theme.panel_bg)
207 }
208
209 #[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 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 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 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 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
281struct TitleCursor {
286 lo: u16,
288 hi: u16,
290}
291
292impl TitleCursor {
293 const fn new(width: u16) -> Self {
295 Self {
296 lo: 1,
297 hi: width.saturating_sub(1),
298 }
299 }
300
301 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(), ' '); 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); 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 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 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); }
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 let area = Rect::new(0, 0, 10, 3); let mut grid = Grid::new(10, 3);
506 Panel::new()
507 .title("あ")
508 .render(&mut Surface::new(&mut grid, area, 0));
509
510 assert_eq!(grid[Pos::new(3, 0)].glyph(), ' ');
514 assert_eq!(grid[Pos::new(4, 0)].glyph(), 'あ');
515
516 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 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 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 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 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}