Files
TUI-WM/src/screenshot.rs
Bjorn Blomberg 9d637a9378 Screenshot feature (text + PNG) and keybind editor tab in settings
- 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>
2026-07-16 00:30:56 +02:00

222 lines
8.0 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<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())
}