use alloc::vec::Vec;
use retroglyph_core::color::{Color, Style};
use retroglyph_core::grid::Rect;
use retroglyph_core::text::truncate_measured;
use super::{BorderType, BoxBorder, Measure, Widget};
use crate::Surface;
use crate::align::Align;
use crate::draw::fill_rect;
use crate::style::Sides;
use crate::text::draw_clipped;
use crate::theme::Theme;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum TitlePosition {
#[default]
Top,
Bottom,
}
#[derive(Clone, Copy, Debug)]
pub struct PanelTitle<'a> {
text: &'a str,
position: TitlePosition,
align: Align,
}
impl<'a> PanelTitle<'a> {
#[must_use]
pub const fn new(text: &'a str, position: TitlePosition, align: Align) -> Self {
Self {
text,
position,
align,
}
}
pub(super) const fn parts(self) -> (&'a str, TitlePosition, Align) {
(self.text, self.position, self.align)
}
}
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct PanelChrome<'a> {
title: Option<&'a str>,
title_align: Align,
border_style: Style,
fill_style: Style,
border_type: BorderType,
padding: Sides,
}
impl<'a> PanelChrome<'a> {
pub(super) const fn title(mut self, title: &'a str) -> Self {
self.title = Some(title);
self
}
pub(super) const fn title_align(mut self, align: Align) -> Self {
self.title_align = align;
self
}
pub(super) const fn border_style(mut self, style: Style) -> Self {
self.border_style = style;
self
}
pub(super) const fn fill_style(mut self, style: Style) -> Self {
self.fill_style = style;
self
}
pub(super) const fn border_type(mut self, border_type: BorderType) -> Self {
self.border_type = border_type;
self
}
pub(super) const fn padding(mut self, padding: Sides) -> Self {
self.padding = padding;
self
}
pub(super) fn theme(self, theme: Theme) -> Self {
self.theme_on(theme, theme.panel_bg)
}
pub(super) fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
self.border_style = Style::new().fg(theme.border).bg(theme.title_bg);
self.fill_style = Style::new().bg(bg);
self
}
}
#[derive(Clone, Debug, Default)]
pub struct Panel<'a> {
chrome: PanelChrome<'a>,
titles: Vec<PanelTitle<'a>>,
}
impl<'a> Panel<'a> {
#[must_use]
pub fn new() -> Self {
Self {
chrome: PanelChrome::default().title_align(Align::Center),
titles: Vec::new(),
}
.theme(Theme::DARK)
}
pub(super) const fn from_chrome(chrome: PanelChrome<'a>) -> Self {
Self {
chrome,
titles: Vec::new(),
}
}
#[must_use]
pub const fn title(mut self, title: &'a str) -> Self {
self.chrome = self.chrome.title(title);
self
}
#[must_use]
pub const fn title_align(mut self, align: Align) -> Self {
self.chrome = self.chrome.title_align(align);
self
}
#[must_use]
pub fn add_title(mut self, title: &'a str, position: TitlePosition, align: Align) -> Self {
self.titles.push(PanelTitle {
text: title,
position,
align,
});
self
}
#[must_use]
pub const fn border_style(mut self, style: Style) -> Self {
self.chrome = self.chrome.border_style(style);
self
}
#[must_use]
pub const fn fill_style(mut self, style: Style) -> Self {
self.chrome = self.chrome.fill_style(style);
self
}
#[must_use]
pub const fn border_type(mut self, border_type: BorderType) -> Self {
self.chrome = self.chrome.border_type(border_type);
self
}
#[must_use]
pub const fn padding(mut self, padding: Sides) -> Self {
self.chrome = self.chrome.padding(padding);
self
}
#[must_use]
pub const fn inner(&self, area: Rect) -> Rect {
let padding = self.chrome.padding;
let left = 1 + padding.left;
let top = 1 + padding.top;
let horizontal = 2 + padding.left + padding.right;
let vertical = 2 + padding.top + padding.bottom;
Rect::new(
area.left().saturating_add(left),
area.top().saturating_add(top),
area.width().saturating_sub(horizontal),
area.height().saturating_sub(vertical),
)
}
#[must_use]
pub fn theme(mut self, theme: Theme) -> Self {
self.chrome = self.chrome.theme(theme);
self
}
#[must_use]
pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
self.chrome = self.chrome.theme_on(theme, bg);
self
}
}
impl Measure for Panel<'_> {
fn height_for(&self, _width: u16) -> u16 {
2u16.saturating_add(self.chrome.padding.top)
.saturating_add(self.chrome.padding.bottom)
}
}
impl Widget for Panel<'_> {
fn render(&self, surface: &mut Surface<'_>) {
let (width, height) = (surface.width(), surface.height());
if width < 2 || height < 2 {
return;
}
let inner = Rect::new(1, 1, width.saturating_sub(2), height.saturating_sub(2));
fill_rect(surface, inner, ' ', self.chrome.fill_style);
BoxBorder::new()
.style(self.chrome.border_style)
.border_type(self.chrome.border_type)
.render(surface);
let mut top = TitleCursor::new(width);
if let Some(t) = self.chrome.title {
top.draw(
surface,
0,
t,
self.chrome.title_align,
self.chrome.border_style,
);
}
for title in &self.titles {
if title.position == TitlePosition::Top {
top.draw(
surface,
0,
title.text,
title.align,
self.chrome.border_style,
);
}
}
let mut bottom = TitleCursor::new(width);
for title in &self.titles {
if title.position == TitlePosition::Bottom {
bottom.draw(
surface,
height - 1,
title.text,
title.align,
self.chrome.border_style,
);
}
}
}
}
struct TitleCursor {
lo: u16,
hi: u16,
}
impl TitleCursor {
const fn new(width: u16) -> Self {
Self {
lo: 1,
hi: width.saturating_sub(1),
}
}
fn draw(&mut self, surface: &mut Surface<'_>, y: u16, text: &str, align: Align, style: Style) {
if self.lo >= self.hi {
return;
}
let avail = self.hi - self.lo;
let Some(max_title_w) = avail.checked_sub(2) else {
return;
};
let (t, t_w) = truncate_measured(text, max_title_w);
let padded = t_w + 2;
let title_x = self.lo + align.offset(avail, padded);
surface.put((title_x, y), ' ', style);
let _ = draw_clipped(surface, (title_x + 1, y), t_w, t, Align::Left, style);
surface.put((title_x + 1 + t_w, y), ' ', style);
match align {
Align::Left => self.lo = title_x + padded,
Align::Right => self.hi = title_x,
_ => self.hi = self.lo,
}
}
}
#[cfg(test)]
mod tests {
use alloc::string::String;
use retroglyph_core::color::Color;
use retroglyph_core::grid::{Grid, Pos};
use super::*;
#[test]
fn draws_border_fill_and_title() {
let area = Rect::new(0, 0, 10, 4);
let border = Style::new().fg(Color::WHITE);
let fill = Style::new();
let mut grid = Grid::new(10, 4);
Panel::new()
.border_style(border)
.fill_style(fill)
.title("hi")
.render(&mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(0, 0)].glyph(), '┌');
assert_eq!(grid[Pos::new(1, 1)].glyph(), ' '); let top_row: String = (0..10).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
assert!(top_row.contains("hi"));
}
#[test]
fn long_title_is_truncated_to_fit() {
let area = Rect::new(0, 0, 8, 3); let mut grid = Grid::new(8, 3);
Panel::new()
.title("a very long title")
.render(&mut Surface::new(&mut grid, area, 0));
let top_row: String = (0..8).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
assert!(!top_row.contains("a very long title"));
}
#[test]
fn theme_maps_named_roles_onto_border_and_fill() {
let area = Rect::new(0, 0, 10, 4);
let mut grid = Grid::new(10, 4);
Panel::new()
.theme(Theme::DARK)
.render(&mut Surface::new(&mut grid, area, 0));
assert_eq!(
grid[Pos::new(0, 0)].style().foreground(),
Theme::DARK.border
);
assert_eq!(
grid[Pos::new(0, 0)].style().background(),
Theme::DARK.title_bg
);
assert_eq!(
grid[Pos::new(1, 1)].style().background(),
Theme::DARK.panel_bg
);
}
#[test]
fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
let area = Rect::new(0, 0, 10, 4);
let mut grid = Grid::new(10, 4);
Panel::new()
.theme_on(Theme::DARK, Color::Default)
.render(&mut Surface::new(&mut grid, area, 0));
assert_eq!(
grid[Pos::new(0, 0)].style().foreground(),
Theme::DARK.border
);
assert_eq!(
grid[Pos::new(0, 0)].style().background(),
Theme::DARK.title_bg
);
assert_eq!(grid[Pos::new(1, 1)].style().background(), Color::Default);
}
#[test]
fn left_aligned_title_starts_after_the_corner() {
let area = Rect::new(0, 0, 12, 3);
let mut grid = Grid::new(12, 3);
Panel::new()
.title("hi")
.title_align(Align::Left)
.render(&mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(1, 0)].glyph(), ' ');
assert_eq!(grid[Pos::new(2, 0)].glyph(), 'h');
assert_eq!(grid[Pos::new(3, 0)].glyph(), 'i');
}
#[test]
fn right_aligned_title_ends_before_the_corner() {
let area = Rect::new(0, 0, 12, 3);
let mut grid = Grid::new(12, 3);
Panel::new()
.title("hi")
.title_align(Align::Right)
.render(&mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(8, 0)].glyph(), 'h');
assert_eq!(grid[Pos::new(9, 0)].glyph(), 'i');
assert_eq!(grid[Pos::new(10, 0)].glyph(), ' ');
}
#[test]
fn height_for_is_the_border_plus_padding() {
assert_eq!(Panel::new().height_for(80), 2);
let padded = Panel::new().padding(Sides::symmetric(1, 0));
assert_eq!(padded.height_for(80), 4); }
#[test]
fn inner_insets_by_the_one_cell_border() {
let area = Rect::new(0, 0, 20, 5);
assert_eq!(Panel::new().inner(area), Rect::new(1, 1, 18, 3));
}
#[test]
fn inner_also_insets_by_padding() {
let area = Rect::new(0, 0, 20, 5);
let panel = Panel::new().padding(Sides::symmetric(0, 1));
assert_eq!(panel.inner(area), Rect::new(2, 1, 16, 3));
}
#[test]
fn inner_saturates_instead_of_underflowing_when_area_is_too_small() {
let area = Rect::new(3, 4, 1, 1);
let panel = Panel::new().padding(Sides::all(2));
assert_eq!(panel.inner(area), Rect::new(6, 7, 0, 0));
}
#[test]
fn too_small_is_a_no_op() {
let area = Rect::new(0, 0, 1, 1);
let mut grid = Grid::new(1, 1);
Panel::new().render(&mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
}
#[test]
fn border_type_selects_the_glyph_set() {
let area = Rect::new(0, 0, 10, 4);
let mut grid = Grid::new(10, 4);
Panel::new()
.border_type(BorderType::Double)
.render(&mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(0, 0)].glyph(), '╔');
assert_eq!(grid[Pos::new(9, 0)].glyph(), '╗');
assert_eq!(grid[Pos::new(0, 3)].glyph(), '╚');
assert_eq!(grid[Pos::new(9, 3)].glyph(), '╝');
}
#[test]
fn wide_char_title_is_centred_by_display_width_not_byte_length() {
let area = Rect::new(0, 0, 10, 3); let mut grid = Grid::new(10, 3);
Panel::new()
.title("あ")
.render(&mut Surface::new(&mut grid, area, 0));
assert_eq!(grid[Pos::new(3, 0)].glyph(), ' ');
assert_eq!(grid[Pos::new(4, 0)].glyph(), 'あ');
assert_eq!(grid[Pos::new(5, 0)].glyph(), ' ');
assert_eq!(grid[Pos::new(6, 0)].glyph(), ' ');
}
#[test]
fn add_title_draws_into_the_bottom_border_row() {
let area = Rect::new(0, 0, 12, 4);
let mut grid = Grid::new(12, 4);
Panel::new()
.add_title("hint", TitlePosition::Bottom, Align::Center)
.render(&mut Surface::new(&mut grid, area, 0));
let bottom_row: String = (0..12).map(|x| grid[Pos::new(x, 3)].glyph()).collect();
assert!(bottom_row.contains("hint"));
let top_row: String = (0..12).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
assert!(!top_row.contains("hint"));
}
#[test]
fn title_and_add_title_coexist_on_different_edges() {
let area = Rect::new(0, 0, 20, 4);
let mut grid = Grid::new(20, 4);
Panel::new()
.title("Inventory")
.add_title("3 / 10 items", TitlePosition::Bottom, Align::Right)
.render(&mut Surface::new(&mut grid, area, 0));
let top_row: String = (0..20).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
assert!(top_row.contains("Inventory"));
let bottom_row: String = (0..20).map(|x| grid[Pos::new(x, 3)].glyph()).collect();
assert!(bottom_row.contains("3 / 10 items"));
}
#[test]
fn two_titles_on_the_same_edge_are_left_and_right_aligned() {
let area = Rect::new(0, 0, 20, 3);
let mut grid = Grid::new(20, 3);
Panel::new()
.add_title("left", TitlePosition::Top, Align::Left)
.add_title("right", TitlePosition::Top, Align::Right)
.render(&mut Surface::new(&mut grid, area, 0));
let top_row: String = (0..20).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
assert!(top_row.contains("left"));
assert!(top_row.contains("right"));
assert!(top_row.find("left").unwrap() < top_row.find("right").unwrap());
}
#[test]
fn a_later_title_overlapping_an_earlier_one_is_clipped_instead_of_overdrawing_it() {
let area = Rect::new(0, 0, 10, 3);
let mut grid = Grid::new(10, 3);
Panel::new()
.add_title("long L", TitlePosition::Top, Align::Left)
.add_title("R", TitlePosition::Top, Align::Right)
.render(&mut Surface::new(&mut grid, area, 0));
let top_row: String = (0..10).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
assert!(top_row.contains("long L"));
assert!(!top_row.contains('R'));
}
#[test]
fn a_title_after_a_centered_title_on_the_same_edge_finds_no_room() {
let area = Rect::new(0, 0, 20, 3);
let mut grid = Grid::new(20, 3);
Panel::new()
.add_title("centered", TitlePosition::Top, Align::Center)
.add_title("dropped", TitlePosition::Top, Align::Right)
.render(&mut Surface::new(&mut grid, area, 0));
let top_row: String = (0..20).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
assert!(top_row.contains("centered"));
assert!(!top_row.contains("dropped"));
}
#[test]
fn a_fifth_add_title_call_still_renders() {
let area = Rect::new(0, 0, 40, 3);
let mut grid = Grid::new(40, 3);
Panel::new()
.add_title("a", TitlePosition::Top, Align::Left)
.add_title("b", TitlePosition::Bottom, Align::Left)
.add_title("c", TitlePosition::Top, Align::Right)
.add_title("d", TitlePosition::Bottom, Align::Right)
.add_title("e", TitlePosition::Top, Align::Center)
.render(&mut Surface::new(&mut grid, area, 0));
let top_row: String = (0..40).map(|x| grid[Pos::new(x, 0)].glyph()).collect();
assert!(top_row.contains('a'));
assert!(top_row.contains('c'));
assert!(top_row.contains('e'));
}
#[test]
fn renders_identically_at_the_origin_and_at_a_scoped_offset() {
let (width, height) = (10, 4);
let mut origin_grid = Grid::new(width, height);
Panel::new().title("hi").render(&mut Surface::new(
&mut origin_grid,
Rect::new(0, 0, width, height),
0,
));
let (ox, oy) = (3, 2);
let mut offset_grid = Grid::new(width + ox, height + oy);
let mut root = Surface::new(
&mut offset_grid,
Rect::new(0, 0, width + ox, height + oy),
0,
);
Panel::new()
.title("hi")
.render(&mut root.scope(Rect::new(ox, oy, width, height)));
for y in 0..height {
for x in 0..width {
assert_eq!(
origin_grid[Pos::new(x, y)].glyph(),
offset_grid[Pos::new(x + ox, y + oy)].glyph(),
"mismatch at local ({x}, {y})"
);
}
}
}
}