use std::path::Path;
use crate::base::{Rect, Rgba, Size};
use super::cell::Attrs;
use super::sgr::{build_incremental, build_reset, csi_n, ColorRepr, Pen};
use super::surface::Surface;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ShotCell {
text: String,
width: u8,
fg: Option<Rgba>,
bg: Option<Rgba>,
ul: Option<Rgba>,
attrs: Attrs,
}
impl ShotCell {
const BLANK: ShotCell = ShotCell {
text: String::new(),
width: 1,
fg: None,
bg: None,
ul: None,
attrs: Attrs::NONE,
};
pub fn text(&self) -> &str {
if self.text.is_empty() && self.width == 1 {
" "
} else {
&self.text
}
}
pub fn width(&self) -> i32 {
self.width as i32
}
pub fn is_continuation(&self) -> bool {
self.width == 0
}
pub fn fg(&self) -> Option<Rgba> {
self.fg
}
pub fn bg(&self) -> Option<Rgba> {
self.bg
}
pub fn ul(&self) -> Option<Rgba> {
self.ul
}
pub fn attrs(&self) -> Attrs {
self.attrs
}
fn is_default_blank(&self) -> bool {
*self == ShotCell::BLANK
}
fn new(
text: &str,
width: u8,
fg: Option<Rgba>,
bg: Option<Rgba>,
ul: Option<Rgba>,
attrs: Attrs,
) -> ShotCell {
ShotCell {
text: if text == " " {
String::new()
} else {
text.to_string()
},
width,
fg,
bg,
ul,
attrs,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Screenshot {
w: i32,
h: i32,
cells: Vec<ShotCell>,
pixel_regions: Vec<Rect>,
}
impl Screenshot {
pub fn from_surface(s: &Surface) -> Screenshot {
let (w, h) = (s.width(), s.height());
let mut cells = Vec::with_capacity((w * h).max(0) as usize);
for y in 0..h {
for x in 0..w {
let Some(cell) = s.get(x, y) else {
cells.push(ShotCell::BLANK);
continue;
};
if cell.is_continuation() {
cells.push(ShotCell::new(
"",
0,
opaque(cell.fg),
opaque(cell.bg),
opaque(cell.ul),
cell.attrs,
));
continue;
}
let text = s.glyph_str(cell);
let width = cell.glyph.width().clamp(1, 2) as u8;
cells.push(ShotCell::new(
if text.is_empty() { " " } else { text },
width,
opaque(cell.fg),
opaque(cell.bg),
opaque(cell.ul),
cell.attrs,
));
}
}
Screenshot {
w,
h,
cells,
pixel_regions: Vec::new(),
}
}
pub(crate) fn from_cells(size: Size, cells: Vec<ShotCell>) -> Screenshot {
debug_assert_eq!(cells.len(), (size.w * size.h).max(0) as usize);
Screenshot {
w: size.w.max(0),
h: size.h.max(0),
cells,
pixel_regions: Vec::new(),
}
}
pub(crate) fn make_cell(
text: &str,
width: u8,
fg: Option<Rgba>,
bg: Option<Rgba>,
ul: Option<Rgba>,
attrs: Attrs,
) -> ShotCell {
ShotCell::new(text, width, fg, bg, ul, attrs)
}
pub fn size(&self) -> Size {
Size::new(self.w, self.h)
}
pub fn width(&self) -> i32 {
self.w
}
pub fn height(&self) -> i32 {
self.h
}
pub fn cell(&self, x: i32, y: i32) -> Option<&ShotCell> {
if x < 0 || y < 0 || x >= self.w || y >= self.h {
return None;
}
self.cells.get((y * self.w + x) as usize)
}
pub fn pixel_regions(&self) -> &[Rect] {
&self.pixel_regions
}
pub fn add_pixel_region(&mut self, rect: Rect) {
let clipped = rect.intersect(Rect::from_size(self.size()));
if !clipped.is_empty() {
self.pixel_regions.push(clipped);
}
}
pub fn to_text(&self) -> String {
let mut out = String::with_capacity((self.w as usize + 1) * self.h as usize);
let mut row = String::with_capacity(self.w.max(0) as usize * 4);
for y in 0..self.h {
row.clear();
for x in 0..self.w {
let Some(c) = self.cell(x, y) else { continue };
if c.is_continuation() {
continue; }
row.push_str(c.text());
}
out.push_str(row.trim_end());
out.push('\n');
}
out
}
pub fn to_ansi(&self) -> String {
let mut out: Vec<u8> = Vec::with_capacity((self.w * self.h).max(16) as usize * 2);
let mut pen = Pen::DEFAULT;
let mut seq_inc: Vec<u8> = Vec::new();
let mut seq_reset: Vec<u8> = Vec::new();
out.extend_from_slice(b"\x1b[0m"); for y in 0..self.h {
let mut end = self.w;
while end > 0 {
match self.cell(end - 1, y) {
Some(c) if c.is_default_blank() => end -= 1,
_ => break,
}
}
let mut anchor_needed = false;
for x in 0..end {
let Some(c) = self.cell(x, y) else { continue };
if c.is_continuation() {
continue;
}
if anchor_needed {
csi_n(&mut out, (x + 1) as u32, b'G');
}
let want = Pen {
attrs: c.attrs,
fg: repr(c.fg),
bg: repr(c.bg),
ul: repr(c.ul),
};
if want != pen {
build_incremental(&pen, &want, &mut seq_inc);
build_reset(&want, &mut seq_reset);
let params: &[u8] = if seq_reset.len() < seq_inc.len() {
&seq_reset
} else {
&seq_inc
};
out.extend_from_slice(b"\x1b[");
out.extend_from_slice(params);
out.push(b'm');
pen = want;
}
let text = c.text();
out.extend_from_slice(text.as_bytes());
anchor_needed = arms_fusion(text);
}
if pen != Pen::DEFAULT {
out.extend_from_slice(b"\x1b[0m");
pen = Pen::DEFAULT;
}
if y + 1 < self.h {
out.extend_from_slice(b"\r\n");
}
}
String::from_utf8(out).expect("ANSI export is valid UTF-8 by construction")
}
pub fn write_text(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
std::fs::write(path, self.to_text())
}
pub fn write_ansi(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
std::fs::write(path, self.to_ansi())
}
pub fn write_svg(&self, path: impl AsRef<Path>) -> std::io::Result<()> {
std::fs::write(path, self.to_svg())
}
}
fn arms_fusion(cluster: &str) -> bool {
if crate::text::is_risky_cluster(cluster) {
return true;
}
cluster
.chars()
.next_back()
.is_some_and(|c| ('\u{1F1E6}'..='\u{1F1FF}').contains(&c))
}
fn opaque(c: Rgba) -> Option<Rgba> {
if c.is_transparent() {
None
} else {
Some(Rgba::rgb(c.r, c.g, c.b))
}
}
fn repr(c: Option<Rgba>) -> ColorRepr {
match c {
None => ColorRepr::Default,
Some(c) => ColorRepr::Rgb(c.r, c.g, c.b),
}
}
#[cfg(test)]
#[path = "screenshot_tests.rs"]
mod tests;