commit - /dev/null
commit + d536c0e2d45bbda2aba250d96bc6befca53081a3
blob - /dev/null
blob + c7940dc283b512d7e38bbdeb9b9b45aefcb0955c (mode 644)
--- /dev/null
+++ .cargo/config.toml
+[build]
+rustflags = ["-L", "/usr/X11R6/lib", "-l", "util"]
blob - /dev/null
blob + 5d1977ce5bf6a5f6a3964550a695376e3864b1bc (mode 644)
--- /dev/null
+++ .gitignore
+target
+*.log
blob - /dev/null
blob + 48b6f1bf6846b19e7711151b9c5b551c97730f5b (mode 644)
--- /dev/null
+++ .rustfmt.toml
+max_width = 80
+reorder_imports = true
blob - /dev/null
blob + 78079d58bd79b350d4273686a25efe710aa45dd0 (mode 644)
--- /dev/null
+++ Cargo.lock
+# 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
+[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
+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
+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
+//! 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
+//! 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
+//! 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
+//! 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(¶ms, true),
+ b'l' => self.set_private_mode(¶ms, 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(¶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::<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
+//! 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
+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)
+ }
+}