use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier},
widgets::{ListState, StatefulWidget, Widget},
};
#[must_use]
pub fn render_to_snapshot<W: Widget>(widget: W, width: u16, height: u16) -> String {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf);
buffer_to_snapshot(&buf)
}
#[must_use]
pub fn render_stateful_to_snapshot<W, S>(
widget: W,
state: &mut S,
width: u16,
height: u16,
) -> String
where
W: StatefulWidget<State = S>,
{
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
widget.render(area, &mut buf, state);
buffer_to_snapshot(&buf)
}
#[must_use]
pub fn mock_list_state(selected: Option<usize>) -> ListState {
let mut state = ListState::default();
state.select(selected);
state
}
#[must_use]
pub fn buffer_to_text(buf: &Buffer) -> String {
let mut output = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
output.push_str(buf[(x, y)].symbol());
}
output.push('\n');
}
output
}
#[must_use]
pub fn buffer_to_snapshot(buf: &Buffer) -> String {
let mut output = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
let cell = &buf[(x, y)];
output.push_str(&format_cell(cell.symbol(), cell.fg, cell.modifier));
}
output.push('\n');
}
output.trim_end_matches('\n').to_string() + "\n"
}
fn format_cell(symbol: &str, fg: Color, modifier: Modifier) -> String {
let style_prefix = format_style(fg, modifier);
if style_prefix.is_empty() {
symbol.to_string()
} else {
format!("[{style_prefix}]{symbol}")
}
}
fn format_style(fg: Color, modifier: Modifier) -> String {
let color_code = color_to_code(fg);
let mod_code = modifier_to_code(modifier);
match (color_code.is_empty(), mod_code.is_empty()) {
(true, true) => String::new(),
(false, true) => color_code,
(true, false) => mod_code,
(false, false) => format!("{color_code}+{mod_code}"),
}
}
fn color_to_code(color: Color) -> String {
match color {
Color::Reset => String::new(),
Color::Black => "Bk".to_string(),
Color::Red => "R".to_string(),
Color::Green => "G".to_string(),
Color::Yellow => "Y".to_string(),
Color::Blue => "Bl".to_string(),
Color::Magenta => "M".to_string(),
Color::Cyan => "C".to_string(),
Color::Gray => "Gy".to_string(),
Color::DarkGray => "dG".to_string(),
Color::LightRed => "lR".to_string(),
Color::LightGreen => "lG".to_string(),
Color::LightYellow => "lY".to_string(),
Color::LightBlue => "lB".to_string(),
Color::LightMagenta => "lM".to_string(),
Color::LightCyan => "lC".to_string(),
Color::White => "W".to_string(),
Color::Indexed(i) => format!("i{i}"),
Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
}
}
fn modifier_to_code(modifier: Modifier) -> String {
let mut codes = Vec::new();
if modifier.contains(Modifier::BOLD) {
codes.push("B");
}
if modifier.contains(Modifier::DIM) {
codes.push("D");
}
if modifier.contains(Modifier::ITALIC) {
codes.push("I");
}
if modifier.contains(Modifier::UNDERLINED) {
codes.push("U");
}
if modifier.contains(Modifier::REVERSED) {
codes.push("V");
}
if modifier.contains(Modifier::CROSSED_OUT) {
codes.push("X");
}
codes.join("")
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Style;
use rstest::rstest;
#[rstest]
#[case(Color::Reset, Modifier::empty(), "")]
#[case(Color::Green, Modifier::empty(), "G")]
#[case(Color::Red, Modifier::empty(), "R")]
#[case(Color::DarkGray, Modifier::empty(), "dG")]
#[case(Color::Reset, Modifier::BOLD, "B")]
#[case(Color::Reset, Modifier::BOLD | Modifier::ITALIC, "BI")]
#[case(Color::White, Modifier::BOLD, "W+B")]
#[case(Color::Green, Modifier::BOLD | Modifier::UNDERLINED, "G+BU")]
fn format_style_variants(#[case] fg: Color, #[case] m: Modifier, #[case] expected: &str) {
assert_eq!(format_style(fg, m), expected);
}
#[rstest]
#[case("a", Color::Reset, Modifier::empty(), "a")]
#[case("●", Color::Green, Modifier::empty(), "[G]●")]
#[case("x", Color::White, Modifier::BOLD, "[W+B]x")]
fn format_cell_variants(
#[case] symbol: &str,
#[case] fg: Color,
#[case] m: Modifier,
#[case] expected: &str,
) {
assert_eq!(format_cell(symbol, fg, m), expected);
}
#[rstest]
#[case(Color::Indexed(1), "i1")]
#[case(Color::Indexed(255), "i255")]
#[case(Color::Rgb(255, 0, 128), "#ff0080")]
fn color_to_code_special(#[case] color: Color, #[case] expected: &str) {
assert_eq!(color_to_code(color), expected);
}
#[test]
fn test_buffer_to_snapshot_simple() {
let area = Rect::new(0, 0, 3, 1);
let mut buf = Buffer::empty(area);
buf[(0, 0)].set_symbol("a");
buf[(1, 0)].set_symbol("b");
buf[(2, 0)].set_symbol("c");
let snapshot = buffer_to_snapshot(&buf);
assert_eq!(snapshot, "abc\n");
}
#[test]
fn test_buffer_to_snapshot_with_style() {
let area = Rect::new(0, 0, 2, 1);
let mut buf = Buffer::empty(area);
buf[(0, 0)]
.set_symbol("●")
.set_style(Style::default().fg(Color::Green));
buf[(1, 0)].set_symbol(" ");
let snapshot = buffer_to_snapshot(&buf);
assert_eq!(snapshot, "[G]● \n");
}
#[test]
fn test_mock_list_state_with_selection() {
let state = mock_list_state(Some(2));
assert_eq!(state.selected(), Some(2));
}
#[test]
fn test_mock_list_state_no_selection() {
let state = mock_list_state(None);
assert_eq!(state.selected(), None);
}
#[test]
fn test_render_stateful_to_snapshot() {
use ratatui::widgets::{List, ListItem};
use crate::tui::theme::{self, BlockVariant};
let items = vec![ListItem::new("Item 1"), ListItem::new("Item 2")];
let list = List::new(items).block(theme::block("Test", BlockVariant::Focused));
let mut state = mock_list_state(Some(0));
let snapshot = render_stateful_to_snapshot(list, &mut state, 20, 5);
assert!(snapshot.contains("Item 1"));
assert!(snapshot.contains("Item 2"));
}
}