use crate::align::HorizontalAlign;
use crate::console::{Console, ConsoleOptions};
use crate::measure::Measurement;
use crate::padding::join_rows;
use crate::protocol::Renderable;
use crate::r#box::{Box as BoxSet, ROUNDED};
use crate::segment::Segment;
use crate::style::Style;
use crate::text::{Text, DEFAULT_TAB_SIZE};
pub struct Panel {
child: Box<dyn Renderable>,
box_set: BoxSet,
title: Option<String>,
title_align: HorizontalAlign,
subtitle: Option<String>,
subtitle_align: HorizontalAlign,
padding: (usize, usize, usize, usize),
border_style: Style,
style: Style,
expand: bool,
width: Option<usize>,
}
impl Panel {
pub fn new(child: Box<dyn Renderable>) -> Self {
Panel {
child,
box_set: ROUNDED,
title: None,
title_align: HorizontalAlign::Center,
subtitle: None,
subtitle_align: HorizontalAlign::Center,
padding: (0, 1, 0, 1),
border_style: Style::new(),
style: Style::new(),
expand: true,
width: None,
}
}
pub fn fit(child: Box<dyn Renderable>) -> Self {
Panel::new(child).expand(false)
}
pub fn expand(mut self, expand: bool) -> Self {
self.expand = expand;
self
}
pub fn width(mut self, width: usize) -> Self {
self.width = Some(width);
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn title_align(mut self, align: HorizontalAlign) -> Self {
self.title_align = align;
self
}
pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
self.subtitle = Some(subtitle.into());
self
}
pub fn subtitle_align(mut self, align: HorizontalAlign) -> Self {
self.subtitle_align = align;
self
}
pub fn box_set(mut self, box_set: BoxSet) -> Self {
self.box_set = box_set;
self
}
pub fn padding(mut self, padding: (usize, usize, usize, usize)) -> Self {
self.padding = padding;
self
}
pub fn border_style(mut self, style: Style) -> Self {
self.border_style = style;
self
}
fn border_line(
&self,
console: &Console,
inner_width: usize,
corners: (char, char, char),
label: Option<&String>,
align: HorizontalAlign,
) -> Vec<Segment> {
let (left_corner, fill_char, right_corner) = corners;
let border_style = Some(self.border_style.clone());
let Some(label) = label.filter(|label| !label.is_empty() && inner_width > 2) else {
return vec![Segment::new(
format!(
"{left_corner}{}{right_corner}",
fill_char.to_string().repeat(inner_width)
),
border_style,
)];
};
let mut label = label_text(label);
label.set_base_style(self.border_style.clone());
let label_width = inner_width - 2;
label.truncate(label_width, None, false);
let fill = label_width.saturating_sub(label.cell_len());
let (left, right) = match align {
HorizontalAlign::Center => (fill / 2, fill - fill / 2),
HorizontalAlign::Left => (0, fill),
HorizontalAlign::Right => (fill, 0),
};
let mut text = Text::styled(
fill_char.to_string().repeat(left),
self.border_style.clone(),
)
.append_text(&label);
text.append(
&fill_char.to_string().repeat(right),
Some(self.border_style.clone().into()),
);
let mut segments = vec![Segment::new(
format!("{left_corner}{fill_char}"),
border_style.clone(),
)];
segments.extend(text.render(console.theme(), console.base_style()));
segments.push(Segment::new(
format!("{fill_char}{right_corner}"),
border_style,
));
segments
}
}
fn label_text(label: &str) -> Text {
let expanded = crate::emoji::replace(label);
let parsed = Text::from_markup(&expanded).unwrap_or_else(|_| Text::new(expanded));
let mut text = parsed.blank_copy();
text.append(&parsed.plain().replace('\n', " "), None);
for span in parsed.spans() {
text.push_span(span.clone());
}
text.expand_tabs(DEFAULT_TAB_SIZE);
text.pad(1, ' ');
text
}
impl Panel {
fn title_text(&self) -> Option<Text> {
self.title
.as_deref()
.filter(|title| !title.is_empty())
.map(label_text)
}
fn measure_padded_child(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
let (top, right, bottom, left) = self.padding;
let max_width = options.max_width;
if max_width < 1 {
return Measurement::new(0, 0);
}
if top == 0 && right == 0 && bottom == 0 && left == 0 {
return Measurement::get(console, options, self.child.as_ref());
}
let extra_width = left + right;
let width = if max_width < extra_width + 1 {
Measurement::new(max_width, max_width)
} else {
let child = Measurement::get(console, options, self.child.as_ref());
Measurement::new(child.minimum + extra_width, child.maximum + extra_width)
.with_maximum(max_width)
};
let width = width.normalize().with_maximum(max_width);
if width.maximum < 1 {
Measurement::new(0, 0)
} else {
width.normalize()
}
}
}
impl Renderable for Panel {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let width = match self.width {
Some(width) => width.min(options.max_width),
None => options.max_width,
};
let box_set = self.box_set.substitute(
console.legacy_windows(),
console.safe_box(),
console.ascii_only(),
);
let mut inner_width = if self.expand {
width.saturating_sub(2)
} else {
self.measure_padded_child(console, &options.update_width(width.saturating_sub(2)))
.maximum
};
if let Some(title) = self.title_text() {
inner_width = options
.max_width
.saturating_sub(2)
.min(inner_width.max(title.cell_len() + 2));
}
let (pt, pr, pb, pl) = self.padding;
let child_width = inner_width.saturating_sub(pl).saturating_sub(pr);
let mut child_options = options.update_width(child_width);
child_options.height = options.height.map(|h| h.saturating_sub(2 + pt + pb));
let child_lines = console.render_lines_styled(
self.child.as_ref(),
&child_options,
Some(&self.style),
true,
);
let border = Some(self.border_style.clone());
let inner_style = Some(self.style.clone());
let left_border = || Segment::new(box_set.mid_left.to_string(), border.clone());
let right_border = || Segment::new(box_set.mid_right.to_string(), border.clone());
let blank_inner = || Segment::new(" ".repeat(inner_width), inner_style.clone());
let mut rows: Vec<Vec<Segment>> = Vec::new();
rows.push(self.border_line(
console,
inner_width,
(box_set.top_left, box_set.top, box_set.top_right),
self.title.as_ref(),
self.title_align,
));
for _ in 0..pt {
rows.push(vec![left_border(), blank_inner(), right_border()]);
}
for line in child_lines {
let mut row = vec![left_border()];
if pl > 0 {
row.push(Segment::new(" ".repeat(pl), inner_style.clone()));
}
row.extend(line);
if pr > 0 {
row.push(Segment::new(" ".repeat(pr), inner_style.clone()));
}
row.push(right_border());
rows.push(row);
}
for _ in 0..pb {
rows.push(vec![left_border(), blank_inner(), right_border()]);
}
rows.push(self.border_line(
console,
inner_width,
(box_set.bottom_left, box_set.bottom, box_set.bottom_right),
self.subtitle.as_ref(),
self.subtitle_align,
));
join_rows(rows)
}
fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
let (_, right, _, left) = self.padding;
let padding = left + right;
let width = match self.width {
Some(width) => width,
None => {
let inner = options.update_width(options.max_width.saturating_sub(padding + 2));
let child = Measurement::get(console, &inner, self.child.as_ref()).maximum;
let title = self
.title_text()
.map_or(0, |title| Measurement::get(console, &inner, &title).maximum);
child.max(title) + padding + 2
}
};
Measurement::new(width, width)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::r#box::SQUARE;
use crate::text::Text;
fn console() -> Console {
Console::builder()
.force_terminal(true)
.color_system(Some(crate::color::ColorSystem::Truecolor))
.width(20)
.build()
}
#[test]
fn plain_panel() {
let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))));
assert_eq!(
out,
"╭──────────────────╮\n│ hello │\n╰──────────────────╯\n"
);
}
#[test]
fn titled_panel() {
let out = console().render_export(&Panel::new(Box::new(Text::new("hello"))).title("T"));
assert_eq!(
out,
"╭─────── T ────────╮\n│ hello │\n╰──────────────────╯\n"
);
}
#[test]
fn square_box() {
let out = console().render_export(&Panel::new(Box::new(Text::new("hi"))).box_set(SQUARE));
assert_eq!(
out,
"┌──────────────────┐\n│ hi │\n└──────────────────┘\n"
);
}
#[test]
fn legacy_windows_substitutes_rounded_to_square() {
let legacy = Console::builder()
.force_terminal(true)
.color_system(Some(crate::color::ColorSystem::Truecolor))
.width(12)
.no_color(false)
.legacy_windows(true)
.build();
let out = legacy.render_export(&Panel::new(Box::new(Text::new("hi"))));
assert_eq!(out, "┌──────────┐\n│ hi │\n└──────────┘\n");
}
#[test]
fn zero_inner_width_renders_no_body_and_empty_text_one_row() {
let narrow = Console::builder()
.force_terminal(true)
.color_system(Some(crate::color::ColorSystem::Truecolor))
.width(4)
.highlight(false)
.build();
let panel = Panel::new(Box::new(Text::new("ab cd"))).box_set(crate::r#box::HEAVY);
assert_eq!(narrow.render_export(&panel), "┏━━┓\n┗━━┛\n");
let empty = Panel::new(Box::new(Text::new(""))).box_set(SQUARE);
assert_eq!(
Console::builder()
.force_terminal(true)
.color_system(Some(crate::color::ColorSystem::Truecolor))
.width(10)
.highlight(false)
.build()
.render_export(&empty),
"┌────────┐\n│ │\n└────────┘\n"
);
}
}