use crate::r#box::{ASCII, BoxChars, ROUNDED, SQUARE};
use crate::cells;
use crate::console::{Console, ConsoleOptions};
use crate::markup;
use crate::renderables::Renderable;
use crate::segment::{Segment, adjust_line_length};
use crate::style::Style;
use crate::text::{JustifyMethod, OverflowMethod, Text};
use super::padding::PaddingDimensions;
#[derive(Debug, Clone)]
pub struct Panel<'a> {
content_lines: Vec<Vec<Segment<'a>>>,
box_style: &'static BoxChars,
safe_box: Option<bool>,
expand: bool,
style: Style,
border_style: Style,
width: Option<usize>,
height: Option<usize>,
padding: PaddingDimensions,
title: Option<Text>,
title_align: JustifyMethod,
subtitle: Option<Text>,
subtitle_align: JustifyMethod,
}
impl Default for Panel<'_> {
fn default() -> Self {
Self {
content_lines: Vec::new(),
box_style: &ROUNDED,
safe_box: None,
expand: true,
style: Style::new(),
border_style: Style::new(),
width: None,
height: None,
padding: PaddingDimensions::symmetric(0, 1),
title: None,
title_align: JustifyMethod::Center,
subtitle: None,
subtitle_align: JustifyMethod::Center,
}
}
}
impl<'a> Panel<'a> {
#[must_use]
pub fn new(content_lines: Vec<Vec<Segment<'a>>>) -> Self {
Self {
content_lines,
..Self::default()
}
}
#[must_use]
pub fn from_text(text: &'a str) -> Self {
let lines: Vec<Vec<Segment<'a>>> = text
.lines()
.map(|line| vec![Segment::new(line, None)])
.collect();
Self::new(lines)
}
#[must_use]
pub fn from_rich_text(text: &'a Text, width: usize) -> Self {
let lines = text
.split_lines()
.into_iter()
.map(|line| {
line.render("")
.into_iter()
.map(super::super::segment::Segment::into_owned)
.collect()
})
.collect();
Self {
content_lines: lines,
width: Some(width),
..Self::default()
}
}
#[must_use]
pub fn box_style(mut self, style: &'static BoxChars) -> Self {
self.box_style = style;
self
}
#[must_use]
pub fn rounded(mut self) -> Self {
self.box_style = &ROUNDED;
self
}
#[must_use]
pub fn square(mut self) -> Self {
self.box_style = &SQUARE;
self
}
#[must_use]
pub fn ascii(mut self) -> Self {
self.box_style = &ASCII;
self.safe_box = Some(true);
self
}
#[must_use]
pub fn safe_box(mut self, safe: bool) -> Self {
self.safe_box = Some(safe);
self
}
#[must_use]
pub fn expand(mut self, expand: bool) -> Self {
self.expand = expand;
self
}
#[must_use]
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub fn border_style(mut self, style: Style) -> Self {
self.border_style = style;
self
}
#[must_use]
pub fn width(mut self, width: usize) -> Self {
self.width = Some(width);
self
}
#[must_use]
pub fn height(mut self, height: usize) -> Self {
self.height = Some(height);
self
}
#[must_use]
pub fn padding(mut self, padding: impl Into<PaddingDimensions>) -> Self {
self.padding = padding.into();
self
}
#[must_use]
pub fn title(mut self, title: impl Into<Text>) -> Self {
self.title = Some(title.into());
self
}
#[must_use]
pub fn title_align(mut self, align: JustifyMethod) -> Self {
self.title_align = align;
self
}
#[must_use]
pub fn title_from_markup(mut self, title: &str) -> Self {
self.title = Some(markup::render_or_plain(title));
self
}
#[must_use]
pub fn subtitle(mut self, subtitle: impl Into<Text>) -> Self {
self.subtitle = Some(subtitle.into());
self
}
#[must_use]
pub fn subtitle_align(mut self, align: JustifyMethod) -> Self {
self.subtitle_align = align;
self
}
#[must_use]
pub fn subtitle_from_markup(mut self, subtitle: &str) -> Self {
self.subtitle = Some(markup::render_or_plain(subtitle));
self
}
fn effective_box(&self) -> &'static BoxChars {
let safe = self.safe_box.unwrap_or(false);
if safe && !self.box_style.ascii {
&ASCII
} else {
self.box_style
}
}
fn content_width(&self) -> usize {
self.content_lines
.iter()
.map(|line: &Vec<Segment<'a>>| line.iter().map(|seg| cells::cell_len(&seg.text)).sum())
.max()
.unwrap_or(0)
}
#[must_use]
pub fn render(&self, max_width: usize) -> Vec<Segment<'a>> {
let box_chars = self.effective_box();
let panel_width = if self.expand {
self.width.unwrap_or(max_width).min(max_width)
} else {
let content_w = self.content_width();
let min_width = content_w + 2 + self.padding.horizontal();
self.width.unwrap_or(min_width).min(max_width)
};
let inner_width = panel_width.saturating_sub(2);
let mut pad_left = self.padding.left;
let mut pad_right = self.padding.right;
let max_content_width = self.content_width();
if max_content_width <= inner_width {
let available_for_padding = inner_width.saturating_sub(max_content_width);
if pad_left + pad_right > available_for_padding {
let mut remaining = available_for_padding;
pad_left = pad_left.min(remaining);
remaining = remaining.saturating_sub(pad_left);
pad_right = pad_right.min(remaining);
}
}
let content_width = inner_width.saturating_sub(pad_left + pad_right);
let mut pad_top = self.padding.top;
let mut pad_bottom = self.padding.bottom;
let mut content_lines = self.content_lines.clone();
if let Some(height) = self.height {
let max_inner_lines = height.saturating_sub(2);
if content_lines.len() > max_inner_lines {
content_lines.truncate(max_inner_lines);
pad_top = 0;
pad_bottom = 0;
} else {
let remaining_after_content = max_inner_lines - content_lines.len();
if pad_top + pad_bottom > remaining_after_content {
let mut remaining = remaining_after_content;
pad_top = pad_top.min(remaining);
remaining = remaining.saturating_sub(pad_top);
pad_bottom = pad_bottom.min(remaining);
}
let max_content_lines = max_inner_lines.saturating_sub(pad_top + pad_bottom);
if content_lines.len() < max_content_lines {
content_lines.extend(
std::iter::repeat_with(Vec::new)
.take(max_content_lines - content_lines.len()),
);
}
}
}
let mut segments = Vec::new();
segments.extend(self.render_top_border(box_chars, inner_width));
segments.push(Segment::line());
for _ in 0..pad_top {
segments.push(Segment::new(
box_chars.head[0].to_string(),
Some(self.border_style.clone()),
));
segments.push(Segment::new(
" ".repeat(inner_width),
Some(self.style.clone()),
));
segments.push(Segment::new(
box_chars.head[3].to_string(),
Some(self.border_style.clone()),
));
segments.push(Segment::line());
}
let left_pad = " ".repeat(pad_left);
let right_pad = " ".repeat(pad_right);
for line in &content_lines {
segments.push(Segment::new(
box_chars.head[0].to_string(),
Some(self.border_style.clone()),
));
if pad_left > 0 {
segments.push(Segment::new(left_pad.clone(), Some(self.style.clone())));
}
let mut content_segments: Vec<Segment<'a>> = line
.iter()
.cloned()
.map(|mut seg: Segment<'a>| {
if !seg.is_control() {
seg.style = Some(match seg.style.take() {
Some(existing) => self.style.combine(&existing),
None => self.style.clone(),
});
}
seg
})
.collect();
content_segments = adjust_line_length(
content_segments,
content_width,
Some(self.style.clone()),
true,
);
segments.extend(content_segments);
if pad_right > 0 {
segments.push(Segment::new(right_pad.clone(), Some(self.style.clone())));
}
segments.push(Segment::new(
box_chars.head[3].to_string(),
Some(self.border_style.clone()),
));
segments.push(Segment::line());
}
for _ in 0..pad_bottom {
segments.push(Segment::new(
box_chars.head[0].to_string(),
Some(self.border_style.clone()),
));
segments.push(Segment::new(
" ".repeat(inner_width),
Some(self.style.clone()),
));
segments.push(Segment::new(
box_chars.head[3].to_string(),
Some(self.border_style.clone()),
));
segments.push(Segment::line());
}
segments.extend(self.render_bottom_border(box_chars, inner_width));
segments.push(Segment::line());
segments
}
fn render_top_border(&self, box_chars: &BoxChars, inner_width: usize) -> Vec<Segment<'a>> {
let border_style = Some(self.border_style.clone());
if let Some(title) = &self.title {
let mut segments = Vec::new();
let max_text_width = if inner_width >= 4 {
inner_width.saturating_sub(4)
} else {
inner_width.saturating_sub(2)
};
let title_text = if inner_width >= 2 {
if title.cell_len() > max_text_width {
truncate_text_to_width(title, max_text_width)
} else {
title.clone()
}
} else {
truncate_text_to_width(title, inner_width)
};
let title_width = title_text.cell_len();
if inner_width < 2 {
segments.push(Segment::new(
box_chars.top[0].to_string(),
border_style.clone(),
));
segments.extend(
title_text
.render("")
.into_iter()
.map(super::super::segment::Segment::into_owned),
);
let remaining = inner_width.saturating_sub(title_width);
if remaining > 0 {
segments.push(Segment::new(
box_chars.top[1].to_string().repeat(remaining),
border_style.clone(),
));
}
segments.push(Segment::new(
box_chars.top[3].to_string(),
border_style.clone(),
));
return segments;
}
let title_total_width = title_width.saturating_add(2);
let available = inner_width.saturating_sub(title_total_width);
let (left_rule, right_rule) = if available == 0 {
(0, 0)
} else {
match self.title_align {
JustifyMethod::Left | JustifyMethod::Default => {
(1, available.saturating_sub(1))
}
JustifyMethod::Right => (available.saturating_sub(1), 1),
JustifyMethod::Center | JustifyMethod::Full => {
let left = available / 2;
(left, available - left)
}
}
};
let left_border = if left_rule > 0 {
format!("{}{}", box_chars.top[0], box_chars.top[1])
} else {
box_chars.top[0].to_string()
};
segments.push(Segment::new(left_border, border_style.clone()));
if left_rule > 1 {
segments.push(Segment::new(
box_chars.top[1].to_string().repeat(left_rule - 1),
border_style.clone(),
));
}
segments.push(Segment::new(" ", border_style.clone()));
segments.extend(
title_text
.render("")
.into_iter()
.map(super::super::segment::Segment::into_owned),
);
segments.push(Segment::new(" ", border_style.clone()));
if right_rule > 1 {
segments.push(Segment::new(
box_chars.top[1].to_string().repeat(right_rule - 1),
border_style.clone(),
));
}
let right_border = if right_rule > 0 {
format!("{}{}", box_chars.top[1], box_chars.top[3])
} else {
box_chars.top[3].to_string()
};
segments.push(Segment::new(right_border, border_style));
return segments;
}
vec![Segment::new(
format!(
"{}{}{}",
box_chars.top[0],
box_chars.top[1].to_string().repeat(inner_width),
box_chars.top[3]
),
border_style,
)]
}
fn render_bottom_border(&self, box_chars: &BoxChars, inner_width: usize) -> Vec<Segment<'a>> {
let mut segments = Vec::new();
if let Some(subtitle) = &self.subtitle {
segments.push(Segment::new(
box_chars.bottom[0].to_string(),
Some(self.border_style.clone()),
));
let max_text_width = if inner_width >= 4 {
inner_width.saturating_sub(4)
} else {
inner_width.saturating_sub(2)
};
let subtitle_text = if inner_width >= 2 {
if subtitle.cell_len() > max_text_width {
truncate_text_to_width(subtitle, max_text_width)
} else {
subtitle.clone()
}
} else {
truncate_text_to_width(subtitle, inner_width)
};
let subtitle_width = subtitle_text.cell_len();
if inner_width < 2 {
segments.extend(
subtitle_text
.render("")
.into_iter()
.map(super::super::segment::Segment::into_owned),
);
let remaining = inner_width.saturating_sub(subtitle_width);
if remaining > 0 {
segments.push(Segment::new(
box_chars.bottom[1].to_string().repeat(remaining),
Some(self.border_style.clone()),
));
}
} else {
let subtitle_total_width = subtitle_width.saturating_add(2);
let available = inner_width.saturating_sub(subtitle_total_width);
let (left_rule, right_rule) = if available == 0 {
(0, 0)
} else {
match self.subtitle_align {
JustifyMethod::Left | JustifyMethod::Default => {
(1, available.saturating_sub(1))
}
JustifyMethod::Right => (available.saturating_sub(1), 1),
JustifyMethod::Center | JustifyMethod::Full => {
let left = available / 2;
(left, available - left)
}
}
};
if left_rule > 0 {
segments.push(Segment::new(
box_chars.bottom[1].to_string().repeat(left_rule),
Some(self.border_style.clone()),
));
}
segments.push(Segment::new(" ", Some(subtitle_text.style().clone())));
segments.extend(
subtitle_text
.render("")
.into_iter()
.map(super::super::segment::Segment::into_owned),
);
segments.push(Segment::new(" ", Some(subtitle_text.style().clone())));
if right_rule > 0 {
segments.push(Segment::new(
box_chars.bottom[1].to_string().repeat(right_rule),
Some(self.border_style.clone()),
));
}
}
} else {
return vec![Segment::new(
format!(
"{}{}{}",
box_chars.bottom[0],
box_chars.bottom[1].to_string().repeat(inner_width),
box_chars.bottom[3]
),
Some(self.border_style.clone()),
)];
}
segments.push(Segment::new(
box_chars.bottom[3].to_string(),
Some(self.border_style.clone()),
));
segments
}
#[must_use]
pub fn render_plain(&self, max_width: usize) -> String {
self.render(max_width)
.into_iter()
.map(|seg| seg.text.into_owned())
.collect()
}
}
impl Renderable for Panel<'_> {
fn render<'b>(&'b self, console: &Console, options: &ConsoleOptions) -> Vec<Segment<'b>> {
if self.safe_box.is_some() {
return self.render(options.max_width).into_iter().collect();
}
let effective = self.clone().safe_box(console.safe_box());
effective.render(options.max_width).into_iter().collect()
}
}
fn truncate_text_to_width(text: &Text, max_width: usize) -> Text {
let mut truncated = text.clone();
truncated.truncate(max_width, OverflowMethod::Ellipsis, false);
truncated
}
#[must_use]
pub fn fit_panel(text: &str) -> Panel<'_> {
Panel::from_text(text).expand(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::segment::split_lines;
use crate::style::Attributes;
#[test]
fn test_panel_from_text() {
let panel = Panel::from_text("Hello\nWorld");
assert_eq!(panel.content_lines.len(), 2);
}
#[test]
fn test_panel_render() {
let panel = Panel::from_text("Hello").width(20);
let segments = panel.render(80);
assert!(!segments.is_empty());
let text: String = segments.iter().map(|s| s.text.as_ref()).collect();
assert!(text.contains("Hello"));
assert!(text.contains('\u{256D}')); }
#[test]
fn test_panel_with_title() {
let panel = Panel::from_text("Content").title("Title").width(30);
let text = panel.render_plain(80);
assert!(text.contains("Title"));
assert!(text.contains("Content"));
}
#[test]
fn test_panel_ascii() {
let panel = Panel::from_text("Hello").ascii().width(20);
let text = panel.render_plain(80);
assert!(text.contains('+')); assert!(text.contains('-')); }
#[test]
fn test_panel_square() {
let panel = Panel::from_text("Hello").square().width(20);
let text = panel.render_plain(80);
assert!(text.contains('\u{250C}')); }
#[test]
fn test_panel_padding() {
let panel = Panel::from_text("Hi").padding((1, 2)).width(20);
let segments = panel.render(80);
let newlines = segments.iter().filter(|s| s.text == "\n").count();
assert!(newlines >= 5);
}
#[test]
fn test_panel_subtitle() {
let panel = Panel::from_text("Content").subtitle("Footer").width(30);
let text = panel.render_plain(80);
assert!(text.contains("Footer"));
}
#[test]
fn test_panel_truncates_to_width() {
let panel = Panel::from_text("This is a very long line")
.width(10)
.padding(0);
let segments = panel.render(10);
let lines = split_lines(segments.into_iter());
for line in lines {
let width: usize = line.iter().map(Segment::cell_length).sum();
if width > 0 {
assert_eq!(width, 10);
}
}
}
#[test]
fn test_panel_height_limits_content_lines() {
let panel = Panel::from_text("A\nB\nC").height(4).padding(0).width(10);
let segments = panel.render(10);
let lines = split_lines(segments.into_iter());
let non_empty_lines = lines
.iter()
.filter(|line| line.iter().map(Segment::cell_length).sum::<usize>() > 0)
.count();
assert_eq!(non_empty_lines, 4);
let text: String = lines
.iter()
.map(|line| line.iter().map(|seg| seg.text.as_ref()).collect::<String>())
.collect();
assert!(!text.contains('C'));
}
#[test]
fn test_panel_height_pads_content_lines() {
let panel = Panel::from_text("A").height(5).padding(0).width(10);
let segments = panel.render(10);
let lines = split_lines(segments.into_iter());
let non_empty_lines = lines
.iter()
.filter(|line| line.iter().map(Segment::cell_length).sum::<usize>() > 0)
.count();
assert_eq!(non_empty_lines, 5);
}
#[test]
fn test_panel_height_prefers_content_over_padding() {
let panel = Panel::from_text("A").height(4).padding((2, 0)).width(10);
let segments = panel.render(10);
let lines = split_lines(segments.into_iter());
let non_empty_lines = lines
.iter()
.filter(|line| line.iter().map(Segment::cell_length).sum::<usize>() > 0)
.count();
assert_eq!(non_empty_lines, 4);
let text: String = lines
.iter()
.map(|line| line.iter().map(|seg| seg.text.as_ref()).collect::<String>())
.collect();
assert!(text.contains('A'));
}
#[test]
fn test_fit_panel() {
let panel = fit_panel("Short");
assert!(!panel.expand);
}
#[test]
fn test_truncate_text_to_width() {
let text = Text::new("Hello World");
let truncated = truncate_text_to_width(&text, 5);
assert_eq!(truncated.plain(), "He...");
}
#[test]
fn test_panel_title_preserves_spans() {
let mut title = Text::new("AB");
title.stylize(0, 1, Style::new().italic());
let panel = Panel::from_text("Content").title(title).width(20);
let segments = panel.render(20);
let title_segment = segments
.iter()
.find(|seg| seg.text.contains('A'))
.expect("expected title segment");
let style = title_segment
.style
.as_ref()
.expect("expected styled segment");
assert!(style.attributes.contains(Attributes::ITALIC));
}
#[test]
fn test_panel_title_from_markup() {
let panel = Panel::from_text("Content")
.title_from_markup("[bold]Styled Title[/]")
.width(30);
let segments = panel.render(30);
let title_segment = segments
.iter()
.find(|seg| seg.text.contains("Styled Title"))
.expect("expected title segment with styled text");
let style = title_segment
.style
.as_ref()
.expect("expected styled segment");
assert!(
style.attributes.contains(Attributes::BOLD),
"title should be bold"
);
}
#[test]
fn test_panel_subtitle_from_markup() {
let panel = Panel::from_text("Content")
.subtitle_from_markup("[italic]Footer[/]")
.width(30);
let segments = panel.render(30);
let footer_segment = segments
.iter()
.find(|seg| seg.text.contains("Footer"))
.expect("expected subtitle segment with styled text");
let style = footer_segment
.style
.as_ref()
.expect("expected styled segment");
assert!(
style.attributes.contains(Attributes::ITALIC),
"subtitle should be italic"
);
}
#[test]
fn test_panel_title_from_markup_no_markup() {
let panel = Panel::from_text("Content")
.title_from_markup("Plain Title")
.width(30);
let text = panel.render_plain(30);
assert!(text.contains("Plain Title"));
}
}