use ratatui::buffer::Buffer;
use ratatui::style::{Color, Modifier};
use serde::Serialize;
use unicode_width::UnicodeWidthStr;
use crate::shared::theme::{Palette, SHOT_CANVAS_DARK, SHOT_CANVAS_LIGHT};
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ShotCell {
pub s: String,
#[serde(skip_serializing_if = "is_narrow")]
pub w: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub fg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bg: Option<String>,
#[serde(skip_serializing_if = "String::is_empty")]
pub m: String,
}
fn is_narrow(w: &u8) -> bool {
*w == 1
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ShotFrame {
pub screen: String,
pub theme: String,
pub locale: String,
pub width: u16,
pub height: u16,
pub canvas_bg: String,
pub canvas_fg: String,
pub rows: Vec<Vec<ShotCell>>,
}
pub fn capture(
buffer: &Buffer,
palette: &Palette,
screen: &str,
theme: &str,
locale: &str,
) -> ShotFrame {
let area = buffer.area();
let canvas_bg = if palette.dark {
SHOT_CANVAS_DARK
} else {
SHOT_CANVAS_LIGHT
};
let canvas_fg = hex(palette.text)
.unwrap_or_else(|| (if palette.dark { "#c9ccd2" } else { "#1e2024" }).into());
let mut rows = Vec::with_capacity(area.height as usize);
for y in area.top()..area.bottom() {
let mut row = Vec::new();
let mut x = area.left();
while x < area.right() {
let cell = &buffer[(x, y)];
let symbol = cell.symbol();
let w = UnicodeWidthStr::width(symbol).clamp(1, 2) as u16;
row.push(ShotCell {
s: symbol.to_string(),
w: w as u8,
fg: hex(cell.fg),
bg: hex(cell.bg),
m: mods(cell.modifier),
});
x += w;
}
rows.push(row);
}
ShotFrame {
screen: screen.into(),
theme: theme.into(),
locale: locale.into(),
width: area.width,
height: area.height,
canvas_bg: hex(canvas_bg).expect("canvas constants are RGB"),
canvas_fg,
rows,
}
}
fn hex(color: Color) -> Option<String> {
let (r, g, b) = match color {
Color::Reset => return None,
Color::Rgb(r, g, b) => (r, g, b),
Color::Black => (0x00, 0x00, 0x00),
Color::Red => (0xcd, 0x00, 0x00),
Color::Green => (0x00, 0xcd, 0x00),
Color::Yellow => (0xcd, 0xcd, 0x00),
Color::Blue => (0x00, 0x00, 0xee),
Color::Magenta => (0xcd, 0x00, 0xcd),
Color::Cyan => (0x00, 0xcd, 0xcd),
Color::Gray => (0xe5, 0xe5, 0xe5),
Color::DarkGray => (0x7f, 0x7f, 0x7f),
Color::LightRed => (0xff, 0x00, 0x00),
Color::LightGreen => (0x00, 0xff, 0x00),
Color::LightYellow => (0xff, 0xff, 0x00),
Color::LightBlue => (0x5c, 0x5c, 0xff),
Color::LightMagenta => (0xff, 0x00, 0xff),
Color::LightCyan => (0x00, 0xff, 0xff),
Color::White => (0xff, 0xff, 0xff),
Color::Indexed(i) => xterm256(i),
};
Some(format!("#{r:02x}{g:02x}{b:02x}"))
}
fn xterm256(i: u8) -> (u8, u8, u8) {
const NAMED: [(u8, u8, u8); 16] = [
(0x00, 0x00, 0x00),
(0xcd, 0x00, 0x00),
(0x00, 0xcd, 0x00),
(0xcd, 0xcd, 0x00),
(0x00, 0x00, 0xee),
(0xcd, 0x00, 0xcd),
(0x00, 0xcd, 0xcd),
(0xe5, 0xe5, 0xe5),
(0x7f, 0x7f, 0x7f),
(0xff, 0x00, 0x00),
(0x00, 0xff, 0x00),
(0xff, 0xff, 0x00),
(0x5c, 0x5c, 0xff),
(0xff, 0x00, 0xff),
(0x00, 0xff, 0xff),
(0xff, 0xff, 0xff),
];
const CUBE: [u8; 6] = [0, 95, 135, 175, 215, 255];
match i {
0..=15 => NAMED[i as usize],
16..=231 => {
let n = i - 16;
(
CUBE[(n / 36) as usize],
CUBE[((n / 6) % 6) as usize],
CUBE[(n % 6) as usize],
)
}
232..=255 => {
let v = 8 + 10 * (i - 232);
(v, v, v)
}
}
}
fn mods(m: Modifier) -> String {
const MAP: [(Modifier, char); 9] = [
(Modifier::BOLD, 'B'),
(Modifier::DIM, 'D'),
(Modifier::ITALIC, 'I'),
(Modifier::UNDERLINED, 'U'),
(Modifier::SLOW_BLINK, 'L'),
(Modifier::RAPID_BLINK, 'F'),
(Modifier::REVERSED, 'R'),
(Modifier::HIDDEN, 'H'),
(Modifier::CROSSED_OUT, 'S'),
];
MAP.iter()
.filter(|(flag, _)| m.contains(*flag))
.map(|(_, c)| *c)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::layout::Rect;
use ratatui::style::Style;
fn palette() -> Palette {
Palette::for_theme(crate::shared::config::Theme::Dark)
}
#[test]
fn wide_glyph_spans_two_columns() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
buf.set_string(0, 0, "日x", Style::default());
let frame = capture(&buf, &palette(), "t", "dark", "en");
let row = &frame.rows[0];
assert_eq!(row[0].s, "日");
assert_eq!(row[0].w, 2);
assert_eq!(row[1].s, "x");
let total: u16 = row.iter().map(|c| c.w as u16).sum();
assert_eq!(total, 4, "row must cover the grid exactly: {row:?}");
}
#[test]
fn colors_map_to_hex_and_reset_to_none() {
assert_eq!(hex(Color::Reset), None);
assert_eq!(hex(Color::Rgb(15, 17, 21)).unwrap(), "#0f1115");
assert_eq!(hex(Color::Blue).unwrap(), "#0000ee");
assert_eq!(hex(Color::Indexed(196)).unwrap(), "#ff0000");
assert_eq!(hex(Color::Indexed(244)).unwrap(), "#808080");
}
#[test]
fn modifiers_serialize_as_letters() {
assert_eq!(
mods(Modifier::BOLD | Modifier::UNDERLINED | Modifier::ITALIC),
"BIU"
);
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
buf.set_string(
0,
0,
"ab",
Style::default()
.fg(Color::Rgb(1, 2, 3))
.add_modifier(Modifier::BOLD),
);
let frame = capture(&buf, &palette(), "t", "dark", "en");
assert_eq!(frame.rows[0][0].m, "B");
assert_eq!(frame.rows[0][0].fg.as_deref(), Some("#010203"));
assert_eq!(frame.rows[0][0].bg, None, "no background was set");
}
#[test]
fn canvas_follows_the_palette() {
let buf = Buffer::empty(Rect::new(0, 0, 1, 1));
let dark = capture(&buf, &palette(), "t", "dark", "en");
assert_eq!(dark.canvas_bg, "#0f1115");
assert_eq!(dark.canvas_fg, "#c9ccd2");
let light = capture(
&buf,
&Palette::for_theme(crate::shared::config::Theme::Light),
"t",
"light",
"en",
);
assert_eq!(light.canvas_bg, "#fafafc");
assert_eq!(light.canvas_fg, "#1e2024");
}
}