commit d536c0e2d45bbda2aba250d96bc6befca53081a3 from: murilo ijanc date: Fri Apr 3 02:22:08 2026 UTC import ot, a minimal terminal emulator in pure xcb 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 = 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 { + 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, + /// 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 { + // 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 = 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, + 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, + alt_grid: Vec, + 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>, + pub scroll_offset: usize, + // Modes. + pub app_cursor: bool, + pub bracketed_paste: bool, + wrap_next: bool, + // Pending response bytes (DA, DSR). + pub response: Vec, + // UTF-8 accumulator. + utf8_buf: [u8; 4], + utf8_len: u8, + utf8_need: u8, + // OSC accumulator. + osc_buf: Vec, + // DCS (sixel) accumulator. + dcs_buf: Vec, + // Sixel images placed on the grid. + pub sixel_images: Vec, + // Pending title set by OSC 0/2. + pub title: Option, +} + +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::() { + 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(¶ms, true), + b'l' => self.set_private_mode(¶ms, false), + _ => { + log_debug!( + "unhandled CSI ?{} {}", + params.iter().map(|p| p.to_string()).collect::>().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(¶ms), + b'n' => self.device_status(¶ms), + 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::>().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 { + 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 { + 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), + Paste(Vec), + 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> { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::() }; + 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) + } +}