- New screenshot action (default alt+p, bindable like any action): renders the current workspace to a buffer and saves it as plain text and/or PNG (screenshot_format = text|png|both, screenshot_dir configurable, default skärmbilder/ under the config dir). The PNG is rasterized cell by cell with an embedded DejaVu Sans Mono font (vendored, ~340 kB); glyphs missing from the font (color emoji) render as filled boxes. A popup confirms the saved paths - Settings dialog gains a 'Tangenter' row: lists all keybinds with label and scope, Enter captures the next keypress as the new chord (persisted by rewriting [[keybind]] via toml_edit), Delete removes a binding — changes take effect immediately - Integration suite: 48/48 (screenshot files + validity, keybind list, live rebind ctrl+space→alt+z) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
222 lines
8.0 KiB
Rust
222 lines
8.0 KiB
Rust
//! Skärmbild av arbetsytan: sparar den compositerade 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<PathBuf>,
|
||
pub png_path: Option<PathBuf>,
|
||
}
|
||
|
||
/// 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<Saved> {
|
||
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())
|
||
}
|