use ratatui_core::layout::{Alignment, Rect};
use ratatui_core::style::{Color, Style};
use ratatui_core::text::Line;
use crate::geometry::{Padding, Size};
use crate::style::{BorderStyle, Role};
use crate::surface::Surface;
use crate::view::{Element, RenderCtx, View};
use super::text::{aligned_x, line_width};
pub struct Boxed {
child: Element,
border: BorderStyle,
border_color: Option<Color>,
padding: Padding,
title: Option<Line<'static>>,
title_bottom: Option<Line<'static>>,
background: Option<Style>,
}
impl Boxed {
pub fn new(child: Element) -> Self {
Self {
child,
border: BorderStyle::Rounded,
border_color: None,
padding: Padding::symmetric(1, 0),
title: None,
title_bottom: None,
background: None,
}
}
pub fn border(mut self, border: BorderStyle) -> Self {
self.border = border;
self
}
pub fn border_color(mut self, color: Color) -> Self {
self.border_color = Some(color);
self
}
pub fn padding(mut self, padding: Padding) -> Self {
self.padding = padding;
self
}
pub fn title(mut self, title: impl Into<Line<'static>>) -> Self {
self.title = Some(title.into());
self
}
pub fn title_bottom(mut self, title: impl Into<Line<'static>>) -> Self {
self.title_bottom = Some(title.into());
self
}
pub fn background(mut self, style: Style) -> Self {
self.background = Some(style);
self
}
fn has_border(&self) -> bool {
!matches!(self.border, BorderStyle::None)
}
fn inner(&self, area: Rect) -> Rect {
let bordered = if self.has_border() {
Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
}
} else {
area
};
self.padding.inner(bordered)
}
fn chrome(&self) -> Size {
let border = if self.has_border() { 2 } else { 0 };
Size::new(
self.padding.horizontal().saturating_add(border),
self.padding.vertical().saturating_add(border),
)
}
}
impl View for Boxed {
fn measure(&self, available: Size) -> Size {
let chrome = self.chrome();
let inner_avail = Size::new(
available.width.saturating_sub(chrome.width),
available.height.saturating_sub(chrome.height),
);
let content = self.child.measure(inner_avail);
Size::new(
content.width.saturating_add(chrome.width),
content.height.saturating_add(chrome.height),
)
}
fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
if area.width == 0 || area.height == 0 {
return;
}
let panel = ctx.sheet.resolve(Role::Panel);
let background = self
.background
.or_else(|| panel.bg.map(|c| Style::default().bg(c)));
if let Some(bg) = background {
let mut fill = surface.child(area);
fill.fill(bg);
}
if self.has_border() {
let color = self.border_color.unwrap_or_else(|| {
if ctx.focused {
ctx.theme.border_focused
} else {
panel.fg.unwrap_or(ctx.theme.border)
}
});
let border_style = Style::default().fg(color);
surface.draw_border(area, self.border.glyphs(), border_style);
if let Some(title) = &self.title {
draw_title(surface, area, area.y, title, Alignment::Left);
}
if let Some(title) = &self.title_bottom {
let bottom = area.bottom().saturating_sub(1);
draw_title(surface, area, bottom, title, Alignment::Right);
}
}
let inner = self.inner(area);
let mut inner_surface = surface.child(inner);
self.child.render(inner, &mut inner_surface, ctx);
}
}
fn draw_title(
surface: &mut Surface,
area: Rect,
y: u16,
title: &Line<'static>,
default_align: Alignment,
) {
let max = area.width.saturating_sub(4);
if line_width(title) > max {
return;
}
let region = Rect {
x: area.x.saturating_add(2),
y,
width: max,
height: 1,
};
let align = title.alignment.unwrap_or(default_align);
let mut x = aligned_x(align, line_width(title), region);
for span in &title.spans {
x = surface.set_string(x, y, span.content.as_ref(), span.style);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Surface;
use crate::components::Text;
use crate::style::Theme;
use crate::test_support::{buffer, rainbow_theme, row};
use crate::view::{RenderCtx, View, element};
use ratatui_core::style::Color;
#[test]
fn boxed_border_closed_across_sizes() {
let theme = Theme::default();
let ctx = RenderCtx::new(&theme);
for w in 2..=12u16 {
for h in 2..=8u16 {
let mut buf = buffer(w, h);
let area = buf.area;
let mut surface = Surface::new(&mut buf, area);
Boxed::new(element(Text::raw("x"))).render(area, &mut surface, &ctx);
assert_eq!(buf[(0, 0)].symbol(), "╭", "top-left at {w}x{h}");
assert_eq!(buf[(w - 1, 0)].symbol(), "╮", "top-right at {w}x{h}");
assert_eq!(buf[(0, h - 1)].symbol(), "╰", "bottom-left at {w}x{h}");
assert_eq!(buf[(w - 1, h - 1)].symbol(), "╯", "bottom-right at {w}x{h}");
}
}
}
#[test]
fn bottom_title_defaults_flush_right() {
let theme = Theme::default();
let ctx = RenderCtx::new(&theme);
let mut buf = buffer(10, 3);
let area = buf.area;
let mut surface = Surface::new(&mut buf, area);
Boxed::new(element(Text::raw("x")))
.title_bottom(" 1/3 ")
.render(area, &mut surface, &ctx);
assert_eq!(row(&buf, 2), "╰── 1/3 ─╯");
}
#[test]
fn bottom_title_honors_explicit_alignment() {
let theme = Theme::default();
let ctx = RenderCtx::new(&theme);
let mut buf = buffer(10, 3);
let area = buf.area;
let mut surface = Surface::new(&mut buf, area);
Boxed::new(element(Text::raw("x")))
.title_bottom(Line::from("ab").left_aligned())
.render(area, &mut surface, &ctx);
assert_eq!(row(&buf, 2), "╰─ab─────╯");
}
#[test]
fn top_title_honors_center_alignment() {
let theme = Theme::default();
let ctx = RenderCtx::new(&theme);
let mut buf = buffer(10, 3);
let area = buf.area;
let mut surface = Surface::new(&mut buf, area);
Boxed::new(element(Text::raw("x")))
.title(Line::from("ab").centered())
.render(area, &mut surface, &ctx);
assert_eq!(row(&buf, 0), "╭───ab───╮");
}
#[test]
fn oversized_bottom_title_is_dropped() {
let theme = Theme::default();
let ctx = RenderCtx::new(&theme);
let mut buf = buffer(6, 3);
let area = buf.area;
let mut surface = Surface::new(&mut buf, area);
Boxed::new(element(Text::raw("x")))
.title_bottom("a title far too wide")
.render(area, &mut surface, &ctx);
assert_eq!(row(&buf, 2), "╰────╯");
}
#[test]
fn boxed_border_follows_theme_focus_color() {
let t = rainbow_theme();
let make = |focused: bool| {
let mut buf = buffer(8, 3);
let area = buf.area;
let ctx = RenderCtx::new(&t).with_focus(focused);
let boxed = Boxed::new(element(Text::raw("x")));
let mut surface = Surface::new(&mut buf, area);
boxed.render(area, &mut surface, &ctx);
buf[(0, 0)].fg };
assert_eq!(make(false), t.border, "unfocused border uses theme.border");
assert_eq!(
make(true),
t.border_focused,
"focused border uses theme.border_focused"
);
}
#[test]
fn explicit_border_color_overrides_theme_and_focus() {
let t = rainbow_theme();
let make = |focused: bool| {
let mut buf = buffer(8, 3);
let area = buf.area;
let ctx = RenderCtx::new(&t).with_focus(focused);
let boxed = Boxed::new(element(Text::raw("x"))).border_color(Color::Indexed(201));
let mut surface = Surface::new(&mut buf, area);
boxed.render(area, &mut surface, &ctx);
buf[(0, 0)].fg
};
assert_eq!(make(false), Color::Indexed(201));
assert_eq!(make(true), Color::Indexed(201));
assert_ne!(Color::Indexed(201), t.border);
assert_ne!(Color::Indexed(201), t.border_focused);
}
#[test]
fn swapping_theme_restyles_the_same_tree() {
let tree = || Boxed::new(element(Text::raw("x")));
let render_border = |theme: &Theme| {
let mut buf = buffer(8, 3);
let area = buf.area;
let ctx = RenderCtx::new(theme);
let mut surface = Surface::new(&mut buf, area);
tree().render(area, &mut surface, &ctx);
buf[(0, 0)].fg
};
let a = Theme {
border: Color::Indexed(21),
..rainbow_theme()
};
let b = Theme {
border: Color::Indexed(99),
..rainbow_theme()
};
assert_eq!(render_border(&a), Color::Indexed(21));
assert_eq!(render_border(&b), Color::Indexed(99));
assert_ne!(
render_border(&a),
render_border(&b),
"theme swap must restyle"
);
}
#[test]
fn stylesheet_panel_recolors_border_and_fills_background() {
use crate::style::{StyleBundle, StyleSheet};
let t = rainbow_theme();
let sheet = StyleSheet {
panel: StyleBundle::new().fg(t.accent_alt).bg(t.surface),
..StyleSheet::from_theme(&t)
};
let mut buf = buffer(8, 3);
let area = buf.area;
let ctx = RenderCtx::new(&t).with_sheet(sheet);
let mut surface = Surface::new(&mut buf, area);
Boxed::new(element(Text::raw("x"))).render(area, &mut surface, &ctx);
assert_eq!(buf[(0, 0)].fg, t.accent_alt, "sheet recolors the border");
assert_ne!(t.accent_alt, t.border, "guard: the sheet color is distinct");
assert_eq!(
buf[(1, 1)].bg,
t.surface,
"sheet fills the panel background"
);
}
#[test]
fn explicit_background_overrides_the_sheet_panel_fill() {
use crate::style::{StyleBundle, StyleSheet};
let t = rainbow_theme();
let sheet = StyleSheet {
panel: StyleBundle::new().bg(t.surface),
..StyleSheet::from_theme(&t)
};
let mut buf = buffer(8, 3);
let area = buf.area;
let ctx = RenderCtx::new(&t).with_sheet(sheet);
let mut surface = Surface::new(&mut buf, area);
Boxed::new(element(Text::raw("x")))
.background(Style::default().bg(t.selection_bg))
.render(area, &mut surface, &ctx);
assert_eq!(
buf[(1, 1)].bg,
t.selection_bg,
"inline style beats the sheet"
);
}
}