3 Commits

Author SHA1 Message Date
d6a69e78ef Document the ghost-glyph issue in docs/KNOWN-ISSUES.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:53:43 +02:00
87ff0e1b77 Wide-glyph damage expansion, screenshot dir in settings, install command on releases
- SafeBackend now tracks wide cells and explicitly overwrites the
  right half when a wide glyph is replaced by a narrow one — the case
  ratatui's diff cannot see (both buffers hold a space there) and the
  likely remaining source of ghost glyphs
- Settings dialog: new 'Skärmbilder' row edits screenshot_dir (saved
  to config.toml, applied immediately)
- Release workflow writes an install one-liner (arch-detecting curl)
  into the release body and updates it on every build
- Integration suite: 50/50

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:52:26 +02:00
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
17 changed files with 795 additions and 43 deletions

View File

@@ -51,12 +51,30 @@ jobs:
run: |
set -e
TAG="latest"
BODY="Rullande bygge från senaste master. Commit: ${{ github.sha }}. Arkitekturer: linux x64 + arm64 (Pi5)."
BASE="https://gitea.brasse-pc.eu/$REPO/releases/download/$TAG"
BODY=$(cat <<EOF
Rullande bygge från senaste master. Commit: ${{ github.sha }}. Arkitekturer: linux x64 + arm64 (Pi5).
## Installera / uppdatera
\`\`\`bash
mkdir -p ~/.local/bin && curl -fsSL "$BASE/tui-wm-linux-\$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')" -o ~/.local/bin/tui-wm && chmod +x ~/.local/bin/tui-wm && ~/.local/bin/tui-wm --init
\`\`\`
Kör sedan \`tui-wm\` (se till att \`~/.local/bin\` finns i \$PATH).
\`--init\` skriver standardkonfiguration till ~/.config/tui-wm — hoppa över den vid uppdatering.
EOF
)
rid=$(curl -s -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"$BODY\"}" | jq -r '.id // empty')
[ -z "$rid" ] && rid=$(curl -s "$API/repos/$REPO/releases/tags/$TAG" \
-H "Authorization: token $TOKEN" | jq -r '.id')
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" '{tag_name:$tag,name:$tag,body:$body}')" | jq -r '.id // empty')
if [ -z "$rid" ]; then
rid=$(curl -s "$API/repos/$REPO/releases/tags/$TAG" \
-H "Authorization: token $TOKEN" | jq -r '.id')
curl -s -X PATCH "$API/repos/$REPO/releases/$rid" \
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "$(jq -n --arg body "$BODY" '{body:$body}')" -o /dev/null
fi
echo "release id: $rid"
for f in tui-wm-linux-x64 tui-wm-linux-arm64 tui-wm-config.tar.gz checksums.txt; do
aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" \

38
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

BIN
assets/DejaVuSansMono.ttf Normal file

Binary file not shown.

View File

@@ -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"

View File

@@ -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]]

View File

@@ -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"

View File

@@ -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"

45
docs/KNOWN-ISSUES.md Normal file
View File

@@ -0,0 +1,45 @@
# Kända problem
## Spöktecken i vissa terminaler vid fönsterflytt
**Status:** öppen — behöver reproduktionsinfo från värdterminalen.
**Symptom:** när ett fönster (t.ex. TUI-FM) dras runt kan enstaka ikoner
och bokstäver "fastna" på skärmen — ofta från vänsterspalten i TUI-FM —
tills en annan ruta dras över dem.
**Analys hittills:**
- Kompositorn är verifierat korrekt: headless-frames (daemon-läget) är
pixelperfekta efter godtyckliga drag — varje rad exakt rätt bredd,
inga dubbletter (`tests/integration.py` + manuella tmux-tester).
- I tmux (strikt referensterminal) uppstår inga spöken, varken under
eller efter drag.
- Rotorsaksklass: terminalen/fonten och `unicode-width` är oense om
glyfbredder (typiskt emoji med tvetydig East-Asian-Width, t.ex. ⭐ ),
eller terminalens hantering av delvis överskrivna breda glyfer.
**Åtgärdade delar (som inte räckt fullt ut enligt rapport):**
1. Full omritning vid drag-släpp/stängning/snap (`needs_full_redraw`).
2. `SafeBackend`: explicit markörpositionering (`ESC[y;xH`) för varje
cell som skrivs — breddoenighet kan inte längre ackumuleras inom en
rad.
3. Skade-expansion för breda glyfer: när ett brett tecken ersätts av
ett smalt skrivs högercellen över explicit (ratatui-diffen ser den
inte eftersom båda buffertarna har space där).
**För att komma vidare behövs:**
- Terminalemulator + version (Konsole? Kitty? Alacritty? ...) och font.
- `echo $TERM` i terminalen där felet syns.
- En PNG-skärmbild via TUI-WM:s inbyggda `alt+p` när spöket syns
(visar kompositorns bild) **plus** en riktig skärmdump av terminalen
(visar vad terminalen faktiskt ritar) — skiljer de sig är felet
bekräftat terminal-sidigt.
- Test i en annan terminal (t.ex. kitty eller wezterm) för jämförelse.
**Möjliga nästa steg:** rendera om hela raden (inte bara cellen) när en
rad innehåller tecken med tvetydig bredd; alternativt heltäckande
frame-omskrivning med synkroniserade uppdateringar (DEC 2026) som i
klient-läget, där problemet inte kan uppstå.

View File

@@ -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<CopyMode>,
/// 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<String>,
/// Associationsvyn är öppen: markerat index i listan
assoc: Option<usize>,
/// 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..=8).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 { .. })) {
@@ -1868,7 +1926,7 @@ impl App {
}
let ca = self.content_area;
let w = 62u16.min(ca.width.saturating_sub(2));
let h = 13u16.min(ca.height.saturating_sub(2));
let h = 17u16.min(ca.height.saturating_sub(2));
let x = ca.x as i32 + ((ca.width.saturating_sub(w)) / 2) as i32;
let y = ca.y as i32 + ((ca.height.saturating_sub(h)) / 2) as i32;
let id = self.next_id;
@@ -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 = 8;
// 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),
@@ -1945,13 +2068,27 @@ impl App {
KeyCode::Enter => {
let buf = editing.take().unwrap_or_default();
let val = buf.trim().to_string();
self.config.default_editor =
if val.is_empty() { None } else { Some(val) };
let _ = Config::save_top_level_str(
"config.toml",
"default_editor",
self.config.default_editor.as_deref(),
);
let row = match self.settings_content(id) {
Some(WindowContent::Settings { selected, .. }) => *selected,
_ => 2,
};
if row == 3 {
self.config.screenshot_dir =
if val.is_empty() { None } else { Some(val) };
let _ = Config::save_top_level_str(
"config.toml",
"screenshot_dir",
self.config.screenshot_dir.as_deref(),
);
} else {
self.config.default_editor =
if val.is_empty() { None } else { Some(val) };
let _ = Config::save_top_level_str(
"config.toml",
"default_editor",
self.config.default_editor.as_deref(),
);
}
}
KeyCode::Esc => {
*editing = None;
@@ -2012,6 +2149,7 @@ impl App {
/// direkt, men tema/flaggor appliceras även omedelbart i minnet.
fn settings_activate(&mut self, id: usize, forward: bool) {
let current_editor = self.config.default_editor.clone().unwrap_or_default();
let current_shotdir = self.config.screenshot_dir.clone().unwrap_or_default();
let Some(WindowContent::Settings { selected, editing, .. }) = self.settings_content(id) else { return };
let row = *selected;
if row == 2 {
@@ -2019,13 +2157,25 @@ impl App {
*editing = Some(current_editor);
return;
}
if row == 5 {
if row == 3 {
// Skärmbildskatalog: börja textredigera värdet
*editing = Some(current_shotdir);
return;
}
if row == 6 {
// Associationer: öppna listvyn
if let Some(WindowContent::Settings { assoc, .. }) = self.settings_content(id) {
*assoc = Some(0);
}
return;
}
if row == 7 {
// 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
@@ -2060,11 +2210,11 @@ impl App {
let _ = Config::save_panel_file("config.toml", 0, &options[next]);
// panel-innehållet läses om av hot-reloaden
}
3 => {
4 => {
self.config.shadow = !self.config.shadow;
let _ = Config::save_top_level_bool("config.toml", "shadow", self.config.shadow);
}
4 => {
5 => {
self.config.show_scrollbar = !self.config.show_scrollbar;
let _ = Config::save_top_level_bool("config.toml", "show_scrollbar", self.config.show_scrollbar);
}
@@ -2626,6 +2776,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<String> {
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 +2849,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 +2866,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",

View File

@@ -21,11 +21,20 @@ use std::io::{self, Write};
pub struct SafeBackend<W: Write> {
writer: W,
/// Positioner där vi senast skrev ett brett (2-kolumns) tecken.
/// Behövs för att expandera ratatuis diff: när ett brett tecken
/// ersätts av ett smalt innehåller diffen bara vänstercellen —
/// högercellen är "space" i båda ratatui-buffertarna och skrivs
/// aldrig om, så terminalen kan lämna kvar glyfens högra halva.
wide_cells: std::collections::HashSet<(u16, u16)>,
}
impl<W: Write> SafeBackend<W> {
pub fn new(writer: W) -> Self {
SafeBackend { writer }
SafeBackend {
writer,
wide_cells: std::collections::HashSet::new(),
}
}
}
@@ -69,11 +78,15 @@ impl<W: Write> Backend for SafeBackend<W> {
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
use std::fmt::Write as _;
let cells: Vec<(u16, u16, &Cell)> = content.collect();
let in_diff: std::collections::HashSet<(u16, u16)> =
cells.iter().map(|(x, y, _)| (*x, *y)).collect();
let mut buf = String::new();
let mut last_fg = None;
let mut last_bg = None;
let mut last_mod = None;
for (x, y, cell) in content {
for (x, y, cell) in cells {
// Explicit position för varje cell — se modulkommentaren.
let _ = write!(buf, "\x1b[{};{}H", y + 1, x + 1);
if last_mod != Some(cell.modifier) {
@@ -91,6 +104,23 @@ impl<W: Write> Backend for SafeBackend<W> {
last_bg = Some(cell.bg);
}
buf.push_str(cell.symbol());
let width = unicode_width::UnicodeWidthStr::width(cell.symbol());
let was_wide = self.wide_cells.contains(&(x, y));
if width > 1 {
self.wide_cells.insert((x, y));
} else {
self.wide_cells.remove(&(x, y));
// Skade-expansion: ett brett tecken ersattes av ett smalt.
// Ratatui-diffen innehåller inte högercellen (space i båda
// buffertarna) men terminalen kan ha kvar glyfens högra
// halva där — skriv explicit över den med ett space i den
// nya cellens bakgrundsfärg.
if was_wide && !in_diff.contains(&(x + 1, y)) {
let _ = write!(buf, "\x1b[{};{}H ", y + 1, x + 2);
self.wide_cells.remove(&(x + 1, y));
}
}
}
buf.push_str("\x1b[0m");
self.writer.write_all(buf.as_bytes())
@@ -117,6 +147,7 @@ impl<W: Write> Backend for SafeBackend<W> {
}
fn clear(&mut self) -> io::Result<()> {
self.wide_cells.clear();
self.writer.write_all(b"\x1b[2J\x1b[H")
}

View File

@@ -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<String, String>,
/// Katalog för skärmbilder (relativ = under konfig-katalogen).
#[serde(default)]
pub screenshot_dir: Option<String>,
/// 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<String>,
}
#[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<String>,
},
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<KeybindConfig> {
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();

View File

@@ -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())

View File

@@ -7,6 +7,7 @@ mod kitty_gfx;
mod log;
mod pty;
mod render;
mod screenshot;
mod server;
mod theme;

View File

@@ -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<usize>,
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 {
"<tryck ny tangent…>".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!("{:<width$}", line, width = area.width as usize),
style,
)),
Rect::new(area.x, y, area.width, 1),
);
y += 1;
}
frame.render_widget(
Paragraph::new(Span::styled(
" ↑↓ välj Enter=byt tangent Delete=ta bort Esc=tillbaka",
Style::default().fg(th.hint),
)),
Rect::new(area.x, area.y + area.height.saturating_sub(1), area.width, 1),
);
return;
}
// Associationsvyn: lista filformat → kommandon
if let Some(sel) = assoc {
let mut exts: Vec<&String> = app.config.open_with.keys().collect();
@@ -591,25 +647,32 @@ fn render_settings_content(
.first()
.and_then(|p| p.file.as_deref())
.unwrap_or("(inline)");
let editor_val = match editing {
Some(buf) => format!("{}▏ (Enter=spara, Esc=avbryt)", buf),
None => app
.config
.default_editor
.clone()
.unwrap_or_else(|| "(ingen — Enter för att ange)".to_string()),
let edit_val = |row: usize, current: Option<String>, hint: &str| -> String {
if selected == row {
if let Some(buf) = editing {
return format!("{}▏ (Enter=spara, Esc=avbryt)", buf);
}
}
current.unwrap_or_else(|| hint.to_string())
};
let editor_val = edit_val(2, app.config.default_editor.clone(), "(ingen — Enter för att ange)");
let shotdir_val = edit_val(3, app.config.screenshot_dir.clone(), "skärmbilder (standard)");
let onoff = |b: bool| if b { "[x] på " } else { "[ ] av " };
let rows: Vec<(String, String)> = vec![
("Tema".to_string(), format!(" {} ", theme_name)),
("Panel".to_string(), format!(" {} ", panel_name)),
("Editor".to_string(), editor_val),
("Skärmbilder".to_string(), shotdir_val),
("Skugga".to_string(), onoff(app.config.shadow).to_string()),
("Scrollbar".to_string(), onoff(app.config.show_scrollbar).to_string()),
(
"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;

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

View File

@@ -396,10 +396,10 @@ def main():
text = disp.frame_text(timeout=5.0, contains="Tema:")
check("inställningsdialogen öppnas från panelen", "Tema:" in text
and "Skugga" in text and "config.toml" in text)
# Ned tre rader till Skugga (0=Tema, 1=Panel, 2=Editor, 3=Skugga)
disp.send(key_event(code="Down"))
disp.send(key_event(code="Down"))
disp.send(key_event(code="Down"))
# Ned fyra rader till Skugga
# (0=Tema, 1=Panel, 2=Editor, 3=Skärmbilder, 4=Skugga)
for _ in range(4):
disp.send(key_event(code="Down"))
disp.send(key_event(code="Enter"))
time.sleep(0.5)
cfg_now = open(os.path.join(cfgdir, "config.toml")).read()
@@ -446,7 +446,7 @@ def main():
text = disp.frame_text(timeout=5.0, contains="Filformat")
check("inställningarna visar filformat-raden", "1 associationer" in text,
f"frame: {text[:300]!r}")
for _ in range(5):
for _ in range(6):
disp.send(key_event(code="Down"))
disp.send(key_event(code="Enter"))
text = disp.frame_text(timeout=5.0, contains=".txt")
@@ -461,6 +461,77 @@ 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}")
# ── 4k2. Skärmbildskatalog via 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="Skärmbilder")
for _ in range(3):
disp.send(key_event(code="Down"))
disp.send(key_event(code="Enter"))
for ch in "bilder":
disp.send(key_event(ch=ch))
disp.send(key_event(code="Enter"))
time.sleep(0.5)
cfg_now = open(os.path.join(cfgdir, "config.toml")).read()
check("skärmbildskatalogen sparas till config",
'screenshot_dir = "bilder"' in cfg_now)
disp.send(key_event(code="Esc"))
time.sleep(0.3)
disp.send(key_event(ch="p", modifiers="ALT"))
disp.frame_text(timeout=8.0, contains="Skärmbild sparad")
disp.send(key_event(code="Enter"))
time.sleep(0.3)
check("skärmbilden hamnar i den valda katalogen",
os.path.isdir(os.path.join(cfgdir, "bilder"))
and len(os.listdir(os.path.join(cfgdir, "bilder"))) >= 1)
# ── 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(7):
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?",