//! Skärmbild av arbetsytan: sparar den composit­erade skärmen som //! textfil och/eller PNG. //! //! Texten är en ren avskrift av cellernas symboler. PNG:n rasteriseras //! cell för cell med den inbäddade DejaVu Sans Mono-fonten: bakgrunds- //! rektangel + glyf i förgrundsfärgen. Glyfer som saknas i fonten //! (t.ex. färgemoji) ritas som en fylld ruta i förgrundsfärgen. use ratatui::buffer::Buffer; use ratatui::style::Color; use std::path::PathBuf; const FONT_BYTES: &[u8] = include_bytes!("../assets/DejaVuSansMono.ttf"); const CELL_W: u32 = 10; const CELL_H: u32 = 20; const FONT_PX: f32 = 17.0; /// Resultatet av en sparad skärmbild. pub struct Saved { pub text_path: Option, pub png_path: Option, } /// Spara `buf` till `dir` som text och/eller PNG beroende på `format` /// ("text", "png" eller "both"). Returnerar sökvägarna som skrevs. pub fn save(buf: &Buffer, dir: &str, format: &str, stamp: &str) -> std::io::Result { std::fs::create_dir_all(dir)?; let base = PathBuf::from(dir).join(format!("tui-wm-{}", stamp)); let mut saved = Saved { text_path: None, png_path: None }; if format == "text" || format == "both" { let path = base.with_extension("txt"); std::fs::write(&path, buffer_to_text(buf))?; saved.text_path = Some(path); } if format == "png" || format == "both" { let path = base.with_extension("png"); buffer_to_png(buf, &path) .map_err(|e| std::io::Error::other(format!("PNG: {}", e)))?; saved.png_path = Some(path); } Ok(saved) } /// Ren text: cellsymboler rad för rad (celler som täcks av ett brett /// tecken hoppas över, precis som i ANSI-serialiseringen). fn buffer_to_text(buf: &Buffer) -> String { let area = buf.area; let mut out = String::with_capacity((area.width as usize + 1) * area.height as usize); for row in 0..area.height { let mut skip = 0u16; for col in 0..area.width { if skip > 0 { skip -= 1; continue; } let cell = &buf.content[(row * area.width + col) as usize]; let sym = cell.symbol(); let w = unicode_width::UnicodeWidthStr::width(sym); if w > 1 { skip = (w as u16).saturating_sub(1); } out.push_str(if sym.is_empty() { " " } else { sym }); } // trimma högerkantens mellanslag while out.ends_with(' ') { out.pop(); } out.push('\n'); } out } /// Standardpalett för ratatui-färger → RGB. fn color_rgb(c: Color, is_fg: bool) -> [u8; 3] { match c { Color::Reset => { if is_fg { [220, 220, 220] } else { [30, 30, 40] } } Color::Black => [0, 0, 0], Color::Red => [205, 49, 49], Color::Green => [13, 188, 121], Color::Yellow => [229, 229, 16], Color::Blue => [36, 114, 200], Color::Magenta => [188, 63, 188], Color::Cyan => [17, 168, 205], Color::Gray => [229, 229, 229], Color::DarkGray => [102, 102, 102], Color::LightRed => [241, 76, 76], Color::LightGreen => [35, 209, 139], Color::LightYellow => [245, 245, 67], Color::LightBlue => [59, 142, 234], Color::LightMagenta => [214, 112, 214], Color::LightCyan => [41, 184, 219], Color::White => [255, 255, 255], Color::Rgb(r, g, b) => [r, g, b], Color::Indexed(i) => xterm256(i), } } /// xterm-256-palettens standardvärden. fn xterm256(i: u8) -> [u8; 3] { match i { 0..=15 => { const BASE: [[u8; 3]; 16] = [ [0, 0, 0], [205, 49, 49], [13, 188, 121], [229, 229, 16], [36, 114, 200], [188, 63, 188], [17, 168, 205], [229, 229, 229], [102, 102, 102], [241, 76, 76], [35, 209, 139], [245, 245, 67], [59, 142, 234], [214, 112, 214], [41, 184, 219], [255, 255, 255], ]; BASE[i as usize] } 16..=231 => { let n = i - 16; let steps = [0u8, 95, 135, 175, 215, 255]; [ steps[(n / 36) as usize], steps[((n % 36) / 6) as usize], steps[(n % 6) as usize], ] } 232..=255 => { let v = 8 + (i - 232) * 10; [v, v, v] } } } fn buffer_to_png(buf: &Buffer, path: &std::path::Path) -> Result<(), String> { use ratatui::style::Modifier; let font = fontdue::Font::from_bytes(FONT_BYTES, fontdue::FontSettings::default()) .map_err(|e| e.to_string())?; let area = buf.area; let img_w = area.width as u32 * CELL_W; let img_h = area.height as u32 * CELL_H; let mut img = image::RgbImage::new(img_w, img_h); for row in 0..area.height { let mut skip = 0u16; for col in 0..area.width { if skip > 0 { skip -= 1; continue; } let cell = &buf.content[(row * area.width + col) as usize]; let sym = cell.symbol(); let cw = unicode_width::UnicodeWidthStr::width(sym).max(1); if cw > 1 { skip = (cw as u16) - 1; } let (mut fg, mut bg) = ( color_rgb(cell.fg, true), color_rgb(cell.bg, false), ); if cell.modifier.contains(Modifier::REVERSED) { std::mem::swap(&mut fg, &mut bg); } if cell.modifier.contains(Modifier::DIM) { fg = [fg[0] / 2, fg[1] / 2, fg[2] / 2]; } let x0 = col as u32 * CELL_W; let y0 = row as u32 * CELL_H; let cell_px_w = CELL_W * cw as u32; // Bakgrund for y in y0..(y0 + CELL_H).min(img_h) { for x in x0..(x0 + cell_px_w).min(img_w) { img.put_pixel(x, y, image::Rgb(bg)); } } // Glyf let ch = sym.chars().next().unwrap_or(' '); if ch == ' ' { continue; } if font.lookup_glyph_index(ch) != 0 { let (metrics, bitmap) = font.rasterize(ch, FONT_PX); // Baslinje ungefär 4 px över cellens botten let gx = x0 as i64 + metrics.xmin as i64 + ((cell_px_w as i64 - metrics.width as i64) / 2).max(0); let gy = y0 as i64 + (CELL_H as i64 - 4) - metrics.height as i64 - metrics.ymin as i64; for by in 0..metrics.height { for bx in 0..metrics.width { let a = bitmap[by * metrics.width + bx] as u32; if a == 0 { continue; } let px = gx + bx as i64; let py = gy + by as i64; if px < 0 || py < 0 || px as u32 >= img_w || py as u32 >= img_h { continue; } let old = img.get_pixel(px as u32, py as u32).0; let blend = |o: u8, f: u8| { ((o as u32 * (255 - a) + f as u32 * a) / 255) as u8 }; img.put_pixel( px as u32, py as u32, image::Rgb([ blend(old[0], fg[0]), blend(old[1], fg[1]), blend(old[2], fg[2]), ]), ); } } } else { // Glyf saknas (t.ex. emoji): fylld ruta med marginal for y in (y0 + 3)..(y0 + CELL_H - 3).min(img_h) { for x in (x0 + 2)..(x0 + cell_px_w - 2).min(img_w) { img.put_pixel(x, y, image::Rgb(fg)); } } } } } img.save(path).map_err(|e| e.to_string()) }