diff --git a/Cargo.lock b/Cargo.lock index f8d8c86..d55cf3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -451,12 +451,28 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fontdue" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b" +dependencies = [ + "hashbrown 0.15.5", + "ttf-parser", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -524,6 +540,17 @@ dependencies = [ "weezl", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -532,7 +559,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -543,7 +570,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1716,12 +1743,19 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + [[package]] name = "tui-wm" version = "0.1.0" dependencies = [ "anyhow", "crossterm", + "fontdue", "image", "interprocess", "libc", diff --git a/Cargo.toml b/Cargo.toml index 8ac964d..9c36a25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ anyhow = "1" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "bmp", "webp"] } interprocess = "2.4" unicode-width = "0.2" +fontdue = "0.9" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/assets/DejaVuSansMono.ttf b/assets/DejaVuSansMono.ttf new file mode 100644 index 0000000..bcf640e Binary files /dev/null and b/assets/DejaVuSansMono.ttf differ diff --git a/assets/default-config.toml b/assets/default-config.toml index 45f5340..1f22c47 100644 --- a/assets/default-config.toml +++ b/assets/default-config.toml @@ -24,6 +24,12 @@ default_window_size = [82, 26] # Bakgrundsfärg för virtuella terminaler ("#RRGGBB", "0"-"255" eller färgnamn) # terminal_bg_color = "#1a1a2e" + +# Skärmbilder: katalog (relativ = under konfig-katalogen) och format +# ("text", "png" eller "both"). +# screenshot_dir = "skärmbilder" +# screenshot_format = "both" + [[panel]] position = "top" file = "panels/topbar.toml" @@ -88,6 +94,13 @@ key = "alt+up" scope = "global" action = { type = "maximize" } +# Skärmbild av arbetsytan (sparas som text + PNG i skärmbilder/) +[[keybind]] +key = "alt+p" +scope = "global" +label = "Skärmbild" +action = { type = "screenshot" } + [[keybind]] key = "alt+c" scope = "global" diff --git a/config.toml b/config.toml index 67a4777..74985f0 100644 --- a/config.toml +++ b/config.toml @@ -22,6 +22,12 @@ background_image = "/home/brasse/Bilder/backrunder/pexels-veeterzy-38136.jpg" # Standardstorlek [bredd, höjd] för nya terminalfönster (default: [82, 26]) # default_window_size = [82, 26] + +# Skärmbilder: katalog (relativ = under konfig-katalogen) och format +# ("text", "png" eller "both"). +# screenshot_dir = "skärmbilder" +# screenshot_format = "both" + [[panel]] position = "top" file = "panels/topbar.toml" @@ -94,6 +100,13 @@ key = "alt+up" scope = "global" action = { type = "maximize" } +# Skärmbild av arbetsytan (sparas som text + PNG i skärmbilder/) +[[keybind]] +key = "alt+p" +scope = "global" +label = "Skärmbild" +action = { type = "screenshot" } + # Copy-mode: bläddra/söka i fokuserat fönsters historik med tangentbordet # (↑↓/PgUp/PgDn bläddrar, / söker, n/N hoppar mellan träffar, q stänger) [[keybind]] diff --git a/dist/linux/config.toml b/dist/linux/config.toml index 92c59c8..204fd5d 100644 --- a/dist/linux/config.toml +++ b/dist/linux/config.toml @@ -88,6 +88,13 @@ key = "alt+up" scope = "global" action = { type = "maximize" } +# Skärmbild av arbetsytan (sparas som text + PNG i skärmbilder/) +[[keybind]] +key = "alt+p" +scope = "global" +label = "Skärmbild" +action = { type = "screenshot" } + [[keybind]] key = "alt+c" scope = "global" diff --git a/dist/win/config.toml b/dist/win/config.toml index 3f2166e..04c25e1 100644 --- a/dist/win/config.toml +++ b/dist/win/config.toml @@ -88,6 +88,13 @@ key = "alt+up" scope = "global" action = { type = "maximize" } +# Skärmbild av arbetsytan (sparas som text + PNG i skärmbilder/) +[[keybind]] +key = "alt+p" +scope = "global" +label = "Skärmbild" +action = { type = "screenshot" } + [[keybind]] key = "alt+c" scope = "global" diff --git a/src/app.rs b/src/app.rs index 2598369..ad76162 100644 --- a/src/app.rs +++ b/src/app.rs @@ -111,6 +111,9 @@ pub struct App { last_title_click: Option<(usize, std::time::Instant)>, /// Copy-mode: bläddra/söka i fokuserat fönsters historik med tangentbordet pub copy_mode: Option, + /// Senaste layout-arean (hela skärmen inkl. paneler) — används av + /// skärmbilden för att rendera i rätt storlek. + pub last_area: Rect, /// Begär full omritning av värdterminalen (rensar spök-tecken som /// vissa terminaler lämnar kvar när breda glyfer delvis skrivs över) pub needs_full_redraw: bool, @@ -320,6 +323,8 @@ pub enum WindowContent { editing: Option, /// Associationsvyn är öppen: markerat index i listan assoc: Option, + /// Tangentvyn är öppen: (markerat index, fångar ny tangent) + keys: Option<(usize, bool)>, }, /// "Öppna med"-dialog: fråga vilken app som ska öppna en fil vars /// format saknar association. @@ -573,6 +578,7 @@ impl App { last_title_click: None, copy_mode: None, needs_full_redraw: false, + last_area: Rect::default(), watch_sig, last_watch_check: None, hovered_tui_btn: false, @@ -582,6 +588,7 @@ impl App { } pub fn update_layout(&mut self, area: Rect) { + self.last_area = area; self.panel_rects.clear(); self.panel_item_rects.clear(); self.tui_wm_btn_rects.clear(); @@ -1354,7 +1361,7 @@ impl App { self.focus_window(id); // rad 1..=4 i innehållet motsvarar inställningsrad 0..=3 let rel = row.saturating_sub(cr.y); - if (1..=6).contains(&rel) { + if (1..=7).contains(&rel) { let target = (rel - 1) as usize; let mut activate = false; if let Some(WindowContent::Settings { selected, .. }) = self.settings_content(id) { @@ -1526,6 +1533,7 @@ impl App { MenuAction::WindowSwitcher => self.switcher_open_or_advance(), MenuAction::CopyMode => self.enter_copy_mode(), MenuAction::SpawnSettings => self.spawn_settings(), + MenuAction::Screenshot => self.take_screenshot(), MenuAction::SnapLeft => self.snap_focused(true), MenuAction::SnapRight => self.snap_focused(false), MenuAction::Maximize => self.toggle_maximize_focused(), @@ -1859,6 +1867,56 @@ impl App { } } + /// Ta en skärmbild av arbetsytan och spara enligt konfigurationen. + pub fn take_screenshot(&mut self) { + let area = self.last_area; + if area.width == 0 || area.height == 0 { + return; + } + let mut tt = match ratatui::Terminal::new( + ratatui::backend::TestBackend::new(area.width, area.height), + ) { + Ok(t) => t, + Err(_) => return, + }; + if tt.draw(|f| crate::render::render(f, self)).is_err() { + return; + } + let dir = self + .config + .screenshot_dir + .clone() + .unwrap_or_else(|| "skärmbilder".to_string()); + let stamp = crate::log::timestamp().replace(' ', "_").replace(':', ""); + let fmt = self.config.screenshot_format.clone(); + match crate::screenshot::save(tt.backend().buffer(), &dir, &fmt, &stamp) { + Ok(s) => { + let mut parts = Vec::new(); + if let Some(p) = &s.text_path { + parts.push(p.display().to_string()); + } + if let Some(p) = &s.png_path { + parts.push(p.display().to_string()); + } + crate::log::log(&format!("skärmbild sparad: {}", parts.join(", "))); + self.spawn_popup_dialog( + format!("Skärmbild sparad:\n{}", parts.join("\n")), + vec!["OK".to_string()], + 0, + None, + ); + } + Err(e) => { + self.spawn_popup_dialog( + format!("Skärmbild misslyckades: {}", e), + vec!["OK".to_string()], + 0, + None, + ); + } + } + } + /// Öppna inställningsdialogen (eller fokusera en redan öppen). fn spawn_settings(&mut self) { if let Some(w) = self.windows.iter().find(|w| matches!(w.content, WindowContent::Settings { .. })) { @@ -1879,7 +1937,7 @@ impl App { y, width: w, height: h, - content: WindowContent::Settings { selected: 0, editing: None, assoc: None }, + content: WindowContent::Settings { selected: 0, editing: None, assoc: None, keys: None }, dragging: None, resizing: None, resizable: false, @@ -1897,7 +1955,72 @@ impl App { } fn handle_settings_key(&mut self, id: usize, key: KeyEvent) { - const ROWS: usize = 6; + const ROWS: usize = 7; + // Tangentvyn är öppen? + let keys_state = match self.settings_content(id) { + Some(WindowContent::Settings { keys, .. }) => *keys, + _ => None, + }; + if let Some((sel, capturing)) = keys_state { + let len = self.config.keybinds.len(); + if capturing { + // Nästa tangenttryck blir den nya genvägen (Esc avbryter) + if key.code == KeyCode::Esc { + if let Some(WindowContent::Settings { keys, .. }) = self.settings_content(id) { + *keys = Some((sel, false)); + } + return; + } + if let Some(chord) = format_key_chord(key) { + if let Some(kb) = self.config.keybinds.get_mut(sel) { + kb.key = chord; + } + self.parsed_keybinds = Self::build_keybinds(&self.config); + let _ = Config::save_keybinds("config.toml", &self.config.keybinds); + if let Some(WindowContent::Settings { keys, .. }) = self.settings_content(id) { + *keys = Some((sel, false)); + } + } + return; + } + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + if let Some(WindowContent::Settings { keys, .. }) = self.settings_content(id) { + *keys = None; + } + } + KeyCode::Up => { + if let Some(WindowContent::Settings { keys, .. }) = self.settings_content(id) { + *keys = Some((sel.saturating_sub(1), false)); + } + } + KeyCode::Down => { + if let Some(WindowContent::Settings { keys, .. }) = self.settings_content(id) { + *keys = Some(((sel + 1).min(len.saturating_sub(1)), false)); + } + } + KeyCode::Enter => { + if len > 0 { + if let Some(WindowContent::Settings { keys, .. }) = self.settings_content(id) { + *keys = Some((sel, true)); + } + } + } + KeyCode::Delete | KeyCode::Backspace => { + if sel < len { + self.config.keybinds.remove(sel); + self.parsed_keybinds = Self::build_keybinds(&self.config); + let _ = Config::save_keybinds("config.toml", &self.config.keybinds); + let new_len = self.config.keybinds.len(); + if let Some(WindowContent::Settings { keys, .. }) = self.settings_content(id) { + *keys = Some((sel.min(new_len.saturating_sub(1)), false)); + } + } + } + _ => {} + } + return; + } // Associationsvyn är öppen? let assoc_open = matches!( self.settings_content(id), @@ -2026,6 +2149,13 @@ impl App { } return; } + if row == 6 { + // Tangenter: öppna keybind-vyn + if let Some(WindowContent::Settings { keys, .. }) = self.settings_content(id) { + *keys = Some((0, false)); + } + return; + } match row { 0 => { // Tema: cykla inbyggt + themes/*.toml @@ -2626,6 +2756,41 @@ pub fn build_open_command(cmd: &str, path: &str) -> String { } } +/// Formatera ett tangenttryck som en keybind-sträng ("alt+w", "ctrl+f2"...). +/// Returnerar None för tangenter som inte kan bindas. +fn format_key_chord(key: KeyEvent) -> Option { + let mut s = String::new(); + if key.modifiers.contains(KeyModifiers::CONTROL) { + s.push_str("ctrl+"); + } + if key.modifiers.contains(KeyModifiers::ALT) { + s.push_str("alt+"); + } + if key.modifiers.contains(KeyModifiers::SHIFT) { + s.push_str("shift+"); + } + let name = match key.code { + KeyCode::Char(' ') => "space".to_string(), + KeyCode::Char(c) => c.to_lowercase().to_string(), + KeyCode::Enter => "enter".to_string(), + KeyCode::Tab | KeyCode::BackTab => "tab".to_string(), + KeyCode::Backspace => "backspace".to_string(), + KeyCode::Delete => "delete".to_string(), + KeyCode::Up => "up".to_string(), + KeyCode::Down => "down".to_string(), + KeyCode::Left => "left".to_string(), + KeyCode::Right => "right".to_string(), + KeyCode::Home => "home".to_string(), + KeyCode::End => "end".to_string(), + KeyCode::PageUp => "pageup".to_string(), + KeyCode::PageDown => "pagedown".to_string(), + KeyCode::F(n) => format!("f{}", n), + _ => return None, + }; + s.push_str(&name); + Some(s) +} + fn default_shell() -> String { if cfg!(windows) { // Försök hitta PowerShell i prioritetsordning: pwsh (Core) → powershell (inbyggd) @@ -2664,6 +2829,11 @@ fn run_command(command: &str) -> String { } } +/// Publik variant för render-lagret. +pub fn action_display_pub(action: &MenuAction) -> &'static str { + action_display(action) +} + /// Returnerar ett kortfattat namn för en MenuAction (används som fallback i keybind-menyn). fn action_display(action: &MenuAction) -> &'static str { match action { @@ -2676,6 +2846,7 @@ fn action_display(action: &MenuAction) -> &'static str { MenuAction::WindowSwitcher => "Fönsterväxlare", MenuAction::CopyMode => "Copy-mode", MenuAction::SpawnSettings => "Inställningar", + MenuAction::Screenshot => "Skärmbild", MenuAction::SnapLeft => "Fäst vänster", MenuAction::SnapRight => "Fäst höger", MenuAction::Maximize => "Maximera/återställ", diff --git a/src/config.rs b/src/config.rs index a0a0465..8a742b2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::fs; #[derive(Deserialize, Debug, Clone, Default)] @@ -23,6 +23,12 @@ pub struct Config { /// Filassociationer: ändelse (gemener) → kommando för open_file. #[serde(default)] pub open_with: std::collections::HashMap, + /// Katalog för skärmbilder (relativ = under konfig-katalogen). + #[serde(default)] + pub screenshot_dir: Option, + /// Skärmbildsformat: "text", "png" eller "both". + #[serde(default = "default_screenshot_format")] + pub screenshot_format: String, /// Rita skugga under/till höger om fönster. #[serde(default = "default_true")] pub shadow: bool, @@ -53,8 +59,12 @@ fn default_window_size() -> [u16; 2] { [82, 26] } +fn default_screenshot_format() -> String { + "both".to_string() +} + /// En tangentbords-genväg definierad i config -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct KeybindConfig { /// T.ex. "ctrl+space", "alt+f2", "ctrl+t" pub key: String, @@ -63,11 +73,11 @@ pub struct KeybindConfig { pub scope: KeybindScope, pub action: MenuAction, /// Visningsnamn som visas i TUI-WM-menyn (valfritt) - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub label: Option, } -#[derive(Deserialize, Debug, Clone, Default, PartialEq)] +#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq)] #[serde(rename_all = "lowercase")] pub enum KeybindScope { /// Aktiveras alltid, oavsett fokus @@ -95,17 +105,18 @@ pub enum PanelPosition { Bottom, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct MenuItem { pub label: String, pub action: MenuAction, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[serde(tag = "type", rename_all = "snake_case")] pub enum MenuAction { Exit, SpawnTerminal { + #[serde(skip_serializing_if = "Option::is_none")] shell: Option, }, RunScript { @@ -134,6 +145,8 @@ pub enum MenuAction { CopyMode, /// Öppnar den inbyggda inställningsdialogen SpawnSettings, + /// Tar en skärmbild av arbetsytan (text och/eller PNG) + Screenshot, /// Gör ingenting – används för display-only rader i menyer NoOp, } @@ -227,6 +240,12 @@ fn default_keybinds() -> Vec { action: MenuAction::Maximize, label: None, }, + KeybindConfig { + key: "alt+p".to_string(), + scope: KeybindScope::Global, + action: MenuAction::Screenshot, + label: Some("Skärmbild".to_string()), + }, KeybindConfig { key: "alt+c".to_string(), scope: KeybindScope::Global, @@ -289,6 +308,8 @@ impl Config { theme: None, default_editor: None, open_with: std::collections::HashMap::new(), + screenshot_dir: None, + screenshot_format: default_screenshot_format(), shadow: true, scrollback_lines: default_scrollback_lines(), show_scrollbar: default_true(), @@ -325,6 +346,21 @@ impl Config { Ok(()) } + /// Skriv om hela [[keybind]]-listan i config-filen. + pub fn save_keybinds(path: &str, binds: &[KeybindConfig]) -> anyhow::Result<()> { + #[derive(Serialize)] + struct Wrapper<'a> { + keybind: &'a [KeybindConfig], + } + let toml_str = toml::to_string(&Wrapper { keybind: binds })?; + let tmp: toml_edit::DocumentMut = toml_str.parse()?; + let content = fs::read_to_string(path).unwrap_or_default(); + let mut doc: toml_edit::DocumentMut = content.parse().unwrap_or_default(); + doc["keybind"] = tmp["keybind"].clone(); + fs::write(path, doc.to_string())?; + Ok(()) + } + /// Spara en filassociation ([open_with] ändelse → kommando). pub fn save_open_with(path: &str, ext: &str, cmd: &str) -> anyhow::Result<()> { let content = fs::read_to_string(path).unwrap_or_default(); diff --git a/src/log.rs b/src/log.rs index 651f097..9a0a721 100644 --- a/src/log.rs +++ b/src/log.rs @@ -65,7 +65,7 @@ fn today_filename() -> String { format!("{:04}-{:02}-{:02}.log", y, mo, d) } -fn timestamp() -> String { +pub fn timestamp() -> String { let secs = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .map(|d| d.as_secs()) diff --git a/src/main.rs b/src/main.rs index a2d96be..16bdc37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod kitty_gfx; mod log; mod pty; mod render; +mod screenshot; mod server; mod theme; diff --git a/src/render.rs b/src/render.rs index ca172da..39dc3c5 100644 --- a/src/render.rs +++ b/src/render.rs @@ -390,8 +390,8 @@ fn render_window(frame: &mut Frame, window: &FloatingWindow, app: &App) { WindowContent::PopupDialog { .. } => { render_popup_dialog(frame, window, focused_id, th); } - WindowContent::Settings { selected, editing, assoc } => { - render_settings_content(frame, cr, *selected, editing.as_deref(), *assoc, app, th); + WindowContent::Settings { selected, editing, assoc, keys } => { + render_settings_content(frame, cr, *selected, editing.as_deref(), *assoc, *keys, app, th); } WindowContent::OpenWith { path, input, save_default } => { render_open_with_content(frame, cr, path, input, *save_default, th); @@ -517,15 +517,71 @@ fn highlight_matches(frame: &mut Frame, rect: Rect, query: &str, style: Style) { } /// Innehållet i inställningsdialogen: interaktiva rader + info-sektion. +#[allow(clippy::too_many_arguments)] fn render_settings_content( frame: &mut Frame, area: Rect, selected: usize, editing: Option<&str>, assoc: Option, + keys: Option<(usize, bool)>, app: &App, th: &crate::theme::Theme, ) { + // Tangentvyn: lista keybinds, ändra med Enter (fånga nästa tryck) + if let Some((sel, capturing)) = keys { + let binds = &app.config.keybinds; + let mut y = area.y; + let list_bottom = area.y + area.height.saturating_sub(2); + let visible = (list_bottom.saturating_sub(y)) as usize; + // enkel scrollning: håll markeringen i fönstret + let offset = sel.saturating_sub(visible.saturating_sub(1)); + for (i, kb) in binds.iter().enumerate().skip(offset) { + if y >= list_bottom { + frame.render_widget( + Paragraph::new(Span::styled(" …fler", Style::default().fg(th.hint))), + Rect::new(area.x, y, area.width, 1), + ); + break; + } + let label = kb + .label + .clone() + .unwrap_or_else(|| crate::app::action_display_pub(&kb.action).to_string()); + let scope = match kb.scope { + crate::config::KeybindScope::Global => "global", + crate::config::KeybindScope::Wm => "wm", + }; + let keytxt = if capturing && i == sel { + "".to_string() + } else { + kb.key.clone() + }; + let style = if i == sel { + th.hover_highlight() + } else { + Style::default().fg(th.text) + }; + let line = format!(" {:<16} {:<28} [{}]", keytxt, label, scope); + frame.render_widget( + Paragraph::new(Span::styled( + format!("{: = app.config.open_with.keys().collect(); @@ -610,6 +666,10 @@ fn render_settings_content( "Filformat".to_string(), format!("{} associationer (Enter=visa/ta bort)", app.config.open_with.len()), ), + ( + "Tangenter".to_string(), + format!("{} bindningar (Enter=visa/ändra)", app.config.keybinds.len()), + ), ]; let mut y = area.y + 1; diff --git a/src/screenshot.rs b/src/screenshot.rs new file mode 100644 index 0000000..a698c4c --- /dev/null +++ b/src/screenshot.rs @@ -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, + 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()) +} diff --git a/tests/integration.py b/tests/integration.py index a562979..6ffed4a 100644 --- a/tests/integration.py +++ b/tests/integration.py @@ -461,6 +461,53 @@ def main(): disp.send(key_event(code="Esc")) time.sleep(0.3) + # ── 4k. Skärmbild via alt+p ────────────────────────────────────── + disp.send(key_event(ch="p", modifiers="ALT")) + text = disp.frame_text(timeout=8.0, contains="Skärmbild sparad") + check("alt+p tar skärmbild och visar bekräftelse", "Skärmbild sparad" in text) + disp.send(key_event(code="Enter")) # stäng popupen + time.sleep(0.3) + shots = sorted(os.listdir(os.path.join(cfgdir, "skärmbilder"))) + txts = [s for s in shots if s.endswith(".txt")] + pngs = [s for s in shots if s.endswith(".png")] + check("skärmbild sparar .txt och .png", bool(txts) and bool(pngs), + f"filer: {shots}") + if txts: + t = open(os.path.join(cfgdir, "skärmbilder", txts[0])).read() + check("textskärmbilden innehåller panelen", "TUI-WM" in t) + if pngs: + magic = open(os.path.join(cfgdir, "skärmbilder", pngs[0]), "rb").read(8) + check("PNG-skärmbilden har giltig header", + magic == b"\x89PNG\r\n\x1a\n", f"magic: {magic!r}") + + # ── 4l. Tangent-fliken i inställningarna ───────────────────────── + disp.send(mouse_event({"Down": "Left"}, gx, 0)) + disp.send(mouse_event({"Up": "Left"}, gx, 0)) + disp.frame_text(timeout=5.0, contains="Tangenter") + for _ in range(6): + disp.send(key_event(code="Down")) + disp.send(key_event(code="Enter")) + text = disp.frame_text(timeout=5.0, contains="Fönsterväxlare") + check("tangentvyn listar keybinds", + "alt+w" in text and "Fönsterväxlare" in text) + # byt tangent på första raden (ctrl+space → alt+z) + disp.send(key_event(code="Enter")) + disp.frame_text(timeout=5.0, contains="tryck ny tangent") + disp.send(key_event(ch="z", modifiers="ALT")) + time.sleep(0.5) + cfg_now = open(os.path.join(cfgdir, "config.toml")).read() + check("ombindning skrivs till config.toml", '"alt+z"' in cfg_now, + f"keybind-rader: {[l for l in cfg_now.splitlines() if 'alt+z' in l]!r}") + disp.send(key_event(code="Esc")) + disp.send(key_event(code="Esc")) + time.sleep(0.3) + # alt+z ska nu öppna kommandodialogen (var ctrl+space) + disp.send(key_event(ch="z", modifiers="ALT")) + text = disp.frame_text(timeout=5.0, contains="Tui-run") + check("den nya bindningen fungerar direkt", "Tui-run" in text) + disp.send(key_event(code="Esc")) + time.sleep(0.3) + # ── 5. Popup + popup_result via Enter ─────────────────────────── app.send({ "type": "spawn_popup", "message": "Integrationstest?",