use std::time::{Duration, Instant};
use ratatui::Frame;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Clear, HighlightSpacing, List, ListState, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, Wrap,
};
use crate::shared::i18n::Locale;
use crate::shared::theme::{MONO_MARK, Palette};
pub fn dim_background(frame: &mut Frame, palette: &Palette) {
let area = frame.area();
let compat = palette.compat;
let muted = palette.muted;
let buf = frame.buffer_mut();
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
let cell = &mut buf[(x, y)];
if compat {
cell.fg = muted;
cell.modifier.remove(Modifier::BOLD);
} else {
cell.modifier |= Modifier::DIM;
}
}
}
}
pub fn paint_canvas(buf: &mut Buffer, palette: &Palette) {
let canvas = palette.canvas;
if canvas == Color::Reset {
return;
}
for cell in buf.content.iter_mut() {
if cell.bg == Color::Reset {
cell.bg = canvas;
}
if cell.fg == Color::Reset {
cell.fg = palette.text;
}
}
}
pub fn strip_styles(buf: &mut Buffer, palette: &Palette) {
if !palette.mono {
return;
}
for cell in buf.content.iter_mut() {
let marked = cell.bg == MONO_MARK;
cell.fg = Color::Reset;
cell.bg = Color::Reset;
cell.underline_color = Color::Reset;
cell.modifier = if marked {
Modifier::REVERSED
} else {
Modifier::empty()
};
}
}
pub fn finish_frame(buf: &mut Buffer, palette: &Palette) {
paint_canvas(buf, palette);
strip_styles(buf, palette);
}
pub fn mark_selected<'a>(list: List<'a>, palette: &Palette) -> List<'a> {
match palette.selected_mark() {
Some(mark) => list
.highlight_symbol(mark)
.highlight_spacing(HighlightSpacing::Always),
None => list,
}
}
pub const ERASE_SCREEN: &str = "\x1b[2J";
pub const RESET_STYLE: &str = "\x1b[0m";
pub fn set_background(canvas: Color) -> String {
match canvas {
Color::Rgb(r, g, b) => format!("\x1b[48;2;{r};{g};{b}m"),
_ => String::new(),
}
}
pub fn prime_full_redraw(buf: &mut Buffer) {
for cell in buf.content.iter_mut() {
cell.set_symbol(" ");
cell.modifier = SENTINEL_MODIFIER;
}
}
const SENTINEL_MODIFIER: Modifier = Modifier::HIDDEN;
pub const SPINNER_STEP: Duration = Duration::from_millis(200);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Spinner {
started: Instant,
drawn: Option<u128>,
}
impl Default for Spinner {
fn default() -> Self {
Self::new()
}
}
impl Spinner {
pub fn new() -> Self {
Self::started_at(Instant::now())
}
fn started_at(started: Instant) -> Self {
Self {
started,
drawn: None,
}
}
fn step_at(&self, now: Instant) -> u128 {
now.saturating_duration_since(self.started).as_millis() / SPINNER_STEP.as_millis()
}
pub fn due(&self) -> bool {
self.due_at(Instant::now())
}
fn due_at(&self, now: Instant) -> bool {
self.drawn != Some(self.step_at(now))
}
pub fn glyph(&mut self, frames: &[char]) -> char {
self.glyph_at(frames, Instant::now())
}
fn glyph_at(&mut self, frames: &[char], now: Instant) -> char {
let step = self.step_at(now);
self.drawn = Some(step);
frames[(step % frames.len() as u128) as usize]
}
}
pub fn render_scrollbar(
frame: &mut Frame,
area: Rect,
total: usize,
viewport: usize,
position: usize,
focused: bool,
palette: &Palette,
) {
if total <= viewport || area.width == 0 || area.height == 0 {
return;
}
let mut state = ScrollbarState::new(total - viewport + 1)
.position(position)
.viewport_content_length(viewport);
let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(Some("│"))
.thumb_symbol("█")
.track_style(palette.border_style(focused))
.thumb_style(palette.border_style(focused));
frame.render_stateful_widget(bar, area, &mut state);
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ListScroll {
offset: usize,
}
impl ListScroll {
pub fn offset(&self) -> usize {
self.offset
}
pub fn render(
&mut self,
frame: &mut Frame,
list: List<'_>,
area: Rect,
len: usize,
view_h: usize,
selected: Option<usize>,
) {
self.offset = self.offset.min(len.saturating_sub(view_h));
let mut state = ListState::default().with_offset(self.offset);
if len > 0
&& let Some(sel) = selected
{
state.select(Some(sel.min(len - 1)));
}
frame.render_stateful_widget(list, area, &mut state);
self.offset = state.offset();
}
}
pub fn keep_visible(scroll: usize, selected: usize, view: usize) -> usize {
if view == 0 {
return 0;
}
if selected < scroll {
selected
} else if selected >= scroll + view {
selected + 1 - view
} else {
scroll
}
}
pub const HINT_GAP: usize = 3;
pub fn hint_cell_width(key: &str, desc: &str) -> usize {
str_width(key) + 2 + 1 + str_width(desc)
}
pub fn hint_cell_widths(items: &[(&str, &str, bool)]) -> Vec<usize> {
items
.iter()
.map(|(key, desc, _)| hint_cell_width(key, desc))
.collect()
}
pub fn hint_grid_layout(
cell_w: &[usize],
cols: usize,
) -> (Vec<Vec<Option<usize>>>, Vec<usize>, usize) {
let n = cell_w.len();
let rows = n.div_ceil(cols);
let full = (rows - 1) * cols; let empty_lead = cols - (n - full);
let mut grid = vec![vec![None; cols]; rows];
for i in 0..n {
let (r, c) = if i < full {
(i / cols, i % cols)
} else {
(rows - 1, empty_lead + (i - full))
};
grid[r][c] = Some(i);
}
let mut colw = vec![0usize; cols];
for row in &grid {
for (c, cell) in row.iter().enumerate() {
if let Some(i) = cell {
colw[c] = colw[c].max(cell_w[*i]);
}
}
}
let block_w = colw.iter().sum::<usize>() + HINT_GAP * cols.saturating_sub(1);
(grid, colw, block_w)
}
pub fn widest_hint_grid(cell_w: &[usize], avail: usize) -> Option<usize> {
capped_hint_grid(cell_w, avail, cell_w.len())
}
pub fn capped_hint_grid(cell_w: &[usize], avail: usize, max_rows: usize) -> Option<usize> {
(cell_w.len().div_ceil(max_rows.max(1)).max(1)..=cell_w.len())
.rev()
.find(|&c| hint_grid_layout(cell_w, c).2 <= avail)
}
pub fn keep_hints(
cell_w: &[usize],
avail: usize,
max_rows: usize,
order: impl IntoIterator<Item = usize>,
) -> Vec<usize> {
let mut kept: Vec<usize> = Vec::new();
for idx in order {
let mut candidate = kept.clone();
candidate.push(idx);
candidate.sort_unstable();
let w: Vec<usize> = candidate.iter().map(|&i| cell_w[i]).collect();
if capped_hint_grid(&w, avail, max_rows).is_some() {
kept = candidate;
}
}
kept
}
pub fn footer_keep_order(items: &[(&str, &str, bool)]) -> Vec<usize> {
let first = |key: &str| items.iter().position(|(k, ..)| *k == key);
let lead: Vec<usize> = [first("F1"), first("Esc")].into_iter().flatten().collect();
let rest = (0..items.len()).filter(|i| !lead.contains(i));
lead.iter().copied().chain(rest).collect()
}
pub fn footer_rows(height: u16, panel_min: u16) -> usize {
usize::from((height / 3).min(height.saturating_sub(panel_min)).max(1))
}
pub struct HintGrid<'a> {
pub items: &'a [(&'a str, &'a str, bool)],
pub cell_w: &'a [usize],
pub cols: usize,
pub width: usize,
pub lead: Option<Vec<Span<'static>>>,
pub accent: Option<usize>,
}
pub fn render_hint_grid(grid: &HintGrid, palette: &Palette) -> Vec<Line<'static>> {
if grid.items.is_empty() || grid.cols == 0 {
return Vec::new();
}
let (cells, colw, block_w) = hint_grid_layout(grid.cell_w, grid.cols);
let lead_w = grid.width.saturating_sub(block_w);
let mut lead = grid.lead.clone();
let mut out: Vec<Line<'static>> = Vec::new();
for (r, row) in cells.iter().enumerate() {
let mut spans: Vec<Span<'static>> = Vec::new();
push_row_lead(&mut spans, r, &mut lead, lead_w);
for (c, cell) in row.iter().enumerate() {
let gap = if c + 1 < grid.cols { HINT_GAP } else { 0 };
match cell {
Some(i) => push_hint_cell(&mut spans, grid, palette, *i, colw[c], gap),
None => spans.push(Span::raw(" ".repeat(colw[c] + gap))),
}
}
out.push(Line::from(spans));
}
out
}
fn push_hint_cell(
spans: &mut Vec<Span<'static>>,
grid: &HintGrid,
palette: &Palette,
i: usize,
col_w: usize,
gap: usize,
) {
let (key, desc, danger) = grid.items[i];
if grid.accent == Some(i) {
spans.extend(palette.hint_highlight_value(key, desc, palette.accent));
} else {
spans.extend(palette.hint_marked(key, desc, danger));
}
let pad = col_w.saturating_sub(grid.cell_w[i]) + gap;
if pad > 0 {
spans.push(Span::raw(" ".repeat(pad)));
}
}
fn push_row_lead(
spans: &mut Vec<Span<'static>>,
r: usize,
lead: &mut Option<Vec<Span<'static>>>,
lead_w: usize,
) {
if r == 0
&& let Some(pill) = lead.take()
{
let pill_w: usize = pill.iter().map(|s| str_width(&s.content)).sum();
spans.extend(pill);
if lead_w > pill_w {
spans.push(Span::raw(" ".repeat(lead_w - pill_w)));
}
} else if lead_w > 0 {
spans.push(Span::raw(" ".repeat(lead_w)));
}
}
pub fn hotkey_grid(
palette: &Palette,
items: &[(&str, &str, bool)],
width: usize,
max_rows: usize,
) -> Vec<Line<'static>> {
let all_w = hint_cell_widths(items);
let kept = keep_hints(&all_w, width, max_rows, footer_keep_order(items));
let items: Vec<(&str, &str, bool)> = kept.iter().map(|&i| items[i]).collect();
let cell_w: Vec<usize> = kept.iter().map(|&i| all_w[i]).collect();
let Some(cols) = widest_hint_grid(&cell_w, width) else {
return Vec::new();
};
render_hint_grid(
&HintGrid {
items: &items,
cell_w: &cell_w,
cols,
width,
lead: None,
accent: None,
},
palette,
)
}
fn str_width(s: &str) -> usize {
crate::shared::wrap::display_width(&s.chars().collect::<Vec<_>>())
}
pub struct ScreenChrome {
pub inner: Rect,
pub panel: Rect,
pub status: Rect,
pub hotkeys: Vec<Line<'static>>,
}
pub fn screen_chrome(
frame: &mut Frame,
palette: &Palette,
title: String,
right: Option<String>,
hk: &[(&str, &str, bool)],
min: MinSize,
) -> ScreenChrome {
let area = frame.area();
frame.render_widget(Clear, area);
let max_rows = footer_rows(area.height, min.height.saturating_sub(1));
let hotkeys = hotkey_grid(palette, hk, area.width as usize, max_rows);
let status_h = (hotkeys.len() as u16).max(1);
let [panel_area, status] =
Layout::vertical([Constraint::Min(3), Constraint::Length(status_h)]).areas(area);
let block = titled_panel(palette, &title, right.as_deref(), panel_area.width);
let inner = block.inner(panel_area);
frame.render_widget(block, panel_area);
ScreenChrome {
inner,
panel: panel_area,
status,
hotkeys,
}
}
pub fn titled_panel(
palette: &Palette,
title: &str,
right: Option<&str>,
width: u16,
) -> Block<'static> {
let avail = usize::from(width.saturating_sub(4));
let forms = right.map(title_forms).unwrap_or_default();
let (title, right) = fit_title_pair(title, &forms, avail);
let block = palette.panel(title, true);
match right {
Some(right) => block.title(
Line::from(Span::styled(format!(" {right} "), palette.muted_style())).right_aligned(),
),
None => block,
}
}
pub fn confirm_prompt(palette: &Palette, title: &str, question: &str, footer: &str) -> Prompt {
Prompt {
title: title.to_string(),
body: vec![Line::from(Span::styled(
question.to_string(),
Style::new().fg(palette.text),
))],
legend: footer.to_string(),
max_width: 56,
min_rows: 5,
trim: true,
}
}
pub const MODAL_MIN_WIDTH: u16 = 20;
pub struct Prompt {
pub title: String,
pub body: Vec<Line<'static>>,
pub legend: String,
pub max_width: u16,
pub min_rows: u16,
pub trim: bool,
}
impl Prompt {
fn width(&self, window_width: u16) -> u16 {
self.max_width.min(window_width)
}
fn paragraph(&self, palette: &Palette, width: u16) -> Paragraph<'static> {
let mut block = palette.panel(self.title.clone(), true);
let mut body = self.body.clone();
if legend_width(&self.legend) <= width {
block = block.title_bottom(
Line::from(Span::styled(self.legend.clone(), palette.muted_style())).centered(),
);
} else {
body.push(Line::from(Span::styled(
self.legend.trim().to_string(),
palette.muted_style(),
)));
}
Paragraph::new(body)
.block(block)
.wrap(Wrap { trim: self.trim })
}
fn rows(&self, palette: &Palette, width: u16) -> u16 {
let rows = self
.paragraph(palette, width)
.line_count(width.saturating_sub(2));
u16::try_from(rows).unwrap_or(u16::MAX).max(self.min_rows)
}
pub fn min_size(&self, palette: &Palette, area: Rect) -> MinSize {
let width = self.width(area.width.max(MODAL_MIN_WIDTH));
MinSize::new(MODAL_MIN_WIDTH, self.rows(palette, width))
}
pub fn render(&self, frame: &mut Frame, palette: &Palette) {
let full = frame.area();
let width = self.width(full.width);
let area = centered_rect(width, self.rows(palette, width), full);
frame.render_widget(Clear, area);
frame.render_widget(self.paragraph(palette, width), area);
}
}
pub fn legend_width(legend: &str) -> u16 {
u16::try_from(str_width(legend) + 2).unwrap_or(u16::MAX)
}
pub const TITLE_SEP: &str = " · ";
pub fn fit_title(title: &str, avail: usize) -> String {
if str_width(title) <= avail {
return title.to_string();
}
let parts: Vec<&str> = title.split(TITLE_SEP).collect();
let sep_w = str_width(TITLE_SEP);
let joined_w = |kept: &[usize]| -> usize {
kept.iter().map(|&i| str_width(parts[i])).sum::<usize>()
+ sep_w * kept.len().saturating_sub(1)
};
let esc = parts
.iter()
.rposition(|p| p.split_whitespace().any(|w| w == "Esc"));
let order = esc
.into_iter()
.chain((0..parts.len()).filter(|&i| Some(i) != esc));
let mut kept: Vec<usize> = Vec::new();
for i in order {
let mut candidate = kept.clone();
candidate.push(i);
candidate.sort_unstable();
if joined_w(&candidate) <= avail {
kept = candidate;
}
}
let mut shown: Vec<String> = kept.iter().map(|&i| parts[i].to_string()).collect();
if kept.first() != Some(&0) {
let used = joined_w(&kept) + if kept.is_empty() { 0 } else { sep_w };
let (cut, _) = crate::shared::wrap::truncate_to_width(parts[0], avail.saturating_sub(used));
if !cut.is_empty() {
shown.insert(0, cut);
}
}
shown.join(TITLE_SEP)
}
pub fn title_forms(title: &str) -> Vec<String> {
let parts: Vec<&str> = title.split(TITLE_SEP).collect();
(1..=parts.len())
.rev()
.map(|n| parts[..n].join(TITLE_SEP))
.collect()
}
pub fn fit_title_pair(title: &str, forms: &[String], avail: usize) -> (String, Option<String>) {
let owed = str_width(title).min(avail / 2);
let cell_width = |form: &str| str_width(form) + 2;
let form = forms
.iter()
.find(|form| cell_width(form) + owed <= avail)
.cloned();
let budget = avail - form.as_deref().map_or(0, cell_width);
(
crate::shared::wrap::truncate_to_width(title, budget).0,
form,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MinSize {
pub width: u16,
pub height: u16,
}
impl MinSize {
pub const fn new(width: u16, height: u16) -> Self {
Self { width, height }
}
pub fn max(self, other: Self) -> Self {
Self::new(self.width.max(other.width), self.height.max(other.height))
}
pub fn fits(self, area: Rect) -> bool {
area.width >= self.width && area.height >= self.height
}
}
pub const SCREEN_MIN_SIZE: MinSize = MinSize::new(30, 8);
fn size_label(width: u16, height: u16) -> String {
format!("{width}×{height}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WayOut {
Quit,
EscOrQuit,
}
fn too_small_lines(
area: Rect,
need: MinSize,
way_out: WayOut,
loc: &'static Locale,
) -> Vec<(TooSmallLine, String)> {
let have = size_label(area.width, area.height);
let want = size_label(need.width, need.height);
let fits = |s: &String| str_width(s) <= area.width as usize;
let size = vec![
loc.tf("ui.too_small.size", &[("have", &have), ("need", &want)]),
want.clone(),
];
let (esc, quit) = (loc.t("ui.too_small.esc"), loc.t("ui.too_small.quit"));
let keys = match way_out {
WayOut::Quit => vec![quit.to_string()],
WayOut::EscOrQuit => vec![format!("{esc} · {quit}"), esc.to_string()],
};
let mut lines: Vec<(TooSmallLine, String)> = [
(TooSmallLine::Size, size),
(
TooSmallLine::Title,
vec![loc.t("ui.too_small.title").to_string()],
),
(TooSmallLine::Keys, keys),
]
.into_iter()
.filter_map(|(kind, forms)| forms.into_iter().find(fits).map(|text| (kind, text)))
.take(area.height as usize)
.collect();
lines.sort_by_key(|(kind, _)| *kind);
lines
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum TooSmallLine {
Title,
Size,
Keys,
}
pub fn render_too_small(
frame: &mut Frame,
palette: &Palette,
loc: &'static Locale,
need: MinSize,
way_out: WayOut,
) {
let area = frame.area();
frame.render_widget(Clear, area);
let lines: Vec<Line> = too_small_lines(area, need, way_out, loc)
.into_iter()
.map(|(kind, text)| {
let style = match kind {
TooSmallLine::Title => Style::new().fg(palette.text).add_modifier(Modifier::BOLD),
TooSmallLine::Size | TooSmallLine::Keys => palette.muted_style(),
};
Line::from(Span::styled(text, style)).centered()
})
.collect();
let rows = u16::try_from(lines.len()).unwrap_or(u16::MAX);
let [middle] = Layout::vertical([Constraint::Length(rows)])
.flex(Flex::Center)
.areas(area);
frame.render_widget(Paragraph::new(lines), middle);
}
pub fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let [h] = Layout::horizontal([Constraint::Length(width.min(area.width))])
.flex(Flex::Center)
.areas(area);
let [v] = Layout::vertical([Constraint::Length(height.min(area.height))])
.flex(Flex::Center)
.areas(h);
v
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn grid_text(items: &[(&str, &str, bool)], width: usize) -> Vec<String> {
hotkey_grid(&Palette::default(), items, width, usize::MAX)
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
#[test]
fn a_footer_is_right_aligned_and_wraps() {
let items: &[(&str, &str, bool)] = &[
("Tab", "section", false),
("↑↓", "fields", false),
("Enter", "edit", false),
("Esc", "back", false),
("Ctrl+Q", "quit", false),
];
let wide = grid_text(items, 200);
assert_eq!(wide.len(), 1);
assert_eq!(
wide[0].chars().count(),
200,
"the row is padded out to the width"
);
assert!(wide[0].trim_end().ends_with("quit"), "{:?}", wide[0]);
assert!(wide[0].starts_with(" "), "left margin: {:?}", wide[0]);
let narrow = grid_text(items, 24);
assert!(narrow.len() > 1);
let all = narrow.join("");
for (key, _, _) in items {
assert!(all.contains(key), "{key} was shed: {narrow:?}");
}
assert!(hotkey_grid(&Palette::default(), &[], 80, 4).is_empty());
for w in [0usize, 1, 2] {
assert!(grid_text(items, w).is_empty(), "{w}");
}
}
#[test]
fn a_footer_sheds_past_its_rows_keeping_f1_then_esc() {
let items: &[(&str, &str, bool)] = &[
("Tab", "section", false),
("Enter", "open", false),
("Del", "delete", true),
("F1", "help", false),
("Esc", "back", false),
("Ctrl+Q", "quit", false),
];
assert_eq!(footer_keep_order(items), vec![3, 4, 0, 1, 2, 5]);
let rows = |width: usize, max_rows: usize| -> Vec<String> {
hotkey_grid(&Palette::default(), items, width, max_rows)
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
};
let wide = rows(200, 1);
assert_eq!(wide.len(), 1);
assert!(items.iter().all(|(k, ..)| wide[0].contains(k)), "{wide:?}");
let one = rows(24, 1);
assert_eq!(one.len(), 1, "{one:?}");
assert_eq!(one[0].trim(), "F1 help Esc back", "{one:?}");
let two = rows(26, 2);
assert_eq!(two.len(), 2, "{two:?}");
let text = two.join("\n");
assert!(
text.contains("F1") && text.contains("Esc") && text.contains("Tab"),
"{text}"
);
assert!(
text.find("Tab") < text.find("F1"),
"the list's order: {text}"
);
assert!(
!text.contains("Enter") && !text.contains("Ctrl+Q"),
"{text}"
);
let all = rows(24, 6).join("\n");
assert!(items.iter().all(|(k, ..)| all.contains(k)), "{all}");
assert!(rows(7, 3).is_empty());
let plain: &[(&str, &str, bool)] = &[("A", "one", false), ("B", "two", false)];
assert_eq!(footer_keep_order(plain), vec![0, 1]);
}
#[test]
fn a_footer_takes_a_third_of_the_window_and_no_row_the_panel_needs() {
assert_eq!(footer_rows(8, 7), 1, "at the minimum: the one row");
assert_eq!(footer_rows(9, 7), 2);
assert_eq!(footer_rows(12, 7), 4, "a third");
assert_eq!(footer_rows(24, 7), 8);
assert_eq!(footer_rows(12, 11), 1);
assert_eq!(footer_rows(14, 11), 3);
assert_eq!(footer_rows(15, 11), 4);
assert_eq!(footer_rows(3, 11), 1);
assert_eq!(footer_rows(0, 0), 1);
}
#[test]
fn a_title_is_fitted_to_its_border_by_whole_parts() {
let editor = "правка · Enter ок · Esc отмена";
assert_eq!(fit_title(editor, 30), editor, "it fits: unchanged");
assert_eq!(fit_title(editor, 29), "правка · Esc отмена");
assert_eq!(fit_title(editor, 19), "правка · Esc отмена");
assert_eq!(
fit_title(editor, 18),
"прав… · Esc отмена",
"cut, not dropped"
);
assert_eq!(
fit_title(editor, 12),
"Esc отмена",
"the way out outlives the name"
);
let counter = "match 3 of 12 · Enter/↓ next · Shift+Enter/↑ previous · Esc close";
assert_eq!(
fit_title(counter, 45),
"match 3 of 12 · Enter/↓ next · Esc close"
);
assert_eq!(fit_title(counter, 30), "match 3 of 12 · Esc close");
let error = "⚠ the value must be a whole number · Esc cancel";
assert_eq!(fit_title(error, 30), "⚠ the value must… · Esc cancel");
let input = "input · Enter send · Shift+Enter newline";
assert_eq!(fit_title(input, 30), "input · Enter send");
assert_eq!(fit_title(input, 5), "input");
assert_eq!(fit_title(input, 4), "inp…");
assert_eq!(fit_title(input, 0), "");
assert_eq!(
fit_title("name · a hint far too long for the row · F1 help", 20),
"name · F1 help"
);
let busy = "input · generating… Esc cancel";
assert_eq!(fit_title(busy, 24), "generating… Esc cancel");
for avail in 0..60 {
for title in [editor, counter, error, input, busy] {
let fitted = fit_title(title, avail);
assert!(str_width(&fitted) <= avail, "{avail}: {fitted:?}");
}
}
}
#[test]
fn a_panels_two_titles_never_overwrite_each_other() {
assert_eq!(
title_forms("files: 2 · +24 −8"),
vec!["files: 2 · +24 −8".to_string(), "files: 2".to_string()]
);
let top = |width: u16| -> String {
let mut term = Terminal::new(TestBackend::new(width, 3)).unwrap();
term.draw(|f| {
let block = titled_panel(
&Palette::default(),
"◆ Changes in the project",
Some("files: 2 · +24 −8"),
width,
);
f.render_widget(block, f.area());
})
.unwrap();
tests::buffer_rows(term.backend().buffer()).remove(0)
};
assert_eq!(
top(60),
format!(
"╭ ◆ Changes in the project {}─ files: 2 · +24 −8 ╮",
"─".repeat(12)
)
);
let narrow = top(40);
assert!(
narrow.starts_with("╭ ◆ Changes in the project ─"),
"{narrow}"
);
assert!(
narrow.ends_with(" files: 2 ╮"),
"the detail goes first: {narrow}"
);
let narrower = top(30);
assert!(narrower.starts_with("╭ ◆ Changes in th… "), "{narrower}");
assert!(narrower.ends_with(" files: 2 ╮"), "{narrower}");
let narrowest = top(20);
assert_eq!(
narrowest, "╭ ◆ Changes in th… ╮",
"then the right title goes"
);
for width in 4..=70u16 {
let row = top(width);
assert_eq!(row.chars().count(), usize::from(width), "{row}");
assert!(row.starts_with('╭') && row.ends_with('╮'), "{width}: {row}");
let left = row.trim_start_matches('╭').trim_start();
assert!(
left.starts_with("◆ Changes in the project") || left.contains('…') || width < 8,
"{width}: {row}"
);
}
}
#[test]
fn a_wrapped_row_lands_under_the_columns_above() {
let items: &[(&str, &str, bool)] = &[
("K1", "aaaa", false),
("K2", "bbbb", false),
("K3", "cccc", false),
("K4", "dddd", false),
];
let cell = hint_cell_width("K1", "aaaa");
let width = cell * 3 + HINT_GAP * 2;
let rows = grid_text(items, width);
assert_eq!(rows.len(), 2, "{rows:?}");
assert_eq!(
rows[1].find("K4"),
rows[0].find("K3"),
"the wrapped cell sits under the last column: {rows:?}"
);
assert!(
rows[1][..rows[1].find("K4").unwrap()].trim().is_empty(),
"everything left of the wrapped cell is padding: {rows:?}"
);
assert_eq!(rows[1].chars().count(), width);
}
#[test]
fn the_block_width_accounts_for_every_column_and_gap() {
let cell_w = vec![10, 12, 8, 14, 9];
for cols in 1..=cell_w.len() {
let (grid, colw, block_w) = hint_grid_layout(&cell_w, cols);
assert_eq!(grid[0].len(), cols);
assert_eq!(
block_w,
colw.iter().sum::<usize>() + HINT_GAP * (cols - 1),
"cols={cols}"
);
let mut seen: Vec<usize> = grid.iter().flatten().flatten().copied().collect();
seen.sort_unstable();
assert_eq!(seen, (0..cell_w.len()).collect::<Vec<_>>(), "cols={cols}");
}
assert_eq!(widest_hint_grid(&cell_w, 1000), Some(5));
assert_eq!(widest_hint_grid(&cell_w, 5), None);
}
#[test]
fn a_dangerous_key_gets_a_red_keycap() {
let p = Palette::default();
let items: &[(&str, &str, bool)] = &[("Del", "delete", true), ("Esc", "back", false)];
let line = &hotkey_grid(&p, items, 80, 4)[0];
let keycap = |key: &str| {
line.spans
.iter()
.find(|s| s.content.trim() == key)
.unwrap_or_else(|| panic!("no {key} keycap"))
.style
.fg
};
assert_eq!(keycap("Del"), Some(p.keycap_danger));
assert_eq!(keycap("Esc"), Some(p.keycap_fg));
}
fn right_column(term: &Terminal<TestBackend>) -> Vec<String> {
let buf = term.backend().buffer();
let area = buf.area;
(area.top()..area.bottom())
.map(|y| buf[(area.right() - 1, y)].symbol().to_string())
.collect()
}
fn draw(scroll: &mut ListScroll, len: usize, selected: usize, h: u16) {
let mut term = Terminal::new(TestBackend::new(12, h)).unwrap();
term.draw(|f| {
let items: Vec<ratatui::widgets::ListItem> =
(0..len).map(|i| format!("row {i}").into()).collect();
let area = f.area();
scroll.render(f, List::new(items), area, len, h as usize, Some(selected));
})
.unwrap();
}
#[test]
fn the_window_moves_only_when_the_selection_leaves_it() {
let mut scroll = ListScroll::default();
draw(&mut scroll, 30, 29, 10);
assert_eq!(scroll.offset(), 20);
draw(&mut scroll, 30, 28, 10);
assert_eq!(scroll.offset(), 20);
draw(&mut scroll, 30, 20, 10);
assert_eq!(scroll.offset(), 20, "the selection is on the top row");
draw(&mut scroll, 30, 19, 10);
assert_eq!(scroll.offset(), 19);
draw(&mut scroll, 30, 28, 10);
assert_eq!(scroll.offset(), 19);
draw(&mut scroll, 30, 29, 10);
assert_eq!(scroll.offset(), 20);
}
#[test]
fn a_shrunken_list_pulls_the_window_back_to_its_tail() {
let mut scroll = ListScroll::default();
draw(&mut scroll, 30, 29, 10);
assert_eq!(scroll.offset(), 20);
draw(&mut scroll, 12, 11, 10);
assert_eq!(scroll.offset(), 2, "the last ten of twelve rows");
draw(&mut scroll, 4, 3, 10);
assert_eq!(scroll.offset(), 0);
}
#[test]
fn scrollbar_renders_thumb_only_on_overflow() {
let palette = Palette::default();
let mut term = Terminal::new(TestBackend::new(10, 6)).unwrap();
term.draw(|f| render_scrollbar(f, f.area(), 6, 6, 0, false, &palette))
.unwrap();
assert!(right_column(&term).iter().all(|s| s != "█" && s != "│"));
term.draw(|f| render_scrollbar(f, f.area(), 24, 6, 0, false, &palette))
.unwrap();
let col = right_column(&term);
assert!(col.iter().any(|s| s == "█"), "no thumb: {col:?}");
assert!(col.iter().any(|s| s == "│"), "no track: {col:?}");
}
#[test]
fn scrollbar_thumb_tracks_position() {
let palette = Palette::default();
let mut term = Terminal::new(TestBackend::new(4, 8)).unwrap();
let thumb_rows = |term: &Terminal<TestBackend>| -> Vec<usize> {
right_column(term)
.iter()
.enumerate()
.filter(|(_, s)| *s == "█")
.map(|(i, _)| i)
.collect()
};
term.draw(|f| render_scrollbar(f, f.area(), 32, 8, 0, false, &palette))
.unwrap();
let top = thumb_rows(&term);
assert_eq!(
top.first(),
Some(&0),
"thumb should be at the top initially: {top:?}"
);
term.draw(|f| render_scrollbar(f, f.area(), 32, 8, 24, false, &palette))
.unwrap();
let bottom = thumb_rows(&term);
assert_eq!(
bottom.last(),
Some(&7),
"thumb should be at the bottom at full scroll: {bottom:?}"
);
}
#[test]
fn scrollbar_thumb_uses_border_color() {
let palette = Palette::default();
let mut term = Terminal::new(TestBackend::new(4, 8)).unwrap();
term.draw(|f| render_scrollbar(f, f.area(), 32, 8, 0, false, &palette))
.unwrap();
let buf = term.backend().buffer();
let area = buf.area;
let thumb_fg = (area.top()..area.bottom())
.map(|y| &buf[(area.right() - 1, y)])
.find(|c| c.symbol() == "█")
.map(|c| c.fg);
assert_eq!(
thumb_fg,
Some(palette.border),
"thumb should be border-colored"
);
}
#[test]
fn scrollbar_zero_area_is_noop() {
let palette = Palette::default();
let mut term = Terminal::new(TestBackend::new(4, 4)).unwrap();
term.draw(|f| {
let zero = Rect::new(0, 0, 0, 0);
render_scrollbar(f, zero, 10, 2, 0, false, &palette);
})
.unwrap();
}
#[test]
fn prime_full_redraw_repaints_all_but_wide_glyph_tails() {
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
use unicode_width::UnicodeWidthStr;
let area = Rect::new(0, 0, 20, 2);
let mut frame = Buffer::empty(area);
Paragraph::new(Line::from(vec![Span::styled(
"a 🗂\u{FE0F} хвост",
Style::new().fg(ratatui::style::Color::Blue),
)]))
.render(area, &mut frame);
let mut sentinel = frame.clone();
prime_full_redraw(&mut sentinel);
let updates = sentinel.diff(&frame);
for &(x, y, _) in &updates {
if x == 0 {
continue;
}
let left = frame[(x - 1, y)].symbol();
assert!(
left.width() < 2,
"update at ({x},{y}) targets the second half of glyph {left:?}"
);
}
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
if updates.iter().any(|&(ux, uy, _)| (ux, uy) == (x, y)) {
continue;
}
let is_tail = x > 0 && frame[(x - 1, y)].symbol().width() == 2;
assert!(is_tail, "cell ({x},{y}) is neither repainted nor a tail");
}
}
}
#[test]
fn screen_switch_emits_vs16_tail_without_full_redraw() {
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
let area = Rect::new(0, 0, 20, 1);
let mut feed = Buffer::empty(area);
Paragraph::new(Line::from(vec![Span::styled(
"a \u{2764}\u{FE0F} tail",
Style::new(),
)]))
.render(area, &mut feed);
let gx = (0..area.width)
.find(|&x| feed[(x, 0)].symbol().contains('\u{FE0F}'))
.expect("VS16 glyph not found in the frame");
let mut other = Buffer::empty(area);
Paragraph::new(Line::from("chat list content!!")).render(area, &mut other);
assert!(
other
.diff(&feed)
.iter()
.any(|&(x, y, _)| (x, y) == (gx + 1, 0)),
"a screen switch sends the VS16 tail — without a full redraw the row will drift"
);
let mut same = feed.clone();
same[(area.width - 1, 0)].set_symbol("Z");
assert!(
!same
.diff(&feed)
.iter()
.any(|&(x, y, _)| (x, y) == (gx + 1, 0)),
"an edit within the screen doesn't touch the VS16 tail"
);
let mut sentinel = feed.clone();
prime_full_redraw(&mut sentinel);
assert!(
!sentinel
.diff(&feed)
.iter()
.any(|&(x, y, _)| (x, y) == (gx + 1, 0)),
"the sentinel must not send the VS16 tail (otherwise it would shift the row itself)"
);
}
#[test]
fn sentinel_modifier_is_unused_by_ui() {
for used in [
Modifier::DIM,
Modifier::BOLD,
Modifier::ITALIC,
Modifier::UNDERLINED,
Modifier::REVERSED,
] {
assert!(
!SENTINEL_MODIFIER.intersects(used),
"sentinel marker intersects a modifier used in the UI {used:?}"
);
}
}
#[test]
fn dim_background_uses_color_in_compat_mode() {
let mut term = Terminal::new(TestBackend::new(4, 2)).unwrap();
term.draw(|f| {
dim_background(f, &Palette::default());
assert!(f.buffer_mut()[(0, 0)].modifier.contains(Modifier::DIM));
})
.unwrap();
let compat = Palette::default().with_compat(true);
term.draw(|f| {
dim_background(f, &compat);
let cell = f.buffer_mut()[(0, 0)].clone();
assert_eq!(cell.fg, compat.muted);
assert!(!cell.modifier.contains(Modifier::DIM));
})
.unwrap();
}
pub(crate) fn buffer_rows(buf: &Buffer) -> Vec<String> {
let area = buf.area;
(area.top()..area.bottom())
.map(|y| {
(area.left()..area.right())
.map(|x| buf[(x, y)].symbol())
.collect()
})
.collect()
}
fn mixed_frame(palette: &Palette) -> Buffer {
use ratatui::widgets::Widget;
let area = Rect::new(0, 0, 12, 3);
let mut buf = Buffer::empty(area);
Paragraph::new(vec![
Line::from(vec![
Span::raw("bare "),
Span::styled("role", Style::new().fg(palette.user)),
]),
Line::from(vec![
Span::styled("sel", Style::new().bg(palette.keycap_bg)),
Span::styled(" rev", Style::new().add_modifier(Modifier::REVERSED)),
]),
Line::from("a 😀 b"),
])
.render(area, &mut buf);
buf
}
#[test]
fn the_system_mode_leaves_the_frame_alone() {
for theme in [
crate::shared::config::Theme::Auto,
crate::shared::config::Theme::Dark,
crate::shared::config::Theme::Light,
] {
let palette = Palette::for_theme(theme);
let drawn = mixed_frame(&palette);
let mut painted = drawn.clone();
paint_canvas(&mut painted, &palette);
assert_eq!(painted, drawn, "{theme:?}");
}
}
#[test]
fn the_full_mode_fills_what_the_widgets_left_at_the_default() {
for name in crate::shared::theme::FULL_THEMES {
let palette = Palette::full(name);
let drawn = mixed_frame(&palette);
let mut painted = drawn.clone();
paint_canvas(&mut painted, &palette);
for (before, after) in drawn.content.iter().zip(&painted.content) {
assert_ne!(after.bg, Color::Reset, "{name}: {:?}", after.symbol());
assert_ne!(after.fg, Color::Reset, "{name}: {:?}", after.symbol());
let want_bg = if before.bg == Color::Reset {
palette.canvas
} else {
before.bg
};
let want_fg = if before.fg == Color::Reset {
palette.text
} else {
before.fg
};
assert_eq!((after.fg, after.bg), (want_fg, want_bg), "{name}");
assert_eq!(after.symbol(), before.symbol(), "{name}");
assert_eq!(after.modifier, before.modifier, "{name}");
}
let cell = |x: u16, y: u16| &painted[(x, y)];
assert_eq!(cell(0, 0).fg, palette.text, "{name}: bare text");
assert_eq!(cell(5, 0).fg, palette.user, "{name}: a role colour");
assert_eq!(cell(0, 1).bg, palette.keycap_bg, "{name}: the backdrop");
assert!(cell(4, 1).modifier.contains(Modifier::REVERSED), "{name}");
assert_eq!(cell(11, 2).bg, palette.canvas, "{name}: an empty cell");
}
}
#[test]
fn a_popup_cleared_over_the_frame_gets_the_canvas_back() {
use ratatui::widgets::{Block, Widget};
let palette = Palette::full("light");
let mut buf = mixed_frame(&palette);
paint_canvas(&mut buf, &palette);
let popup = Rect::new(2, 0, 6, 3);
Clear.render(popup, &mut buf);
Block::bordered().render(popup, &mut buf);
assert_eq!(buf[(3, 1)].bg, Color::Reset, "the premise: Clear resets");
paint_canvas(&mut buf, &palette);
assert!(buf.content.iter().all(|c| c.bg != Color::Reset));
}
#[test]
fn the_monochrome_pass_leaves_nothing_but_the_mark() {
use crate::shared::theme::MONO_MARK;
use ratatui::widgets::Widget;
let palette = Palette::mono();
let mut buf = mixed_frame(&palette);
Paragraph::new(Line::from(vec![
Span::styled(
"m",
palette.search_match(Style::new().bold().fg(Color::Red)),
),
Span::styled("s", palette.selection(Style::new().underlined())),
Span::styled("u", Style::new().underlined().underline_color(Color::Red)),
]))
.render(Rect::new(8, 2, 3, 1), &mut buf);
let drawn = buf.clone();
assert_eq!(drawn[(8, 2)].bg, MONO_MARK, "the premise: a mark is drawn");
assert!(drawn[(4, 1)].modifier.contains(Modifier::REVERSED));
strip_styles(&mut buf, &palette);
for (before, after) in drawn.content.iter().zip(&buf.content) {
let what = before.symbol();
assert_eq!(after.symbol(), what, "the text is not the pass's");
assert_eq!(after.fg, Color::Reset, "{what:?}");
assert_eq!(after.bg, Color::Reset, "{what:?}");
assert_eq!(after.underline_color, Color::Reset, "{what:?}");
let want = if before.bg == MONO_MARK {
Modifier::REVERSED
} else {
Modifier::empty()
};
assert_eq!(after.modifier, want, "{what:?}");
}
assert_eq!(buf[(8, 2)].modifier, Modifier::REVERSED, "a search match");
assert_eq!(buf[(9, 2)].modifier, Modifier::REVERSED, "a selection");
assert_eq!(buf[(4, 1)].modifier, Modifier::empty(), "a popup's row");
assert_eq!(buf[(0, 1)].bg, Color::Reset, "the selection backdrop");
}
#[test]
fn only_the_monochrome_mode_is_stripped() {
let mut palettes = vec![Palette::default()];
palettes.extend(crate::shared::theme::FULL_THEMES.map(Palette::full));
for palette in palettes {
let drawn = mixed_frame(&palette);
let mut passed = drawn.clone();
strip_styles(&mut passed, &palette);
assert_eq!(passed, drawn);
let mut finished = drawn.clone();
finish_frame(&mut finished, &palette);
let mut painted = drawn.clone();
paint_canvas(&mut painted, &palette);
assert_eq!(finished, painted);
}
let mono = Palette::mono();
let mut finished = mixed_frame(&mono);
finish_frame(&mut finished, &mono);
assert!(
finished
.content
.iter()
.all(|c| c.fg == Color::Reset && c.bg == Color::Reset && c.modifier.is_empty()),
"the monochrome palette paints no canvas and is stripped"
);
}
#[test]
fn a_popup_over_the_frame_is_stripped_with_it() {
use ratatui::widgets::{Block, Widget};
let palette = Palette::mono();
let mut buf = mixed_frame(&palette);
for cell in buf.content.iter_mut() {
cell.modifier |= Modifier::DIM; }
let popup = Rect::new(2, 0, 6, 3);
Clear.render(popup, &mut buf);
Block::bordered()
.border_style(palette.border_style(true))
.render(popup, &mut buf);
strip_styles(&mut buf, &palette);
assert!(buf.content.iter().all(|c| c.modifier.is_empty()));
assert!(buf.content.iter().all(|c| c.fg == Color::Reset));
assert_eq!(buf[(2, 0)].symbol(), "┌", "the border is what is left");
}
#[test]
fn a_list_gets_its_marker_only_in_the_monochrome_mode() {
use ratatui::widgets::StatefulWidget;
let rows = |palette: &Palette| -> Vec<String> {
let area = Rect::new(0, 0, 8, 3);
let mut buf = Buffer::empty(area);
let list = mark_selected(
List::new(["one", "two", "three"]).highlight_style(Style::new().reversed()),
palette,
);
let mut state = ListState::default().with_selected(Some(1));
StatefulWidget::render(list, area, &mut buf, &mut state);
buffer_rows(&buf)
};
assert_eq!(
rows(&Palette::default()),
["one ", "two ", "three "]
);
assert_eq!(rows(&Palette::mono()), [" one ", "› two ", " three "]);
}
#[test]
fn the_erase_sets_the_canvas_and_the_reset_gives_it_back() {
let canvas = crate::shared::theme::CANVAS_DARK;
assert_eq!(set_background(canvas), "\x1b[48;2;15;17;21m");
assert_eq!(ERASE_SCREEN, "\x1b[2J");
assert_eq!(RESET_STYLE, "\x1b[0m");
assert_eq!(set_background(Color::Reset), "");
assert_eq!(set_background(Color::Blue), "");
}
const FRAMES: &[char] = &['a', 'b', 'c'];
fn ms(n: u64) -> Duration {
Duration::from_millis(n)
}
#[test]
fn a_spinner_steps_with_time_not_with_frames() {
let t0 = Instant::now();
let mut s = Spinner::started_at(t0);
for _ in 0..5 {
assert_eq!(s.glyph_at(FRAMES, t0), 'a');
}
assert_eq!(s.glyph_at(FRAMES, t0 + SPINNER_STEP - ms(1)), 'a');
assert_eq!(s.glyph_at(FRAMES, t0 + SPINNER_STEP), 'b');
assert_eq!(s.glyph_at(FRAMES, t0 + SPINNER_STEP * 2), 'c');
assert_eq!(s.glyph_at(FRAMES, t0 + SPINNER_STEP * 3), 'a', "wraps");
}
#[test]
fn a_spinner_is_due_only_when_its_glyph_would_change() {
let t0 = Instant::now();
let mut s = Spinner::started_at(t0);
assert!(s.due_at(t0), "never drawn — the first frame is due");
s.glyph_at(FRAMES, t0);
assert!(!s.due_at(t0 + ms(50)));
assert!(!s.due_at(t0 + SPINNER_STEP - ms(1)));
assert!(s.due_at(t0 + SPINNER_STEP), "the next glyph is due");
s.glyph_at(FRAMES, t0 + SPINNER_STEP + ms(40));
assert!(!s.due_at(t0 + SPINNER_STEP * 2 - ms(1)));
assert!(s.due_at(t0 + SPINNER_STEP * 2));
}
use crate::shared::i18n::{Lang, locale};
fn question() -> Prompt {
Prompt {
title: "Tool call".into(),
body: vec![Line::raw("Run this call?"), Line::raw(" indented()")],
legend: " Enter — run · A — allow for this turn · Esc — decline ".into(),
max_width: 72,
min_rows: 0,
trim: false,
}
}
fn prompt_rows(prompt: &Prompt, width: u16, height: u16) -> Vec<String> {
let palette = Palette::default();
let mut term = Terminal::new(TestBackend::new(width, height)).unwrap();
term.draw(|f| prompt.render(f, &palette)).unwrap();
buffer_rows(term.backend().buffer())
}
fn inner_text(rows: &[String]) -> String {
rows.iter()
.filter(|r| r.starts_with('│'))
.map(|r| r.trim_matches('│').trim())
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn a_prompts_keys_are_on_the_border_or_in_the_body_never_cut() {
let prompt = question();
let legend = prompt.legend.trim();
let needs = legend_width(&prompt.legend);
assert_eq!(needs, 57, "the text and the two corners");
let rows = prompt_rows(&prompt, needs, 12);
assert_eq!(rows.len(), 12);
let shown: Vec<&String> = rows.iter().filter(|r| !r.trim().is_empty()).collect();
assert_eq!(shown.len(), 4, "border, two lines, border: {shown:?}");
assert!(shown[3].contains(legend), "on the border: {shown:?}");
assert!(!inner_text(&rows).contains("Enter"), "and not in the body");
for width in (MODAL_MIN_WIDTH..needs).rev() {
let rows = prompt_rows(&prompt, width, 20);
let body = inner_text(&rows);
assert!(
body.ends_with(legend),
"{width} columns: the keys close the body, whole — {body:?}"
);
let bottom = rows.iter().rfind(|r| r.starts_with('╰')).unwrap();
assert!(!bottom.contains("Esc"), "{width}: not half on the border");
assert!(rows.iter().any(|r| r.contains(" indented()")), "{width}");
}
}
#[test]
fn a_prompts_minimum_is_the_rows_its_text_wraps_into() {
let palette = Palette::default();
let prompt = question();
let mut last = 0;
for width in [100, 72, 57, 40, 30, 20] {
let need = prompt.min_size(&palette, Rect::new(0, 0, width, 50));
assert_eq!(need.width, MODAL_MIN_WIDTH);
let rows = prompt_rows(&prompt, width, 50);
let drawn = rows.iter().filter(|r| !r.trim().is_empty()).count();
assert_eq!(usize::from(need.height), drawn, "{width}: {rows:#?}");
assert!(need.height >= last, "narrower is never shorter");
last = need.height;
}
assert!(last > 4, "the control: twenty columns do wrap it");
assert_eq!(
prompt.min_size(&palette, Rect::new(0, 0, 5, 50)),
prompt.min_size(&palette, Rect::new(0, 0, MODAL_MIN_WIDTH, 50)),
);
}
#[test]
fn a_confirmation_keeps_its_five_rows_and_grows_past_them() {
let palette = Palette::default();
let short = confirm_prompt(
&palette,
"Confirmation",
"Delete it?",
" Enter — yes · Esc — no ",
);
assert_eq!(short.min_size(&palette, Rect::new(0, 0, 80, 24)).height, 5);
let long = confirm_prompt(
&palette,
"Confirmation",
&"a question long enough to wrap ".repeat(8),
" Enter — yes · Esc — no ",
);
let need = long.min_size(&palette, Rect::new(0, 0, 30, 24));
assert!(need.height > 5, "{need:?}");
let rows = prompt_rows(&long, 30, need.height);
assert!(rows[0].starts_with('╭') && rows.last().unwrap().starts_with('╰'));
assert!(rows.last().unwrap().contains("Enter — yes · Esc — no"));
}
#[test]
fn a_minimum_holds_both_layers_and_fits_by_both_sides() {
let (screen, popup) = (MinSize::new(20, 9), MinSize::new(46, 6));
assert_eq!(screen.max(popup), MinSize::new(46, 9));
let need = MinSize::new(46, 9);
assert!(need.fits(Rect::new(0, 0, 46, 9)));
assert!(!need.fits(Rect::new(0, 0, 45, 9)), "a column short");
assert!(!need.fits(Rect::new(0, 0, 46, 8)), "a row short");
}
#[test]
fn the_placeholder_says_what_it_can_in_the_room_it_has() {
let need = MinSize::new(46, 12);
for &lang in Lang::ALL {
let loc = locale(lang);
let lines = |w: u16, h: u16, way_out: WayOut| {
too_small_lines(Rect::new(0, 0, w, h), need, way_out, loc)
};
let kinds = |w: u16, h: u16| -> Vec<TooSmallLine> {
lines(w, h, WayOut::Quit)
.into_iter()
.map(|(kind, _)| kind)
.collect()
};
use TooSmallLine::{Keys, Size, Title};
assert_eq!(kinds(45, 6), [Title, Size, Keys], "{lang:?}");
assert_eq!(kinds(45, 2), [Title, Size], "{lang:?}");
assert_eq!(kinds(45, 1), [Size], "{lang:?}");
assert_eq!(kinds(45, 0), [], "{lang:?}");
for w in 0..=60u16 {
for way_out in [WayOut::Quit, WayOut::EscOrQuit] {
for (_, text) in lines(w, 3, way_out) {
assert!(str_width(&text) <= w as usize, "{lang:?} {w}: {text:?}");
}
}
}
let narrow = lines(8, 1, WayOut::Quit);
assert_eq!(narrow, [(Size, "46×12".to_string())], "{lang:?}");
assert!(lines(4, 3, WayOut::Quit).is_empty());
let full = lines(45, 6, WayOut::Quit);
assert!(full[1].1.contains("45×6") && full[1].1.contains("46×12"));
assert!(
full[2].1.contains("Ctrl+Q") && !full[2].1.contains("Esc"),
"{full:?}"
);
let both = lines(45, 6, WayOut::EscOrQuit);
assert!(
both[2].1.contains("Esc") && both[2].1.contains("Ctrl+Q"),
"{both:?}"
);
let esc = lines(20, 6, WayOut::EscOrQuit);
let keys = esc.iter().find(|(kind, _)| *kind == Keys).unwrap();
assert_eq!(keys.1, loc.t("ui.too_small.esc"), "{lang:?}");
}
}
#[test]
fn the_placeholder_takes_the_whole_frame_at_any_size() {
let palette = Palette::default();
let loc = locale(Lang::En);
let need = MinSize::new(46, 12);
let draw = |w: u16, h: u16| -> Vec<String> {
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| {
let area = f.area();
f.render_widget(Paragraph::new(vec![Line::raw("XXXXXXXXXXXX"); 40]), area);
render_too_small(f, &palette, loc, need, WayOut::Quit);
})
.unwrap();
buffer_rows(term.backend().buffer())
};
for (w, h) in [(0, 0), (1, 1), (3, 40), (200, 1), (45, 6), (120, 40)] {
let rows = draw(w, h);
assert!(rows.iter().all(|r| !r.contains('X')), "{w}×{h}: {rows:?}");
}
let rows = draw(45, 6);
let title = rows
.iter()
.position(|r| r.contains("Window too small"))
.unwrap();
let blank_below = rows[title + 3..]
.iter()
.filter(|r| r.trim().is_empty())
.count();
assert!(
title.abs_diff(blank_below) <= 1 && title + 3 + blank_below == 6,
"three lines, centred in six rows: {rows:#?}"
);
let line = &rows[title];
let (left, right) = (
line.len() - line.trim_start().len(),
line.len() - line.trim_end().len(),
);
assert!(left.abs_diff(right) <= 1, "centred: {line:?}");
}
}