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>
This commit is contained in:
2026-07-16 00:30:56 +02:00
parent 3db0c82e03
commit 9d637a9378
14 changed files with 625 additions and 14 deletions

221
src/screenshot.rs Normal file
View File

@@ -0,0 +1,221 @@
//! 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())
}