Commit Diff


commit - /dev/null
commit + d536c0e2d45bbda2aba250d96bc6befca53081a3
blob - /dev/null
blob + c7940dc283b512d7e38bbdeb9b9b45aefcb0955c (mode 644)
--- /dev/null
+++ .cargo/config.toml
@@ -0,0 +1,2 @@
+[build]
+rustflags = ["-L", "/usr/X11R6/lib", "-l", "util"]
blob - /dev/null
blob + 5d1977ce5bf6a5f6a3964550a695376e3864b1bc (mode 644)
--- /dev/null
+++ .gitignore
@@ -0,0 +1,2 @@
+target
+*.log
blob - /dev/null
blob + 48b6f1bf6846b19e7711151b9c5b551c97730f5b (mode 644)
--- /dev/null
+++ .rustfmt.toml
@@ -0,0 +1,2 @@
+max_width = 80
+reorder_imports = true
blob - /dev/null
blob + 78079d58bd79b350d4273686a25efe710aa45dd0 (mode 644)
--- /dev/null
+++ Cargo.lock
@@ -0,0 +1,66 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "libc"
+version = "0.2.183"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "ot"
+version = "0.1.0"
+dependencies = [
+ "x11",
+ "xcb",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "quick-xml"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "x11"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "xcb"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6"
+dependencies = [
+ "bitflags",
+ "libc",
+ "quick-xml",
+ "x11",
+]
blob - /dev/null
blob + 98f5322f139fc1818a3ae30d9c681b1eec2f124d (mode 644)
--- /dev/null
+++ Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "ot"
+version = "0.1.0"
+edition = "2024"
+
+[lints.clippy]
+all = "warn"
+
+[dependencies]
+xcb = { version = "1", features = ["xkb", "xlib_xcb"] }
+x11 = { version = "2", features = ["xlib", "xft", "xrender"] }
+
+[profile.release]
+opt-level = "s"
+lto = true
+codegen-units = 1
+strip = true
+panic = "abort"
blob - /dev/null
blob + 76fd1382ff51d325c06a8b45bf6059de9589e897 (mode 644)
--- /dev/null
+++ Makefile
@@ -0,0 +1,43 @@
+CARGO = cargo
+DISPLAY_NUM = :3
+XEPHYR_SIZE = 800x600
+LOG = ot.log
+
+OT = ./target/debug/ot
+
+all: build
+
+build:
+	$(CARGO) build
+
+release:
+	$(CARGO) build --release
+
+clean:
+	$(CARGO) clean
+
+run: build
+	@echo "Starting Xephyr on $(DISPLAY_NUM) ($(XEPHYR_SIZE))..."
+	Xephyr $(DISPLAY_NUM) -screen $(XEPHYR_SIZE) -ac &
+	@sleep 1
+	DISPLAY=$(DISPLAY_NUM) $(OT) &
+
+run-debug: build
+	@echo "Starting Xephyr on $(DISPLAY_NUM) ($(XEPHYR_SIZE))..."
+	@echo "Logs: $(LOG)"
+	Xephyr $(DISPLAY_NUM) -screen $(XEPHYR_SIZE) -ac &
+	@sleep 1
+	OT_LOG=debug DISPLAY=$(DISPLAY_NUM) $(OT) 2>$(LOG) &
+	@sleep 0.5
+	tail -f $(LOG)
+
+stop:
+	-pkill -f "Xephyr $(DISPLAY_NUM)" 2>/dev/null
+	-pkill -f "target/debug/ot" 2>/dev/null
+
+restart: stop build run
+
+install: release
+	doas install -m 755 target/release/ot /usr/local/bin/ot
+
+.PHONY: all build release clean run run-debug stop restart install
blob - /dev/null
blob + 978e3bded06ab85df6596adfec59a226ab40ce8c (mode 644)
--- /dev/null
+++ src/log.rs
@@ -0,0 +1,51 @@
+use std::sync::OnceLock;
+
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum Level {
+    Debug,
+    Info,
+    Warn,
+}
+
+static LOG_LEVEL: OnceLock<Level> = OnceLock::new();
+
+pub fn init() {
+    let level = match std::env::var("OT_LOG").as_deref() {
+        Ok("debug") => Level::Debug,
+        Ok("warn") => Level::Warn,
+        _ => Level::Info,
+    };
+    let _ = LOG_LEVEL.set(level);
+}
+
+#[inline]
+pub fn level() -> Level {
+    LOG_LEVEL.get().copied().unwrap_or(Level::Info)
+}
+
+#[macro_export]
+macro_rules! log_debug {
+    ($($arg:tt)*) => {
+        if $crate::log::level() <= $crate::log::Level::Debug {
+            eprintln!("ot [DEBUG] {}", format_args!($($arg)*));
+        }
+    };
+}
+
+#[macro_export]
+macro_rules! log_info {
+    ($($arg:tt)*) => {
+        if $crate::log::level() <= $crate::log::Level::Info {
+            eprintln!("ot [INFO] {}", format_args!($($arg)*));
+        }
+    };
+}
+
+#[macro_export]
+macro_rules! log_warn {
+    ($($arg:tt)*) => {
+        if $crate::log::level() <= $crate::log::Level::Warn {
+            eprintln!("ot [WARN] {}", format_args!($($arg)*));
+        }
+    };
+}
blob - /dev/null
blob + a7fdc1a751f959143bdfdf9070c3b77e798f76b8 (mode 644)
--- /dev/null
+++ src/main.rs
@@ -0,0 +1,164 @@
+//! ot — own terminal. Minimal terminal emulator in pure xcb.
+
+#[macro_use]
+mod log;
+mod pty;
+mod sixel;
+mod term;
+mod x11;
+mod xft_font;
+
+use std::os::unix::io::RawFd;
+
+const POLLIN: i16 = 0x0001;
+const POLLHUP: i16 = 0x0010;
+const EINTR: i32 = 4;
+
+#[cfg(target_os = "openbsd")]
+type NfdsT = std::ffi::c_uint;
+#[cfg(target_os = "linux")]
+type NfdsT = std::ffi::c_ulong;
+
+#[repr(C)]
+struct PollFd {
+    fd: RawFd,
+    events: i16,
+    revents: i16,
+}
+
+unsafe extern "C" {
+    fn poll(
+        fds: *mut PollFd,
+        nfds: NfdsT,
+        timeout: std::ffi::c_int,
+    ) -> std::ffi::c_int;
+}
+
+fn open_url(url: &str) {
+    use std::process::Command;
+    let _ = Command::new("firefox").arg(url).spawn();
+}
+
+fn main() {
+    log::init();
+    log_info!("starting");
+
+    let rows = 24u16;
+    let cols = 80u16;
+
+    let mut x = match x11::X11::new(cols, rows) {
+        Ok(x) => x,
+        Err(e) => {
+            eprintln!("ot: {}", e);
+            std::process::exit(1);
+        }
+    };
+
+    let pty = match pty::Pty::new(rows, cols) {
+        Ok(p) => p,
+        Err(e) => {
+            eprintln!("ot: {}", e);
+            std::process::exit(1);
+        }
+    };
+
+    let mut term = term::Term::new(rows as usize, cols as usize);
+    term.cell_h = x.cell_height() as usize;
+    x.draw(&term);
+
+    let x_fd = x.fd();
+
+    loop {
+        let mut fds = [
+            PollFd {
+                fd: x_fd,
+                events: POLLIN,
+                revents: 0,
+            },
+            PollFd {
+                fd: pty.master,
+                events: POLLIN,
+                revents: 0,
+            },
+        ];
+
+        let ret = unsafe { poll(fds.as_mut_ptr(), 2, -1) };
+        if ret < 0 {
+            let err = std::io::Error::last_os_error();
+            if err.raw_os_error() == Some(EINTR) {
+                continue;
+            }
+            break;
+        }
+
+        let mut need_draw = false;
+
+        // Read all available PTY output.
+        if fds[1].revents & POLLIN != 0 {
+            let mut buf = [0u8; 8192];
+            loop {
+                let n = pty.read(&mut buf);
+                if n <= 0 {
+                    break;
+                }
+                term.process(&buf[..n as usize]);
+            }
+            term.scroll_offset = 0;
+            need_draw = true;
+            if let Some(title) = term.title.take() {
+                x.set_title(&title);
+            }
+        }
+
+        // Shell exited.
+        if fds[1].revents & POLLHUP != 0 {
+            break;
+        }
+
+        // Process X11 events.
+        if fds[0].revents & POLLIN != 0 {
+            while let Some(ev) =
+                x.poll_event(&term, term.app_cursor)
+            {
+                match ev {
+                    x11::Event::Key(bytes) => {
+                        term.scroll_offset = 0;
+                        pty.write_all(&bytes);
+                    }
+                    x11::Event::Paste(data) => {
+                        pty.write_all(&data);
+                    }
+                    x11::Event::OpenUrl(url) => {
+                        open_url(&url);
+                    }
+                    x11::Event::ScrollUp => {
+                        term.scroll_view_up(5);
+                        need_draw = true;
+                    }
+                    x11::Event::ScrollDown => {
+                        term.scroll_view_down(5);
+                        need_draw = true;
+                    }
+                    x11::Event::Resize(c, r) => {
+                        term.resize(r as usize, c as usize);
+                        pty.resize(r, c);
+                        x.resize(c, r);
+                        need_draw = true;
+                    }
+                    x11::Event::Redraw => need_draw = true,
+                    x11::Event::Close => return,
+                }
+            }
+        }
+
+        // Write pending terminal responses (DA, DSR).
+        if !term.response.is_empty() {
+            let resp = std::mem::take(&mut term.response);
+            pty.write_all(&resp);
+        }
+
+        if need_draw {
+            x.draw(&term);
+        }
+    }
+}
blob - /dev/null
blob + d46247fce5ef22160dd06195f22d7b4633e494e8 (mode 644)
--- /dev/null
+++ src/pty.rs
@@ -0,0 +1,126 @@
+//! PTY management — fork, exec shell, read/write.
+
+use std::os::unix::io::RawFd;
+
+#[repr(C)]
+struct Winsize {
+    ws_row: u16,
+    ws_col: u16,
+    ws_xpixel: u16,
+    ws_ypixel: u16,
+}
+
+unsafe extern "C" {
+    fn forkpty(
+        amaster: *mut i32,
+        name: *mut i8,
+        termp: *const u8,
+        winp: *const Winsize,
+    ) -> i32;
+    fn execvp(file: *const i8, argv: *const *const i8) -> i32;
+    fn read(fd: i32, buf: *mut u8, count: usize) -> isize;
+    fn write(fd: i32, buf: *const u8, count: usize) -> isize;
+    fn close(fd: i32) -> i32;
+    fn fcntl(fd: i32, cmd: i32, arg: i32) -> i32;
+    fn ioctl(fd: i32, request: u64, arg: *const Winsize) -> i32;
+}
+
+const F_SETFL: i32 = 4;
+
+#[cfg(target_os = "openbsd")]
+const O_NONBLOCK: i32 = 0x0004;
+#[cfg(target_os = "linux")]
+const O_NONBLOCK: i32 = 0x0800;
+
+#[cfg(target_os = "openbsd")]
+const TIOCSWINSZ: u64 = 0x80087467;
+#[cfg(target_os = "linux")]
+const TIOCSWINSZ: u64 = 0x5414;
+
+pub struct Pty {
+    pub master: RawFd,
+    #[allow(dead_code)]
+    pid: i32,
+}
+
+impl Pty {
+    pub fn new(
+        rows: u16,
+        cols: u16,
+    ) -> Result<Self, String> {
+        let ws = Winsize {
+            ws_row: rows,
+            ws_col: cols,
+            ws_xpixel: 0,
+            ws_ypixel: 0,
+        };
+
+        let mut master: i32 = -1;
+        let pid = unsafe {
+            forkpty(
+                &mut master,
+                std::ptr::null_mut(),
+                std::ptr::null(),
+                &ws,
+            )
+        };
+
+        if pid < 0 {
+            return Err("forkpty failed".into());
+        }
+
+        if pid == 0 {
+            // Child: set TERM and exec shell.
+            unsafe { std::env::set_var("TERM", "xterm-256color") };
+            let shell = std::env::var("SHELL")
+                .unwrap_or_else(|_| "/bin/ksh".into());
+            let c_shell =
+                std::ffi::CString::new(shell.as_str()).unwrap();
+            let argv = [c_shell.as_ptr(), std::ptr::null()];
+            unsafe { execvp(c_shell.as_ptr(), argv.as_ptr()) };
+            std::process::exit(1);
+        }
+
+        // Parent: set master fd non-blocking.
+        unsafe { fcntl(master, F_SETFL, O_NONBLOCK) };
+
+        Ok(Pty { master, pid })
+    }
+
+    pub fn read(&self, buf: &mut [u8]) -> isize {
+        unsafe { read(self.master, buf.as_mut_ptr(), buf.len()) }
+    }
+
+    pub fn write_all(&self, data: &[u8]) {
+        let mut off = 0;
+        while off < data.len() {
+            let n = unsafe {
+                write(
+                    self.master,
+                    data[off..].as_ptr(),
+                    data.len() - off,
+                )
+            };
+            if n <= 0 {
+                break;
+            }
+            off += n as usize;
+        }
+    }
+
+    pub fn resize(&self, rows: u16, cols: u16) {
+        let ws = Winsize {
+            ws_row: rows,
+            ws_col: cols,
+            ws_xpixel: 0,
+            ws_ypixel: 0,
+        };
+        unsafe { ioctl(self.master, TIOCSWINSZ, &ws) };
+    }
+}
+
+impl Drop for Pty {
+    fn drop(&mut self) {
+        unsafe { close(self.master) };
+    }
+}
blob - /dev/null
blob + acb8ba63305c55bdfbb361a03bc50226b6a2849e (mode 644)
--- /dev/null
+++ src/sixel.rs
@@ -0,0 +1,304 @@
+//! Sixel graphics decoder.
+//!
+//! Decodes a DCS sixel payload into an RGBA pixel buffer.
+
+/// Decoded sixel image.
+pub struct SixelImage {
+    pub width: usize,
+    pub height: usize,
+    /// Row-major pixel data, 0xRRGGBB per pixel.
+    pub pixels: Vec<u32>,
+    /// Grid position (set by caller after decode).
+    pub row: usize,
+    pub col: usize,
+}
+
+/// Default 16-color palette (VGA).
+const DEFAULT_PALETTE: [u32; 16] = [
+    0x000000, 0xcd0000, 0x00cd00, 0xcdcd00, 0x0000ee, 0xcd00cd,
+    0x00cdcd, 0xe5e5e5, 0x7f7f7f, 0xff0000, 0x00ff00, 0xffff00,
+    0x5c5cff, 0xff00ff, 0x00ffff, 0xffffff,
+];
+
+/// Decode a DCS sixel payload (bytes after ESC P, including params and q).
+pub fn decode(data: &[u8], bg: u32) -> Option<SixelImage> {
+    // Skip params, find 'q' introducer.
+    let q_pos = data.iter().position(|&b| b == b'q')?;
+    let body = &data[q_pos + 1..];
+
+    let mut palette = [0u32; 256];
+    palette[..16].copy_from_slice(&DEFAULT_PALETTE);
+
+    // Pre-allocate a reasonable buffer; will grow as needed.
+    let mut width: usize = 0;
+    let mut height: usize = 0;
+    let mut pixels: Vec<u32> = Vec::new();
+    let mut buf_w: usize = 0; // allocated width
+    let mut buf_h: usize = 0; // allocated height in pixels
+
+    let mut x: usize = 0;
+    let mut band: usize = 0; // each band = 6 rows
+    let mut color: usize = 0;
+
+    let mut i = 0;
+    while i < body.len() {
+        let b = body[i];
+        match b {
+            // Color command.
+            b'#' => {
+                i += 1;
+                let reg = read_num(body, &mut i);
+                if i < body.len() && body[i] == b';' {
+                    // Color definition: #reg;type;a;b;c
+                    i += 1;
+                    let ctype = read_num(body, &mut i);
+                    skip_semi(body, &mut i);
+                    let a = read_num(body, &mut i);
+                    skip_semi(body, &mut i);
+                    let bv = read_num(body, &mut i);
+                    skip_semi(body, &mut i);
+                    let c = read_num(body, &mut i);
+                    if (reg as usize) < 256 {
+                        palette[reg as usize] = if ctype == 2 {
+                            rgb100_to_rgb(a, bv, c)
+                        } else {
+                            hls_to_rgb(a, bv, c)
+                        };
+                    }
+                }
+                if (reg as usize) < 256 {
+                    color = reg as usize;
+                }
+                continue;
+            }
+            // Repeat.
+            b'!' => {
+                i += 1;
+                let count = read_num(body, &mut i).max(1) as usize;
+                if i < body.len() {
+                    let sc = body[i];
+                    i += 1;
+                    if sc >= 0x3F && sc <= 0x7E {
+                        let bits = sc - 0x3F;
+                        ensure_size(
+                            &mut pixels,
+                            &mut buf_w,
+                            &mut buf_h,
+                            x + count,
+                            band * 6 + 6,
+                            bg,
+                        );
+                        for dx in 0..count {
+                            put_sixel(
+                                &mut pixels,
+                                buf_w,
+                                x + dx,
+                                band,
+                                bits,
+                                palette[color],
+                            );
+                        }
+                        x += count;
+                        if x > width {
+                            width = x;
+                        }
+                        let h = band * 6 + 6;
+                        if h > height {
+                            height = h;
+                        }
+                    }
+                }
+                continue;
+            }
+            // Carriage return.
+            b'$' => {
+                x = 0;
+                i += 1;
+                continue;
+            }
+            // New line (next band).
+            b'-' => {
+                x = 0;
+                band += 1;
+                i += 1;
+                continue;
+            }
+            // Sixel data character.
+            0x3F..=0x7E => {
+                let bits = b - 0x3F;
+                ensure_size(
+                    &mut pixels,
+                    &mut buf_w,
+                    &mut buf_h,
+                    x + 1,
+                    band * 6 + 6,
+                    bg,
+                );
+                put_sixel(
+                    &mut pixels,
+                    buf_w,
+                    x,
+                    band,
+                    bits,
+                    palette[color],
+                );
+                x += 1;
+                if x > width {
+                    width = x;
+                }
+                let h = band * 6 + 6;
+                if h > height {
+                    height = h;
+                }
+                i += 1;
+                continue;
+            }
+            _ => {
+                i += 1;
+                continue;
+            }
+        }
+    }
+
+    if width == 0 || height == 0 {
+        return None;
+    }
+
+    // Trim buffer to actual dimensions.
+    let mut trimmed = vec![bg; width * height];
+    for y in 0..height {
+        let src_start = y * buf_w;
+        let dst_start = y * width;
+        let src_end = src_start + width.min(buf_w);
+        let dst_end = dst_start + width.min(buf_w);
+        trimmed[dst_start..dst_end]
+            .copy_from_slice(&pixels[src_start..src_end]);
+    }
+
+    Some(SixelImage {
+        width,
+        height,
+        pixels: trimmed,
+        row: 0,
+        col: 0,
+    })
+}
+
+/// Paint one sixel (6 vertical pixels) at (x, band*6).
+fn put_sixel(
+    pixels: &mut [u32],
+    buf_w: usize,
+    x: usize,
+    band: usize,
+    bits: u8,
+    color: u32,
+) {
+    let y0 = band * 6;
+    for bit in 0..6u8 {
+        if bits & (1 << bit) != 0 {
+            let y = y0 + bit as usize;
+            let idx = y * buf_w + x;
+            if idx < pixels.len() {
+                pixels[idx] = color;
+            }
+        }
+    }
+}
+
+/// Grow pixel buffer if needed.
+fn ensure_size(
+    pixels: &mut Vec<u32>,
+    buf_w: &mut usize,
+    buf_h: &mut usize,
+    need_w: usize,
+    need_h: usize,
+    bg: u32,
+) {
+    if need_w <= *buf_w && need_h <= *buf_h {
+        return;
+    }
+    let new_w = need_w.max(*buf_w).max(256);
+    let new_h = need_h.max(*buf_h).max(64);
+    if new_w != *buf_w || new_h != *buf_h {
+        let mut new = vec![bg; new_w * new_h];
+        // Copy old data.
+        let copy_h = (*buf_h).min(new_h);
+        let copy_w = (*buf_w).min(new_w);
+        for y in 0..copy_h {
+            let src = y * *buf_w;
+            let dst = y * new_w;
+            new[dst..dst + copy_w]
+                .copy_from_slice(&pixels[src..src + copy_w]);
+        }
+        *pixels = new;
+        *buf_w = new_w;
+        *buf_h = new_h;
+    }
+}
+
+fn read_num(data: &[u8], i: &mut usize) -> u32 {
+    let mut val: u32 = 0;
+    while *i < data.len() && data[*i].is_ascii_digit() {
+        val = val
+            .saturating_mul(10)
+            .saturating_add((data[*i] - b'0') as u32);
+        *i += 1;
+    }
+    val
+}
+
+fn skip_semi(data: &[u8], i: &mut usize) {
+    if *i < data.len() && data[*i] == b';' {
+        *i += 1;
+    }
+}
+
+/// Convert RGB components in 0-100 range to 0xRRGGBB.
+fn rgb100_to_rgb(r: u32, g: u32, b: u32) -> u32 {
+    let r8 = (r * 255 / 100).min(255);
+    let g8 = (g * 255 / 100).min(255);
+    let b8 = (b * 255 / 100).min(255);
+    (r8 << 16) | (g8 << 8) | b8
+}
+
+/// Convert HLS (hue 0-360, lightness 0-100, saturation 0-100) to 0xRRGGBB.
+fn hls_to_rgb(h: u32, l: u32, s: u32) -> u32 {
+    if s == 0 {
+        let v = (l * 255 / 100).min(255);
+        return (v << 16) | (v << 8) | v;
+    }
+    let hf = (h % 360) as f64;
+    let lf = l as f64 / 100.0;
+    let sf = s as f64 / 100.0;
+    let q = if lf < 0.5 {
+        lf * (1.0 + sf)
+    } else {
+        lf + sf - lf * sf
+    };
+    let p = 2.0 * lf - q;
+    let r = hue_to_rgb(p, q, hf / 360.0 + 1.0 / 3.0);
+    let g = hue_to_rgb(p, q, hf / 360.0);
+    let b = hue_to_rgb(p, q, hf / 360.0 - 1.0 / 3.0);
+    let r8 = (r * 255.0).min(255.0) as u32;
+    let g8 = (g * 255.0).min(255.0) as u32;
+    let b8 = (b * 255.0).min(255.0) as u32;
+    (r8 << 16) | (g8 << 8) | b8
+}
+
+fn hue_to_rgb(p: f64, q: f64, mut t: f64) -> f64 {
+    if t < 0.0 {
+        t += 1.0;
+    }
+    if t > 1.0 {
+        t -= 1.0;
+    }
+    if t < 1.0 / 6.0 {
+        p + (q - p) * 6.0 * t
+    } else if t < 0.5 {
+        q
+    } else if t < 2.0 / 3.0 {
+        p + (q - p) * (2.0 / 3.0 - t) * 6.0
+    } else {
+        p
+    }
+}
blob - /dev/null
blob + 11b72d700bf6e217a91fd316231049dc449f1edf (mode 644)
--- /dev/null
+++ src/term.rs
@@ -0,0 +1,1037 @@
+//! Terminal state — grid, cursor, VT100/xterm escape parser, scrollback.
+
+use std::collections::VecDeque;
+
+pub const ATTR_BOLD: u8 = 1;
+pub const ATTR_UNDERLINE: u8 = 2;
+pub const ATTR_REVERSE: u8 = 4;
+
+pub const DEFAULT_FG: u8 = 7;
+pub const DEFAULT_BG: u8 = 0;
+
+const MAX_SCROLLBACK: usize = 10_000;
+const CSI_BUF_MAX: usize = 64;
+
+#[derive(Clone, Copy)]
+pub struct Cell {
+    pub ch: char,
+    pub fg: u8,
+    pub bg: u8,
+    pub attr: u8,
+}
+
+impl Cell {
+    pub fn blank() -> Self {
+        Cell {
+            ch: ' ',
+            fg: DEFAULT_FG,
+            bg: DEFAULT_BG,
+            attr: 0,
+        }
+    }
+
+    fn with_attrs(fg: u8, bg: u8, attr: u8) -> Self {
+        Cell {
+            ch: ' ',
+            fg,
+            bg,
+            attr,
+        }
+    }
+}
+
+#[derive(Clone, Copy)]
+struct Cursor {
+    row: usize,
+    col: usize,
+}
+
+#[derive(PartialEq)]
+enum State {
+    Ground,
+    Escape,
+    Csi,
+    Osc,
+    OscEscape,
+    SkipOne,
+    Utf8,
+    Dcs,
+    DcsEscape,
+}
+
+pub struct Term {
+    pub rows: usize,
+    pub cols: usize,
+    pub cell_h: usize,
+    grid: Vec<Cell>,
+    alt_grid: Vec<Cell>,
+    alt_screen: bool,
+    cursor: Cursor,
+    saved: Cursor,
+    pub cursor_visible: bool,
+    // Current drawing attributes.
+    fg: u8,
+    bg: u8,
+    attr: u8,
+    // Scroll region (inclusive).
+    scroll_top: usize,
+    scroll_bot: usize,
+    // Parser.
+    state: State,
+    csi_buf: [u8; CSI_BUF_MAX],
+    csi_len: usize,
+    // Scrollback.
+    scrollback: VecDeque<Vec<Cell>>,
+    pub scroll_offset: usize,
+    // Modes.
+    pub app_cursor: bool,
+    pub bracketed_paste: bool,
+    wrap_next: bool,
+    // Pending response bytes (DA, DSR).
+    pub response: Vec<u8>,
+    // UTF-8 accumulator.
+    utf8_buf: [u8; 4],
+    utf8_len: u8,
+    utf8_need: u8,
+    // OSC accumulator.
+    osc_buf: Vec<u8>,
+    // DCS (sixel) accumulator.
+    dcs_buf: Vec<u8>,
+    // Sixel images placed on the grid.
+    pub sixel_images: Vec<crate::sixel::SixelImage>,
+    // Pending title set by OSC 0/2.
+    pub title: Option<String>,
+}
+
+impl Term {
+    pub fn new(rows: usize, cols: usize) -> Self {
+        Term {
+            rows,
+            cols,
+            cell_h: 1,
+            grid: vec![Cell::blank(); rows * cols],
+            alt_grid: vec![Cell::blank(); rows * cols],
+            alt_screen: false,
+            cursor: Cursor { row: 0, col: 0 },
+            saved: Cursor { row: 0, col: 0 },
+            cursor_visible: true,
+            fg: DEFAULT_FG,
+            bg: DEFAULT_BG,
+            attr: 0,
+            scroll_top: 0,
+            scroll_bot: rows - 1,
+            state: State::Ground,
+            csi_buf: [0; CSI_BUF_MAX],
+            csi_len: 0,
+            scrollback: VecDeque::new(),
+            scroll_offset: 0,
+            app_cursor: false,
+            bracketed_paste: false,
+            wrap_next: false,
+            response: Vec::new(),
+            utf8_buf: [0; 4],
+            utf8_len: 0,
+            utf8_need: 0,
+            osc_buf: Vec::new(),
+            dcs_buf: Vec::new(),
+            sixel_images: Vec::new(),
+            title: None,
+        }
+    }
+
+    // --- Public accessors ---
+
+    pub fn cursor_row(&self) -> usize {
+        self.cursor.row
+    }
+
+    pub fn cursor_col(&self) -> usize {
+        self.cursor.col
+    }
+
+    /// Return the cells for a display row, accounting for scroll offset.
+    pub fn get_row(&self, screen_row: usize) -> &[Cell] {
+        if self.scroll_offset == 0 {
+            let s = screen_row * self.cols;
+            &self.grid[s..s + self.cols]
+        } else {
+            let sb_len = self.scrollback.len();
+            let vline = sb_len - self.scroll_offset + screen_row;
+            if vline < sb_len {
+                &self.scrollback[vline]
+            } else {
+                let grid_row = vline - sb_len;
+                let s = grid_row * self.cols;
+                &self.grid[s..s + self.cols]
+            }
+        }
+    }
+
+    pub fn get_cell(&self, row: usize, col: usize) -> Cell {
+        self.grid[row * self.cols + col]
+    }
+
+    pub fn scroll_view_up(&mut self, n: usize) {
+        let max = self.scrollback.len();
+        self.scroll_offset = (self.scroll_offset + n).min(max);
+    }
+
+    pub fn scroll_view_down(&mut self, n: usize) {
+        self.scroll_offset = self.scroll_offset.saturating_sub(n);
+    }
+
+    pub fn resize(&mut self, rows: usize, cols: usize) {
+        let mut new = vec![Cell::blank(); rows * cols];
+        let cr = rows.min(self.rows);
+        let cc = cols.min(self.cols);
+        for r in 0..cr {
+            for c in 0..cc {
+                new[r * cols + c] = self.grid[r * self.cols + c];
+            }
+        }
+        self.grid = new;
+        self.alt_grid = vec![Cell::blank(); rows * cols];
+        self.rows = rows;
+        self.cols = cols;
+        self.scroll_top = 0;
+        self.scroll_bot = rows - 1;
+        self.cursor.row = self.cursor.row.min(rows - 1);
+        self.cursor.col = self.cursor.col.min(cols - 1);
+        self.scroll_offset = 0;
+    }
+
+    // --- Byte processing ---
+
+    pub fn process(&mut self, data: &[u8]) {
+        for &b in data {
+            self.process_byte(b);
+        }
+    }
+
+    fn process_byte(&mut self, b: u8) {
+        match self.state {
+            State::Ground => self.ground(b),
+            State::Escape => self.escape(b),
+            State::Csi => self.csi_collect(b),
+            State::Osc => self.osc_collect(b),
+            State::OscEscape => self.osc_escape(b),
+            State::SkipOne => self.state = State::Ground,
+            State::Utf8 => self.utf8_collect(b),
+            State::Dcs => self.dcs_collect(b),
+            State::DcsEscape => self.dcs_escape(b),
+        }
+    }
+
+    fn ground(&mut self, b: u8) {
+        match b {
+            0x07 => {} // BEL — ignore
+            0x08 => self.backspace(),
+            0x09 => self.tab(),
+            0x0A..=0x0C => self.newline(),
+            0x0D => self.carriage_return(),
+            0x1B => self.state = State::Escape,
+            0x20..=0x7E => self.put_char(b as char),
+            0xC0..=0xDF => self.utf8_start(b, 2),
+            0xE0..=0xEF => self.utf8_start(b, 3),
+            0xF0..=0xF7 => self.utf8_start(b, 4),
+            _ => {}
+        }
+    }
+
+    fn utf8_start(&mut self, b: u8, need: u8) {
+        self.utf8_buf[0] = b;
+        self.utf8_len = 1;
+        self.utf8_need = need;
+        self.state = State::Utf8;
+    }
+
+    fn utf8_collect(&mut self, b: u8) {
+        if b & 0xC0 != 0x80 {
+            // Invalid continuation — drop and reprocess.
+            self.state = State::Ground;
+            self.ground(b);
+            return;
+        }
+        self.utf8_buf[self.utf8_len as usize] = b;
+        self.utf8_len += 1;
+        if self.utf8_len == self.utf8_need {
+            self.state = State::Ground;
+            let s = &self.utf8_buf[..self.utf8_len as usize];
+            if let Ok(s) = std::str::from_utf8(s) {
+                if let Some(ch) = s.chars().next() {
+                    self.put_char(ch);
+                }
+            }
+        }
+    }
+
+    fn dcs_collect(&mut self, b: u8) {
+        match b {
+            0x1B => self.state = State::DcsEscape,
+            0x07 => self.dcs_finalize(),
+            _ => {
+                // Cap at 4 MB to prevent unbounded growth.
+                if self.dcs_buf.len() < 4 * 1024 * 1024 {
+                    self.dcs_buf.push(b);
+                }
+            }
+        }
+    }
+
+    fn dcs_escape(&mut self, b: u8) {
+        if b == b'\\' {
+            self.dcs_finalize();
+        } else {
+            // Not ST — push the ESC and byte, stay in DCS.
+            if self.dcs_buf.len() < 4 * 1024 * 1024 {
+                self.dcs_buf.push(0x1B);
+                self.dcs_buf.push(b);
+            }
+            self.state = State::Dcs;
+        }
+    }
+
+    fn dcs_finalize(&mut self) {
+        self.state = State::Ground;
+        // Check if this is a sixel sequence (contains 'q').
+        if self.dcs_buf.iter().any(|&b| b == b'q') {
+            if let Some(mut img) =
+                crate::sixel::decode(&self.dcs_buf, 0xffffff)
+            {
+                img.row = self.cursor.row;
+                img.col = self.cursor.col;
+                // Advance cursor past the image.
+                let img_rows = if self.cell_h > 0 {
+                    (img.height + self.cell_h - 1) / self.cell_h
+                } else {
+                    1
+                };
+                for _ in 0..img_rows {
+                    if self.cursor.row == self.scroll_bot {
+                        self.scroll_up(1);
+                        // Image scrolls up with the grid.
+                        if img.row > 0 {
+                            img.row -= 1;
+                        }
+                    } else if self.cursor.row < self.rows - 1 {
+                        self.cursor.row += 1;
+                    }
+                }
+                self.cursor.col = 0;
+                self.sixel_images.push(img);
+            }
+        }
+        self.dcs_buf.clear();
+    }
+
+    fn escape(&mut self, b: u8) {
+        self.state = State::Ground;
+        match b {
+            b'[' => {
+                self.csi_len = 0;
+                self.state = State::Csi;
+            }
+            b']' => {
+                self.osc_buf.clear();
+                self.state = State::Osc;
+            }
+            b'D' => self.index(),
+            b'M' => self.reverse_index(),
+            b'c' => self.reset(),
+            b'7' => self.saved = self.cursor,
+            b'8' => {
+                self.cursor = self.saved;
+                self.clamp_cursor();
+            }
+            b'P' => {
+                self.dcs_buf.clear();
+                self.state = State::Dcs;
+            }
+            b'(' | b')' | b'*' | b'+' => self.state = State::SkipOne,
+            _ => {
+                log_debug!("unhandled ESC {:?}", b as char);
+            }
+        }
+    }
+
+    fn csi_collect(&mut self, b: u8) {
+        match b {
+            0x20..=0x3F => {
+                // Parameter or intermediate byte.
+                if self.csi_len < CSI_BUF_MAX {
+                    self.csi_buf[self.csi_len] = b;
+                    self.csi_len += 1;
+                }
+            }
+            0x40..=0x7E => {
+                // Final byte — dispatch.
+                self.state = State::Ground;
+                self.csi_dispatch(b);
+            }
+            _ => self.state = State::Ground,
+        }
+    }
+
+    fn osc_collect(&mut self, b: u8) {
+        if b == 0x07 {
+            self.osc_dispatch();
+        } else if b == 0x1B {
+            self.state = State::OscEscape;
+        } else if self.osc_buf.len() < 4096 {
+            self.osc_buf.push(b);
+        }
+    }
+
+    fn osc_escape(&mut self, b: u8) {
+        if b == b'\\' {
+            self.osc_dispatch();
+        } else {
+            // Not ST — discard.
+            self.state = State::Ground;
+            self.osc_buf.clear();
+        }
+    }
+
+    fn osc_dispatch(&mut self) {
+        self.state = State::Ground;
+        // OSC format: "Ps;Pt" where Ps is the command number.
+        if let Some(semi) =
+            self.osc_buf.iter().position(|&b| b == b';')
+        {
+            if let Ok(s) =
+                std::str::from_utf8(&self.osc_buf[..semi])
+            {
+                if let Ok(cmd) = s.parse::<u32>() {
+                    let payload = &self.osc_buf[semi + 1..];
+                    if let Ok(text) = std::str::from_utf8(payload)
+                    {
+                        match cmd {
+                            // 0 = icon name + title, 2 = title.
+                            0 | 2 => {
+                                self.title =
+                                    Some(text.to_string());
+                            }
+                            _ => {}
+                        }
+                    }
+                }
+            }
+        }
+        self.osc_buf.clear();
+    }
+
+    // --- CSI dispatch ---
+
+    fn csi_dispatch(&mut self, ch: u8) {
+        let buf = &self.csi_buf[..self.csi_len];
+        let private = buf.first() == Some(&b'?');
+        let pstart = if private { 1 } else { 0 };
+        let params = parse_params(&buf[pstart..]);
+
+        let p0 = params.first().copied().unwrap_or(0);
+        let p1 = params.get(1).copied().unwrap_or(0);
+
+        if private {
+            match ch {
+                b'h' => self.set_private_mode(&params, true),
+                b'l' => self.set_private_mode(&params, false),
+                _ => {
+                    log_debug!(
+                        "unhandled CSI ?{} {}",
+                        params.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(";"),
+                        ch as char
+                    );
+                }
+            }
+            return;
+        }
+
+        self.wrap_next = false;
+
+        match ch {
+            b'A' => self.cursor_up(p0.max(1) as usize),
+            b'B' | b'e' => self.cursor_down(p0.max(1) as usize),
+            b'C' | b'a' => self.cursor_forward(p0.max(1) as usize),
+            b'D' => self.cursor_back(p0.max(1) as usize),
+            b'E' => {
+                self.cursor_down(p0.max(1) as usize);
+                self.cursor.col = 0;
+            }
+            b'F' => {
+                self.cursor_up(p0.max(1) as usize);
+                self.cursor.col = 0;
+            }
+            b'G' | b'`' => {
+                self.cursor.col =
+                    (p0.max(1) as usize - 1).min(self.cols - 1);
+            }
+            b'H' | b'f' => self.set_cursor(
+                p0.max(1) as usize - 1,
+                p1.max(1) as usize - 1,
+            ),
+            b'J' => self.erase_display(p0 as usize),
+            b'K' => self.erase_line(p0 as usize),
+            b'L' => self.insert_lines(p0.max(1) as usize),
+            b'M' => self.delete_lines(p0.max(1) as usize),
+            b'P' => self.delete_chars(p0.max(1) as usize),
+            b'S' => self.scroll_up(p0.max(1) as usize),
+            b'T' => self.scroll_down(p0.max(1) as usize),
+            b'X' => self.erase_chars(p0.max(1) as usize),
+            b'@' => self.insert_chars(p0.max(1) as usize),
+            b'd' => {
+                self.cursor.row =
+                    (p0.max(1) as usize - 1).min(self.rows - 1);
+            }
+            b'm' => self.sgr(&params),
+            b'n' => self.device_status(&params),
+            b'r' => {
+                self.set_scroll_region(p0 as usize, p1 as usize)
+            }
+            b's' => self.saved = self.cursor,
+            b'u' => {
+                self.cursor = self.saved;
+                self.clamp_cursor();
+            }
+            b'c' => self.device_attributes(),
+            b't' => {} // Window ops — ignore.
+            _ => {
+                log_debug!(
+                    "unhandled CSI {} {}",
+                    params.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(";"),
+                    ch as char
+                );
+            }
+        }
+    }
+
+    // --- Terminal operations ---
+
+    fn put_char(&mut self, ch: char) {
+        if self.wrap_next {
+            self.cursor.col = 0;
+            if self.cursor.row == self.scroll_bot {
+                self.scroll_up(1);
+            } else if self.cursor.row < self.rows - 1 {
+                self.cursor.row += 1;
+            }
+            self.wrap_next = false;
+        }
+
+        let idx = self.cursor.row * self.cols + self.cursor.col;
+        self.grid[idx] = Cell {
+            ch,
+            fg: self.fg,
+            bg: self.bg,
+            attr: self.attr,
+        };
+
+        if self.cursor.col < self.cols - 1 {
+            self.cursor.col += 1;
+        } else {
+            self.wrap_next = true;
+        }
+    }
+
+    fn newline(&mut self) {
+        if self.cursor.row == self.scroll_bot {
+            self.scroll_up(1);
+        } else if self.cursor.row < self.rows - 1 {
+            self.cursor.row += 1;
+        }
+    }
+
+    fn carriage_return(&mut self) {
+        self.cursor.col = 0;
+        self.wrap_next = false;
+    }
+
+    fn backspace(&mut self) {
+        if self.cursor.col > 0 {
+            self.cursor.col -= 1;
+            self.wrap_next = false;
+        }
+    }
+
+    fn tab(&mut self) {
+        let next = (self.cursor.col + 8) & !7;
+        self.cursor.col = next.min(self.cols - 1);
+        self.wrap_next = false;
+    }
+
+    fn index(&mut self) {
+        if self.cursor.row == self.scroll_bot {
+            self.scroll_up(1);
+        } else if self.cursor.row < self.rows - 1 {
+            self.cursor.row += 1;
+        }
+    }
+
+    fn reverse_index(&mut self) {
+        if self.cursor.row == self.scroll_top {
+            self.scroll_down(1);
+        } else if self.cursor.row > 0 {
+            self.cursor.row -= 1;
+        }
+    }
+
+    fn set_cursor(&mut self, row: usize, col: usize) {
+        self.cursor.row = row.min(self.rows - 1);
+        self.cursor.col = col.min(self.cols - 1);
+        self.wrap_next = false;
+    }
+
+    fn cursor_up(&mut self, n: usize) {
+        self.cursor.row = self.cursor.row.saturating_sub(n);
+    }
+
+    fn cursor_down(&mut self, n: usize) {
+        self.cursor.row =
+            (self.cursor.row + n).min(self.rows - 1);
+    }
+
+    fn cursor_forward(&mut self, n: usize) {
+        self.cursor.col =
+            (self.cursor.col + n).min(self.cols - 1);
+    }
+
+    fn cursor_back(&mut self, n: usize) {
+        self.cursor.col = self.cursor.col.saturating_sub(n);
+    }
+
+    fn clamp_cursor(&mut self) {
+        self.cursor.row = self.cursor.row.min(self.rows - 1);
+        self.cursor.col = self.cursor.col.min(self.cols - 1);
+    }
+
+    // --- Scroll ---
+
+    fn scroll_up(&mut self, n: usize) {
+        for _ in 0..n {
+            // Save top line to scrollback (main screen only).
+            if self.scroll_top == 0 && !self.alt_screen {
+                let s = 0;
+                let row = self.grid[s..s + self.cols].to_vec();
+                self.scrollback.push_back(row);
+                if self.scrollback.len() > MAX_SCROLLBACK {
+                    self.scrollback.pop_front();
+                }
+            }
+            // Shift lines up within scroll region.
+            let top = self.scroll_top * self.cols;
+            let bot = (self.scroll_bot + 1) * self.cols;
+            if top + self.cols < bot {
+                self.grid.copy_within(top + self.cols..bot, top);
+            }
+            // Clear bottom line of region.
+            let clr = self.scroll_bot * self.cols;
+            for i in clr..clr + self.cols {
+                self.grid[i] = Cell::with_attrs(
+                    self.fg, self.bg, self.attr,
+                );
+            }
+        }
+        // Shift sixel images up; discard those that scroll off.
+        self.sixel_images.retain_mut(|img| {
+            if img.row >= n {
+                img.row -= n;
+                true
+            } else {
+                false
+            }
+        });
+    }
+
+    fn scroll_down(&mut self, n: usize) {
+        for _ in 0..n {
+            let top = self.scroll_top * self.cols;
+            let bot = (self.scroll_bot + 1) * self.cols;
+            if top + self.cols < bot {
+                self.grid
+                    .copy_within(top..bot - self.cols, top + self.cols);
+            }
+            for i in top..top + self.cols {
+                self.grid[i] = Cell::with_attrs(
+                    self.fg, self.bg, self.attr,
+                );
+            }
+        }
+    }
+
+    fn set_scroll_region(&mut self, top: usize, bot: usize) {
+        let t = if top == 0 { 0 } else { top - 1 };
+        let b = if bot == 0 {
+            self.rows - 1
+        } else {
+            (bot - 1).min(self.rows - 1)
+        };
+        if t < b {
+            self.scroll_top = t;
+            self.scroll_bot = b;
+        }
+        self.cursor.row = 0;
+        self.cursor.col = 0;
+        self.wrap_next = false;
+    }
+
+    // --- Erase ---
+
+    fn erase_display(&mut self, mode: usize) {
+        let blank = Cell::with_attrs(self.fg, self.bg, self.attr);
+        match mode {
+            0 => {
+                // Cursor to end.
+                let start = self.cursor.row * self.cols + self.cursor.col;
+                for i in start..self.rows * self.cols {
+                    self.grid[i] = blank;
+                }
+            }
+            1 => {
+                // Start to cursor.
+                let end = self.cursor.row * self.cols + self.cursor.col;
+                for i in 0..=end {
+                    self.grid[i] = blank;
+                }
+            }
+            2 | 3 => {
+                // Entire screen.
+                for c in &mut self.grid {
+                    *c = blank;
+                }
+                self.sixel_images.clear();
+            }
+            _ => {}
+        }
+    }
+
+    fn erase_line(&mut self, mode: usize) {
+        let blank = Cell::with_attrs(self.fg, self.bg, self.attr);
+        let row_start = self.cursor.row * self.cols;
+        match mode {
+            0 => {
+                for i in (row_start + self.cursor.col)
+                    ..row_start + self.cols
+                {
+                    self.grid[i] = blank;
+                }
+            }
+            1 => {
+                for i in row_start..=row_start + self.cursor.col {
+                    self.grid[i] = blank;
+                }
+            }
+            2 => {
+                for i in row_start..row_start + self.cols {
+                    self.grid[i] = blank;
+                }
+            }
+            _ => {}
+        }
+    }
+
+    fn erase_chars(&mut self, n: usize) {
+        let blank = Cell::with_attrs(self.fg, self.bg, self.attr);
+        let start = self.cursor.row * self.cols + self.cursor.col;
+        let end = (start + n).min(self.cursor.row * self.cols + self.cols);
+        for i in start..end {
+            self.grid[i] = blank;
+        }
+    }
+
+    // --- Insert / Delete ---
+
+    fn insert_lines(&mut self, n: usize) {
+        if self.cursor.row < self.scroll_top
+            || self.cursor.row > self.scroll_bot
+        {
+            return;
+        }
+        let blank = Cell::with_attrs(self.fg, self.bg, self.attr);
+        for _ in 0..n {
+            let src = self.cursor.row * self.cols;
+            let bot = (self.scroll_bot + 1) * self.cols;
+            if src + self.cols < bot {
+                self.grid.copy_within(
+                    src..bot - self.cols,
+                    src + self.cols,
+                );
+            }
+            for i in src..src + self.cols {
+                self.grid[i] = blank;
+            }
+        }
+    }
+
+    fn delete_lines(&mut self, n: usize) {
+        if self.cursor.row < self.scroll_top
+            || self.cursor.row > self.scroll_bot
+        {
+            return;
+        }
+        let blank = Cell::with_attrs(self.fg, self.bg, self.attr);
+        for _ in 0..n {
+            let src = self.cursor.row * self.cols;
+            let bot = (self.scroll_bot + 1) * self.cols;
+            if src + self.cols < bot {
+                self.grid
+                    .copy_within(src + self.cols..bot, src);
+            }
+            let clr = self.scroll_bot * self.cols;
+            for i in clr..clr + self.cols {
+                self.grid[i] = blank;
+            }
+        }
+    }
+
+    fn insert_chars(&mut self, n: usize) {
+        let row_start = self.cursor.row * self.cols;
+        let src = row_start + self.cursor.col;
+        let row_end = row_start + self.cols;
+        if src + n < row_end {
+            self.grid
+                .copy_within(src..row_end - n, src + n);
+        }
+        let blank = Cell::with_attrs(self.fg, self.bg, self.attr);
+        let end = (src + n).min(row_end);
+        for i in src..end {
+            self.grid[i] = blank;
+        }
+    }
+
+    fn delete_chars(&mut self, n: usize) {
+        let row_start = self.cursor.row * self.cols;
+        let src = row_start + self.cursor.col;
+        let row_end = row_start + self.cols;
+        if src + n < row_end {
+            self.grid
+                .copy_within(src + n..row_end, src);
+        }
+        let blank = Cell::with_attrs(self.fg, self.bg, self.attr);
+        let clr_start = row_end.saturating_sub(n);
+        for i in clr_start..row_end {
+            self.grid[i] = blank;
+        }
+    }
+
+    // --- SGR (Select Graphic Rendition) ---
+
+    fn sgr(&mut self, params: &[u16]) {
+        if params.is_empty() {
+            self.reset_attrs();
+            return;
+        }
+        let mut i = 0;
+        while i < params.len() {
+            match params[i] {
+                0 => self.reset_attrs(),
+                1 => self.attr |= ATTR_BOLD,
+                4 => self.attr |= ATTR_UNDERLINE,
+                7 => self.attr |= ATTR_REVERSE,
+                22 => self.attr &= !ATTR_BOLD,
+                24 => self.attr &= !ATTR_UNDERLINE,
+                27 => self.attr &= !ATTR_REVERSE,
+                30..=37 => self.fg = (params[i] - 30) as u8,
+                38 => {
+                    if i + 2 < params.len() && params[i + 1] == 5 {
+                        self.fg = params[i + 2] as u8;
+                        i += 2;
+                    }
+                }
+                39 => self.fg = DEFAULT_FG,
+                40..=47 => self.bg = (params[i] - 40) as u8,
+                48 => {
+                    if i + 2 < params.len() && params[i + 1] == 5 {
+                        self.bg = params[i + 2] as u8;
+                        i += 2;
+                    }
+                }
+                49 => self.bg = DEFAULT_BG,
+                90..=97 => self.fg = (params[i] - 90 + 8) as u8,
+                100..=107 => self.bg = (params[i] - 100 + 8) as u8,
+                _ => {}
+            }
+            i += 1;
+        }
+    }
+
+    fn reset_attrs(&mut self) {
+        self.fg = DEFAULT_FG;
+        self.bg = DEFAULT_BG;
+        self.attr = 0;
+    }
+
+    // --- Modes ---
+
+    fn set_private_mode(&mut self, params: &[u16], on: bool) {
+        for &p in params {
+            match p {
+                1 => self.app_cursor = on,
+                7 => {} // DECAWM — always on.
+                12 => {}
+                25 => self.cursor_visible = on,
+                1049 => self.set_alt_screen(on),
+                2004 => self.bracketed_paste = on,
+                _ => {
+                    log_debug!(
+                        "unhandled DECPM ?{} {}",
+                        p,
+                        if on { 'h' } else { 'l' }
+                    );
+                }
+            }
+        }
+    }
+
+    fn set_alt_screen(&mut self, on: bool) {
+        if on && !self.alt_screen {
+            std::mem::swap(&mut self.grid, &mut self.alt_grid);
+            self.saved = self.cursor;
+            self.alt_screen = true;
+            self.erase_display(2);
+        } else if !on && self.alt_screen {
+            std::mem::swap(&mut self.grid, &mut self.alt_grid);
+            self.cursor = self.saved;
+            self.clamp_cursor();
+            self.alt_screen = false;
+        }
+    }
+
+    // --- Device queries ---
+
+    fn device_attributes(&mut self) {
+        // Report as VT100.
+        self.response.extend_from_slice(b"\x1b[?1;2c");
+    }
+
+    fn device_status(&mut self, params: &[u16]) {
+        if params.first() == Some(&6) {
+            // Cursor position report.
+            let r = format!(
+                "\x1b[{};{}R",
+                self.cursor.row + 1,
+                self.cursor.col + 1
+            );
+            self.response.extend_from_slice(r.as_bytes());
+        }
+    }
+
+    fn reset(&mut self) {
+        self.fg = DEFAULT_FG;
+        self.bg = DEFAULT_BG;
+        self.attr = 0;
+        self.cursor = Cursor { row: 0, col: 0 };
+        self.saved = self.cursor;
+        self.cursor_visible = true;
+        self.scroll_top = 0;
+        self.scroll_bot = self.rows - 1;
+        self.app_cursor = false;
+        self.bracketed_paste = false;
+        self.wrap_next = false;
+        self.alt_screen = false;
+        for c in &mut self.grid {
+            *c = Cell::blank();
+        }
+        self.sixel_images.clear();
+    }
+
+    // --- Text extraction ---
+
+    /// Extract text between two positions (inclusive).
+    pub fn extract_text(
+        &self,
+        sr: usize,
+        sc: usize,
+        er: usize,
+        ec: usize,
+    ) -> String {
+        let mut text = String::new();
+        for row in sr..=er {
+            let cells = self.get_row(row);
+            let c0 = if row == sr { sc } else { 0 };
+            let c1 =
+                if row == er { ec } else { self.cols - 1 };
+            for cell in cells.iter().take(c1.min(self.cols - 1) + 1).skip(c0) {
+                let ch = cell.ch;
+                text.push(if ch >= ' ' && ch != '\x7f' {
+                    ch
+                } else {
+                    ' '
+                });
+            }
+            // Trim trailing spaces, add newline between rows.
+            if row < er {
+                let trimmed = text.trim_end_matches(' ').len();
+                text.truncate(trimmed);
+                text.push('\n');
+            }
+        }
+        let trimmed = text.trim_end_matches(' ').len();
+        text.truncate(trimmed);
+        text
+    }
+
+    /// Find a URL at (row, col) in the visible grid.
+    pub fn find_url_at(
+        &self,
+        row: usize,
+        col: usize,
+    ) -> Option<String> {
+        let cells = self.get_row(row);
+        let line: String = cells
+            .iter()
+            .map(|c| {
+                if c.ch >= ' ' && c.ch != '\x7f' {
+                    c.ch
+                } else {
+                    ' '
+                }
+            })
+            .collect();
+
+        for prefix in ["https://", "http://"] {
+            let mut from = 0;
+            while let Some(pos) = line[from..].find(prefix) {
+                let start = from + pos;
+                let end = line[start..]
+                    .find(|c: char| {
+                        c.is_whitespace()
+                            || matches!(
+                                c,
+                                '"' | '\''
+                                    | '>'
+                                    | ')'
+                                    | ']'
+                                    | '}'
+                            )
+                    })
+                    .map_or(line.trim_end().len(), |e| start + e);
+                if col >= start && col < end {
+                    return Some(line[start..end].to_string());
+                }
+                from = start + prefix.len();
+            }
+        }
+        None
+    }
+}
+
+// --- Helpers ---
+
+fn parse_params(buf: &[u8]) -> Vec<u16> {
+    let mut params = Vec::new();
+    let mut val: u16 = 0;
+    let mut has_digit = false;
+    for &b in buf {
+        if b.is_ascii_digit() {
+            val = val.saturating_mul(10).saturating_add((b - b'0') as u16);
+            has_digit = true;
+        } else if b == b';' {
+            params.push(if has_digit { val } else { 0 });
+            val = 0;
+            has_digit = false;
+        }
+    }
+    if has_digit || !params.is_empty() {
+        params.push(if has_digit { val } else { 0 });
+    }
+    params
+}
blob - /dev/null
blob + fb306781b69c6d8036b92b857dfe355d5a770369 (mode 644)
--- /dev/null
+++ src/x11.rs
@@ -0,0 +1,1075 @@
+//! X11 window — rendering, input, selection, clipboard via xcb + Xft.
+
+use std::os::raw::c_ulong;
+
+use xcb::x;
+use xcb::{Connection, Xid};
+
+use crate::term::{
+    Cell, Term, ATTR_BOLD, ATTR_REVERSE, ATTR_UNDERLINE, DEFAULT_BG,
+    DEFAULT_FG,
+};
+use crate::xft_font::XftFont;
+
+const PAD: u16 = 8;
+const BORDER_W: u16 = 0;
+
+const FONT: &str = "Berkeley Mono Condensed:size=11";
+const FONT_BOLD: &str = "Berkeley Mono Bold Condensed:size=11";
+
+// Xterm/OpenBSD default colors (white background).
+const BG_COLOR: u32 = 0xffffff;
+const FG_COLOR: u32 = 0x000000;
+const SEL_BG: u32 = 0x0000ee;
+const SEL_FG: u32 = 0xffffff;
+
+// XTerm-col.ad from /usr/xenocara (rgb.txt values).
+const COLORS_16: [u32; 16] = [
+    0x000000, // color0  black
+    0xcd0000, // color1  red3
+    0x00cd00, // color2  green3
+    0xcdcd00, // color3  yellow3
+    0x0000ee, // color4  blue2
+    0xcd00cd, // color5  magenta3
+    0x00cdcd, // color6  cyan3
+    0xe5e5e5, // color7  gray90
+    0x7f7f7f, // color8  gray50
+    0xff0000, // color9  red
+    0x00ff00, // color10 green
+    0xffff00, // color11 yellow
+    0x5c5cff, // color12 rgb:5c/5c/ff
+    0xff00ff, // color13 magenta
+    0x00ffff, // color14 cyan
+    0xffffff, // color15 white
+];
+
+pub enum Event {
+    Key(Vec<u8>),
+    Paste(Vec<u8>),
+    OpenUrl(String),
+    Resize(u16, u16),
+    Redraw,
+    ScrollUp,
+    ScrollDown,
+    Close,
+}
+
+struct Sel {
+    active: bool,
+    dragging: bool,
+    start: (usize, usize), // (row, col)
+    end: (usize, usize),
+    text: String,
+}
+
+impl Sel {
+    fn new() -> Self {
+        Sel {
+            active: false,
+            dragging: false,
+            start: (0, 0),
+            end: (0, 0),
+            text: String::new(),
+        }
+    }
+
+    fn normalized(&self) -> ((usize, usize), (usize, usize)) {
+        if self.start <= self.end {
+            (self.start, self.end)
+        } else {
+            (self.end, self.start)
+        }
+    }
+
+    fn contains(&self, row: usize, col: usize) -> bool {
+        if !self.active {
+            return false;
+        }
+        let ((sr, sc), (er, ec)) = self.normalized();
+        if row < sr || row > er {
+            return false;
+        }
+        if sr == er {
+            return col >= sc && col <= ec;
+        }
+        if row == sr {
+            return col >= sc;
+        }
+        if row == er {
+            return col <= ec;
+        }
+        true
+    }
+}
+
+struct Atoms {
+    clipboard: x::Atom,
+    primary: x::Atom,
+    utf8_string: x::Atom,
+    targets: x::Atom,
+    ot_sel: x::Atom,
+    net_wm_name: x::Atom,
+}
+
+pub struct X11 {
+    // xft must be declared before conn so it is dropped first,
+    // while the X connection is still alive.
+    xft: XftFont,
+    conn: Connection,
+    dpy: *mut x11::xlib::Display,
+    win: x::Window,
+    pixmap: x::Pixmap,
+    gc: x::Gcontext,
+    wm_delete: x::Atom,
+    atoms: Atoms,
+    depth: u8,
+    char_w: u16,
+    char_h: u16,
+    width: u16,
+    height: u16,
+    cols: u16,
+    rows: u16,
+    sel: Sel,
+}
+
+impl X11 {
+    pub fn new(
+        cols: u16,
+        rows: u16,
+    ) -> Result<Self, Box<dyn std::error::Error>> {
+        let (conn, screen_num) =
+            Connection::connect_with_xlib_display()?;
+        let setup = conn.get_setup();
+        let screen = setup
+            .roots()
+            .nth(screen_num as usize)
+            .ok_or("no screen")?;
+        let root = screen.root();
+        let depth = screen.root_depth();
+
+        // Open Xft font.
+        let dpy = conn.get_raw_dpy();
+        let xft = unsafe { XftFont::open(dpy, screen_num, FONT, FONT_BOLD) }
+            .map_err(|e| e)?;
+        let char_w = xft.char_width() as u16;
+        let char_h = xft.height as u16;
+
+        // GC (used for fills and CopyArea, not text).
+        let gc: x::Gcontext = conn.generate_id();
+        conn.send_request(&x::CreateGc {
+            cid: gc,
+            drawable: x::Drawable::Window(root),
+            value_list: &[
+                x::Gc::Foreground(FG_COLOR),
+                x::Gc::Background(BG_COLOR),
+            ],
+        });
+
+        // Window size.
+        let width = 2 * PAD + cols * char_w;
+        let height = 2 * PAD + rows * char_h;
+        let scr_w = screen.width_in_pixels();
+        let scr_h = screen.height_in_pixels();
+        let win_x = (scr_w.saturating_sub(width)) as i16 / 2;
+        let win_y = (scr_h.saturating_sub(height)) as i16 / 2;
+
+        // Create window.
+        let win: x::Window = conn.generate_id();
+        conn.send_request(&x::CreateWindow {
+            depth: x::COPY_FROM_PARENT as u8,
+            wid: win,
+            parent: root,
+            x: win_x,
+            y: win_y,
+            width,
+            height,
+            border_width: BORDER_W,
+            class: x::WindowClass::InputOutput,
+            visual: screen.root_visual(),
+            value_list: &[
+                x::Cw::BackPixel(BG_COLOR),
+                x::Cw::EventMask(
+                    x::EventMask::EXPOSURE
+                        | x::EventMask::KEY_PRESS
+                        | x::EventMask::STRUCTURE_NOTIFY
+                        | x::EventMask::FOCUS_CHANGE
+                        | x::EventMask::BUTTON_PRESS
+                        | x::EventMask::BUTTON_RELEASE
+                        | x::EventMask::BUTTON_MOTION,
+                ),
+            ],
+        });
+
+        // Intern atoms.
+        let wm_proto_c = conn.send_request(&x::InternAtom {
+            only_if_exists: false,
+            name: b"WM_PROTOCOLS",
+        });
+        let wm_del_c = conn.send_request(&x::InternAtom {
+            only_if_exists: false,
+            name: b"WM_DELETE_WINDOW",
+        });
+        let clip_c = conn.send_request(&x::InternAtom {
+            only_if_exists: false,
+            name: b"CLIPBOARD",
+        });
+        let prim_c = conn.send_request(&x::InternAtom {
+            only_if_exists: false,
+            name: b"PRIMARY",
+        });
+        let utf8_c = conn.send_request(&x::InternAtom {
+            only_if_exists: false,
+            name: b"UTF8_STRING",
+        });
+        let tgt_c = conn.send_request(&x::InternAtom {
+            only_if_exists: false,
+            name: b"TARGETS",
+        });
+        let ot_c = conn.send_request(&x::InternAtom {
+            only_if_exists: false,
+            name: b"_OT_SEL",
+        });
+        let net_name_c = conn.send_request(&x::InternAtom {
+            only_if_exists: false,
+            name: b"_NET_WM_NAME",
+        });
+
+        let wm_protocols =
+            conn.wait_for_reply(wm_proto_c)?.atom();
+        let wm_delete = conn.wait_for_reply(wm_del_c)?.atom();
+        let atoms = Atoms {
+            clipboard: conn.wait_for_reply(clip_c)?.atom(),
+            primary: conn.wait_for_reply(prim_c)?.atom(),
+            utf8_string: conn.wait_for_reply(utf8_c)?.atom(),
+            targets: conn.wait_for_reply(tgt_c)?.atom(),
+            ot_sel: conn.wait_for_reply(ot_c)?.atom(),
+            net_wm_name: conn.wait_for_reply(net_name_c)?.atom(),
+        };
+
+        conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: win,
+            property: wm_protocols,
+            r#type: x::ATOM_ATOM,
+            data: &[wm_delete],
+        });
+
+        // Set window title.
+        conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: win,
+            property: x::ATOM_WM_NAME,
+            r#type: x::ATOM_STRING,
+            data: b"ot",
+        });
+        conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: win,
+            property: atoms.net_wm_name,
+            r#type: atoms.utf8_string,
+            data: b"ot",
+        });
+
+        // Set WM_CLASS.
+        conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: win,
+            property: x::ATOM_WM_CLASS,
+            r#type: x::ATOM_STRING,
+            data: b"ot\0ot\0",
+        });
+
+        // Create back-buffer pixmap.
+        let pixmap: x::Pixmap = conn.generate_id();
+        conn.send_request(&x::CreatePixmap {
+            depth,
+            pid: pixmap,
+            drawable: x::Drawable::Window(win),
+            width,
+            height,
+        });
+
+        conn.send_request(&x::MapWindow { window: win });
+        conn.flush()?;
+
+        Ok(X11 {
+            conn,
+            dpy,
+            win,
+            pixmap,
+            gc,
+            wm_delete,
+            atoms,
+            depth,
+            xft,
+            char_w,
+            char_h,
+            width,
+            height,
+            cols,
+            rows,
+            sel: Sel::new(),
+        })
+    }
+
+    pub fn fd(&self) -> std::os::unix::io::RawFd {
+        use std::os::unix::io::AsRawFd;
+        self.conn.as_raw_fd()
+    }
+
+    pub fn set_title(&self, title: &str) {
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.win,
+            property: x::ATOM_WM_NAME,
+            r#type: x::ATOM_STRING,
+            data: title.as_bytes(),
+        });
+        self.conn.send_request(&x::ChangeProperty {
+            mode: x::PropMode::Replace,
+            window: self.win,
+            property: self.atoms.net_wm_name,
+            r#type: self.atoms.utf8_string,
+            data: title.as_bytes(),
+        });
+        let _ = self.conn.flush();
+    }
+
+    pub fn cell_height(&self) -> u16 {
+        self.char_h
+    }
+
+    pub fn resize(&mut self, cols: u16, rows: u16) {
+        self.cols = cols;
+        self.rows = rows;
+        self.width = 2 * PAD + cols * self.char_w;
+        self.height = 2 * PAD + rows * self.char_h;
+        self.conn
+            .send_request(&x::FreePixmap { pixmap: self.pixmap });
+        let pix: x::Pixmap = self.conn.generate_id();
+        self.conn.send_request(&x::CreatePixmap {
+            depth: self.depth,
+            pid: pix,
+            drawable: x::Drawable::Window(self.win),
+            width: self.width,
+            height: self.height,
+        });
+        self.pixmap = pix;
+        let _ = self.conn.flush();
+    }
+
+    // --- Pixel <-> cell conversion ---
+
+    fn pixel_to_cell(&self, px: i16, py: i16) -> (usize, usize) {
+        let col = (px - PAD as i16).max(0) as usize
+            / self.char_w as usize;
+        let row = (py - PAD as i16).max(0) as usize
+            / self.char_h as usize;
+        (
+            row.min(self.rows as usize - 1),
+            col.min(self.cols as usize - 1),
+        )
+    }
+
+    // --- Selection / clipboard ---
+
+    fn own_selection(&self) {
+        self.conn.send_request(&x::SetSelectionOwner {
+            owner: self.win,
+            selection: self.atoms.primary,
+            time: x::CURRENT_TIME,
+        });
+        self.conn.send_request(&x::SetSelectionOwner {
+            owner: self.win,
+            selection: self.atoms.clipboard,
+            time: x::CURRENT_TIME,
+        });
+        let _ = self.conn.flush();
+    }
+
+    fn handle_selection_request(
+        &self,
+        ev: x::SelectionRequestEvent,
+    ) {
+        let property = if ev.property() == x::ATOM_NONE {
+            ev.target()
+        } else {
+            ev.property()
+        };
+
+        let success = if ev.target() == self.atoms.targets {
+            self.conn.send_request(&x::ChangeProperty {
+                mode: x::PropMode::Replace,
+                window: ev.requestor(),
+                property,
+                r#type: x::ATOM_ATOM,
+                data: &[
+                    self.atoms.utf8_string,
+                    x::ATOM_STRING,
+                ],
+            });
+            true
+        } else if ev.target() == self.atoms.utf8_string
+            || ev.target() == x::ATOM_STRING
+        {
+            self.conn.send_request(&x::ChangeProperty {
+                mode: x::PropMode::Replace,
+                window: ev.requestor(),
+                property,
+                r#type: ev.target(),
+                data: self.sel.text.as_bytes(),
+            });
+            true
+        } else {
+            false
+        };
+
+        let reply_prop =
+            if success { property } else { x::ATOM_NONE };
+
+        self.conn.send_request(&x::SendEvent {
+            propagate: false,
+            destination: x::SendEventDest::Window(
+                ev.requestor(),
+            ),
+            event_mask: x::EventMask::empty(),
+            event: &x::SelectionNotifyEvent::new(
+                ev.time(),
+                ev.requestor(),
+                ev.selection(),
+                ev.target(),
+                reply_prop,
+            ),
+        });
+        let _ = self.conn.flush();
+    }
+
+    fn request_paste(&self) {
+        self.conn.send_request(&x::ConvertSelection {
+            requestor: self.win,
+            selection: self.atoms.clipboard,
+            target: self.atoms.utf8_string,
+            property: self.atoms.ot_sel,
+            time: x::CURRENT_TIME,
+        });
+        let _ = self.conn.flush();
+    }
+
+    fn read_paste(&self) -> Option<Vec<u8>> {
+        let cookie = self.conn.send_request(&x::GetProperty {
+            delete: true,
+            window: self.win,
+            property: self.atoms.ot_sel,
+            r#type: x::ATOM_ANY,
+            long_offset: 0,
+            long_length: 8192,
+        });
+        let reply = self.conn.wait_for_reply(cookie).ok()?;
+        let data: &[u8] = reply.value();
+        if data.is_empty() {
+            None
+        } else {
+            Some(data.to_vec())
+        }
+    }
+
+    // --- Rendering ---
+
+    pub fn draw(&self, term: &Term) {
+        let d = x::Drawable::Pixmap(self.pixmap);
+        let pix_id = self.pixmap.resource_id() as c_ulong;
+
+        // Clear.
+        self.set_fg(BG_COLOR);
+        self.fill_rect(d, 0, 0, self.width, self.height);
+
+        // Flush xcb before Xft draws.
+        let _ = self.conn.flush();
+
+        // Draw rows.
+        for row in 0..term.rows {
+            let cells = term.get_row(row);
+            let y_base = PAD as i32 + row as i32 * self.char_h as i32;
+            let text_y = y_base + self.xft.ascent as i32;
+
+            let mut col = 0;
+            while col < term.cols {
+                let (fg, bg) =
+                    self.effective_colors(&cells[col], row, col);
+                let attr = cells[col].attr;
+                let start = col;
+                let mut text = String::new();
+
+                while col < term.cols {
+                    let (cf, cb) = self
+                        .effective_colors(&cells[col], row, col);
+                    if cf != fg || cb != bg || cells[col].attr != attr
+                    {
+                        break;
+                    }
+                    let ch = cells[col].ch;
+                    text.push(
+                        if ch < ' ' || ch == '\x7f' {
+                            ' '
+                        } else {
+                            ch
+                        },
+                    );
+                    col += 1;
+                }
+
+                let px =
+                    PAD as i32 + start as i32 * self.char_w as i32;
+                let run_w =
+                    text.chars().count() as u16 * self.char_w;
+
+                // Fill background.
+                self.set_fg(bg);
+                self.fill_rect(
+                    d, px as u16, y_base as u16, run_w, self.char_h,
+                );
+                let _ = self.conn.flush();
+
+                // Draw text with Xft.
+                self.xft.draw_text(
+                    pix_id,
+                    px,
+                    text_y,
+                    &text,
+                    fg,
+                    attr & ATTR_BOLD != 0,
+                );
+
+                // Underline.
+                if attr & ATTR_UNDERLINE != 0 {
+                    let uy = y_base as u16 + self.char_h - 1;
+                    self.set_fg(fg);
+                    self.fill_rect(d, px as u16, uy, run_w, 1);
+                    let _ = self.conn.flush();
+                }
+            }
+        }
+
+        // Draw sixel images.
+        for img in &term.sixel_images {
+            self.draw_sixel(d, img);
+        }
+
+        // Draw cursor.
+        if term.cursor_visible && term.scroll_offset == 0 {
+            let cr = term.cursor_row();
+            let cc = term.cursor_col();
+            let cx = PAD + cc as u16 * self.char_w;
+            let cy = PAD + cr as u16 * self.char_h;
+
+            self.set_fg(FG_COLOR);
+            self.fill_rect(d, cx, cy, self.char_w, self.char_h);
+            let _ = self.conn.flush();
+
+            let cell = term.get_cell(cr, cc);
+            let glyph = if cell.ch < ' ' || cell.ch == '\x7f' {
+                ' '
+            } else {
+                cell.ch
+            };
+            let mut s = String::new();
+            s.push(glyph);
+            self.xft.draw_text(
+                pix_id,
+                cx as i32,
+                cy as i32 + self.xft.ascent as i32,
+                &s,
+                BG_COLOR,
+                cell.attr & ATTR_BOLD != 0,
+            );
+        }
+
+        // Blit to window.
+        self.conn.send_request(&x::CopyArea {
+            src_drawable: x::Drawable::Pixmap(self.pixmap),
+            dst_drawable: x::Drawable::Window(self.win),
+            gc: self.gc,
+            src_x: 0,
+            src_y: 0,
+            dst_x: 0,
+            dst_y: 0,
+            width: self.width,
+            height: self.height,
+        });
+        let _ = self.conn.flush();
+    }
+
+    fn effective_colors(
+        &self,
+        cell: &Cell,
+        row: usize,
+        col: usize,
+    ) -> (u32, u32) {
+        let (fg, bg) = self.cell_colors(cell);
+        if self.sel.contains(row, col) {
+            (SEL_FG, SEL_BG)
+        } else {
+            (fg, bg)
+        }
+    }
+
+    fn cell_colors(&self, cell: &Cell) -> (u32, u32) {
+        let bold = cell.attr & ATTR_BOLD != 0;
+
+        let mut fg = if cell.fg == DEFAULT_FG {
+            FG_COLOR
+        } else {
+            self.color_to_rgb(cell.fg, bold)
+        };
+        let mut bg = if cell.bg == DEFAULT_BG {
+            BG_COLOR
+        } else {
+            self.color_to_rgb(cell.bg, false)
+        };
+
+        if cell.attr & ATTR_REVERSE != 0 {
+            std::mem::swap(&mut fg, &mut bg);
+        }
+        (fg, bg)
+    }
+
+    fn color_to_rgb(&self, idx: u8, bold: bool) -> u32 {
+        if idx < 8 && bold {
+            COLORS_16[(idx + 8) as usize]
+        } else if idx < 16 {
+            COLORS_16[idx as usize]
+        } else if idx < 232 {
+            let n = (idx - 16) as u32;
+            let b = (n % 6) * 51;
+            let g = ((n / 6) % 6) * 51;
+            let r = (n / 36) * 51;
+            (r << 16) | (g << 8) | b
+        } else {
+            let v = 8 + (idx - 232) as u32 * 10;
+            (v << 16) | (v << 8) | v
+        }
+    }
+
+    // --- X11 helpers ---
+
+    fn set_fg(&self, color: u32) {
+        self.conn.send_request(&x::ChangeGc {
+            gc: self.gc,
+            value_list: &[x::Gc::Foreground(color)],
+        });
+    }
+
+    fn fill_rect(
+        &self,
+        d: x::Drawable,
+        x: u16,
+        y: u16,
+        w: u16,
+        h: u16,
+    ) {
+        self.conn.send_request(&x::PolyFillRectangle {
+            drawable: d,
+            gc: self.gc,
+            rectangles: &[x::Rectangle {
+                x: x as i16,
+                y: y as i16,
+                width: w,
+                height: h,
+            }],
+        });
+    }
+
+    fn draw_sixel(
+        &self,
+        d: x::Drawable,
+        img: &crate::sixel::SixelImage,
+    ) {
+        if img.width == 0 || img.height == 0 {
+            return;
+        }
+        let px = PAD as i16 + img.col as i16 * self.char_w as i16;
+        let py = PAD as i16 + img.row as i16 * self.char_h as i16;
+
+        // Clip to pixmap bounds.
+        let draw_w = img.width.min(
+            (self.width as usize).saturating_sub(px as usize),
+        );
+        let draw_h = img.height.min(
+            (self.height as usize).saturating_sub(py as usize),
+        );
+        if draw_w == 0 || draw_h == 0 {
+            return;
+        }
+
+        // Convert to ZPixmap format (BGRX on little-endian).
+        // Send in strips to avoid exceeding max request size.
+        let max_req = self.conn.get_maximum_request_length() as usize
+            * 4; // bytes
+        let row_bytes = draw_w * 4;
+        let overhead = 32; // PutImage header
+        let max_rows =
+            ((max_req - overhead) / row_bytes).max(1).min(draw_h);
+
+        let mut y_off = 0;
+        while y_off < draw_h {
+            let rows = max_rows.min(draw_h - y_off);
+            let mut data =
+                Vec::with_capacity(rows * draw_w * 4);
+            for row in y_off..y_off + rows {
+                for col in 0..draw_w {
+                    let pixel =
+                        img.pixels[row * img.width + col];
+                    let r = ((pixel >> 16) & 0xFF) as u8;
+                    let g = ((pixel >> 8) & 0xFF) as u8;
+                    let b = (pixel & 0xFF) as u8;
+                    // BGRX for little-endian ZPixmap, 24-depth.
+                    data.extend_from_slice(&[b, g, r, 0]);
+                }
+            }
+
+            self.conn.send_request(&x::PutImage {
+                format: x::ImageFormat::ZPixmap,
+                drawable: d,
+                gc: self.gc,
+                width: draw_w as u16,
+                height: rows as u16,
+                dst_x: px,
+                dst_y: py + y_off as i16,
+                left_pad: 0,
+                depth: self.depth,
+                data: &data,
+            });
+            y_off += rows;
+        }
+    }
+
+    // --- Events ---
+
+    pub fn poll_event(
+        &mut self,
+        term: &Term,
+        app_cursor: bool,
+    ) -> Option<Event> {
+        let event = self.conn.poll_for_event().ok()??;
+        match event {
+            xcb::Event::X(x::Event::Expose(_)) => {
+                Some(Event::Redraw)
+            }
+            xcb::Event::X(x::Event::KeyPress(ev)) => {
+                self.on_key(ev, app_cursor)
+            }
+            xcb::Event::X(x::Event::ButtonPress(ev)) => {
+                self.on_button_press(ev, term)
+            }
+            xcb::Event::X(x::Event::ButtonRelease(ev)) => {
+                self.on_button_release(ev, term)
+            }
+            xcb::Event::X(x::Event::MotionNotify(ev)) => {
+                self.on_motion(ev)
+            }
+            xcb::Event::X(x::Event::SelectionRequest(ev)) => {
+                self.handle_selection_request(ev);
+                None
+            }
+            xcb::Event::X(x::Event::SelectionNotify(ev)) => {
+                if ev.property() != x::ATOM_NONE {
+                    self.read_paste().map(Event::Paste)
+                } else {
+                    None
+                }
+            }
+            xcb::Event::X(x::Event::SelectionClear(_)) => {
+                self.sel.active = false;
+                Some(Event::Redraw)
+            }
+            xcb::Event::X(x::Event::ConfigureNotify(ev)) => {
+                let w = ev.width();
+                let h = ev.height();
+                let nc = w.saturating_sub(2 * PAD)
+                    / self.char_w;
+                let nr = h.saturating_sub(2 * PAD)
+                    / self.char_h;
+                if nc > 0
+                    && nr > 0
+                    && (nc != self.cols || nr != self.rows)
+                {
+                    Some(Event::Resize(nc, nr))
+                } else {
+                    None
+                }
+            }
+            xcb::Event::X(x::Event::ClientMessage(ev)) => {
+                if let x::ClientMessageData::Data32(d) =
+                    ev.data()
+                    && d[0] == self.wm_delete.resource_id()
+                {
+                    return Some(Event::Close);
+                }
+                None
+            }
+            _ => None,
+        }
+    }
+
+    // --- Mouse ---
+
+    fn on_button_press(
+        &mut self,
+        ev: x::ButtonPressEvent,
+        term: &Term,
+    ) -> Option<Event> {
+        let (row, col) =
+            self.pixel_to_cell(ev.event_x(), ev.event_y());
+        let ctrl = ev.state().contains(x::KeyButMask::CONTROL);
+
+        match ev.detail() {
+            1 if ctrl => {
+                if let Some(url) = term.find_url_at(row, col) {
+                    return Some(Event::OpenUrl(url));
+                }
+                None
+            }
+            1 => {
+                self.sel.active = false;
+                self.sel.dragging = true;
+                self.sel.start = (row, col);
+                self.sel.end = (row, col);
+                Some(Event::Redraw)
+            }
+            2 => {
+                self.conn.send_request(&x::ConvertSelection {
+                    requestor: self.win,
+                    selection: self.atoms.primary,
+                    target: self.atoms.utf8_string,
+                    property: self.atoms.ot_sel,
+                    time: x::CURRENT_TIME,
+                });
+                let _ = self.conn.flush();
+                None
+            }
+            _ => None,
+        }
+    }
+
+    fn on_button_release(
+        &mut self,
+        ev: x::ButtonReleaseEvent,
+        term: &Term,
+    ) -> Option<Event> {
+        if ev.detail() != 1 || !self.sel.dragging {
+            return None;
+        }
+        self.sel.dragging = false;
+        let (row, col) =
+            self.pixel_to_cell(ev.event_x(), ev.event_y());
+        self.sel.end = (row, col);
+
+        if self.sel.start == self.sel.end {
+            self.sel.active = false;
+            return Some(Event::Redraw);
+        }
+
+        self.sel.active = true;
+        let ((sr, sc), (er, ec)) = self.sel.normalized();
+        self.sel.text = term.extract_text(sr, sc, er, ec);
+        self.own_selection();
+        Some(Event::Redraw)
+    }
+
+    fn on_motion(
+        &mut self,
+        ev: x::MotionNotifyEvent,
+    ) -> Option<Event> {
+        if !self.sel.dragging {
+            return None;
+        }
+        let (row, col) =
+            self.pixel_to_cell(ev.event_x(), ev.event_y());
+        if (row, col) != self.sel.end {
+            self.sel.end = (row, col);
+            self.sel.active = true;
+            return Some(Event::Redraw);
+        }
+        None
+    }
+
+    // --- Keyboard ---
+
+    fn keysym(&self, keycode: u8, shift: bool) -> u64 {
+        let group = if shift { 1 } else { 0 };
+        unsafe {
+            x11::xlib::XkbKeycodeToKeysym(
+                self.dpy,
+                keycode,
+                0,
+                group,
+            ) as u64
+        }
+    }
+
+    fn on_key(
+        &mut self,
+        ev: x::KeyPressEvent,
+        app_cursor: bool,
+    ) -> Option<Event> {
+        let keycode = ev.detail();
+        let state = ev.state();
+        let shift = state.contains(x::KeyButMask::SHIFT);
+        let ctrl = state.contains(x::KeyButMask::CONTROL);
+
+        let ks = self.keysym(keycode, shift);
+
+        log_debug!(
+            "key: code={} ks=0x{:x} shift={} ctrl={}",
+            keycode,
+            ks,
+            shift,
+            ctrl
+        );
+
+        // Keysym constants (X11/keysymdef.h).
+        const XK_ESCAPE: u64 = 0xff1b;
+        const XK_RETURN: u64 = 0xff0d;
+        const XK_TAB: u64 = 0xff09;
+        const XK_BACKSPACE: u64 = 0xff08;
+        const XK_DELETE: u64 = 0xffff;
+        const XK_HOME: u64 = 0xff50;
+        const XK_END: u64 = 0xff57;
+        const XK_PAGE_UP: u64 = 0xff55;
+        const XK_PAGE_DOWN: u64 = 0xff56;
+        const XK_INSERT: u64 = 0xff63;
+        const XK_UP: u64 = 0xff52;
+        const XK_DOWN: u64 = 0xff54;
+        const XK_LEFT: u64 = 0xff51;
+        const XK_RIGHT: u64 = 0xff53;
+        const XK_F1: u64 = 0xffbe;
+        const XK_F2: u64 = 0xffbf;
+        const XK_F3: u64 = 0xffc0;
+        const XK_F4: u64 = 0xffc1;
+        const XK_F5: u64 = 0xffc2;
+        const XK_F6: u64 = 0xffc3;
+        const XK_F7: u64 = 0xffc4;
+        const XK_F8: u64 = 0xffc5;
+        const XK_F9: u64 = 0xffc6;
+        const XK_F10: u64 = 0xffc7;
+        const XK_F11: u64 = 0xffc8;
+        const XK_F12: u64 = 0xffc9;
+
+        // Shift+PgUp/PgDown -> scrollback.
+        if shift && !ctrl {
+            let ks_base = self.keysym(keycode, false);
+            match ks_base {
+                XK_PAGE_UP => return Some(Event::ScrollUp),
+                XK_PAGE_DOWN => {
+                    return Some(Event::ScrollDown)
+                }
+                _ => {}
+            }
+        }
+
+        // Shift+Insert -> paste clipboard.
+        if shift && self.keysym(keycode, false) == XK_INSERT {
+            self.request_paste();
+            return None;
+        }
+
+        // Special keys.
+        let seq: Option<&[u8]> = match self.keysym(keycode, false)
+        {
+            XK_ESCAPE => Some(b"\x1b"),
+            XK_BACKSPACE => Some(b"\x7f"),
+            XK_TAB => Some(b"\t"),
+            XK_RETURN => Some(b"\r"),
+            XK_DELETE => Some(b"\x1b[3~"),
+            XK_HOME => Some(b"\x1b[H"),
+            XK_END => Some(b"\x1b[F"),
+            XK_PAGE_UP => Some(b"\x1b[5~"),
+            XK_PAGE_DOWN => Some(b"\x1b[6~"),
+            _ => None,
+        };
+        if let Some(s) = seq {
+            return Some(Event::Key(s.to_vec()));
+        }
+
+        // Arrow keys.
+        let arrow: Option<&[u8]> =
+            match self.keysym(keycode, false) {
+                XK_UP if app_cursor => Some(b"\x1bOA"),
+                XK_UP => Some(b"\x1b[A"),
+                XK_DOWN if app_cursor => Some(b"\x1bOB"),
+                XK_DOWN => Some(b"\x1b[B"),
+                XK_RIGHT if app_cursor => Some(b"\x1bOC"),
+                XK_RIGHT => Some(b"\x1b[C"),
+                XK_LEFT if app_cursor => Some(b"\x1bOD"),
+                XK_LEFT => Some(b"\x1b[D"),
+                _ => None,
+            };
+        if let Some(s) = arrow {
+            return Some(Event::Key(s.to_vec()));
+        }
+
+        // F-keys.
+        let fkey: Option<&[u8]> =
+            match self.keysym(keycode, false) {
+                XK_F1 => Some(b"\x1bOP"),
+                XK_F2 => Some(b"\x1bOQ"),
+                XK_F3 => Some(b"\x1bOR"),
+                XK_F4 => Some(b"\x1bOS"),
+                XK_F5 => Some(b"\x1b[15~"),
+                XK_F6 => Some(b"\x1b[17~"),
+                XK_F7 => Some(b"\x1b[18~"),
+                XK_F8 => Some(b"\x1b[19~"),
+                XK_F9 => Some(b"\x1b[20~"),
+                XK_F10 => Some(b"\x1b[21~"),
+                XK_F11 => Some(b"\x1b[23~"),
+                XK_F12 => Some(b"\x1b[24~"),
+                _ => None,
+            };
+        if let Some(s) = fkey {
+            return Some(Event::Key(s.to_vec()));
+        }
+
+        // Ctrl+key.
+        if ctrl && ks >= 0x61 && ks <= 0x7a {
+            // a-z keysym
+            return Some(Event::Key(vec![
+                (ks as u8) - b'a' + 1,
+            ]));
+        }
+        if ctrl {
+            let ks_base = self.keysym(keycode, false);
+            if ks_base >= 0x61 && ks_base <= 0x7a {
+                return Some(Event::Key(vec![
+                    (ks_base as u8) - b'a' + 1,
+                ]));
+            }
+        }
+
+        // Printable ASCII/Latin-1 characters.
+        if ks >= 0x20 && ks <= 0x7e {
+            return Some(Event::Key(vec![ks as u8]));
+        }
+
+        // Unicode keysyms (0x01000000 + codepoint).
+        if ks > 0x01000000 && ks < 0x01110000 {
+            let cp = (ks - 0x01000000) as u32;
+            if let Some(ch) = char::from_u32(cp) {
+                let mut buf = [0u8; 4];
+                let s = ch.encode_utf8(&mut buf);
+                return Some(Event::Key(s.as_bytes().to_vec()));
+            }
+        }
+
+        // Latin-1 supplement (keysyms 0xa0-0xff map to U+00A0-U+00FF).
+        if ks >= 0xa0 && ks <= 0xff {
+            let ch = ks as u32;
+            if let Some(ch) = char::from_u32(ch) {
+                let mut buf = [0u8; 4];
+                let s = ch.encode_utf8(&mut buf);
+                return Some(Event::Key(s.as_bytes().to_vec()));
+            }
+        }
+
+        None
+    }
+}
blob - /dev/null
blob + bee2ff8c35adedd010c3593cc3e370a84b036863 (mode 644)
--- /dev/null
+++ src/xft_font.rs
@@ -0,0 +1,182 @@
+use std::ffi::CString;
+use std::os::raw::{c_int, c_uchar, c_ulong};
+
+use x11::xft;
+use x11::xlib;
+use x11::xrender;
+
+/// Xft font pair (regular + bold) for anti-aliased text rendering.
+pub struct XftFont {
+    dpy: *mut xlib::Display,
+    visual: *mut xlib::Visual,
+    colormap: c_ulong,
+    font: *mut xft::XftFont,
+    bold: *mut xft::XftFont,
+    pub ascent: u32,
+    pub height: u32,
+}
+
+impl XftFont {
+    /// Open regular and bold fonts by fontconfig patterns.
+    ///
+    /// # Safety
+    /// `dpy` must be a valid Xlib Display pointer for the lifetime of this struct.
+    pub unsafe fn open(
+        dpy: *mut xlib::Display,
+        screen: c_int,
+        pattern: &str,
+        bold_pattern: &str,
+    ) -> Result<Self, String> {
+        let visual = unsafe { xlib::XDefaultVisual(dpy, screen) };
+        let colormap = unsafe { xlib::XDefaultColormap(dpy, screen) };
+
+        let font = unsafe { open_font(dpy, screen, pattern) }?;
+        let bold = unsafe { open_font(dpy, screen, bold_pattern) }
+            .unwrap_or(font);
+
+        let ascent = unsafe { (*font).ascent as u32 };
+        let height =
+            unsafe { ((*font).ascent + (*font).descent) as u32 };
+
+        Ok(XftFont {
+            dpy,
+            visual,
+            colormap,
+            font,
+            bold,
+            ascent,
+            height,
+        })
+    }
+
+    /// Measure the pixel width of a UTF-8 string.
+    pub fn text_width(&self, text: &str) -> u32 {
+        if text.is_empty() {
+            return 0;
+        }
+        let mut extents = xrender::XGlyphInfo {
+            width: 0,
+            height: 0,
+            x: 0,
+            y: 0,
+            xOff: 0,
+            yOff: 0,
+        };
+        unsafe {
+            xft::XftTextExtentsUtf8(
+                self.dpy,
+                self.font,
+                text.as_ptr() as *const c_uchar,
+                text.len() as c_int,
+                &mut extents,
+            );
+        }
+        extents.xOff as u32
+    }
+
+    /// Measure the width of a single character.
+    pub fn char_width(&self) -> u32 {
+        self.text_width("W")
+    }
+
+    /// Draw UTF-8 text on a drawable at (x, y) with given foreground color.
+    pub fn draw_text(
+        &self,
+        drawable: c_ulong,
+        x: i32,
+        y: i32,
+        text: &str,
+        fg: u32,
+        bold: bool,
+    ) {
+        if text.is_empty() {
+            return;
+        }
+        let font = if bold { self.bold } else { self.font };
+        unsafe {
+            let draw = xft::XftDrawCreate(
+                self.dpy,
+                drawable,
+                self.visual,
+                self.colormap,
+            );
+            if draw.is_null() {
+                return;
+            }
+
+            let mut color = self.alloc_color(fg);
+
+            xft::XftDrawStringUtf8(
+                draw,
+                &color,
+                font,
+                x as c_int,
+                y as c_int,
+                text.as_ptr() as *const c_uchar,
+                text.len() as c_int,
+            );
+
+            xft::XftColorFree(
+                self.dpy,
+                self.visual,
+                self.colormap,
+                &mut color,
+            );
+            xft::XftDrawDestroy(draw);
+
+            xlib::XFlush(self.dpy);
+        }
+    }
+
+    /// Allocate an XftColor from an 0xRRGGBB u32.
+    unsafe fn alloc_color(&self, rgb: u32) -> xft::XftColor {
+        let r = ((rgb >> 16) & 0xff) as u16;
+        let g = ((rgb >> 8) & 0xff) as u16;
+        let b = (rgb & 0xff) as u16;
+        let render_color = xrender::XRenderColor {
+            red: r | (r << 8),
+            green: g | (g << 8),
+            blue: b | (b << 8),
+            alpha: 0xffff,
+        };
+        let mut color = unsafe { std::mem::zeroed::<xft::XftColor>() };
+        unsafe {
+            xft::XftColorAllocValue(
+                self.dpy,
+                self.visual,
+                self.colormap,
+                &render_color,
+                &mut color,
+            );
+        }
+        color
+    }
+}
+
+impl Drop for XftFont {
+    fn drop(&mut self) {
+        unsafe {
+            if self.bold != self.font {
+                xft::XftFontClose(self.dpy, self.bold);
+            }
+            xft::XftFontClose(self.dpy, self.font);
+        }
+    }
+}
+
+unsafe fn open_font(
+    dpy: *mut xlib::Display,
+    screen: c_int,
+    pattern: &str,
+) -> Result<*mut xft::XftFont, String> {
+    let c_pattern = CString::new(pattern)
+        .map_err(|_| "font pattern contains null byte".to_string())?;
+    let font = unsafe {
+        xft::XftFontOpenName(dpy, screen, c_pattern.as_ptr())
+    };
+    if font.is_null() {
+        Err(format!("failed to open font '{}'", pattern))
+    } else {
+        Ok(font)
+    }
+}