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

View File

@@ -441,7 +441,10 @@ impl App {
config,
panel_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_window_close: None,
hovered_resize: None,
@@ -574,7 +577,9 @@ impl App {
// ── PTY resize om fönsterstorlek ändrats ─────────────────────────────
for window in &mut self.windows {
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 };
if !*alive {
continue;
@@ -1445,7 +1450,8 @@ impl App {
let x = ca.x as i32 + 2 + offset;
let y = ca.y as i32 + 1 + offset;
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 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;
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 {
if skip_cols > 0 {
skip_cols -= 1;
continue;
}
let idx = (row * area.width + col) as usize;
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;
if new_mod != last_mod {
out.push_str("\x1b[0m");

View File

@@ -185,7 +185,14 @@ fn run_standalone_loop(
};
all_clients.retain(|_, (role, _, _, tx)| {
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 {
true
}
@@ -344,7 +351,14 @@ fn run_daemon_mode() -> io::Result<()> {
};
all_clients.retain(|_, (role, _, _, tx)| {
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 {
true
}

View File

@@ -439,6 +439,13 @@ fn render_terminal(
.unwrap_or(false);
let (sym, style) = match screen.cell(r as u16, c as u16) {
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 = if s.is_empty() { " ".to_string() } else { s.to_string() };
let fg = vt_color(cell.fgcolor());