refactor for background process + spawning gui
This commit is contained in:
Generated
+12
-22
@@ -165,6 +165,15 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -1887,27 +1896,6 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vte"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a0b683b20ef64071ff03745b14391751f6beab06a54347885459b77a3f2caa5"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"utf8parse",
|
||||
"vte_generate_state_changes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vte_generate_state_changes"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -2616,6 +2604,7 @@ dependencies = [
|
||||
name = "zterm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"bitflags 2.10.0",
|
||||
"bytemuck",
|
||||
"dirs",
|
||||
@@ -2623,6 +2612,8 @@ dependencies = [
|
||||
"fontdue",
|
||||
"libc",
|
||||
"log",
|
||||
"memchr",
|
||||
"memmap2",
|
||||
"polling",
|
||||
"pollster",
|
||||
"rustix 0.38.44",
|
||||
@@ -2631,7 +2622,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"ttf-parser 0.25.1",
|
||||
"vte",
|
||||
"wgpu",
|
||||
"winit",
|
||||
]
|
||||
|
||||
+7
-7
@@ -11,19 +11,12 @@ path = "src/lib.rs"
|
||||
name = "zterm"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "ztermd"
|
||||
path = "src/bin/ztermd.rs"
|
||||
|
||||
[dependencies]
|
||||
# Window and rendering
|
||||
winit = { version = "0.30", features = ["wayland", "x11"] }
|
||||
wgpu = "23"
|
||||
pollster = "0.4"
|
||||
|
||||
# Terminal emulation
|
||||
vte = "0.13"
|
||||
|
||||
# PTY handling
|
||||
rustix = { version = "0.38", features = ["termios", "pty", "process", "fs"] }
|
||||
|
||||
@@ -50,4 +43,11 @@ ttf-parser = "0.25"
|
||||
# Configuration
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
bincode = "1"
|
||||
dirs = "6"
|
||||
|
||||
# Shared memory for fast IPC
|
||||
memmap2 = "0.9"
|
||||
|
||||
# Fast byte searching
|
||||
memchr = "2"
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
+1545
-452
File diff suppressed because it is too large
Load Diff
-254
@@ -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
@@ -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 {
|
||||
|
||||
+1050
-1014
File diff suppressed because it is too large
Load Diff
-160
@@ -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;
|
||||
}
|
||||
}
|
||||
+1644
-354
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# ZTerm terminal description
|
||||
# Compile with: tic -x -o ~/.terminfo zterm.terminfo
|
||||
# Or system-wide: sudo tic -x zterm.terminfo
|
||||
|
||||
zterm|ZTerm - GPU-accelerated terminal emulator,
|
||||
am, bce, ccc, hs, km, mc5i, mir, msgr, npc, xenl,
|
||||
colors#0x100, cols#80, it#8, lines#24, pairs#0x7fff,
|
||||
acsc=++\,\,--..00``aaffgghhiijjkkllmmnnooppqqrrssttuuvvwwxxyyzz{{||}}~~,
|
||||
bel=^G, cr=\r, ht=^I, ind=\n, nel=\EE,
|
||||
clear=\E[H\E[2J, ed=\E[J, el=\E[K, el1=\E[1K,
|
||||
civis=\E[?25l, cnorm=\E[?12h\E[?25h, cvvis=\E[?12;25h,
|
||||
home=\E[H,
|
||||
cub=\E[%p1%dD, cub1=^H,
|
||||
cud=\E[%p1%dB, cud1=\n,
|
||||
cuf=\E[%p1%dC, cuf1=\E[C,
|
||||
cuu=\E[%p1%dA, cuu1=\E[A,
|
||||
cup=\E[%i%p1%d;%p2%dH,
|
||||
hpa=\E[%i%p1%dG, vpa=\E[%i%p1%dd,
|
||||
csr=\E[%i%p1%d;%p2%dr,
|
||||
ri=\EM, rin=\E[%p1%dT,
|
||||
indn=\E[%p1%dS,
|
||||
dch=\E[%p1%dP, dch1=\E[P,
|
||||
dl=\E[%p1%dM, dl1=\E[M,
|
||||
ech=\E[%p1%dX,
|
||||
ich=\E[%p1%d@,
|
||||
il=\E[%p1%dL, il1=\E[L,
|
||||
bold=\E[1m, dim=\E[2m, sitm=\E[3m, smul=\E[4m,
|
||||
blink=\E[5m, rev=\E[7m, invis=\E[8m, smxx=\E[9m,
|
||||
ritm=\E[23m, rmul=\E[24m, rmxx=\E[29m,
|
||||
sgr0=\E(B\E[m,
|
||||
rmso=\E[27m, smso=\E[7m,
|
||||
smacs=\E(0, rmacs=\E(B,
|
||||
sgr=%?%p9%t\E(0%e\E(B%;\E[0%?%p6%t;1%;%?%p2%t;4%;%?%p1%p3%|%t;7%;%?%p4%t;5%;%?%p7%t;8%;m,
|
||||
op=\E[39;49m,
|
||||
setab=\E[%?%p1%{8}%<%t4%p1%d%e%p1%{16}%<%t10%p1%{8}%-%d%e48;5;%p1%d%;m,
|
||||
setaf=\E[%?%p1%{8}%<%t3%p1%d%e%p1%{16}%<%t9%p1%{8}%-%d%e38;5;%p1%d%;m,
|
||||
initc=\E]4;%p1%d;rgb:%p2%{255}%*%{1000}%/%2.2X/%p3%{255}%*%{1000}%/%2.2X/%p4%{255}%*%{1000}%/%2.2X\E\\,
|
||||
oc=\E]104\007,
|
||||
smir=\E[4h, rmir=\E[4l,
|
||||
smkx=\E[?1h, rmkx=\E[?1l,
|
||||
smam=\E[?7h, rmam=\E[?7l,
|
||||
smcup=\E[?1049h, rmcup=\E[?1049l,
|
||||
hts=\EH, tbc=\E[3g,
|
||||
cbt=\E[Z,
|
||||
flash=\E[?5h$<100/>\E[?5l,
|
||||
sc=\E7, rc=\E8,
|
||||
rep=%p1%c\E[%p2%{1}%-%db,
|
||||
tsl=\E]2;, fsl=^G, dsl=\E]2;\E\\,
|
||||
kbs=^?,
|
||||
kf1=\EOP, kf2=\EOQ, kf3=\EOR, kf4=\EOS,
|
||||
kf5=\E[15~, kf6=\E[17~, kf7=\E[18~, kf8=\E[19~,
|
||||
kf9=\E[20~, kf10=\E[21~, kf11=\E[23~, kf12=\E[24~,
|
||||
kf13=\E[1;2P, kf14=\E[1;2Q, kf15=\E[1;2R, kf16=\E[1;2S,
|
||||
kf17=\E[15;2~, kf18=\E[17;2~, kf19=\E[18;2~, kf20=\E[19;2~,
|
||||
kf21=\E[20;2~, kf22=\E[21;2~, kf23=\E[23;2~, kf24=\E[24;2~,
|
||||
kcuu1=\EOA, kcud1=\EOB, kcuf1=\EOC, kcub1=\EOD,
|
||||
khome=\EOH, kend=\EOF,
|
||||
kich1=\E[2~, kdch1=\E[3~,
|
||||
kpp=\E[5~, knp=\E[6~,
|
||||
kHOM=\E[1;2H, kEND=\E[1;2F,
|
||||
kIC=\E[2;2~, kDC=\E[3;2~,
|
||||
kPRV=\E[5;2~, kNXT=\E[6;2~,
|
||||
kLFT=\E[1;2D, kRIT=\E[1;2C,
|
||||
kind=\E[1;2B, kri=\E[1;2A,
|
||||
kent=\EOM,
|
||||
kcbt=\E[Z,
|
||||
kmous=\E[M,
|
||||
XM=\E[?1006;1000%?%p1%{1}%=%th%el%;,
|
||||
Ss=\E[%p1%d q,
|
||||
Se=\E[2 q,
|
||||
Reference in New Issue
Block a user