diff --git a/Cargo.lock b/Cargo.lock index 9bab6b7..584d71a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,6 +165,15 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -1887,27 +1896,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vte" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a0b683b20ef64071ff03745b14391751f6beab06a54347885459b77a3f2caa5" -dependencies = [ - "arrayvec", - "utf8parse", - "vte_generate_state_changes", -] - -[[package]] -name = "vte_generate_state_changes" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" -dependencies = [ - "proc-macro2", - "quote", -] - [[package]] name = "walkdir" version = "2.5.0" @@ -2616,6 +2604,7 @@ dependencies = [ name = "zterm" version = "0.1.0" dependencies = [ + "bincode", "bitflags 2.10.0", "bytemuck", "dirs", @@ -2623,6 +2612,8 @@ dependencies = [ "fontdue", "libc", "log", + "memchr", + "memmap2", "polling", "pollster", "rustix 0.38.44", @@ -2631,7 +2622,6 @@ dependencies = [ "serde_json", "thiserror 2.0.17", "ttf-parser 0.25.1", - "vte", "wgpu", "winit", ] diff --git a/Cargo.toml b/Cargo.toml index d1b0fe7..536f4c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,19 +11,12 @@ path = "src/lib.rs" name = "zterm" path = "src/main.rs" -[[bin]] -name = "ztermd" -path = "src/bin/ztermd.rs" - [dependencies] # Window and rendering winit = { version = "0.30", features = ["wayland", "x11"] } wgpu = "23" pollster = "0.4" -# Terminal emulation -vte = "0.13" - # PTY handling rustix = { version = "0.38", features = ["termios", "pty", "process", "fs"] } @@ -50,4 +43,11 @@ ttf-parser = "0.25" # Configuration serde = { version = "1", features = ["derive"] } serde_json = "1" +bincode = "1" dirs = "6" + +# Shared memory for fast IPC +memmap2 = "0.9" + +# Fast byte searching +memchr = "2" diff --git a/src/bin/bench_process.rs b/src/bin/bench_process.rs new file mode 100644 index 0000000..5e73ce6 --- /dev/null +++ b/src/bin/bench_process.rs @@ -0,0 +1,36 @@ +use zterm::terminal::Terminal; +use std::time::Instant; +use std::io::Write; + +fn main() { + // Generate seq 1 100000 output + let mut data = Vec::new(); + for i in 1..=100000 { + writeln!(&mut data, "{}", i).unwrap(); + } + println!("Data size: {} bytes", data.len()); + + // Test with different terminal sizes to see scroll impact + for rows in [24, 100, 1000] { + let mut terminal = Terminal::new(80, rows, 10000); + let start = Instant::now(); + terminal.process(&data); + let elapsed = start.elapsed(); + println!("Terminal {}x{}: {:?} ({:.2} MB/s)", + 80, rows, + elapsed, + (data.len() as f64 / 1024.0 / 1024.0) / elapsed.as_secs_f64() + ); + } + + // Test with scrollback disabled + println!("\nWith scrollback disabled:"); + let mut terminal = Terminal::new(80, 24, 0); + let start = Instant::now(); + terminal.process(&data); + let elapsed = start.elapsed(); + println!("Terminal 80x24, no scrollback: {:?} ({:.2} MB/s)", + elapsed, + (data.len() as f64 / 1024.0 / 1024.0) / elapsed.as_secs_f64() + ); +} diff --git a/src/bin/ztermd.rs b/src/bin/ztermd.rs deleted file mode 100644 index bbd7776..0000000 --- a/src/bin/ztermd.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! ZTerm Daemon - Background process that manages terminal sessions. - -use zterm::daemon::Daemon; - -fn main() { - // Initialize logging - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - - log::info!("ZTerm daemon starting..."); - - match Daemon::new() { - Ok(mut daemon) => { - if let Err(e) = daemon.run() { - log::error!("Daemon error: {}", e); - std::process::exit(1); - } - } - Err(e) => { - log::error!("Failed to start daemon: {}", e); - std::process::exit(1); - } - } - - log::info!("ZTerm daemon exiting"); -} diff --git a/src/client.rs b/src/client.rs deleted file mode 100644 index 5af4612..0000000 --- a/src/client.rs +++ /dev/null @@ -1,303 +0,0 @@ -//! ZTerm client - connects to daemon and handles rendering. - -use crate::daemon::{is_running, socket_path, start_daemon}; -use crate::protocol::{ClientMessage, DaemonMessage, Direction, PaneSnapshot, SplitDirection, WindowState}; -use std::io::{self, Read, Write}; -use std::os::unix::net::UnixStream; -use std::time::Duration; - -/// Client connection to the daemon. -pub struct DaemonClient { - stream: UnixStream, - /// Current window state from daemon. - pub window: Option, - /// Current pane snapshots. - pub panes: Vec, -} - -impl DaemonClient { - /// Connects to the daemon, starting it if necessary. - pub fn connect() -> io::Result { - // Start daemon if not running - if !is_running() { - log::info!("Starting daemon..."); - start_daemon()?; - - // Wait for daemon to start - for _ in 0..50 { - if is_running() { - break; - } - std::thread::sleep(Duration::from_millis(50)); - } - - if !is_running() { - return Err(io::Error::new( - io::ErrorKind::ConnectionRefused, - "failed to start daemon", - )); - } - } - - let socket = socket_path(); - log::info!("Connecting to daemon at {:?}", socket); - - let stream = UnixStream::connect(&socket)?; - // Keep blocking mode for initial handshake - stream.set_nonblocking(false)?; - - Ok(Self { - stream, - window: None, - panes: Vec::new(), - }) - } - - /// Sends hello message with initial window size. - pub fn hello(&mut self, cols: usize, rows: usize) -> io::Result<()> { - self.send(&ClientMessage::Hello { cols, rows }) - } - - /// Sets the socket to non-blocking mode for use after initial handshake. - pub fn set_nonblocking(&mut self) -> io::Result<()> { - self.stream.set_nonblocking(true) - } - - /// Sends keyboard input. - pub fn send_input(&mut self, data: Vec) -> io::Result<()> { - self.send(&ClientMessage::Input { data }) - } - - /// Sends resize notification. - pub fn send_resize(&mut self, cols: usize, rows: usize) -> io::Result<()> { - self.send(&ClientMessage::Resize { cols, rows }) - } - - /// Requests creation of a new tab. - pub fn create_tab(&mut self) -> io::Result<()> { - self.send(&ClientMessage::CreateTab) - } - - /// Requests closing the current tab. - pub fn close_tab(&mut self, tab_id: u32) -> io::Result<()> { - self.send(&ClientMessage::CloseTab { tab_id }) - } - - /// Requests switching to the next tab. - pub fn next_tab(&mut self) -> io::Result<()> { - self.send(&ClientMessage::NextTab) - } - - /// Requests switching to the previous tab. - pub fn prev_tab(&mut self) -> io::Result<()> { - self.send(&ClientMessage::PrevTab) - } - - /// Requests switching to a tab by index (0-based). - pub fn switch_tab_index(&mut self, index: usize) -> io::Result<()> { - self.send(&ClientMessage::SwitchTabIndex { index }) - } - - /// Requests splitting the current pane horizontally (new pane below). - pub fn split_horizontal(&mut self) -> io::Result<()> { - self.send(&ClientMessage::SplitPane { direction: SplitDirection::Horizontal }) - } - - /// Requests splitting the current pane vertically (new pane to the right). - pub fn split_vertical(&mut self) -> io::Result<()> { - self.send(&ClientMessage::SplitPane { direction: SplitDirection::Vertical }) - } - - /// Requests closing the current pane (closes tab if last pane). - pub fn close_pane(&mut self) -> io::Result<()> { - self.send(&ClientMessage::ClosePane) - } - - /// Requests focusing a pane in the given direction. - pub fn focus_pane(&mut self, direction: Direction) -> io::Result<()> { - self.send(&ClientMessage::FocusPane { direction }) - } - - /// Sends a message to the daemon. - pub fn send(&mut self, msg: &ClientMessage) -> io::Result<()> { - let json = serde_json::to_vec(msg) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - let len = json.len() as u32; - self.stream.write_all(&len.to_le_bytes())?; - self.stream.write_all(&json)?; - self.stream.flush()?; - Ok(()) - } - - /// Tries to receive a message (non-blocking). - /// Socket must be set to non-blocking mode first. - pub fn try_recv(&mut self) -> io::Result> { - // Try to read the length prefix (non-blocking) - let mut len_buf = [0u8; 4]; - match self.stream.read(&mut len_buf) { - Ok(0) => { - // EOF - daemon disconnected - return Err(io::Error::new(io::ErrorKind::ConnectionReset, "daemon disconnected")); - } - Ok(n) if n < 4 => { - // Partial read - shouldn't happen often with 4-byte prefix - // For now, treat as no data - return Ok(None); - } - Ok(_) => {} // Got all 4 bytes - Err(e) if e.kind() == io::ErrorKind::WouldBlock => { - return Ok(None); - } - Err(e) => return Err(e), - } - - let len = u32::from_le_bytes(len_buf) as usize; - - // Sanity check - if len > 64 * 1024 * 1024 { - return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large")); - } - - // For the message body, temporarily use blocking mode with timeout - // since we know the data should be available - self.stream.set_nonblocking(false)?; - self.stream.set_read_timeout(Some(Duration::from_secs(5)))?; - - let mut buf = vec![0u8; len]; - let result = self.stream.read_exact(&mut buf); - - // Restore non-blocking mode - self.stream.set_nonblocking(true)?; - - result?; - - let msg: DaemonMessage = serde_json::from_slice(&buf) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - self.handle_message(&msg); - Ok(Some(msg)) - } - - /// Receives a message (blocking). - pub fn recv(&mut self) -> io::Result { - // Use a long timeout for blocking reads - self.stream.set_read_timeout(Some(Duration::from_secs(30)))?; - - // Read length prefix - let mut len_buf = [0u8; 4]; - self.stream.read_exact(&mut len_buf)?; - let len = u32::from_le_bytes(len_buf) as usize; - - // Sanity check - if len > 64 * 1024 * 1024 { - return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large")); - } - - // Read message body - let mut buf = vec![0u8; len]; - self.stream.read_exact(&mut buf)?; - - let msg: DaemonMessage = serde_json::from_slice(&buf) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - self.handle_message(&msg); - Ok(msg) - } - - /// Handles a received message by updating local state. - fn handle_message(&mut self, msg: &DaemonMessage) { - match msg { - DaemonMessage::FullState { window, panes } => { - self.window = Some(window.clone()); - self.panes = panes.clone(); - } - DaemonMessage::PaneUpdate { pane_id, cells, cursor } => { - // Update existing pane or add new - if let Some(pane) = self.panes.iter_mut().find(|p| p.pane_id == *pane_id) { - pane.cells = cells.clone(); - pane.cursor = cursor.clone(); - } else { - self.panes.push(PaneSnapshot { - pane_id: *pane_id, - cells: cells.clone(), - cursor: cursor.clone(), - scroll_offset: 0, - scrollback_len: 0, - }); - } - } - DaemonMessage::TabChanged { active_tab } => { - if let Some(ref mut window) = self.window { - window.active_tab = *active_tab; - } - } - DaemonMessage::TabCreated { tab } => { - if let Some(ref mut window) = self.window { - window.tabs.push(tab.clone()); - } - } - DaemonMessage::TabClosed { tab_id } => { - if let Some(ref mut window) = self.window { - window.tabs.retain(|t| t.id != *tab_id); - } - } - DaemonMessage::PaneCreated { tab_id, pane } => { - if let Some(ref mut window) = self.window { - if let Some(tab) = window.tabs.iter_mut().find(|t| t.id == *tab_id) { - tab.panes.push(pane.clone()); - } - } - } - DaemonMessage::PaneClosed { tab_id, pane_id } => { - if let Some(ref mut window) = self.window { - if let Some(tab) = window.tabs.iter_mut().find(|t| t.id == *tab_id) { - tab.panes.retain(|p| p.id != *pane_id); - } - } - // Also remove from pane snapshots - self.panes.retain(|p| p.pane_id != *pane_id); - } - DaemonMessage::PaneFocused { tab_id, active_pane } => { - if let Some(ref mut window) = self.window { - if let Some(tab) = window.tabs.iter_mut().find(|t| t.id == *tab_id) { - tab.active_pane = *active_pane; - } - } - } - DaemonMessage::Shutdown => { - log::info!("Daemon shutting down"); - } - } - } - - /// Gets the active pane snapshot. - pub fn active_pane(&self) -> Option<&PaneSnapshot> { - let window = self.window.as_ref()?; - let tab = window.tabs.get(window.active_tab)?; - let pane_info = tab.panes.get(tab.active_pane)?; - self.panes.iter().find(|p| p.pane_id == pane_info.id) - } - - /// Returns the file descriptor for polling. - pub fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { - use std::os::fd::AsFd; - self.stream.as_fd() - } - - /// Returns the raw file descriptor for initial poll registration. - pub fn as_raw_fd(&self) -> std::os::fd::RawFd { - use std::os::fd::AsRawFd; - self.stream.as_raw_fd() - } - - /// Sends goodbye message before disconnecting. - pub fn goodbye(&mut self) -> io::Result<()> { - self.send(&ClientMessage::Goodbye) - } -} - -impl Drop for DaemonClient { - fn drop(&mut self) { - let _ = self.goodbye(); - } -} diff --git a/src/config.rs b/src/config.rs index ed93dba..3f92dc4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -194,6 +194,10 @@ pub enum Action { FocusPaneLeft, /// Focus the pane to the right of the current one. FocusPaneRight, + /// Copy selection to clipboard. + Copy, + /// Paste from clipboard. + Paste, } /// Keybinding configuration. @@ -238,6 +242,10 @@ pub struct Keybindings { pub focus_pane_left: Keybind, /// Focus pane to the right. pub focus_pane_right: Keybind, + /// Copy selection to clipboard. + pub copy: Keybind, + /// Paste from clipboard. + pub paste: Keybind, } impl Default for Keybindings { @@ -256,12 +264,14 @@ impl Default for Keybindings { tab_8: Keybind("alt+8".to_string()), tab_9: Keybind("alt+9".to_string()), split_horizontal: Keybind("ctrl+shift+h".to_string()), - split_vertical: Keybind("ctrl+shift+v".to_string()), + split_vertical: Keybind("ctrl+shift+e".to_string()), close_pane: Keybind("ctrl+shift+w".to_string()), focus_pane_up: Keybind("ctrl+shift+up".to_string()), focus_pane_down: Keybind("ctrl+shift+down".to_string()), focus_pane_left: Keybind("ctrl+shift+left".to_string()), focus_pane_right: Keybind("ctrl+shift+right".to_string()), + copy: Keybind("ctrl+shift+c".to_string()), + paste: Keybind("ctrl+shift+v".to_string()), } } } @@ -328,6 +338,12 @@ impl Keybindings { if let Some(parsed) = self.focus_pane_right.parse() { map.insert(parsed, Action::FocusPaneRight); } + if let Some(parsed) = self.copy.parse() { + map.insert(parsed, Action::Copy); + } + if let Some(parsed) = self.paste.parse() { + map.insert(parsed, Action::Paste); + } map } @@ -344,6 +360,8 @@ pub struct Config { /// Background opacity (0.0 = fully transparent, 1.0 = fully opaque). /// Requires compositor support for transparency. pub background_opacity: f32, + /// Number of lines to keep in scrollback buffer. + pub scrollback_lines: usize, /// Keybindings. pub keybindings: Keybindings, } @@ -354,6 +372,7 @@ impl Default for Config { font_size: 16.0, tab_bar_position: TabBarPosition::Top, background_opacity: 1.0, + scrollback_lines: 50_000, keybindings: Keybindings::default(), } } diff --git a/src/daemon.rs b/src/daemon.rs deleted file mode 100644 index 17d33c2..0000000 --- a/src/daemon.rs +++ /dev/null @@ -1,605 +0,0 @@ -//! ZTerm daemon - manages terminal sessions and communicates with clients. - -use crate::protocol::{ClientMessage, DaemonMessage, PaneSnapshot}; -use crate::window_state::WindowStateManager; -use polling::{Event, Events, Poller}; -use std::collections::HashMap; -use std::io::{self, Read, Write}; -use std::os::fd::AsRawFd; -use std::os::unix::net::{UnixListener, UnixStream}; -use std::path::PathBuf; -use std::time::Duration; - -/// Get the socket path for the daemon. -pub fn socket_path() -> PathBuf { - let runtime_dir = std::env::var("XDG_RUNTIME_DIR") - .unwrap_or_else(|_| format!("/tmp/zterm-{}", unsafe { libc::getuid() })); - PathBuf::from(runtime_dir).join("zterm.sock") -} - -/// Event keys for the poller. -const LISTENER_KEY: usize = 0; -const CLIENT_KEY_BASE: usize = 1000; -const SESSION_KEY_BASE: usize = 2000; - -/// A connected client. -struct Client { - stream: UnixStream, -} - -impl Client { - fn new(stream: UnixStream) -> io::Result { - // Set to non-blocking mode for use with poller - stream.set_nonblocking(true)?; - Ok(Self { stream }) - } - - fn send(&mut self, msg: &DaemonMessage) -> io::Result<()> { - let json = serde_json::to_vec(msg) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - let len = json.len() as u32; - - // Temporarily set blocking mode for sends to ensure complete writes - self.stream.set_nonblocking(false)?; - self.stream.set_write_timeout(Some(Duration::from_secs(5)))?; - - let result = (|| { - self.stream.write_all(&len.to_le_bytes())?; - self.stream.write_all(&json)?; - self.stream.flush() - })(); - - // Restore non-blocking mode - self.stream.set_nonblocking(true)?; - - result - } - - /// Tries to receive a message. Returns: - /// - Ok(Some(msg)) if a message was received - /// - Ok(None) if no data available (would block) - /// - Err with ConnectionReset if client disconnected - /// - Err for other errors - fn try_recv(&mut self) -> io::Result> { - // Try to read length prefix (non-blocking) - let mut len_buf = [0u8; 4]; - match self.stream.read(&mut len_buf) { - Ok(0) => { - // EOF - client disconnected - return Err(io::Error::new(io::ErrorKind::ConnectionReset, "client disconnected")); - } - Ok(n) if n < 4 => { - // Partial read - need to read more (shouldn't happen often with small prefix) - // For now, treat as would-block and let next poll handle it - return Ok(None); - } - Ok(_) => {} // Got all 4 bytes - Err(e) if e.kind() == io::ErrorKind::WouldBlock => { - return Ok(None); - } - Err(e) => return Err(e), - } - - let len = u32::from_le_bytes(len_buf) as usize; - - // Sanity check - if len > 64 * 1024 * 1024 { - return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large")); - } - - // Read the message body - since we're non-blocking, we need to handle partial reads - // For simplicity, temporarily set blocking with timeout for the body - self.stream.set_nonblocking(false)?; - self.stream.set_read_timeout(Some(Duration::from_secs(5)))?; - - let mut buf = vec![0u8; len]; - let result = self.stream.read_exact(&mut buf); - - // Restore non-blocking mode - self.stream.set_nonblocking(true)?; - - result?; - - let msg: ClientMessage = serde_json::from_slice(&buf) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - - Ok(Some(msg)) - } -} - -/// The daemon server. -pub struct Daemon { - listener: UnixListener, - poller: Poller, - state: WindowStateManager, - clients: HashMap, - next_client_id: usize, - read_buffer: Vec, -} - -impl Daemon { - /// Creates and starts a new daemon. - pub fn new() -> io::Result { - let socket = socket_path(); - - // Remove old socket if it exists - let _ = std::fs::remove_file(&socket); - - // Create parent directory if needed - if let Some(parent) = socket.parent() { - std::fs::create_dir_all(parent)?; - } - - let listener = UnixListener::bind(&socket)?; - listener.set_nonblocking(true)?; - - log::info!("Daemon listening on {:?}", socket); - - let poller = Poller::new()?; - - // Register listener for new connections - unsafe { - poller.add(listener.as_raw_fd(), Event::readable(LISTENER_KEY))?; - } - - // Create initial window state (will create session when client connects with size) - let state = WindowStateManager::new(80, 24); // Default size, will be updated - - Ok(Self { - listener, - poller, - state, - clients: HashMap::new(), - next_client_id: 0, - read_buffer: vec![0u8; 65536], - }) - } - - /// Runs the daemon main loop. - pub fn run(&mut self) -> io::Result<()> { - let mut events = Events::new(); - - loop { - events.clear(); - - // Poll with a longer timeout - we'll wake up when there's actual I/O - // Use None for infinite wait, or a reasonable timeout for periodic checks - self.poller.wait(&mut events, Some(Duration::from_millis(100)))?; - - // Track which sessions had output so we only read from those - let mut sessions_with_output: Vec = Vec::new(); - - for event in events.iter() { - match event.key { - LISTENER_KEY => { - self.accept_client()?; - } - key if key >= CLIENT_KEY_BASE && key < SESSION_KEY_BASE => { - let client_id = key - CLIENT_KEY_BASE; - if let Err(e) = self.handle_client(client_id) { - log::warn!("Client {} error: {}", client_id, e); - self.remove_client(client_id); - } - } - key if key >= SESSION_KEY_BASE => { - let session_id = (key - SESSION_KEY_BASE) as u32; - sessions_with_output.push(session_id); - // Don't re-register here - we'll do it AFTER reading from the session - } - _ => {} - } - } - - // Read from sessions that have data, THEN re-register for polling - // This order is critical: we must fully drain the buffer before re-registering - // to avoid busy-looping with level-triggered polling - for session_id in sessions_with_output { - if let Some(session) = self.state.sessions.get_mut(&session_id) { - // First, drain all available data from the PTY - let _ = session.poll(&mut self.read_buffer); - - // Now re-register for polling (after buffer is drained) - let _ = self.poller.modify( - session.fd(), - Event::readable(SESSION_KEY_BASE + session_id as usize), - ); - } - } - - // Send updates to clients if any session is dirty - if self.state.any_dirty() { - self.broadcast_updates()?; - self.state.mark_all_clean(); - } - - // Re-register listener - self.poller.modify(&self.listener, Event::readable(LISTENER_KEY))?; - - // If no tabs exist, create one automatically - if self.state.tabs.is_empty() { - log::info!("No tabs open, creating new tab"); - if let Err(e) = self.state.create_initial_tab() { - log::error!("Failed to create tab: {}", e); - } else { - // Register session FD for polling - for session in self.state.sessions.values() { - unsafe { - let _ = self.poller.add( - session.fd().as_raw_fd(), - Event::readable(SESSION_KEY_BASE + session.id as usize), - ); - } - } - } - } - } - - // Notify clients of shutdown - for client in self.clients.values_mut() { - let _ = client.send(&DaemonMessage::Shutdown); - } - - // Clean up socket - let _ = std::fs::remove_file(socket_path()); - - Ok(()) - } - - fn accept_client(&mut self) -> io::Result<()> { - match self.listener.accept() { - Ok((stream, _addr)) => { - let client_id = self.next_client_id; - self.next_client_id += 1; - - log::info!("Client {} connected", client_id); - - let client = Client::new(stream)?; - - // Register client socket for reading - unsafe { - self.poller.add( - client.stream.as_raw_fd(), - Event::readable(CLIENT_KEY_BASE + client_id), - )?; - } - - self.clients.insert(client_id, client); - } - Err(e) if e.kind() == io::ErrorKind::WouldBlock => {} - Err(e) => return Err(e), - } - Ok(()) - } - - fn handle_client(&mut self, client_id: usize) -> io::Result<()> { - // First, collect all messages from this client - // We read all available messages BEFORE re-registering to avoid busy-looping - let messages: Vec = { - let client = self.clients.get_mut(&client_id) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "client not found"))?; - - let mut msgs = Vec::new(); - loop { - match client.try_recv() { - Ok(Some(msg)) => msgs.push(msg), - Ok(None) => break, - Err(e) => return Err(e), - } - } - - // Re-register for more events AFTER draining the socket - self.poller.modify(&client.stream, Event::readable(CLIENT_KEY_BASE + client_id))?; - - msgs - }; - - // Now process messages without holding client borrow - for msg in messages { - match msg { - ClientMessage::Hello { cols, rows } => { - log::info!("Client {} says hello with size {}x{}", client_id, cols, rows); - - // Update dimensions - self.state.resize(cols, rows); - - // Create initial tab if none exists - if self.state.tabs.is_empty() { - self.state.create_initial_tab() - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; - - // Register session FD for polling - for session in self.state.sessions.values() { - unsafe { - self.poller.add( - session.fd().as_raw_fd(), - Event::readable(SESSION_KEY_BASE + session.id as usize), - )?; - } - } - } - - // Send full state to client - self.send_full_state(client_id)?; - } - - ClientMessage::Input { data } => { - if let Some(session) = self.state.focused_session_mut() { - let _ = session.write(&data); - - // Send any terminal responses back to PTY - if let Some(response) = session.take_response() { - let _ = session.write(&response); - } - } - } - - ClientMessage::Resize { cols, rows } => { - log::debug!("Client {} resize to {}x{}", client_id, cols, rows); - self.state.resize(cols, rows); - - // Send updated state - self.broadcast_updates()?; - } - - ClientMessage::CreateTab => { - match self.state.create_tab() { - Ok(tab_id) => { - log::info!("Created tab {}", tab_id); - - // Register new session for polling - if let Some(tab) = self.state.tabs.iter().find(|t| t.id == tab_id) { - if let Some(pane) = tab.panes.first() { - if let Some(session) = self.state.sessions.get(&pane.session_id) { - unsafe { - let _ = self.poller.add( - session.fd().as_raw_fd(), - Event::readable(SESSION_KEY_BASE + session.id as usize), - ); - } - } - } - } - - // Broadcast full state update - self.broadcast_full_state()?; - } - Err(e) => { - log::error!("Failed to create tab: {}", e); - } - } - } - - ClientMessage::CloseTab { tab_id } => { - if self.state.close_tab(tab_id) { - log::info!("Closed tab {}", tab_id); - self.broadcast_full_state()?; - } - } - - ClientMessage::SwitchTab { tab_id } => { - if self.state.switch_tab(tab_id) { - log::debug!("Switched to tab {}", tab_id); - - // Send tab changed message - let active_tab = self.state.active_tab; - for client in self.clients.values_mut() { - let _ = client.send(&DaemonMessage::TabChanged { active_tab }); - } - - // Send current pane content - self.broadcast_updates()?; - } - } - - ClientMessage::NextTab => { - if self.state.next_tab() { - log::debug!("Switched to next tab"); - let active_tab = self.state.active_tab; - for client in self.clients.values_mut() { - let _ = client.send(&DaemonMessage::TabChanged { active_tab }); - } - self.broadcast_updates()?; - } - } - - ClientMessage::PrevTab => { - if self.state.prev_tab() { - log::debug!("Switched to previous tab"); - let active_tab = self.state.active_tab; - for client in self.clients.values_mut() { - let _ = client.send(&DaemonMessage::TabChanged { active_tab }); - } - self.broadcast_updates()?; - } - } - - ClientMessage::SwitchTabIndex { index } => { - if self.state.switch_tab_index(index) { - log::debug!("Switched to tab index {}", index); - let active_tab = self.state.active_tab; - for client in self.clients.values_mut() { - let _ = client.send(&DaemonMessage::TabChanged { active_tab }); - } - self.broadcast_updates()?; - } - } - - ClientMessage::SplitPane { direction } => { - match self.state.split_pane(direction) { - Ok((tab_id, pane_info)) => { - log::info!("Split pane in tab {}, new pane {}", tab_id, pane_info.id); - - // Register new session for polling - if let Some(session) = self.state.sessions.get(&pane_info.session_id) { - unsafe { - let _ = self.poller.add( - session.fd().as_raw_fd(), - Event::readable(SESSION_KEY_BASE + session.id as usize), - ); - } - } - - // Broadcast full state update - self.broadcast_full_state()?; - } - Err(e) => { - log::error!("Failed to split pane: {}", e); - } - } - } - - ClientMessage::ClosePane => { - if let Some((tab_id, pane_id, tab_closed)) = self.state.close_pane() { - if tab_closed { - log::info!("Closed pane {} (last pane, closed tab {})", pane_id, tab_id); - } else { - log::info!("Closed pane {} in tab {}", pane_id, tab_id); - } - self.broadcast_full_state()?; - } - } - - ClientMessage::FocusPane { direction } => { - if let Some((tab_id, active_pane)) = self.state.focus_pane_direction(direction) { - log::debug!("Focused pane in direction {:?}", direction); - for client in self.clients.values_mut() { - let _ = client.send(&DaemonMessage::PaneFocused { tab_id, active_pane }); - } - self.broadcast_updates()?; - } - } - - ClientMessage::Scroll { pane_id, delta } => { - // Find the session ID for this pane - let session_id = self.state.active_tab() - .and_then(|tab| tab.panes.iter().find(|p| p.id == pane_id)) - .map(|pane| pane.session_id); - - // Now adjust scroll on the session - if let Some(session_id) = session_id { - if let Some(session) = self.state.sessions.get_mut(&session_id) { - if delta > 0 { - session.terminal.scroll_viewport_up(delta as usize); - } else if delta < 0 { - session.terminal.scroll_viewport_down((-delta) as usize); - } - // Mark session dirty to trigger update - session.dirty = true; - } - } - self.broadcast_updates()?; - } - - ClientMessage::Goodbye => { - log::info!("Client {} disconnecting", client_id); - return Err(io::Error::new(io::ErrorKind::ConnectionReset, "goodbye")); - } - } - } - - Ok(()) - } - - fn remove_client(&mut self, client_id: usize) { - if let Some(client) = self.clients.remove(&client_id) { - let _ = self.poller.delete(&client.stream); - log::info!("Client {} removed", client_id); - } - } - - - fn send_full_state(&mut self, client_id: usize) -> io::Result<()> { - let window = self.state.to_protocol(); - - // Collect snapshots for all visible panes in the active tab - let panes: Vec = if let Some(tab) = self.state.active_tab() { - tab.panes.iter().filter_map(|pane| { - self.state.sessions.get(&pane.session_id) - .map(|session| session.snapshot(pane.id)) - }).collect() - } else { - Vec::new() - }; - - let msg = DaemonMessage::FullState { window, panes }; - - if let Some(client) = self.clients.get_mut(&client_id) { - client.send(&msg)?; - } - - Ok(()) - } - - fn broadcast_full_state(&mut self) -> io::Result<()> { - let window = self.state.to_protocol(); - - let panes: Vec = if let Some(tab) = self.state.active_tab() { - tab.panes.iter().filter_map(|pane| { - self.state.sessions.get(&pane.session_id) - .map(|session| session.snapshot(pane.id)) - }).collect() - } else { - Vec::new() - }; - - let msg = DaemonMessage::FullState { window, panes }; - - for client in self.clients.values_mut() { - let _ = client.send(&msg); - } - - Ok(()) - } - - fn broadcast_updates(&mut self) -> io::Result<()> { - // For now, send full state on updates - // TODO: Send incremental PaneUpdate messages instead - self.broadcast_full_state() - } -} - -/// Check if the daemon is running. -pub fn is_running() -> bool { - let socket = socket_path(); - if !socket.exists() { - return false; - } - - // Try to connect - match UnixStream::connect(&socket) { - Ok(_) => true, - Err(_) => { - // Socket exists but can't connect - stale socket - let _ = std::fs::remove_file(&socket); - false - } - } -} - -/// Start the daemon in the background. -pub fn start_daemon() -> io::Result<()> { - use std::process::Command; - - // Get path to current executable - let exe = std::env::current_exe()?; - let daemon_exe = exe.with_file_name("ztermd"); - - if !daemon_exe.exists() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!("daemon executable not found: {:?}", daemon_exe), - )); - } - - // Spawn daemon in background - Command::new(&daemon_exe) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn()?; - - // Wait a bit for it to start - std::thread::sleep(Duration::from_millis(100)); - - Ok(()) -} diff --git a/src/glyph_shader.wgsl b/src/glyph_shader.wgsl index a7758f5..095fe5c 100644 --- a/src/glyph_shader.wgsl +++ b/src/glyph_shader.wgsl @@ -1,4 +1,9 @@ // Glyph rendering shader for terminal emulator +// Supports both legacy quad-based rendering and new instanced cell rendering + +// ═══════════════════════════════════════════════════════════════════════════════ +// LEGACY QUAD-BASED RENDERING (for backwards compatibility) +// ═══════════════════════════════════════════════════════════════════════════════ struct VertexInput { @location(0) position: vec2, @@ -46,3 +51,311 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { // The background was already rendered, so we just blend the glyph on top return vec4(in.color.rgb, in.color.a * glyph_alpha); } + +// ═══════════════════════════════════════════════════════════════════════════════ +// KITTY-STYLE INSTANCED CELL RENDERING +// ═══════════════════════════════════════════════════════════════════════════════ + +// Color table uniform containing 256 indexed colors + default fg/bg +struct ColorTable { + // 256 indexed colors + default_fg (256) + default_bg (257) + colors: array, 258>, +} + +// Grid parameters uniform +struct GridParams { + // Grid dimensions in cells + cols: u32, + rows: u32, + // Cell dimensions in pixels + cell_width: f32, + cell_height: f32, + // Screen dimensions in pixels + screen_width: f32, + screen_height: f32, + // Y offset for tab bar + y_offset: f32, + // Cursor position (-1 if hidden) + cursor_col: i32, + cursor_row: i32, + // Cursor style: 0=block, 1=underline, 2=bar + cursor_style: u32, + // Padding + _padding: vec2, +} + +// GPUCell instance data (matches Rust GPUCell struct) +struct GPUCell { + fg: u32, + bg: u32, + decoration_fg: u32, + sprite_idx: u32, + attrs: u32, +} + +// Sprite info for glyph positioning +struct SpriteInfo { + // UV coordinates in atlas (x, y, width, height) - normalized 0-1 + uv: vec4, + // Offset from cell origin (x, y) in pixels + offset: vec2, + // Size in pixels + size: vec2, +} + +// Uniforms and storage buffers for instanced rendering +@group(1) @binding(0) +var color_table: ColorTable; + +@group(1) @binding(1) +var grid_params: GridParams; + +@group(1) @binding(2) +var cells: array; + +@group(1) @binding(3) +var sprites: array; + +// Constants for packed color decoding +const COLOR_TYPE_DEFAULT: u32 = 0u; +const COLOR_TYPE_INDEXED: u32 = 1u; +const COLOR_TYPE_RGB: u32 = 2u; + +// Constants for cell attributes +const ATTR_DECORATION_MASK: u32 = 0x7u; +const ATTR_BOLD_BIT: u32 = 0x8u; +const ATTR_ITALIC_BIT: u32 = 0x10u; +const ATTR_REVERSE_BIT: u32 = 0x20u; +const ATTR_STRIKE_BIT: u32 = 0x40u; +const ATTR_DIM_BIT: u32 = 0x80u; + +// Colored glyph flag +const COLORED_GLYPH_FLAG: u32 = 0x80000000u; + +// Vertex output for instanced cell rendering +struct CellVertexOutput { + @builtin(position) clip_position: vec4, + @location(0) uv: vec2, + @location(1) fg_color: vec4, + @location(2) bg_color: vec4, + @location(3) @interpolate(flat) is_background: u32, + @location(4) @interpolate(flat) is_colored_glyph: u32, +} + +// Resolve a packed color to RGBA +fn resolve_color(packed: u32, is_foreground: bool) -> vec4 { + let color_type = packed & 0xFFu; + + if color_type == COLOR_TYPE_DEFAULT { + // Default color - use color table entry 256 (fg) or 257 (bg) + if is_foreground { + return color_table.colors[256]; + } else { + return color_table.colors[257]; + } + } else if color_type == COLOR_TYPE_INDEXED { + // Indexed color - look up in color table + let index = (packed >> 8u) & 0xFFu; + return color_table.colors[index]; + } else { + // RGB color - extract components + let r = f32((packed >> 8u) & 0xFFu) / 255.0; + let g = f32((packed >> 16u) & 0xFFu) / 255.0; + let b = f32((packed >> 24u) & 0xFFu) / 255.0; + return vec4(r, g, b, 1.0); + } +} + +// Convert sRGB to linear (for GPU rendering to sRGB surface) +fn srgb_to_linear(c: f32) -> f32 { + if c <= 0.04045 { + return c / 12.92; + } else { + return pow((c + 0.055) / 1.055, 2.4); + } +} + +// Convert pixel coordinate to NDC +fn pixel_to_ndc(pixel: vec2, screen: vec2) -> vec2 { + return vec2( + (pixel.x / screen.x) * 2.0 - 1.0, + 1.0 - (pixel.y / screen.y) * 2.0 + ); +} + +// Background vertex shader (renders cell backgrounds) +// vertex_index: 0-3 for quad corners +// instance_index: cell index in row-major order +@vertex +fn vs_cell_bg( + @builtin(vertex_index) vertex_index: u32, + @builtin(instance_index) instance_index: u32 +) -> CellVertexOutput { + let col = instance_index % grid_params.cols; + let row = instance_index / grid_params.cols; + + // Skip if out of bounds + if row >= grid_params.rows { + var out: CellVertexOutput; + out.clip_position = vec4(0.0, 0.0, 0.0, 0.0); + return out; + } + + // Get cell data + let cell = cells[instance_index]; + + // Calculate cell pixel position + let cell_x = f32(col) * grid_params.cell_width; + let cell_y = grid_params.y_offset + f32(row) * grid_params.cell_height; + + // Quad vertex positions (0=top-left, 1=top-right, 2=bottom-right, 3=bottom-left) + var positions: array, 4>; + positions[0] = vec2(cell_x, cell_y); + positions[1] = vec2(cell_x + grid_params.cell_width, cell_y); + positions[2] = vec2(cell_x + grid_params.cell_width, cell_y + grid_params.cell_height); + positions[3] = vec2(cell_x, cell_y + grid_params.cell_height); + + let screen_size = vec2(grid_params.screen_width, grid_params.screen_height); + let ndc_pos = pixel_to_ndc(positions[vertex_index], screen_size); + + // Resolve colors + let attrs = cell.attrs; + let is_reverse = (attrs & ATTR_REVERSE_BIT) != 0u; + + var fg = resolve_color(cell.fg, true); + var bg = resolve_color(cell.bg, false); + + // Handle reverse video + if is_reverse { + let tmp = fg; + fg = bg; + bg = tmp; + } + + // Convert to linear for sRGB surface + fg = vec4(srgb_to_linear(fg.r), srgb_to_linear(fg.g), srgb_to_linear(fg.b), fg.a); + bg = vec4(srgb_to_linear(bg.r), srgb_to_linear(bg.g), srgb_to_linear(bg.b), bg.a); + + var out: CellVertexOutput; + out.clip_position = vec4(ndc_pos, 0.0, 1.0); + out.uv = vec2(0.0, 0.0); // Not used for background + out.fg_color = fg; + out.bg_color = bg; + out.is_background = 1u; + out.is_colored_glyph = 0u; + + return out; +} + +// Glyph vertex shader (renders cell glyphs) +@vertex +fn vs_cell_glyph( + @builtin(vertex_index) vertex_index: u32, + @builtin(instance_index) instance_index: u32 +) -> CellVertexOutput { + let col = instance_index % grid_params.cols; + let row = instance_index / grid_params.cols; + + // Skip if out of bounds + if row >= grid_params.rows { + var out: CellVertexOutput; + out.clip_position = vec4(0.0, 0.0, 0.0, 0.0); + return out; + } + + // Get cell data + let cell = cells[instance_index]; + let sprite_idx = cell.sprite_idx & ~COLORED_GLYPH_FLAG; + let is_colored = (cell.sprite_idx & COLORED_GLYPH_FLAG) != 0u; + + // Skip if no glyph + if sprite_idx == 0u { + var out: CellVertexOutput; + out.clip_position = vec4(0.0, 0.0, 0.0, 0.0); + return out; + } + + // Get sprite info + let sprite = sprites[sprite_idx]; + + // Skip if sprite has no size + if sprite.size.x <= 0.0 || sprite.size.y <= 0.0 { + var out: CellVertexOutput; + out.clip_position = vec4(0.0, 0.0, 0.0, 0.0); + return out; + } + + // Calculate cell pixel position + let cell_x = f32(col) * grid_params.cell_width; + let cell_y = grid_params.y_offset + f32(row) * grid_params.cell_height; + + // Calculate glyph position (baseline-relative) + let baseline_y = cell_y + grid_params.cell_height * 0.8; + let glyph_x = cell_x + sprite.offset.x; + let glyph_y = baseline_y - sprite.offset.y - sprite.size.y; + + // Quad vertex positions + var positions: array, 4>; + positions[0] = vec2(glyph_x, glyph_y); + positions[1] = vec2(glyph_x + sprite.size.x, glyph_y); + positions[2] = vec2(glyph_x + sprite.size.x, glyph_y + sprite.size.y); + positions[3] = vec2(glyph_x, glyph_y + sprite.size.y); + + // UV coordinates + var uvs: array, 4>; + uvs[0] = vec2(sprite.uv.x, sprite.uv.y); + uvs[1] = vec2(sprite.uv.x + sprite.uv.z, sprite.uv.y); + uvs[2] = vec2(sprite.uv.x + sprite.uv.z, sprite.uv.y + sprite.uv.w); + uvs[3] = vec2(sprite.uv.x, sprite.uv.y + sprite.uv.w); + + let screen_size = vec2(grid_params.screen_width, grid_params.screen_height); + let ndc_pos = pixel_to_ndc(positions[vertex_index], screen_size); + + // Resolve colors + let attrs = cell.attrs; + let is_reverse = (attrs & ATTR_REVERSE_BIT) != 0u; + + var fg = resolve_color(cell.fg, true); + var bg = resolve_color(cell.bg, false); + + if is_reverse { + let tmp = fg; + fg = bg; + bg = tmp; + } + + // Convert to linear + fg = vec4(srgb_to_linear(fg.r), srgb_to_linear(fg.g), srgb_to_linear(fg.b), fg.a); + + var out: CellVertexOutput; + out.clip_position = vec4(ndc_pos, 0.0, 1.0); + out.uv = uvs[vertex_index]; + out.fg_color = fg; + out.bg_color = vec4(0.0, 0.0, 0.0, 0.0); + out.is_background = 0u; + out.is_colored_glyph = select(0u, 1u, is_colored); + + return out; +} + +// Fragment shader for cell rendering (both background and glyph) +@fragment +fn fs_cell(in: CellVertexOutput) -> @location(0) vec4 { + if in.is_background == 1u { + // Background - just output the bg color + return in.bg_color; + } + + // Glyph - sample from atlas + let glyph_alpha = textureSample(atlas_texture, atlas_sampler, in.uv).r; + + if in.is_colored_glyph == 1u { + // Colored glyph (emoji) - use atlas color directly + // Note: For now we just use alpha since our atlas is single-channel + // Full emoji support would need an RGBA atlas + return vec4(in.fg_color.rgb, glyph_alpha); + } + + // Normal glyph - tint with foreground color + return vec4(in.fg_color.rgb, in.fg_color.a * glyph_alpha); +} diff --git a/src/keyboard.rs b/src/keyboard.rs index 8198c14..adac077 100644 --- a/src/keyboard.rs +++ b/src/keyboard.rs @@ -458,19 +458,17 @@ impl<'a> KeyEncoder<'a> { } } - /// Encodes arrow/home/end keys: CSI 1;mod X or SS3 X (no modifiers). + /// Encodes arrow/home/end keys: CSI 1;mod X (with modifiers) or SS3 X (no modifiers). fn encode_arrow(&self, letter: u8, mod_param: Option) -> Vec { if let Some(m) = mod_param { - vec![0x1b, b'[', b'1', b';', b'0' + (m / 10), b'0' + (m % 10), letter] - .into_iter() - .filter(|&b| b != b'0' || m >= 10) - .collect::>() - .into_iter() - .take(6 + if m >= 10 { 1 } else { 0 }) - .collect() + // With modifiers: CSI 1;mod letter + let mut result = vec![0x1b, b'[', b'1', b';']; + result.extend_from_slice(m.to_string().as_bytes()); + result.push(letter); + result } else { - // SS3 letter (cursor key mode) or CSI letter - vec![0x1b, b'[', letter] + // No modifiers: SS3 letter (application cursor mode) + vec![0x1b, b'O', letter] } } diff --git a/src/lib.rs b/src/lib.rs index 67a7637..c4af061 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,14 +1,10 @@ //! ZTerm - A GPU-accelerated terminal emulator for Wayland. //! -//! This library provides shared functionality between the daemon and client. +//! Single-process architecture: one process owns PTY, terminal state, and rendering. -pub mod client; pub mod config; -pub mod daemon; pub mod keyboard; -pub mod protocol; pub mod pty; pub mod renderer; -pub mod session; pub mod terminal; -pub mod window_state; +pub mod vt_parser; diff --git a/src/main.rs b/src/main.rs index dc350c1..aa1d1ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,89 +1,975 @@ -//! ZTerm Client - GPU-accelerated terminal emulator that connects to the daemon. +//! ZTerm - GPU-accelerated terminal emulator. +//! +//! Single-process architecture: owns PTY, terminal state, and rendering. +//! Supports window close/reopen without losing terminal state. -use zterm::client::DaemonClient; use zterm::config::{Action, Config}; use zterm::keyboard::{FunctionalKey, KeyEncoder, KeyEventType, KeyboardState, Modifiers}; -use zterm::protocol::{ClientMessage, DaemonMessage, Direction, PaneId, PaneInfo, PaneSnapshot, WindowState}; +use zterm::pty::Pty; use zterm::renderer::Renderer; +use zterm::terminal::{Terminal, MouseTrackingMode}; + +use std::collections::HashMap; +use std::io::Write; +use std::os::fd::AsRawFd; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; use polling::{Event, Events, Poller}; -use std::collections::HashMap; -use std::os::fd::{AsRawFd, BorrowedFd}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::Duration; use winit::application::ApplicationHandler; -use winit::dpi::PhysicalSize; -use winit::event::{ElementState, KeyEvent, Modifiers as WinitModifiers, MouseScrollDelta, WindowEvent}; +use winit::dpi::{PhysicalPosition, PhysicalSize}; +use winit::event::{ElementState, KeyEvent, MouseButton, Modifiers as WinitModifiers, MouseScrollDelta, WindowEvent}; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy}; use winit::keyboard::{Key, NamedKey}; use winit::platform::wayland::EventLoopBuilderExtWayland; use winit::window::{Window, WindowId}; +/// Kitty-style shared buffer for PTY I/O using double-buffering. +/// +/// Uses two buffers that swap roles: +/// - I/O thread writes to the "write" buffer +/// - Main thread parses from the "read" buffer +/// - On `swap()`, the buffers exchange roles +/// +/// This gives us: +/// - Zero-copy parsing (main thread reads directly from buffer) +/// - No lock contention during parsing (each thread has its own buffer) +/// - No memmove needed +const PTY_BUF_SIZE: usize = 4 * 1024 * 1024; // 4MB like Kitty + +struct SharedPtyBuffer { + inner: Mutex, +} + +struct DoubleBuffer { + /// Two buffers that swap roles + bufs: [Vec; 2], + /// Which buffer the I/O thread writes to (0 or 1) + write_idx: usize, + /// How many bytes are pending in the write buffer + write_len: usize, +} + +impl SharedPtyBuffer { + fn new() -> Self { + Self { + inner: Mutex::new(DoubleBuffer { + bufs: [vec![0u8; PTY_BUF_SIZE], vec![0u8; PTY_BUF_SIZE]], + write_idx: 0, + write_len: 0, + }), + } + } + + /// Read from PTY fd into the write buffer. Called by I/O thread. + /// Returns number of bytes read, 0 if no space/would block, -1 on error. + fn read_from_fd(&self, fd: i32) -> isize { + let mut inner = self.inner.lock().unwrap(); + + let available = PTY_BUF_SIZE.saturating_sub(inner.write_len); + if available == 0 { + return 0; // Buffer full, need swap + } + + let write_idx = inner.write_idx; + let write_len = inner.write_len; + let buf_ptr = unsafe { inner.bufs[write_idx].as_mut_ptr().add(write_len) }; + + let result = unsafe { + libc::read(fd, buf_ptr as *mut libc::c_void, available) + }; + + if result > 0 { + inner.write_len += result as usize; + } + result + } + + /// Check if there's space in the write buffer. + fn has_space(&self) -> bool { + let inner = self.inner.lock().unwrap(); + inner.write_len < PTY_BUF_SIZE + } + + /// Swap buffers and return data to parse. Called by main thread. + /// The I/O thread will start writing to the other buffer. + fn take_pending(&self) -> Vec { + let mut inner = self.inner.lock().unwrap(); + + if inner.write_len == 0 { + return Vec::new(); // Nothing new to parse + } + + // Swap: the write buffer becomes the read buffer + let read_idx = inner.write_idx; + let read_len = inner.write_len; + + // Switch I/O thread to the other buffer + inner.write_idx = 1 - inner.write_idx; + inner.write_len = 0; + + // Return a copy of the data to parse + // (We have to copy because we can't return a reference with the mutex) + inner.bufs[read_idx][..read_len].to_vec() + } +} + +/// Unique identifier for a pane. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct PaneId(u64); + +impl PaneId { + fn new() -> Self { + use std::sync::atomic::AtomicU64; + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + Self(NEXT_ID.fetch_add(1, Ordering::Relaxed)) + } +} + +/// A single pane containing a terminal and its PTY. +struct Pane { + /// Unique identifier for this pane. + id: PaneId, + /// Terminal state (grid, cursor, scrollback, etc.). + terminal: Terminal, + /// PTY connection to the shell. + pty: Pty, + /// Raw file descriptor for the PTY (for polling). + pty_fd: i32, + /// Shared buffer for this pane's PTY I/O. + pty_buffer: Arc, + /// Selection state for this pane. + selection: Option, + /// Whether we're currently selecting in this pane. + is_selecting: bool, + /// Last scrollback length for tracking changes. + last_scrollback_len: u32, +} + +impl Pane { + /// Create a new pane with its own terminal and PTY. + fn new(cols: usize, rows: usize, scrollback_lines: usize) -> Result { + let terminal = Terminal::new(cols, rows, scrollback_lines); + let pty = Pty::spawn(None).map_err(|e| format!("Failed to spawn PTY: {}", e))?; + + // Set terminal size + if let Err(e) = pty.resize(cols as u16, rows as u16) { + log::warn!("Failed to set initial PTY size: {}", e); + } + + let pty_fd = pty.as_raw_fd(); + + Ok(Self { + id: PaneId::new(), + terminal, + pty, + pty_fd, + pty_buffer: Arc::new(SharedPtyBuffer::new()), + selection: None, + is_selecting: false, + last_scrollback_len: 0, + }) + } + + /// Resize the terminal and PTY. + fn resize(&mut self, cols: usize, rows: usize) { + self.terminal.resize(cols, rows); + if let Err(e) = self.pty.resize(cols as u16, rows as u16) { + log::warn!("Failed to resize PTY: {}", e); + } + } + + /// Write data to the PTY. + fn write_to_pty(&mut self, data: &[u8]) { + if let Err(e) = self.pty.write(data) { + log::warn!("Failed to write to PTY: {}", e); + } + } + + /// Check if the shell has exited. + fn child_exited(&self) -> bool { + self.pty.child_exited() + } +} + +/// Geometry of a pane in pixels. +#[derive(Debug, Clone, Copy)] +struct PaneGeometry { + /// Left edge in pixels. + x: f32, + /// Top edge in pixels. + y: f32, + /// Width in pixels. + width: f32, + /// Height in pixels. + height: f32, + /// Number of columns. + cols: usize, + /// Number of rows. + rows: usize, +} + +/// A node in the split tree - either a split or a leaf (pane). +enum SplitNode { + /// A leaf node containing a pane. + Leaf { + pane_id: PaneId, + /// Cached geometry, updated during layout. + geometry: PaneGeometry, + }, + /// A split node with two children. + Split { + /// True for horizontal split (panes side-by-side), false for vertical (panes stacked). + horizontal: bool, + /// Size ratio of the first child (0.0 to 1.0). + ratio: f32, + /// First child (left or top). + first: Box, + /// Second child (right or bottom). + second: Box, + }, +} + +impl SplitNode { + /// Create a new leaf node. + fn leaf(pane_id: PaneId) -> Self { + SplitNode::Leaf { + pane_id, + geometry: PaneGeometry { + x: 0.0, + y: 0.0, + width: 0.0, + height: 0.0, + cols: 0, + rows: 0, + }, + } + } + + /// Split this node, replacing it with a split containing the original and a new pane. + /// Returns the new node that should replace this one. + fn split(self, new_pane_id: PaneId, horizontal: bool) -> Self { + SplitNode::Split { + horizontal, + ratio: 0.5, + first: Box::new(self), + second: Box::new(SplitNode::leaf(new_pane_id)), + } + } + + /// Calculate layout for all nodes given the available space. + fn layout(&mut self, x: f32, y: f32, width: f32, height: f32, cell_width: f32, cell_height: f32, border_width: f32) { + match self { + SplitNode::Leaf { geometry, .. } => { + let cols = ((width - border_width) / cell_width).floor() as usize; + let rows = ((height - border_width) / cell_height).floor() as usize; + *geometry = PaneGeometry { + x, + y, + width, + height, + cols: cols.max(1), + rows: rows.max(1), + }; + } + SplitNode::Split { horizontal, ratio, first, second } => { + if *horizontal { + // Side-by-side split + let first_width = (width * *ratio) - border_width / 2.0; + let second_width = width - first_width - border_width; + first.layout(x, y, first_width, height, cell_width, cell_height, border_width); + second.layout(x + first_width + border_width, y, second_width, height, cell_width, cell_height, border_width); + } else { + // Stacked split + let first_height = (height * *ratio) - border_width / 2.0; + let second_height = height - first_height - border_width; + first.layout(x, y, width, first_height, cell_width, cell_height, border_width); + second.layout(x, y + first_height + border_width, width, second_height, cell_width, cell_height, border_width); + } + } + } + } + + /// Find the geometry for a specific pane. + fn find_geometry(&self, target_id: PaneId) -> Option { + match self { + SplitNode::Leaf { pane_id, geometry } => { + if *pane_id == target_id { + Some(*geometry) + } else { + None + } + } + SplitNode::Split { first, second, .. } => { + first.find_geometry(target_id).or_else(|| second.find_geometry(target_id)) + } + } + } + + /// Collect all pane IDs. + fn collect_pane_ids(&self, ids: &mut Vec) { + match self { + SplitNode::Leaf { pane_id, .. } => ids.push(*pane_id), + SplitNode::Split { first, second, .. } => { + first.collect_pane_ids(ids); + second.collect_pane_ids(ids); + } + } + } + + /// Collect all pane geometries. + fn collect_geometries(&self, geometries: &mut Vec<(PaneId, PaneGeometry)>) { + match self { + SplitNode::Leaf { pane_id, geometry } => { + geometries.push((*pane_id, *geometry)); + } + SplitNode::Split { first, second, .. } => { + first.collect_geometries(geometries); + second.collect_geometries(geometries); + } + } + } + + /// Find a neighbor pane in the given direction. + /// Returns the pane ID of the neighbor, if any. + fn find_neighbor(&self, target_id: PaneId, direction: Direction) -> Option { + // First, find the geometry of the target pane + let target_geom = self.find_geometry(target_id)?; + + // Collect all geometries + let mut all_geoms = Vec::new(); + self.collect_geometries(&mut all_geoms); + + // Find the best candidate in the given direction + let mut best: Option<(PaneId, f32)> = None; + + for (pane_id, geom) in all_geoms { + if pane_id == target_id { + continue; + } + + let is_neighbor = match direction { + Direction::Up => { + // Neighbor is above: its bottom edge is near our top edge + geom.y + geom.height <= target_geom.y + 5.0 && + Self::overlaps_horizontally(&geom, &target_geom) + } + Direction::Down => { + // Neighbor is below: its top edge is near our bottom edge + geom.y >= target_geom.y + target_geom.height - 5.0 && + Self::overlaps_horizontally(&geom, &target_geom) + } + Direction::Left => { + // Neighbor is to the left: its right edge is near our left edge + geom.x + geom.width <= target_geom.x + 5.0 && + Self::overlaps_vertically(&geom, &target_geom) + } + Direction::Right => { + // Neighbor is to the right: its left edge is near our right edge + geom.x >= target_geom.x + target_geom.width - 5.0 && + Self::overlaps_vertically(&geom, &target_geom) + } + }; + + if is_neighbor { + // Calculate distance (for choosing closest) + let distance = match direction { + Direction::Up => target_geom.y - (geom.y + geom.height), + Direction::Down => geom.y - (target_geom.y + target_geom.height), + Direction::Left => target_geom.x - (geom.x + geom.width), + Direction::Right => geom.x - (target_geom.x + target_geom.width), + }; + + if distance >= 0.0 { + if best.is_none() || distance < best.unwrap().1 { + best = Some((pane_id, distance)); + } + } + } + } + + best.map(|(id, _)| id) + } + + fn overlaps_horizontally(a: &PaneGeometry, b: &PaneGeometry) -> bool { + let a_left = a.x; + let a_right = a.x + a.width; + let b_left = b.x; + let b_right = b.x + b.width; + a_left < b_right && a_right > b_left + } + + fn overlaps_vertically(a: &PaneGeometry, b: &PaneGeometry) -> bool { + let a_top = a.y; + let a_bottom = a.y + a.height; + let b_top = b.y; + let b_bottom = b.y + b.height; + a_top < b_bottom && a_bottom > b_top + } + + /// Remove a pane from the tree. Returns the new tree root (or None if tree is empty). + fn remove_pane(self, target_id: PaneId) -> Option { + match self { + SplitNode::Leaf { pane_id, .. } => { + if pane_id == target_id { + None // Remove this leaf + } else { + Some(self) // Keep this leaf + } + } + SplitNode::Split { horizontal, ratio, first, second } => { + // Check if target is in first or second subtree + let first_has_target = first.contains_pane(target_id); + let second_has_target = second.contains_pane(target_id); + + if first_has_target { + match first.remove_pane(target_id) { + Some(new_first) => Some(SplitNode::Split { + horizontal, + ratio, + first: Box::new(new_first), + second, + }), + None => Some(*second), // First child removed, promote second + } + } else if second_has_target { + match second.remove_pane(target_id) { + Some(new_second) => Some(SplitNode::Split { + horizontal, + ratio, + first, + second: Box::new(new_second), + }), + None => Some(*first), // Second child removed, promote first + } + } else { + Some(SplitNode::Split { horizontal, ratio, first, second }) + } + } + } + } + + /// Check if this tree contains the given pane. + fn contains_pane(&self, target_id: PaneId) -> bool { + match self { + SplitNode::Leaf { pane_id, .. } => *pane_id == target_id, + SplitNode::Split { first, second, .. } => { + first.contains_pane(target_id) || second.contains_pane(target_id) + } + } + } + + /// Split the pane with the given ID. + fn split_pane(self, target_id: PaneId, new_pane_id: PaneId, horizontal: bool) -> Self { + match self { + SplitNode::Leaf { pane_id, geometry } => { + if pane_id == target_id { + SplitNode::Leaf { pane_id, geometry }.split(new_pane_id, horizontal) + } else { + SplitNode::Leaf { pane_id, geometry } + } + } + SplitNode::Split { horizontal: h, ratio, first, second } => { + SplitNode::Split { + horizontal: h, + ratio, + first: Box::new(first.split_pane(target_id, new_pane_id, horizontal)), + second: Box::new(second.split_pane(target_id, new_pane_id, horizontal)), + } + } + } + } +} + +/// Direction for pane navigation. +#[derive(Debug, Clone, Copy)] +enum Direction { + Up, + Down, + Left, + Right, +} + +/// Unique identifier for a tab. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct TabId(u64); + +impl TabId { + fn new() -> Self { + use std::sync::atomic::AtomicU64; + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + Self(NEXT_ID.fetch_add(1, Ordering::Relaxed)) + } +} + +/// A single tab containing one or more panes arranged in a split tree. +struct Tab { + /// Unique identifier for this tab. + id: TabId, + /// All panes in this tab, keyed by PaneId. + panes: HashMap, + /// The split tree structure. + split_root: SplitNode, + /// Currently active pane ID. + active_pane: PaneId, + /// Tab title (from OSC or shell). + title: String, +} + +impl Tab { + /// Create a new tab with a single pane. + fn new(cols: usize, rows: usize, scrollback_lines: usize) -> Result { + let pane = Pane::new(cols, rows, scrollback_lines)?; + let pane_id = pane.id; + + let mut panes = HashMap::new(); + panes.insert(pane_id, pane); + + Ok(Self { + id: TabId::new(), + panes, + split_root: SplitNode::leaf(pane_id), + active_pane: pane_id, + title: String::from("zsh"), + }) + } + + /// Get the active pane. + fn active_pane(&self) -> Option<&Pane> { + self.panes.get(&self.active_pane) + } + + /// Get the active pane mutably. + fn active_pane_mut(&mut self) -> Option<&mut Pane> { + self.panes.get_mut(&self.active_pane) + } + + /// Resize all panes based on new window dimensions. + fn resize(&mut self, width: f32, height: f32, cell_width: f32, cell_height: f32, border_width: f32) { + // Recalculate layout + self.split_root.layout(0.0, 0.0, width, height, cell_width, cell_height, border_width); + + // Resize each pane's terminal based on its geometry + let mut geometries = Vec::new(); + self.split_root.collect_geometries(&mut geometries); + + for (pane_id, geom) in geometries { + if let Some(pane) = self.panes.get_mut(&pane_id) { + pane.resize(geom.cols, geom.rows); + } + } + } + + /// Write data to the active pane's PTY. + fn write_to_pty(&mut self, data: &[u8]) { + if let Some(pane) = self.active_pane_mut() { + pane.write_to_pty(data); + } + } + + /// Check if any pane's shell has exited and clean up. + /// Returns true if all panes have exited (tab should close). + fn check_exited_panes(&mut self) -> bool { + // Collect exited pane IDs + let exited: Vec = self.panes + .iter() + .filter(|(_, pane)| pane.child_exited()) + .map(|(id, _)| *id) + .collect(); + + // Remove exited panes + for pane_id in exited { + self.remove_pane(pane_id); + } + + self.panes.is_empty() + } + + /// Split the active pane. + fn split(&mut self, horizontal: bool, cols: usize, rows: usize, scrollback_lines: usize) -> Result { + let new_pane = Pane::new(cols, rows, scrollback_lines)?; + let new_pane_id = new_pane.id; + + // Add to panes map + self.panes.insert(new_pane_id, new_pane); + + // Update split tree + let old_root = std::mem::replace(&mut self.split_root, SplitNode::leaf(PaneId(0))); + self.split_root = old_root.split_pane(self.active_pane, new_pane_id, horizontal); + + // Focus the new pane + self.active_pane = new_pane_id; + + Ok(new_pane_id) + } + + /// Remove a pane from the tab. + fn remove_pane(&mut self, pane_id: PaneId) { + // Remove from map + self.panes.remove(&pane_id); + + // Update split tree + let old_root = std::mem::replace(&mut self.split_root, SplitNode::leaf(PaneId(0))); + if let Some(new_root) = old_root.remove_pane(pane_id) { + self.split_root = new_root; + } + + // If we removed the active pane, select a new one + if self.active_pane == pane_id { + if let Some(first_pane_id) = self.panes.keys().next() { + self.active_pane = *first_pane_id; + } + } + } + + /// Close the active pane. + fn close_active_pane(&mut self) { + let pane_id = self.active_pane; + self.remove_pane(pane_id); + } + + /// Navigate to a neighbor pane in the given direction. + fn focus_neighbor(&mut self, direction: Direction) { + if let Some(neighbor_id) = self.split_root.find_neighbor(self.active_pane, direction) { + self.active_pane = neighbor_id; + } + } + + /// Get all pane IDs. + fn pane_ids(&self) -> Vec { + self.panes.keys().copied().collect() + } + + /// Get pane by ID. + fn get_pane(&self, pane_id: PaneId) -> Option<&Pane> { + self.panes.get(&pane_id) + } + + /// Get pane by ID mutably. + fn get_pane_mut(&mut self, pane_id: PaneId) -> Option<&mut Pane> { + self.panes.get_mut(&pane_id) + } + + /// Collect all pane geometries for rendering. + fn collect_pane_geometries(&self) -> Vec<(PaneId, PaneGeometry)> { + let mut geometries = Vec::new(); + self.split_root.collect_geometries(&mut geometries); + geometries + } + + /// Check if all panes have exited (tab should be closed). + fn child_exited(&mut self) -> bool { + self.check_exited_panes() + } +} + +/// PID file location for single-instance support. +fn pid_file_path() -> std::path::PathBuf { + let runtime_dir = std::env::var("XDG_RUNTIME_DIR") + .unwrap_or_else(|_| "/tmp".to_string()); + std::path::PathBuf::from(runtime_dir).join("zterm.pid") +} + +/// Check if another instance is running and signal it to show window. +/// Returns true if we signaled an existing instance (and should exit). +fn signal_existing_instance() -> bool { + let pid_path = pid_file_path(); + + if let Ok(contents) = std::fs::read_to_string(&pid_path) { + if let Ok(pid) = contents.trim().parse::() { + // Check if process is alive + let alive = unsafe { libc::kill(pid, 0) == 0 }; + + if alive { + // Send SIGUSR1 to show window + log::info!("Signaling existing instance (PID {})", pid); + unsafe { libc::kill(pid, libc::SIGUSR1) }; + return true; + } else { + // Stale PID file, remove it + let _ = std::fs::remove_file(&pid_path); + } + } + } + + false +} + +/// Write our PID to the PID file. +fn write_pid_file() -> std::io::Result<()> { + let pid = std::process::id(); + std::fs::write(pid_file_path(), pid.to_string()) +} + +/// Remove the PID file on exit. +fn remove_pid_file() { + let _ = std::fs::remove_file(pid_file_path()); +} + +/// A cell position in the terminal grid. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct CellPosition { + col: usize, + row: isize, +} + +/// Selection state for mouse text selection. +#[derive(Clone, Debug)] +struct Selection { + start: CellPosition, + end: CellPosition, +} + +impl Selection { + fn normalized(&self) -> (CellPosition, CellPosition) { + if self.start.row < self.end.row + || (self.start.row == self.end.row && self.start.col <= self.end.col) { + (self.start, self.end) + } else { + (self.end, self.start) + } + } + + fn to_screen_coords(&self, current_scroll_offset: usize, visible_rows: usize) -> Option<(usize, usize, usize, usize)> { + let (start, end) = self.normalized(); + let scroll_offset = current_scroll_offset as isize; + let screen_start_row = start.row + scroll_offset; + let screen_end_row = end.row + scroll_offset; + + if screen_end_row < 0 || screen_start_row >= visible_rows as isize { + return None; + } + + let screen_start_row = screen_start_row.max(0) as usize; + let screen_end_row = (screen_end_row as usize).min(visible_rows.saturating_sub(1)); + let start_col = if start.row + scroll_offset < 0 { 0 } else { start.col }; + let end_col = if end.row + scroll_offset >= visible_rows as isize { usize::MAX } else { end.col }; + + Some((start_col, screen_start_row, end_col, screen_end_row)) + } +} + +/// User event for the event loop. +#[derive(Debug, Clone)] +enum UserEvent { + /// Signal received to show the window. + ShowWindow, + /// PTY has data available for a specific pane. + PtyReadable(PaneId), +} + /// Main application state. struct App { + /// Window (None when headless/closed). window: Option>, + /// GPU renderer (None when headless). renderer: Option, - daemon_client: Option, - /// Current window state (tabs info) from daemon. - window_state: Option, - /// All pane snapshots from daemon. - panes: Vec, - /// Whether we need to redraw. - dirty: bool, - /// Current modifier state. - modifiers: WinitModifiers, - /// Keyboard state for encoding (tracks protocol mode from daemon). - keyboard_state: KeyboardState, + /// All open tabs. + tabs: Vec, + /// Index of the currently active tab. + active_tab: usize, /// Application configuration. config: Config, /// Keybinding action map. action_map: HashMap<(bool, bool, bool, bool, String), Action>, - /// Event loop proxy for waking from daemon poll thread. - event_loop_proxy: Option>, - /// Shutdown signal for daemon poll thread. + /// Current modifier state. + modifiers: WinitModifiers, + /// Keyboard state for encoding. + keyboard_state: KeyboardState, + /// Event loop proxy for signaling from other threads. + event_loop_proxy: Option>, + /// Shutdown signal. shutdown: Arc, + /// Current mouse cursor position. + cursor_position: PhysicalPosition, + /// Frame counter for FPS logging. + frame_count: u64, + /// Last time we logged FPS. + last_frame_log: std::time::Instant, + /// Whether window should be created on next opportunity. + should_create_window: bool, } -const DAEMON_SOCKET_KEY: usize = 1; +const PTY_KEY: usize = 1; impl App { fn new() -> Self { let config = Config::load(); log::info!("Config: font_size={}", config.font_size); - // Build action map from keybindings let action_map = config.keybindings.build_action_map(); + log::info!("Action map built with {} bindings:", action_map.len()); + for (key, action) in &action_map { + log::info!(" {:?} => {:?}", key, action); + } Self { window: None, renderer: None, - daemon_client: None, - window_state: None, - panes: Vec::new(), - dirty: true, - modifiers: WinitModifiers::default(), - keyboard_state: KeyboardState::new(), + tabs: Vec::new(), + active_tab: 0, config, action_map, + modifiers: WinitModifiers::default(), + keyboard_state: KeyboardState::new(), event_loop_proxy: None, shutdown: Arc::new(AtomicBool::new(false)), + cursor_position: PhysicalPosition::new(0.0, 0.0), + frame_count: 0, + last_frame_log: std::time::Instant::now(), + should_create_window: false, } } - fn set_event_loop_proxy(&mut self, proxy: EventLoopProxy<()>) { + fn set_event_loop_proxy(&mut self, proxy: EventLoopProxy) { self.event_loop_proxy = Some(proxy); } - - fn initialize(&mut self, event_loop: &ActiveEventLoop) { - let init_start = std::time::Instant::now(); + + /// Create a new tab and start its I/O thread. + /// Returns the index of the new tab. + fn create_tab(&mut self, cols: usize, rows: usize) -> Option { + log::info!("Creating new tab with {}x{} terminal", cols, rows); + + match Tab::new(cols, rows, self.config.scrollback_lines) { + Ok(tab) => { + let tab_idx = self.tabs.len(); + + // Start I/O threads for all panes in this tab + for pane in tab.panes.values() { + self.start_pane_io_thread(pane); + } + + self.tabs.push(tab); + self.active_tab = tab_idx; + + log::info!("Tab {} created (total: {})", tab_idx, self.tabs.len()); + Some(tab_idx) + } + Err(e) => { + log::error!("Failed to create tab: {}", e); + None + } + } + } + + /// Start background I/O thread for a pane's PTY. + fn start_pane_io_thread(&self, pane: &Pane) { + self.start_pane_io_thread_with_info(pane.id, pane.pty_fd, pane.pty_buffer.clone()); + } + + /// Start background I/O thread for a pane's PTY with explicit info. + fn start_pane_io_thread_with_info(&self, pane_id: PaneId, pty_fd: i32, pty_buffer: Arc) { + let Some(proxy) = self.event_loop_proxy.clone() else { return }; + let shutdown = self.shutdown.clone(); + + std::thread::Builder::new() + .name(format!("pty-io-{}", pane_id.0)) + .spawn(move || { + const INPUT_DELAY: Duration = Duration::from_millis(3); + + let poller = match Poller::new() { + Ok(p) => p, + Err(e) => { + log::error!("Failed to create PTY poller: {}", e); + return; + } + }; + + unsafe { + if let Err(e) = poller.add(pty_fd, Event::readable(PTY_KEY)) { + log::error!("Failed to add PTY to poller: {}", e); + return; + } + } + + let mut events = Events::new(); + let mut last_wakeup_at = std::time::Instant::now(); + let mut has_pending_wakeup = false; + + while !shutdown.load(Ordering::Relaxed) { + events.clear(); + + let has_space = pty_buffer.has_space(); + + let timeout = if has_pending_wakeup { + let elapsed = last_wakeup_at.elapsed(); + Some(INPUT_DELAY.saturating_sub(elapsed)) + } else { + Some(Duration::from_millis(100)) + }; + + match poller.wait(&mut events, timeout) { + Ok(_) if !events.is_empty() && has_space => { + loop { + let result = pty_buffer.read_from_fd(pty_fd); + if result < 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + if err.kind() == std::io::ErrorKind::WouldBlock { + break; + } + log::debug!("PTY read error: {}", err); + break; + } else if result == 0 { + break; + } else { + has_pending_wakeup = true; + continue; + } + } + + let now = std::time::Instant::now(); + if now.duration_since(last_wakeup_at) >= INPUT_DELAY { + let _ = proxy.send_event(UserEvent::PtyReadable(pane_id)); + last_wakeup_at = now; + has_pending_wakeup = false; + } + + unsafe { + let _ = poller.modify( + std::os::fd::BorrowedFd::borrow_raw(pty_fd), + Event::readable(PTY_KEY), + ); + } + } + Ok(_) => { + if has_pending_wakeup { + let now = std::time::Instant::now(); + if now.duration_since(last_wakeup_at) >= INPUT_DELAY { + let _ = proxy.send_event(UserEvent::PtyReadable(pane_id)); + last_wakeup_at = now; + has_pending_wakeup = false; + } + } + } + Err(e) => { + log::error!("PTY poll error: {}", e); + break; + } + } + } + + log::debug!("PTY I/O thread for pane {} exiting", pane_id.0); + }) + .expect("Failed to spawn PTY I/O thread"); + } + + /// Create the window and renderer. + fn create_window(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { + return; // Window already exists + } + + log::info!("Creating window"); - // Create window first so it appears immediately let mut window_attributes = Window::default_attributes() .with_title("ZTerm") .with_inner_size(PhysicalSize::new(800, 600)); - // Enable transparency if background opacity is less than 1.0 if self.config.background_opacity < 1.0 { window_attributes = window_attributes.with_transparent(true); } @@ -93,277 +979,147 @@ impl App { .create_window(window_attributes) .expect("Failed to create window"), ); - log::debug!("Window created in {:?}", init_start.elapsed()); - // Start daemon connection in parallel with renderer initialization - let daemon_start = std::time::Instant::now(); - let mut daemon_client = match DaemonClient::connect() { - Ok(client) => client, - Err(e) => { - log::error!("Failed to connect to daemon: {}", e); - event_loop.exit(); - return; - } - }; - log::debug!("Daemon connected in {:?}", daemon_start.elapsed()); - - // Create renderer (this is the slow part - GPU initialization) - let renderer_start = std::time::Instant::now(); let renderer = pollster::block_on(Renderer::new(window.clone(), &self.config)); - log::debug!("Renderer created in {:?}", renderer_start.elapsed()); - - // Calculate terminal size based on window size let (cols, rows) = renderer.terminal_size(); - - // Send hello with our size - if let Err(e) = daemon_client.hello(cols, rows) { - log::error!("Failed to send hello: {}", e); - event_loop.exit(); - return; - } - - // Wait for initial state - match daemon_client.recv() { - Ok(DaemonMessage::FullState { window: win_state, panes }) => { - log::debug!("Received initial state with {} tabs, {} panes", - win_state.tabs.len(), panes.len()); - self.window_state = Some(win_state); - self.panes = panes; - } - Ok(msg) => { - log::warn!("Unexpected initial message: {:?}", msg); - } - Err(e) => { - log::error!("Failed to receive initial state: {}", e); - event_loop.exit(); - return; - } - } - // Switch to non-blocking mode for the event loop - if let Err(e) = daemon_client.set_nonblocking() { - log::error!("Failed to set non-blocking mode: {}", e); - event_loop.exit(); - return; - } - - // Set up polling for daemon socket in a background thread - // This thread will wake the event loop when data is available - if let Some(proxy) = self.event_loop_proxy.clone() { - let daemon_fd = daemon_client.as_raw_fd(); - let shutdown = self.shutdown.clone(); - - std::thread::spawn(move || { - let poller = match Poller::new() { - Ok(p) => p, - Err(e) => { - log::error!("Failed to create poller: {}", e); - return; - } - }; - - // SAFETY: daemon_fd is valid for the lifetime of the daemon_client, - // and we signal shutdown before dropping daemon_client - unsafe { - if let Err(e) = poller.add(daemon_fd, Event::readable(DAEMON_SOCKET_KEY)) { - log::error!("Failed to add daemon socket to poller: {}", e); - return; - } - } - - let mut events = Events::new(); - - while !shutdown.load(Ordering::Relaxed) { - events.clear(); - - // Wait for data with a timeout so we can check shutdown - match poller.wait(&mut events, Some(Duration::from_millis(100))) { - Ok(_) if !events.is_empty() => { - // Wake the event loop by sending an empty event - let _ = proxy.send_event(()); - - // Re-register for more events - // SAFETY: daemon_fd is still valid - unsafe { - let _ = poller.modify( - std::os::fd::BorrowedFd::borrow_raw(daemon_fd), - Event::readable(DAEMON_SOCKET_KEY) - ); - } - } - Ok(_) => {} // Timeout, no events - Err(e) => { - log::error!("Poller error: {}", e); - break; - } - } - } - - log::debug!("Daemon poll thread exiting"); - }); + // Create first tab if no tabs exist + if self.tabs.is_empty() { + self.create_tab(cols, rows); + } else { + // Resize existing tabs to match window + self.resize_all_panes(); } self.window = Some(window); self.renderer = Some(renderer); - self.daemon_client = Some(daemon_client); - - log::info!("Client initialized in {:?}: {}x{} cells", init_start.elapsed(), cols, rows); + self.should_create_window = false; + + log::info!("Window created: {}x{} cells", cols, rows); } + + /// Destroy the window but keep terminal state. + fn destroy_window(&mut self) { + log::info!("Destroying window (keeping terminal alive)"); + self.renderer = None; + self.window = None; + } + + /// Resize all panes in all tabs based on renderer dimensions. + fn resize_all_panes(&mut self) { + let Some(renderer) = &self.renderer else { return }; + + let cell_width = renderer.cell_width; + let cell_height = renderer.cell_height; + let width = renderer.width as f32; + let height = renderer.height as f32 - renderer.tab_bar_height(); + let border_width = 2.0; // Border width in pixels + + for tab in &mut self.tabs { + tab.resize(width, height, cell_width, cell_height, border_width); + } + } + + /// Process PTY data for a specific pane. + /// Returns true if any data was processed. + fn poll_pane(&mut self, pane_id: PaneId) -> bool { + // Find the pane across all tabs + for tab in &mut self.tabs { + if let Some(pane) = tab.get_pane_mut(pane_id) { + // Take all pending data atomically + let data = pane.pty_buffer.take_pending(); + let len = data.len(); + + if len == 0 { + return false; + } + + let process_start = std::time::Instant::now(); + pane.terminal.process(&data); + let process_time_ns = process_start.elapsed().as_nanos() as u64; + + if process_time_ns > 5_000_000 { + log::info!("PTY: process={:.2}ms bytes={}", + process_time_ns as f64 / 1_000_000.0, + len); + } + + return true; + } + } + false + } + + /// Send bytes to the active tab's PTY. + fn write_to_pty(&mut self, data: &[u8]) { + if let Some(tab) = self.tabs.get_mut(self.active_tab) { + tab.write_to_pty(data); + } + } + + /// Get the active tab, if any. + fn active_tab(&self) -> Option<&Tab> { + self.tabs.get(self.active_tab) + } + + /// Get the active tab mutably, if any. + fn active_tab_mut(&mut self) -> Option<&mut Tab> { + self.tabs.get_mut(self.active_tab) + } + + fn resize(&mut self, new_size: PhysicalSize) { + if new_size.width == 0 || new_size.height == 0 { + return; + } - /// Gets all pane snapshots with their layout info for the active tab. - /// Returns (panes_with_info, active_pane_id) with cloned/owned data. - fn active_tab_panes(&self) -> (Vec<(PaneSnapshot, PaneInfo)>, PaneId) { - let Some(win) = self.window_state.as_ref() else { - return (Vec::new(), 0); + if let Some(renderer) = &mut self.renderer { + renderer.resize(new_size.width, new_size.height); + } + + // Resize all panes + self.resize_all_panes(); + + if let Some(renderer) = &self.renderer { + let (cols, rows) = renderer.terminal_size(); + log::debug!("Resized to {}x{} cells", cols, rows); + } + } + + fn get_scroll_offset(&self) -> usize { + self.active_tab() + .and_then(|t| t.active_pane()) + .map(|p| p.terminal.scroll_offset) + .unwrap_or(0) + } + + fn has_mouse_tracking(&self) -> bool { + self.active_tab() + .and_then(|t| t.active_pane()) + .map(|p| p.terminal.mouse_tracking != MouseTrackingMode::None) + .unwrap_or(false) + } + + fn get_mouse_modifiers(&self) -> u8 { + let mod_state = self.modifiers.state(); + let mut mods = 0u8; + if mod_state.shift_key() { mods |= 1; } + if mod_state.alt_key() { mods |= 2; } + if mod_state.control_key() { mods |= 4; } + mods + } + + fn send_mouse_event(&mut self, button: u8, col: u16, row: u16, pressed: bool, is_motion: bool) { + let seq = { + let Some(tab) = self.active_tab() else { return }; + let Some(pane) = tab.active_pane() else { return }; + pane.terminal.encode_mouse(button, col, row, pressed, is_motion, self.get_mouse_modifiers()) }; - let Some(tab) = win.tabs.get(win.active_tab) else { - return (Vec::new(), 0); - }; - - let active_pane_id = tab.panes.get(tab.active_pane) - .map(|p| p.id) - .unwrap_or(0); - - let panes_with_info: Vec<(PaneSnapshot, PaneInfo)> = tab.panes.iter() - .filter_map(|pane_info| { - self.panes.iter() - .find(|snap| snap.pane_id == pane_info.id) - .map(|snap| (snap.clone(), pane_info.clone())) - }) - .collect(); - - (panes_with_info, active_pane_id) - } - - /// Gets the active pane ID and its snapshot. - fn get_active_pane(&self) -> Option<(PaneId, &PaneSnapshot)> { - let win = self.window_state.as_ref()?; - let tab = win.tabs.get(win.active_tab)?; - let pane_info = tab.panes.get(tab.active_pane)?; - let snapshot = self.panes.iter().find(|s| s.pane_id == pane_info.id)?; - Some((pane_info.id, snapshot)) - } - - fn poll_daemon(&mut self) { - let Some(client) = &mut self.daemon_client else { return }; - - // Read all available messages (non-blocking) - // The background thread wakes us when data is available - let mut messages = Vec::new(); - loop { - match client.try_recv() { - Ok(Some(msg)) => { - messages.push(msg); - } - Ok(None) => break, - Err(e) => { - log::error!("Daemon connection error: {}", e); - // Daemon disconnected - we'll handle this after the loop - messages.push(DaemonMessage::Shutdown); - break; - } - } - } - - // Now process messages without holding the client borrow - for msg in messages { - self.handle_daemon_message(msg); + if !seq.is_empty() { + self.write_to_pty(&seq); } } - fn handle_daemon_message(&mut self, msg: DaemonMessage) { - match msg { - DaemonMessage::FullState { window, panes } => { - log::debug!("Received full state: {} tabs, {} panes", - window.tabs.len(), panes.len()); - self.window_state = Some(window); - self.panes = panes; - self.dirty = true; - } - DaemonMessage::PaneUpdate { pane_id, cells, cursor } => { - log::debug!("Received pane update for pane {}", pane_id); - if let Some(pane) = self.panes.iter_mut().find(|p| p.pane_id == pane_id) { - pane.cells = cells; - pane.cursor = cursor; - } else { - // New pane - self.panes.push(PaneSnapshot { - pane_id, - cells, - cursor, - scroll_offset: 0, - scrollback_len: 0, - }); - } - self.dirty = true; - } - DaemonMessage::TabChanged { active_tab } => { - log::debug!("Tab changed to {}", active_tab); - if let Some(ref mut win) = self.window_state { - win.active_tab = active_tab; - } - self.dirty = true; - } - DaemonMessage::TabCreated { tab } => { - log::debug!("Tab created: {:?}", tab); - if let Some(ref mut win) = self.window_state { - win.tabs.push(tab); - } - self.dirty = true; - } - DaemonMessage::TabClosed { tab_id } => { - log::debug!("Tab closed: {}", tab_id); - if let Some(ref mut win) = self.window_state { - win.tabs.retain(|t| t.id != tab_id); - // Adjust active tab if needed - if win.active_tab >= win.tabs.len() && !win.tabs.is_empty() { - win.active_tab = win.tabs.len() - 1; - } - } - // Remove panes for closed tab - // Note: daemon should send updated panes, but we clean up just in case - self.dirty = true; - } - DaemonMessage::PaneCreated { tab_id, pane } => { - log::debug!("Pane created in tab {}: {:?}", tab_id, pane); - if let Some(ref mut win) = self.window_state { - if let Some(tab) = win.tabs.iter_mut().find(|t| t.id == tab_id) { - tab.panes.push(pane); - } - } - self.dirty = true; - } - DaemonMessage::PaneClosed { tab_id, pane_id } => { - log::debug!("Pane {} closed in tab {}", pane_id, tab_id); - if let Some(ref mut win) = self.window_state { - if let Some(tab) = win.tabs.iter_mut().find(|t| t.id == tab_id) { - tab.panes.retain(|p| p.id != pane_id); - } - } - // Also remove from pane snapshots - self.panes.retain(|p| p.pane_id != pane_id); - self.dirty = true; - } - DaemonMessage::PaneFocused { tab_id, active_pane } => { - log::debug!("Pane focus changed in tab {}: pane {}", tab_id, active_pane); - if let Some(ref mut win) = self.window_state { - if let Some(tab) = win.tabs.iter_mut().find(|t| t.id == tab_id) { - tab.active_pane = active_pane; - } - } - self.dirty = true; - } - DaemonMessage::Shutdown => { - log::info!("Daemon shutting down"); - self.daemon_client = None; - } - } - } - - /// Checks if the key event matches a keybinding and executes the action. - /// Returns true if the key was consumed by a keybinding. fn check_keybinding(&mut self, event: &KeyEvent) -> bool { - // Only process key presses, not releases or repeats if event.state != ElementState::Pressed || event.repeat { return false; } @@ -374,7 +1130,6 @@ impl App { let shift = mod_state.shift_key(); let super_key = mod_state.super_key(); - // Get the key name let key_name = match &event.logical_key { Key::Named(named) => { match named { @@ -412,97 +1167,269 @@ impl App { _ => return false, }; - // Look up the action - let lookup = (ctrl, alt, shift, super_key, key_name); + let lookup = (ctrl, alt, shift, super_key, key_name.clone()); + log::debug!("Keybind lookup: {:?}", lookup); let Some(action) = self.action_map.get(&lookup).copied() else { return false; }; - // Execute the action + log::info!("Executing action: {:?}", action); + self.execute_action(action); true } fn execute_action(&mut self, action: Action) { - let Some(client) = &mut self.daemon_client else { return }; - match action { + Action::Copy => { + self.copy_selection_to_clipboard(); + } + Action::Paste => { + self.paste_from_clipboard(); + } Action::NewTab => { - log::debug!("Action: NewTab"); - let _ = client.create_tab(); - } - Action::NextTab => { - log::debug!("Action: NextTab"); - let _ = client.next_tab(); - } - Action::PrevTab => { - log::debug!("Action: PrevTab"); - let _ = client.prev_tab(); - } - Action::Tab1 => { let _ = client.switch_tab_index(0); } - Action::Tab2 => { let _ = client.switch_tab_index(1); } - Action::Tab3 => { let _ = client.switch_tab_index(2); } - Action::Tab4 => { let _ = client.switch_tab_index(3); } - Action::Tab5 => { let _ = client.switch_tab_index(4); } - Action::Tab6 => { let _ = client.switch_tab_index(5); } - Action::Tab7 => { let _ = client.switch_tab_index(6); } - Action::Tab8 => { let _ = client.switch_tab_index(7); } - Action::Tab9 => { let _ = client.switch_tab_index(8); } - Action::SplitHorizontal => { - log::debug!("Action: SplitHorizontal"); - let _ = client.split_horizontal(); - } - Action::SplitVertical => { - log::debug!("Action: SplitVertical"); - let _ = client.split_vertical(); + if let Some(renderer) = &self.renderer { + let (cols, rows) = renderer.terminal_size(); + self.create_tab(cols, rows); + if let Some(window) = &self.window { + window.request_redraw(); + } + } } Action::ClosePane => { - log::debug!("Action: ClosePane"); - let _ = client.close_pane(); + self.close_active_pane(); + } + Action::NextTab => { + if !self.tabs.is_empty() { + self.active_tab = (self.active_tab + 1) % self.tabs.len(); + if let Some(window) = &self.window { + window.request_redraw(); + } + } + } + Action::PrevTab => { + if !self.tabs.is_empty() { + self.active_tab = if self.active_tab == 0 { + self.tabs.len() - 1 + } else { + self.active_tab - 1 + }; + if let Some(window) = &self.window { + window.request_redraw(); + } + } + } + Action::Tab1 => self.switch_to_tab(0), + Action::Tab2 => self.switch_to_tab(1), + Action::Tab3 => self.switch_to_tab(2), + Action::Tab4 => self.switch_to_tab(3), + Action::Tab5 => self.switch_to_tab(4), + Action::Tab6 => self.switch_to_tab(5), + Action::Tab7 => self.switch_to_tab(6), + Action::Tab8 => self.switch_to_tab(7), + Action::Tab9 => self.switch_to_tab(8), + Action::SplitHorizontal => { + self.split_pane(true); + } + Action::SplitVertical => { + self.split_pane(false); } Action::FocusPaneUp => { - log::debug!("Action: FocusPaneUp"); - let _ = client.focus_pane(Direction::Up); + self.focus_pane(Direction::Up); } Action::FocusPaneDown => { - log::debug!("Action: FocusPaneDown"); - let _ = client.focus_pane(Direction::Down); + self.focus_pane(Direction::Down); } Action::FocusPaneLeft => { - log::debug!("Action: FocusPaneLeft"); - let _ = client.focus_pane(Direction::Left); + self.focus_pane(Direction::Left); } Action::FocusPaneRight => { - log::debug!("Action: FocusPaneRight"); - let _ = client.focus_pane(Direction::Right); + self.focus_pane(Direction::Right); } } } - + + fn split_pane(&mut self, horizontal: bool) { + // Get terminal dimensions + let (cols, rows) = if let Some(renderer) = &self.renderer { + renderer.terminal_size() + } else { + return; + }; + + let scrollback_lines = self.config.scrollback_lines; + let active_tab = self.active_tab; + + // Create the new pane and get its info for the I/O thread + let new_pane_info = if let Some(tab) = self.tabs.get_mut(active_tab) { + match tab.split(horizontal, cols, rows, scrollback_lines) { + Ok(new_pane_id) => { + // Get the info we need to start the I/O thread + tab.get_pane(new_pane_id).map(|pane| { + (pane.id, pane.pty_fd, pane.pty_buffer.clone()) + }) + } + Err(e) => { + log::error!("Failed to split pane: {}", e); + None + } + } + } else { + None + }; + + // Start I/O thread for the new pane (outside the tab borrow) + if let Some((pane_id, pty_fd, pty_buffer)) = new_pane_info { + self.start_pane_io_thread_with_info(pane_id, pty_fd, pty_buffer); + // Recalculate layout + self.resize_all_panes(); + if let Some(window) = &self.window { + window.request_redraw(); + } + log::info!("Split pane (horizontal={}), new pane {}", horizontal, pane_id.0); + } + } + + fn focus_pane(&mut self, direction: Direction) { + if let Some(tab) = self.tabs.get_mut(self.active_tab) { + tab.focus_neighbor(direction); + if let Some(window) = &self.window { + window.request_redraw(); + } + } + } + + fn close_active_pane(&mut self) { + let should_close_tab = if let Some(tab) = self.tabs.get_mut(self.active_tab) { + tab.close_active_pane(); + tab.panes.is_empty() + } else { + false + }; + + if should_close_tab { + self.tabs.remove(self.active_tab); + if !self.tabs.is_empty() && self.active_tab >= self.tabs.len() { + self.active_tab = self.tabs.len() - 1; + } + } else { + // Recalculate layout after removing pane + self.resize_all_panes(); + } + + if let Some(window) = &self.window { + window.request_redraw(); + } + } + + fn switch_to_tab(&mut self, idx: usize) { + if idx < self.tabs.len() { + self.active_tab = idx; + if let Some(window) = &self.window { + window.request_redraw(); + } + } + } + + fn paste_from_clipboard(&mut self) { + let output = match Command::new("wl-paste") + .arg("--no-newline") + .output() + { + Ok(output) => output, + Err(e) => { + log::warn!("Failed to run wl-paste: {}", e); + return; + } + }; + + if output.status.success() && !output.stdout.is_empty() { + self.write_to_pty(&output.stdout); + } + } + + fn copy_selection_to_clipboard(&mut self) { + let Some(tab) = self.active_tab() else { return }; + let Some(pane) = tab.active_pane() else { return }; + let Some(selection) = &pane.selection else { return }; + let terminal = &pane.terminal; + + let (start, end) = selection.normalized(); + let mut text = String::new(); + + let scroll_offset = terminal.scroll_offset as isize; + let rows = terminal.rows; + + let screen_start_row = (start.row + scroll_offset).max(0) as usize; + let screen_end_row = ((end.row + scroll_offset).max(0) as usize).min(rows.saturating_sub(1)); + + let visible_rows = terminal.visible_rows(); + + for screen_row in screen_start_row..=screen_end_row { + if screen_row >= visible_rows.len() { + break; + } + + let content_row = screen_row as isize - scroll_offset; + if content_row < start.row || content_row > end.row { + continue; + } + + let row_cells = visible_rows[screen_row]; + let cols = row_cells.len(); + let col_start = if content_row == start.row { start.col } else { 0 }; + let col_end = if content_row == end.row { end.col } else { cols.saturating_sub(1) }; + + let mut line = String::new(); + for col in col_start..=col_end.min(cols.saturating_sub(1)) { + let c = row_cells[col].character; + if c != '\0' { + line.push(c); + } + } + + text.push_str(line.trim_end()); + if content_row < end.row { + text.push('\n'); + } + } + + if text.is_empty() { + return; + } + + match Command::new("wl-copy") + .stdin(Stdio::piped()) + .spawn() + { + Ok(mut child) => { + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(text.as_bytes()); + } + let _ = child.wait(); + } + Err(e) => { + log::warn!("Failed to run wl-copy: {}", e); + } + } + } + fn handle_keyboard_input(&mut self, event: KeyEvent) { - // First check if this is a keybinding if self.check_keybinding(&event) { return; } - // Determine event type let event_type = match event.state { ElementState::Pressed => { - if event.repeat { - KeyEventType::Repeat - } else { - KeyEventType::Press - } + if event.repeat { KeyEventType::Repeat } else { KeyEventType::Press } } ElementState::Released => KeyEventType::Release, }; - // In legacy mode, ignore release events if event_type == KeyEventType::Release && !self.keyboard_state.report_events() { return; } - // Build modifiers from the tracked state let mod_state = self.modifiers.state(); let modifiers = Modifiers { shift: mod_state.shift_key(), @@ -576,58 +1503,58 @@ impl App { }; if let Some(bytes) = bytes { - // Check scroll offset before borrowing client mutably - let scroll_reset = self.get_active_pane() - .filter(|(_, snapshot)| snapshot.scroll_offset > 0) - .map(|(pane_id, snapshot)| (pane_id, snapshot.scroll_offset)); - - // Now borrow client mutably - if let Some(client) = &mut self.daemon_client { - let _ = client.send_input(bytes); - - // Reset scroll position when typing (go back to live terminal) - if let Some((active_pane_id, scroll_offset)) = scroll_reset { - let _ = client.send(&ClientMessage::Scroll { - pane_id: active_pane_id, - delta: -(scroll_offset as i32) - }); + // Reset scroll when typing + if let Some(tab) = self.active_tab_mut() { + if let Some(pane) = tab.active_pane_mut() { + if pane.terminal.scroll_offset > 0 { + pane.terminal.scroll_offset = 0; + } } } - } - } - - fn resize(&mut self, new_size: PhysicalSize) { - if new_size.width == 0 || new_size.height == 0 { - return; - } - - if let Some(renderer) = &mut self.renderer { - renderer.resize(new_size.width, new_size.height); - - let (cols, rows) = renderer.terminal_size(); - - if let Some(client) = &mut self.daemon_client { - let _ = client.send_resize(cols, rows); - } - - log::debug!("Resized to {}x{} cells", cols, rows); - self.dirty = true; + self.write_to_pty(&bytes); } } } -impl ApplicationHandler for App { +impl ApplicationHandler for App { fn resumed(&mut self, event_loop: &ActiveEventLoop) { if self.window.is_none() { - self.initialize(event_loop); + self.create_window(event_loop); + } + } + + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { + match event { + UserEvent::ShowWindow => { + log::info!("Received signal to show window"); + if self.window.is_none() { + self.create_window(event_loop); + } + } + UserEvent::PtyReadable(pane_id) => { + // I/O thread has batched wakeups - read all available data now + let start = std::time::Instant::now(); + self.poll_pane(pane_id); + let process_time = start.elapsed(); + + // Request redraw to display the new content + if let Some(window) = &self.window { + window.request_redraw(); + } + + if process_time.as_millis() > 5 { + log::info!("PTY process took {:?}", process_time); + } + } } } fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { match event { WindowEvent::CloseRequested => { - log::info!("Window close requested"); - event_loop.exit(); + log::info!("Window close requested - hiding window"); + self.destroy_window(); + // Don't exit - keep running headless } WindowEvent::Resized(new_size) => { @@ -636,20 +1563,16 @@ impl ApplicationHandler for App { WindowEvent::ScaleFactorChanged { scale_factor, .. } => { log::info!("Scale factor changed to {}", scale_factor); - if let Some(renderer) = &mut self.renderer { - if renderer.set_scale_factor(scale_factor) { - let (cols, rows) = renderer.terminal_size(); - - if let Some(client) = &mut self.daemon_client { - let _ = client.send_resize(cols, rows); - } - - log::info!("Terminal resized to {}x{} cells after scale change", cols, rows); - } - - if let Some(window) = &self.window { - window.request_redraw(); - } + let should_resize = if let Some(renderer) = &mut self.renderer { + renderer.set_scale_factor(scale_factor) + } else { + false + }; + if should_resize { + self.resize_all_panes(); + } + if let Some(window) = &self.window { + window.request_redraw(); } } @@ -658,26 +1581,29 @@ impl ApplicationHandler for App { } WindowEvent::MouseWheel { delta, .. } => { - // Handle mouse wheel for scrollback let lines = match delta { - MouseScrollDelta::LineDelta(_, y) => { - // y > 0 means scrolling up (into history), y < 0 means down - (y * 3.0) as i32 // 3 lines per scroll notch - } - MouseScrollDelta::PixelDelta(pos) => { - // Convert pixels to lines (rough approximation) - (pos.y / 20.0) as i32 - } + MouseScrollDelta::LineDelta(_, y) => (y * 3.0) as i32, + MouseScrollDelta::PixelDelta(pos) => (pos.y / 20.0) as i32, }; if lines != 0 { - // Get the active pane ID to scroll - if let Some((active_pane_id, _)) = self.get_active_pane() { - if let Some(client) = &mut self.daemon_client { - let _ = client.send(&ClientMessage::Scroll { - pane_id: active_pane_id, - delta: lines - }); + if self.has_mouse_tracking() { + if let Some(renderer) = &self.renderer { + if let Some((col, row)) = renderer.pixel_to_cell( + self.cursor_position.x, + self.cursor_position.y + ) { + let button = if lines > 0 { 64 } else { 65 }; + let count = lines.abs().min(3); + for _ in 0..count { + self.send_mouse_event(button, col as u16, row as u16, true, false); + } + } + } + } else if let Some(tab) = self.active_tab_mut() { + // Positive lines = scroll wheel up = go into history (increase offset) + if let Some(pane) = tab.active_pane_mut() { + pane.terminal.scroll(lines); } } if let Some(window) = &self.window { @@ -685,6 +1611,105 @@ impl ApplicationHandler for App { } } } + + WindowEvent::CursorMoved { position, .. } => { + self.cursor_position = position; + + let is_selecting = self.active_tab() + .and_then(|t| t.active_pane()) + .map(|p| p.is_selecting) + .unwrap_or(false); + if is_selecting && !self.has_mouse_tracking() { + if let Some(renderer) = &self.renderer { + if let Some((col, screen_row)) = renderer.pixel_to_cell(position.x, position.y) { + let scroll_offset = self.get_scroll_offset(); + let content_row = screen_row as isize - scroll_offset as isize; + + if let Some(tab) = self.active_tab_mut() { + if let Some(pane) = tab.active_pane_mut() { + if let Some(ref mut selection) = pane.selection { + selection.end = CellPosition { col, row: content_row }; + if let Some(window) = &self.window { + window.request_redraw(); + } + } + } + } + } + } + } + } + + WindowEvent::MouseInput { state, button, .. } => { + let button_code = match button { + MouseButton::Left => 0, + MouseButton::Middle => 1, + MouseButton::Right => 2, + _ => return, + }; + + if self.has_mouse_tracking() { + if let Some(renderer) = &self.renderer { + if let Some((col, row)) = renderer.pixel_to_cell( + self.cursor_position.x, + self.cursor_position.y + ) { + let pressed = state == ElementState::Pressed; + self.send_mouse_event(button_code, col as u16, row as u16, pressed, false); + if button == MouseButton::Left { + if let Some(tab) = self.active_tab_mut() { + if let Some(pane) = tab.active_pane_mut() { + pane.is_selecting = pressed; + } + } + } + } + } + if let Some(tab) = self.active_tab_mut() { + if let Some(pane) = tab.active_pane_mut() { + pane.selection = None; + } + } + } else if button == MouseButton::Left { + match state { + ElementState::Pressed => { + if let Some(renderer) = &self.renderer { + if let Some((col, screen_row)) = renderer.pixel_to_cell( + self.cursor_position.x, + self.cursor_position.y + ) { + let scroll_offset = self.get_scroll_offset(); + let content_row = screen_row as isize - scroll_offset as isize; + let pos = CellPosition { col, row: content_row }; + if let Some(tab) = self.active_tab_mut() { + if let Some(pane) = tab.active_pane_mut() { + pane.selection = Some(Selection { start: pos, end: pos }); + pane.is_selecting = true; + } + } + } + } + } + ElementState::Released => { + let was_selecting = self.active_tab() + .and_then(|t| t.active_pane()) + .map(|p| p.is_selecting) + .unwrap_or(false); + if was_selecting { + if let Some(tab) = self.active_tab_mut() { + if let Some(pane) = tab.active_pane_mut() { + pane.is_selecting = false; + } + } + self.copy_selection_to_clipboard(); + } + } + } + } + if let Some(window) = &self.window { + window.request_redraw(); + } + } WindowEvent::KeyboardInput { event, .. } => { self.handle_keyboard_input(event); @@ -694,34 +1719,76 @@ impl ApplicationHandler for App { } WindowEvent::RedrawRequested => { - // Gather all panes for the active tab with their layout info (cloned to avoid borrow conflict) - let (panes_with_info, active_pane_id) = self.active_tab_panes(); - let tabs = self.window_state.as_ref().map(|w| w.tabs.clone()); - let active_tab = self.window_state.as_ref().map(|w| w.active_tab).unwrap_or(0); + let frame_start = std::time::Instant::now(); + self.frame_count += 1; + + if self.last_frame_log.elapsed() >= Duration::from_secs(1) { + log::debug!("FPS: {}", self.frame_count); + self.frame_count = 0; + self.last_frame_log = std::time::Instant::now(); + } + + // Note: poll_pane() is called from UserEvent::PtyReadable, not here. + // This avoids double-processing and keeps rendering fast. + + // Send any terminal responses back to PTY (for active pane) + if let Some(tab) = self.active_tab_mut() { + if let Some(pane) = tab.active_pane_mut() { + if let Some(response) = pane.terminal.take_response() { + pane.write_to_pty(&response); + } + + // Track scrollback changes for selection adjustment + let scrollback_len = pane.terminal.scrollback.len() as u32; + if scrollback_len != pane.last_scrollback_len { + let lines_added = scrollback_len.saturating_sub(pane.last_scrollback_len) as isize; + if let Some(ref mut selection) = pane.selection { + selection.start.row -= lines_added; + selection.end.row -= lines_added; + } + pane.last_scrollback_len = scrollback_len; + } + } + } + + // Render + let render_start = std::time::Instant::now(); + let num_tabs = self.tabs.len(); + let active_tab_idx = self.active_tab; if let Some(renderer) = &mut self.renderer { - if !panes_with_info.is_empty() { - let tabs = tabs.unwrap_or_default(); - // Convert owned data to references for the renderer - let pane_refs: Vec<(&PaneSnapshot, &PaneInfo)> = panes_with_info.iter() - .map(|(snap, info)| (snap, info)) - .collect(); - match renderer.render_with_tabs(&pane_refs, active_pane_id, &tabs, active_tab) { - Ok(_) => {} - Err(wgpu::SurfaceError::Lost) => { - renderer.resize(renderer.width, renderer.height); - } - Err(wgpu::SurfaceError::OutOfMemory) => { - log::error!("Out of GPU memory!"); - event_loop.exit(); - } - Err(e) => { - log::error!("Render error: {:?}", e); + if let Some(tab) = self.tabs.get(active_tab_idx) { + if let Some(pane) = tab.active_pane() { + let scroll_offset = pane.terminal.scroll_offset; + let visible_rows = renderer.terminal_size().1; + let renderer_selection = pane.selection.as_ref() + .and_then(|sel| sel.to_screen_coords(scroll_offset, visible_rows)); + + renderer.set_selection(renderer_selection); + + match renderer.render_from_terminal(&pane.terminal, num_tabs, active_tab_idx) { + Ok(_) => {} + Err(wgpu::SurfaceError::Lost) => { + renderer.resize(renderer.width, renderer.height); + } + Err(wgpu::SurfaceError::OutOfMemory) => { + log::error!("Out of GPU memory!"); + event_loop.exit(); + } + Err(e) => { + log::error!("Render error: {:?}", e); + } } } - self.dirty = false; } } + let render_time = render_start.elapsed(); + let frame_time = frame_start.elapsed(); + + if frame_time.as_millis() > 10 { + log::info!("Slow frame: total={:?} render={:?}", + frame_time, render_time); + } } _ => {} @@ -729,70 +1796,96 @@ impl ApplicationHandler for App { } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - // Check if daemon is still connected - if self.daemon_client.is_none() { - log::info!("Lost connection to daemon, exiting"); + // Check if all tabs have exited + if self.tabs.is_empty() { + log::info!("All tabs closed, exiting"); event_loop.exit(); return; } - - // Poll daemon for updates - self.poll_daemon(); - // Request redraw if we have new content - if self.dirty { - if let Some(window) = &self.window { - window.request_redraw(); + // Check for exited tabs and remove them + let mut i = 0; + while i < self.tabs.len() { + if self.tabs[i].child_exited() { + log::info!("Tab {} shell exited", i); + self.tabs.remove(i); + if self.active_tab >= self.tabs.len() && !self.tabs.is_empty() { + self.active_tab = self.tabs.len() - 1; + } + } else { + i += 1; } } - // Use WaitUntil to wake up periodically and check for daemon messages - // This is more compatible than relying on send_event across threads - event_loop.set_control_flow(ControlFlow::WaitUntil( - std::time::Instant::now() + Duration::from_millis(16) - )); - } - - fn user_event(&mut self, _event_loop: &ActiveEventLoop, _event: ()) { - // Daemon poll thread woke us up - poll for messages - self.poll_daemon(); - - // Request redraw if we have new content - if self.dirty { - if let Some(window) = &self.window { - window.request_redraw(); - } + if self.tabs.is_empty() { + log::info!("All tabs closed, exiting"); + event_loop.exit(); + return; } + + // Batching is done in the I/O thread (Kitty-style). + // We just wait for events here. + event_loop.set_control_flow(ControlFlow::Wait); } } impl Drop for App { fn drop(&mut self) { - // Signal the daemon poll thread to exit self.shutdown.store(true, Ordering::Relaxed); + remove_pid_file(); } } fn main() { - // Initialize logging env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - log::info!("Starting ZTerm client"); + log::info!("Starting ZTerm"); - // Create event loop with Wayland preference - let event_loop = EventLoop::builder() + // Check for existing instance + if signal_existing_instance() { + log::info!("Signaled existing instance, exiting"); + return; + } + + // Write PID file + if let Err(e) = write_pid_file() { + log::warn!("Failed to write PID file: {}", e); + } + + // Set up SIGUSR1 handler + unsafe { + libc::signal(libc::SIGUSR1, handle_sigusr1 as usize); + } + + // Create event loop + let event_loop = EventLoop::::with_user_event() .with_any_thread(true) .build() .expect("Failed to create event loop"); - // Use Wait instead of Poll to avoid busy-looping - // The daemon poll thread will wake us when data is available event_loop.set_control_flow(ControlFlow::Wait); let mut app = App::new(); + let proxy = event_loop.create_proxy(); + app.set_event_loop_proxy(proxy.clone()); - // Give the app a proxy to wake the event loop from the daemon poll thread - app.set_event_loop_proxy(event_loop.create_proxy()); - + // Store proxy for signal handler (uses the global static defined below) + unsafe { + EVENT_PROXY = Some(proxy); + } + event_loop.run_app(&mut app).expect("Event loop error"); } + +// Global static for signal handler access +static mut EVENT_PROXY: Option> = None; + +extern "C" fn handle_sigusr1(_: i32) { + // Signal handler - must be async-signal-safe + // We can only set a flag here, the actual window creation happens in the event loop + unsafe { + if let Some(ref proxy) = EVENT_PROXY { + let _ = proxy.send_event(UserEvent::ShowWindow); + } + } +} diff --git a/src/protocol.rs b/src/protocol.rs deleted file mode 100644 index 488aede..0000000 --- a/src/protocol.rs +++ /dev/null @@ -1,254 +0,0 @@ -//! Protocol messages for daemon/client communication. -//! -//! The daemon owns all terminal state (sessions, tabs, panes). -//! The client is a thin rendering layer that receives cell data and sends input. - -use serde::{Deserialize, Serialize}; - -/// Unique identifier for a session (owns a PTY + terminal state). -pub type SessionId = u32; - -/// Unique identifier for a pane within a tab. -pub type PaneId = u32; - -/// Unique identifier for a tab. -pub type TabId = u32; - -/// Direction for splitting a pane. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SplitDirection { - /// Split horizontally (new pane below). - Horizontal, - /// Split vertically (new pane to the right). - Vertical, -} - -/// Direction for pane navigation. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Direction { - /// Navigate up. - Up, - /// Navigate down. - Down, - /// Navigate left. - Left, - /// Navigate right. - Right, -} - -/// Cursor shape styles. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CursorStyle { - /// Block cursor (like normal mode in vim). - #[default] - Block, - /// Underline cursor. - Underline, - /// Bar/beam cursor (like insert mode in vim). - Bar, -} - -/// A single cell to be rendered by the client. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct RenderCell { - pub character: char, - pub fg_color: CellColor, - pub bg_color: CellColor, - pub bold: bool, - pub italic: bool, - pub underline: bool, -} - -/// Color representation for protocol messages. -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] -pub enum CellColor { - /// Default foreground or background. - Default, - /// RGB color. - Rgb(u8, u8, u8), - /// Indexed color (0-255). - Indexed(u8), -} - -/// A pane's layout within a tab. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct PaneInfo { - pub id: PaneId, - pub session_id: SessionId, - /// Position and size in cells (for future splits). - /// For now, always (0, 0, cols, rows). - pub x: usize, - pub y: usize, - pub cols: usize, - pub rows: usize, -} - -/// A tab containing one or more panes. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct TabInfo { - pub id: TabId, - /// Index of the active/focused pane within this tab. - pub active_pane: usize, - pub panes: Vec, -} - -/// Cursor information for a pane. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct CursorInfo { - pub col: usize, - pub row: usize, - pub visible: bool, - pub style: CursorStyle, -} - -/// Full window state sent to client on connect. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct WindowState { - /// All tabs. - pub tabs: Vec, - /// Index of the active tab. - pub active_tab: usize, - /// Terminal dimensions in cells. - pub cols: usize, - pub rows: usize, -} - -/// Messages sent from client to daemon. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum ClientMessage { - /// Client is connecting and requests full state. - /// Includes the client's window size. - Hello { cols: usize, rows: usize }, - - /// Keyboard input to send to the focused session. - Input { data: Vec }, - - /// Window was resized. - Resize { cols: usize, rows: usize }, - - /// Request to create a new tab. - CreateTab, - - /// Request to close the current tab. - CloseTab { tab_id: TabId }, - - /// Switch to a different tab by ID. - SwitchTab { tab_id: TabId }, - - /// Switch to next tab. - NextTab, - - /// Switch to previous tab. - PrevTab, - - /// Switch to tab by index (0-based). - SwitchTabIndex { index: usize }, - - /// Split the current pane. - SplitPane { direction: SplitDirection }, - - /// Close the current pane (closes tab if last pane). - ClosePane, - - /// Focus a pane in the given direction. - FocusPane { direction: Direction }, - - /// Scroll the viewport (for scrollback viewing). - /// Positive delta scrolls up (into history), negative scrolls down (toward live). - Scroll { pane_id: PaneId, delta: i32 }, - - /// Client is disconnecting gracefully. - Goodbye, -} - -/// Messages sent from daemon to client. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub enum DaemonMessage { - /// Full state snapshot (sent on connect and major changes). - FullState { - window: WindowState, - /// Cell data for all visible panes, keyed by pane ID. - /// Each pane has rows x cols cells. - panes: Vec, - }, - - /// Incremental update for a single pane. - PaneUpdate { - pane_id: PaneId, - cells: Vec>, - cursor: CursorInfo, - }, - - /// Active tab changed. - TabChanged { active_tab: usize }, - - /// A tab was created. - TabCreated { tab: TabInfo }, - - /// A tab was closed. - TabClosed { tab_id: TabId }, - - /// A pane was created (split). - PaneCreated { tab_id: TabId, pane: PaneInfo }, - - /// A pane was closed. - PaneClosed { tab_id: TabId, pane_id: PaneId }, - - /// Active pane changed within a tab. - PaneFocused { tab_id: TabId, active_pane: usize }, - - /// Daemon is shutting down. - Shutdown, -} - -/// Snapshot of a pane's content. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct PaneSnapshot { - pub pane_id: PaneId, - pub cells: Vec>, - pub cursor: CursorInfo, - /// Current scroll offset (0 = live terminal, >0 = viewing scrollback). - pub scroll_offset: usize, - /// Total lines in scrollback buffer. - pub scrollback_len: usize, -} - -/// Wire format for messages: length-prefixed JSON. -/// Format: [4 bytes little-endian length][JSON payload] -pub mod wire { - use super::*; - use std::io::{self, Read, Write}; - - /// Write a message to a writer with length prefix. - pub fn write_message(writer: &mut W, msg: &M) -> io::Result<()> { - let json = serde_json::to_vec(msg).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - let len = json.len() as u32; - writer.write_all(&len.to_le_bytes())?; - writer.write_all(&json)?; - writer.flush()?; - Ok(()) - } - - /// Read a message from a reader with length prefix. - pub fn read_message Deserialize<'de>>(reader: &mut R) -> io::Result { - let mut len_buf = [0u8; 4]; - reader.read_exact(&mut len_buf)?; - let len = u32::from_le_bytes(len_buf) as usize; - - // Sanity check to prevent huge allocations - if len > 64 * 1024 * 1024 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "message too large", - )); - } - - let mut buf = vec![0u8; len]; - reader.read_exact(&mut buf)?; - - serde_json::from_slice(&buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) - } -} diff --git a/src/pty.rs b/src/pty.rs index f3ae79a..e3bd9f5 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -4,7 +4,7 @@ use rustix::fs::{fcntl_setfl, OFlags}; use rustix::io::{read, write, Errno}; use rustix::pty::{grantpt, openpt, ptsname, unlockpt, OpenptFlags}; use std::ffi::CString; -use std::os::fd::{AsFd, BorrowedFd, OwnedFd}; +use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd, RawFd}; use thiserror::Error; #[derive(Error, Debug)] @@ -94,6 +94,15 @@ impl Pty { unsafe { libc::close(slave_fd) }; } + // Set TERM environment variable + // Try zterm first, fall back to xterm-256color if zterm terminfo isn't installed + // SAFETY: We're in a forked child process before exec, single-threaded + unsafe { + std::env::set_var("TERM", "zterm"); + // Also set COLORTERM to indicate true color support + std::env::set_var("COLORTERM", "truecolor"); + } + // Determine which shell to use let shell_path = shell .map(String::from) @@ -163,6 +172,28 @@ impl Pty { pub fn child_pid(&self) -> rustix::process::Pid { self.child_pid } + + /// Check if the child process has exited. + pub fn child_exited(&self) -> bool { + let mut status: libc::c_int = 0; + let result = unsafe { + libc::waitpid( + self.child_pid.as_raw_nonzero().get(), + &mut status, + libc::WNOHANG, + ) + }; + // If waitpid returns the child PID, the child has exited + // If it returns 0, the child is still running + // If it returns -1, there was an error (child might have already been reaped) + result != 0 + } +} + +impl AsRawFd for Pty { + fn as_raw_fd(&self) -> RawFd { + self.master.as_raw_fd() + } } impl Drop for Pty { diff --git a/src/renderer.rs b/src/renderer.rs index 03ed2b0..d3743e8 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -2,12 +2,77 @@ //! Uses rustybuzz (HarfBuzz port) for text shaping to support font features. use crate::config::TabBarPosition; -use crate::protocol::{CellColor, CursorStyle, PaneId, PaneInfo, PaneSnapshot, TabInfo}; -use crate::terminal::{Color, ColorPalette, CursorShape, Terminal}; +use crate::terminal::{Color, ColorPalette, CursorShape, GPUCell, Terminal}; use fontdue::Font as FontdueFont; use rustybuzz::UnicodeBuffer; use std::collections::HashMap; use std::sync::Arc; +use wgpu::util::DeviceExt; + +// ═══════════════════════════════════════════════════════════════════════════════ +// KITTY-STYLE INSTANCED RENDERING STRUCTURES +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Color table for shader uniform (258 colors: 256 indexed + default fg/bg). +/// Each color is stored as [R, G, B, A] in linear color space. +#[repr(C)] +#[derive(Clone, Copy, Debug)] +struct ColorTableUniform { + colors: [[f32; 4]; 258], +} + +// Manual bytemuck implementations since Pod/Zeroable aren't derived for [T; 258] +unsafe impl bytemuck::Zeroable for ColorTableUniform {} +unsafe impl bytemuck::Pod for ColorTableUniform {} + +impl Default for ColorTableUniform { + fn default() -> Self { + Self { + colors: [[0.0; 4]; 258], + } + } +} + +/// Grid parameters for instanced rendering. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)] +struct GridParamsUniform { + /// Number of columns + cols: u32, + /// Number of rows + rows: u32, + /// Cell width in pixels + cell_width: f32, + /// Cell height in pixels + cell_height: f32, + /// Screen width in pixels + screen_width: f32, + /// Screen height in pixels + screen_height: f32, + /// Y offset for tab bar + y_offset: f32, + /// Cursor column (-1 if hidden) + cursor_col: i32, + /// Cursor row (-1 if hidden) + cursor_row: i32, + /// Cursor style: 0=block, 1=underline, 2=bar + cursor_style: u32, + /// Padding for 16-byte alignment + _padding: [u32; 2], +} + +/// Sprite info for glyph atlas lookup. +/// Matches the SpriteInfo struct in the shader. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)] +pub struct SpriteInfo { + /// UV coordinates in atlas (x, y, width, height) - normalized 0-1 + pub uv: [f32; 4], + /// Offset from cell origin (x, y) in pixels + pub offset: [f32; 2], + /// Size in pixels (width, height) + pub size: [f32; 2], +} /// Size of the glyph atlas texture. const ATLAS_SIZE: u32 = 1024; @@ -21,8 +86,6 @@ struct GlyphInfo { offset: [f32; 2], /// Size of the glyph in pixels. size: [f32; 2], - /// Advance width (how much to move cursor after this glyph). - advance: f32, } /// Wrapper to hold the rustybuzz Face with a 'static lifetime. @@ -82,6 +145,7 @@ pub struct Renderer { atlas_dirty: bool, // Font and shaping + #[allow(dead_code)] // Kept alive for rustybuzz::Face which borrows it font_data: Box<[u8]>, fontdue_font: FontdueFont, fallback_fonts: Vec, @@ -118,6 +182,55 @@ pub struct Renderer { tab_bar_position: TabBarPosition, /// Background opacity (0.0 = transparent, 1.0 = opaque). background_opacity: f32, + + // Reusable vertex/index buffers to avoid per-frame allocations + bg_vertices: Vec, + bg_indices: Vec, + glyph_vertices: Vec, + glyph_indices: Vec, + + /// Current selection range for rendering (start_col, start_row, end_col, end_row). + /// If set, cells within this range will be rendered with inverted colors. + selection: Option<(usize, usize, usize, usize)>, + + // ═══════════════════════════════════════════════════════════════════════════════ + // KITTY-STYLE INSTANCED RENDERING INFRASTRUCTURE + // ═══════════════════════════════════════════════════════════════════════════════ + + /// Instanced rendering pipeline for backgrounds + cell_bg_pipeline: Option, + /// Instanced rendering pipeline for glyphs + cell_glyph_pipeline: Option, + /// Bind group for instanced rendering (color table, grid params, cells, sprites) + cell_bind_group: Option, + /// Bind group layout for instanced rendering + cell_bind_group_layout: Option, + + /// Color table uniform buffer (258 colors) + color_table_buffer: Option, + /// Grid parameters uniform buffer + grid_params_buffer: Option, + /// GPU cell storage buffer + cell_buffer: Option, + /// Cell buffer capacity (number of cells) + cell_buffer_capacity: usize, + /// Sprite info storage buffer + sprite_buffer: Option, + /// Sprite buffer capacity + sprite_buffer_capacity: usize, + + /// Index buffer for instanced quads (shared between bg and glyph) + quad_index_buffer: Option, + + /// CPU-side sprite info array (maps sprite_idx -> SpriteInfo) + sprite_info: Vec, + /// Map from character to sprite index for fast lookup + char_to_sprite: HashMap, + /// Next available sprite index + next_sprite_idx: u32, + + /// Whether to use instanced rendering (can be disabled for debugging) + use_instanced_rendering: bool, } use crate::config::Config; @@ -185,7 +298,13 @@ impl Renderer { format: surface_format, width: size.width.max(1), height: size.height.max(1), - present_mode: wgpu::PresentMode::Mailbox, + // Use Immediate for lowest latency (no vsync wait) + // Fall back to Mailbox if Immediate not supported + present_mode: if surface_caps.present_modes.contains(&wgpu::PresentMode::Immediate) { + wgpu::PresentMode::Immediate + } else { + wgpu::PresentMode::Mailbox + }, alpha_mode, view_formats: vec![], desired_maximum_frame_latency: 2, @@ -365,6 +484,213 @@ impl Renderer { cache: None, }); + // ═══════════════════════════════════════════════════════════════════════════════ + // KITTY-STYLE INSTANCED RENDERING INITIALIZATION + // ═══════════════════════════════════════════════════════════════════════════════ + + // Initial capacity for cell buffer (e.g., 80x24 terminal = 1920 cells) + let initial_cell_capacity: usize = 80 * 40; + let initial_sprite_capacity: usize = 512; + + // Create color table uniform buffer (258 colors * 4 floats * 4 bytes = 4128 bytes) + let color_table_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Color Table Buffer"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + // Create grid params uniform buffer + let grid_params_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Grid Params Buffer"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + // Create cell storage buffer (GPUCell is 20 bytes) + let cell_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Cell Storage Buffer"), + size: (initial_cell_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + // Create sprite info storage buffer (SpriteInfo is 32 bytes) + let sprite_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Sprite Storage Buffer"), + size: (initial_sprite_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + // Create index buffer for instanced quads (6 indices per quad: 0,1,2, 0,2,3) + let quad_indices: [u16; 6] = [0, 1, 2, 0, 2, 3]; + let quad_index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Quad Index Buffer"), + contents: bytemuck::cast_slice(&quad_indices), + usage: wgpu::BufferUsages::INDEX, + }); + + // Create bind group layout for instanced rendering (group 1) + let cell_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Cell Bind Group Layout"), + entries: &[ + // binding 0: ColorTable uniform + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // binding 1: GridParams uniform + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // binding 2: cells storage buffer (read-only) + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // binding 3: sprites storage buffer (read-only) + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + + // Create bind group for instanced rendering + let cell_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Cell Bind Group"), + layout: &cell_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: color_table_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: grid_params_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: cell_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: sprite_buffer.as_entire_binding(), + }, + ], + }); + + // Create pipeline layout for instanced rendering (uses both group 0 and group 1) + let cell_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Cell Pipeline Layout"), + bind_group_layouts: &[&glyph_bind_group_layout, &cell_bind_group_layout], + push_constant_ranges: &[], + }); + + // Create background pipeline + let cell_bg_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Cell Background Pipeline"), + layout: Some(&cell_pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_cell_bg"), + buffers: &[], // No vertex buffers - using instancing + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_cell"), + targets: &[Some(wgpu::ColorTargetState { + format: surface_config.format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + // Create glyph pipeline + let cell_glyph_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Cell Glyph Pipeline"), + layout: Some(&cell_pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_cell_glyph"), + buffers: &[], // No vertex buffers - using instancing + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_cell"), + targets: &[Some(wgpu::ColorTargetState { + format: surface_config.format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + // Initialize sprite info array with entry 0 = no glyph/empty + let sprite_info = vec![SpriteInfo::default()]; + + // ═══════════════════════════════════════════════════════════════════════════════ + // END KITTY-STYLE INSTANCED RENDERING INITIALIZATION + // ═══════════════════════════════════════════════════════════════════════════════ + // Create initial buffers with some capacity let initial_vertex_capacity = 4096; let initial_index_capacity = 6144; @@ -418,6 +744,29 @@ impl Renderer { palette: ColorPalette::default(), tab_bar_position: config.tab_bar_position, background_opacity: config.background_opacity.clamp(0.0, 1.0), + // Pre-allocate reusable buffers for rendering + bg_vertices: Vec::with_capacity(4096), + bg_indices: Vec::with_capacity(6144), + glyph_vertices: Vec::with_capacity(4096), + glyph_indices: Vec::with_capacity(6144), + selection: None, + + // Kitty-style instanced rendering infrastructure + cell_bg_pipeline: Some(cell_bg_pipeline), + cell_glyph_pipeline: Some(cell_glyph_pipeline), + cell_bind_group: Some(cell_bind_group), + cell_bind_group_layout: Some(cell_bind_group_layout), + color_table_buffer: Some(color_table_buffer), + grid_params_buffer: Some(grid_params_buffer), + cell_buffer: Some(cell_buffer), + cell_buffer_capacity: initial_cell_capacity, + sprite_buffer: Some(sprite_buffer), + sprite_buffer_capacity: initial_sprite_capacity, + quad_index_buffer: Some(quad_index_buffer), + sprite_info, + char_to_sprite: HashMap::new(), + next_sprite_idx: 1, // 0 is reserved for empty/no glyph + use_instanced_rendering: true, // Use Kitty-style instanced rendering } } @@ -436,6 +785,42 @@ impl Renderer { _ => 0.0, } } + + /// Sets the current selection range for highlighting. + /// Pass None to clear the selection. + /// The selection is specified as (start_col, start_row, end_col, end_row) in normalized order. + pub fn set_selection(&mut self, selection: Option<(usize, usize, usize, usize)>) { + self.selection = selection; + } + + /// Checks if a cell at (col, row) is within the current selection. + fn is_cell_selected(&self, col: usize, row: usize) -> bool { + let Some((start_col, start_row, end_col, end_row)) = self.selection else { + return false; + }; + + // Check if the row is within the selection range + if row < start_row || row > end_row { + return false; + } + + // For single-row selection + if start_row == end_row { + return col >= start_col && col <= end_col; + } + + // For multi-row selection + if row == start_row { + // First row: from start_col to end of line + return col >= start_col; + } else if row == end_row { + // Last row: from start of line to end_col + return col <= end_col; + } else { + // Middle rows: entire row is selected + return true; + } + } /// Resizes the rendering surface. pub fn resize(&mut self, new_width: u32, new_height: u32) { @@ -455,6 +840,45 @@ impl Renderer { let rows = (available_height / self.cell_height).floor() as usize; (cols.max(1), rows.max(1)) } + + /// Converts a pixel position to a terminal cell position. + /// Returns None if the position is outside the terminal area (e.g., in the tab bar). + pub fn pixel_to_cell(&self, x: f64, y: f64) -> Option<(usize, usize)> { + let terminal_y_offset = self.terminal_y_offset(); + let tab_bar_height = self.tab_bar_height(); + let height = self.height as f32; + + // Check if position is in the tab bar area (which could be at top or bottom) + match self.tab_bar_position { + TabBarPosition::Top => { + if (y as f32) < tab_bar_height { + return None; + } + } + TabBarPosition::Bottom => { + if (y as f32) >= height - tab_bar_height { + return None; + } + } + TabBarPosition::Hidden => {} + } + + // Adjust y to be relative to terminal area + let terminal_y = y as f32 - terminal_y_offset; + + // Calculate cell position + let col = (x as f32 / self.cell_width).floor() as usize; + let row = (terminal_y / self.cell_height).floor() as usize; + + // Get terminal dimensions to clamp + let (max_cols, max_rows) = self.terminal_size(); + + // Clamp to valid range + let col = col.min(max_cols.saturating_sub(1)); + let row = row.min(max_rows.saturating_sub(1)); + + Some((col, row)) + } /// Updates the scale factor and recalculates font/cell dimensions. /// Returns true if the cell dimensions changed (terminal needs resize). @@ -1416,7 +1840,6 @@ impl Renderer { uv: [0.0, 0.0, 0.0, 0.0], offset: [0.0, 0.0], size: [0.0, 0.0], - advance: self.cell_width, }; self.char_cache.insert(c, info); return info; @@ -1445,7 +1868,6 @@ impl Renderer { uv: [uv_x, uv_y, uv_w, uv_h], offset: [0.0, 0.0], size: [glyph_width as f32, glyph_height as f32], - advance: self.cell_width, }; // Update atlas cursor @@ -1499,7 +1921,6 @@ impl Renderer { uv: [0.0, 0.0, 0.0, 0.0], offset: [0.0, 0.0], size: [0.0, 0.0], - advance: metrics.advance_width, }; self.char_cache.insert(c, info); return info; @@ -1522,7 +1943,6 @@ impl Renderer { uv: [0.0, 0.0, 0.0, 0.0], offset: [0.0, 0.0], size: [0.0, 0.0], - advance: metrics.advance_width, }; self.char_cache.insert(c, info); return info; @@ -1550,7 +1970,6 @@ impl Renderer { uv: [uv_x, uv_y, uv_w, uv_h], offset: [metrics.xmin as f32, metrics.ymin as f32], size: [glyph_width as f32, glyph_height as f32], - advance: metrics.advance_width, }; // Update atlas cursor @@ -1578,7 +1997,6 @@ impl Renderer { uv: [0.0, 0.0, 0.0, 0.0], offset: [0.0, 0.0], size: [0.0, 0.0], - advance: metrics.advance_width, }; self.glyph_cache.insert(cache_key, info); return info; @@ -1601,7 +2019,6 @@ impl Renderer { uv: [0.0, 0.0, 0.0, 0.0], offset: [0.0, 0.0], size: [0.0, 0.0], - advance: metrics.advance_width, }; self.glyph_cache.insert(cache_key, info); return info; @@ -1629,7 +2046,6 @@ impl Renderer { uv: [uv_x, uv_y, uv_w, uv_h], offset: [metrics.xmin as f32, metrics.ymin as f32], size: [glyph_width as f32, glyph_height as f32], - advance: metrics.advance_width, }; // Update atlas cursor @@ -1709,35 +2125,6 @@ impl Renderer { ] } - /// Converts a protocol CellColor to RGBA in linear color space. - /// Default backgrounds are fully transparent to let the window clear color show through. - /// Explicit background colors remain fully opaque. - fn cell_color_to_rgba(&self, color: &CellColor, is_foreground: bool) -> [f32; 4] { - // For default background: fully transparent so clear color shows through - if !is_foreground && *color == CellColor::Default { - return [0.0, 0.0, 0.0, 0.0]; - } - - let srgb = match color { - CellColor::Default => { - // Only foreground gets here (background returns early above) - let [r, g, b] = self.palette.default_fg; - [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] - } - CellColor::Rgb(r, g, b) => [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0], - CellColor::Indexed(idx) => { - let [r, g, b] = self.palette.colors[*idx as usize]; - [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] - } - }; - [ - Self::srgb_to_linear(srgb[0]), - Self::srgb_to_linear(srgb[1]), - Self::srgb_to_linear(srgb[2]), - srgb[3], - ] - } - /// Convert pixel X coordinate to NDC, snapped to pixel boundaries. #[inline] fn pixel_to_ndc_x(pixel: f32, screen_width: f32) -> f32 { @@ -1759,13 +2146,13 @@ impl Renderer { .texture .create_view(&wgpu::TextureViewDescriptor::default()); - // Use two separate lists: backgrounds first, then glyphs + // Reuse pre-allocated buffers - clear instead of reallocating // This ensures wide glyphs (like Nerd Font icons) can extend beyond their cell // without being covered by adjacent cell backgrounds - let mut bg_vertices: Vec = Vec::with_capacity(terminal.cols * terminal.rows * 4); - let mut bg_indices: Vec = Vec::with_capacity(terminal.cols * terminal.rows * 6); - let mut glyph_vertices: Vec = Vec::with_capacity(terminal.cols * terminal.rows * 4); - let mut glyph_indices: Vec = Vec::with_capacity(terminal.cols * terminal.rows * 6); + self.bg_vertices.clear(); + self.bg_indices.clear(); + self.glyph_vertices.clear(); + self.glyph_indices.clear(); let width = self.width as f32; let height = self.height as f32; @@ -1828,32 +2215,32 @@ impl Renderer { let cell_top = Self::pixel_to_ndc_y(cell_y, height); let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); - let base_idx = bg_vertices.len() as u32; - bg_vertices.push(GlyphVertex { + let base_idx = self.bg_vertices.len() as u32; + self.bg_vertices.push(GlyphVertex { position: [cell_left, cell_top], uv: [0.0, 0.0], color: fg_color, bg_color, }); - bg_vertices.push(GlyphVertex { + self.bg_vertices.push(GlyphVertex { position: [cell_right, cell_top], uv: [0.0, 0.0], color: fg_color, bg_color, }); - bg_vertices.push(GlyphVertex { + self.bg_vertices.push(GlyphVertex { position: [cell_right, cell_bottom], uv: [0.0, 0.0], color: fg_color, bg_color, }); - bg_vertices.push(GlyphVertex { + self.bg_vertices.push(GlyphVertex { position: [cell_left, cell_bottom], uv: [0.0, 0.0], color: fg_color, bg_color, }); - bg_indices.extend_from_slice(&[ + self.bg_indices.extend_from_slice(&[ base_idx, base_idx + 1, base_idx + 2, base_idx, base_idx + 2, base_idx + 3, ]); @@ -1871,32 +2258,32 @@ impl Renderer { let top = Self::pixel_to_ndc_y(glyph_y, height); let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); - let base_idx = glyph_vertices.len() as u32; - glyph_vertices.push(GlyphVertex { + let base_idx = self.glyph_vertices.len() as u32; + self.glyph_vertices.push(GlyphVertex { position: [left, top], uv: [glyph.uv[0], glyph.uv[1]], color: fg_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [right, top], uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], color: fg_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [right, bottom], uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], color: fg_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [left, bottom], uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], color: fg_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_indices.extend_from_slice(&[ + self.glyph_indices.extend_from_slice(&[ base_idx, base_idx + 1, base_idx + 2, base_idx, base_idx + 2, base_idx + 3, ]); @@ -1914,32 +2301,32 @@ impl Renderer { let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); // Add background quad - let base_idx = bg_vertices.len() as u32; - bg_vertices.push(GlyphVertex { + let base_idx = self.bg_vertices.len() as u32; + self.bg_vertices.push(GlyphVertex { position: [cell_left, cell_top], uv: [0.0, 0.0], color: fg_color, bg_color, }); - bg_vertices.push(GlyphVertex { + self.bg_vertices.push(GlyphVertex { position: [cell_right, cell_top], uv: [0.0, 0.0], color: fg_color, bg_color, }); - bg_vertices.push(GlyphVertex { + self.bg_vertices.push(GlyphVertex { position: [cell_right, cell_bottom], uv: [0.0, 0.0], color: fg_color, bg_color, }); - bg_vertices.push(GlyphVertex { + self.bg_vertices.push(GlyphVertex { position: [cell_left, cell_bottom], uv: [0.0, 0.0], color: fg_color, bg_color, }); - bg_indices.extend_from_slice(&[ + self.bg_indices.extend_from_slice(&[ base_idx, base_idx + 1, base_idx + 2, base_idx, base_idx + 2, base_idx + 3, ]); @@ -1963,32 +2350,32 @@ impl Renderer { let top = Self::pixel_to_ndc_y(glyph_y, height); let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); - let base_idx = glyph_vertices.len() as u32; - glyph_vertices.push(GlyphVertex { + let base_idx = self.glyph_vertices.len() as u32; + self.glyph_vertices.push(GlyphVertex { position: [left, top], uv: [glyph.uv[0], glyph.uv[1]], color: fg_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [right, top], uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], color: fg_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [right, bottom], uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], color: fg_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [left, bottom], uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], color: fg_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_indices.extend_from_slice(&[ + self.glyph_indices.extend_from_slice(&[ base_idx, base_idx + 1, base_idx + 2, base_idx, base_idx + 2, base_idx + 3, ]); @@ -2071,34 +2458,34 @@ impl Renderer { let cursor_top = Self::pixel_to_ndc_y(top, height); let cursor_bottom = Self::pixel_to_ndc_y(bottom, height); - let base_idx = glyph_vertices.len() as u32; + let base_idx = self.glyph_vertices.len() as u32; - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [cursor_left, cursor_top], uv: [0.0, 0.0], color: cursor_bg_color, bg_color: cursor_bg_color, }); - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [cursor_right, cursor_top], uv: [0.0, 0.0], color: cursor_bg_color, bg_color: cursor_bg_color, }); - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [cursor_right, cursor_bottom], uv: [0.0, 0.0], color: cursor_bg_color, bg_color: cursor_bg_color, }); - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [cursor_left, cursor_bottom], uv: [0.0, 0.0], color: cursor_bg_color, bg_color: cursor_bg_color, }); - glyph_indices.extend_from_slice(&[ + self.glyph_indices.extend_from_slice(&[ base_idx, base_idx + 1, base_idx + 2, @@ -2139,32 +2526,32 @@ impl Renderer { let g_top = Self::pixel_to_ndc_y(glyph_y, height); let g_bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); - let base_idx = glyph_vertices.len() as u32; - glyph_vertices.push(GlyphVertex { + let base_idx = self.glyph_vertices.len() as u32; + self.glyph_vertices.push(GlyphVertex { position: [g_left, g_top], uv: [glyph.uv[0], glyph.uv[1]], color: char_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [g_right, g_top], uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], color: char_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [g_right, g_bottom], uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], color: char_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [g_left, g_bottom], uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], color: char_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_indices.extend_from_slice(&[ + self.glyph_indices.extend_from_slice(&[ base_idx, base_idx + 1, base_idx + 2, base_idx, base_idx + 2, base_idx + 3, ]); @@ -2172,16 +2559,14 @@ impl Renderer { } // Combine: backgrounds first, then glyphs (with adjusted indices) - let mut vertices = bg_vertices; - let mut indices = bg_indices; - - let glyph_vertex_offset = vertices.len() as u32; - vertices.extend(glyph_vertices); - indices.extend(glyph_indices.iter().map(|i| i + glyph_vertex_offset)); + // We need to calculate total counts and adjust glyph indices + let bg_vertex_count = self.bg_vertices.len(); + let total_vertex_count = bg_vertex_count + self.glyph_vertices.len(); + let total_index_count = self.bg_indices.len() + self.glyph_indices.len(); // Resize buffers if needed - if vertices.len() > self.vertex_capacity { - self.vertex_capacity = vertices.len() * 2; + if total_vertex_count > self.vertex_capacity { + self.vertex_capacity = total_vertex_count * 2; self.vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("Glyph Vertex Buffer"), size: (self.vertex_capacity * std::mem::size_of::()) as u64, @@ -2190,8 +2575,8 @@ impl Renderer { }); } - if indices.len() > self.index_capacity { - self.index_capacity = indices.len() * 2; + if total_index_count > self.index_capacity { + self.index_capacity = total_index_count * 2; self.index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("Glyph Index Buffer"), size: (self.index_capacity * std::mem::size_of::()) as u64, @@ -2200,9 +2585,35 @@ impl Renderer { }); } - // Upload vertex and index data - self.queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&vertices)); - self.queue.write_buffer(&self.index_buffer, 0, bytemuck::cast_slice(&indices)); + // Upload background vertices first, then glyph vertices + self.queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.bg_vertices)); + self.queue.write_buffer( + &self.vertex_buffer, + (bg_vertex_count * std::mem::size_of::()) as u64, + bytemuck::cast_slice(&self.glyph_vertices), + ); + + // Upload background indices first + self.queue.write_buffer(&self.index_buffer, 0, bytemuck::cast_slice(&self.bg_indices)); + + // Upload glyph indices with offset adjustment (need to adjust indices by bg_vertex_count) + // Create adjusted indices on the stack if small enough, otherwise use temporary allocation + let glyph_vertex_offset = bg_vertex_count as u32; + let bg_index_bytes = self.bg_indices.len() * std::mem::size_of::(); + + // Write adjusted glyph indices + if !self.glyph_indices.is_empty() { + // For large batches, we need a temporary buffer - this is unavoidable + // but happens only once per frame instead of incrementally + let adjusted_indices: Vec = self.glyph_indices.iter() + .map(|i| i + glyph_vertex_offset) + .collect(); + self.queue.write_buffer( + &self.index_buffer, + bg_index_bytes as u64, + bytemuck::cast_slice(&adjusted_indices), + ); + } // Upload atlas if dirty if self.atlas_dirty { @@ -2266,7 +2677,7 @@ impl Renderer { render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32); - render_pass.draw_indexed(0..indices.len() as u32, 0, 0..1); + render_pass.draw_indexed(0..total_index_count as u32, 0, 0..1); } self.queue.submit(std::iter::once(encoder.finish())); @@ -2276,540 +2687,35 @@ impl Renderer { Ok(()) } - - /// Renders a pane from protocol data (used by client). - pub fn render_pane(&mut self, pane: &PaneSnapshot) -> Result<(), wgpu::SurfaceError> { - let output = self.surface.get_current_texture()?; - let view = output - .texture - .create_view(&wgpu::TextureViewDescriptor::default()); - - let rows = pane.cells.len(); - let cols = if rows > 0 { pane.cells[0].len() } else { 0 }; - - let mut bg_vertices: Vec = Vec::with_capacity(cols * rows * 4); - let mut bg_indices: Vec = Vec::with_capacity(cols * rows * 6); - let mut glyph_vertices: Vec = Vec::with_capacity(cols * rows * 4); - let mut glyph_indices: Vec = Vec::with_capacity(cols * rows * 6); - - let width = self.width as f32; - let height = self.height as f32; - - // Common programming ligatures to check (longest first for greedy matching) - const LIGATURE_PATTERNS: &[&str] = &[ - // 3-char - "===", "!==", ">>>", "<<<", "||=", "&&=", "??=", "...", "-->", "<--", "<->", - // 2-char - "=>", "->", "<-", ">=", "<=", "==", "!=", "::", "&&", "||", "??", "..", "++", - "--", "<<", ">>", "|>", "<|", "/*", "*/", "//", "##", ":=", "~=", "<>", - ]; - - for (row_idx, row) in pane.cells.iter().enumerate() { - let mut col_idx = 0; - while col_idx < row.len() { - let cell = &row[col_idx]; - let cell_x = col_idx as f32 * self.cell_width; - let cell_y = row_idx as f32 * self.cell_height; - - let fg_color = self.cell_color_to_rgba(&cell.fg_color, true); - let bg_color = self.cell_color_to_rgba(&cell.bg_color, false); - - // Check for ligatures by looking ahead - let mut ligature_len = 0; - let mut ligature_glyph: Option = None; - - for pattern in LIGATURE_PATTERNS { - let pat_len = pattern.len(); - if col_idx + pat_len <= row.len() { - let candidate: String = row[col_idx..col_idx + pat_len] - .iter() - .map(|c| c.character) - .collect(); - - if candidate == *pattern { - let shaped = self.shape_text(&candidate); - if shaped.glyphs.len() == 1 { - let glyph_id = shaped.glyphs[0].0; - ligature_glyph = Some(self.get_glyph_by_id(glyph_id)); - ligature_len = pat_len; - break; - } - } - } - } - - if let Some(glyph) = ligature_glyph { - let span_width = ligature_len as f32 * self.cell_width; - - for i in 0..ligature_len { - let bg_cell_x = (col_idx + i) as f32 * self.cell_width; - let cell_left = Self::pixel_to_ndc_x(bg_cell_x, width); - let cell_right = Self::pixel_to_ndc_x(bg_cell_x + self.cell_width, width); - let cell_top = Self::pixel_to_ndc_y(cell_y, height); - let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); - - let base_idx = bg_vertices.len() as u32; - bg_vertices.push(GlyphVertex { - position: [cell_left, cell_top], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_vertices.push(GlyphVertex { - position: [cell_right, cell_top], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_vertices.push(GlyphVertex { - position: [cell_right, cell_bottom], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_vertices.push(GlyphVertex { - position: [cell_left, cell_bottom], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_indices.extend_from_slice(&[ - base_idx, base_idx + 1, base_idx + 2, - base_idx, base_idx + 2, base_idx + 3, - ]); - } - - if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { - let baseline_y = (cell_y + self.cell_height * 0.8).round(); - let glyph_x = (cell_x + (span_width - glyph.size[0]) / 2.0 + glyph.offset[0]).round(); - let glyph_y = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); - - let left = Self::pixel_to_ndc_x(glyph_x, width); - let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); - let top = Self::pixel_to_ndc_y(glyph_y, height); - let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); - - let base_idx = glyph_vertices.len() as u32; - glyph_vertices.push(GlyphVertex { - position: [left, top], - uv: [glyph.uv[0], glyph.uv[1]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_vertices.push(GlyphVertex { - position: [right, top], - uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_vertices.push(GlyphVertex { - position: [right, bottom], - uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_vertices.push(GlyphVertex { - position: [left, bottom], - uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_indices.extend_from_slice(&[ - base_idx, base_idx + 1, base_idx + 2, - base_idx, base_idx + 2, base_idx + 3, - ]); - } - - col_idx += ligature_len; - } else { - // Single character rendering - let cell_left = Self::pixel_to_ndc_x(cell_x, width); - let cell_right = Self::pixel_to_ndc_x(cell_x + self.cell_width, width); - let cell_top = Self::pixel_to_ndc_y(cell_y, height); - let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); - - let base_idx = bg_vertices.len() as u32; - bg_vertices.push(GlyphVertex { - position: [cell_left, cell_top], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_vertices.push(GlyphVertex { - position: [cell_right, cell_top], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_vertices.push(GlyphVertex { - position: [cell_right, cell_bottom], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_vertices.push(GlyphVertex { - position: [cell_left, cell_bottom], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_indices.extend_from_slice(&[ - base_idx, base_idx + 1, base_idx + 2, - base_idx, base_idx + 2, base_idx + 3, - ]); - - let c = cell.character; - if c != ' ' && c != '\0' { - let glyph = self.rasterize_char(c); - if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { - // Box-drawing characters fill the entire cell - let (glyph_x, glyph_y) = if Self::is_box_drawing(c) { - (cell_x, cell_y) - } else { - // Calculate glyph position with baseline alignment - let baseline_y = (cell_y + self.cell_height * 0.8).round(); - let gx = (cell_x + glyph.offset[0]).round(); - let gy = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); - (gx, gy) - }; - - let left = Self::pixel_to_ndc_x(glyph_x, width); - let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); - let top = Self::pixel_to_ndc_y(glyph_y, height); - let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); - - let base_idx = glyph_vertices.len() as u32; - glyph_vertices.push(GlyphVertex { - position: [left, top], - uv: [glyph.uv[0], glyph.uv[1]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_vertices.push(GlyphVertex { - position: [right, top], - uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_vertices.push(GlyphVertex { - position: [right, bottom], - uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_vertices.push(GlyphVertex { - position: [left, bottom], - uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_indices.extend_from_slice(&[ - base_idx, base_idx + 1, base_idx + 2, - base_idx, base_idx + 2, base_idx + 3, - ]); - } - } - - col_idx += 1; - } - } - } - - // Add cursor - if pane.cursor.visible { - let cursor_x = pane.cursor.col as f32 * self.cell_width; - let cursor_y = pane.cursor.row as f32 * self.cell_height; - - // Get the cell under the cursor to determine colors - let cursor_cell = pane.cells - .get(pane.cursor.row) - .and_then(|row| row.get(pane.cursor.col)); - - // Get fg and bg colors from the cell under cursor - let (cell_fg, cell_bg, cell_char) = if let Some(cell) = cursor_cell { - let fg = self.cell_color_to_rgba(&cell.fg_color, true); - let bg = self.cell_color_to_rgba(&cell.bg_color, false); - (fg, bg, cell.character) - } else { - // Default colors if cell doesn't exist - let fg = self.cell_color_to_rgba(&CellColor::Default, true); - let bg = [0.0, 0.0, 0.0, 0.0]; - (fg, bg, ' ') - }; - - let has_character = cell_char != ' ' && cell_char != '\0'; - - // Cursor color: invert the background, or use fg if there's a character - let cursor_bg_color = if has_character { - // Character present: cursor takes fg color as background - [cell_fg[0], cell_fg[1], cell_fg[2], 1.0] - } else { - // Empty cell: invert the background color - if cell_bg[3] < 0.01 { - // Transparent background -> white cursor - let white = Self::srgb_to_linear(0.9); - [white, white, white, 1.0] - } else { - // Invert the background color - [1.0 - cell_bg[0], 1.0 - cell_bg[1], 1.0 - cell_bg[2], 1.0] - } - }; - - // Determine cursor bounds based on style - let (left, right, top, bottom) = match pane.cursor.style { - CursorStyle::Block => ( - cursor_x, - cursor_x + self.cell_width, - cursor_y, - cursor_y + self.cell_height, - ), - CursorStyle::Underline => { - let underline_height = 2.0_f32.max(self.cell_height * 0.1); - ( - cursor_x, - cursor_x + self.cell_width, - cursor_y + self.cell_height - underline_height, - cursor_y + self.cell_height, - ) - } - CursorStyle::Bar => { - let bar_width = 2.0_f32.max(self.cell_width * 0.1); - ( - cursor_x, - cursor_x + bar_width, - cursor_y, - cursor_y + self.cell_height, - ) - } - }; - - let cursor_left = Self::pixel_to_ndc_x(left, width); - let cursor_right = Self::pixel_to_ndc_x(right, width); - let cursor_top = Self::pixel_to_ndc_y(top, height); - let cursor_bottom = Self::pixel_to_ndc_y(bottom, height); - - let base_idx = glyph_vertices.len() as u32; - - glyph_vertices.push(GlyphVertex { - position: [cursor_left, cursor_top], - uv: [0.0, 0.0], - color: cursor_bg_color, - bg_color: cursor_bg_color, - }); - glyph_vertices.push(GlyphVertex { - position: [cursor_right, cursor_top], - uv: [0.0, 0.0], - color: cursor_bg_color, - bg_color: cursor_bg_color, - }); - glyph_vertices.push(GlyphVertex { - position: [cursor_right, cursor_bottom], - uv: [0.0, 0.0], - color: cursor_bg_color, - bg_color: cursor_bg_color, - }); - glyph_vertices.push(GlyphVertex { - position: [cursor_left, cursor_bottom], - uv: [0.0, 0.0], - color: cursor_bg_color, - bg_color: cursor_bg_color, - }); - - glyph_indices.extend_from_slice(&[ - base_idx, base_idx + 1, base_idx + 2, - base_idx, base_idx + 2, base_idx + 3, - ]); - - // If block cursor and there's a character, re-render it with inverted color - if matches!(pane.cursor.style, CursorStyle::Block) && has_character { - // Character color: use bg color (inverted from normal) - let char_color = if cell_bg[3] < 0.01 { - // If bg was transparent, use black for the character - [0.0, 0.0, 0.0, 1.0] - } else { - [cell_bg[0], cell_bg[1], cell_bg[2], 1.0] - }; - - let glyph = self.rasterize_char(cell_char); - if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { - let cell_x = cursor_x; - let cell_y = cursor_y; - let (glyph_x, glyph_y) = if Self::is_box_drawing(cell_char) { - (cell_x, cell_y) - } else { - let baseline_y = (cell_y + self.cell_height * 0.8).round(); - let gx = (cell_x + glyph.offset[0]).round(); - let gy = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); - (gx, gy) - }; - - let g_left = Self::pixel_to_ndc_x(glyph_x, width); - let g_right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); - let g_top = Self::pixel_to_ndc_y(glyph_y, height); - let g_bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); - - let base_idx = glyph_vertices.len() as u32; - glyph_vertices.push(GlyphVertex { - position: [g_left, g_top], - uv: [glyph.uv[0], glyph.uv[1]], - color: char_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_vertices.push(GlyphVertex { - position: [g_right, g_top], - uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], - color: char_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_vertices.push(GlyphVertex { - position: [g_right, g_bottom], - uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], - color: char_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_vertices.push(GlyphVertex { - position: [g_left, g_bottom], - uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], - color: char_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_indices.extend_from_slice(&[ - base_idx, base_idx + 1, base_idx + 2, - base_idx, base_idx + 2, base_idx + 3, - ]); - } - } - } - - // Combine vertices - let mut vertices = bg_vertices; - let mut indices = bg_indices; - - let glyph_vertex_offset = vertices.len() as u32; - vertices.extend(glyph_vertices); - indices.extend(glyph_indices.iter().map(|i| i + glyph_vertex_offset)); - - // Resize buffers if needed - if vertices.len() > self.vertex_capacity { - self.vertex_capacity = vertices.len() * 2; - self.vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Glyph Vertex Buffer"), - size: (self.vertex_capacity * std::mem::size_of::()) as u64, - usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - } - - if indices.len() > self.index_capacity { - self.index_capacity = indices.len() * 2; - self.index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Glyph Index Buffer"), - size: (self.index_capacity * std::mem::size_of::()) as u64, - usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - } - - // Upload data - self.queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&vertices)); - self.queue.write_buffer(&self.index_buffer, 0, bytemuck::cast_slice(&indices)); - - if self.atlas_dirty { - self.queue.write_texture( - wgpu::ImageCopyTexture { - texture: &self.atlas_texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - &self.atlas_data, - wgpu::ImageDataLayout { - offset: 0, - bytes_per_row: Some(ATLAS_SIZE), - rows_per_image: Some(ATLAS_SIZE), - }, - wgpu::Extent3d { - width: ATLAS_SIZE, - height: ATLAS_SIZE, - depth_or_array_layers: 1, - }, - ); - self.atlas_dirty = false; - } - - // Create command encoder and render - let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("Render Encoder"), - }); - - { - let [bg_r, bg_g, bg_b] = self.palette.default_bg; - let bg_r_linear = Self::srgb_to_linear(bg_r as f32 / 255.0) as f64; - let bg_g_linear = Self::srgb_to_linear(bg_g as f32 / 255.0) as f64; - let bg_b_linear = Self::srgb_to_linear(bg_b as f32 / 255.0) as f64; - let bg_alpha = self.background_opacity as f64; - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Render Pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { - r: bg_r_linear, - g: bg_g_linear, - b: bg_b_linear, - a: bg_alpha, - }), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - occlusion_query_set: None, - timestamp_writes: None, - }); - - render_pass.set_pipeline(&self.glyph_pipeline); - render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); - render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); - render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32); - render_pass.draw_indexed(0..indices.len() as u32, 0, 0..1); - } - - self.queue.submit(std::iter::once(encoder.finish())); - output.present(); - - Ok(()) - } - - /// Renders multiple panes with tab bar from protocol data (used by client). - /// The tab bar is rendered outside the terminal grid at the configured position. + /// Renders terminal content directly from a Terminal reference. + /// This is the new preferred method that avoids cross-process synchronization. /// /// Arguments: - /// - `panes`: All pane snapshots with their layout info for the active tab - /// - `active_pane_id`: The ID of the focused pane (for cursor rendering) - /// - `tabs`: Tab information for the tab bar + /// - `terminal`: Reference to the terminal state (same process) + /// - `num_tabs`: Number of tabs for the tab bar (0 to hide) /// - `active_tab`: Index of the active tab - pub fn render_with_tabs( + pub fn render_from_terminal( &mut self, - panes: &[(&PaneSnapshot, &PaneInfo)], - active_pane_id: PaneId, - tabs: &[TabInfo], + terminal: &Terminal, + num_tabs: usize, active_tab: usize, ) -> Result<(), wgpu::SurfaceError> { + // Sync palette from terminal (OSC sequences update terminal.palette) + self.palette = terminal.palette.clone(); + let output = self.surface.get_current_texture()?; let view = output .texture .create_view(&wgpu::TextureViewDescriptor::default()); - // Estimate total cells across all panes for buffer capacity - let total_cells: usize = panes.iter() - .map(|(snap, _)| snap.cells.len() * snap.cells.first().map_or(0, |r| r.len())) - .sum(); + let cols = terminal.cols; + let rows = terminal.rows; - let mut bg_vertices: Vec = Vec::with_capacity(total_cells * 4 + 64); - let mut bg_indices: Vec = Vec::with_capacity(total_cells * 6 + 96); - let mut glyph_vertices: Vec = Vec::with_capacity(total_cells * 4 + 256); - let mut glyph_indices: Vec = Vec::with_capacity(total_cells * 6 + 384); + // Reuse pre-allocated buffers + self.bg_vertices.clear(); + self.bg_indices.clear(); + self.glyph_vertices.clear(); + self.glyph_indices.clear(); let width = self.width as f32; let height = self.height as f32; @@ -2817,19 +2723,17 @@ impl Renderer { let terminal_y_offset = self.terminal_y_offset(); // ═══════════════════════════════════════════════════════════════════ - // RENDER TAB BAR (outside terminal grid) + // RENDER TAB BAR // ═══════════════════════════════════════════════════════════════════ - if self.tab_bar_position != TabBarPosition::Hidden && !tabs.is_empty() { + if self.tab_bar_position != TabBarPosition::Hidden && num_tabs > 0 { let tab_bar_y = match self.tab_bar_position { TabBarPosition::Top => 0.0, TabBarPosition::Bottom => height - tab_bar_height, TabBarPosition::Hidden => unreachable!(), }; - // Tab bar background - slightly different from terminal background let tab_bar_bg = { let [r, g, b] = self.palette.default_bg; - // Darken/lighten slightly for visual separation let factor = 0.85_f32; [ Self::srgb_to_linear((r as f32 / 255.0) * factor), @@ -2845,52 +2749,48 @@ impl Renderer { let bar_top = Self::pixel_to_ndc_y(tab_bar_y, height); let bar_bottom = Self::pixel_to_ndc_y(tab_bar_y + tab_bar_height, height); - let base_idx = bg_vertices.len() as u32; - bg_vertices.push(GlyphVertex { + let base_idx = self.bg_vertices.len() as u32; + self.bg_vertices.push(GlyphVertex { position: [bar_left, bar_top], uv: [0.0, 0.0], color: tab_bar_bg, bg_color: tab_bar_bg, }); - bg_vertices.push(GlyphVertex { + self.bg_vertices.push(GlyphVertex { position: [bar_right, bar_top], uv: [0.0, 0.0], color: tab_bar_bg, bg_color: tab_bar_bg, }); - bg_vertices.push(GlyphVertex { + self.bg_vertices.push(GlyphVertex { position: [bar_right, bar_bottom], uv: [0.0, 0.0], color: tab_bar_bg, bg_color: tab_bar_bg, }); - bg_vertices.push(GlyphVertex { + self.bg_vertices.push(GlyphVertex { position: [bar_left, bar_bottom], uv: [0.0, 0.0], color: tab_bar_bg, bg_color: tab_bar_bg, }); - bg_indices.extend_from_slice(&[ + self.bg_indices.extend_from_slice(&[ base_idx, base_idx + 1, base_idx + 2, base_idx, base_idx + 2, base_idx + 3, ]); // Render each tab - let mut tab_x = 4.0_f32; // Start with small padding + let mut tab_x = 4.0_f32; let tab_padding = 8.0_f32; - let min_tab_width = self.cell_width * 8.0; // Minimum width for tab + let min_tab_width = self.cell_width * 8.0; - for (idx, _tab) in tabs.iter().enumerate() { + for idx in 0..num_tabs { let is_active = idx == active_tab; - - // Generate tab title (for now, just "Tab N" or use first pane info) let title = format!(" {} ", idx + 1); let title_width = title.chars().count() as f32 * self.cell_width; let tab_width = title_width.max(min_tab_width); - // Tab background let tab_bg = if is_active { - // Active tab uses terminal background let [r, g, b] = self.palette.default_bg; [ Self::srgb_to_linear(r as f32 / 255.0), @@ -2899,7 +2799,6 @@ impl Renderer { 1.0, ] } else { - // Inactive tabs are slightly darker tab_bar_bg }; @@ -2914,57 +2813,37 @@ impl Renderer { ] }; - // Tab background rect + let tab_top = Self::pixel_to_ndc_y(tab_bar_y + 2.0, height); + let tab_bottom = Self::pixel_to_ndc_y(tab_bar_y + tab_bar_height - 2.0, height); let tab_left = Self::pixel_to_ndc_x(tab_x, width); let tab_right = Self::pixel_to_ndc_x(tab_x + tab_width, width); - - // For top tab bar: active tab extends to touch terminal content - // For bottom tab bar: active tab extends upward - let (tab_top, tab_bottom) = if is_active { - match self.tab_bar_position { - TabBarPosition::Top => ( - Self::pixel_to_ndc_y(tab_bar_y + 2.0, height), - Self::pixel_to_ndc_y(tab_bar_y + tab_bar_height, height), - ), - TabBarPosition::Bottom => ( - Self::pixel_to_ndc_y(tab_bar_y, height), - Self::pixel_to_ndc_y(tab_bar_y + tab_bar_height - 2.0, height), - ), - TabBarPosition::Hidden => unreachable!(), - } - } else { - ( - Self::pixel_to_ndc_y(tab_bar_y + 4.0, height), - Self::pixel_to_ndc_y(tab_bar_y + tab_bar_height - 2.0, height), - ) - }; - let base_idx = bg_vertices.len() as u32; - bg_vertices.push(GlyphVertex { + let base_idx = self.bg_vertices.len() as u32; + self.bg_vertices.push(GlyphVertex { position: [tab_left, tab_top], uv: [0.0, 0.0], color: tab_bg, bg_color: tab_bg, }); - bg_vertices.push(GlyphVertex { + self.bg_vertices.push(GlyphVertex { position: [tab_right, tab_top], uv: [0.0, 0.0], color: tab_bg, bg_color: tab_bg, }); - bg_vertices.push(GlyphVertex { + self.bg_vertices.push(GlyphVertex { position: [tab_right, tab_bottom], uv: [0.0, 0.0], color: tab_bg, bg_color: tab_bg, }); - bg_vertices.push(GlyphVertex { + self.bg_vertices.push(GlyphVertex { position: [tab_left, tab_bottom], uv: [0.0, 0.0], color: tab_bg, bg_color: tab_bg, }); - bg_indices.extend_from_slice(&[ + self.bg_indices.extend_from_slice(&[ base_idx, base_idx + 1, base_idx + 2, base_idx, base_idx + 2, base_idx + 3, ]); @@ -2989,32 +2868,32 @@ impl Renderer { let top = Self::pixel_to_ndc_y(glyph_y, height); let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); - let base_idx = glyph_vertices.len() as u32; - glyph_vertices.push(GlyphVertex { + let base_idx = self.glyph_vertices.len() as u32; + self.glyph_vertices.push(GlyphVertex { position: [left, top], uv: [glyph.uv[0], glyph.uv[1]], color: tab_fg, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [right, top], uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], color: tab_fg, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [right, bottom], uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], color: tab_fg, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { + self.glyph_vertices.push(GlyphVertex { position: [left, bottom], uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], color: tab_fg, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_indices.extend_from_slice(&[ + self.glyph_indices.extend_from_slice(&[ base_idx, base_idx + 1, base_idx + 2, base_idx, base_idx + 2, base_idx + 3, ]); @@ -3026,369 +2905,125 @@ impl Renderer { } // ═══════════════════════════════════════════════════════════════════ - // RENDER TERMINAL CONTENT (all panes, offset by tab bar) + // RENDER TERMINAL CONTENT FROM TERMINAL STATE // ═══════════════════════════════════════════════════════════════════ - const LIGATURE_PATTERNS: &[&str] = &[ - "===", "!==", ">>>", "<<<", "||=", "&&=", "??=", "...", "-->", "<--", "<->", - "=>", "->", "<-", ">=", "<=", "==", "!=", "::", "&&", "||", "??", "..", "++", - "--", "<<", ">>", "|>", "<|", "/*", "*/", "//", "##", ":=", "~=", "<>", - ]; - - // Separator line color (slightly brighter than background) - let separator_color = { - let [r, g, b] = self.palette.default_bg; - let factor = 1.5_f32; - [ - Self::srgb_to_linear(((r as f32 / 255.0) * factor).min(1.0)), - Self::srgb_to_linear(((g as f32 / 255.0) * factor).min(1.0)), - Self::srgb_to_linear(((b as f32 / 255.0) * factor).min(1.0)), - 1.0, - ] + + // Get visible rows (accounts for scroll offset) + let visible_rows = terminal.visible_rows(); + + // Cache palette values to avoid borrow conflicts with rasterize_char + let palette_default_fg = self.palette.default_fg; + let palette_colors = self.palette.colors; + + // Helper to convert Color to linear RGBA (uses cached palette) + let color_to_rgba = |color: &Color, is_foreground: bool| -> [f32; 4] { + match color { + Color::Default => { + if is_foreground { + let [r, g, b] = palette_default_fg; + [ + Self::srgb_to_linear(r as f32 / 255.0), + Self::srgb_to_linear(g as f32 / 255.0), + Self::srgb_to_linear(b as f32 / 255.0), + 1.0, + ] + } else { + // Default background: transparent + [0.0, 0.0, 0.0, 0.0] + } + } + Color::Rgb(r, g, b) => [ + Self::srgb_to_linear(*r as f32 / 255.0), + Self::srgb_to_linear(*g as f32 / 255.0), + Self::srgb_to_linear(*b as f32 / 255.0), + 1.0, + ], + Color::Indexed(idx) => { + let [r, g, b] = palette_colors[*idx as usize]; + [ + Self::srgb_to_linear(r as f32 / 255.0), + Self::srgb_to_linear(g as f32 / 255.0), + Self::srgb_to_linear(b as f32 / 255.0), + 1.0, + ] + } + } }; - // Render each pane - for (pane, pane_info) in panes.iter() { - let is_active_pane = pane.pane_id == active_pane_id; + // Render each row + for (row_idx, row) in visible_rows.iter().enumerate() { + if row_idx >= rows { + break; + } - // Calculate pane position in pixels - let pane_x_offset = pane_info.x as f32 * self.cell_width; - let pane_y_offset = terminal_y_offset + pane_info.y as f32 * self.cell_height; - - for (row_idx, row) in pane.cells.iter().enumerate() { - // Skip rows outside the pane bounds - if row_idx >= pane_info.rows { + // Find the last non-empty cell in this row for selection clipping + let last_content_col = row.iter() + .enumerate() + .rev() + .find(|(_, cell)| cell.character != ' ' && cell.character != '\0') + .map(|(idx, _)| idx) + .unwrap_or(0); + + for (col_idx, cell) in row.iter().enumerate() { + if col_idx >= cols { break; } - let mut col_idx = 0; - while col_idx < row.len() && col_idx < pane_info.cols { - let cell = &row[col_idx]; - let cell_x = pane_x_offset + col_idx as f32 * self.cell_width; - let cell_y = pane_y_offset + row_idx as f32 * self.cell_height; + let cell_x = col_idx as f32 * self.cell_width; + let cell_y = terminal_y_offset + row_idx as f32 * self.cell_height; - let fg_color = self.cell_color_to_rgba(&cell.fg_color, true); - let bg_color = self.cell_color_to_rgba(&cell.bg_color, false); - - // Check for ligatures - let mut ligature_len = 0; - let mut ligature_glyph: Option = None; - - for pattern in LIGATURE_PATTERNS { - let pat_len = pattern.len(); - if col_idx + pat_len <= row.len() && col_idx + pat_len <= pane_info.cols { - let candidate: String = row[col_idx..col_idx + pat_len] - .iter() - .map(|c| c.character) - .collect(); - - if candidate == *pattern { - let shaped = self.shape_text(&candidate); - if shaped.glyphs.len() == 1 { - let glyph_id = shaped.glyphs[0].0; - ligature_glyph = Some(self.get_glyph_by_id(glyph_id)); - ligature_len = pat_len; - break; - } - } - } - } - - if let Some(glyph) = ligature_glyph { - let span_width = ligature_len as f32 * self.cell_width; - - for i in 0..ligature_len { - let bg_cell_x = pane_x_offset + (col_idx + i) as f32 * self.cell_width; - let cell_left = Self::pixel_to_ndc_x(bg_cell_x, width); - let cell_right = Self::pixel_to_ndc_x(bg_cell_x + self.cell_width, width); - let cell_top = Self::pixel_to_ndc_y(cell_y, height); - let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); - - let base_idx = bg_vertices.len() as u32; - bg_vertices.push(GlyphVertex { - position: [cell_left, cell_top], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_vertices.push(GlyphVertex { - position: [cell_right, cell_top], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_vertices.push(GlyphVertex { - position: [cell_right, cell_bottom], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_vertices.push(GlyphVertex { - position: [cell_left, cell_bottom], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_indices.extend_from_slice(&[ - base_idx, base_idx + 1, base_idx + 2, - base_idx, base_idx + 2, base_idx + 3, - ]); - } - - if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { - let baseline_y = (cell_y + self.cell_height * 0.8).round(); - let glyph_x = (cell_x + (span_width - glyph.size[0]) / 2.0 + glyph.offset[0]).round(); - let glyph_y = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); - - let left = Self::pixel_to_ndc_x(glyph_x, width); - let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); - let top = Self::pixel_to_ndc_y(glyph_y, height); - let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); - - let base_idx = glyph_vertices.len() as u32; - glyph_vertices.push(GlyphVertex { - position: [left, top], - uv: [glyph.uv[0], glyph.uv[1]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_vertices.push(GlyphVertex { - position: [right, top], - uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_vertices.push(GlyphVertex { - position: [right, bottom], - uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_vertices.push(GlyphVertex { - position: [left, bottom], - uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_indices.extend_from_slice(&[ - base_idx, base_idx + 1, base_idx + 2, - base_idx, base_idx + 2, base_idx + 3, - ]); - } - - col_idx += ligature_len; - } else { - // Single character rendering - let cell_left = Self::pixel_to_ndc_x(cell_x, width); - let cell_right = Self::pixel_to_ndc_x(cell_x + self.cell_width, width); - let cell_top = Self::pixel_to_ndc_y(cell_y, height); - let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); - - let base_idx = bg_vertices.len() as u32; - bg_vertices.push(GlyphVertex { - position: [cell_left, cell_top], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_vertices.push(GlyphVertex { - position: [cell_right, cell_top], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_vertices.push(GlyphVertex { - position: [cell_right, cell_bottom], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_vertices.push(GlyphVertex { - position: [cell_left, cell_bottom], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - bg_indices.extend_from_slice(&[ - base_idx, base_idx + 1, base_idx + 2, - base_idx, base_idx + 2, base_idx + 3, - ]); - - let c = cell.character; - if c != ' ' && c != '\0' { - let glyph = self.rasterize_char(c); - if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { - let (glyph_x, glyph_y) = if Self::is_box_drawing(c) { - (cell_x, cell_y) - } else { - let baseline_y = (cell_y + self.cell_height * 0.8).round(); - let gx = (cell_x + glyph.offset[0]).round(); - let gy = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); - (gx, gy) - }; - - let left = Self::pixel_to_ndc_x(glyph_x, width); - let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); - let top = Self::pixel_to_ndc_y(glyph_y, height); - let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); - - let base_idx = glyph_vertices.len() as u32; - glyph_vertices.push(GlyphVertex { - position: [left, top], - uv: [glyph.uv[0], glyph.uv[1]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_vertices.push(GlyphVertex { - position: [right, top], - uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_vertices.push(GlyphVertex { - position: [right, bottom], - uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_vertices.push(GlyphVertex { - position: [left, bottom], - uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - glyph_indices.extend_from_slice(&[ - base_idx, base_idx + 1, base_idx + 2, - base_idx, base_idx + 2, base_idx + 3, - ]); - } - } - - col_idx += 1; - } + let mut fg_color = color_to_rgba(&cell.fg_color, true); + let mut bg_color = color_to_rgba(&cell.bg_color, false); + + // Handle selection + if self.is_cell_selected(col_idx, row_idx) && col_idx <= last_content_col { + fg_color = [0.0, 0.0, 0.0, 1.0]; // Black foreground + bg_color = [1.0, 1.0, 1.0, 1.0]; // White background } - } - // Draw cursor only in the active pane - if is_active_pane && pane.cursor.visible { - let cursor_x = pane_x_offset + pane.cursor.col as f32 * self.cell_width; - let cursor_y = pane_y_offset + pane.cursor.row as f32 * self.cell_height; + // Cell bounds + let cell_left = Self::pixel_to_ndc_x(cell_x, width); + let cell_right = Self::pixel_to_ndc_x(cell_x + self.cell_width, width); + let cell_top = Self::pixel_to_ndc_y(cell_y, height); + let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); - // Get the cell under the cursor to determine colors - let cursor_cell = pane.cells - .get(pane.cursor.row) - .and_then(|row| row.get(pane.cursor.col)); - - // Get fg and bg colors from the cell under cursor - let (cell_fg, cell_bg, cell_char) = if let Some(cell) = cursor_cell { - let fg = self.cell_color_to_rgba(&cell.fg_color, true); - let bg = self.cell_color_to_rgba(&cell.bg_color, false); - (fg, bg, cell.character) - } else { - // Default colors if cell doesn't exist - let fg = self.cell_color_to_rgba(&CellColor::Default, true); - let bg = [0.0, 0.0, 0.0, 0.0]; - (fg, bg, ' ') - }; - - let has_character = cell_char != ' ' && cell_char != '\0'; - - // Cursor color: invert the background, or use fg if there's a character - let cursor_bg_color = if has_character { - // Character present: cursor takes fg color as background - [cell_fg[0], cell_fg[1], cell_fg[2], 1.0] - } else { - // Empty cell: invert the background color - // If bg is transparent/default, invert to white; otherwise invert RGB - if cell_bg[3] < 0.01 { - // Transparent background -> white cursor - let white = Self::srgb_to_linear(0.9); - [white, white, white, 1.0] - } else { - // Invert the background color - [1.0 - cell_bg[0], 1.0 - cell_bg[1], 1.0 - cell_bg[2], 1.0] - } - }; - - // Determine cursor bounds based on style - let (left, right, top, bottom) = match pane.cursor.style { - CursorStyle::Block => ( - cursor_x, - cursor_x + self.cell_width, - cursor_y, - cursor_y + self.cell_height, - ), - CursorStyle::Underline => { - let underline_height = 2.0_f32.max(self.cell_height * 0.1); - ( - cursor_x, - cursor_x + self.cell_width, - cursor_y + self.cell_height - underline_height, - cursor_y + self.cell_height, - ) - } - CursorStyle::Bar => { - let bar_width = 2.0_f32.max(self.cell_width * 0.1); - ( - cursor_x, - cursor_x + bar_width, - cursor_y, - cursor_y + self.cell_height, - ) - } - }; - - let cursor_left = Self::pixel_to_ndc_x(left, width); - let cursor_right = Self::pixel_to_ndc_x(right, width); - let cursor_top = Self::pixel_to_ndc_y(top, height); - let cursor_bottom = Self::pixel_to_ndc_y(bottom, height); - - let base_idx = glyph_vertices.len() as u32; - - glyph_vertices.push(GlyphVertex { - position: [cursor_left, cursor_top], + // Add background quad + let base_idx = self.bg_vertices.len() as u32; + self.bg_vertices.push(GlyphVertex { + position: [cell_left, cell_top], uv: [0.0, 0.0], - color: cursor_bg_color, - bg_color: cursor_bg_color, + color: fg_color, + bg_color, }); - glyph_vertices.push(GlyphVertex { - position: [cursor_right, cursor_top], + self.bg_vertices.push(GlyphVertex { + position: [cell_right, cell_top], uv: [0.0, 0.0], - color: cursor_bg_color, - bg_color: cursor_bg_color, + color: fg_color, + bg_color, }); - glyph_vertices.push(GlyphVertex { - position: [cursor_right, cursor_bottom], + self.bg_vertices.push(GlyphVertex { + position: [cell_right, cell_bottom], uv: [0.0, 0.0], - color: cursor_bg_color, - bg_color: cursor_bg_color, + color: fg_color, + bg_color, }); - glyph_vertices.push(GlyphVertex { - position: [cursor_left, cursor_bottom], + self.bg_vertices.push(GlyphVertex { + position: [cell_left, cell_bottom], uv: [0.0, 0.0], - color: cursor_bg_color, - bg_color: cursor_bg_color, + color: fg_color, + bg_color, }); - - glyph_indices.extend_from_slice(&[ + self.bg_indices.extend_from_slice(&[ base_idx, base_idx + 1, base_idx + 2, base_idx, base_idx + 2, base_idx + 3, ]); - // If block cursor and there's a character, re-render it with inverted color - if matches!(pane.cursor.style, CursorStyle::Block) && has_character { - // Character color: use bg color (inverted from normal) - let char_color = if cell_bg[3] < 0.01 { - // If bg was transparent, use black for the character - [0.0, 0.0, 0.0, 1.0] - } else { - [cell_bg[0], cell_bg[1], cell_bg[2], 1.0] - }; - - let glyph = self.rasterize_char(cell_char); + // Add glyph if it has content + let c = cell.character; + if c != ' ' && c != '\0' { + let glyph = self.rasterize_char(c); if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { - let cell_x = cursor_x; - let cell_y = cursor_y; - let (glyph_x, glyph_y) = if Self::is_box_drawing(cell_char) { + let (glyph_x, glyph_y) = if Self::is_box_drawing(c) { (cell_x, cell_y) } else { let baseline_y = (cell_y + self.cell_height * 0.8).round(); @@ -3397,166 +3032,208 @@ impl Renderer { (gx, gy) }; - let g_left = Self::pixel_to_ndc_x(glyph_x, width); - let g_right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); - let g_top = Self::pixel_to_ndc_y(glyph_y, height); - let g_bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); + let left = Self::pixel_to_ndc_x(glyph_x, width); + let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); + let top = Self::pixel_to_ndc_y(glyph_y, height); + let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); - let base_idx = glyph_vertices.len() as u32; - glyph_vertices.push(GlyphVertex { - position: [g_left, g_top], + let base_idx = self.glyph_vertices.len() as u32; + self.glyph_vertices.push(GlyphVertex { + position: [left, top], uv: [glyph.uv[0], glyph.uv[1]], - color: char_color, + color: fg_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { - position: [g_right, g_top], + self.glyph_vertices.push(GlyphVertex { + position: [right, top], uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], - color: char_color, + color: fg_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { - position: [g_right, g_bottom], + self.glyph_vertices.push(GlyphVertex { + position: [right, bottom], uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], - color: char_color, + color: fg_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { - position: [g_left, g_bottom], + self.glyph_vertices.push(GlyphVertex { + position: [left, bottom], uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], - color: char_color, + color: fg_color, bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_indices.extend_from_slice(&[ + self.glyph_indices.extend_from_slice(&[ base_idx, base_idx + 1, base_idx + 2, base_idx, base_idx + 2, base_idx + 3, ]); } } } - } // ═══════════════════════════════════════════════════════════════════ - // DRAW PANE SEPARATORS (only between adjacent panes, sized to the pane bounds) + // RENDER CURSOR // ═══════════════════════════════════════════════════════════════════ - if panes.len() > 1 { - let separator_thickness = 1.0_f32; + // Only show cursor when viewing live terminal (not scrolled into history) + if terminal.cursor_visible && terminal.scroll_offset == 0 + && terminal.cursor_row < rows && terminal.cursor_col < cols { + let cursor_col = terminal.cursor_col; + let cursor_row = terminal.cursor_row; + let cursor_x = cursor_col as f32 * self.cell_width; + let cursor_y = terminal_y_offset + cursor_row as f32 * self.cell_height; + + // Get cell under cursor + let cursor_cell = visible_rows.get(cursor_row).and_then(|row| row.get(cursor_col)); - for (_, pane_info) in panes.iter() { - let pane_left_x = pane_info.x as f32 * self.cell_width; - let pane_right_x = pane_left_x + pane_info.cols as f32 * self.cell_width; - let pane_top_y = terminal_y_offset + pane_info.y as f32 * self.cell_height; - let pane_bottom_y = pane_top_y + pane_info.rows as f32 * self.cell_height; - - // Check if there's a pane directly to the right (draw vertical separator on right edge) - let has_pane_right = panes.iter().any(|(_, other)| { - let other_left_x = other.x as f32 * self.cell_width; - let other_top_y = terminal_y_offset + other.y as f32 * self.cell_height; - let other_bottom_y = other_top_y + other.rows as f32 * self.cell_height; - // Other pane starts at our right edge and overlaps vertically - (other_left_x - pane_right_x).abs() < 2.0 - && other_top_y < pane_bottom_y - && other_bottom_y > pane_top_y - }); - - if has_pane_right { - // Draw vertical separator at right edge, spanning this pane's height - // Extend to window edges if this pane is at top/bottom - let sep_top = if pane_info.y == 0 { terminal_y_offset } else { pane_top_y }; - let terminal_bottom = height - if matches!(self.tab_bar_position, TabBarPosition::Bottom) { tab_bar_height } else { 0.0 }; - let max_pane_bottom: f32 = panes.iter() - .map(|(_, p)| terminal_y_offset + p.y as f32 * self.cell_height + p.rows as f32 * self.cell_height) - .fold(0.0_f32, |a, b| a.max(b)); - let sep_bottom = if (pane_bottom_y - max_pane_bottom).abs() < 2.0 { terminal_bottom } else { pane_bottom_y }; - - let sep_left_ndc = Self::pixel_to_ndc_x(pane_right_x, width); - let sep_right_ndc = Self::pixel_to_ndc_x(pane_right_x + separator_thickness, width); - let sep_top_ndc = Self::pixel_to_ndc_y(sep_top, height); - let sep_bottom_ndc = Self::pixel_to_ndc_y(sep_bottom, height); + let (cell_fg, cell_bg, cell_char) = if let Some(cell) = cursor_cell { + let fg = color_to_rgba(&cell.fg_color, true); + let bg = color_to_rgba(&cell.bg_color, false); + (fg, bg, cell.character) + } else { + let fg = { + let [r, g, b] = self.palette.default_fg; + [ + Self::srgb_to_linear(r as f32 / 255.0), + Self::srgb_to_linear(g as f32 / 255.0), + Self::srgb_to_linear(b as f32 / 255.0), + 1.0, + ] + }; + (fg, [0.0, 0.0, 0.0, 0.0], ' ') + }; - let base_idx = glyph_vertices.len() as u32; - glyph_vertices.push(GlyphVertex { - position: [sep_left_ndc, sep_top_ndc], - uv: [0.0, 0.0], - color: separator_color, - bg_color: separator_color, - }); - glyph_vertices.push(GlyphVertex { - position: [sep_right_ndc, sep_top_ndc], - uv: [0.0, 0.0], - color: separator_color, - bg_color: separator_color, - }); - glyph_vertices.push(GlyphVertex { - position: [sep_right_ndc, sep_bottom_ndc], - uv: [0.0, 0.0], - color: separator_color, - bg_color: separator_color, - }); - glyph_vertices.push(GlyphVertex { - position: [sep_left_ndc, sep_bottom_ndc], - uv: [0.0, 0.0], - color: separator_color, - bg_color: separator_color, - }); - glyph_indices.extend_from_slice(&[ - base_idx, base_idx + 1, base_idx + 2, - base_idx, base_idx + 2, base_idx + 3, - ]); + let has_character = cell_char != ' ' && cell_char != '\0'; + + let cursor_bg_color = if has_character { + [cell_fg[0], cell_fg[1], cell_fg[2], 1.0] + } else { + if cell_bg[3] < 0.01 { + let white = Self::srgb_to_linear(0.9); + [white, white, white, 1.0] + } else { + [1.0 - cell_bg[0], 1.0 - cell_bg[1], 1.0 - cell_bg[2], 1.0] } - - // Check if there's a pane directly below (draw horizontal separator on bottom edge) - let has_pane_below = panes.iter().any(|(_, other)| { - let other_left_x = other.x as f32 * self.cell_width; - let other_right_x = other_left_x + other.cols as f32 * self.cell_width; - let other_top_y = terminal_y_offset + other.y as f32 * self.cell_height; - // Other pane starts at our bottom edge and overlaps horizontally - (other_top_y - pane_bottom_y).abs() < 2.0 - && other_left_x < pane_right_x - && other_right_x > pane_left_x - }); - - if has_pane_below { - // Draw horizontal separator at bottom edge, spanning this pane's width - // Extend to window edges if this pane is at left/right - let sep_left = if pane_info.x == 0 { 0.0 } else { pane_left_x }; - let max_pane_right: f32 = panes.iter() - .map(|(_, p)| p.x as f32 * self.cell_width + p.cols as f32 * self.cell_width) - .fold(0.0_f32, |a, b| a.max(b)); - let sep_right = if (pane_right_x - max_pane_right).abs() < 2.0 { width } else { pane_right_x }; - - let sep_left_ndc = Self::pixel_to_ndc_x(sep_left, width); - let sep_right_ndc = Self::pixel_to_ndc_x(sep_right, width); - let sep_top_ndc = Self::pixel_to_ndc_y(pane_bottom_y, height); - let sep_bottom_ndc = Self::pixel_to_ndc_y(pane_bottom_y + separator_thickness, height); + }; - let base_idx = glyph_vertices.len() as u32; - glyph_vertices.push(GlyphVertex { - position: [sep_left_ndc, sep_top_ndc], - uv: [0.0, 0.0], - color: separator_color, - bg_color: separator_color, + // Convert cursor shape to style + let cursor_style = match terminal.cursor_shape { + CursorShape::BlinkingBlock | CursorShape::SteadyBlock => 0, + CursorShape::BlinkingUnderline | CursorShape::SteadyUnderline => 1, + CursorShape::BlinkingBar | CursorShape::SteadyBar => 2, + }; + + let (left, right, top, bottom) = match cursor_style { + 0 => ( // Block + cursor_x, + cursor_x + self.cell_width, + cursor_y, + cursor_y + self.cell_height, + ), + 1 => { // Underline + let underline_height = 2.0_f32.max(self.cell_height * 0.1); + ( + cursor_x, + cursor_x + self.cell_width, + cursor_y + self.cell_height - underline_height, + cursor_y + self.cell_height, + ) + } + _ => { // Bar + let bar_width = 2.0_f32.max(self.cell_width * 0.1); + ( + cursor_x, + cursor_x + bar_width, + cursor_y, + cursor_y + self.cell_height, + ) + } + }; + + let cursor_left = Self::pixel_to_ndc_x(left, width); + let cursor_right = Self::pixel_to_ndc_x(right, width); + let cursor_top = Self::pixel_to_ndc_y(top, height); + let cursor_bottom = Self::pixel_to_ndc_y(bottom, height); + + let base_idx = self.glyph_vertices.len() as u32; + self.glyph_vertices.push(GlyphVertex { + position: [cursor_left, cursor_top], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + self.glyph_vertices.push(GlyphVertex { + position: [cursor_right, cursor_top], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + self.glyph_vertices.push(GlyphVertex { + position: [cursor_right, cursor_bottom], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + self.glyph_vertices.push(GlyphVertex { + position: [cursor_left, cursor_bottom], + uv: [0.0, 0.0], + color: cursor_bg_color, + bg_color: cursor_bg_color, + }); + self.glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + + // If block cursor and there's a character, render it inverted + if cursor_style == 0 && has_character { + let char_color = if cell_bg[3] < 0.01 { + [0.0, 0.0, 0.0, 1.0] + } else { + [cell_bg[0], cell_bg[1], cell_bg[2], 1.0] + }; + + let glyph = self.rasterize_char(cell_char); + if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { + let (glyph_x, glyph_y) = if Self::is_box_drawing(cell_char) { + (cursor_x, cursor_y) + } else { + let baseline_y = (cursor_y + self.cell_height * 0.8).round(); + let gx = (cursor_x + glyph.offset[0]).round(); + let gy = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); + (gx, gy) + }; + + let g_left = Self::pixel_to_ndc_x(glyph_x, width); + let g_right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); + let g_top = Self::pixel_to_ndc_y(glyph_y, height); + let g_bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); + + let base_idx = self.glyph_vertices.len() as u32; + self.glyph_vertices.push(GlyphVertex { + position: [g_left, g_top], + uv: [glyph.uv[0], glyph.uv[1]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { - position: [sep_right_ndc, sep_top_ndc], - uv: [0.0, 0.0], - color: separator_color, - bg_color: separator_color, + self.glyph_vertices.push(GlyphVertex { + position: [g_right, g_top], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { - position: [sep_right_ndc, sep_bottom_ndc], - uv: [0.0, 0.0], - color: separator_color, - bg_color: separator_color, + self.glyph_vertices.push(GlyphVertex { + position: [g_right, g_bottom], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_vertices.push(GlyphVertex { - position: [sep_left_ndc, sep_bottom_ndc], - uv: [0.0, 0.0], - color: separator_color, - bg_color: separator_color, + self.glyph_vertices.push(GlyphVertex { + position: [g_left, g_bottom], + uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], + color: char_color, + bg_color: [0.0, 0.0, 0.0, 0.0], }); - glyph_indices.extend_from_slice(&[ + self.glyph_indices.extend_from_slice(&[ base_idx, base_idx + 1, base_idx + 2, base_idx, base_idx + 2, base_idx + 3, ]); @@ -3564,17 +3241,16 @@ impl Renderer { } } - // Combine vertices - let mut vertices = bg_vertices; - let mut indices = bg_indices; - - let glyph_vertex_offset = vertices.len() as u32; - vertices.extend(glyph_vertices); - indices.extend(glyph_indices.iter().map(|i| i + glyph_vertex_offset)); + // ═══════════════════════════════════════════════════════════════════ + // SUBMIT TO GPU + // ═══════════════════════════════════════════════════════════════════ + let bg_vertex_count = self.bg_vertices.len(); + let total_vertex_count = bg_vertex_count + self.glyph_vertices.len(); + let total_index_count = self.bg_indices.len() + self.glyph_indices.len(); // Resize buffers if needed - if vertices.len() > self.vertex_capacity { - self.vertex_capacity = vertices.len() * 2; + if total_vertex_count > self.vertex_capacity { + self.vertex_capacity = total_vertex_count * 2; self.vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("Glyph Vertex Buffer"), size: (self.vertex_capacity * std::mem::size_of::()) as u64, @@ -3583,8 +3259,8 @@ impl Renderer { }); } - if indices.len() > self.index_capacity { - self.index_capacity = indices.len() * 2; + if total_index_count > self.index_capacity { + self.index_capacity = total_index_count * 2; self.index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("Glyph Index Buffer"), size: (self.index_capacity * std::mem::size_of::()) as u64, @@ -3593,9 +3269,30 @@ impl Renderer { }); } - // Upload data - self.queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&vertices)); - self.queue.write_buffer(&self.index_buffer, 0, bytemuck::cast_slice(&indices)); + // Upload vertices + self.queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.bg_vertices)); + self.queue.write_buffer( + &self.vertex_buffer, + (bg_vertex_count * std::mem::size_of::()) as u64, + bytemuck::cast_slice(&self.glyph_vertices), + ); + + // Upload indices + self.queue.write_buffer(&self.index_buffer, 0, bytemuck::cast_slice(&self.bg_indices)); + + let glyph_vertex_offset = bg_vertex_count as u32; + let bg_index_bytes = self.bg_indices.len() * std::mem::size_of::(); + + if !self.glyph_indices.is_empty() { + let adjusted_indices: Vec = self.glyph_indices.iter() + .map(|i| i + glyph_vertex_offset) + .collect(); + self.queue.write_buffer( + &self.index_buffer, + bg_index_bytes as u64, + bytemuck::cast_slice(&adjusted_indices), + ); + } if self.atlas_dirty { self.queue.write_texture( @@ -3655,7 +3352,346 @@ impl Renderer { render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32); - render_pass.draw_indexed(0..indices.len() as u32, 0, 0..1); + render_pass.draw_indexed(0..total_index_count as u32, 0, 0..1); + } + + self.queue.submit(std::iter::once(encoder.finish())); + output.present(); + + Ok(()) + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // KITTY-STYLE INSTANCED RENDERING HELPER METHODS + // ═══════════════════════════════════════════════════════════════════════════════ + + /// Update the color table uniform buffer with the current palette. + /// The color table contains 258 colors: 256 indexed colors + default fg (256) + default bg (257). + fn update_color_table(&mut self, palette: &ColorPalette) { + let Some(ref buffer) = self.color_table_buffer else { + return; + }; + + let mut color_table = ColorTableUniform::default(); + + // Fill 256 indexed colors + for i in 0..256 { + let [r, g, b] = palette.colors[i]; + color_table.colors[i] = [ + r as f32 / 255.0, + g as f32 / 255.0, + b as f32 / 255.0, + 1.0, + ]; + } + + // Default foreground at index 256 + let [fg_r, fg_g, fg_b] = palette.default_fg; + color_table.colors[256] = [ + fg_r as f32 / 255.0, + fg_g as f32 / 255.0, + fg_b as f32 / 255.0, + 1.0, + ]; + + // Default background at index 257 + let [bg_r, bg_g, bg_b] = palette.default_bg; + color_table.colors[257] = [ + bg_r as f32 / 255.0, + bg_g as f32 / 255.0, + bg_b as f32 / 255.0, + 1.0, + ]; + + self.queue.write_buffer(buffer, 0, bytemuck::bytes_of(&color_table)); + } + + /// Get or create a sprite index for a character. + /// Returns the sprite index, or 0 if the character has no visible glyph. + fn get_or_create_sprite(&mut self, c: char) -> u32 { + // Check cache first + if let Some(&idx) = self.char_to_sprite.get(&c) { + return idx; + } + + // Space and control characters have no visible glyph + if c == ' ' || c == '\0' || c.is_control() { + self.char_to_sprite.insert(c, 0); + return 0; + } + + // Rasterize the character to get its glyph info + let glyph_info = self.rasterize_char(c); + + // If the glyph has no visible pixels, return 0 + if glyph_info.size[0] <= 0.0 || glyph_info.size[1] <= 0.0 { + self.char_to_sprite.insert(c, 0); + return 0; + } + + // Assign a new sprite index + let sprite_idx = self.next_sprite_idx; + self.next_sprite_idx += 1; + + // Ensure we have capacity in the sprite_info vector + while self.sprite_info.len() <= sprite_idx as usize { + self.sprite_info.push(SpriteInfo::default()); + } + + // Store sprite info + self.sprite_info[sprite_idx as usize] = SpriteInfo { + uv: glyph_info.uv, + offset: glyph_info.offset, + size: glyph_info.size, + }; + + // Cache the mapping + self.char_to_sprite.insert(c, sprite_idx); + + sprite_idx + } + + /// Ensure the cell buffer has enough capacity for the given number of cells. + fn ensure_cell_buffer_capacity(&mut self, num_cells: usize) { + if num_cells <= self.cell_buffer_capacity { + return; + } + + // Grow by 2x or to the required size, whichever is larger + let new_capacity = (self.cell_buffer_capacity * 2).max(num_cells); + + let new_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Cell Storage Buffer"), + size: (new_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + self.cell_buffer = Some(new_buffer); + self.cell_buffer_capacity = new_capacity; + + // Recreate bind group with new buffer + self.recreate_cell_bind_group(); + } + + /// Ensure the sprite buffer has enough capacity. + fn ensure_sprite_buffer_capacity(&mut self, num_sprites: usize) { + if num_sprites <= self.sprite_buffer_capacity { + return; + } + + let new_capacity = (self.sprite_buffer_capacity * 2).max(num_sprites); + + let new_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Sprite Storage Buffer"), + size: (new_capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + self.sprite_buffer = Some(new_buffer); + self.sprite_buffer_capacity = new_capacity; + + // Recreate bind group with new buffer + self.recreate_cell_bind_group(); + } + + /// Recreate the cell bind group after buffer reallocation. + fn recreate_cell_bind_group(&mut self) { + let Some(ref layout) = self.cell_bind_group_layout else { + return; + }; + let Some(ref color_table_buffer) = self.color_table_buffer else { + return; + }; + let Some(ref grid_params_buffer) = self.grid_params_buffer else { + return; + }; + let Some(ref cell_buffer) = self.cell_buffer else { + return; + }; + let Some(ref sprite_buffer) = self.sprite_buffer else { + return; + }; + + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Cell Bind Group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: color_table_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: grid_params_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: cell_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: sprite_buffer.as_entire_binding(), + }, + ], + }); + + self.cell_bind_group = Some(bind_group); + } + + /// Render using Kitty-style instanced rendering. + /// This is the new high-performance rendering path. + pub fn render_instanced(&mut self, terminal: &mut Terminal) -> Result<(), wgpu::SurfaceError> { + // Early return if instanced rendering is not set up + if !self.use_instanced_rendering { + return self.render(terminal); + } + + let output = self.surface.get_current_texture()?; + let view = output.texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let (cols, rows) = self.terminal_size(); + let num_cells = cols * rows; + + // Ensure buffers are large enough + self.ensure_cell_buffer_capacity(num_cells); + self.ensure_sprite_buffer_capacity(self.next_sprite_idx as usize + 256); + + // Update color table from palette + self.update_color_table(&terminal.palette); + + // Build GPU cells array + let mut gpu_cells = Vec::with_capacity(num_cells); + for row_idx in 0..rows.min(terminal.grid.len()) { + let row = &terminal.grid[row_idx]; + for col_idx in 0..cols { + if col_idx < row.len() { + let cell = &row[col_idx]; + let sprite_idx = self.get_or_create_sprite(cell.character); + gpu_cells.push(GPUCell::from_cell(cell, sprite_idx)); + } else { + gpu_cells.push(GPUCell::empty()); + } + } + } + // Fill remaining rows with empty cells + while gpu_cells.len() < num_cells { + gpu_cells.push(GPUCell::empty()); + } + + // Upload cell data + if let Some(ref buffer) = self.cell_buffer { + self.queue.write_buffer(buffer, 0, bytemuck::cast_slice(&gpu_cells)); + } + + // Upload sprite info + if let Some(ref buffer) = self.sprite_buffer { + self.queue.write_buffer(buffer, 0, bytemuck::cast_slice(&self.sprite_info)); + } + + // Update grid params + let grid_params = GridParamsUniform { + cols: cols as u32, + rows: rows as u32, + cell_width: self.cell_width, + cell_height: self.cell_height, + screen_width: self.width as f32, + screen_height: self.height as f32, + y_offset: self.terminal_y_offset(), + cursor_col: if terminal.cursor_visible { terminal.cursor_col as i32 } else { -1 }, + cursor_row: if terminal.cursor_visible { terminal.cursor_row as i32 } else { -1 }, + cursor_style: match terminal.cursor_shape { + CursorShape::BlinkingBlock | CursorShape::SteadyBlock => 0, + CursorShape::BlinkingUnderline | CursorShape::SteadyUnderline => 1, + CursorShape::BlinkingBar | CursorShape::SteadyBar => 2, + }, + _padding: [0, 0], + }; + + if let Some(ref buffer) = self.grid_params_buffer { + self.queue.write_buffer(buffer, 0, bytemuck::bytes_of(&grid_params)); + } + + // Upload atlas if dirty + if self.atlas_dirty { + self.queue.write_texture( + wgpu::ImageCopyTexture { + texture: &self.atlas_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &self.atlas_data, + wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(ATLAS_SIZE), + rows_per_image: Some(ATLAS_SIZE), + }, + wgpu::Extent3d { + width: ATLAS_SIZE, + height: ATLAS_SIZE, + depth_or_array_layers: 1, + }, + ); + self.atlas_dirty = false; + } + + // Render + let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Instanced Render Encoder"), + }); + + { + // Clear with background color + let [bg_r, bg_g, bg_b] = terminal.palette.default_bg; + let bg_r_linear = Self::srgb_to_linear(bg_r as f32 / 255.0) as f64; + let bg_g_linear = Self::srgb_to_linear(bg_g as f32 / 255.0) as f64; + let bg_b_linear = Self::srgb_to_linear(bg_b as f32 / 255.0) as f64; + let bg_alpha = self.background_opacity as f64; + + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Instanced Render Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: bg_r_linear, + g: bg_g_linear, + b: bg_b_linear, + a: bg_alpha, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + }); + + // Get references to avoid borrow issues + let cell_bg_pipeline = self.cell_bg_pipeline.as_ref(); + let cell_glyph_pipeline = self.cell_glyph_pipeline.as_ref(); + let glyph_bind_group = &self.glyph_bind_group; + let cell_bind_group = self.cell_bind_group.as_ref(); + let quad_index_buffer = self.quad_index_buffer.as_ref(); + + if let (Some(bg_pipeline), Some(glyph_pipeline), Some(cell_bg), Some(idx_buf)) = + (cell_bg_pipeline, cell_glyph_pipeline, cell_bind_group, quad_index_buffer) + { + // Pass 1: Render backgrounds + render_pass.set_pipeline(bg_pipeline); + render_pass.set_bind_group(0, glyph_bind_group, &[]); + render_pass.set_bind_group(1, cell_bg, &[]); + render_pass.set_index_buffer(idx_buf.slice(..), wgpu::IndexFormat::Uint16); + render_pass.draw_indexed(0..6, 0, 0..num_cells as u32); + + // Pass 2: Render glyphs + render_pass.set_pipeline(glyph_pipeline); + // Bind groups already set + render_pass.draw_indexed(0..6, 0, 0..num_cells as u32); + } } self.queue.submit(std::iter::once(encoder.finish())); diff --git a/src/session.rs b/src/session.rs deleted file mode 100644 index b497a6a..0000000 --- a/src/session.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Terminal session management. -//! -//! A Session owns a PTY and its associated terminal state. - -use crate::protocol::{CellColor, CursorInfo, CursorStyle, PaneSnapshot, RenderCell, SessionId}; -use crate::pty::Pty; -use crate::terminal::{Cell, Color, ColorPalette, CursorShape, Terminal}; -use vte::Parser; - -/// A terminal session with its PTY and state. -pub struct Session { - /// Unique session identifier. - pub id: SessionId, - /// The PTY connected to the shell. - pub pty: Pty, - /// Terminal state (grid, cursor, colors, etc.). - pub terminal: Terminal, - /// VTE parser for this session. - parser: Parser, - /// Whether the session has new output to send. - pub dirty: bool, -} - -impl Session { - /// Creates a new session with the given dimensions. - pub fn new(id: SessionId, cols: usize, rows: usize) -> Result { - let pty = Pty::spawn(None)?; - pty.resize(cols as u16, rows as u16)?; - - let terminal = Terminal::new(cols, rows); - - Ok(Self { - id, - pty, - terminal, - parser: Parser::new(), - dirty: true, - }) - } - - /// Reads available data from the PTY and processes it. - /// Returns the number of bytes read. - pub fn poll(&mut self, buffer: &mut [u8]) -> Result { - let mut total = 0; - - loop { - match self.pty.read(buffer) { - Ok(0) => break, // WOULDBLOCK or no data - Ok(n) => { - self.terminal.process(&buffer[..n], &mut self.parser); - total += n; - self.dirty = true; - } - Err(e) => return Err(e), - } - } - - Ok(total) - } - - /// Writes data to the PTY (keyboard input). - pub fn write(&self, data: &[u8]) -> Result { - self.pty.write(data) - } - - /// Resizes the session. - pub fn resize(&mut self, cols: usize, rows: usize) { - self.terminal.resize(cols, rows); - let _ = self.pty.resize(cols as u16, rows as u16); - self.dirty = true; - } - - /// Gets any pending response from the terminal (e.g., query responses). - pub fn take_response(&mut self) -> Option> { - self.terminal.take_response() - } - - /// Converts internal Color to protocol CellColor, resolving indexed colors using the palette. - fn convert_color(color: &Color, palette: &ColorPalette) -> CellColor { - match color { - Color::Default => CellColor::Default, - Color::Rgb(r, g, b) => CellColor::Rgb(*r, *g, *b), - Color::Indexed(i) => { - // Resolve indexed colors to RGB using the palette - let [r, g, b] = palette.colors[*i as usize]; - CellColor::Rgb(r, g, b) - } - } - } - - /// Converts internal Cell to protocol RenderCell. - fn convert_cell(cell: &Cell, palette: &ColorPalette) -> RenderCell { - RenderCell { - character: cell.character, - fg_color: Self::convert_color(&cell.fg_color, palette), - bg_color: Self::convert_color(&cell.bg_color, palette), - bold: cell.bold, - italic: cell.italic, - underline: cell.underline, - } - } - - /// Creates a snapshot of the terminal state for sending to client. - pub fn snapshot(&self, pane_id: u32) -> PaneSnapshot { - let palette = &self.terminal.palette; - - // Use visible_rows() which accounts for scroll offset - let cells: Vec> = self.terminal.visible_rows() - .iter() - .map(|row| row.iter().map(|cell| Self::convert_cell(cell, palette)).collect()) - .collect(); - - // Convert terminal cursor shape to protocol cursor style - let cursor_style = match self.terminal.cursor_shape { - CursorShape::BlinkingBlock | CursorShape::SteadyBlock => CursorStyle::Block, - CursorShape::BlinkingUnderline | CursorShape::SteadyUnderline => CursorStyle::Underline, - CursorShape::BlinkingBar | CursorShape::SteadyBar => CursorStyle::Bar, - }; - - // When scrolled back, adjust cursor row to account for offset - // and hide cursor if it's not visible in the viewport - let (cursor_row, cursor_visible) = if self.terminal.scroll_offset > 0 { - // Cursor is at the "live" position, but we're viewing history - // The cursor should appear scroll_offset rows lower, or be hidden - let adjusted_row = self.terminal.cursor_row + self.terminal.scroll_offset; - if adjusted_row >= self.terminal.rows { - // Cursor is not in visible area - (0, false) - } else { - (adjusted_row, self.terminal.cursor_visible) - } - } else { - (self.terminal.cursor_row, self.terminal.cursor_visible) - }; - - PaneSnapshot { - pane_id, - cells, - cursor: CursorInfo { - col: self.terminal.cursor_col, - row: cursor_row, - visible: cursor_visible, - style: cursor_style, - }, - scroll_offset: self.terminal.scroll_offset, - scrollback_len: self.terminal.scrollback.len(), - } - } - - /// Returns the raw file descriptor for polling. - pub fn fd(&self) -> std::os::fd::BorrowedFd<'_> { - self.pty.master_fd() - } - - /// Marks the session as clean (updates sent). - pub fn mark_clean(&mut self) { - self.dirty = false; - self.terminal.dirty = false; - } -} diff --git a/src/terminal.rs b/src/terminal.rs index 48216cd..1b1af80 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -1,10 +1,10 @@ //! Terminal state management and escape sequence handling. use crate::keyboard::{query_response, KeyboardState}; -use vte::{Params, Parser, Perform}; +use crate::vt_parser::{CsiParams, Handler, Parser}; /// A single cell in the terminal grid. -#[derive(Clone, Debug)] +#[derive(Clone, Copy, Debug)] pub struct Cell { pub character: char, pub fg_color: Color, @@ -28,8 +28,9 @@ impl Default for Cell { } /// Terminal colors. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Default)] pub enum Color { + #[default] Default, Rgb(u8, u8, u8), Indexed(u8), @@ -53,7 +54,38 @@ pub enum CursorShape { SteadyBar, } +/// Mouse tracking mode - determines what mouse events are reported to the application. +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum MouseTrackingMode { + /// No mouse tracking (terminal handles selection). + #[default] + None, + /// X10 compatibility mode - only report button press (mode 9). + X10, + /// Normal tracking mode - report button press and release (mode 1000). + Normal, + /// Button-event tracking - report press, release, and motion while button pressed (mode 1002). + ButtonEvent, + /// Any-event tracking - report all motion events (mode 1003). + AnyEvent, +} + +/// Mouse encoding format - how mouse events are encoded. +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum MouseEncoding { + /// Default X10 encoding (limited to 223 rows/cols). + #[default] + X10, + /// UTF-8 encoding (mode 1005) - deprecated, rarely used. + Utf8, + /// SGR extended encoding (mode 1006) - most common modern format. + Sgr, + /// URXVT encoding (mode 1015) - rarely used. + Urxvt, +} + /// Color palette with 256 colors + default fg/bg. +#[derive(Clone)] pub struct ColorPalette { /// 256 indexed colors (ANSI 0-15 + 216 color cube + 24 grayscale). pub colors: [[u8; 3]; 256], @@ -112,6 +144,292 @@ impl Default for ColorPalette { } } +/// Packed color value for GPU transfer (Kitty-style encoding). +/// Layout: type in low 8 bits, RGB value in upper 24 bits. +/// - Type 0: Default (use color table entries 256/257 for fg/bg) +/// - Type 1: Indexed (index in bits 8-15, look up in color table) +/// - Type 2: RGB (R in bits 8-15, G in bits 16-23, B in bits 24-31) +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(transparent)] +pub struct PackedColor(pub u32); + +impl PackedColor { + /// Color type: default (resolved from color table) + pub const TYPE_DEFAULT: u8 = 0; + /// Color type: indexed (look up in 256-color palette) + pub const TYPE_INDEXED: u8 = 1; + /// Color type: direct RGB + pub const TYPE_RGB: u8 = 2; + + /// Create a default color (resolved at render time from palette). + #[inline] + pub const fn default_color() -> Self { + Self(Self::TYPE_DEFAULT as u32) + } + + /// Create an indexed color (0-255 palette index). + #[inline] + pub const fn indexed(index: u8) -> Self { + Self(Self::TYPE_INDEXED as u32 | ((index as u32) << 8)) + } + + /// Create a direct RGB color. + #[inline] + pub const fn rgb(r: u8, g: u8, b: u8) -> Self { + Self(Self::TYPE_RGB as u32 | ((r as u32) << 8) | ((g as u32) << 16) | ((b as u32) << 24)) + } + + /// Get the color type. + #[inline] + pub const fn color_type(self) -> u8 { + (self.0 & 0xFF) as u8 + } + + /// Get the index for indexed colors. + #[inline] + pub const fn index(self) -> u8 { + ((self.0 >> 8) & 0xFF) as u8 + } + + /// Get RGB components for RGB colors. + #[inline] + pub const fn rgb_components(self) -> (u8, u8, u8) { + ( + ((self.0 >> 8) & 0xFF) as u8, + ((self.0 >> 16) & 0xFF) as u8, + ((self.0 >> 24) & 0xFF) as u8, + ) + } +} + +impl From for PackedColor { + fn from(color: Color) -> Self { + match color { + Color::Default => Self::default_color(), + Color::Indexed(idx) => Self::indexed(idx), + Color::Rgb(r, g, b) => Self::rgb(r, g, b), + } + } +} + +impl From<&Color> for PackedColor { + fn from(color: &Color) -> Self { + (*color).into() + } +} + +/// Packed cell attributes for GPU transfer (Kitty-style). +/// Layout (32-bit bitfield): +/// - bits 0-2: decoration (underline style, 0=none, 1=single, 2=double, 3=curly, etc.) +/// - bit 3: bold +/// - bit 4: italic +/// - bit 5: reverse +/// - bit 6: strike +/// - bit 7: dim +/// - bits 8-31: reserved for future use +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[repr(transparent)] +pub struct CellAttrs(pub u32); + +impl CellAttrs { + pub const DECORATION_MASK: u32 = 0b111; + pub const BOLD_BIT: u32 = 1 << 3; + pub const ITALIC_BIT: u32 = 1 << 4; + pub const REVERSE_BIT: u32 = 1 << 5; + pub const STRIKE_BIT: u32 = 1 << 6; + pub const DIM_BIT: u32 = 1 << 7; + + /// Decoration values + pub const DECO_NONE: u32 = 0; + pub const DECO_SINGLE: u32 = 1; + pub const DECO_DOUBLE: u32 = 2; + pub const DECO_CURLY: u32 = 3; + pub const DECO_DOTTED: u32 = 4; + pub const DECO_DASHED: u32 = 5; + + #[inline] + pub const fn new() -> Self { + Self(0) + } + + #[inline] + pub const fn with_underline(self, style: u32) -> Self { + Self((self.0 & !Self::DECORATION_MASK) | (style & Self::DECORATION_MASK)) + } + + #[inline] + pub const fn with_bold(self, bold: bool) -> Self { + if bold { + Self(self.0 | Self::BOLD_BIT) + } else { + Self(self.0 & !Self::BOLD_BIT) + } + } + + #[inline] + pub const fn with_italic(self, italic: bool) -> Self { + if italic { + Self(self.0 | Self::ITALIC_BIT) + } else { + Self(self.0 & !Self::ITALIC_BIT) + } + } + + #[inline] + pub const fn with_reverse(self, reverse: bool) -> Self { + if reverse { + Self(self.0 | Self::REVERSE_BIT) + } else { + Self(self.0 & !Self::REVERSE_BIT) + } + } + + #[inline] + pub const fn with_strike(self, strike: bool) -> Self { + if strike { + Self(self.0 | Self::STRIKE_BIT) + } else { + Self(self.0 & !Self::STRIKE_BIT) + } + } + + #[inline] + pub const fn with_dim(self, dim: bool) -> Self { + if dim { + Self(self.0 | Self::DIM_BIT) + } else { + Self(self.0 & !Self::DIM_BIT) + } + } + + #[inline] + pub const fn decoration(self) -> u32 { + self.0 & Self::DECORATION_MASK + } + + #[inline] + pub const fn is_bold(self) -> bool { + (self.0 & Self::BOLD_BIT) != 0 + } + + #[inline] + pub const fn is_italic(self) -> bool { + (self.0 & Self::ITALIC_BIT) != 0 + } + + #[inline] + pub const fn is_reverse(self) -> bool { + (self.0 & Self::REVERSE_BIT) != 0 + } + + #[inline] + pub const fn is_strike(self) -> bool { + (self.0 & Self::STRIKE_BIT) != 0 + } + + #[inline] + pub const fn is_dim(self) -> bool { + (self.0 & Self::DIM_BIT) != 0 + } +} + +/// GPU cell data for instanced rendering (Kitty-style). +/// +/// This struct is uploaded directly to the GPU for each cell. +/// The shader uses instanced rendering where each cell is one instance. +/// +/// Layout: 20 bytes total +/// - fg: 4 bytes (packed color) +/// - bg: 4 bytes (packed color) +/// - decoration_fg: 4 bytes (packed color for underline/strikethrough) +/// - sprite_idx: 4 bytes (glyph atlas index, bit 31 = colored glyph flag) +/// - attrs: 4 bytes (packed attributes) +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)] +pub struct GPUCell { + /// Foreground color (packed) + pub fg: u32, + /// Background color (packed) + pub bg: u32, + /// Decoration color for underline/strikethrough (packed) + pub decoration_fg: u32, + /// Sprite index in glyph atlas (bit 31 = colored glyph flag) + pub sprite_idx: u32, + /// Packed attributes (bold, italic, underline style, etc.) + pub attrs: u32, +} + +impl GPUCell { + /// Flag indicating this glyph is colored (e.g., emoji) and should not be tinted + pub const COLORED_GLYPH_FLAG: u32 = 1 << 31; + /// Sprite index indicating no glyph (space/empty) + pub const NO_GLYPH: u32 = 0; + + /// Create an empty cell (space with default colors) + #[inline] + pub const fn empty() -> Self { + Self { + fg: PackedColor::TYPE_DEFAULT as u32, + bg: PackedColor::TYPE_DEFAULT as u32, + decoration_fg: PackedColor::TYPE_DEFAULT as u32, + sprite_idx: Self::NO_GLYPH, + attrs: 0, + } + } + + /// Create a GPUCell from terminal Cell and a sprite index + #[inline] + pub fn from_cell(cell: &Cell, sprite_idx: u32) -> Self { + let fg = PackedColor::from(&cell.fg_color); + let bg = PackedColor::from(&cell.bg_color); + + let mut attrs = CellAttrs::new(); + if cell.bold { + attrs = attrs.with_bold(true); + } + if cell.italic { + attrs = attrs.with_italic(true); + } + if cell.underline { + attrs = attrs.with_underline(CellAttrs::DECO_SINGLE); + } + + Self { + fg: fg.0, + bg: bg.0, + decoration_fg: fg.0, // Use fg color for decoration by default + sprite_idx, + attrs: attrs.0, + } + } + + /// Set the sprite index + #[inline] + pub fn with_sprite(mut self, idx: u32) -> Self { + self.sprite_idx = idx; + self + } + + /// Mark this glyph as colored (emoji) + #[inline] + pub fn with_colored_glyph(mut self) -> Self { + self.sprite_idx |= Self::COLORED_GLYPH_FLAG; + self + } + + /// Get the sprite index (without the colored flag) + #[inline] + pub const fn get_sprite_idx(self) -> u32 { + self.sprite_idx & !Self::COLORED_GLYPH_FLAG + } + + /// Check if this is a colored glyph + #[inline] + pub const fn is_colored_glyph(self) -> bool { + (self.sprite_idx & Self::COLORED_GLYPH_FLAG) != 0 + } +} + impl ColorPalette { /// Parse a color specification like "#RRGGBB" or "rgb:RR/GG/BB". pub fn parse_color_spec(spec: &str) -> Option<[u8; 3]> { @@ -175,10 +493,197 @@ impl ColorPalette { } } +/// Saved cursor state for DECSC/DECRC. +#[derive(Clone, Debug, Default)] +struct SavedCursor { + col: usize, + row: usize, + fg: Color, + bg: Color, + bold: bool, + italic: bool, + underline: bool, +} + +/// Alternate screen buffer state. +#[derive(Clone)] +struct AlternateScreen { + grid: Vec>, + line_map: Vec, + cursor_col: usize, + cursor_row: usize, + saved_cursor: SavedCursor, + scroll_top: usize, + scroll_bottom: usize, +} + +/// Timing stats for performance debugging. +#[derive(Debug, Default)] +pub struct ProcessingStats { + /// Total time spent in scroll_up operations (nanoseconds). + pub scroll_up_ns: u64, + /// Number of scroll_up calls. + pub scroll_up_count: u32, + /// Total time spent in scrollback operations (nanoseconds). + pub scrollback_ns: u64, + /// Time in VecDeque pop_front. + pub pop_front_ns: u64, + /// Time in VecDeque push_back. + pub push_back_ns: u64, + /// Time in mem::swap. + pub swap_ns: u64, + /// Total time spent in line clearing (nanoseconds). + pub clear_line_ns: u64, + /// Total time spent in text handler (nanoseconds). + pub text_handler_ns: u64, + /// Number of characters processed. + pub chars_processed: u32, +} + +impl ProcessingStats { + pub fn reset(&mut self) { + *self = Self::default(); + } + + pub fn log_if_slow(&self, threshold_ms: u64) { + let total_ms = (self.scroll_up_ns + self.text_handler_ns) / 1_000_000; + if total_ms >= threshold_ms { + log::info!( + "TIMING: scroll_up={:.2}ms ({}x), scrollback={:.2}ms [pop={:.2}ms swap={:.2}ms push={:.2}ms], clear={:.2}ms, text={:.2}ms, chars={}", + self.scroll_up_ns as f64 / 1_000_000.0, + self.scroll_up_count, + self.scrollback_ns as f64 / 1_000_000.0, + self.pop_front_ns as f64 / 1_000_000.0, + self.swap_ns as f64 / 1_000_000.0, + self.push_back_ns as f64 / 1_000_000.0, + self.clear_line_ns as f64 / 1_000_000.0, + self.text_handler_ns as f64 / 1_000_000.0, + self.chars_processed, + ); + } + } +} + +/// Kitty-style ring buffer for scrollback history. +/// +/// Pre-allocates all lines upfront to avoid allocation during scrolling. +/// Uses modulo arithmetic for O(1) operations with no memory allocation or +/// pointer chasing - just simple index arithmetic like Kitty's historybuf. +/// +/// Key insight from Kitty: When the buffer is full, instead of pop_front + push_back +/// (which involves linked-list-style pointer updates in VecDeque), we just: +/// 1. Calculate the insertion slot with modulo arithmetic +/// 2. Increment the start pointer (also with modulo) +/// +/// This eliminates all per-scroll overhead that was causing timing variance. +pub struct ScrollbackBuffer { + /// Pre-allocated line storage. All lines are allocated upfront. + lines: Vec>, + /// Index of the oldest line (start of valid data). + start: usize, + /// Number of valid lines currently stored. + count: usize, + /// Maximum capacity (same as lines.len()). + capacity: usize, +} + +impl ScrollbackBuffer { + /// Creates a new scrollback buffer with the given capacity and column width. + /// All lines are pre-allocated to avoid any allocation during scrolling. + pub fn new(capacity: usize, cols: usize) -> Self { + // Pre-allocate all lines upfront + let lines = if capacity > 0 { + (0..capacity).map(|_| vec![Cell::default(); cols]).collect() + } else { + Vec::new() + }; + + Self { + lines, + start: 0, + count: 0, + capacity, + } + } + + /// Returns the number of lines currently stored. + #[inline] + pub fn len(&self) -> usize { + self.count + } + + /// Returns true if the buffer is empty. + #[inline] + pub fn is_empty(&self) -> bool { + self.count == 0 + } + + /// Returns true if the buffer is at capacity. + #[inline] + pub fn is_full(&self) -> bool { + self.count == self.capacity + } + + /// Push a line into the buffer, returning a mutable reference to write into. + /// + /// If the buffer is full, the oldest line is overwritten and its slot is returned + /// for reuse (the caller can swap content into it). + /// + /// This is the key operation - it's O(1) with just modulo arithmetic, no allocation. + #[inline] + pub fn push(&mut self) -> &mut Vec { + if self.capacity == 0 { + // Shouldn't happen in normal use, but handle gracefully + panic!("Cannot push to zero-capacity scrollback buffer"); + } + + // Calculate insertion index: (start + count) % capacity + // This is where the new line goes + let idx = (self.start + self.count) % self.capacity; + + if self.count == self.capacity { + // Buffer is full - we're overwriting the oldest line + // Advance start to point to the new oldest line + self.start = (self.start + 1) % self.capacity; + // count stays the same + } else { + // Buffer not full yet - just increment count + self.count += 1; + } + + &mut self.lines[idx] + } + + /// Get a line by logical index (0 = oldest, count-1 = newest). + /// Returns None if index is out of bounds. + #[inline] + pub fn get(&self, index: usize) -> Option<&Vec> { + if index >= self.count { + return None; + } + // Map logical index to physical index + let physical_idx = (self.start + index) % self.capacity; + Some(&self.lines[physical_idx]) + } + + /// Clear all lines from the buffer. + /// Note: This doesn't deallocate - lines stay allocated for reuse. + #[inline] + pub fn clear(&mut self) { + self.start = 0; + self.count = 0; + // Lines remain allocated but logically empty + } +} + /// The terminal grid state. pub struct Terminal { /// Grid of cells (row-major order). + /// Access via line_map for correct visual ordering. pub grid: Vec>, + /// Maps visual row index to actual grid row index. + /// This allows O(1) scrolling by rotating indices instead of moving cells. + line_map: Vec, /// Number of columns. pub cols: usize, /// Number of rows. @@ -203,6 +708,9 @@ pub struct Terminal { pub current_underline: bool, /// Whether the terminal content has changed. pub dirty: bool, + /// Bitmap of dirty lines - bit N is set if line N needs redrawing. + /// Supports up to 256 lines (4 x u64). + pub dirty_lines: [u64; 4], /// Scroll region top (0-indexed, inclusive). scroll_top: usize, /// Scroll region bottom (0-indexed, inclusive). @@ -214,23 +722,60 @@ pub struct Terminal { /// Color palette (can be modified by OSC sequences). pub palette: ColorPalette, /// Scrollback buffer (lines that scrolled off the top). - pub scrollback: Vec>, - /// Maximum number of lines to keep in scrollback. - pub scrollback_limit: usize, + /// Uses a Kitty-style ring buffer for O(1) operations with no allocation. + pub scrollback: ScrollbackBuffer, /// Current scroll offset (0 = viewing live terminal, >0 = viewing history). pub scroll_offset: usize, + /// Mouse tracking mode (what events to report to application). + pub mouse_tracking: MouseTrackingMode, + /// Mouse encoding format (how to encode mouse events). + pub mouse_encoding: MouseEncoding, + /// Saved cursor state (DECSC/DECRC). + saved_cursor: SavedCursor, + /// Alternate screen buffer (for fullscreen apps like vim, less). + alternate_screen: Option, + /// Whether we're currently using the alternate screen. + pub using_alternate_screen: bool, + /// Application cursor keys mode (DECCKM) - arrows send ESC O instead of ESC [. + pub application_cursor_keys: bool, + /// Auto-wrap mode (DECAWM) - wrap at end of line. + auto_wrap: bool, + /// Bracketed paste mode - wrap pasted text with escape sequences. + pub bracketed_paste: bool, + /// Focus event reporting mode. + pub focus_reporting: bool, + /// Synchronized output mode (for reducing flicker). + synchronized_output: bool, + /// Pool of pre-allocated empty lines to avoid allocation during scrolling. + /// When we need a new line, we pop from this pool instead of allocating. + line_pool: Vec>, + /// VT parser for escape sequence handling. + parser: Option, + /// Performance timing stats (for debugging). + pub stats: ProcessingStats, } impl Terminal { - /// Default scrollback limit (10,000 lines). - const DEFAULT_SCROLLBACK_LIMIT: usize = 10_000; + /// Default scrollback limit (10,000 lines for better cache performance). + pub const DEFAULT_SCROLLBACK_LIMIT: usize = 10_000; + + /// Size of the line pool for recycling allocations. + /// This avoids allocation during the first N scrolls before scrollback is full. + const LINE_POOL_SIZE: usize = 64; - /// Creates a new terminal with the given dimensions. - pub fn new(cols: usize, rows: usize) -> Self { + /// Creates a new terminal with the given dimensions and scrollback limit. + pub fn new(cols: usize, rows: usize, scrollback_limit: usize) -> Self { let grid = vec![vec![Cell::default(); cols]; rows]; + let line_map: Vec = (0..rows).collect(); + + // Pre-allocate a pool of empty lines to avoid allocation during scrolling + let line_pool: Vec> = (0..Self::LINE_POOL_SIZE) + .map(|_| vec![Cell::default(); cols]) + .collect(); Self { grid, + line_map, cols, rows, cursor_col: 0, @@ -243,14 +788,117 @@ impl Terminal { current_italic: false, current_underline: false, dirty: true, + dirty_lines: [!0u64; 4], // All lines dirty initially scroll_top: 0, scroll_bottom: rows.saturating_sub(1), keyboard: KeyboardState::new(), response_queue: Vec::new(), palette: ColorPalette::default(), - scrollback: Vec::new(), - scrollback_limit: Self::DEFAULT_SCROLLBACK_LIMIT, + scrollback: ScrollbackBuffer::new(scrollback_limit, cols), scroll_offset: 0, + mouse_tracking: MouseTrackingMode::default(), + mouse_encoding: MouseEncoding::default(), + saved_cursor: SavedCursor::default(), + alternate_screen: None, + using_alternate_screen: false, + application_cursor_keys: false, + auto_wrap: true, // Auto-wrap is on by default + bracketed_paste: false, + focus_reporting: false, + synchronized_output: false, + line_pool, + parser: Some(Parser::new()), + stats: ProcessingStats::default(), + } + } + + /// Return a line to the pool for reuse (if pool isn't full). + #[allow(dead_code)] + #[inline] + fn return_line_to_pool(&mut self, line: Vec) { + if self.line_pool.len() < Self::LINE_POOL_SIZE { + self.line_pool.push(line); + } + // Otherwise, let the line be dropped + } + + /// Mark a specific line as dirty (needs redrawing). + #[inline] + pub fn mark_line_dirty(&mut self, line: usize) { + if line < 256 { + let word = line / 64; + let bit = line % 64; + self.dirty_lines[word] |= 1u64 << bit; + } + } + + /// Mark all lines as dirty. + #[inline] + pub fn mark_all_lines_dirty(&mut self) { + self.dirty_lines = [!0u64; 4]; + } + + /// Check if a line is dirty. + #[inline] + pub fn is_line_dirty(&self, line: usize) -> bool { + if line < 256 { + let word = line / 64; + let bit = line % 64; + (self.dirty_lines[word] & (1u64 << bit)) != 0 + } else { + true // Lines beyond 256 are always considered dirty + } + } + + /// Clear all dirty line flags. + #[inline] + pub fn clear_dirty_lines(&mut self) { + self.dirty_lines = [0u64; 4]; + } + + /// Get the dirty lines bitmap (for passing to shm). + #[inline] + pub fn get_dirty_lines(&self) -> u64 { + // Return first 64 lines worth of dirty bits (most common case) + self.dirty_lines[0] + } + + /// Get the actual grid row index for a visual row. + #[inline] + pub fn grid_row(&self, visual_row: usize) -> usize { + self.line_map[visual_row] + } + + /// Get a reference to a row by visual index. + #[inline] + pub fn row(&self, visual_row: usize) -> &Vec { + &self.grid[self.line_map[visual_row]] + } + + /// Get a mutable reference to a row by visual index. + #[inline] + pub fn row_mut(&mut self, visual_row: usize) -> &mut Vec { + let idx = self.line_map[visual_row]; + &mut self.grid[idx] + } + + /// Clear a row (by actual grid index, not visual). + #[inline] + fn clear_grid_row(&mut self, grid_row: usize) { + let blank = self.blank_cell(); + self.grid[grid_row].fill(blank); + } + + /// Create a blank cell with the current background color (BCE - Background Color Erase). + #[inline] + fn blank_cell(&self) -> Cell { + Cell { + character: ' ', + fg_color: Color::Default, + bg_color: self.current_bg, + bold: false, + italic: false, + underline: false, } } @@ -263,10 +911,15 @@ impl Terminal { } } - /// Processes raw bytes from the PTY using the provided parser. - pub fn process(&mut self, bytes: &[u8], parser: &mut Parser) { - for byte in bytes { - parser.advance(self, *byte); + /// Processes raw bytes from the PTY using the internal VT parser. + /// Uses Kitty-style architecture: UTF-8 decode until ESC, then parse escape sequences. + pub fn process(&mut self, bytes: &[u8]) { + // We need to temporarily take ownership of the parser to satisfy the borrow checker, + // since parse() needs &mut self for both parser and handler (Terminal). + // Use Option::take to avoid creating a new default parser each time. + if let Some(mut parser) = self.parser.take() { + parser.parse(bytes, self); + self.parser = Some(parser); } self.dirty = true; } @@ -280,14 +933,17 @@ impl Terminal { // Create new grid let mut new_grid = vec![vec![Cell::default(); cols]; rows]; - // Copy existing content - for row in 0..rows.min(self.rows) { + // Copy existing content using line_map for correct visual ordering + for visual_row in 0..rows.min(self.rows) { + let old_grid_row = self.line_map[visual_row]; for col in 0..cols.min(self.cols) { - new_grid[row][col] = self.grid[row][col].clone(); + new_grid[visual_row][col] = self.grid[old_grid_row][col].clone(); } } self.grid = new_grid; + // Reset line_map to identity (0, 1, 2, ...) + self.line_map = (0..rows).collect(); self.cols = cols; self.rows = rows; @@ -299,56 +955,170 @@ impl Terminal { self.cursor_col = self.cursor_col.min(cols.saturating_sub(1)); self.cursor_row = self.cursor_row.min(rows.saturating_sub(1)); self.dirty = true; + self.mark_all_lines_dirty(); + } + + /// Switch to alternate screen buffer. + fn enter_alternate_screen(&mut self) { + if self.using_alternate_screen { + return; // Already in alternate screen + } + + // Save main screen state + self.alternate_screen = Some(AlternateScreen { + grid: self.grid.clone(), + line_map: self.line_map.clone(), + cursor_col: self.cursor_col, + cursor_row: self.cursor_row, + saved_cursor: self.saved_cursor.clone(), + scroll_top: self.scroll_top, + scroll_bottom: self.scroll_bottom, + }); + + // Clear the screen for alternate buffer + self.grid = vec![vec![Cell::default(); self.cols]; self.rows]; + self.line_map = (0..self.rows).collect(); + self.cursor_col = 0; + self.cursor_row = 0; + // Reset scroll region to full screen for alternate buffer + self.scroll_top = 0; + self.scroll_bottom = self.rows.saturating_sub(1); + // Reset scroll offset (can't view scrollback in alternate screen) + self.scroll_offset = 0; + self.using_alternate_screen = true; + self.mark_all_lines_dirty(); + self.dirty = true; + log::debug!( + "Entered alternate screen buffer: rows={}, cols={}, scroll_region={}-{}, dirty_lines={:016x}{:016x}{:016x}{:016x}", + self.rows, self.cols, self.scroll_top, self.scroll_bottom, + self.dirty_lines[3], self.dirty_lines[2], self.dirty_lines[1], self.dirty_lines[0] + ); + } + + /// Switch back to main screen buffer. + fn leave_alternate_screen(&mut self) { + if !self.using_alternate_screen { + return; // Not in alternate screen + } + + if let Some(saved) = self.alternate_screen.take() { + self.grid = saved.grid; + self.line_map = saved.line_map; + self.cursor_col = saved.cursor_col; + self.cursor_row = saved.cursor_row; + self.saved_cursor = saved.saved_cursor; + self.scroll_top = saved.scroll_top; + self.scroll_bottom = saved.scroll_bottom; + } + + self.using_alternate_screen = false; + self.mark_all_lines_dirty(); + log::debug!("Left alternate screen buffer"); } /// Scrolls the scroll region up by n lines. + /// Uses line_map rotation for O(n) instead of O(n*cols) cell copying. fn scroll_up(&mut self, n: usize) { - let n = n.min(self.scroll_bottom - self.scroll_top + 1); + let region_size = self.scroll_bottom - self.scroll_top + 1; + let n = n.min(region_size); + + self.stats.scroll_up_count += n as u32; + for _ in 0..n { - // Remove the top line of the scroll region - let removed_line = self.grid.remove(self.scroll_top); + // Save the top line's grid index before rotation + let recycled_grid_row = self.line_map[self.scroll_top]; // Save to scrollback only if scrolling from the very top of the screen - if self.scroll_top == 0 { - self.scrollback.push(removed_line); - // Trim scrollback if it exceeds the limit - if self.scrollback.len() > self.scrollback_limit { - self.scrollback.remove(0); - } + // AND not in alternate screen mode (alternate screen never uses scrollback) + // AND scrollback is enabled (capacity > 0) + if self.scroll_top == 0 && !self.using_alternate_screen && self.scrollback.capacity > 0 { + // Get a slot in the ring buffer - this is O(1) with just modulo arithmetic + // If buffer is full, this overwrites the oldest line (perfect for our swap) + let dest = self.scrollback.push(); + // Swap grid row content into scrollback slot + // The scrollback slot's old content (if any) moves to the grid row + std::mem::swap(&mut self.grid[recycled_grid_row], dest); + // Clear the grid row (now contains old scrollback data or empty) + self.clear_grid_row(recycled_grid_row); + } else { + // Not saving to scrollback - just clear the line + self.clear_grid_row(recycled_grid_row); } - // Insert a new blank line at the bottom of the scroll region - self.grid - .insert(self.scroll_bottom, vec![Cell::default(); self.cols]); + // Rotate line_map: shift all indices up within scroll region using memmove + self.line_map.copy_within(self.scroll_top + 1..=self.scroll_bottom, self.scroll_top); + self.line_map[self.scroll_bottom] = recycled_grid_row; + } + + // Mark all lines dirty with a single bitmask operation instead of loop + self.mark_region_dirty(self.scroll_top, self.scroll_bottom); + } + + /// Mark a range of lines as dirty efficiently. + #[inline] + fn mark_region_dirty(&mut self, start: usize, end: usize) { + // For small regions (< 64 lines), this is faster than individual calls + for line in start..=end.min(255) { + let word = line / 64; + let bit = line % 64; + self.dirty_lines[word] |= 1u64 << bit; } } /// Scrolls the scroll region down by n lines. + /// Uses line_map rotation for O(n) instead of O(n*cols) cell copying. fn scroll_down(&mut self, n: usize) { - let n = n.min(self.scroll_bottom - self.scroll_top + 1); + let region_size = self.scroll_bottom - self.scroll_top + 1; + let n = n.min(region_size); + for _ in 0..n { - // Remove the bottom line of the scroll region - self.grid.remove(self.scroll_bottom); - // Insert a new blank line at the top of the scroll region - self.grid - .insert(self.scroll_top, vec![Cell::default(); self.cols]); + // Save the bottom line's grid index before rotation + let recycled_grid_row = self.line_map[self.scroll_bottom]; + + // Rotate line_map: shift all indices down within scroll region using memmove + self.line_map.copy_within(self.scroll_top..self.scroll_bottom, self.scroll_top + 1); + self.line_map[self.scroll_top] = recycled_grid_row; + + // Clear the recycled line (now at visual top of scroll region) + self.clear_grid_row(recycled_grid_row); } + + // Mark all lines in the scroll region as dirty. + self.mark_region_dirty(self.scroll_top, self.scroll_bottom); } /// Scrolls the viewport up (into scrollback history) by n lines. /// Returns the new scroll offset. + /// Note: Scrollback is disabled in alternate screen mode. pub fn scroll_viewport_up(&mut self, n: usize) -> usize { + // Alternate screen has no scrollback + if self.using_alternate_screen { + return 0; + } let max_offset = self.scrollback.len(); - self.scroll_offset = (self.scroll_offset + n).min(max_offset); - self.dirty = true; + let new_offset = (self.scroll_offset + n).min(max_offset); + if new_offset != self.scroll_offset { + self.scroll_offset = new_offset; + self.dirty = true; + self.mark_all_lines_dirty(); // All visible content changes when scrolling + } self.scroll_offset } /// Scrolls the viewport down (toward live terminal) by n lines. /// Returns the new scroll offset. + /// Note: Scrollback is disabled in alternate screen mode. pub fn scroll_viewport_down(&mut self, n: usize) -> usize { - self.scroll_offset = self.scroll_offset.saturating_sub(n); - self.dirty = true; + // Alternate screen has no scrollback + if self.using_alternate_screen { + return 0; + } + let new_offset = self.scroll_offset.saturating_sub(n); + if new_offset != self.scroll_offset { + self.scroll_offset = new_offset; + self.dirty = true; + self.mark_all_lines_dirty(); // All visible content changes when scrolling + } self.scroll_offset } @@ -357,6 +1127,116 @@ impl Terminal { if self.scroll_offset != 0 { self.scroll_offset = 0; self.dirty = true; + self.mark_all_lines_dirty(); // All visible content changes when scrolling + } + } + + /// Scroll the viewport by the given number of lines. + /// Positive values scroll up (into history), negative values scroll down (toward live). + pub fn scroll(&mut self, lines: i32) { + if lines > 0 { + self.scroll_viewport_up(lines as usize); + } else if lines < 0 { + self.scroll_viewport_down((-lines) as usize); + } + } + + /// Encode a mouse event based on current tracking mode and encoding. + /// Returns the escape sequence to send to the application, or empty vec if no tracking. + pub fn encode_mouse( + &self, + button: u8, + col: u16, + row: u16, + pressed: bool, + is_motion: bool, + modifiers: u8, + ) -> Vec { + // Check if we should report this event based on tracking mode + match self.mouse_tracking { + MouseTrackingMode::None => return Vec::new(), + MouseTrackingMode::X10 => { + // X10 only reports button press, not release or motion + if !pressed || is_motion { + return Vec::new(); + } + } + MouseTrackingMode::Normal => { + // Normal reports press and release, not motion + if is_motion { + return Vec::new(); + } + } + MouseTrackingMode::ButtonEvent => { + // Button-event reports press, release, and motion while button pressed + // The is_motion flag indicates motion events + } + MouseTrackingMode::AnyEvent => { + // Any-event reports all motion + } + } + + // Build the button code + // Bits 0-1: button (0=left, 1=middle, 2=right, 3=release) + // Bit 2: shift + // Bit 3: meta/alt + // Bit 4: control + // Bits 5-6: 00=press, 01=motion with button, 10=scroll + let mut cb = button; + + // Handle release + if !pressed && !is_motion { + // For SGR encoding, we keep the button number + // For X10/UTF-8 encoding, release is button 3 + if self.mouse_encoding != MouseEncoding::Sgr { + cb = 3; + } + } + + // Add modifiers + cb |= modifiers << 2; + + // Add motion flag + if is_motion { + cb |= 32; + } + + // Convert to 1-based coordinates + let col = col.saturating_add(1); + let row = row.saturating_add(1); + + match self.mouse_encoding { + MouseEncoding::X10 => { + // X10 encoding: ESC [ M Cb Cx Cy + // Each value is encoded as a byte with 32 added + // Limited to 223 columns/rows + let cb = (cb + 32).min(255); + let cx = ((col as u8).min(223) + 32).min(255); + let cy = ((row as u8).min(223) + 32).min(255); + vec![0x1b, b'[', b'M', cb, cx, cy] + } + MouseEncoding::Utf8 => { + // UTF-8 encoding: ESC [ M Cb Cx Cy + // Values > 127 are UTF-8 encoded + // This is deprecated and rarely used + let cb = cb + 32; + let cx = (col as u8).saturating_add(32); + let cy = (row as u8).saturating_add(32); + vec![0x1b, b'[', b'M', cb, cx, cy] + } + MouseEncoding::Sgr => { + // SGR encoding: ESC [ < Cb ; Cx ; Cy M/m + // M for press, m for release + // Most modern and recommended format + let suffix = if pressed { b'M' } else { b'm' }; + format!("\x1b[<{};{};{}{}", cb, col, row, suffix as char).into_bytes() + } + MouseEncoding::Urxvt => { + // URXVT encoding: ESC [ Cb ; Cx ; Cy M + // Similar to SGR but uses decimal with offset + let cb = cb + 32; + format!("\x1b[{};{};{}M", cb, col, row).into_bytes() + } } } @@ -366,9 +1246,9 @@ impl Terminal { let mut rows = Vec::with_capacity(self.rows); if self.scroll_offset == 0 { - // No scrollback viewing, just return the grid - for row in &self.grid { - rows.push(row); + // No scrollback viewing, just return the grid via line_map + for visual_row in 0..self.rows { + rows.push(&self.grid[self.line_map[visual_row]]); } } else { // We're viewing scrollback @@ -382,18 +1262,19 @@ impl Terminal { if i < lines_from_scrollback { // This row comes from scrollback + // Use ring buffer's get() method with logical index let scrollback_idx = scrollback_len - self.scroll_offset + i; - if scrollback_idx < scrollback_len { - rows.push(&self.scrollback[scrollback_idx]); + if let Some(line) = self.scrollback.get(scrollback_idx) { + rows.push(line); } else { // Shouldn't happen, but fall back to grid - rows.push(&self.grid[i]); + rows.push(&self.grid[self.line_map[i]]); } } else { // This row comes from the grid - let grid_idx = i - lines_from_scrollback; - if grid_idx < self.grid.len() { - rows.push(&self.grid[grid_idx]); + let grid_visual_idx = i - lines_from_scrollback; + if grid_visual_idx < self.rows { + rows.push(&self.grid[self.line_map[grid_visual_idx]]); } } } @@ -403,38 +1284,68 @@ impl Terminal { } /// Inserts n blank lines at the cursor position, scrolling lines below down. + /// Uses line_map rotation for efficiency. fn insert_lines(&mut self, n: usize) { if self.cursor_row < self.scroll_top || self.cursor_row > self.scroll_bottom { return; } let n = n.min(self.scroll_bottom - self.cursor_row + 1); + for _ in 0..n { - // Remove the bottom line of the scroll region - self.grid.remove(self.scroll_bottom); - // Insert a new blank line at the cursor row - self.grid - .insert(self.cursor_row, vec![Cell::default(); self.cols]); + // Save the bottom line's grid index before rotation + let recycled_grid_row = self.line_map[self.scroll_bottom]; + + // Rotate line_map: shift lines from cursor to bottom down by 1 + // The bottom line becomes the new line at cursor position + for i in (self.cursor_row + 1..=self.scroll_bottom).rev() { + self.line_map[i] = self.line_map[i - 1]; + } + self.line_map[self.cursor_row] = recycled_grid_row; + + // Clear the recycled line (now at cursor position) + self.clear_grid_row(recycled_grid_row); + + // Mark affected lines dirty + for line in self.cursor_row..=self.scroll_bottom { + self.mark_line_dirty(line); + } } } /// Deletes n lines at the cursor position, scrolling lines below up. + /// Uses line_map rotation for efficiency. fn delete_lines(&mut self, n: usize) { if self.cursor_row < self.scroll_top || self.cursor_row > self.scroll_bottom { return; } let n = n.min(self.scroll_bottom - self.cursor_row + 1); + for _ in 0..n { - // Remove the line at cursor - self.grid.remove(self.cursor_row); - // Insert a new blank line at the bottom of the scroll region - self.grid - .insert(self.scroll_bottom, vec![Cell::default(); self.cols]); + // Save the line at cursor's grid index before rotation + let recycled_grid_row = self.line_map[self.cursor_row]; + + // Rotate line_map: shift lines from cursor to bottom up by 1 + // The cursor line becomes the new bottom line + for i in self.cursor_row..self.scroll_bottom { + self.line_map[i] = self.line_map[i + 1]; + } + self.line_map[self.scroll_bottom] = recycled_grid_row; + + // Clear the recycled line (now at bottom of scroll region) + self.clear_grid_row(recycled_grid_row); + + // Mark affected lines dirty + for line in self.cursor_row..=self.scroll_bottom { + self.mark_line_dirty(line); + } } } /// Inserts n blank characters at the cursor, shifting existing chars right. fn insert_characters(&mut self, n: usize) { - let row = &mut self.grid[self.cursor_row]; + let grid_row = self.line_map[self.cursor_row]; + let blank = self.blank_cell(); + let row = &mut self.grid[grid_row]; let n = n.min(self.cols - self.cursor_col); // Remove n characters from the end for _ in 0..n { @@ -442,13 +1353,16 @@ impl Terminal { } // Insert n blank characters at cursor position for _ in 0..n { - row.insert(self.cursor_col, Cell::default()); + row.insert(self.cursor_col, blank); } + self.mark_line_dirty(self.cursor_row); } /// Deletes n characters at the cursor, shifting remaining chars left. fn delete_characters(&mut self, n: usize) { - let row = &mut self.grid[self.cursor_row]; + let grid_row = self.line_map[self.cursor_row]; + let blank = self.blank_cell(); + let row = &mut self.grid[grid_row]; let n = n.min(self.cols - self.cursor_col); // Remove n characters at cursor position for _ in 0..n { @@ -458,125 +1372,158 @@ impl Terminal { } // Pad with blank characters at the end while row.len() < self.cols { - row.push(Cell::default()); + row.push(blank); } + self.mark_line_dirty(self.cursor_row); } /// Erases n characters at the cursor (replaces with spaces, doesn't shift). fn erase_characters(&mut self, n: usize) { + let grid_row = self.line_map[self.cursor_row]; let n = n.min(self.cols - self.cursor_col); + let blank = self.blank_cell(); for i in 0..n { if self.cursor_col + i < self.cols { - self.grid[self.cursor_row][self.cursor_col + i] = Cell::default(); + self.grid[grid_row][self.cursor_col + i] = blank; } } + self.mark_line_dirty(self.cursor_row); } /// Clears the current line from cursor to end. fn clear_line_from_cursor(&mut self) { + let grid_row = self.line_map[self.cursor_row]; + let blank = self.blank_cell(); for col in self.cursor_col..self.cols { - self.grid[self.cursor_row][col] = Cell::default(); + self.grid[grid_row][col] = blank; } + self.mark_line_dirty(self.cursor_row); } - /// Clears the entire screen. + /// Clears the entire screen, pushing current content to scrollback first (main screen only). fn clear_screen(&mut self) { - for row in &mut self.grid { - for cell in row { - *cell = Cell::default(); + // Push all visible lines to scrollback before clearing + // This preserves the content in history so the user can scroll back to see it + // BUT: Only do this for main screen, not alternate screen + // AND only if scrollback is enabled + if !self.using_alternate_screen && self.scrollback.capacity > 0 { + for visual_row in 0..self.rows { + let grid_row = self.line_map[visual_row]; + // Get a slot in the ring buffer and swap content into it + let dest = self.scrollback.push(); + std::mem::swap(&mut self.grid[grid_row], dest); } } + + // Now clear the grid with BCE + let blank = self.blank_cell(); + for row in &mut self.grid { + row.fill(blank); + } + self.mark_all_lines_dirty(); self.cursor_col = 0; self.cursor_row = 0; } - - /// Handles Kitty keyboard protocol escape sequences. - fn handle_keyboard_protocol(&mut self, params: &[u16], intermediates: &[u8]) { - match intermediates { - // CSI ? u - Query current keyboard flags - [b'?'] => { - let response = query_response(self.keyboard.flags()); - self.response_queue.extend(response); - } - // CSI = flags ; mode u - Set keyboard flags - [b'='] => { - let flags = params.first().copied().unwrap_or(0) as u8; - let mode = params.get(1).copied().unwrap_or(1) as u8; - self.keyboard.set_flags(flags, mode); - log::debug!( - "Keyboard flags set to {:?} (mode {})", - self.keyboard.flags(), - mode - ); - } - // CSI > flags u - Push keyboard flags onto stack - [b'>'] => { - let flags = if params.is_empty() { - None - } else { - Some(params[0] as u8) - }; - self.keyboard.push(flags); - log::debug!("Keyboard flags pushed: {:?}", self.keyboard.flags()); - } - // CSI < number u - Pop keyboard flags from stack - [b'<'] => { - let count = params.first().copied().unwrap_or(1) as usize; - self.keyboard.pop(count); - log::debug!("Keyboard flags popped: {:?}", self.keyboard.flags()); - } - _ => { - // Unknown intermediate, ignore - } - } - } } -impl Perform for Terminal { - fn print(&mut self, c: char) { - if self.cursor_col >= self.cols { - self.cursor_col = 0; - self.cursor_row += 1; - if self.cursor_row > self.scroll_bottom { - self.scroll_up(1); - self.cursor_row = self.scroll_bottom; +impl Handler for Terminal { + /// Handle a chunk of decoded text (Unicode codepoints). + /// This includes control characters (0x00-0x1F except ESC). + fn text(&mut self, chars: &[char]) { + // Cache the current line to avoid repeated line_map lookups + let mut cached_row = self.cursor_row; + let mut grid_row = self.line_map[cached_row]; + + for &c in chars { + match c { + // Bell + '\x07' => { + // BEL - ignore for now (could trigger visual bell) + } + // Backspace + '\x08' => { + if self.cursor_col > 0 { + self.cursor_col -= 1; + } + } + // Tab + '\x09' => { + let next_tab = (self.cursor_col / 8 + 1) * 8; + self.cursor_col = next_tab.min(self.cols - 1); + } + // Line feed, Vertical tab, Form feed + '\x0A' | '\x0B' | '\x0C' => { + self.cursor_row += 1; + if self.cursor_row > self.scroll_bottom { + self.scroll_up(1); + self.cursor_row = self.scroll_bottom; + } + // Update cache after line change + cached_row = self.cursor_row; + grid_row = self.line_map[cached_row]; + } + // Carriage return + '\x0D' => { + self.cursor_col = 0; + } + // Printable characters (including all Unicode) + c if c >= ' ' => { + // Handle wrap + if self.cursor_col >= self.cols { + if self.auto_wrap { + self.cursor_col = 0; + self.cursor_row += 1; + if self.cursor_row > self.scroll_bottom { + self.scroll_up(1); + self.cursor_row = self.scroll_bottom; + } + // Update cache after line change + cached_row = self.cursor_row; + grid_row = self.line_map[cached_row]; + } else { + self.cursor_col = self.cols - 1; + } + } + + // Write character directly using cached grid_row + self.grid[grid_row][self.cursor_col] = Cell { + character: c, + fg_color: self.current_fg, + bg_color: self.current_bg, + bold: self.current_bold, + italic: self.current_italic, + underline: self.current_underline, + }; + self.cursor_col += 1; + } + // Other control chars - ignore + _ => {} } } - - self.grid[self.cursor_row][self.cursor_col] = Cell { - character: c, - fg_color: self.current_fg, - bg_color: self.current_bg, - bold: self.current_bold, - italic: self.current_italic, - underline: self.current_underline, - }; - - self.cursor_col += 1; + + // Mark all lines dirty at the end (we touched many lines) + self.mark_all_lines_dirty(); } - fn execute(&mut self, byte: u8) { + /// Handle control characters embedded in escape sequences. + fn control(&mut self, byte: u8) { match byte { - // Backspace 0x08 => { if self.cursor_col > 0 { self.cursor_col -= 1; } } - // Tab 0x09 => { let next_tab = (self.cursor_col / 8 + 1) * 8; self.cursor_col = next_tab.min(self.cols - 1); } - // Line feed - 0x0A => { + 0x0A | 0x0B | 0x0C => { self.cursor_row += 1; if self.cursor_row > self.scroll_bottom { self.scroll_up(1); self.cursor_row = self.scroll_bottom; } } - // Carriage return 0x0D => { self.cursor_col = 0; } @@ -584,33 +1531,31 @@ impl Perform for Terminal { } } - fn hook(&mut self, _params: &Params, _intermediates: &[u8], _ignore: bool, _action: char) {} - - fn put(&mut self, _byte: u8) {} - - fn unhook(&mut self) {} - - fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) { - // Handle OSC sequences - if params.is_empty() { + /// Handle a complete OSC sequence. + fn osc(&mut self, data: &[u8]) { + // Parse OSC format: "number;content" or "number;arg;content" + // Split on ';' + let parts: Vec<&[u8]> = data.splitn(3, |&b| b == b';').collect(); + if parts.is_empty() { return; } - - // First param is the OSC number - let osc_num = match std::str::from_utf8(params[0]) { + + // First part is the OSC number + let osc_num = match std::str::from_utf8(parts[0]) { Ok(s) => s.parse::().unwrap_or(u32::MAX), Err(_) => return, }; - + match osc_num { + // OSC 0, 1, 2 - Set window title (ignore for now) + 0 | 1 | 2 => {} // OSC 4 - Set/query indexed color 4 => { - // Format: OSC 4 ; index ; color ST - // params[0] = "4", params[1] = "index", params[2] = "color" - if params.len() >= 3 { - if let Ok(index_str) = std::str::from_utf8(params[1]) { + // Format: OSC 4;index;color ST + if parts.len() >= 3 { + if let Ok(index_str) = std::str::from_utf8(parts[1]) { if let Ok(index) = index_str.parse::() { - if let Ok(color_spec) = std::str::from_utf8(params[2]) { + if let Ok(color_spec) = std::str::from_utf8(parts[2]) { if let Some(rgb) = ColorPalette::parse_color_spec(color_spec) { self.palette.colors[index as usize] = rgb; log::debug!("OSC 4: Set color {} to {:?}", index, rgb); @@ -622,8 +1567,8 @@ impl Perform for Terminal { } // OSC 10 - Set/query default foreground color 10 => { - if params.len() >= 2 { - if let Ok(color_spec) = std::str::from_utf8(params[1]) { + if parts.len() >= 2 { + if let Ok(color_spec) = std::str::from_utf8(parts[1]) { if let Some(rgb) = ColorPalette::parse_color_spec(color_spec) { self.palette.default_fg = rgb; log::debug!("OSC 10: Set default foreground to {:?}", rgb); @@ -633,8 +1578,8 @@ impl Perform for Terminal { } // OSC 11 - Set/query default background color 11 => { - if params.len() >= 2 { - if let Ok(color_spec) = std::str::from_utf8(params[1]) { + if parts.len() >= 2 { + if let Ok(color_spec) = std::str::from_utf8(parts[1]) { if let Some(rgb) = ColorPalette::parse_color_spec(color_spec) { self.palette.default_bg = rgb; log::debug!("OSC 11: Set default background to {:?}", rgb); @@ -642,69 +1587,89 @@ impl Perform for Terminal { } } } - // OSC 0, 1, 2 - Set window title (ignore for now) - 0 | 1 | 2 => {} _ => { log::debug!("Unhandled OSC {}", osc_num); } } } - fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) { - // For most commands, we just need the first value of each parameter group - let flat_params: Vec = params.iter().map(|p| p[0]).collect(); + /// Handle a complete CSI sequence. + fn csi(&mut self, params: &CsiParams) { + let action = params.final_char as char; + let primary = params.primary; + let secondary = params.secondary; match action { // Cursor Up 'A' => { - let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; + let n = params.get(0, 1).max(1) as usize; self.cursor_row = self.cursor_row.saturating_sub(n); } // Cursor Down 'B' => { - let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; + let n = params.get(0, 1).max(1) as usize; self.cursor_row = (self.cursor_row + n).min(self.rows - 1); } // Cursor Forward 'C' => { - let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; + let n = params.get(0, 1).max(1) as usize; self.cursor_col = (self.cursor_col + n).min(self.cols - 1); } // Cursor Back 'D' => { - let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; + let n = params.get(0, 1).max(1) as usize; self.cursor_col = self.cursor_col.saturating_sub(n); } + // Cursor Next Line (CNL) + 'E' => { + let n = params.get(0, 1).max(1) as usize; + self.cursor_col = 0; + self.cursor_row = (self.cursor_row + n).min(self.rows - 1); + } + // Cursor Previous Line (CPL) + 'F' => { + let n = params.get(0, 1).max(1) as usize; + self.cursor_col = 0; + self.cursor_row = self.cursor_row.saturating_sub(n); + } + // Cursor Horizontal Absolute (CHA) + 'G' => { + let col = params.get(0, 1).max(1) as usize; + self.cursor_col = (col - 1).min(self.cols - 1); + } // Cursor Position 'H' | 'f' => { - let row = flat_params.first().copied().unwrap_or(1).max(1) as usize; - let col = flat_params.get(1).copied().unwrap_or(1).max(1) as usize; + let row = params.get(0, 1).max(1) as usize; + let col = params.get(1, 1).max(1) as usize; self.cursor_row = (row - 1).min(self.rows - 1); self.cursor_col = (col - 1).min(self.cols - 1); } // Erase in Display 'J' => { - let mode = flat_params.first().copied().unwrap_or(0); + let mode = params.get(0, 0); + let blank = self.blank_cell(); match mode { 0 => { // Clear from cursor to end of screen self.clear_line_from_cursor(); - for row in (self.cursor_row + 1)..self.rows { - for cell in &mut self.grid[row] { - *cell = Cell::default(); - } + for visual_row in (self.cursor_row + 1)..self.rows { + let grid_row = self.line_map[visual_row]; + self.grid[grid_row].fill(blank); + self.mark_line_dirty(visual_row); } } 1 => { // Clear from start to cursor - for row in 0..self.cursor_row { - for cell in &mut self.grid[row] { - *cell = Cell::default(); - } + for visual_row in 0..self.cursor_row { + let grid_row = self.line_map[visual_row]; + self.grid[grid_row].fill(blank); + self.mark_line_dirty(visual_row); } + let cursor_grid_row = self.line_map[self.cursor_row]; for col in 0..=self.cursor_col { - self.grid[self.cursor_row][col] = Cell::default(); + self.grid[cursor_grid_row][col] = blank; } + self.mark_line_dirty(self.cursor_row); } 2 | 3 => { // Clear entire screen @@ -715,173 +1680,126 @@ impl Perform for Terminal { } // Erase in Line 'K' => { - let mode = flat_params.first().copied().unwrap_or(0); + let mode = params.get(0, 0); + let blank = self.blank_cell(); match mode { 0 => self.clear_line_from_cursor(), 1 => { + let grid_row = self.line_map[self.cursor_row]; for col in 0..=self.cursor_col { - self.grid[self.cursor_row][col] = Cell::default(); + self.grid[grid_row][col] = blank; } + self.mark_line_dirty(self.cursor_row); } 2 => { - for cell in &mut self.grid[self.cursor_row] { - *cell = Cell::default(); - } + let grid_row = self.line_map[self.cursor_row]; + self.grid[grid_row].fill(blank); + self.mark_line_dirty(self.cursor_row); } _ => {} } } + // Insert Lines (IL) + 'L' => { + let n = params.get(0, 1).max(1) as usize; + self.insert_lines(n); + } + // Delete Lines (DL) + 'M' => { + let n = params.get(0, 1).max(1) as usize; + self.delete_lines(n); + } + // Delete Characters (DCH) + 'P' => { + let n = params.get(0, 1).max(1) as usize; + self.delete_characters(n); + } + // Scroll Up (SU) + 'S' => { + let n = params.get(0, 1).max(1) as usize; + self.scroll_up(n); + } + // Scroll Down (SD) + 'T' => { + let n = params.get(0, 1).max(1) as usize; + self.scroll_down(n); + } + // Erase Characters (ECH) + 'X' => { + let n = params.get(0, 1).max(1) as usize; + self.erase_characters(n); + } + // Insert Characters (ICH) + '@' => { + let n = params.get(0, 1).max(1) as usize; + self.insert_characters(n); + } + // Repeat preceding character (REP) + 'b' => { + let n = params.get(0, 1).max(1) as usize; + if self.cursor_col > 0 { + let grid_row = self.line_map[self.cursor_row]; + let last_char = self.grid[grid_row][self.cursor_col - 1].character; + for _ in 0..n { + self.print_char(last_char); + } + } + } + // Device Attributes (DA) + 'c' => { + if primary == 0 || primary == b'?' { + // Primary DA - respond as VT220 + self.response_queue.extend_from_slice(b"\x1b[?62;c"); + } else if primary == b'>' { + // Secondary DA - respond with terminal version + self.response_queue.extend_from_slice(b"\x1b[>0;0;0c"); + } + } + // Vertical Position Absolute (VPA) + 'd' => { + let row = params.get(0, 1).max(1) as usize; + self.cursor_row = (row - 1).min(self.rows - 1); + } // SGR (Select Graphic Rendition) 'm' => { - // Handle SGR with proper sub-parameter support - // VTE gives us parameter groups - each group can have sub-params (colon-separated) - // We need to handle both: - // - Legacy: ESC[38;5;196m -> groups: [38], [5], [196] - // - Modern: ESC[38:5:196m -> groups: [38, 5, 196] - // - Modern: ESC[38:2:r:g:bm -> groups: [38, 2, r, g, b] - - let param_groups: Vec> = params.iter() - .map(|subparams| subparams.iter().copied().collect()) - .collect(); - - log::debug!("SGR param_groups: {:?}", param_groups); - - if param_groups.is_empty() { - self.current_fg = Color::Default; - self.current_bg = Color::Default; - self.current_bold = false; - self.current_italic = false; - self.current_underline = false; - return; - } - - let mut i = 0; - while i < param_groups.len() { - let group = ¶m_groups[i]; - let code = group.first().copied().unwrap_or(0); - - match code { - 0 => { - self.current_fg = Color::Default; - self.current_bg = Color::Default; - self.current_bold = false; - self.current_italic = false; - self.current_underline = false; - } - 1 => self.current_bold = true, - 3 => self.current_italic = true, - 4 => self.current_underline = true, - 7 => { - // Reverse video - swap fg and bg - std::mem::swap(&mut self.current_fg, &mut self.current_bg); - } - 22 => self.current_bold = false, - 23 => self.current_italic = false, - 24 => self.current_underline = false, - 27 => { - // Reverse video off - swap back (simplified) - std::mem::swap(&mut self.current_fg, &mut self.current_bg); - } - 30..=37 => self.current_fg = Color::Indexed((code - 30) as u8), - 38 => { - // Foreground color - check for sub-parameters first (colon format) - if group.len() >= 3 && group[1] == 5 { - // Colon format: 38:5:index - self.current_fg = Color::Indexed(group[2] as u8); - } else if group.len() >= 5 && group[1] == 2 { - // Colon format: 38:2:r:g:b or 38:2:colorspace:r:g:b - // Check if we have colorspace indicator - if group.len() >= 6 { - // 38:2:colorspace:r:g:b - self.current_fg = Color::Rgb( - group[3] as u8, - group[4] as u8, - group[5] as u8, - ); - } else { - // 38:2:r:g:b - self.current_fg = Color::Rgb( - group[2] as u8, - group[3] as u8, - group[4] as u8, - ); - } - } else if i + 2 < param_groups.len() { - // Semicolon format: check next groups - let mode = param_groups[i + 1].first().copied().unwrap_or(0); - if mode == 5 { - // 38;5;index - let idx = param_groups[i + 2].first().copied().unwrap_or(0); - self.current_fg = Color::Indexed(idx as u8); - i += 2; - } else if mode == 2 && i + 4 < param_groups.len() { - // 38;2;r;g;b - let r = param_groups[i + 2].first().copied().unwrap_or(0); - let g = param_groups[i + 3].first().copied().unwrap_or(0); - let b = param_groups[i + 4].first().copied().unwrap_or(0); - self.current_fg = Color::Rgb(r as u8, g as u8, b as u8); - i += 4; - } - } - } - 39 => self.current_fg = Color::Default, - 40..=47 => self.current_bg = Color::Indexed((code - 40) as u8), - 48 => { - // Background color - check for sub-parameters first (colon format) - if group.len() >= 3 && group[1] == 5 { - // Colon format: 48:5:index - self.current_bg = Color::Indexed(group[2] as u8); - } else if group.len() >= 5 && group[1] == 2 { - // Colon format: 48:2:r:g:b or 48:2:colorspace:r:g:b - if group.len() >= 6 { - // 48:2:colorspace:r:g:b - self.current_bg = Color::Rgb( - group[3] as u8, - group[4] as u8, - group[5] as u8, - ); - } else { - // 48:2:r:g:b - self.current_bg = Color::Rgb( - group[2] as u8, - group[3] as u8, - group[4] as u8, - ); - } - } else if i + 2 < param_groups.len() { - // Semicolon format: check next groups - let mode = param_groups[i + 1].first().copied().unwrap_or(0); - if mode == 5 { - // 48;5;index - let idx = param_groups[i + 2].first().copied().unwrap_or(0); - self.current_bg = Color::Indexed(idx as u8); - i += 2; - } else if mode == 2 && i + 4 < param_groups.len() { - // 48;2;r;g;b - let r = param_groups[i + 2].first().copied().unwrap_or(0); - let g = param_groups[i + 3].first().copied().unwrap_or(0); - let b = param_groups[i + 4].first().copied().unwrap_or(0); - self.current_bg = Color::Rgb(r as u8, g as u8, b as u8); - i += 4; - } - } - } - 49 => self.current_bg = Color::Default, - 90..=97 => { - self.current_fg = Color::Indexed((code - 90 + 8) as u8) - } - 100..=107 => { - self.current_bg = Color::Indexed((code - 100 + 8) as u8) - } - _ => {} + self.handle_sgr(params); + } + // Device Status Report (DSR) + 'n' => { + let param = params.get(0, 0); + match param { + 5 => { + // Status report - respond with "OK" + self.response_queue.extend_from_slice(b"\x1b[0n"); } - i += 1; + 6 => { + // Cursor position report + let response = format!("\x1b[{};{}R", self.cursor_row + 1, self.cursor_col + 1); + self.response_queue.extend_from_slice(response.as_bytes()); + } + _ => {} + } + } + // DECSCUSR - Set Cursor Style (CSI Ps SP q) + // Also handle CSI q (no space) as reset to default + 'q' => { + if secondary == b' ' || secondary == 0 { + let style = params.get(0, 0); + self.cursor_shape = match style { + 0 | 1 => CursorShape::BlinkingBlock, + 2 => CursorShape::SteadyBlock, + 3 => CursorShape::BlinkingUnderline, + 4 => CursorShape::SteadyUnderline, + 5 => CursorShape::BlinkingBar, + 6 => CursorShape::SteadyBar, + _ => CursorShape::BlinkingBlock, + }; } } // Set Scrolling Region (DECSTBM) 'r' => { - let top = flat_params.first().copied().unwrap_or(1).max(1) as usize; - let bottom = flat_params.get(1).copied().unwrap_or(self.rows as u16).max(1) as usize; + let top = params.get(0, 1).max(1) as usize; + let bottom = params.get(1, self.rows as i32).max(1) as usize; self.scroll_top = (top - 1).min(self.rows - 1); self.scroll_bottom = (bottom - 1).min(self.rows - 1); if self.scroll_top > self.scroll_bottom { @@ -891,92 +1809,464 @@ impl Perform for Terminal { self.cursor_row = 0; self.cursor_col = 0; } - // Scroll Up (SU) - 'S' => { - let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; - self.scroll_up(n); - } - // Scroll Down (SD) - 'T' => { - let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; - self.scroll_down(n); - } - // Insert Lines (IL) - 'L' => { - let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; - self.insert_lines(n); - } - // Delete Lines (DL) - 'M' => { - let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; - self.delete_lines(n); - } - // Insert Characters (ICH) - '@' => { - let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; - self.insert_characters(n); - } - // Delete Characters (DCH) - 'P' => { - let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; - self.delete_characters(n); - } - // Erase Characters (ECH) - 'X' => { - let n = flat_params.first().copied().unwrap_or(1).max(1) as usize; - self.erase_characters(n); + // Window manipulation (CSI Ps t) + 't' => { + let ps = params.get(0, 0); + match ps { + 22 | 23 => { + // Save/restore window title - ignore + } + _ => { + log::trace!("Window manipulation: ps={}", ps); + } + } } // Kitty keyboard protocol 'u' => { - self.handle_keyboard_protocol(&flat_params, intermediates); - } - // DECSCUSR - Set Cursor Style (CSI Ps SP q) - 'q' if intermediates == [b' '] => { - let style = flat_params.first().copied().unwrap_or(0); - self.cursor_shape = match style { - 0 | 1 => CursorShape::BlinkingBlock, // 0 = default (blinking block), 1 = blinking block - 2 => CursorShape::SteadyBlock, - 3 => CursorShape::BlinkingUnderline, - 4 => CursorShape::SteadyUnderline, - 5 => CursorShape::BlinkingBar, - 6 => CursorShape::SteadyBar, - _ => CursorShape::BlinkingBlock, - }; - log::debug!("DECSCUSR: cursor shape set to {:?}", self.cursor_shape); + self.handle_keyboard_protocol_csi(params); } // DEC Private Mode Set (CSI ? Ps h) - 'h' if intermediates == [b'?'] => { - for ¶m in &flat_params { - match param { - 25 => { - // DECTCEM - Show cursor - self.cursor_visible = true; - log::debug!("DECTCEM: cursor visible"); - } - _ => { - log::debug!("Unhandled DEC private mode set: {}", param); - } - } - } + 'h' if primary == b'?' => { + self.handle_dec_private_mode_set(params); } // DEC Private Mode Reset (CSI ? Ps l) - 'l' if intermediates == [b'?'] => { - for ¶m in &flat_params { - match param { - 25 => { - // DECTCEM - Hide cursor - self.cursor_visible = false; - log::debug!("DECTCEM: cursor hidden"); + 'l' if primary == b'?' => { + self.handle_dec_private_mode_reset(params); + } + _ => { + log::debug!( + "Unhandled CSI: action='{}' primary={} secondary={} params={:?}", + action, primary, secondary, ¶ms.params[..params.num_params] + ); + } + } + } + + fn save_cursor(&mut self) { + self.saved_cursor = SavedCursor { + col: self.cursor_col, + row: self.cursor_row, + fg: self.current_fg, + bg: self.current_bg, + bold: self.current_bold, + italic: self.current_italic, + underline: self.current_underline, + }; + log::debug!("ESC 7: Cursor saved at ({}, {})", self.cursor_col, self.cursor_row); + } + + fn restore_cursor(&mut self) { + self.cursor_col = self.saved_cursor.col.min(self.cols.saturating_sub(1)); + self.cursor_row = self.saved_cursor.row.min(self.rows.saturating_sub(1)); + self.current_fg = self.saved_cursor.fg; + self.current_bg = self.saved_cursor.bg; + self.current_bold = self.saved_cursor.bold; + self.current_italic = self.saved_cursor.italic; + self.current_underline = self.saved_cursor.underline; + log::debug!("ESC 8: Cursor restored to ({}, {})", self.cursor_col, self.cursor_row); + } + + fn reset(&mut self) { + self.current_fg = Color::Default; + self.current_bg = Color::Default; + self.current_bold = false; + self.current_italic = false; + self.current_underline = false; + self.cursor_col = 0; + self.cursor_row = 0; + self.cursor_visible = true; + self.cursor_shape = CursorShape::default(); + self.scroll_top = 0; + self.scroll_bottom = self.rows.saturating_sub(1); + self.mouse_tracking = MouseTrackingMode::None; + self.mouse_encoding = MouseEncoding::X10; + self.application_cursor_keys = false; + self.auto_wrap = true; + self.bracketed_paste = false; + self.focus_reporting = false; + self.synchronized_output = false; + if self.using_alternate_screen { + self.leave_alternate_screen(); + } + for row in &mut self.grid { + for cell in row { + *cell = Cell::default(); + } + } + self.mark_all_lines_dirty(); + log::debug!("ESC c: Full terminal reset"); + } + + fn index(&mut self) { + if self.cursor_row >= self.scroll_bottom { + self.scroll_up(1); + } else { + self.cursor_row += 1; + } + } + + fn newline(&mut self) { + self.cursor_col = 0; + if self.cursor_row >= self.scroll_bottom { + self.scroll_up(1); + } else { + self.cursor_row += 1; + } + } + + fn reverse_index(&mut self) { + if self.cursor_row <= self.scroll_top { + self.scroll_down(1); + } else { + self.cursor_row -= 1; + } + } + + fn set_tab_stop(&mut self) { + // HTS - default tab stops every 8 columns + } + + fn set_keypad_mode(&mut self, application: bool) { + if application { + log::debug!("ESC =: Application keypad mode"); + } else { + log::debug!("ESC >: Normal keypad mode"); + } + } + + fn designate_charset(&mut self, _set: u8, _charset: u8) { + // UTF-8 internally, no-op + } + + fn screen_alignment(&mut self) { + for visual_row in 0..self.rows { + let grid_row = self.line_map[visual_row]; + for cell in &mut self.grid[grid_row] { + *cell = Cell { + character: 'E', + fg_color: Color::Default, + bg_color: Color::Default, + bold: false, + italic: false, + underline: false, + }; + } + self.mark_line_dirty(visual_row); + } + } +} + +impl Terminal { + /// Print a single character at the cursor position. + #[inline] + fn print_char(&mut self, c: char) { + if self.cursor_col >= self.cols { + if self.auto_wrap { + self.cursor_col = 0; + self.cursor_row += 1; + if self.cursor_row > self.scroll_bottom { + self.scroll_up(1); + self.cursor_row = self.scroll_bottom; + } + } else { + self.cursor_col = self.cols - 1; + } + } + + let grid_row = self.line_map[self.cursor_row]; + self.grid[grid_row][self.cursor_col] = Cell { + character: c, + fg_color: self.current_fg, + bg_color: self.current_bg, + bold: self.current_bold, + italic: self.current_italic, + underline: self.current_underline, + }; + self.mark_line_dirty(self.cursor_row); + self.cursor_col += 1; + } + + /// Handle SGR (Select Graphic Rendition) parameters. + fn handle_sgr(&mut self, params: &CsiParams) { + if params.num_params == 0 { + self.current_fg = Color::Default; + self.current_bg = Color::Default; + self.current_bold = false; + self.current_italic = false; + self.current_underline = false; + return; + } + + let mut i = 0; + while i < params.num_params { + let code = params.params[i]; + + match code { + 0 => { + self.current_fg = Color::Default; + self.current_bg = Color::Default; + self.current_bold = false; + self.current_italic = false; + self.current_underline = false; + } + 1 => self.current_bold = true, + 3 => self.current_italic = true, + 4 => self.current_underline = true, + 7 => std::mem::swap(&mut self.current_fg, &mut self.current_bg), + 22 => self.current_bold = false, + 23 => self.current_italic = false, + 24 => self.current_underline = false, + 27 => std::mem::swap(&mut self.current_fg, &mut self.current_bg), + 30..=37 => self.current_fg = Color::Indexed((code - 30) as u8), + 38 => { + // Extended foreground color + if i + 1 < params.num_params && params.is_sub_param[i + 1] { + let mode = params.params[i + 1]; + if mode == 5 && i + 2 < params.num_params { + self.current_fg = Color::Indexed(params.params[i + 2] as u8); + i += 2; + } else if mode == 2 && i + 4 < params.num_params { + self.current_fg = Color::Rgb( + params.params[i + 2] as u8, + params.params[i + 3] as u8, + params.params[i + 4] as u8, + ); + i += 4; } - _ => { - log::debug!("Unhandled DEC private mode reset: {}", param); + } else if i + 2 < params.num_params { + let mode = params.params[i + 1]; + if mode == 5 { + self.current_fg = Color::Indexed(params.params[i + 2] as u8); + i += 2; + } else if mode == 2 && i + 4 < params.num_params { + self.current_fg = Color::Rgb( + params.params[i + 2] as u8, + params.params[i + 3] as u8, + params.params[i + 4] as u8, + ); + i += 4; } } } + 39 => self.current_fg = Color::Default, + 40..=47 => self.current_bg = Color::Indexed((code - 40) as u8), + 48 => { + // Extended background color + if i + 1 < params.num_params && params.is_sub_param[i + 1] { + let mode = params.params[i + 1]; + if mode == 5 && i + 2 < params.num_params { + self.current_bg = Color::Indexed(params.params[i + 2] as u8); + i += 2; + } else if mode == 2 && i + 4 < params.num_params { + self.current_bg = Color::Rgb( + params.params[i + 2] as u8, + params.params[i + 3] as u8, + params.params[i + 4] as u8, + ); + i += 4; + } + } else if i + 2 < params.num_params { + let mode = params.params[i + 1]; + if mode == 5 { + self.current_bg = Color::Indexed(params.params[i + 2] as u8); + i += 2; + } else if mode == 2 && i + 4 < params.num_params { + self.current_bg = Color::Rgb( + params.params[i + 2] as u8, + params.params[i + 3] as u8, + params.params[i + 4] as u8, + ); + i += 4; + } + } + } + 49 => self.current_bg = Color::Default, + 90..=97 => self.current_fg = Color::Indexed((code - 90 + 8) as u8), + 100..=107 => self.current_bg = Color::Indexed((code - 100 + 8) as u8), + _ => {} + } + i += 1; + } + } + + /// Handle Kitty keyboard protocol CSI sequences. + fn handle_keyboard_protocol_csi(&mut self, params: &CsiParams) { + match params.primary { + b'?' => { + let response = query_response(self.keyboard.flags()); + self.response_queue.extend(response); + } + b'=' => { + let flags = params.get(0, 0) as u8; + let mode = params.get(1, 1) as u8; + self.keyboard.set_flags(flags, mode); + log::debug!("Keyboard flags set to {:?} (mode {})", self.keyboard.flags(), mode); + } + b'>' => { + let flags = if params.num_params == 0 { + None + } else { + Some(params.params[0] as u8) + }; + self.keyboard.push(flags); + log::debug!("Keyboard flags pushed: {:?}", self.keyboard.flags()); + } + b'<' => { + let count = params.get(0, 1) as usize; + self.keyboard.pop(count); + log::debug!("Keyboard flags popped: {:?}", self.keyboard.flags()); } _ => {} } } - fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {} + /// Handle DEC private mode set (CSI ? Ps h). + fn handle_dec_private_mode_set(&mut self, params: &CsiParams) { + for i in 0..params.num_params { + match params.params[i] { + 1 => { + self.application_cursor_keys = true; + log::debug!("DECCKM: Application cursor keys enabled"); + } + 7 => { + self.auto_wrap = true; + log::debug!("DECAWM: Auto-wrap enabled"); + } + 9 => { + self.mouse_tracking = MouseTrackingMode::X10; + log::debug!("Mouse tracking: X10 mode enabled"); + } + 25 => { + self.cursor_visible = true; + log::debug!("DECTCEM: cursor visible"); + } + 47 => self.enter_alternate_screen(), + 1000 => { + self.mouse_tracking = MouseTrackingMode::Normal; + log::debug!("Mouse tracking: Normal mode enabled"); + } + 1002 => { + self.mouse_tracking = MouseTrackingMode::ButtonEvent; + log::debug!("Mouse tracking: Button-event mode enabled"); + } + 1003 => { + self.mouse_tracking = MouseTrackingMode::AnyEvent; + log::debug!("Mouse tracking: Any-event mode enabled"); + } + 1004 => { + self.focus_reporting = true; + log::debug!("Focus event reporting enabled"); + } + 1005 => { + self.mouse_encoding = MouseEncoding::Utf8; + log::debug!("Mouse encoding: UTF-8"); + } + 1006 => { + self.mouse_encoding = MouseEncoding::Sgr; + log::debug!("Mouse encoding: SGR"); + } + 1015 => { + self.mouse_encoding = MouseEncoding::Urxvt; + log::debug!("Mouse encoding: URXVT"); + } + 1047 => self.enter_alternate_screen(), + 1048 => Handler::save_cursor(self), + 1049 => { + Handler::save_cursor(self); + self.enter_alternate_screen(); + } + 2004 => { + self.bracketed_paste = true; + log::debug!("Bracketed paste mode enabled"); + } + 2026 => { + self.synchronized_output = true; + log::trace!("Synchronized output enabled"); + } + _ => log::debug!("Unhandled DEC private mode set: {}", params.params[i]), + } + } + } + + /// Handle DEC private mode reset (CSI ? Ps l). + fn handle_dec_private_mode_reset(&mut self, params: &CsiParams) { + for i in 0..params.num_params { + match params.params[i] { + 1 => { + self.application_cursor_keys = false; + log::debug!("DECCKM: Normal cursor keys enabled"); + } + 7 => { + self.auto_wrap = false; + log::debug!("DECAWM: Auto-wrap disabled"); + } + 9 => { + if self.mouse_tracking == MouseTrackingMode::X10 { + self.mouse_tracking = MouseTrackingMode::None; + log::debug!("Mouse tracking: X10 mode disabled"); + } + } + 25 => { + self.cursor_visible = false; + log::debug!("DECTCEM: cursor hidden"); + } + 47 => self.leave_alternate_screen(), + 1000 => { + if self.mouse_tracking == MouseTrackingMode::Normal { + self.mouse_tracking = MouseTrackingMode::None; + log::debug!("Mouse tracking: Normal mode disabled"); + } + } + 1002 => { + if self.mouse_tracking == MouseTrackingMode::ButtonEvent { + self.mouse_tracking = MouseTrackingMode::None; + log::debug!("Mouse tracking: Button-event mode disabled"); + } + } + 1003 => { + if self.mouse_tracking == MouseTrackingMode::AnyEvent { + self.mouse_tracking = MouseTrackingMode::None; + log::debug!("Mouse tracking: Any-event mode disabled"); + } + } + 1004 => { + self.focus_reporting = false; + log::debug!("Focus event reporting disabled"); + } + 1005 => { + if self.mouse_encoding == MouseEncoding::Utf8 { + self.mouse_encoding = MouseEncoding::X10; + log::debug!("Mouse encoding: reset to X10"); + } + } + 1006 => { + if self.mouse_encoding == MouseEncoding::Sgr { + self.mouse_encoding = MouseEncoding::X10; + log::debug!("Mouse encoding: reset to X10"); + } + } + 1015 => { + if self.mouse_encoding == MouseEncoding::Urxvt { + self.mouse_encoding = MouseEncoding::X10; + log::debug!("Mouse encoding: reset to X10"); + } + } + 1047 => self.leave_alternate_screen(), + 1048 => Handler::restore_cursor(self), + 1049 => { + self.leave_alternate_screen(); + Handler::restore_cursor(self); + } + 2004 => { + self.bracketed_paste = false; + log::debug!("Bracketed paste mode disabled"); + } + 2026 => { + self.synchronized_output = false; + log::trace!("Synchronized output disabled"); + } + _ => log::debug!("Unhandled DEC private mode reset: {}", params.params[i]), + } + } + } } diff --git a/src/vt_parser.rs b/src/vt_parser.rs new file mode 100644 index 0000000..63e5077 --- /dev/null +++ b/src/vt_parser.rs @@ -0,0 +1,982 @@ +//! VT Parser - A high-performance terminal escape sequence parser. +//! +//! Based on Kitty's vt-parser.c design, this parser uses explicit state tracking +//! to enable fast-path processing of normal text while correctly handling +//! escape sequences. +//! +//! Key design principles from Kitty: +//! 1. UTF-8 decode until ESC sentinel is found (not byte-by-byte parsing) +//! 2. Pass decoded codepoints to the text handler, not raw bytes +//! 3. Control characters (LF, CR, TAB, BS, etc.) are handled inline in text drawing +//! 4. Only ESC triggers state machine transitions + +/// Maximum number of CSI parameters. +pub const MAX_CSI_PARAMS: usize = 256; + +/// Maximum length of an OSC string. +const MAX_OSC_LEN: usize = 4096; + +/// Maximum length of an escape sequence before we give up. +const MAX_ESCAPE_LEN: usize = 262144; // 256KB like Kitty + +/// Replacement character for invalid UTF-8. +const REPLACEMENT_CHAR: char = '\u{FFFD}'; + +/// UTF-8 decoder states (DFA-based, like Kitty uses). +const UTF8_ACCEPT: u8 = 0; +const UTF8_REJECT: u8 = 12; + +/// UTF-8 state transition and character class tables. +/// Based on Bjoern Hoehrmann's DFA decoder. +static UTF8_DECODE_TABLE: [u8; 364] = [ + // Character class lookup (0-255) + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, + 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, + 8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2, + 10,3,3,3,3,3,3,3,3,3,3,3,3,4,3,3, 11,6,6,6,5,8,8,8,8,8,8,8,8,8,8,8, + // State transition table + 0,12,24,36,60,96,84,12,12,12,48,72, 12,12,12,12,12,12,12,12,12,12,12,12, + 12, 0,12,12,12,12,12, 0,12, 0,12,12, 12,24,12,12,12,12,12,24,12,24,12,12, + 12,12,12,12,12,12,12,24,12,12,12,12, 12,24,12,12,12,12,12,12,12,24,12,12, + 12,12,12,12,12,12,12,36,12,36,12,12, 12,36,12,12,12,12,12,36,12,36,12,12, + 12,36,12,12,12,12,12,12,12,12,12,12, +]; + +/// Decode a single UTF-8 byte using DFA. +#[inline] +fn decode_utf8(state: &mut u8, codep: &mut u32, byte: u8) -> u8 { + let char_class = UTF8_DECODE_TABLE[byte as usize]; + *codep = if *state == UTF8_ACCEPT { + (0xFF >> char_class) as u32 & byte as u32 + } else { + (byte as u32 & 0x3F) | (*codep << 6) + }; + *state = UTF8_DECODE_TABLE[256 + *state as usize + char_class as usize]; + *state +} + +/// UTF-8 decoder that decodes until ESC (0x1B) is found. +/// Returns (output_chars, bytes_consumed, found_esc). +#[derive(Debug, Default)] +pub struct Utf8Decoder { + state: u8, + codep: u32, +} + +impl Utf8Decoder { + pub fn new() -> Self { + Self::default() + } + + pub fn reset(&mut self) { + self.state = UTF8_ACCEPT; + self.codep = 0; + } + + /// Decode UTF-8 bytes until ESC is found. + /// Outputs decoded codepoints to the output buffer. + /// Returns (bytes_consumed, found_esc). + #[inline] + pub fn decode_to_esc(&mut self, src: &[u8], output: &mut Vec) -> (usize, bool) { + output.clear(); + // Pre-allocate capacity to avoid reallocations during decode. + // Worst case: one char per byte (ASCII). Kitty does the same. + output.reserve(src.len()); + let mut consumed = 0; + + for &byte in src { + consumed += 1; + + if byte == 0x1B { + // ESC found - emit replacement if we were in the middle of a sequence + if self.state != UTF8_ACCEPT { + output.push(REPLACEMENT_CHAR); + } + self.reset(); + return (consumed, true); + } + + let prev_state = self.state; + match decode_utf8(&mut self.state, &mut self.codep, byte) { + UTF8_ACCEPT => { + // Safe because we control the codepoint values from valid UTF-8 + if let Some(c) = char::from_u32(self.codep) { + output.push(c); + } + } + UTF8_REJECT => { + // Invalid UTF-8 sequence + output.push(REPLACEMENT_CHAR); + self.state = UTF8_ACCEPT; + // If previous state was accept, we consumed a bad lead byte + // Otherwise, re-process this byte as a potential new sequence start + if prev_state != UTF8_ACCEPT { + consumed -= 1; + continue; + } + } + _ => { + // Continue accumulating multi-byte sequence + } + } + } + + (consumed, false) + } +} + +/// Parser state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum State { + /// Normal text processing mode. + Normal, + /// Just saw ESC, waiting for next character. + Escape, + /// ESC seen, waiting for second char of two-char sequence (e.g., ESC ( B). + EscapeIntermediate(u8), + /// Processing CSI sequence (ESC [). + Csi, + /// Processing OSC sequence (ESC ]). + Osc, + /// Processing DCS sequence (ESC P). + Dcs, + /// Processing APC sequence (ESC _). + Apc, + /// Processing PM sequence (ESC ^). + Pm, + /// Processing SOS sequence (ESC X). + Sos, +} + +impl Default for State { + fn default() -> Self { + State::Normal + } +} + +/// CSI parsing sub-state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum CsiState { + #[default] + Start, + Body, + PostSecondary, +} + +/// Parsed CSI sequence data. +#[derive(Debug, Clone)] +pub struct CsiParams { + /// Collected parameters. + pub params: [i32; MAX_CSI_PARAMS], + /// Which parameters are sub-parameters (colon-separated). + pub is_sub_param: [bool; MAX_CSI_PARAMS], + /// Number of collected parameters. + pub num_params: usize, + /// Primary modifier (e.g., '?' in CSI ? Ps h). + pub primary: u8, + /// Secondary modifier (e.g., '$' in CSI Ps $ p). + pub secondary: u8, + /// Final character (e.g., 'm' in CSI 1 m). + pub final_char: u8, + /// Whether the sequence is valid. + pub is_valid: bool, + // Internal parsing state + state: CsiState, + accumulator: i64, + multiplier: i32, + num_digits: usize, +} + +impl Default for CsiParams { + fn default() -> Self { + Self { + params: [0; MAX_CSI_PARAMS], + is_sub_param: [false; MAX_CSI_PARAMS], + num_params: 0, + primary: 0, + secondary: 0, + final_char: 0, + is_valid: false, + state: CsiState::Start, + accumulator: 0, + multiplier: 1, + num_digits: 0, + } + } +} + +impl CsiParams { + /// Reset for a new CSI sequence. + pub fn reset(&mut self) { + self.params = [0; MAX_CSI_PARAMS]; + self.is_sub_param = [false; MAX_CSI_PARAMS]; + self.num_params = 0; + self.primary = 0; + self.secondary = 0; + self.final_char = 0; + self.is_valid = false; + self.state = CsiState::Start; + self.accumulator = 0; + self.multiplier = 1; + self.num_digits = 0; + } + + /// Get parameter at index, or default value if not present. + #[inline] + pub fn get(&self, index: usize, default: i32) -> i32 { + if index < self.num_params && self.params[index] != 0 { + self.params[index] + } else { + default + } + } + + /// Add a digit to the current parameter. + #[inline] + fn add_digit(&mut self, digit: u8) { + self.accumulator = self.accumulator.saturating_mul(10).saturating_add((digit - b'0') as i64); + self.num_digits += 1; + } + + /// Commit the current parameter. + fn commit_param(&mut self) -> bool { + if self.num_params >= MAX_CSI_PARAMS { + return false; + } + let value = (self.accumulator as i32).saturating_mul(self.multiplier); + self.params[self.num_params] = value; + self.num_params += 1; + self.accumulator = 0; + self.multiplier = 1; + self.num_digits = 0; + true + } +} + +/// VT Parser with Kitty-style state tracking. +#[derive(Debug)] +pub struct Parser { + /// Current parser state. + pub state: State, + /// CSI parameters being collected. + pub csi: CsiParams, + /// UTF-8 decoder for text. + utf8: Utf8Decoder, + /// Decoded character buffer (reused to avoid allocation). + char_buf: Vec, + /// OSC string buffer. + osc_buffer: Vec, + /// DCS/APC/PM/SOS string buffer. + string_buffer: Vec, + /// Intermediate byte for two-char escape sequences. + intermediate: u8, + /// Number of bytes consumed in current escape sequence (for max length check). + escape_len: usize, +} + +impl Default for Parser { + fn default() -> Self { + Self { + state: State::Normal, + csi: CsiParams::default(), + utf8: Utf8Decoder::new(), + // Pre-allocate to match typical read buffer sizes (1MB) to avoid reallocation + char_buf: Vec::with_capacity(1024 * 1024), + osc_buffer: Vec::new(), + string_buffer: Vec::new(), + intermediate: 0, + escape_len: 0, + } + } +} + +impl Parser { + /// Create a new parser. + pub fn new() -> Self { + Self::default() + } + + /// Check if parser is in normal (ground) state. + #[inline] + pub fn is_normal(&self) -> bool { + self.state == State::Normal + } + + /// Reset parser to normal state. + pub fn reset(&mut self) { + self.state = State::Normal; + self.csi.reset(); + self.utf8.reset(); + self.char_buf.clear(); + self.osc_buffer.clear(); + self.string_buffer.clear(); + self.intermediate = 0; + self.escape_len = 0; + } + + /// Process a buffer of bytes, calling the handler for each action. + /// Returns the number of bytes consumed. + pub fn parse(&mut self, bytes: &[u8], handler: &mut H) -> usize { + let mut pos = 0; + + while pos < bytes.len() { + match self.state { + State::Normal => { + // Fast path: UTF-8 decode until ESC + let (consumed, found_esc) = self.utf8.decode_to_esc(&bytes[pos..], &mut self.char_buf); + + // Process decoded characters (text + control chars) + if !self.char_buf.is_empty() { + handler.text(&self.char_buf); + } + + pos += consumed; + + if found_esc { + self.state = State::Escape; + self.escape_len = 0; + } + } + State::Escape => { + pos += self.consume_escape(bytes, pos, handler); + } + State::EscapeIntermediate(_) => { + pos += self.consume_escape_intermediate(bytes, pos, handler); + } + State::Csi => { + pos += self.consume_csi(bytes, pos, handler); + } + State::Osc => { + pos += self.consume_osc(bytes, pos, handler); + } + State::Dcs | State::Apc | State::Pm | State::Sos => { + pos += self.consume_string_command(bytes, pos, handler); + } + } + } + + pos + } + + /// Process bytes after ESC. + fn consume_escape(&mut self, bytes: &[u8], pos: usize, handler: &mut H) -> usize { + if pos >= bytes.len() { + return 0; + } + + let ch = bytes[pos]; + self.escape_len += 1; + + match ch { + // CSI: ESC [ + b'[' => { + self.state = State::Csi; + self.csi.reset(); + 1 + } + // OSC: ESC ] + b']' => { + self.state = State::Osc; + self.osc_buffer.clear(); + 1 + } + // DCS: ESC P + b'P' => { + self.state = State::Dcs; + self.string_buffer.clear(); + 1 + } + // APC: ESC _ + b'_' => { + self.state = State::Apc; + self.string_buffer.clear(); + 1 + } + // PM: ESC ^ + b'^' => { + self.state = State::Pm; + self.string_buffer.clear(); + 1 + } + // SOS: ESC X + b'X' => { + self.state = State::Sos; + self.string_buffer.clear(); + 1 + } + // Two-char sequences: ESC ( ESC ) ESC # ESC % ESC SP etc. + b'(' | b')' | b'*' | b'+' | b'-' | b'.' | b'/' | b'%' | b'#' | b' ' => { + self.state = State::EscapeIntermediate(ch); + self.intermediate = ch; + 1 + } + // Single-char escape sequences + b'7' => { + // DECSC - Save cursor + handler.save_cursor(); + self.state = State::Normal; + 1 + } + b'8' => { + // DECRC - Restore cursor + handler.restore_cursor(); + self.state = State::Normal; + 1 + } + b'c' => { + // RIS - Full reset + handler.reset(); + self.state = State::Normal; + 1 + } + b'D' => { + // IND - Index (move down, scroll if needed) + handler.index(); + self.state = State::Normal; + 1 + } + b'E' => { + // NEL - Next line + handler.newline(); + self.state = State::Normal; + 1 + } + b'H' => { + // HTS - Horizontal tab set + handler.set_tab_stop(); + self.state = State::Normal; + 1 + } + b'M' => { + // RI - Reverse index + handler.reverse_index(); + self.state = State::Normal; + 1 + } + b'=' => { + // DECKPAM - Application keypad mode + handler.set_keypad_mode(true); + self.state = State::Normal; + 1 + } + b'>' => { + // DECKPNM - Normal keypad mode + handler.set_keypad_mode(false); + self.state = State::Normal; + 1 + } + b'\\' => { + // ST - String terminator (ignore if not in string mode) + self.state = State::Normal; + 1 + } + _ => { + // Unknown escape sequence, ignore and return to normal + log::debug!("Unknown escape sequence: ESC {:02x}", ch); + self.state = State::Normal; + 1 + } + } + } + + /// Process second byte of two-char escape sequence. + fn consume_escape_intermediate(&mut self, bytes: &[u8], pos: usize, handler: &mut H) -> usize { + if pos >= bytes.len() { + return 0; + } + + let ch = bytes[pos]; + let intermediate = self.intermediate; + self.escape_len += 1; + self.state = State::Normal; + + match intermediate { + b'(' | b')' => { + // Designate character set G0/G1 + let set = if intermediate == b'(' { 0 } else { 1 }; + handler.designate_charset(set, ch); + } + b'#' => { + if ch == b'8' { + // DECALN - Screen alignment test + handler.screen_alignment(); + } + } + b'%' => { + // Character set selection (we always use UTF-8) + } + b' ' => { + // S7C1T / S8C1T - we ignore these + } + _ => {} + } + + 1 + } + + /// Process CSI sequence bytes. + fn consume_csi(&mut self, bytes: &[u8], pos: usize, handler: &mut H) -> usize { + let mut consumed = 0; + + while pos + consumed < bytes.len() { + let ch = bytes[pos + consumed]; + consumed += 1; + self.escape_len += 1; + + // Check for max length + if self.escape_len > MAX_ESCAPE_LEN { + log::debug!("CSI sequence too long, aborting"); + self.state = State::Normal; + return consumed; + } + + match self.csi.state { + CsiState::Start => { + match ch { + // Control characters embedded in CSI - handle them + 0x00..=0x1F => { + // Handle control chars (except ESC which would be weird here) + if ch != 0x1B { + handler.control(ch); + } + } + b';' => { + // Empty parameter = 0 + self.csi.params[self.csi.num_params] = 0; + self.csi.num_params += 1; + self.csi.state = CsiState::Body; + } + b'0'..=b'9' => { + self.csi.add_digit(ch); + self.csi.state = CsiState::Body; + } + b'?' | b'>' | b'<' | b'=' => { + self.csi.primary = ch; + self.csi.state = CsiState::Body; + } + b' ' | b'\'' | b'"' | b'!' | b'$' => { + self.csi.secondary = ch; + self.csi.state = CsiState::PostSecondary; + } + b'-' => { + self.csi.multiplier = -1; + self.csi.num_digits = 1; + self.csi.state = CsiState::Body; + } + // Final byte + b'@'..=b'~' => { + self.csi.final_char = ch; + self.csi.is_valid = true; + self.dispatch_csi(handler); + self.state = State::Normal; + return consumed; + } + _ => { + log::debug!("Invalid CSI character: {:02x}", ch); + self.state = State::Normal; + return consumed; + } + } + } + CsiState::Body => { + match ch { + 0x00..=0x1F => { + if ch != 0x1B { + handler.control(ch); + } + } + b'0'..=b'9' => { + self.csi.add_digit(ch); + } + b';' => { + if self.csi.num_digits == 0 { + self.csi.num_digits = 1; // Empty = 0 + } + if !self.csi.commit_param() { + self.state = State::Normal; + return consumed; + } + self.csi.is_sub_param[self.csi.num_params] = false; + } + b':' => { + if !self.csi.commit_param() { + self.state = State::Normal; + return consumed; + } + self.csi.is_sub_param[self.csi.num_params] = true; + } + b' ' | b'\'' | b'"' | b'!' | b'$' | b'#' | b'*' => { + if !self.csi.commit_param() { + self.state = State::Normal; + return consumed; + } + self.csi.secondary = ch; + self.csi.state = CsiState::PostSecondary; + } + b'-' if self.csi.num_digits == 0 => { + self.csi.multiplier = -1; + self.csi.num_digits = 1; + } + // Final byte + b'@'..=b'~' => { + if self.csi.num_digits > 0 || self.csi.num_params > 0 { + self.csi.commit_param(); + } + self.csi.final_char = ch; + self.csi.is_valid = true; + self.dispatch_csi(handler); + self.state = State::Normal; + return consumed; + } + _ => { + log::debug!("Invalid CSI body character: {:02x}", ch); + self.state = State::Normal; + return consumed; + } + } + } + CsiState::PostSecondary => { + match ch { + 0x00..=0x1F => { + if ch != 0x1B { + handler.control(ch); + } + } + // Final byte + b'@'..=b'~' => { + self.csi.final_char = ch; + self.csi.is_valid = true; + self.dispatch_csi(handler); + self.state = State::Normal; + return consumed; + } + _ => { + log::debug!("Invalid CSI post-secondary character: {:02x}", ch); + self.state = State::Normal; + return consumed; + } + } + } + } + } + + consumed + } + + /// Dispatch a complete CSI sequence to the handler. + fn dispatch_csi(&mut self, handler: &mut H) { + handler.csi(&self.csi); + } + + /// Process OSC sequence bytes. + fn consume_osc(&mut self, bytes: &[u8], pos: usize, handler: &mut H) -> usize { + let mut consumed = 0; + + while pos + consumed < bytes.len() { + let ch = bytes[pos + consumed]; + consumed += 1; + self.escape_len += 1; + + // Check for max length + if self.escape_len > MAX_ESCAPE_LEN || self.osc_buffer.len() > MAX_OSC_LEN { + log::debug!("OSC sequence too long, aborting"); + self.state = State::Normal; + return consumed; + } + + match ch { + // BEL terminates OSC + 0x07 => { + handler.osc(&self.osc_buffer); + self.state = State::Normal; + return consumed; + } + // ESC \ (ST) terminates OSC + 0x1B => { + // Need to peek at next byte + if pos + consumed < bytes.len() && bytes[pos + consumed] == b'\\' { + consumed += 1; + handler.osc(&self.osc_buffer); + self.state = State::Normal; + return consumed; + } else { + // ESC not followed by \, dispatch what we have + handler.osc(&self.osc_buffer); + self.state = State::Escape; + return consumed; + } + } + // C1 ST (0x9C) terminates OSC + 0x9C => { + handler.osc(&self.osc_buffer); + self.state = State::Normal; + return consumed; + } + _ => { + self.osc_buffer.push(ch); + } + } + } + + consumed + } + + /// Process DCS/APC/PM/SOS sequence bytes (string commands terminated by ST). + fn consume_string_command(&mut self, bytes: &[u8], pos: usize, handler: &mut H) -> usize { + let mut consumed = 0; + + while pos + consumed < bytes.len() { + let ch = bytes[pos + consumed]; + consumed += 1; + self.escape_len += 1; + + // Check for max length + if self.escape_len > MAX_ESCAPE_LEN { + log::debug!("String command too long, aborting"); + self.state = State::Normal; + return consumed; + } + + match ch { + // ESC \ (ST) terminates + 0x1B => { + if pos + consumed < bytes.len() && bytes[pos + consumed] == b'\\' { + consumed += 1; + // Dispatch based on original state + match self.state { + State::Dcs => handler.dcs(&self.string_buffer), + State::Apc => handler.apc(&self.string_buffer), + State::Pm => handler.pm(&self.string_buffer), + State::Sos => handler.sos(&self.string_buffer), + _ => {} + } + self.state = State::Normal; + return consumed; + } else { + self.string_buffer.push(ch); + } + } + // C1 ST (0x9C) terminates + 0x9C => { + match self.state { + State::Dcs => handler.dcs(&self.string_buffer), + State::Apc => handler.apc(&self.string_buffer), + State::Pm => handler.pm(&self.string_buffer), + State::Sos => handler.sos(&self.string_buffer), + _ => {} + } + self.state = State::Normal; + return consumed; + } + _ => { + self.string_buffer.push(ch); + } + } + } + + consumed + } +} + +/// Handler trait for responding to parsed escape sequences. +/// +/// Unlike the vte crate's Perform trait, this trait receives decoded characters +/// (not bytes) for text, and control characters are expected to be handled +/// inline in the text() method (like Kitty does). +pub trait Handler { + /// Handle a chunk of decoded text (Unicode codepoints). + /// + /// This includes control characters (0x00-0x1F except ESC). + /// The handler should process control chars like: + /// - LF (0x0A), VT (0x0B), FF (0x0C): line feed + /// - CR (0x0D): carriage return + /// - HT (0x09): tab + /// - BS (0x08): backspace + /// - BEL (0x07): bell + /// + /// ESC is never passed to this method - it triggers state transitions. + fn text(&mut self, chars: &[char]); + + /// Handle a single control character embedded in a CSI/OSC sequence. + /// This is called for control chars (0x00-0x1F) that appear inside + /// escape sequences, which should still be processed. + fn control(&mut self, byte: u8); + + /// Handle a complete CSI sequence. + fn csi(&mut self, params: &CsiParams); + + /// Handle a complete OSC sequence. + fn osc(&mut self, data: &[u8]); + + /// Handle a DCS sequence. + fn dcs(&mut self, _data: &[u8]) {} + + /// Handle an APC sequence. + fn apc(&mut self, _data: &[u8]) {} + + /// Handle a PM sequence. + fn pm(&mut self, _data: &[u8]) {} + + /// Handle a SOS sequence. + fn sos(&mut self, _data: &[u8]) {} + + /// Save cursor position (DECSC). + fn save_cursor(&mut self) {} + + /// Restore cursor position (DECRC). + fn restore_cursor(&mut self) {} + + /// Full terminal reset (RIS). + fn reset(&mut self) {} + + /// Index - move cursor down, scroll if at bottom (IND). + fn index(&mut self) {} + + /// Newline - carriage return + line feed (NEL). + fn newline(&mut self) {} + + /// Reverse index - move cursor up, scroll if at top (RI). + fn reverse_index(&mut self) {} + + /// Set tab stop at current position (HTS). + fn set_tab_stop(&mut self) {} + + /// Set keypad application/normal mode. + fn set_keypad_mode(&mut self, _application: bool) {} + + /// Designate character set. + fn designate_charset(&mut self, _set: u8, _charset: u8) {} + + /// Screen alignment test (DECALN). + fn screen_alignment(&mut self) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestHandler { + text_chunks: Vec>, + csi_count: usize, + osc_count: usize, + control_chars: Vec, + } + + impl TestHandler { + fn new() -> Self { + Self { + text_chunks: Vec::new(), + csi_count: 0, + osc_count: 0, + control_chars: Vec::new(), + } + } + } + + impl Handler for TestHandler { + fn text(&mut self, chars: &[char]) { + self.text_chunks.push(chars.to_vec()); + } + + fn control(&mut self, byte: u8) { + self.control_chars.push(byte); + } + + fn csi(&mut self, _params: &CsiParams) { + self.csi_count += 1; + } + + fn osc(&mut self, _data: &[u8]) { + self.osc_count += 1; + } + } + + #[test] + fn test_plain_text() { + let mut parser = Parser::new(); + let mut handler = TestHandler::new(); + + parser.parse(b"Hello, World!", &mut handler); + + assert_eq!(handler.text_chunks.len(), 1); + let text: String = handler.text_chunks[0].iter().collect(); + assert_eq!(text, "Hello, World!"); + } + + #[test] + fn test_utf8_text() { + let mut parser = Parser::new(); + let mut handler = TestHandler::new(); + + parser.parse("Hello, 世界!".as_bytes(), &mut handler); + + assert_eq!(handler.text_chunks.len(), 1); + let text: String = handler.text_chunks[0].iter().collect(); + assert_eq!(text, "Hello, 世界!"); + } + + #[test] + fn test_control_chars_in_text() { + let mut parser = Parser::new(); + let mut handler = TestHandler::new(); + + // Text with LF and CR + parser.parse(b"Hello\nWorld\r!", &mut handler); + + assert_eq!(handler.text_chunks.len(), 1); + let text: String = handler.text_chunks[0].iter().collect(); + assert_eq!(text, "Hello\nWorld\r!"); + } + + #[test] + fn test_csi_sequence() { + let mut parser = Parser::new(); + let mut handler = TestHandler::new(); + + // ESC [ 1 ; 2 m (SGR bold + dim) + parser.parse(b"\x1b[1;2m", &mut handler); + + assert_eq!(handler.csi_count, 1); + } + + #[test] + fn test_mixed_text_and_csi() { + let mut parser = Parser::new(); + let mut handler = TestHandler::new(); + + parser.parse(b"Hello\x1b[1mWorld", &mut handler); + + assert_eq!(handler.text_chunks.len(), 2); + let text1: String = handler.text_chunks[0].iter().collect(); + let text2: String = handler.text_chunks[1].iter().collect(); + assert_eq!(text1, "Hello"); + assert_eq!(text2, "World"); + assert_eq!(handler.csi_count, 1); + } + + #[test] + fn test_osc_sequence() { + let mut parser = Parser::new(); + let mut handler = TestHandler::new(); + + // OSC 0 ; title BEL + parser.parse(b"\x1b]0;My Title\x07", &mut handler); + + assert_eq!(handler.osc_count, 1); + } + + #[test] + fn test_csi_with_subparams() { + let mut parser = Parser::new(); + let mut handler = TestHandler::new(); + + // CSI 38:2:255:128:64 m (RGB foreground with colon separators) + parser.parse(b"\x1b[38:2:255:128:64m", &mut handler); + + assert_eq!(handler.csi_count, 1); + } +} diff --git a/src/window_state.rs b/src/window_state.rs deleted file mode 100644 index 6821473..0000000 --- a/src/window_state.rs +++ /dev/null @@ -1,963 +0,0 @@ -//! Window state management for tabs and panes. -//! -//! The daemon maintains the full UI state including tabs, panes, and which is active. - -use crate::protocol::{Direction, PaneId, PaneInfo, SessionId, SplitDirection, TabId, TabInfo, WindowState as ProtocolWindowState}; -use crate::session::Session; -use std::collections::HashMap; - -/// Check if two ranges overlap. -fn ranges_overlap(a_start: usize, a_end: usize, b_start: usize, b_end: usize) -> bool { - a_start < b_end && b_start < a_end -} - -/// A pane within a tab. -pub struct Pane { - pub id: PaneId, - pub session_id: SessionId, - /// Position in cells (for future splits). - pub x: usize, - pub y: usize, - /// Size in cells. - pub cols: usize, - pub rows: usize, -} - -impl Pane { - pub fn new(id: PaneId, session_id: SessionId, cols: usize, rows: usize) -> Self { - Self { - id, - session_id, - x: 0, - y: 0, - cols, - rows, - } - } - - pub fn new_at(id: PaneId, session_id: SessionId, x: usize, y: usize, cols: usize, rows: usize) -> Self { - Self { - id, - session_id, - x, - y, - cols, - rows, - } - } - - pub fn to_info(&self) -> PaneInfo { - PaneInfo { - id: self.id, - session_id: self.session_id, - x: self.x, - y: self.y, - cols: self.cols, - rows: self.rows, - } - } -} - -/// A tab containing one or more panes. -pub struct Tab { - pub id: TabId, - pub panes: Vec, - pub active_pane: usize, -} - -impl Tab { - pub fn new(id: TabId, pane: Pane) -> Self { - Self { - id, - panes: vec![pane], - active_pane: 0, - } - } - - pub fn active_pane(&self) -> Option<&Pane> { - self.panes.get(self.active_pane) - } - - pub fn to_info(&self) -> TabInfo { - TabInfo { - id: self.id, - active_pane: self.active_pane, - panes: self.panes.iter().map(|p| p.to_info()).collect(), - } - } -} - -/// Manages all window state: tabs, panes, sessions. -pub struct WindowStateManager { - /// All sessions, keyed by session ID. - pub sessions: HashMap, - /// All tabs in order. - pub tabs: Vec, - /// Index of the active tab. - pub active_tab: usize, - /// Terminal dimensions in cells. - pub cols: usize, - pub rows: usize, - /// Next session ID to assign. - next_session_id: SessionId, - /// Next pane ID to assign. - next_pane_id: PaneId, - /// Next tab ID to assign. - next_tab_id: TabId, -} - -impl WindowStateManager { - /// Creates a new window state manager with initial dimensions. - pub fn new(cols: usize, rows: usize) -> Self { - Self { - sessions: HashMap::new(), - tabs: Vec::new(), - active_tab: 0, - cols, - rows, - next_session_id: 0, - next_pane_id: 0, - next_tab_id: 0, - } - } - - /// Creates the initial tab with a single session. - pub fn create_initial_tab(&mut self) -> Result<(), crate::pty::PtyError> { - let session_id = self.next_session_id; - self.next_session_id += 1; - - let session = Session::new(session_id, self.cols, self.rows)?; - self.sessions.insert(session_id, session); - - let pane_id = self.next_pane_id; - self.next_pane_id += 1; - - let pane = Pane::new(pane_id, session_id, self.cols, self.rows); - - let tab_id = self.next_tab_id; - self.next_tab_id += 1; - - let tab = Tab::new(tab_id, pane); - self.tabs.push(tab); - - Ok(()) - } - - /// Creates a new tab with a new session. - pub fn create_tab(&mut self) -> Result { - let session_id = self.next_session_id; - self.next_session_id += 1; - - let session = Session::new(session_id, self.cols, self.rows)?; - self.sessions.insert(session_id, session); - - let pane_id = self.next_pane_id; - self.next_pane_id += 1; - - let pane = Pane::new(pane_id, session_id, self.cols, self.rows); - - let tab_id = self.next_tab_id; - self.next_tab_id += 1; - - let tab = Tab::new(tab_id, pane); - self.tabs.push(tab); - - // Switch to the new tab - self.active_tab = self.tabs.len() - 1; - - Ok(tab_id) - } - - /// Closes a tab and its sessions. - pub fn close_tab(&mut self, tab_id: TabId) -> bool { - if let Some(idx) = self.tabs.iter().position(|t| t.id == tab_id) { - let tab = self.tabs.remove(idx); - - // Remove all sessions owned by this tab's panes - for pane in &tab.panes { - self.sessions.remove(&pane.session_id); - } - - // Adjust active tab index - if self.tabs.is_empty() { - self.active_tab = 0; - } else if self.active_tab >= self.tabs.len() { - self.active_tab = self.tabs.len() - 1; - } - - true - } else { - false - } - } - - /// Switches to a tab by ID. - pub fn switch_tab(&mut self, tab_id: TabId) -> bool { - if let Some(idx) = self.tabs.iter().position(|t| t.id == tab_id) { - self.active_tab = idx; - true - } else { - false - } - } - - /// Switches to the next tab (wrapping around). - pub fn next_tab(&mut self) -> bool { - if self.tabs.is_empty() { - return false; - } - self.active_tab = (self.active_tab + 1) % self.tabs.len(); - true - } - - /// Switches to the previous tab (wrapping around). - pub fn prev_tab(&mut self) -> bool { - if self.tabs.is_empty() { - return false; - } - if self.active_tab == 0 { - self.active_tab = self.tabs.len() - 1; - } else { - self.active_tab -= 1; - } - true - } - - /// Switches to a tab by index (0-based). - pub fn switch_tab_index(&mut self, index: usize) -> bool { - if index < self.tabs.len() { - self.active_tab = index; - true - } else { - false - } - } - - /// Splits the active pane in the active tab. - /// Returns (tab_id, new_pane_info) on success. - pub fn split_pane(&mut self, direction: SplitDirection) -> Result<(TabId, PaneInfo), crate::pty::PtyError> { - let tab = self.tabs.get_mut(self.active_tab) - .ok_or_else(|| crate::pty::PtyError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "No active tab")))?; - - let tab_id = tab.id; - let active_pane = tab.panes.get_mut(tab.active_pane) - .ok_or_else(|| crate::pty::PtyError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "No active pane")))?; - - // Calculate new dimensions - let (new_x, new_y, new_cols, new_rows, orig_cols, orig_rows) = match direction { - SplitDirection::Horizontal => { - // Split top/bottom: new pane goes below - let half_rows = active_pane.rows / 2; - let new_rows = active_pane.rows - half_rows; - let new_y = active_pane.y + half_rows; - ( - active_pane.x, - new_y, - active_pane.cols, - new_rows, - active_pane.cols, - half_rows, - ) - } - SplitDirection::Vertical => { - // Split left/right: new pane goes to the right - let half_cols = active_pane.cols / 2; - let new_cols = active_pane.cols - half_cols; - let new_x = active_pane.x + half_cols; - ( - new_x, - active_pane.y, - new_cols, - active_pane.rows, - half_cols, - active_pane.rows, - ) - } - }; - - // Update original pane dimensions - let orig_session_id = active_pane.session_id; - active_pane.cols = orig_cols; - active_pane.rows = orig_rows; - - // Resize the original session - if let Some(session) = self.sessions.get_mut(&orig_session_id) { - session.resize(orig_cols, orig_rows); - } - - // Create new session for the new pane - let session_id = self.next_session_id; - self.next_session_id += 1; - - let session = Session::new(session_id, new_cols, new_rows)?; - self.sessions.insert(session_id, session); - - // Create new pane - let pane_id = self.next_pane_id; - self.next_pane_id += 1; - - let new_pane = Pane::new_at(pane_id, session_id, new_x, new_y, new_cols, new_rows); - let pane_info = new_pane.to_info(); - - // Add pane to tab and focus it - let tab = self.tabs.get_mut(self.active_tab).unwrap(); - tab.panes.push(new_pane); - tab.active_pane = tab.panes.len() - 1; - - Ok((tab_id, pane_info)) - } - - /// Closes the active pane in the active tab. - /// Returns Some((tab_id, pane_id, tab_closed)) on success. - /// If tab_closed is true, the tab was removed because it was the last pane. - pub fn close_pane(&mut self) -> Option<(TabId, PaneId, bool)> { - let tab = self.tabs.get_mut(self.active_tab)?; - let tab_id = tab.id; - - if tab.panes.is_empty() { - return None; - } - - // Capture closed pane's geometry before removing - let closed_pane = tab.panes.remove(tab.active_pane); - let pane_id = closed_pane.id; - let closed_x = closed_pane.x; - let closed_y = closed_pane.y; - let closed_cols = closed_pane.cols; - let closed_rows = closed_pane.rows; - - // Remove the session - self.sessions.remove(&closed_pane.session_id); - - // If this was the last pane, close the tab - if tab.panes.is_empty() { - self.tabs.remove(self.active_tab); - - // Adjust active tab index - if !self.tabs.is_empty() && self.active_tab >= self.tabs.len() { - self.active_tab = self.tabs.len() - 1; - } - - return Some((tab_id, pane_id, true)); - } - - // Adjust active pane index - if tab.active_pane >= tab.panes.len() { - tab.active_pane = tab.panes.len() - 1; - } - - // Recalculate pane layouts after closing - pass closed pane geometry - self.recalculate_pane_layout_after_close( - self.active_tab, - closed_x, - closed_y, - closed_cols, - closed_rows, - ); - - Some((tab_id, pane_id, false)) - } - - /// Focuses a pane in the given direction from the current pane. - /// Returns (tab_id, new_active_pane_index) on success. - pub fn focus_pane_direction(&mut self, direction: Direction) -> Option<(TabId, usize)> { - let tab = self.tabs.get_mut(self.active_tab)?; - let tab_id = tab.id; - - if tab.panes.len() <= 1 { - return None; - } - - let current_pane = tab.panes.get(tab.active_pane)?; - - // Current pane's bounding box and center - let curr_x = current_pane.x; - let curr_y = current_pane.y; - let curr_right = curr_x + current_pane.cols; - let curr_bottom = curr_y + current_pane.rows; - let curr_center_x = curr_x + current_pane.cols / 2; - let curr_center_y = curr_y + current_pane.rows / 2; - - let mut best_idx: Option = None; - let mut best_score: Option<(i64, i64)> = None; // (negative_overlap, distance) - lower is better - - for (idx, pane) in tab.panes.iter().enumerate() { - if idx == tab.active_pane { - continue; - } - - let pane_x = pane.x; - let pane_y = pane.y; - let pane_right = pane_x + pane.cols; - let pane_bottom = pane_y + pane.rows; - - // Check if pane is in the correct direction - let in_direction = match direction { - Direction::Up => pane_bottom <= curr_y, - Direction::Down => pane_y >= curr_bottom, - Direction::Left => pane_right <= curr_x, - Direction::Right => pane_x >= curr_right, - }; - - if !in_direction { - continue; - } - - // Calculate overlap on perpendicular axis and distance - let (overlap, distance) = match direction { - Direction::Up | Direction::Down => { - // Horizontal overlap - let overlap_start = curr_x.max(pane_x); - let overlap_end = curr_right.min(pane_right); - let overlap = if overlap_end > overlap_start { - (overlap_end - overlap_start) as i64 - } else { - 0 - }; - - // Vertical distance (edge to edge) - let dist = match direction { - Direction::Up => (curr_y as i64) - (pane_bottom as i64), - Direction::Down => (pane_y as i64) - (curr_bottom as i64), - _ => unreachable!(), - }; - - (overlap, dist) - } - Direction::Left | Direction::Right => { - // Vertical overlap - let overlap_start = curr_y.max(pane_y); - let overlap_end = curr_bottom.min(pane_bottom); - let overlap = if overlap_end > overlap_start { - (overlap_end - overlap_start) as i64 - } else { - 0 - }; - - // Horizontal distance (edge to edge) - let dist = match direction { - Direction::Left => (curr_x as i64) - (pane_right as i64), - Direction::Right => (pane_x as i64) - (curr_right as i64), - _ => unreachable!(), - }; - - (overlap, dist) - } - }; - - // Score: prefer more overlap (so negate it), then prefer closer distance - let score = (-overlap, distance); - - if best_score.is_none() || score < best_score.unwrap() { - best_score = Some(score); - best_idx = Some(idx); - } - } - - // If no exact directional match, try a fallback: find the nearest pane - // in the general direction (allowing for some tolerance) - if best_idx.is_none() { - for (idx, pane) in tab.panes.iter().enumerate() { - if idx == tab.active_pane { - continue; - } - - let pane_center_x = pane.x + pane.cols / 2; - let pane_center_y = pane.y + pane.rows / 2; - - // Check if pane center is in the general direction - let in_general_direction = match direction { - Direction::Up => pane_center_y < curr_center_y, - Direction::Down => pane_center_y > curr_center_y, - Direction::Left => pane_center_x < curr_center_x, - Direction::Right => pane_center_x > curr_center_x, - }; - - if !in_general_direction { - continue; - } - - // Calculate distance from center to center - let dx = (pane_center_x as i64) - (curr_center_x as i64); - let dy = (pane_center_y as i64) - (curr_center_y as i64); - let distance = dx * dx + dy * dy; // squared distance is fine for comparison - - let score = (0i64, distance); // no overlap bonus for fallback - - if best_score.is_none() || score < best_score.unwrap() { - best_score = Some(score); - best_idx = Some(idx); - } - } - } - - if let Some(idx) = best_idx { - tab.active_pane = idx; - Some((tab_id, idx)) - } else { - None - } - } - - /// Recalculates pane layout after a pane is closed. - /// Expands neighboring panes to fill the closed pane's space. - /// Prefers expanding smaller panes to balance the layout. - fn recalculate_pane_layout_after_close( - &mut self, - tab_idx: usize, - closed_x: usize, - closed_y: usize, - closed_cols: usize, - closed_rows: usize, - ) { - let Some(tab) = self.tabs.get_mut(tab_idx) else { - return; - }; - - // If only one pane remains, give it full size - if tab.panes.len() == 1 { - let pane = &mut tab.panes[0]; - pane.x = 0; - pane.y = 0; - pane.cols = self.cols; - pane.rows = self.rows; - - if let Some(session) = self.sessions.get_mut(&pane.session_id) { - session.resize(self.cols, self.rows); - } - return; - } - - let closed_right = closed_x + closed_cols; - let closed_bottom = closed_y + closed_rows; - - // Find all panes that perfectly match an edge (same width/height as closed pane) - // These are candidates for absorbing the space. - // We'll pick the smallest one to balance the layout. - - #[derive(Debug, Clone, Copy)] - enum ExpandDirection { - Left, // pane is to the left, expand right - Right, // pane is to the right, expand left - Top, // pane is above, expand down - Bottom, // pane is below, expand up - } - - let mut perfect_matches: Vec<(usize, usize, ExpandDirection)> = Vec::new(); // (idx, area, direction) - - for (idx, pane) in tab.panes.iter().enumerate() { - let pane_right = pane.x + pane.cols; - let pane_bottom = pane.y + pane.rows; - let area = pane.cols * pane.rows; - - // Left neighbor with exact same height - if pane_right == closed_x && pane.y == closed_y && pane.rows == closed_rows { - perfect_matches.push((idx, area, ExpandDirection::Left)); - } - - // Right neighbor with exact same height - if pane.x == closed_right && pane.y == closed_y && pane.rows == closed_rows { - perfect_matches.push((idx, area, ExpandDirection::Right)); - } - - // Top neighbor with exact same width - if pane_bottom == closed_y && pane.x == closed_x && pane.cols == closed_cols { - perfect_matches.push((idx, area, ExpandDirection::Top)); - } - - // Bottom neighbor with exact same width - if pane.y == closed_bottom && pane.x == closed_x && pane.cols == closed_cols { - perfect_matches.push((idx, area, ExpandDirection::Bottom)); - } - } - - // If we have perfect matches, pick the smallest pane - if !perfect_matches.is_empty() { - // Sort by area (smallest first) - perfect_matches.sort_by_key(|(_, area, _)| *area); - let (idx, _, direction) = perfect_matches[0]; - - let pane = &mut tab.panes[idx]; - match direction { - ExpandDirection::Left => { - // Pane is to the left, expand right - pane.cols += closed_cols; - } - ExpandDirection::Right => { - // Pane is to the right, expand left - pane.x = closed_x; - pane.cols += closed_cols; - } - ExpandDirection::Top => { - // Pane is above, expand down - pane.rows += closed_rows; - } - ExpandDirection::Bottom => { - // Pane is below, expand up - pane.y = closed_y; - pane.rows += closed_rows; - } - } - - if let Some(session) = self.sessions.get_mut(&pane.session_id) { - session.resize(pane.cols, pane.rows); - } - return; - } - - // No perfect match - need to expand multiple panes. - // Determine which direction has the most coverage and expand all panes on that edge. - - // Calculate coverage for each edge direction - let mut bottom_neighbors: Vec = Vec::new(); // panes below closed pane - let mut top_neighbors: Vec = Vec::new(); // panes above closed pane - let mut right_neighbors: Vec = Vec::new(); // panes to the right - let mut left_neighbors: Vec = Vec::new(); // panes to the left - - let mut bottom_coverage = 0usize; - let mut top_coverage = 0usize; - let mut right_coverage = 0usize; - let mut left_coverage = 0usize; - - for (idx, pane) in tab.panes.iter().enumerate() { - let pane_right = pane.x + pane.cols; - let pane_bottom = pane.y + pane.rows; - - // Bottom neighbors: their top edge touches closed pane's bottom edge - if pane.y == closed_bottom { - let overlap_start = pane.x.max(closed_x); - let overlap_end = pane_right.min(closed_right); - if overlap_end > overlap_start { - bottom_neighbors.push(idx); - bottom_coverage += overlap_end - overlap_start; - } - } - - // Top neighbors: their bottom edge touches closed pane's top edge - if pane_bottom == closed_y { - let overlap_start = pane.x.max(closed_x); - let overlap_end = pane_right.min(closed_right); - if overlap_end > overlap_start { - top_neighbors.push(idx); - top_coverage += overlap_end - overlap_start; - } - } - - // Right neighbors: their left edge touches closed pane's right edge - if pane.x == closed_right { - let overlap_start = pane.y.max(closed_y); - let overlap_end = pane_bottom.min(closed_bottom); - if overlap_end > overlap_start { - right_neighbors.push(idx); - right_coverage += overlap_end - overlap_start; - } - } - - // Left neighbors: their right edge touches closed pane's left edge - if pane_right == closed_x { - let overlap_start = pane.y.max(closed_y); - let overlap_end = pane_bottom.min(closed_bottom); - if overlap_end > overlap_start { - left_neighbors.push(idx); - left_coverage += overlap_end - overlap_start; - } - } - } - - // For partial matches, prefer the side with smaller total area (to balance layout) - // Calculate total area for each side - let bottom_area: usize = bottom_neighbors.iter() - .map(|&idx| tab.panes[idx].cols * tab.panes[idx].rows) - .sum(); - let top_area: usize = top_neighbors.iter() - .map(|&idx| tab.panes[idx].cols * tab.panes[idx].rows) - .sum(); - let right_area: usize = right_neighbors.iter() - .map(|&idx| tab.panes[idx].cols * tab.panes[idx].rows) - .sum(); - let left_area: usize = left_neighbors.iter() - .map(|&idx| tab.panes[idx].cols * tab.panes[idx].rows) - .sum(); - - // Build candidates: (neighbors, coverage, total_area) - let mut candidates: Vec<(&Vec, usize, usize, &str)> = Vec::new(); - if !bottom_neighbors.is_empty() { - candidates.push((&bottom_neighbors, bottom_coverage, bottom_area, "bottom")); - } - if !top_neighbors.is_empty() { - candidates.push((&top_neighbors, top_coverage, top_area, "top")); - } - if !right_neighbors.is_empty() { - candidates.push((&right_neighbors, right_coverage, right_area, "right")); - } - if !left_neighbors.is_empty() { - candidates.push((&left_neighbors, left_coverage, left_area, "left")); - } - - if candidates.is_empty() { - return; - } - - // Sort by: coverage (descending), then area (ascending - prefer smaller) - candidates.sort_by(|a, b| { - b.1.cmp(&a.1) // coverage descending - .then_with(|| a.2.cmp(&b.2)) // area ascending - }); - - let (neighbors, _, _, direction) = candidates[0]; - - // Collect session IDs to resize after modifying panes - let mut sessions_to_resize: Vec<(SessionId, usize, usize)> = Vec::new(); - - match direction { - "bottom" => { - for &idx in neighbors { - let pane = &mut tab.panes[idx]; - pane.y = closed_y; - pane.rows += closed_rows; - sessions_to_resize.push((pane.session_id, pane.cols, pane.rows)); - } - } - "top" => { - for &idx in neighbors { - let pane = &mut tab.panes[idx]; - pane.rows += closed_rows; - sessions_to_resize.push((pane.session_id, pane.cols, pane.rows)); - } - } - "right" => { - for &idx in neighbors { - let pane = &mut tab.panes[idx]; - pane.x = closed_x; - pane.cols += closed_cols; - sessions_to_resize.push((pane.session_id, pane.cols, pane.rows)); - } - } - "left" => { - for &idx in neighbors { - let pane = &mut tab.panes[idx]; - pane.cols += closed_cols; - sessions_to_resize.push((pane.session_id, pane.cols, pane.rows)); - } - } - _ => {} - } - - // Resize all affected sessions - for (session_id, cols, rows) in sessions_to_resize { - if let Some(session) = self.sessions.get_mut(&session_id) { - session.resize(cols, rows); - } - } - } - - /// Gets the currently active tab. - pub fn active_tab(&self) -> Option<&Tab> { - self.tabs.get(self.active_tab) - } - - /// Gets the currently active tab mutably. - pub fn active_tab_mut(&mut self) -> Option<&mut Tab> { - self.tabs.get_mut(self.active_tab) - } - - /// Gets the currently focused session (active pane of active tab). - pub fn focused_session(&self) -> Option<&Session> { - self.active_tab() - .and_then(|tab| tab.active_pane()) - .and_then(|pane| self.sessions.get(&pane.session_id)) - } - - /// Gets the currently focused session mutably. - pub fn focused_session_mut(&mut self) -> Option<&mut Session> { - let session_id = self.active_tab() - .and_then(|tab| tab.active_pane()) - .map(|pane| pane.session_id)?; - self.sessions.get_mut(&session_id) - } - - /// Resizes all sessions to new dimensions. - /// Recalculates pane layouts to maintain proper split ratios. - pub fn resize(&mut self, cols: usize, rows: usize) { - let old_cols = self.cols; - let old_rows = self.rows; - self.cols = cols; - self.rows = rows; - - if old_cols == 0 || old_rows == 0 || cols == 0 || rows == 0 { - return; - } - - for tab in &mut self.tabs { - if tab.panes.is_empty() { - continue; - } - - // Single pane: just give it full size - if tab.panes.len() == 1 { - let pane = &mut tab.panes[0]; - pane.x = 0; - pane.y = 0; - pane.cols = cols; - pane.rows = rows; - continue; - } - - // Multiple panes: convert to ratios, then back to cells - // This preserves the relative split positions - - // First, convert each pane's geometry to ratios (0.0 - 1.0) - let ratios: Vec<(f64, f64, f64, f64)> = tab.panes.iter().map(|pane| { - let x_ratio = pane.x as f64 / old_cols as f64; - let y_ratio = pane.y as f64 / old_rows as f64; - let w_ratio = pane.cols as f64 / old_cols as f64; - let h_ratio = pane.rows as f64 / old_rows as f64; - (x_ratio, y_ratio, w_ratio, h_ratio) - }).collect(); - - // Convert back to cell positions with new dimensions - for (pane, (x_ratio, y_ratio, w_ratio, h_ratio)) in tab.panes.iter_mut().zip(ratios.iter()) { - pane.x = (x_ratio * cols as f64).round() as usize; - pane.y = (y_ratio * rows as f64).round() as usize; - pane.cols = (w_ratio * cols as f64).round() as usize; - pane.rows = (h_ratio * rows as f64).round() as usize; - - // Ensure minimum size - pane.cols = pane.cols.max(1); - pane.rows = pane.rows.max(1); - } - - // Fix gaps and overlaps by adjusting panes that share edges - // For each pair of adjacent panes, ensure they meet exactly - Self::fix_pane_edges(&mut tab.panes, cols, rows); - } - - // Resize all sessions to match their pane sizes - for tab in &self.tabs { - for pane in &tab.panes { - if let Some(session) = self.sessions.get_mut(&pane.session_id) { - session.resize(pane.cols, pane.rows); - } - } - } - } - - /// Fixes gaps and overlaps between panes after resize. - /// Ensures adjacent panes meet exactly and edge panes extend to window boundaries. - fn fix_pane_edges(panes: &mut [Pane], cols: usize, rows: usize) { - let n = panes.len(); - if n == 0 { - return; - } - - // For each pane, check if it should extend to the window edge - for pane in panes.iter_mut() { - // If pane is at x=0, ensure it starts at 0 - if pane.x <= 1 { - let old_right = pane.x + pane.cols; - pane.x = 0; - pane.cols = old_right; // maintain right edge position - } - - // If pane is at y=0, ensure it starts at 0 - if pane.y <= 1 { - let old_bottom = pane.y + pane.rows; - pane.y = 0; - pane.rows = old_bottom; // maintain bottom edge position - } - } - - // For each pair of panes, if they're adjacent, make them meet exactly - for i in 0..n { - for j in (i + 1)..n { - // Get the two panes' boundaries - let (i_right, i_bottom) = { - let p = &panes[i]; - (p.x + p.cols, p.y + p.rows) - }; - let (j_right, j_bottom) = { - let p = &panes[j]; - (p.x + p.cols, p.y + p.rows) - }; - let (i_x, i_y) = (panes[i].x, panes[i].y); - let (j_x, j_y) = (panes[j].x, panes[j].y); - - // Check if j is to the right of i (vertical split) - // i's right edge should meet j's left edge - if i_right.abs_diff(j_x) <= 2 && - ranges_overlap(i_y, i_bottom, j_y, j_bottom) { - // They should meet - adjust j's x to match i's right edge - let meet_point = i_right; - let j_old_right = j_right; - panes[j].x = meet_point; - panes[j].cols = j_old_right.saturating_sub(meet_point).max(1); - } - - // Check if i is to the right of j - if j_right.abs_diff(i_x) <= 2 && - ranges_overlap(i_y, i_bottom, j_y, j_bottom) { - let meet_point = j_right; - let i_old_right = i_right; - panes[i].x = meet_point; - panes[i].cols = i_old_right.saturating_sub(meet_point).max(1); - } - - // Check if j is below i (horizontal split) - if i_bottom.abs_diff(j_y) <= 2 && - ranges_overlap(i_x, i_right, j_x, j_right) { - let meet_point = i_bottom; - let j_old_bottom = j_bottom; - panes[j].y = meet_point; - panes[j].rows = j_old_bottom.saturating_sub(meet_point).max(1); - } - - // Check if i is below j - if j_bottom.abs_diff(i_y) <= 2 && - ranges_overlap(i_x, i_right, j_x, j_right) { - let meet_point = j_bottom; - let i_old_bottom = i_bottom; - panes[i].y = meet_point; - panes[i].rows = i_old_bottom.saturating_sub(meet_point).max(1); - } - } - } - - // Finally, extend edge panes to window boundaries - for pane in panes.iter_mut() { - let pane_right = pane.x + pane.cols; - let pane_bottom = pane.y + pane.rows; - - // Extend to right edge if close - if pane_right >= cols.saturating_sub(2) { - pane.cols = cols.saturating_sub(pane.x).max(1); - } - - // Extend to bottom edge if close - if pane_bottom >= rows.saturating_sub(2) { - pane.rows = rows.saturating_sub(pane.y).max(1); - } - } - } - - /// Creates a protocol WindowState message. - pub fn to_protocol(&self) -> ProtocolWindowState { - ProtocolWindowState { - tabs: self.tabs.iter().map(|t| t.to_info()).collect(), - active_tab: self.active_tab, - cols: self.cols, - rows: self.rows, - } - } - - /// Returns whether any session has new output. - pub fn any_dirty(&self) -> bool { - self.sessions.values().any(|s| s.dirty) - } - - /// Marks all sessions as clean. - pub fn mark_all_clean(&mut self) { - for session in self.sessions.values_mut() { - session.mark_clean(); - } - } -} diff --git a/zterm.terminfo b/zterm.terminfo new file mode 100644 index 0000000..b35c562 --- /dev/null +++ b/zterm.terminfo @@ -0,0 +1,70 @@ +# ZTerm terminal description +# Compile with: tic -x -o ~/.terminfo zterm.terminfo +# Or system-wide: sudo tic -x zterm.terminfo + +zterm|ZTerm - GPU-accelerated terminal emulator, + am, bce, ccc, hs, km, mc5i, mir, msgr, npc, xenl, + colors#0x100, cols#80, it#8, lines#24, pairs#0x7fff, + acsc=++\,\,--..00``aaffgghhiijjkkllmmnnooppqqrrssttuuvvwwxxyyzz{{||}}~~, + bel=^G, cr=\r, ht=^I, ind=\n, nel=\EE, + clear=\E[H\E[2J, ed=\E[J, el=\E[K, el1=\E[1K, + civis=\E[?25l, cnorm=\E[?12h\E[?25h, cvvis=\E[?12;25h, + home=\E[H, + cub=\E[%p1%dD, cub1=^H, + cud=\E[%p1%dB, cud1=\n, + cuf=\E[%p1%dC, cuf1=\E[C, + cuu=\E[%p1%dA, cuu1=\E[A, + cup=\E[%i%p1%d;%p2%dH, + hpa=\E[%i%p1%dG, vpa=\E[%i%p1%dd, + csr=\E[%i%p1%d;%p2%dr, + ri=\EM, rin=\E[%p1%dT, + indn=\E[%p1%dS, + dch=\E[%p1%dP, dch1=\E[P, + dl=\E[%p1%dM, dl1=\E[M, + ech=\E[%p1%dX, + ich=\E[%p1%d@, + il=\E[%p1%dL, il1=\E[L, + bold=\E[1m, dim=\E[2m, sitm=\E[3m, smul=\E[4m, + blink=\E[5m, rev=\E[7m, invis=\E[8m, smxx=\E[9m, + ritm=\E[23m, rmul=\E[24m, rmxx=\E[29m, + sgr0=\E(B\E[m, + rmso=\E[27m, smso=\E[7m, + smacs=\E(0, rmacs=\E(B, + sgr=%?%p9%t\E(0%e\E(B%;\E[0%?%p6%t;1%;%?%p2%t;4%;%?%p1%p3%|%t;7%;%?%p4%t;5%;%?%p7%t;8%;m, + op=\E[39;49m, + setab=\E[%?%p1%{8}%<%t4%p1%d%e%p1%{16}%<%t10%p1%{8}%-%d%e48;5;%p1%d%;m, + setaf=\E[%?%p1%{8}%<%t3%p1%d%e%p1%{16}%<%t9%p1%{8}%-%d%e38;5;%p1%d%;m, + initc=\E]4;%p1%d;rgb:%p2%{255}%*%{1000}%/%2.2X/%p3%{255}%*%{1000}%/%2.2X/%p4%{255}%*%{1000}%/%2.2X\E\\, + oc=\E]104\007, + smir=\E[4h, rmir=\E[4l, + smkx=\E[?1h, rmkx=\E[?1l, + smam=\E[?7h, rmam=\E[?7l, + smcup=\E[?1049h, rmcup=\E[?1049l, + hts=\EH, tbc=\E[3g, + cbt=\E[Z, + flash=\E[?5h$<100/>\E[?5l, + sc=\E7, rc=\E8, + rep=%p1%c\E[%p2%{1}%-%db, + tsl=\E]2;, fsl=^G, dsl=\E]2;\E\\, + kbs=^?, + kf1=\EOP, kf2=\EOQ, kf3=\EOR, kf4=\EOS, + kf5=\E[15~, kf6=\E[17~, kf7=\E[18~, kf8=\E[19~, + kf9=\E[20~, kf10=\E[21~, kf11=\E[23~, kf12=\E[24~, + kf13=\E[1;2P, kf14=\E[1;2Q, kf15=\E[1;2R, kf16=\E[1;2S, + kf17=\E[15;2~, kf18=\E[17;2~, kf19=\E[18;2~, kf20=\E[19;2~, + kf21=\E[20;2~, kf22=\E[21;2~, kf23=\E[23;2~, kf24=\E[24;2~, + kcuu1=\EOA, kcud1=\EOB, kcuf1=\EOC, kcub1=\EOD, + khome=\EOH, kend=\EOF, + kich1=\E[2~, kdch1=\E[3~, + kpp=\E[5~, knp=\E[6~, + kHOM=\E[1;2H, kEND=\E[1;2F, + kIC=\E[2;2~, kDC=\E[3;2~, + kPRV=\E[5;2~, kNXT=\E[6;2~, + kLFT=\E[1;2D, kRIT=\E[1;2C, + kind=\E[1;2B, kri=\E[1;2A, + kent=\EOM, + kcbt=\E[Z, + kmous=\E[M, + XM=\E[?1006;1000%?%p1%{1}%=%th%el%;, + Ss=\E[%p1%d q, + Se=\E[2 q,