use ratatui::Frame;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Clear, List, ListState, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
};
use crate::shared::theme::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 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 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> {
(1..=cell_w.len())
.rev()
.find(|&c| hint_grid_layout(cell_w, c).2 <= avail)
}
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,
) -> Vec<Line<'static>> {
if items.is_empty() {
return Vec::new();
}
let cell_w = hint_cell_widths(items);
let cols = widest_hint_grid(&cell_w, width).unwrap_or(1);
render_hint_grid(
&HintGrid {
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)],
) -> ScreenChrome {
let area = frame.area();
frame.render_widget(Clear, area);
let hotkeys = hotkey_grid(palette, hk, area.width as usize);
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 mut block = palette.panel(title, true);
if let Some(right) = right {
block = block.title(
Line::from(Span::styled(format!(" {right} "), palette.muted_style())).right_aligned(),
);
}
let inner = block.inner(panel_area);
frame.render_widget(block, panel_area);
ScreenChrome {
inner,
panel: panel_area,
status,
hotkeys,
}
}
pub fn confirm_popup(
frame: &mut Frame,
palette: &Palette,
title: &str,
question: &str,
footer: &str,
) {
let width = 56u16.min(frame.area().width);
let area = centered_rect(width, 5, frame.area());
frame.render_widget(Clear, area);
let block = palette.panel(title.to_string(), true).title_bottom(
Line::from(Span::styled(footer.to_string(), palette.muted_style())).centered(),
);
let body = Paragraph::new(Line::from(Span::styled(
question.to_string(),
Style::new().fg(palette.text),
)))
.block(block)
.wrap(Wrap { trim: true });
frame.render_widget(body, area);
}
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)]
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)
.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).is_empty());
for w in [0usize, 1, 2] {
assert_eq!(grid_text(items, w).len(), items.len());
}
}
#[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)[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();
}
}