use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use unicode_width::UnicodeWidthStr;
use crate::theme::Theme;
const TL: char = '╭';
const TR: char = '╮';
const BL: char = '╰';
const BR: char = '╯';
const H: char = '─';
const V: char = '│';
pub type Bind<'a> = (&'a str, &'a str);
#[derive(Default)]
pub struct PanelOpts<'a> {
pub key: Option<&'a str>,
pub title: Option<&'a str>,
pub sub: Option<&'a str>,
pub right: Option<&'a str>,
pub right_style: Option<Style>,
pub foot_left: &'a [Bind<'a>],
pub foot_right: Option<&'a str>,
pub focused: bool,
}
pub fn panel(buf: &mut Buffer, area: Rect, t: &Theme, o: &PanelOpts) -> Rect {
if area.width < 2 || area.height < 2 {
return area;
}
let border = Style::default().fg(if o.focused { t.brand } else { t.border });
let x0 = area.x;
let y0 = area.y;
let x1 = area.x + area.width - 1;
let y1 = area.y + area.height - 1;
for x in (x0 + 1)..x1 {
set(buf, x, y0, H, border);
set(buf, x, y1, H, border);
}
for y in (y0 + 1)..y1 {
set(buf, x0, y, V, border);
set(buf, x1, y, V, border);
}
set(buf, x0, y0, TL, border);
set(buf, x1, y0, TR, border);
set(buf, x0, y1, BL, border);
set(buf, x1, y1, BR, border);
let mut cx = x0 + 1;
if o.key.is_some() || o.title.is_some() {
cx = put(buf, cx, y0, "─", border);
}
if let Some(k) = o.key {
cx = put(buf, cx, y0, "┤", border);
cx = put(
buf,
cx,
y0,
k,
Style::default().fg(t.key_hint).add_modifier(Modifier::BOLD),
);
cx = put(buf, cx, y0, "├─", border);
}
if let Some(title) = o.title {
cx = put(buf, cx, y0, "┤ ", border);
cx = put(
buf,
cx,
y0,
title,
Style::default().fg(t.brand).add_modifier(Modifier::BOLD),
);
cx = put(buf, cx, y0, " ├", border);
}
if let Some(sub) = o.sub {
cx = put(buf, cx, y0, "─┤ ", border);
cx = put(buf, cx, y0, sub, Style::default().fg(t.text_muted));
let _ = put(buf, cx, y0, " ├", border);
}
if let Some(right) = o.right {
let style = o
.right_style
.unwrap_or_else(|| Style::default().fg(t.text_muted));
insert_right(buf, x0, x1, y0, right, style, border);
}
if !o.foot_left.is_empty() {
let mut fx = x0 + 2;
fx = put(buf, fx, y1, "┤ ", border);
for (i, (key, rest)) in o.foot_left.iter().enumerate() {
if i > 0 {
fx = put(buf, fx, y1, " ", border);
}
fx = put(
buf,
fx,
y1,
key,
Style::default().fg(t.key_hint).add_modifier(Modifier::BOLD),
);
fx = put(buf, fx, y1, rest, Style::default().fg(t.text_muted));
}
let _ = put(buf, fx, y1, " ├", border);
}
if let Some(fr) = o.foot_right {
insert_right(
buf,
x0,
x1,
y1,
fr,
Style::default().fg(t.text_muted),
border,
);
}
Rect::new(x0 + 1, y0 + 1, area.width - 2, area.height - 2)
}
pub fn pane(f: &mut ratatui::Frame, area: Rect, t: &Theme, o: &PanelOpts) -> Rect {
panel(f.buffer_mut(), area, t, o)
}
fn insert_right(
buf: &mut Buffer,
x0: u16,
x1: u16,
y: u16,
text: &str,
style: Style,
border: Style,
) {
let w = text.width() as u16 + 4; if w + 2 > x1 || x1 - 1 - w <= x0 {
return;
}
let x = x1 - 1 - w;
let mut cx = put(buf, x, y, "┤ ", border);
cx = put(buf, cx, y, text, style);
let _ = put(buf, cx, y, " ├", border);
}
fn set(buf: &mut Buffer, x: u16, y: u16, ch: char, style: Style) {
if x >= buf.area.right() || y >= buf.area.bottom() {
return;
}
let cell = &mut buf[(x, y)];
cell.set_char(ch);
cell.set_style(style);
}
fn put(buf: &mut Buffer, x: u16, y: u16, s: &str, style: Style) -> u16 {
if x >= buf.area.right() || y >= buf.area.bottom() {
return x;
}
let max = (buf.area.right() - x) as usize;
buf.set_stringn(x, y, s, max, style);
x + s.width() as u16
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme;
fn buf(w: u16, h: u16) -> Buffer {
Buffer::empty(Rect::new(0, 0, w, h))
}
fn row(b: &Buffer, y: u16) -> String {
(0..b.area.width)
.map(|x| b[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect()
}
#[test]
fn a_panel_draws_rounded_corners_and_returns_its_interior() {
let mut b = buf(20, 5);
let t = theme::dark();
let inner = panel(&mut b, Rect::new(0, 0, 20, 5), &t, &PanelOpts::default());
assert_eq!(inner, Rect::new(1, 1, 18, 3));
assert_eq!(b[(0, 0)].symbol(), "╭");
assert_eq!(b[(19, 0)].symbol(), "╮");
assert_eq!(b[(0, 4)].symbol(), "╰");
assert_eq!(b[(19, 4)].symbol(), "╯");
}
#[test]
fn the_title_and_hotkey_live_in_the_top_border() {
let mut b = buf(48, 4);
let t = theme::dark();
panel(
&mut b,
Rect::new(0, 0, 48, 4),
&t,
&PanelOpts {
key: Some("1"),
title: Some("monitor"),
sub: Some("web-01"),
..Default::default()
},
);
let top = row(&b, 0);
assert!(
top.starts_with("╭─┤1├─┤ monitor ├─┤ web-01 ├"),
"top border was {top:?}"
);
}
#[test]
fn keybinds_live_in_the_bottom_border_costing_no_row() {
let mut b = buf(40, 4);
let t = theme::dark();
panel(
&mut b,
Rect::new(0, 0, 40, 4),
&t,
&PanelOpts {
foot_left: &[("↑↓", " select"), ("q", "uit")],
..Default::default()
},
);
let bottom = row(&b, 3);
assert!(
bottom.starts_with("╰─┤ ↑↓ select quit ├"),
"bottom border was {bottom:?}"
);
}
#[test]
fn right_hand_inserts_end_one_cell_short_of_the_corner() {
let mut b = buf(30, 3);
let t = theme::dark();
panel(
&mut b,
Rect::new(0, 0, 30, 3),
&t,
&PanelOpts {
right: Some("ok"),
..Default::default()
},
);
let top = row(&b, 0);
assert!(top.ends_with("┤ ok ├─╮"), "top border was {top:?}");
}
#[test]
fn a_narrow_panel_drops_the_right_insert_rather_than_colliding() {
let mut b = buf(10, 3);
let t = theme::dark();
panel(
&mut b,
Rect::new(0, 0, 10, 3),
&t,
&PanelOpts {
title: Some("monitor"),
right: Some("a very long status"),
..Default::default()
},
);
let top = row(&b, 0);
assert!(
top.starts_with("╭─┤ monit"),
"the title must survive: {top:?}"
);
assert!(!top.contains('├'), "no right insert should fit: {top:?}");
}
#[test]
fn a_degenerate_rect_does_not_panic() {
let mut b = buf(4, 4);
let t = theme::dark();
for (w, h) in [(0, 0), (1, 1), (1, 4), (4, 1)] {
let r = Rect::new(0, 0, w, h);
let _ = panel(&mut b, r, &t, &PanelOpts::default());
}
}
#[test]
fn focus_changes_the_border_colour_not_the_layout() {
let t = theme::dark();
let mut plain = buf(20, 4);
let a = panel(
&mut plain,
Rect::new(0, 0, 20, 4),
&t,
&PanelOpts::default(),
);
let mut focused = buf(20, 4);
let b2 = panel(
&mut focused,
Rect::new(0, 0, 20, 4),
&t,
&PanelOpts {
focused: true,
..Default::default()
},
);
assert_eq!(a, b2, "focus must not move anything");
assert_ne!(plain[(0, 0)].style().fg, focused[(0, 0)].style().fg);
}
}