use super::palette::RgbColor;
use super::theme::PanelStyle;
pub type RichCell = (char, Option<RgbColor>, Option<RgbColor>);
#[derive(Clone, Debug, Copy, PartialEq)]
pub enum TextAlignment {
Left,
Center,
Right,
}
#[derive(Clone, Debug, Copy, PartialEq)]
pub enum TitleAlignment {
Left,
Center,
}
#[derive(Clone, Debug, Default)]
pub struct PanelSize {
pub width: usize,
pub height: usize,
}
impl PanelSize {
pub fn new(width: usize, height: usize) -> Self {
Self { width, height }
}
pub fn inner_width(&self, padding: &Padding, border: bool) -> usize {
let border_width = if border { 2 } else { 0 };
self.width
.saturating_sub(padding.left + padding.right + border_width)
}
pub fn inner_height(&self, padding: &Padding, border: bool) -> usize {
let border_height = if border { 2 } else { 0 };
self.height
.saturating_sub(padding.top + padding.bottom + border_height)
}
}
#[derive(Clone, Debug, Default)]
pub struct Padding {
pub top: usize,
pub bottom: usize,
pub left: usize,
pub right: usize,
}
impl Padding {
pub const PANEL: Self = Self {
top: 1,
bottom: 1,
left: 2,
right: 2,
};
pub const COMPACT: Self = Self {
top: 0,
bottom: 0,
left: 1,
right: 1,
};
pub fn new(top: usize, bottom: usize, left: usize, right: usize) -> Self {
Self {
top,
bottom,
left,
right,
}
}
pub fn uniform(all: usize) -> Self {
Self {
top: all,
bottom: all,
left: all,
right: all,
}
}
pub fn vertical(vert: usize, horizontal: usize) -> Self {
Self {
top: vert,
bottom: vert,
left: horizontal,
right: horizontal,
}
}
pub fn horizontal(horizontal: usize, vertical: usize) -> Self {
Self {
top: vertical,
bottom: vertical,
left: horizontal,
right: horizontal,
}
}
}
#[derive(Clone, Debug, Copy, PartialEq)]
pub enum ColumnLayout {
Single,
TwoEqual,
TwoLeftWide,
TwoRightWide,
FourEqual,
}
impl ColumnLayout {
pub fn column_ratios(&self, total_width: usize) -> (usize, usize) {
match self {
ColumnLayout::Single => (total_width, 0),
ColumnLayout::TwoEqual => {
let left = total_width / 2;
(left, total_width - left)
}
ColumnLayout::TwoLeftWide => {
let left = (total_width * 6) / 10;
(left, total_width - left)
}
ColumnLayout::TwoRightWide => {
let left = (total_width * 4) / 10;
(left, total_width - left)
}
ColumnLayout::FourEqual => {
let left = total_width / 2;
(left, total_width - left)
}
}
}
pub fn four_column_ratios(total_width: usize) -> [usize; 4] {
let base = total_width / 4;
let rem = total_width % 4;
[
base + usize::from(rem > 0),
base + usize::from(rem > 1),
base + usize::from(rem > 2),
base,
]
}
pub fn is_single(&self) -> bool {
matches!(self, ColumnLayout::Single)
}
pub fn is_two_column(&self) -> bool {
matches!(
self,
ColumnLayout::TwoEqual | ColumnLayout::TwoLeftWide | ColumnLayout::TwoRightWide
)
}
pub fn is_four_column(&self) -> bool {
matches!(self, ColumnLayout::FourEqual)
}
}
#[derive(Clone, Debug)]
pub struct BorderConfig {
pub top_left: char,
pub top_right: char,
pub bottom_left: char,
pub bottom_right: char,
pub top_horizontal: char,
pub bottom_horizontal: char,
pub vertical: char,
pub left_intersection: char,
pub right_intersection: char,
}
impl Default for BorderConfig {
fn default() -> Self {
Self {
top_left: '█',
top_right: '█',
bottom_left: '█',
bottom_right: '█',
top_horizontal: '▀',
bottom_horizontal: '▄',
vertical: '█',
left_intersection: '█',
right_intersection: '█',
}
}
}
impl BorderConfig {
pub fn box_drawing() -> Self {
Self {
top_left: '╭',
top_right: '╮',
bottom_left: '╰',
bottom_right: '╯',
top_horizontal: '─',
bottom_horizontal: '─',
vertical: '│',
left_intersection: '├',
right_intersection: '┤',
}
}
pub fn solid_blocks() -> Self {
Self::default()
}
pub fn simple() -> Self {
Self {
top_left: '+',
top_right: '+',
bottom_left: '+',
bottom_right: '+',
top_horizontal: '-',
bottom_horizontal: '-',
vertical: '|',
left_intersection: '+',
right_intersection: '+',
}
}
}
#[derive(Clone, Debug)]
pub enum PanelRow {
Empty,
Separator,
Single {
text: String,
align: TextAlignment,
},
TwoCol {
left: String,
right: String,
left_align: TextAlignment,
right_align: TextAlignment,
},
FourCol {
cells: [String; 4],
aligns: [TextAlignment; 4],
},
}
pub struct RenderedOverlay {
pub lines: Vec<String>,
pub title_box: Option<RenderedTitleBox>,
pub rich_lines: Option<Vec<Vec<RichCell>>>,
}
pub struct RenderedTitleBox {
pub lines: Vec<String>,
pub col_offset: usize,
}
pub struct PanelBuilder {
content_width: usize,
content_height: Option<usize>,
padding: Padding,
title: Option<String>,
title_alignment: TitleAlignment,
border: Option<BorderConfig>,
border_color: Option<RgbColor>,
column_layout: ColumnLayout,
rows: Vec<PanelRow>,
style: PanelStyle,
title_box: bool,
}
impl PanelBuilder {
pub fn new(content_width: usize, content_height: Option<usize>) -> Self {
Self {
content_width,
content_height,
padding: Padding::default(),
title: None,
title_alignment: TitleAlignment::Center,
border: Some(BorderConfig::solid_blocks()),
border_color: None,
column_layout: ColumnLayout::Single,
rows: Vec::new(),
style: PanelStyle::default(),
title_box: false,
}
}
pub fn with_padding(mut self, padding: Padding) -> Self {
self.padding = padding;
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_title_alignment(mut self, alignment: TitleAlignment) -> Self {
self.title_alignment = alignment;
self
}
pub fn with_title_box(mut self) -> Self {
self.title_box = true;
self
}
pub fn with_border(mut self, chars: BorderConfig) -> Self {
self.border = Some(chars);
self
}
pub fn with_no_border(mut self) -> Self {
self.border = None;
self
}
pub fn with_border_color(mut self, color: RgbColor) -> Self {
self.border_color = Some(color);
self
}
pub fn with_columns(mut self, layout: ColumnLayout) -> Self {
self.column_layout = layout;
self
}
pub fn with_style(mut self, style: PanelStyle) -> Self {
self.style = style;
self
}
pub fn add_empty(mut self) -> Self {
self.rows.push(PanelRow::Empty);
self
}
pub fn add_empty_n(mut self, n: usize) -> Self {
for _ in 0..n {
self.rows.push(PanelRow::Empty);
}
self
}
pub fn add_separator(mut self) -> Self {
self.rows.push(PanelRow::Separator);
self
}
pub fn add_single(mut self, text: impl Into<String>, align: TextAlignment) -> Self {
self.rows.push(PanelRow::Single {
text: text.into(),
align,
});
self
}
pub fn add_two_col(
mut self,
left: impl Into<String>,
right: impl Into<String>,
left_align: TextAlignment,
right_align: TextAlignment,
) -> Self {
self.rows.push(PanelRow::TwoCol {
left: left.into(),
right: right.into(),
left_align,
right_align,
});
self
}
pub fn add_four_col(mut self, cells: [String; 4], aligns: [TextAlignment; 4]) -> Self {
self.rows.push(PanelRow::FourCol { cells, aligns });
self
}
pub fn with_rows(mut self, rows: Vec<PanelRow>) -> Self {
self.rows = rows;
self
}
pub fn total_width(&self) -> usize {
let border_width = if self.border.is_some() { 2 } else { 0 };
border_width + self.padding.left + self.content_width + self.padding.right
}
pub fn total_height(&self) -> usize {
let border_height = if self.border.is_some() { 2 } else { 0 };
let content_rows = self.content_height.unwrap_or(self.rows.len());
border_height + self.padding.top + content_rows + self.padding.bottom
}
pub fn content_width(&self) -> usize {
self.content_width
}
pub fn inner_width(&self) -> usize {
self.content_width
}
pub fn width(&self) -> usize {
self.total_width()
}
pub fn render_single_row(&self, text: &str, align: TextAlignment) -> String {
let aligned = self.align_text(text, self.content_width, align);
self.wrap_content_line(&aligned)
}
pub fn render_two_col_row(
&self,
left: &str,
right: &str,
la: TextAlignment,
ra: TextAlignment,
) -> String {
let (lw, rw) = self.column_layout.column_ratios(self.content_width);
let left_str = self.align_text(left, lw, la);
let right_str = self.align_text(right, rw, ra);
self.wrap_content_line(&format!("{}{}", left_str, right_str))
}
pub fn render_four_col_row(&self, cells: &[&str; 4], aligns: &[TextAlignment; 4]) -> String {
let widths = ColumnLayout::four_column_ratios(self.content_width);
let mut content = String::with_capacity(self.content_width);
for (i, &cell) in cells.iter().enumerate() {
content.push_str(&self.align_text(cell, widths[i], aligns[i]));
}
self.wrap_content_line(&content)
}
pub fn render_empty_content_line(&self) -> String {
let inner = self.padding.left + self.content_width + self.padding.right;
if let Some(ref border) = self.border {
format!(
"{}{}{}",
border.vertical,
" ".repeat(inner),
border.vertical
)
} else {
" ".repeat(self.total_width())
}
}
pub fn render_separator_line(&self) -> String {
let inner = self.padding.left + self.content_width + self.padding.right;
if let Some(ref border) = self.border {
format!(
"{}{}{}",
border.left_intersection,
border.top_horizontal.to_string().repeat(inner),
border.right_intersection
)
} else {
" ".repeat(self.total_width())
}
}
pub fn render_separator(&self) -> String {
self.render_separator_line()
}
pub fn build_separator(&self) -> String {
self.render_separator_line()
}
pub fn build_title_box(&self) -> Option<(Vec<String>, usize)> {
if !self.title_box {
return None;
}
let title = self.title.as_ref()?;
let border = self.border.as_ref()?;
let title_text = format!(" {} ", title);
let title_inner_w = title_text.chars().count();
let mini_lines = PanelBuilder::new(title_inner_w, None)
.with_border(border.clone())
.add_single(&title_text, TextAlignment::Left)
.build();
let inner = self.padding.left + self.content_width + self.padding.right;
let title_box_w = title_inner_w + 2; let col_offset = match self.title_alignment {
TitleAlignment::Center => inner.saturating_sub(title_box_w) / 2,
TitleAlignment::Left => 0,
};
Some((mini_lines, col_offset))
}
pub fn build_overlay(self) -> RenderedOverlay {
let title_box = self
.build_title_box()
.map(|(lines, col_offset)| RenderedTitleBox { lines, col_offset });
RenderedOverlay {
lines: self.build(),
title_box,
rich_lines: None,
}
}
pub fn build(self) -> Vec<String> {
let mut lines = Vec::new();
if self.border.is_some() {
lines.push(self.render_top_border());
}
for _ in 0..self.padding.top {
lines.push(self.render_empty_content_line());
}
let max_rows = self.content_height.unwrap_or(self.rows.len());
for row in self.rows.iter().take(max_rows) {
let line = match row {
PanelRow::Empty => self.render_empty_content_line(),
PanelRow::Separator => self.render_separator_line(),
PanelRow::Single { text, align } => self.render_single_row(text, *align),
PanelRow::TwoCol {
left,
right,
left_align,
right_align,
} => self.render_two_col_row(left, right, *left_align, *right_align),
PanelRow::FourCol { cells, aligns } => {
let cell_refs: [&str; 4] = [&cells[0], &cells[1], &cells[2], &cells[3]];
self.render_four_col_row(&cell_refs, aligns)
}
};
lines.push(line);
}
if let Some(h) = self.content_height {
let rendered_content =
lines
.len()
.saturating_sub(if self.border.is_some() { 1 } else { 0 })
- self.padding.top;
for _ in rendered_content..h {
lines.push(self.render_empty_content_line());
}
}
for _ in 0..self.padding.bottom {
lines.push(self.render_empty_content_line());
}
if self.border.is_some() {
lines.push(self.render_bottom_border());
}
lines
}
fn render_top_border(&self) -> String {
let border = self.border.as_ref().unwrap();
let inner = self.padding.left + self.content_width + self.padding.right;
if self.title_box && self.title.is_some() {
return format!(
"{}{}{}",
border.top_left,
border.top_horizontal.to_string().repeat(inner),
border.top_right,
);
}
if let Some(ref title) = self.title {
let title_str = format!(" {} ", title);
let title_len = title_str.chars().count();
let remaining = inner.saturating_sub(title_len);
let (left_fill, right_fill) = match self.title_alignment {
TitleAlignment::Center => {
let l = remaining / 2;
(l, remaining - l)
}
TitleAlignment::Left => (0, remaining),
};
format!(
"{}{}{}{}{}",
border.top_left,
border.top_horizontal.to_string().repeat(left_fill),
title_str,
border.top_horizontal.to_string().repeat(right_fill),
border.top_right
)
} else {
format!(
"{}{}{}",
border.top_left,
border.top_horizontal.to_string().repeat(inner),
border.top_right
)
}
}
fn render_bottom_border(&self) -> String {
let border = self.border.as_ref().unwrap();
let inner = self.padding.left + self.content_width + self.padding.right;
format!(
"{}{}{}",
border.bottom_left,
border.bottom_horizontal.to_string().repeat(inner),
border.bottom_right
)
}
fn wrap_content_line(&self, content: &str) -> String {
if let Some(ref border) = self.border {
format!(
"{}{}{}{}{}",
border.vertical,
" ".repeat(self.padding.left),
content,
" ".repeat(self.padding.right),
border.vertical
)
} else {
format!(
"{}{}{}",
" ".repeat(self.padding.left),
content,
" ".repeat(self.padding.right)
)
}
}
fn align_text(&self, text: &str, width: usize, alignment: TextAlignment) -> String {
let text_len = text.chars().count();
if text_len >= width {
return text.chars().take(width).collect();
}
let remaining = width - text_len;
match alignment {
TextAlignment::Left => format!("{}{}", text, " ".repeat(remaining)),
TextAlignment::Center => {
let left = remaining / 2;
let right = remaining - left;
format!("{}{}{}", " ".repeat(left), text, " ".repeat(right))
}
TextAlignment::Right => format!("{}{}", " ".repeat(remaining), text),
}
}
}
pub fn footer_hints(actions: &[(&str, &str)]) -> String {
actions
.iter()
.map(|(key, verb)| format!("{key} {verb}"))
.collect::<Vec<_>>()
.join(" · ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn footer_hints_joins_with_middle_dot() {
assert_eq!(
footer_hints(&[("↵", "save"), ("esc", "cancel")]),
"↵ save · esc cancel"
);
}
#[test]
fn footer_hints_single_and_empty() {
assert_eq!(footer_hints(&[("esc", "cancel")]), "esc cancel");
assert_eq!(footer_hints(&[]), "");
}
#[test]
fn footer_hints_multi_action_browser_grammar() {
assert_eq!(
footer_hints(&[
("↑↓", "navigate"),
("↵", "load"),
("del", "delete"),
("esc", "cancel")
]),
"↑↓ navigate · ↵ load · del delete · esc cancel"
);
}
#[test]
fn test_total_width_with_border_and_padding() {
let panel = PanelBuilder::new(44, None).with_padding(Padding::new(1, 1, 2, 2));
assert_eq!(panel.total_width(), 50);
}
#[test]
fn test_total_width_no_border() {
let panel = PanelBuilder::new(44, None)
.with_padding(Padding::new(1, 1, 2, 2))
.with_no_border();
assert_eq!(panel.total_width(), 48);
}
#[test]
fn test_build_lines_all_same_width() {
let lines = PanelBuilder::new(26, None)
.with_padding(Padding::new(1, 1, 2, 2))
.with_title("TEST")
.add_single("Hello world", TextAlignment::Left)
.add_separator()
.add_empty()
.add_single("Right aligned", TextAlignment::Right)
.build();
let expected_width = 1 + 2 + 26 + 2 + 1; for (i, line) in lines.iter().enumerate() {
assert_eq!(
line.chars().count(),
expected_width,
"Line {} has wrong width: '{}'",
i,
line
);
}
}
#[test]
fn test_top_border_with_title() {
let panel = PanelBuilder::new(26, None)
.with_padding(Padding::new(1, 1, 2, 2))
.with_title("STATS");
let border_line = panel.render_top_border();
assert!(
border_line.starts_with('█'),
"Should start with solid block corner"
);
assert!(
border_line.ends_with('█'),
"Should end with solid block corner"
);
assert!(border_line.contains("STATS"), "Should contain title");
assert_eq!(border_line.chars().count(), 32);
}
#[test]
fn test_separator_line_width() {
let panel = PanelBuilder::new(26, None).with_padding(Padding::new(1, 1, 2, 2));
let sep = panel.render_separator_line();
assert!(sep.starts_with('█'));
assert!(sep.ends_with('█'));
assert_eq!(sep.chars().count(), 32);
}
#[test]
fn test_four_column_ratios() {
let widths = ColumnLayout::four_column_ratios(54);
assert_eq!(widths.iter().sum::<usize>(), 54);
for w in widths {
assert!((13..=14).contains(&w));
}
}
#[test]
fn test_four_col_row_width() {
let panel = PanelBuilder::new(54, None)
.with_padding(Padding::new(1, 1, 2, 2))
.with_columns(ColumnLayout::FourEqual)
.add_four_col(
[
"p, Space".to_string(),
"Pause".to_string(),
"c, C".to_string(),
"Palette".to_string(),
],
[
TextAlignment::Left,
TextAlignment::Left,
TextAlignment::Left,
TextAlignment::Left,
],
);
let lines = panel.build();
let expected = 1 + 2 + 54 + 2 + 1; for line in &lines {
assert_eq!(line.chars().count(), expected);
}
}
#[test]
fn test_border_is_additive() {
let cw = 44usize;
let panel = PanelBuilder::new(cw, None).with_padding(Padding::new(1, 1, 2, 2));
assert_eq!(panel.total_width(), cw + 2 + 4);
}
#[test]
fn test_solid_blocks_default_border() {
let lines = PanelBuilder::new(10, None).build();
assert!(
lines[0].starts_with('█'),
"Top border should start with solid block █"
);
assert!(
lines.last().unwrap().starts_with('█'),
"Bottom border should start with solid block █"
);
}
#[test]
fn test_two_col_row_width() {
let panel = PanelBuilder::new(44, None)
.with_padding(Padding::new(1, 1, 2, 2))
.with_columns(ColumnLayout::TwoEqual)
.add_two_col(
"Left content",
"Right content",
TextAlignment::Left,
TextAlignment::Right,
);
let lines = panel.build();
let expected = 50;
for line in &lines {
assert_eq!(line.chars().count(), expected);
}
}
}