SafeBackend: explicit per-cell cursor positioning eliminates ghost glyphs
Terminals and fonts disagree about the rendered width of some glyphs (emoji like ⭐/➕). Ratatui's stock backend writes contiguous runs and trusts the terminal to advance exactly unicode-width columns, so a single disagreement shifts the rest of the run — misplaced characters then stick around ('ghosts') until something else repaints the cell. The new SafeBackend positions the cursor explicitly (ESC[y;xH) before every cell it writes, so width disagreements can never accumulate. Diffs are small, so the ~8 bytes/cell overhead is negligible. Verified in tmux: rendering, dragging and IPC unchanged; 41/41 integration checks pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
150
src/backend.rs
Normal file
150
src/backend.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
//! SafeBackend — en crossterm-baserad ratatui-backend som positionerar
|
||||
//! markören EXPLICIT för varje cell som skrivs.
|
||||
//!
|
||||
//! Ratatuis vanliga backend skriver sammanhängande celler som en "run"
|
||||
//! och litar på att terminalen avancerar markören exakt lika många
|
||||
//! kolumner som `unicode-width` räknar. Terminaler och fonter är dock
|
||||
//! oense om bredden på vissa glyfer (t.ex. emoji som ⭐/➕): en enda
|
||||
//! oenighet förskjuter resten av raden, och felplacerade tecken blir
|
||||
//! kvar som "spöken" tills cellen råkar ritas om.
|
||||
//!
|
||||
//! Genom att sätta markören med `ESC[y;xH` inför varje cell kan
|
||||
//! oenigheter aldrig ackumuleras — varje glyf landar exakt där den ska.
|
||||
//! Diffen från ratatui är oftast liten, så overheaden (~8 byte/cell)
|
||||
//! är försumbar.
|
||||
|
||||
use ratatui::backend::{Backend, ClearType, WindowSize};
|
||||
use ratatui::buffer::Cell;
|
||||
use ratatui::layout::{Position, Size};
|
||||
use ratatui::style::Modifier;
|
||||
use std::io::{self, Write};
|
||||
|
||||
pub struct SafeBackend<W: Write> {
|
||||
writer: W,
|
||||
}
|
||||
|
||||
impl<W: Write> SafeBackend<W> {
|
||||
pub fn new(writer: W) -> Self {
|
||||
SafeBackend { writer }
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Write> Write for SafeBackend<W> {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.writer.write(buf)
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
fn modifier_ansi(m: Modifier) -> String {
|
||||
let mut out = String::from("\x1b[0m");
|
||||
if m.contains(Modifier::BOLD) {
|
||||
out.push_str("\x1b[1m");
|
||||
}
|
||||
if m.contains(Modifier::DIM) {
|
||||
out.push_str("\x1b[2m");
|
||||
}
|
||||
if m.contains(Modifier::ITALIC) {
|
||||
out.push_str("\x1b[3m");
|
||||
}
|
||||
if m.contains(Modifier::UNDERLINED) {
|
||||
out.push_str("\x1b[4m");
|
||||
}
|
||||
if m.contains(Modifier::REVERSED) {
|
||||
out.push_str("\x1b[7m");
|
||||
}
|
||||
if m.contains(Modifier::CROSSED_OUT) {
|
||||
out.push_str("\x1b[9m");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl<W: Write> Backend for SafeBackend<W> {
|
||||
type Error = io::Error;
|
||||
|
||||
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
|
||||
where
|
||||
I: Iterator<Item = (u16, u16, &'a Cell)>,
|
||||
{
|
||||
use std::fmt::Write as _;
|
||||
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 {
|
||||
// Explicit position för varje cell — se modulkommentaren.
|
||||
let _ = write!(buf, "\x1b[{};{}H", y + 1, x + 1);
|
||||
if last_mod != Some(cell.modifier) {
|
||||
buf.push_str(&modifier_ansi(cell.modifier));
|
||||
last_fg = None;
|
||||
last_bg = None;
|
||||
last_mod = Some(cell.modifier);
|
||||
}
|
||||
if last_fg != Some(cell.fg) {
|
||||
buf.push_str(&crate::ipc::color_fg(cell.fg));
|
||||
last_fg = Some(cell.fg);
|
||||
}
|
||||
if last_bg != Some(cell.bg) {
|
||||
buf.push_str(&crate::ipc::color_bg(cell.bg));
|
||||
last_bg = Some(cell.bg);
|
||||
}
|
||||
buf.push_str(cell.symbol());
|
||||
}
|
||||
buf.push_str("\x1b[0m");
|
||||
self.writer.write_all(buf.as_bytes())
|
||||
}
|
||||
|
||||
fn hide_cursor(&mut self) -> io::Result<()> {
|
||||
self.writer.write_all(b"\x1b[?25l")
|
||||
}
|
||||
|
||||
fn show_cursor(&mut self) -> io::Result<()> {
|
||||
self.writer.write_all(b"\x1b[?25h")
|
||||
}
|
||||
|
||||
fn get_cursor_position(&mut self) -> io::Result<Position> {
|
||||
// Används inte av TUI-WM (markören är dold); undvik en blockerande
|
||||
// terminal-query och svara med origo.
|
||||
Ok(Position::new(0, 0))
|
||||
}
|
||||
|
||||
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
|
||||
let p: Position = position.into();
|
||||
self.writer
|
||||
.write_all(format!("\x1b[{};{}H", p.y + 1, p.x + 1).as_bytes())
|
||||
}
|
||||
|
||||
fn clear(&mut self) -> io::Result<()> {
|
||||
self.writer.write_all(b"\x1b[2J\x1b[H")
|
||||
}
|
||||
|
||||
fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
|
||||
let seq: &[u8] = match clear_type {
|
||||
ClearType::All => b"\x1b[2J",
|
||||
ClearType::AfterCursor => b"\x1b[0J",
|
||||
ClearType::BeforeCursor => b"\x1b[1J",
|
||||
ClearType::CurrentLine => b"\x1b[2K",
|
||||
ClearType::UntilNewLine => b"\x1b[0K",
|
||||
};
|
||||
self.writer.write_all(seq)
|
||||
}
|
||||
|
||||
fn size(&self) -> io::Result<Size> {
|
||||
let (w, h) = crossterm::terminal::size()?;
|
||||
Ok(Size::new(w, h))
|
||||
}
|
||||
|
||||
fn window_size(&mut self) -> io::Result<WindowSize> {
|
||||
let ws = crossterm::terminal::window_size()?;
|
||||
Ok(WindowSize {
|
||||
columns_rows: Size::new(ws.columns, ws.rows),
|
||||
pixels: Size::new(ws.width, ws.height),
|
||||
})
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.writer.flush()
|
||||
}
|
||||
}
|
||||
@@ -253,7 +253,7 @@ pub fn buffer_to_ansi(buf: &Buffer) -> Vec<u8> {
|
||||
out.into_bytes()
|
||||
}
|
||||
|
||||
fn color_fg(c: ratatui::style::Color) -> String {
|
||||
pub(crate) fn color_fg(c: ratatui::style::Color) -> String {
|
||||
use ratatui::style::Color::*;
|
||||
match c {
|
||||
Reset => "\x1b[39m".into(),
|
||||
@@ -350,7 +350,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn color_bg(c: ratatui::style::Color) -> String {
|
||||
pub(crate) fn color_bg(c: ratatui::style::Color) -> String {
|
||||
use ratatui::style::Color::*;
|
||||
match c {
|
||||
Reset => "\x1b[49m".into(),
|
||||
|
||||
11
src/main.rs
11
src/main.rs
@@ -1,4 +1,5 @@
|
||||
mod app;
|
||||
mod backend;
|
||||
mod client;
|
||||
mod config;
|
||||
mod ipc;
|
||||
@@ -17,7 +18,7 @@ use crossterm::{
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ipc::{ClientMessage, ClientRole, ServerMessage, WindowInfo};
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use ratatui::Terminal;
|
||||
use server::IpcEvent;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Write as _};
|
||||
@@ -220,7 +221,9 @@ fn run_standalone() -> io::Result<()> {
|
||||
)
|
||||
);
|
||||
}
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
// SafeBackend positionerar varje cell explicit — immun mot terminal/
|
||||
// font-oenighet om glyfbredder (spök-tecken vid fönsterflytt).
|
||||
let backend = backend::SafeBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let config = Config::load_or_default("config.toml");
|
||||
@@ -239,7 +242,7 @@ fn run_standalone() -> io::Result<()> {
|
||||
}
|
||||
|
||||
fn run_standalone_loop(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
terminal: &mut Terminal<backend::SafeBackend<io::Stdout>>,
|
||||
app: &mut App,
|
||||
ipc_rx: mpsc::Receiver<IpcEvent>,
|
||||
_socket_path: &str,
|
||||
@@ -401,7 +404,7 @@ fn get_terminal_pixel_size() -> (u16, u16) {
|
||||
|
||||
/// Uppdatera Kitty-bakgrundsbild om det behövs (ny bild, storleksändring, borttagen).
|
||||
fn update_background(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
terminal: &mut Terminal<backend::SafeBackend<io::Stdout>>,
|
||||
app: &App,
|
||||
bg_state: &mut Option<kitty_gfx::BgImage>,
|
||||
cols: u16,
|
||||
|
||||
Reference in New Issue
Block a user