refactor for background process + spawning gui

This commit is contained in:
Zacharias-Brohn
2025-12-15 12:20:37 +01:00
parent 5d47177fbf
commit e4d742cadf
19 changed files with 5928 additions and 4384 deletions
+36
View File
@@ -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()
);
}
-25
View File
@@ -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");
}
-303
View File
@@ -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<WindowState>,
/// Current pane snapshots.
pub panes: Vec<PaneSnapshot>,
}
impl DaemonClient {
/// Connects to the daemon, starting it if necessary.
pub fn connect() -> io::Result<Self> {
// 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<u8>) -> 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<Option<DaemonMessage>> {
// 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<DaemonMessage> {
// 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();
}
}
+20 -1
View File
@@ -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(),
}
}
-605
View File
@@ -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<Self> {
// 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<Option<ClientMessage>> {
// 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<usize, Client>,
next_client_id: usize,
read_buffer: Vec<u8>,
}
impl Daemon {
/// Creates and starts a new daemon.
pub fn new() -> io::Result<Self> {
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<u32> = 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<ClientMessage> = {
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<PaneSnapshot> = 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<PaneSnapshot> = 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(())
}
+313
View File
@@ -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<f32>,
@@ -46,3 +51,311 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// The background was already rendered, so we just blend the glyph on top
return vec4<f32>(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<vec4<f32>, 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<u32>,
}
// 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<f32>,
// Offset from cell origin (x, y) in pixels
offset: vec2<f32>,
// Size in pixels
size: vec2<f32>,
}
// Uniforms and storage buffers for instanced rendering
@group(1) @binding(0)
var<uniform> color_table: ColorTable;
@group(1) @binding(1)
var<uniform> grid_params: GridParams;
@group(1) @binding(2)
var<storage, read> cells: array<GPUCell>;
@group(1) @binding(3)
var<storage, read> sprites: array<SpriteInfo>;
// 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<f32>,
@location(0) uv: vec2<f32>,
@location(1) fg_color: vec4<f32>,
@location(2) bg_color: vec4<f32>,
@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<f32> {
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<f32>(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<f32>, screen: vec2<f32>) -> vec2<f32> {
return vec2<f32>(
(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<f32>(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<vec2<f32>, 4>;
positions[0] = vec2<f32>(cell_x, cell_y);
positions[1] = vec2<f32>(cell_x + grid_params.cell_width, cell_y);
positions[2] = vec2<f32>(cell_x + grid_params.cell_width, cell_y + grid_params.cell_height);
positions[3] = vec2<f32>(cell_x, cell_y + grid_params.cell_height);
let screen_size = vec2<f32>(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<f32>(srgb_to_linear(fg.r), srgb_to_linear(fg.g), srgb_to_linear(fg.b), fg.a);
bg = vec4<f32>(srgb_to_linear(bg.r), srgb_to_linear(bg.g), srgb_to_linear(bg.b), bg.a);
var out: CellVertexOutput;
out.clip_position = vec4<f32>(ndc_pos, 0.0, 1.0);
out.uv = vec2<f32>(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<f32>(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<f32>(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<f32>(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<vec2<f32>, 4>;
positions[0] = vec2<f32>(glyph_x, glyph_y);
positions[1] = vec2<f32>(glyph_x + sprite.size.x, glyph_y);
positions[2] = vec2<f32>(glyph_x + sprite.size.x, glyph_y + sprite.size.y);
positions[3] = vec2<f32>(glyph_x, glyph_y + sprite.size.y);
// UV coordinates
var uvs: array<vec2<f32>, 4>;
uvs[0] = vec2<f32>(sprite.uv.x, sprite.uv.y);
uvs[1] = vec2<f32>(sprite.uv.x + sprite.uv.z, sprite.uv.y);
uvs[2] = vec2<f32>(sprite.uv.x + sprite.uv.z, sprite.uv.y + sprite.uv.w);
uvs[3] = vec2<f32>(sprite.uv.x, sprite.uv.y + sprite.uv.w);
let screen_size = vec2<f32>(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<f32>(srgb_to_linear(fg.r), srgb_to_linear(fg.g), srgb_to_linear(fg.b), fg.a);
var out: CellVertexOutput;
out.clip_position = vec4<f32>(ndc_pos, 0.0, 1.0);
out.uv = uvs[vertex_index];
out.fg_color = fg;
out.bg_color = vec4<f32>(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<f32> {
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<f32>(in.fg_color.rgb, glyph_alpha);
}
// Normal glyph - tint with foreground color
return vec4<f32>(in.fg_color.rgb, in.fg_color.a * glyph_alpha);
}
+8 -10
View File
@@ -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<u8>) -> Vec<u8> {
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::<Vec<_>>()
.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]
}
}
+2 -6
View File
@@ -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;
+1566 -473
View File
File diff suppressed because it is too large Load Diff
-254
View File
@@ -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<PaneInfo>,
}
/// 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<TabInfo>,
/// 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<u8> },
/// 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<PaneSnapshot>,
},
/// Incremental update for a single pane.
PaneUpdate {
pane_id: PaneId,
cells: Vec<Vec<RenderCell>>,
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<Vec<RenderCell>>,
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<W: Write, M: Serialize>(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<R: Read, M: for<'de> Deserialize<'de>>(reader: &mut R) -> io::Result<M> {
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))
}
}
+32 -1
View File
@@ -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 {
+1190 -1154
View File
File diff suppressed because it is too large Load Diff
-160
View File
@@ -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<Self, crate::pty::PtyError> {
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<usize, crate::pty::PtyError> {
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<usize, crate::pty::PtyError> {
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<Vec<u8>> {
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<Vec<RenderCell>> = 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;
}
}
+1690 -400
View File
File diff suppressed because it is too large Load Diff
+982
View File
@@ -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<char>) -> (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<char>,
/// OSC string buffer.
osc_buffer: Vec<u8>,
/// DCS/APC/PM/SOS string buffer.
string_buffer: Vec<u8>,
/// 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<H: Handler>(&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<H: Handler>(&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<H: Handler>(&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<H: Handler>(&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<H: Handler>(&mut self, handler: &mut H) {
handler.csi(&self.csi);
}
/// Process OSC sequence bytes.
fn consume_osc<H: Handler>(&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<H: Handler>(&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<Vec<char>>,
csi_count: usize,
osc_count: usize,
control_chars: Vec<u8>,
}
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);
}
}
-963
View File
@@ -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<Pane>,
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<SessionId, Session>,
/// All tabs in order.
pub tabs: Vec<Tab>,
/// 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<TabId, 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);
// 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<usize> = 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<usize> = Vec::new(); // panes below closed pane
let mut top_neighbors: Vec<usize> = Vec::new(); // panes above closed pane
let mut right_neighbors: Vec<usize> = Vec::new(); // panes to the right
let mut left_neighbors: Vec<usize> = 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, 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();
}
}
}