use crate::{
Component,
RenderError,
Rendered,
layout::Border,
theme::{
ColorMode,
Palette,
Style,
Theme,
},
};
pub struct Panel {
title: Option<String>,
lines: Vec<String>,
border: Border,
pad: u16,
}
impl Panel {
pub fn new() -> Self {
Self {
title: None,
lines: Vec::new(),
border: Border::ROUNDED,
pad: 1,
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn lines(mut self, lines: Vec<String>) -> Self {
self.lines = lines;
self
}
pub fn border(mut self, border: Border) -> Self {
self.border = border;
self
}
pub fn pad(mut self, pad: u16) -> Self {
self.pad = pad;
self
}
pub fn height(&self) -> u16 {
let mut h = self.lines.len() as u16;
if self.border.top != ' ' {
h += 1;
}
if self.border.bottom != ' ' {
h += 1;
}
h.max(1)
}
fn border_style(&self) -> Style {
Style::new().fg(Theme::current().border_default())
}
}
impl Default for Panel {
fn default() -> Self {
Self::new()
}
}
impl Component for Panel {
fn render(&self, width: u16) -> Result<Rendered, RenderError> {
let _theme = Theme::current();
let border_style = self.border_style();
let (border_w, _border_h) = self.border.size();
let pad = self.pad as usize;
let mode = ColorMode::detect();
let prefix = border_style.prefix(mode);
let suffix = Style::suffix();
let mut rendered = Rendered::empty();
let available = width.saturating_sub(border_w * 2) as usize;
let actual_pad = pad.min(available / 2);
let inner_width = available.saturating_sub(actual_pad * 2);
let total_width = inner_width + actual_pad * 2;
{
let mut top = String::new();
top.push_str(&prefix);
top.push(self.border.top_left);
let title_text = self.title.as_ref().map(|t| {
let max_title = total_width.saturating_sub(2);
let t = if t.len() > max_title {
&t[..max_title]
} else {
t
};
format!(" {} ", t)
});
if let Some(ref t) = title_text {
top.push_str(t);
let t_visible = crate::utils::visible_width(t);
let fill_count = total_width.saturating_sub(t_visible);
if fill_count > 0 {
top.push_str(&prefix);
top.push_str(&self.border.top.to_string().repeat(fill_count));
top.push_str(suffix);
}
} else {
top.push_str(&prefix);
top.push_str(&self.border.top.to_string().repeat(total_width));
top.push_str(suffix);
}
top.push_str(&prefix);
top.push(self.border.top_right);
top.push_str(suffix);
rendered.lines.push(top);
}
let content_height = self.lines.len().max(1);
for i in 0..content_height {
let mut line = String::new();
if self.border.left != ' ' {
line.push_str(&prefix);
line.push(self.border.left);
line.push_str(suffix);
}
for _ in 0..actual_pad {
line.push(' ');
}
let content = if i < self.lines.len() {
crate::utils::truncate_to_width(&self.lines[i], inner_width as u16, "")
} else {
String::new()
};
line.push_str(&content);
let content_visible = crate::utils::visible_width(&content);
for _ in content_visible..inner_width {
line.push(' ');
}
for _ in 0..actual_pad {
line.push(' ');
}
if self.border.right != ' ' && width > 1 {
line.push_str(&prefix);
line.push(self.border.right);
line.push_str(suffix);
}
rendered.lines.push(line);
}
{
let mut bottom = String::new();
bottom.push_str(&prefix);
bottom.push(self.border.bottom_left);
bottom.push_str(&self.border.bottom.to_string().repeat(total_width));
bottom.push(self.border.bottom_right);
bottom.push_str(suffix);
rendered.lines.push(bottom);
}
Ok(rendered)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::Theme;
#[test]
fn panel_renders_rounded_border() {
Theme::with(Theme::Light, || {
let panel = Panel::new().lines(vec!["Hello".into()]);
let rendered = panel.render(9).unwrap();
assert_eq!(rendered.lines.len(), 3);
assert!(rendered.lines[0].contains('╭'));
assert!(rendered.lines[0].contains('╮'));
assert!(rendered.lines[1].contains('│'));
assert!(rendered.lines[2].contains('╰'));
assert!(rendered.lines[2].contains('╯'));
});
}
#[test]
fn panel_renders_thin_border() {
Theme::with(Theme::Light, || {
let panel = Panel::new().border(Border::THIN).lines(vec!["Hi".into()]);
let rendered = panel.render(6).unwrap();
assert!(rendered.lines[0].contains('┌'));
assert!(rendered.lines[0].contains('┐'));
assert!(rendered.lines[2].contains('└'));
assert!(rendered.lines[2].contains('┘'));
});
}
#[test]
fn panel_renders_thick_border() {
Theme::with(Theme::Light, || {
let panel = Panel::new().border(Border::THICK).lines(vec!["X".into()]);
let rendered = panel.render(5).unwrap();
assert!(rendered.lines[0].contains('┏'));
assert!(rendered.lines[0].contains('┓'));
assert!(rendered.lines[2].contains('┗'));
assert!(rendered.lines[2].contains('┛'));
});
}
#[test]
fn panel_renders_double_border() {
Theme::with(Theme::Light, || {
let panel = Panel::new().border(Border::DOUBLE).lines(vec!["X".into()]);
let rendered = panel.render(5).unwrap();
assert!(rendered.lines[0].contains('╔'));
assert!(rendered.lines[0].contains('╗'));
assert!(rendered.lines[2].contains('╚'));
assert!(rendered.lines[2].contains('╝'));
});
}
#[test]
fn panel_with_title() {
Theme::with(Theme::Light, || {
let panel = Panel::new().title("Test").lines(vec!["Content".into()]);
let rendered = panel.render(15).unwrap();
assert!(rendered.lines[0].contains("Test"));
assert!(rendered.lines[1].contains("Content"));
});
}
#[test]
fn panel_content_is_inside_border() {
Theme::with(Theme::Light, || {
let panel = Panel::new().lines(vec!["A".into()]);
let rendered = panel.render(5).unwrap();
let row1 = &rendered.lines[1];
assert!(row1.contains('A'));
assert!(row1.contains('│'));
});
}
#[test]
fn panel_with_padding() {
Theme::with(Theme::Light, || {
let panel = Panel::new().pad(2).lines(vec!["X".into()]);
let rendered = panel.render(7).unwrap();
assert_eq!(rendered.lines.len(), 3);
let row1 = &rendered.lines[1];
assert!(row1.contains('X'));
});
}
#[test]
fn panel_empty_lines_still_has_border() {
Theme::with(Theme::Light, || {
let panel = Panel::new();
let rendered = panel.render(5).unwrap();
assert!(rendered.lines[0].contains('╭'));
assert!(rendered.lines[0].contains('╮'));
});
}
#[test]
fn panel_trims_long_content() {
Theme::with(Theme::Light, || {
let panel = Panel::new().lines(vec!["This is way too long".into()]);
let rendered = panel.render(10).unwrap();
let row1 = &rendered.lines[1];
assert!(!row1.contains("way too long"));
});
}
#[test]
fn panel_uses_theme_color() {
Theme::with(Theme::Light, || {
let panel = Panel::new().lines(vec!["Hi".into()]);
let rendered = panel.render(6).unwrap();
assert!(rendered.lines[0].starts_with('\x1b'));
});
}
#[test]
fn panel_height_calculation() {
let panel = Panel::new().lines(vec!["a".into(), "b".into()]);
assert_eq!(panel.height(), 4);
}
#[test]
fn panel_default_is_rounded() {
let panel = Panel::default();
assert_eq!(panel.border, Border::ROUNDED);
}
#[test]
fn panel_respects_narrow_width() {
Theme::with(Theme::Light, || {
let panel = Panel::new().lines(vec!["X".into()]);
let rendered = panel.render(3).unwrap();
for (i, line) in rendered.lines.iter().enumerate() {
let vw = crate::utils::visible_width(line);
assert!(
vw <= 3,
"line {} exceeds width 3 (actual {}): {:?}",
i,
vw,
line
);
}
});
}
}