use std::borrow::Cow;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Padding};
use crate::theme::Theme;
pub const FRAME_WIDTH: u16 = 4;
pub const FRAME_HEIGHT: u16 = 2;
pub fn centred(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect {
x: area.x + area.width.saturating_sub(width) / 2,
y: area.y + area.height.saturating_sub(height) / 2,
width,
height,
}
}
pub const ESC: &str = "Esc";
pub fn dialog_block(theme: &Theme, title: &str, width: u16) -> Block<'static> {
let border = Style::default().fg(theme.border_focused);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border)
.padding(Padding::horizontal(1));
let room = usize::from(width).saturating_sub(4);
let title = crate::grid::truncate(&crate::glyphs::utility(title), room);
if title.is_empty() || title == "…" {
return block;
}
block.title_top(Line::from(vec![
Span::styled("┤", border),
Span::styled(
title,
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
),
Span::styled("├", border),
]))
}
pub fn key_row(bindings: &[Binding], gap: &str, theme: &Theme, width: u16) -> Line<'static> {
let key_style = Style::default().fg(theme.key).add_modifier(Modifier::BOLD);
let muted = Style::default().fg(theme.muted);
let hints = bindings
.iter()
.filter(|binding| binding.primary)
.map(|binding| {
vec![
Span::styled(binding.key.clone(), key_style),
Span::styled(format!(" {}", binding.action), muted),
]
})
.collect();
crate::grid::assemble(
crate::grid::way_out_last(hints, &Span::styled(gap.to_string(), muted), width),
width,
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Binding {
pub key: Cow<'static, str>,
pub action: Cow<'static, str>,
pub primary: bool,
}
impl Binding {
pub const fn primary(key: &'static str, action: &'static str) -> Self {
Self {
key: Cow::Borrowed(key),
action: Cow::Borrowed(action),
primary: true,
}
}
pub const fn extra(key: &'static str, action: &'static str) -> Self {
Self {
key: Cow::Borrowed(key),
action: Cow::Borrowed(action),
primary: false,
}
}
pub fn owned(key: impl Into<String>, action: impl Into<String>, primary: bool) -> Self {
Self {
key: Cow::Owned(key.into()),
action: Cow::Owned(action.into()),
primary,
}
}
}
pub fn hint_line(bindings: &[Binding], theme: &Theme, budget: u16) -> Option<Line<'static>> {
if bindings.is_empty() || budget < 8 {
return None;
}
let key_style = Style::default().fg(theme.key).add_modifier(Modifier::BOLD);
let action_style = Style::default().fg(theme.muted);
let mut spans = vec![Span::styled(" ", action_style)];
let mut used = 2u16;
for binding in bindings.iter().filter(|b| b.primary) {
let separator = if spans.len() > 1 { " · " } else { "" };
let width = crate::grid::display_width(separator)
+ crate::grid::display_width(&binding.key)
+ 1
+ crate::grid::display_width(&binding.action);
let Ok(width) = u16::try_from(width) else {
continue;
};
if used + width > budget {
break;
}
if !separator.is_empty() {
spans.push(Span::styled(separator, action_style));
}
spans.push(Span::styled(binding.key.clone(), key_style));
spans.push(Span::styled(format!(" {}", binding.action), action_style));
used += width;
}
if spans.len() == 1 {
return None;
}
spans.push(Span::styled(" ", action_style));
Some(Line::from(spans))
}
pub struct FrameSpec<'a> {
pub title: &'a str,
pub counter: Option<String>,
pub focused: bool,
pub bindings: &'a [Binding],
pub index: usize,
}
pub fn draw(frame: &mut ratatui::Frame, area: Rect, theme: &Theme, spec: &FrameSpec<'_>) -> Rect {
if area.width < 2 || area.height < 2 {
return Rect::new(area.x, area.y, 0, 0);
}
let border_style = Style::default().fg(if spec.focused {
theme.border_focused
} else {
theme.border
});
let available = usize::from(area.width).saturating_sub(2);
let wanted = spec
.counter
.as_ref()
.map_or(0, |counter| crate::grid::display_width(counter) + 2);
let counter_width = if wanted <= available { wanted } else { 0 };
let index_width = usize::from(spec.index <= 9) * 2;
let title_budget = available.saturating_sub(counter_width + index_width + 3);
let title = crate::grid::truncate(spec.title, title_budget);
let title_is_gone = title == "…";
let title = if title_is_gone { String::new() } else { title };
let mut title_spans = vec![Span::styled("┤", border_style)];
if spec.index <= 9 {
title_spans.push(Span::styled(
format!("{}", spec.index),
Style::default()
.fg(if spec.focused { theme.key } else { theme.muted })
.add_modifier(Modifier::BOLD),
));
if !title_is_gone {
title_spans.push(Span::styled(" ", border_style));
}
}
title_spans.push(Span::styled(
title,
if spec.focused {
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.muted)
},
));
title_spans.push(Span::styled("├", border_style));
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.padding(Padding::horizontal(1));
if title_budget > 0 && !(title_is_gone && spec.index > 9) {
block = block.title_top(Line::from(title_spans));
}
if let Some(counter) = spec.counter.as_ref().filter(|_| counter_width > 0) {
block = block.title_top(
Line::from(vec![
Span::styled("┤", border_style),
Span::styled(
counter.clone(),
Style::default().fg(if spec.focused {
theme.label
} else {
theme.muted
}),
),
Span::styled("├", border_style),
])
.right_aligned(),
);
}
if spec.focused {
let budget = area.width.saturating_sub(8);
if let Some(hints) = hint_line(spec.bindings, theme, budget) {
block = block.title_bottom(hints.centered());
}
}
let inner = block.inner(area);
frame.render_widget(block, area);
inner
}
pub fn rule(frame: &mut ratatui::Frame, area: Rect, theme: &Theme, label: &str) {
if area.width == 0 || area.height == 0 {
return;
}
let label = crate::glyphs::utility(label);
let label_width = crate::grid::walked_width(&label);
let width = usize::from(area.width);
let rule_style = Style::default().fg(theme.rule);
let mut spans = Vec::new();
if label.is_empty() || label_width + 4 > width {
spans.push(Span::styled("─".repeat(width), rule_style));
} else {
spans.push(Span::styled(
label,
Style::default()
.fg(theme.label)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(" ", rule_style));
spans.push(Span::styled(
"─".repeat(width - label_width - 1),
rule_style,
));
}
frame.render_widget(ratatui::widgets::Paragraph::new(Line::from(spans)), area);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn top_border(width: u16, title: &str, counter: Option<&str>) -> String {
top_border_indexed(width, title, counter, 9)
}
fn top_border_indexed(width: u16, title: &str, counter: Option<&str>, index: usize) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, 3)).expect("backend");
terminal
.draw(|frame| {
draw(
frame,
Rect::new(0, 0, width, 3),
&Theme::default(),
&FrameSpec {
title,
counter: counter.map(str::to_string),
focused: false,
bindings: &[],
index,
},
);
})
.expect("draws");
let buffer = terminal.backend().buffer();
(0..width)
.map(|x| buffer[(x, 0)].symbol())
.collect::<String>()
}
#[test]
fn a_title_too_wide_for_its_border_is_shortened_not_left_unclosed() {
for width in 12..40u16 {
let border = top_border(width, "Weather — Boston, Massachusetts", Some("at 03:45"));
assert_eq!(
border.matches('├').count(),
border.matches('┤').count(),
"every segment opens and closes, at width {width}: {border}"
);
}
}
#[test]
fn a_title_cut_down_to_its_ellipsis_is_not_drawn() {
let mut ever_tight = false;
for width in 6..40u16 {
let unnumbered = top_border_indexed(width, "CPU", Some("18 cores"), 11);
assert!(
!unnumbered.contains("┤…├"),
"a lone ellipsis is not a title, at width {width}: {unnumbered}"
);
let numbered = top_border_indexed(width, "CPU", Some("18 cores"), 4);
assert!(
!numbered.contains("┤…├"),
"nor when there is a jump key beside it, at width {width}: {numbered}"
);
if numbered.contains("┤4├") {
ever_tight = true;
assert!(
!numbered.contains('…'),
"the key is kept and the ellipsis dropped, at width {width}: {numbered}"
);
}
}
assert!(
ever_tight,
"no width in the sweep was tight enough to reach the case under test"
);
}
#[test]
fn the_counter_survives_a_title_that_wants_the_whole_border() {
let border = top_border(30, "An Extremely Long Panel Name Indeed", Some("12 open"));
assert!(
border.contains("12 open"),
"the counter is not squeezed out: {border}"
);
assert!(border.contains('…'), "the title says it was cut: {border}");
}
fn bindings() -> Vec<Binding> {
vec![
Binding::primary("a", "add"),
Binding::primary("e", "edit"),
Binding::primary("d", "delete"),
Binding::extra("j", "down"),
]
}
#[test]
fn hints_include_only_primary_bindings() {
let line = hint_line(&bindings(), &Theme::default(), 200).expect("should fit");
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("add"));
assert!(text.contains("edit"));
assert!(!text.contains("down"), "extras belong in the help overlay");
}
#[test]
fn hints_stop_at_the_budget_rather_than_overflowing() {
let theme = Theme::default();
for budget in 0..80u16 {
if let Some(line) = hint_line(&bindings(), &theme, budget) {
let width: usize = line
.spans
.iter()
.map(|span| crate::grid::display_width(&span.content))
.sum();
assert!(
width <= budget as usize,
"hint of {width} exceeded budget {budget}"
);
}
}
}
#[test]
fn a_tiny_budget_produces_no_hint_at_all() {
assert!(hint_line(&bindings(), &Theme::default(), 4).is_none());
assert!(hint_line(&bindings(), &Theme::default(), 0).is_none());
}
#[test]
fn runtime_binding_hints_budget_terminal_cells() {
let binding = Binding::owned("界", "open", true);
let line = hint_line(&[binding], &Theme::default(), 9).expect("exactly fits");
let width: usize = line
.spans
.iter()
.map(|span| crate::grid::display_width(&span.content))
.sum();
assert_eq!(width, 9);
}
#[test]
fn no_bindings_means_no_hint() {
assert!(hint_line(&[], &Theme::default(), 100).is_none());
let extras_only = [Binding::extra("j", "down")];
assert!(hint_line(&extras_only, &Theme::default(), 100).is_none());
}
#[test]
fn the_frame_returns_a_padded_interior() {
let theme = Theme::default();
let area = Rect::new(0, 0, 40, 10);
let mut terminal = Terminal::new(TestBackend::new(40, 10)).unwrap();
let mut inner = Rect::default();
terminal
.draw(|f| {
inner = draw(
f,
area,
&theme,
&FrameSpec {
title: "Tasks",
counter: None,
focused: true,
bindings: &bindings(),
index: 1,
},
);
})
.unwrap();
assert_eq!(inner.width, area.width - 4);
assert_eq!(inner.height, area.height - 2);
assert_eq!(inner.x, area.x + 2);
}
#[test]
fn the_frame_degrades_instead_of_panicking_when_tiny() {
let theme = Theme::default();
for (w, h) in [(0, 0), (1, 1), (2, 2), (3, 3), (5, 2)] {
let mut terminal = Terminal::new(TestBackend::new(w.max(1), h.max(1))).unwrap();
terminal
.draw(|f| {
let inner = draw(
f,
Rect::new(0, 0, w, h),
&theme,
&FrameSpec {
title: "Tasks",
counter: Some("3 open".into()),
focused: true,
bindings: &bindings(),
index: 1,
},
);
assert!(inner.width <= w);
assert!(inner.height <= h);
})
.unwrap();
}
}
#[test]
fn the_title_and_counter_appear_in_the_top_border() {
let theme = Theme::default();
let mut terminal = Terminal::new(TestBackend::new(44, 6)).unwrap();
terminal
.draw(|f| {
draw(
f,
Rect::new(0, 0, 44, 6),
&theme,
&FrameSpec {
title: "Tasks",
counter: Some("3 open".into()),
focused: true,
bindings: &bindings(),
index: 2,
},
);
})
.unwrap();
let buf = terminal.backend().buffer();
let top: String = (0..44).map(|x| buf[(x, 0)].symbol()).collect();
assert!(top.contains("Tasks"), "got: {top}");
assert!(
top.contains("3 open"),
"counter belongs in the frame: {top}"
);
assert!(
top.contains('2'),
"the jump key belongs in the title: {top}"
);
}
#[test]
fn hints_appear_only_when_the_panel_is_focused() {
let theme = Theme::default();
let render = |focused: bool| {
let mut terminal = Terminal::new(TestBackend::new(50, 6)).unwrap();
terminal
.draw(|f| {
draw(
f,
Rect::new(0, 0, 50, 6),
&theme,
&FrameSpec {
title: "Tasks",
counter: None,
focused,
bindings: &bindings(),
index: 1,
},
);
})
.unwrap();
let buf = terminal.backend().buffer();
(0..50).map(|x| buf[(x, 5)].symbol()).collect::<String>()
};
assert!(render(true).contains("add"), "focused panels show hints");
assert!(
!render(false).contains("add"),
"unfocused hints teach users to press keys on the wrong panel"
);
}
#[test]
fn a_rule_fills_its_width_exactly() {
let theme = Theme::default();
for width in [0u16, 1, 5, 12, 40] {
let mut terminal = Terminal::new(TestBackend::new(width.max(1), 1)).unwrap();
terminal
.draw(|f| rule(f, Rect::new(0, 0, width, 1), &theme, "next hours"))
.unwrap();
}
}
#[test]
fn a_rule_drops_its_label_when_there_is_no_room() {
let theme = Theme::default();
let mut terminal = Terminal::new(TestBackend::new(6, 1)).unwrap();
terminal
.draw(|f| rule(f, Rect::new(0, 0, 6, 1), &theme, "next hours"))
.unwrap();
let buf = terminal.backend().buffer();
let line: String = (0..6).map(|x| buf[(x, 0)].symbol()).collect();
assert_eq!(line, "──────", "a clipped label is worse than none");
}
#[test]
fn a_dialog_title_is_cut_with_its_brackets_or_not_drawn() {
let theme = Theme::default();
for width in 1..=16u16 {
let mut terminal = Terminal::new(TestBackend::new(width, 3)).unwrap();
terminal
.draw(|f| f.render_widget(dialog_block(&theme, "key map", width), f.area()))
.unwrap();
let buf = terminal.backend().buffer();
let top: String = (0..width).map(|x| buf[(x, 0)].symbol()).collect();
if width >= 11 {
assert!(top.contains("┤KEY MAP├"), "{width}: {top}");
} else if width >= 6 {
assert!(top.contains("…├"), "{width}: cut and marked: {top}");
} else {
assert!(!top.contains('┤'), "{width}: no room, no title: {top}");
}
assert_eq!(
top.contains('┤'),
top.contains('├'),
"{width}: a bracket without its partner: {top}"
);
}
}
fn dialog_top(draw: impl FnOnce(&mut ratatui::Frame)) -> String {
let mut terminal = Terminal::new(TestBackend::new(80, 30)).unwrap();
terminal.draw(draw).unwrap();
let buf = terminal.backend().buffer();
(0..30)
.map(|y| (0..80).map(|x| buf[(x, y)].symbol()).collect::<String>())
.find(|row| row.contains('╭'))
.expect("a dialog")
.trim()
.to_string()
}
#[test]
fn every_dialog_punches_its_title_into_the_border() {
let theme = Theme::default();
let picker = crate::picker::Picker::new(vec!["clocks".into()]);
let themes = crate::theme_picker::ThemePicker::new(None, None);
let prompt = crate::prompt::Prompt::new(
"ADD A CLOCK",
"Esc cancels",
"",
crate::prompt::Completion::None,
);
let mut keys = crate::keymap_dialog::KeymapDialog::new();
let keymap = crate::keymap::Keymap::default();
let tops = [
(
dialog_top(|f| picker.render(f, f.area(), &theme, |_| false, None)),
"┤PANELS├",
),
(
dialog_top(|f| themes.render(f, f.area(), &theme)),
"┤THEME├",
),
(
dialog_top(|f| prompt.render(f, f.area(), &theme)),
"┤ADD A CLOCK├",
),
(
dialog_top(|f| keys.render(f, f.area(), &keymap, &[], None, &theme)),
"┤KEY MAP├",
),
];
for (top, title) in tops {
assert!(top.starts_with(&format!("╭{title}─")), "{top}");
}
}
fn dialog_rows(width: u16, draw: impl FnOnce(&mut ratatui::Frame)) -> Vec<String> {
let mut terminal = Terminal::new(TestBackend::new(width, 30)).unwrap();
terminal.draw(draw).unwrap();
let buf = terminal.backend().buffer();
let rows: Vec<String> = (0..30)
.map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect())
.collect();
let (Some(top), Some(bottom)) = (
rows.iter().position(|row| row.contains('╭')),
rows.iter().position(|row| row.contains('╰')),
) else {
return Vec::new();
};
rows[top + 1..bottom]
.iter()
.filter_map(|row| {
let inside = row.trim().strip_prefix('│')?.strip_suffix('│')?;
Some(inside.trim().to_string())
})
.collect()
}
struct Footer {
dialog: &'static str,
hints: &'static [&'static str],
gap: &'static str,
esc: &'static str,
full: &'static str,
}
impl Footer {
fn sweep(&self, mut draw: impl FnMut(&mut ratatui::Frame)) {
let Self { dialog, esc, .. } = self;
let (mut cut, mut dropped) = (false, false);
for width in 1..=100u16 {
let rows = dialog_rows(width, &mut draw);
let Some(footer) = rows.last() else {
continue;
};
if footer.is_empty() {
assert!(width < 5, "{dialog} at {width}: room, and nothing said");
continue;
}
if let Some(head) = footer.strip_suffix('…') {
assert!(
esc.starts_with(head.trim_end()),
"{dialog} at {width}: cut, and not down to {esc:?} alone: {footer:?}"
);
cut = true;
} else {
self.assert_whole(width, footer);
dropped |= footer == esc;
}
if width == 100 {
assert_eq!(
footer, self.full,
"{dialog}: every hint where there is room"
);
}
}
assert!(cut && dropped, "{dialog}: cut {cut}, dropped {dropped}");
}
fn assert_whole(&self, width: u16, footer: &str) {
let Self {
dialog,
hints,
gap,
esc,
..
} = self;
let Some(rest) = footer.strip_suffix(esc) else {
panic!("{dialog} at {width}: {footer:?} does not end in {esc:?}");
};
if rest.is_empty() {
return;
}
let rest = rest.strip_suffix(gap).unwrap_or_else(|| {
panic!("{dialog} at {width}: {footer:?} has no gap before {esc:?}")
});
for hint in rest.split(gap) {
assert!(
hints.contains(&hint),
"{dialog} at {width}: {hint:?} is not a whole hint in {footer:?}"
);
}
}
}
#[test]
fn every_dialog_footer_drops_whole_hints_and_keeps_esc_longest() {
let theme = Theme::default();
let picker = crate::picker::Picker::new(
crate::widgets::WIDGET_NAMES
.iter()
.map(|name| (*name).to_string())
.collect(),
);
Footer {
dialog: "the panel picker",
hints: &["space toggle"],
gap: " ",
esc: "Esc close",
full: "space toggle Esc close",
}
.sweep(|f| picker.render(f, f.area(), &theme, |_| false, None));
let themes = crate::theme_picker::ThemePicker::new(None, None);
Footer {
dialog: "the theme picker",
hints: &["↵ keep"],
gap: " ",
esc: "Esc put back",
full: "↵ keep Esc put back",
}
.sweep(|f| themes.render(f, f.area(), &theme));
let prompt = crate::prompt::Prompt::new(
"ADD A CLOCK",
"Tab completes · Esc cancels",
"",
crate::prompt::Completion::None,
);
Footer {
dialog: "a prompt",
hints: &["Tab completes"],
gap: " · ",
esc: "Esc cancels",
full: "Tab completes · Esc cancels",
}
.sweep(|f| prompt.render(f, f.area(), &theme));
let mut keys = crate::keymap_dialog::KeymapDialog::new();
let keymap = crate::keymap::Keymap::default();
Footer {
dialog: "the key map",
hints: &["↓ more", "↑↓ more", "↑ more", "r reload", "d defaults"],
gap: " ",
esc: "Esc close",
full: "r reload d defaults Esc close",
}
.sweep(|f| keys.render(f, f.area(), &keymap, &[], None, &theme));
}
#[test]
fn the_panel_pickers_status_row_is_whole_or_says_it_was_cut() {
let theme = Theme::default();
let picker = crate::picker::Picker::new(vec!["clocks".into(), "cpu".into()]);
for error in [
None,
Some("config not saved: /home/someone/.config/mirador/config.toml: permission denied"),
] {
let status = error.unwrap_or("written to your config on close");
let mut cut = false;
for width in 1..=100u16 {
let rows = dialog_rows(width, |f| {
picker.render(f, f.area(), &theme, |_| false, error);
});
let Some(row) = rows.len().checked_sub(2).map(|row| &rows[row]) else {
continue;
};
if let Some(head) = row.strip_suffix('…') {
assert!(status.starts_with(head), "{width}: {row:?}");
cut = true;
} else {
assert!(row.is_empty() || row == status, "{width}: {row:?}");
}
}
assert!(cut, "a sweep that never cut the status tests nothing");
}
}
fn rule_row(width: u16, label: &str) -> String {
use ratatui::buffer::CellWidth;
let mut terminal = Terminal::new(TestBackend::new(width, 1)).expect("backend");
terminal
.draw(|frame| rule(frame, frame.area(), &Theme::default(), label))
.expect("draws");
let buffer = terminal.backend().buffer();
let mut row = String::new();
let mut x = 0;
while x < width {
let cell = &buffer[(x, 0)];
row.push_str(cell.symbol());
x += cell.cell_width().max(1);
}
row
}
fn drawn_width(text: &str) -> usize {
let mut buffer = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 200, 1));
let (end, _) = buffer.set_stringn(0, 0, text, usize::MAX, Style::default());
usize::from(end)
}
#[test]
fn a_rule_measures_its_label_in_cells() {
for label in ["next hours", "日本語日本", "日本語日本語", "ガガガ", "ガガガガガ"]
{
let shown = crate::glyphs::utility(label);
let cells = drawn_width(&shown);
for width in 1..=30u16 {
let room = usize::from(width);
let expected = if cells + 4 > room {
"─".repeat(room)
} else {
format!("{shown} {}", "─".repeat(room - cells - 1))
};
assert_eq!(rule_row(width, label), expected, "{label:?} at {width}");
}
}
assert_eq!(rule_row(14, "next hours"), "NEXT HOURS ───");
assert_eq!(rule_row(14, "ガガガガガ"), "ガガガガガ ───");
assert_eq!(rule_row(13, "ガガガガガ"), "─".repeat(13));
}
}