Fix wide-char (emoji/CJK) column drift through the whole render chain

Wide characters shifted everything after them one column to the right:

- render.rs: vt100's empty wide-continuation cell was padded to a
  space, making wide chars occupy 2+1 columns — skip it entirely
- ipc.rs buffer_to_ansi: same bug for daemon frames — skip cells
  covered by a preceding wide symbol (unicode-width)
- app.rs: clamp virtual terminals to >= 2 columns (vt100 0.16 has a
  subtraction underflow panic on wide chars in a 1-col grid) and give
  content_area a sane 80x24 default so windows spawned before the
  first layout pass aren't degenerate
- main.rs: use try_send for display frames so one slow/stalled client
  can no longer freeze the whole WM
- integration test: new end-to-end check that emoji do not shift the
  row, plus fix a false match against the window title

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 09:33:06 +02:00
parent 5ca8eb4647
commit 6aee7ce00a
7 changed files with 63 additions and 5 deletions

1
Cargo.lock generated
View File

@@ -1731,6 +1731,7 @@ dependencies = [
"serde_json", "serde_json",
"toml", "toml",
"toml_edit", "toml_edit",
"unicode-width",
"vt100", "vt100",
] ]

View File

@@ -19,6 +19,7 @@ vt100 = "0.16"
anyhow = "1" 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"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" libc = "0.2"

View File

@@ -441,7 +441,10 @@ impl App {
config, config,
panel_rects: Vec::new(), panel_rects: Vec::new(),
panel_item_rects: Vec::new(), panel_item_rects: Vec::new(),
content_area: Rect::default(), // Vettig startyta tills första update_layout — fönster som spawnas
// innan dess får annars 0-storlek (och en 1-kolumns vt100 kan
// panika på breda tecken).
content_area: Rect::new(0, 0, 80, 24),
hovered_panel_item: None, hovered_panel_item: None,
hovered_window_close: None, hovered_window_close: None,
hovered_resize: None, hovered_resize: None,
@@ -574,7 +577,9 @@ impl App {
// ── PTY resize om fönsterstorlek ändrats ───────────────────────────── // ── PTY resize om fönsterstorlek ändrats ─────────────────────────────
for window in &mut self.windows { for window in &mut self.windows {
let new_rows = window.height.saturating_sub(2).max(1); let new_rows = window.height.saturating_sub(2).max(1);
let new_cols = window.width.saturating_sub(2).max(1); // Minst 2 kolumner: vt100 0.16 har en underflow-panik när ett
// brett tecken (emoji/CJK) skrivs i en 1-kolumns terminal.
let new_cols = window.width.saturating_sub(2).max(2);
let WindowContent::Terminal { pty, parser, alive, .. } = &mut window.content else { continue }; let WindowContent::Terminal { pty, parser, alive, .. } = &mut window.content else { continue };
if !*alive { if !*alive {
continue; continue;
@@ -1445,7 +1450,8 @@ impl App {
let x = ca.x as i32 + 2 + offset; let x = ca.x as i32 + 2 + offset;
let y = ca.y as i32 + 1 + offset; let y = ca.y as i32 + 1 + offset;
let rows = h.saturating_sub(2).max(1); let rows = h.saturating_sub(2).max(1);
let cols = w.saturating_sub(2).max(1); // Minst 2 kolumner — se kommentaren vid PTY-resize i tick().
let cols = w.saturating_sub(2).max(2);
let id = self.next_id; let id = self.next_id;
let mut env_vars: Vec<(String, String)> = Vec::new(); let mut env_vars: Vec<(String, String)> = Vec::new();

View File

@@ -198,9 +198,22 @@ pub fn buffer_to_ansi(buf: &Buffer) -> Vec<u8> {
use std::fmt::Write as FmtWrite; use std::fmt::Write as FmtWrite;
let _ = write!(out, "\x1b[{};1H", row + 1); let _ = write!(out, "\x1b[{};1H", row + 1);
} }
// Celler som täcks av ett brett tecken (emoji/CJK i cellen innan)
// ska INTE skrivas ut — terminalen har redan avancerat 2 kolumner
// för det breda tecknet, och ett extra mellanslag skulle förskjuta
// resten av raden åt höger.
let mut skip_cols = 0u16;
for col in 0..area.width { for col in 0..area.width {
if skip_cols > 0 {
skip_cols -= 1;
continue;
}
let idx = (row * area.width + col) as usize; let idx = (row * area.width + col) as usize;
let cell = &buf.content[idx]; let cell = &buf.content[idx];
let sym_width = unicode_width::UnicodeWidthStr::width(cell.symbol());
if sym_width > 1 {
skip_cols = (sym_width as u16).saturating_sub(1);
}
let new_mod = cell.modifier; let new_mod = cell.modifier;
if new_mod != last_mod { if new_mod != last_mod {
out.push_str("\x1b[0m"); out.push_str("\x1b[0m");

View File

@@ -185,7 +185,14 @@ fn run_standalone_loop(
}; };
all_clients.retain(|_, (role, _, _, tx)| { all_clients.retain(|_, (role, _, _, tx)| {
if *role == ClientRole::Display { if *role == ClientRole::Display {
tx.send(frame_msg.clone()).is_ok() // try_send: en långsam/hängd display-klient får INTE
// blockera hela WM:et — droppa framen till den klienten
// och koppla bara bort vid stängd kanal.
match tx.try_send(frame_msg.clone()) {
Ok(()) => true,
Err(std::sync::mpsc::TrySendError::Full(_)) => true,
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => false,
}
} else { } else {
true true
} }
@@ -344,7 +351,14 @@ fn run_daemon_mode() -> io::Result<()> {
}; };
all_clients.retain(|_, (role, _, _, tx)| { all_clients.retain(|_, (role, _, _, tx)| {
if *role == ClientRole::Display { if *role == ClientRole::Display {
tx.send(frame_msg.clone()).is_ok() // try_send: en långsam/hängd display-klient får INTE
// blockera hela WM:et — droppa framen till den klienten
// och koppla bara bort vid stängd kanal.
match tx.try_send(frame_msg.clone()) {
Ok(()) => true,
Err(std::sync::mpsc::TrySendError::Full(_)) => true,
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => false,
}
} else { } else {
true true
} }

View File

@@ -439,6 +439,13 @@ fn render_terminal(
.unwrap_or(false); .unwrap_or(false);
let (sym, style) = match screen.cell(r as u16, c as u16) { let (sym, style) = match screen.cell(r as u16, c as u16) {
Some(cell) => { Some(cell) => {
// Fortsättningscellen efter ett brett tecken (emoji,
// CJK) är tom i vt100 — den får INTE bli ett mellanslag,
// för då tar tecknet 2+1 kolumner och resten av raden
// förskjuts åt höger. Hoppa över den helt.
if cell.is_wide_continuation() {
continue;
}
let s = cell.contents(); let s = cell.contents();
let s = if s.is_empty() { " ".to_string() } else { s.to_string() }; let s = if s.is_empty() { " ".to_string() } else { s.to_string() };
let fg = vt_color(cell.fgcolor()); let fg = vt_color(cell.fgcolor());

View File

@@ -172,6 +172,22 @@ def main():
text = disp.frame_text(timeout=5.0, contains="x") text = disp.frame_text(timeout=5.0, contains="x")
check("key_event når den virtuella terminalen", "x" in text) check("key_event når den virtuella terminalen", "x" in text)
# ── 4b. Breda tecken (emoji) får inte förskjuta raden ───────────
# vt100 lämnar en tom fortsättningscell efter breda tecken; om
# renderaren gör den till mellanslag blir "📁X" → "📁 X" och allt
# efter emojin skjuts åt höger.
# OBS: fönstertiteln visar kommandosträngen (innehåller "X-MARKER"
# som text) — vänta därför på emojin+markören som bara finns i
# fönstrets INNEHÅLL.
app.send({"type": "spawn_window",
"command": "printf '\\360\\237\\223\\201X-MARKER\\n'; exec cat",
"request_id": "w-emoji"})
emoji_open = app.recv_until("window_opened")
text = disp.frame_text(timeout=5.0, contains="📁X-MARKER")
check("emoji förskjuter inte raden", "📁X-MARKER" in text,
f"frame-text: {text[-200:]!r}")
app.send({"type": "close_window", "window_id": emoji_open["id"]})
# ── 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?",