1 Commits

Author SHA1 Message Date
ff2c075f3e Merge modernize: modern TUI stack, cross-platform IPC, themes, scrollback, snap, copy-mode, open_file, settings app
All checks were successful
release / build-release (push) Successful in 6m13s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:24:20 +02:00
17 changed files with 43 additions and 795 deletions

View File

@@ -51,30 +51,12 @@ jobs:
run: | run: |
set -e set -e
TAG="latest" TAG="latest"
BASE="https://gitea.brasse-pc.eu/$REPO/releases/download/$TAG" BODY="Rullande bygge från senaste master. Commit: ${{ github.sha }}. Arkitekturer: linux x64 + arm64 (Pi5)."
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" \ rid=$(curl -s -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \ -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" '{tag_name:$tag,name:$tag,body:$body}')" | jq -r '.id // empty') -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"$BODY\"}" | jq -r '.id // empty')
if [ -z "$rid" ]; then [ -z "$rid" ] && rid=$(curl -s "$API/repos/$REPO/releases/tags/$TAG" \
rid=$(curl -s "$API/repos/$REPO/releases/tags/$TAG" \ -H "Authorization: token $TOKEN" | jq -r '.id')
-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" echo "release id: $rid"
for f in tui-wm-linux-x64 tui-wm-linux-arm64 tui-wm-config.tar.gz checksums.txt; do 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" \ aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" \

38
Cargo.lock generated
View File

@@ -451,28 +451,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" 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]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.32" version = "0.3.32"
@@ -540,17 +524,6 @@ dependencies = [
"weezl", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
@@ -559,7 +532,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [ dependencies = [
"allocator-api2", "allocator-api2",
"equivalent", "equivalent",
"foldhash 0.2.0", "foldhash",
] ]
[[package]] [[package]]
@@ -570,7 +543,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [ dependencies = [
"allocator-api2", "allocator-api2",
"equivalent", "equivalent",
"foldhash 0.2.0", "foldhash",
] ]
[[package]] [[package]]
@@ -1743,19 +1716,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "ttf-parser"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8"
[[package]] [[package]]
name = "tui-wm" name = "tui-wm"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"crossterm", "crossterm",
"fontdue",
"image", "image",
"interprocess", "interprocess",
"libc", "libc",

View File

@@ -20,7 +20,6 @@ anyhow = "1"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "bmp", "webp"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "bmp", "webp"] }
interprocess = "2.4" interprocess = "2.4"
unicode-width = "0.2" unicode-width = "0.2"
fontdue = "0.9"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" libc = "0.2"

Binary file not shown.

View File

@@ -24,12 +24,6 @@ default_window_size = [82, 26]
# Bakgrundsfärg för virtuella terminaler ("#RRGGBB", "0"-"255" eller färgnamn) # Bakgrundsfärg för virtuella terminaler ("#RRGGBB", "0"-"255" eller färgnamn)
# terminal_bg_color = "#1a1a2e" # 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]] [[panel]]
position = "top" position = "top"
file = "panels/topbar.toml" file = "panels/topbar.toml"
@@ -94,13 +88,6 @@ key = "alt+up"
scope = "global" scope = "global"
action = { type = "maximize" } 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]] [[keybind]]
key = "alt+c" key = "alt+c"
scope = "global" scope = "global"

View File

@@ -22,12 +22,6 @@ background_image = "/home/brasse/Bilder/backrunder/pexels-veeterzy-38136.jpg"
# Standardstorlek [bredd, höjd] för nya terminalfönster (default: [82, 26]) # Standardstorlek [bredd, höjd] för nya terminalfönster (default: [82, 26])
# default_window_size = [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]] [[panel]]
position = "top" position = "top"
file = "panels/topbar.toml" file = "panels/topbar.toml"
@@ -100,13 +94,6 @@ key = "alt+up"
scope = "global" scope = "global"
action = { type = "maximize" } 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 # 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) # (↑↓/PgUp/PgDn bläddrar, / söker, n/N hoppar mellan träffar, q stänger)
[[keybind]] [[keybind]]

View File

@@ -88,13 +88,6 @@ key = "alt+up"
scope = "global" scope = "global"
action = { type = "maximize" } 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]] [[keybind]]
key = "alt+c" key = "alt+c"
scope = "global" scope = "global"

View File

@@ -88,13 +88,6 @@ key = "alt+up"
scope = "global" scope = "global"
action = { type = "maximize" } 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]] [[keybind]]
key = "alt+c" key = "alt+c"
scope = "global" scope = "global"

View File

@@ -1,45 +0,0 @@
# 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,9 +111,6 @@ pub struct App {
last_title_click: Option<(usize, std::time::Instant)>, last_title_click: Option<(usize, std::time::Instant)>,
/// Copy-mode: bläddra/söka i fokuserat fönsters historik med tangentbordet /// Copy-mode: bläddra/söka i fokuserat fönsters historik med tangentbordet
pub copy_mode: Option<CopyMode>, 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 /// Begär full omritning av värdterminalen (rensar spök-tecken som
/// vissa terminaler lämnar kvar när breda glyfer delvis skrivs över) /// vissa terminaler lämnar kvar när breda glyfer delvis skrivs över)
pub needs_full_redraw: bool, pub needs_full_redraw: bool,
@@ -323,8 +320,6 @@ pub enum WindowContent {
editing: Option<String>, editing: Option<String>,
/// Associationsvyn är öppen: markerat index i listan /// Associationsvyn är öppen: markerat index i listan
assoc: Option<usize>, 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 /// "Öppna med"-dialog: fråga vilken app som ska öppna en fil vars
/// format saknar association. /// format saknar association.
@@ -578,7 +573,6 @@ impl App {
last_title_click: None, last_title_click: None,
copy_mode: None, copy_mode: None,
needs_full_redraw: false, needs_full_redraw: false,
last_area: Rect::default(),
watch_sig, watch_sig,
last_watch_check: None, last_watch_check: None,
hovered_tui_btn: false, hovered_tui_btn: false,
@@ -588,7 +582,6 @@ impl App {
} }
pub fn update_layout(&mut self, area: Rect) { pub fn update_layout(&mut self, area: Rect) {
self.last_area = area;
self.panel_rects.clear(); self.panel_rects.clear();
self.panel_item_rects.clear(); self.panel_item_rects.clear();
self.tui_wm_btn_rects.clear(); self.tui_wm_btn_rects.clear();
@@ -1361,7 +1354,7 @@ impl App {
self.focus_window(id); self.focus_window(id);
// rad 1..=4 i innehållet motsvarar inställningsrad 0..=3 // rad 1..=4 i innehållet motsvarar inställningsrad 0..=3
let rel = row.saturating_sub(cr.y); let rel = row.saturating_sub(cr.y);
if (1..=8).contains(&rel) { if (1..=6).contains(&rel) {
let target = (rel - 1) as usize; let target = (rel - 1) as usize;
let mut activate = false; let mut activate = false;
if let Some(WindowContent::Settings { selected, .. }) = self.settings_content(id) { if let Some(WindowContent::Settings { selected, .. }) = self.settings_content(id) {
@@ -1533,7 +1526,6 @@ impl App {
MenuAction::WindowSwitcher => self.switcher_open_or_advance(), MenuAction::WindowSwitcher => self.switcher_open_or_advance(),
MenuAction::CopyMode => self.enter_copy_mode(), MenuAction::CopyMode => self.enter_copy_mode(),
MenuAction::SpawnSettings => self.spawn_settings(), MenuAction::SpawnSettings => self.spawn_settings(),
MenuAction::Screenshot => self.take_screenshot(),
MenuAction::SnapLeft => self.snap_focused(true), MenuAction::SnapLeft => self.snap_focused(true),
MenuAction::SnapRight => self.snap_focused(false), MenuAction::SnapRight => self.snap_focused(false),
MenuAction::Maximize => self.toggle_maximize_focused(), MenuAction::Maximize => self.toggle_maximize_focused(),
@@ -1867,56 +1859,6 @@ 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). /// Öppna inställningsdialogen (eller fokusera en redan öppen).
fn spawn_settings(&mut self) { fn spawn_settings(&mut self) {
if let Some(w) = self.windows.iter().find(|w| matches!(w.content, WindowContent::Settings { .. })) { if let Some(w) = self.windows.iter().find(|w| matches!(w.content, WindowContent::Settings { .. })) {
@@ -1926,7 +1868,7 @@ impl App {
} }
let ca = self.content_area; let ca = self.content_area;
let w = 62u16.min(ca.width.saturating_sub(2)); let w = 62u16.min(ca.width.saturating_sub(2));
let h = 17u16.min(ca.height.saturating_sub(2)); let h = 13u16.min(ca.height.saturating_sub(2));
let x = ca.x as i32 + ((ca.width.saturating_sub(w)) / 2) as i32; 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 y = ca.y as i32 + ((ca.height.saturating_sub(h)) / 2) as i32;
let id = self.next_id; let id = self.next_id;
@@ -1937,7 +1879,7 @@ impl App {
y, y,
width: w, width: w,
height: h, height: h,
content: WindowContent::Settings { selected: 0, editing: None, assoc: None, keys: None }, content: WindowContent::Settings { selected: 0, editing: None, assoc: None },
dragging: None, dragging: None,
resizing: None, resizing: None,
resizable: false, resizable: false,
@@ -1955,72 +1897,7 @@ impl App {
} }
fn handle_settings_key(&mut self, id: usize, key: KeyEvent) { fn handle_settings_key(&mut self, id: usize, key: KeyEvent) {
const ROWS: usize = 8; const ROWS: usize = 6;
// 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? // Associationsvyn är öppen?
let assoc_open = matches!( let assoc_open = matches!(
self.settings_content(id), self.settings_content(id),
@@ -2068,27 +1945,13 @@ impl App {
KeyCode::Enter => { KeyCode::Enter => {
let buf = editing.take().unwrap_or_default(); let buf = editing.take().unwrap_or_default();
let val = buf.trim().to_string(); let val = buf.trim().to_string();
let row = match self.settings_content(id) { self.config.default_editor =
Some(WindowContent::Settings { selected, .. }) => *selected, if val.is_empty() { None } else { Some(val) };
_ => 2, let _ = Config::save_top_level_str(
}; "config.toml",
if row == 3 { "default_editor",
self.config.screenshot_dir = self.config.default_editor.as_deref(),
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 => { KeyCode::Esc => {
*editing = None; *editing = None;
@@ -2149,7 +2012,6 @@ impl App {
/// direkt, men tema/flaggor appliceras även omedelbart i minnet. /// direkt, men tema/flaggor appliceras även omedelbart i minnet.
fn settings_activate(&mut self, id: usize, forward: bool) { fn settings_activate(&mut self, id: usize, forward: bool) {
let current_editor = self.config.default_editor.clone().unwrap_or_default(); 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 Some(WindowContent::Settings { selected, editing, .. }) = self.settings_content(id) else { return };
let row = *selected; let row = *selected;
if row == 2 { if row == 2 {
@@ -2157,25 +2019,13 @@ impl App {
*editing = Some(current_editor); *editing = Some(current_editor);
return; return;
} }
if row == 3 { if row == 5 {
// Skärmbildskatalog: börja textredigera värdet
*editing = Some(current_shotdir);
return;
}
if row == 6 {
// Associationer: öppna listvyn // Associationer: öppna listvyn
if let Some(WindowContent::Settings { assoc, .. }) = self.settings_content(id) { if let Some(WindowContent::Settings { assoc, .. }) = self.settings_content(id) {
*assoc = Some(0); *assoc = Some(0);
} }
return; 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 { match row {
0 => { 0 => {
// Tema: cykla inbyggt + themes/*.toml // Tema: cykla inbyggt + themes/*.toml
@@ -2210,11 +2060,11 @@ impl App {
let _ = Config::save_panel_file("config.toml", 0, &options[next]); let _ = Config::save_panel_file("config.toml", 0, &options[next]);
// panel-innehållet läses om av hot-reloaden // panel-innehållet läses om av hot-reloaden
} }
4 => { 3 => {
self.config.shadow = !self.config.shadow; self.config.shadow = !self.config.shadow;
let _ = Config::save_top_level_bool("config.toml", "shadow", self.config.shadow); let _ = Config::save_top_level_bool("config.toml", "shadow", self.config.shadow);
} }
5 => { 4 => {
self.config.show_scrollbar = !self.config.show_scrollbar; self.config.show_scrollbar = !self.config.show_scrollbar;
let _ = Config::save_top_level_bool("config.toml", "show_scrollbar", self.config.show_scrollbar); let _ = Config::save_top_level_bool("config.toml", "show_scrollbar", self.config.show_scrollbar);
} }
@@ -2776,41 +2626,6 @@ 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 { fn default_shell() -> String {
if cfg!(windows) { if cfg!(windows) {
// Försök hitta PowerShell i prioritetsordning: pwsh (Core) → powershell (inbyggd) // Försök hitta PowerShell i prioritetsordning: pwsh (Core) → powershell (inbyggd)
@@ -2849,11 +2664,6 @@ 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). /// Returnerar ett kortfattat namn för en MenuAction (används som fallback i keybind-menyn).
fn action_display(action: &MenuAction) -> &'static str { fn action_display(action: &MenuAction) -> &'static str {
match action { match action {
@@ -2866,7 +2676,6 @@ fn action_display(action: &MenuAction) -> &'static str {
MenuAction::WindowSwitcher => "Fönsterväxlare", MenuAction::WindowSwitcher => "Fönsterväxlare",
MenuAction::CopyMode => "Copy-mode", MenuAction::CopyMode => "Copy-mode",
MenuAction::SpawnSettings => "Inställningar", MenuAction::SpawnSettings => "Inställningar",
MenuAction::Screenshot => "Skärmbild",
MenuAction::SnapLeft => "Fäst vänster", MenuAction::SnapLeft => "Fäst vänster",
MenuAction::SnapRight => "Fäst höger", MenuAction::SnapRight => "Fäst höger",
MenuAction::Maximize => "Maximera/återställ", MenuAction::Maximize => "Maximera/återställ",

View File

@@ -21,20 +21,11 @@ use std::io::{self, Write};
pub struct SafeBackend<W: Write> { pub struct SafeBackend<W: Write> {
writer: W, 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> { impl<W: Write> SafeBackend<W> {
pub fn new(writer: W) -> Self { pub fn new(writer: W) -> Self {
SafeBackend { SafeBackend { writer }
writer,
wide_cells: std::collections::HashSet::new(),
}
} }
} }
@@ -78,15 +69,11 @@ impl<W: Write> Backend for SafeBackend<W> {
I: Iterator<Item = (u16, u16, &'a Cell)>, I: Iterator<Item = (u16, u16, &'a Cell)>,
{ {
use std::fmt::Write as _; 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 buf = String::new();
let mut last_fg = None; let mut last_fg = None;
let mut last_bg = None; let mut last_bg = None;
let mut last_mod = None; let mut last_mod = None;
for (x, y, cell) in cells { for (x, y, cell) in content {
// Explicit position för varje cell — se modulkommentaren. // Explicit position för varje cell — se modulkommentaren.
let _ = write!(buf, "\x1b[{};{}H", y + 1, x + 1); let _ = write!(buf, "\x1b[{};{}H", y + 1, x + 1);
if last_mod != Some(cell.modifier) { if last_mod != Some(cell.modifier) {
@@ -104,23 +91,6 @@ impl<W: Write> Backend for SafeBackend<W> {
last_bg = Some(cell.bg); last_bg = Some(cell.bg);
} }
buf.push_str(cell.symbol()); 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"); buf.push_str("\x1b[0m");
self.writer.write_all(buf.as_bytes()) self.writer.write_all(buf.as_bytes())
@@ -147,7 +117,6 @@ impl<W: Write> Backend for SafeBackend<W> {
} }
fn clear(&mut self) -> io::Result<()> { fn clear(&mut self) -> io::Result<()> {
self.wide_cells.clear();
self.writer.write_all(b"\x1b[2J\x1b[H") self.writer.write_all(b"\x1b[2J\x1b[H")
} }

View File

@@ -1,4 +1,4 @@
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use std::fs; use std::fs;
#[derive(Deserialize, Debug, Clone, Default)] #[derive(Deserialize, Debug, Clone, Default)]
@@ -23,12 +23,6 @@ pub struct Config {
/// Filassociationer: ändelse (gemener) → kommando för open_file. /// Filassociationer: ändelse (gemener) → kommando för open_file.
#[serde(default)] #[serde(default)]
pub open_with: std::collections::HashMap<String, String>, 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. /// Rita skugga under/till höger om fönster.
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub shadow: bool, pub shadow: bool,
@@ -59,12 +53,8 @@ fn default_window_size() -> [u16; 2] {
[82, 26] [82, 26]
} }
fn default_screenshot_format() -> String {
"both".to_string()
}
/// En tangentbords-genväg definierad i config /// En tangentbords-genväg definierad i config
#[derive(Deserialize, Serialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct KeybindConfig { pub struct KeybindConfig {
/// T.ex. "ctrl+space", "alt+f2", "ctrl+t" /// T.ex. "ctrl+space", "alt+f2", "ctrl+t"
pub key: String, pub key: String,
@@ -73,11 +63,11 @@ pub struct KeybindConfig {
pub scope: KeybindScope, pub scope: KeybindScope,
pub action: MenuAction, pub action: MenuAction,
/// Visningsnamn som visas i TUI-WM-menyn (valfritt) /// Visningsnamn som visas i TUI-WM-menyn (valfritt)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default)]
pub label: Option<String>, pub label: Option<String>,
} }
#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq)] #[derive(Deserialize, Debug, Clone, Default, PartialEq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum KeybindScope { pub enum KeybindScope {
/// Aktiveras alltid, oavsett fokus /// Aktiveras alltid, oavsett fokus
@@ -105,18 +95,17 @@ pub enum PanelPosition {
Bottom, Bottom,
} }
#[derive(Deserialize, Serialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct MenuItem { pub struct MenuItem {
pub label: String, pub label: String,
pub action: MenuAction, pub action: MenuAction,
} }
#[derive(Deserialize, Serialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum MenuAction { pub enum MenuAction {
Exit, Exit,
SpawnTerminal { SpawnTerminal {
#[serde(skip_serializing_if = "Option::is_none")]
shell: Option<String>, shell: Option<String>,
}, },
RunScript { RunScript {
@@ -145,8 +134,6 @@ pub enum MenuAction {
CopyMode, CopyMode,
/// Öppnar den inbyggda inställningsdialogen /// Öppnar den inbyggda inställningsdialogen
SpawnSettings, SpawnSettings,
/// Tar en skärmbild av arbetsytan (text och/eller PNG)
Screenshot,
/// Gör ingenting används för display-only rader i menyer /// Gör ingenting används för display-only rader i menyer
NoOp, NoOp,
} }
@@ -240,12 +227,6 @@ fn default_keybinds() -> Vec<KeybindConfig> {
action: MenuAction::Maximize, action: MenuAction::Maximize,
label: None, label: None,
}, },
KeybindConfig {
key: "alt+p".to_string(),
scope: KeybindScope::Global,
action: MenuAction::Screenshot,
label: Some("Skärmbild".to_string()),
},
KeybindConfig { KeybindConfig {
key: "alt+c".to_string(), key: "alt+c".to_string(),
scope: KeybindScope::Global, scope: KeybindScope::Global,
@@ -308,8 +289,6 @@ impl Config {
theme: None, theme: None,
default_editor: None, default_editor: None,
open_with: std::collections::HashMap::new(), open_with: std::collections::HashMap::new(),
screenshot_dir: None,
screenshot_format: default_screenshot_format(),
shadow: true, shadow: true,
scrollback_lines: default_scrollback_lines(), scrollback_lines: default_scrollback_lines(),
show_scrollbar: default_true(), show_scrollbar: default_true(),
@@ -346,21 +325,6 @@ impl Config {
Ok(()) 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). /// Spara en filassociation ([open_with] ändelse → kommando).
pub fn save_open_with(path: &str, ext: &str, cmd: &str) -> anyhow::Result<()> { pub fn save_open_with(path: &str, ext: &str, cmd: &str) -> anyhow::Result<()> {
let content = fs::read_to_string(path).unwrap_or_default(); 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) format!("{:04}-{:02}-{:02}.log", y, mo, d)
} }
pub fn timestamp() -> String { fn timestamp() -> String {
let secs = SystemTime::now() let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH) .duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())

View File

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

View File

@@ -390,8 +390,8 @@ fn render_window(frame: &mut Frame, window: &FloatingWindow, app: &App) {
WindowContent::PopupDialog { .. } => { WindowContent::PopupDialog { .. } => {
render_popup_dialog(frame, window, focused_id, th); render_popup_dialog(frame, window, focused_id, th);
} }
WindowContent::Settings { selected, editing, assoc, keys } => { WindowContent::Settings { selected, editing, assoc } => {
render_settings_content(frame, cr, *selected, editing.as_deref(), *assoc, *keys, app, th); render_settings_content(frame, cr, *selected, editing.as_deref(), *assoc, app, th);
} }
WindowContent::OpenWith { path, input, save_default } => { WindowContent::OpenWith { path, input, save_default } => {
render_open_with_content(frame, cr, path, input, *save_default, th); render_open_with_content(frame, cr, path, input, *save_default, th);
@@ -517,71 +517,15 @@ fn highlight_matches(frame: &mut Frame, rect: Rect, query: &str, style: Style) {
} }
/// Innehållet i inställningsdialogen: interaktiva rader + info-sektion. /// Innehållet i inställningsdialogen: interaktiva rader + info-sektion.
#[allow(clippy::too_many_arguments)]
fn render_settings_content( fn render_settings_content(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
selected: usize, selected: usize,
editing: Option<&str>, editing: Option<&str>,
assoc: Option<usize>, assoc: Option<usize>,
keys: Option<(usize, bool)>,
app: &App, app: &App,
th: &crate::theme::Theme, 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 // Associationsvyn: lista filformat → kommandon
if let Some(sel) = assoc { if let Some(sel) = assoc {
let mut exts: Vec<&String> = app.config.open_with.keys().collect(); let mut exts: Vec<&String> = app.config.open_with.keys().collect();
@@ -647,32 +591,25 @@ fn render_settings_content(
.first() .first()
.and_then(|p| p.file.as_deref()) .and_then(|p| p.file.as_deref())
.unwrap_or("(inline)"); .unwrap_or("(inline)");
let edit_val = |row: usize, current: Option<String>, hint: &str| -> String { let editor_val = match editing {
if selected == row { Some(buf) => format!("{}▏ (Enter=spara, Esc=avbryt)", buf),
if let Some(buf) = editing { None => app
return format!("{}▏ (Enter=spara, Esc=avbryt)", buf); .config
} .default_editor
} .clone()
current.unwrap_or_else(|| hint.to_string()) .unwrap_or_else(|| "(ingen — Enter för att ange)".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 onoff = |b: bool| if b { "[x] på " } else { "[ ] av " };
let rows: Vec<(String, String)> = vec![ let rows: Vec<(String, String)> = vec![
("Tema".to_string(), format!(" {} ", theme_name)), ("Tema".to_string(), format!(" {} ", theme_name)),
("Panel".to_string(), format!(" {} ", panel_name)), ("Panel".to_string(), format!(" {} ", panel_name)),
("Editor".to_string(), editor_val), ("Editor".to_string(), editor_val),
("Skärmbilder".to_string(), shotdir_val),
("Skugga".to_string(), onoff(app.config.shadow).to_string()), ("Skugga".to_string(), onoff(app.config.shadow).to_string()),
("Scrollbar".to_string(), onoff(app.config.show_scrollbar).to_string()), ("Scrollbar".to_string(), onoff(app.config.show_scrollbar).to_string()),
( (
"Filformat".to_string(), "Filformat".to_string(),
format!("{} associationer (Enter=visa/ta bort)", app.config.open_with.len()), 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; let mut y = area.y + 1;

View File

@@ -1,221 +0,0 @@
//! 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:") text = disp.frame_text(timeout=5.0, contains="Tema:")
check("inställningsdialogen öppnas från panelen", "Tema:" in text check("inställningsdialogen öppnas från panelen", "Tema:" in text
and "Skugga" in text and "config.toml" in text) and "Skugga" in text and "config.toml" in text)
# Ned fyra rader till Skugga # Ned tre rader till Skugga (0=Tema, 1=Panel, 2=Editor, 3=Skugga)
# (0=Tema, 1=Panel, 2=Editor, 3=Skärmbilder, 4=Skugga) disp.send(key_event(code="Down"))
for _ in range(4): disp.send(key_event(code="Down"))
disp.send(key_event(code="Down")) disp.send(key_event(code="Down"))
disp.send(key_event(code="Enter")) disp.send(key_event(code="Enter"))
time.sleep(0.5) time.sleep(0.5)
cfg_now = open(os.path.join(cfgdir, "config.toml")).read() 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") text = disp.frame_text(timeout=5.0, contains="Filformat")
check("inställningarna visar filformat-raden", "1 associationer" in text, check("inställningarna visar filformat-raden", "1 associationer" in text,
f"frame: {text[:300]!r}") f"frame: {text[:300]!r}")
for _ in range(6): for _ in range(5):
disp.send(key_event(code="Down")) disp.send(key_event(code="Down"))
disp.send(key_event(code="Enter")) disp.send(key_event(code="Enter"))
text = disp.frame_text(timeout=5.0, contains=".txt") text = disp.frame_text(timeout=5.0, contains=".txt")
@@ -461,77 +461,6 @@ def main():
disp.send(key_event(code="Esc")) disp.send(key_event(code="Esc"))
time.sleep(0.3) 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 ─────────────────────────── # ── 5. Popup + popup_result via Enter ───────────────────────────
app.send({ app.send({
"type": "spawn_popup", "message": "Integrationstest?", "type": "spawn_popup", "message": "Integrationstest?",