initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
max_width = 80
|
||||||
Generated
+2637
File diff suppressed because it is too large
Load Diff
+53
@@ -0,0 +1,53 @@
|
|||||||
|
[package]
|
||||||
|
name = "zterm"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "zterm"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
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"] }
|
||||||
|
|
||||||
|
# Async I/O
|
||||||
|
polling = "3"
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = "2"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log = "0.4"
|
||||||
|
env_logger = "0.11"
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
bytemuck = { version = "1", features = ["derive"] }
|
||||||
|
libc = "0.2"
|
||||||
|
bitflags = "2"
|
||||||
|
|
||||||
|
# Font rasterization and shaping
|
||||||
|
fontdue = "0.9"
|
||||||
|
rustybuzz = "0.20"
|
||||||
|
ttf-parser = "0.25"
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
dirs = "6"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
//! 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
@@ -0,0 +1,303 @@
|
|||||||
|
//! 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+425
@@ -0,0 +1,425 @@
|
|||||||
|
//! Configuration management for ZTerm.
|
||||||
|
//!
|
||||||
|
//! Loads configuration from `~/.config/zterm/config.json`.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Position of the tab bar.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TabBarPosition {
|
||||||
|
/// Tab bar at the top of the window.
|
||||||
|
#[default]
|
||||||
|
Top,
|
||||||
|
/// Tab bar at the bottom of the window.
|
||||||
|
Bottom,
|
||||||
|
/// Tab bar is hidden.
|
||||||
|
Hidden,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A keybinding specification.
|
||||||
|
/// Format: "modifier+modifier+key" where modifiers are: ctrl, alt, shift, super
|
||||||
|
/// Examples: "ctrl+shift+t", "ctrl+w", "alt+1"
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct Keybind(pub String);
|
||||||
|
|
||||||
|
impl Keybind {
|
||||||
|
/// Parses the keybind into modifiers and key.
|
||||||
|
/// Returns (ctrl, alt, shift, super_key, key_char_or_name)
|
||||||
|
///
|
||||||
|
/// Supports special syntax for symbol keys:
|
||||||
|
/// - "ctrl+alt+plus" or "ctrl+alt++" for the + key
|
||||||
|
/// - "ctrl+minus" or "ctrl+-" for the - key
|
||||||
|
/// - Symbol names: plus, minus, equal, bracket_left, bracket_right, etc.
|
||||||
|
pub fn parse(&self) -> Option<(bool, bool, bool, bool, String)> {
|
||||||
|
let lowercase = self.0.to_lowercase();
|
||||||
|
|
||||||
|
// Handle the special case where the key is "+" at the end
|
||||||
|
// e.g., "ctrl+alt++" should parse as ctrl+alt with key "+"
|
||||||
|
let (modifier_part, key) = if lowercase.ends_with("++") {
|
||||||
|
// Last char is the key "+", everything before the final "++" is modifiers
|
||||||
|
let prefix = &lowercase[..lowercase.len() - 2];
|
||||||
|
(prefix, "+".to_string())
|
||||||
|
} else if lowercase == "+" {
|
||||||
|
// Just the plus key alone
|
||||||
|
("", "+".to_string())
|
||||||
|
} else if let Some(last_plus) = lowercase.rfind('+') {
|
||||||
|
// Normal case: split at last +
|
||||||
|
let key_part = &lowercase[last_plus + 1..];
|
||||||
|
let mod_part = &lowercase[..last_plus];
|
||||||
|
// Normalize symbol names to actual characters
|
||||||
|
let key = Self::normalize_key_name(key_part);
|
||||||
|
(mod_part, key)
|
||||||
|
} else {
|
||||||
|
// No modifiers, just a key
|
||||||
|
let key = Self::normalize_key_name(&lowercase);
|
||||||
|
("", key)
|
||||||
|
};
|
||||||
|
|
||||||
|
if key.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ctrl = false;
|
||||||
|
let mut alt = false;
|
||||||
|
let mut shift = false;
|
||||||
|
let mut super_key = false;
|
||||||
|
|
||||||
|
// Parse modifiers from the modifier part
|
||||||
|
for part in modifier_part.split('+') {
|
||||||
|
match part {
|
||||||
|
"ctrl" | "control" => ctrl = true,
|
||||||
|
"alt" => alt = true,
|
||||||
|
"shift" => shift = true,
|
||||||
|
"super" | "meta" | "cmd" => super_key = true,
|
||||||
|
"" => {} // Empty parts from splitting
|
||||||
|
_ => {} // Unknown modifiers ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((ctrl, alt, shift, super_key, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalizes key names to their canonical form.
|
||||||
|
/// Supports both symbol names ("plus", "minus") and literal symbols ("+", "-").
|
||||||
|
fn normalize_key_name(name: &str) -> String {
|
||||||
|
match name {
|
||||||
|
// Arrow keys
|
||||||
|
"left" | "arrowleft" | "arrow_left" => "left".to_string(),
|
||||||
|
"right" | "arrowright" | "arrow_right" => "right".to_string(),
|
||||||
|
"up" | "arrowup" | "arrow_up" => "up".to_string(),
|
||||||
|
"down" | "arrowdown" | "arrow_down" => "down".to_string(),
|
||||||
|
|
||||||
|
// Other special keys
|
||||||
|
"enter" | "return" => "enter".to_string(),
|
||||||
|
"tab" => "tab".to_string(),
|
||||||
|
"escape" | "esc" => "escape".to_string(),
|
||||||
|
"backspace" | "back" => "backspace".to_string(),
|
||||||
|
"delete" | "del" => "delete".to_string(),
|
||||||
|
"insert" | "ins" => "insert".to_string(),
|
||||||
|
"home" => "home".to_string(),
|
||||||
|
"end" => "end".to_string(),
|
||||||
|
"pageup" | "page_up" | "pgup" => "pageup".to_string(),
|
||||||
|
"pagedown" | "page_down" | "pgdn" => "pagedown".to_string(),
|
||||||
|
|
||||||
|
// Function keys
|
||||||
|
"f1" => "f1".to_string(),
|
||||||
|
"f2" => "f2".to_string(),
|
||||||
|
"f3" => "f3".to_string(),
|
||||||
|
"f4" => "f4".to_string(),
|
||||||
|
"f5" => "f5".to_string(),
|
||||||
|
"f6" => "f6".to_string(),
|
||||||
|
"f7" => "f7".to_string(),
|
||||||
|
"f8" => "f8".to_string(),
|
||||||
|
"f9" => "f9".to_string(),
|
||||||
|
"f10" => "f10".to_string(),
|
||||||
|
"f11" => "f11".to_string(),
|
||||||
|
"f12" => "f12".to_string(),
|
||||||
|
|
||||||
|
// Symbol name aliases
|
||||||
|
"plus" => "+".to_string(),
|
||||||
|
"minus" => "-".to_string(),
|
||||||
|
"equal" | "equals" => "=".to_string(),
|
||||||
|
"bracket_left" | "bracketleft" | "lbracket" => "[".to_string(),
|
||||||
|
"bracket_right" | "bracketright" | "rbracket" => "]".to_string(),
|
||||||
|
"brace_left" | "braceleft" | "lbrace" => "{".to_string(),
|
||||||
|
"brace_right" | "braceright" | "rbrace" => "}".to_string(),
|
||||||
|
"semicolon" => ";".to_string(),
|
||||||
|
"colon" => ":".to_string(),
|
||||||
|
"apostrophe" | "quote" => "'".to_string(),
|
||||||
|
"quotedbl" | "doublequote" => "\"".to_string(),
|
||||||
|
"comma" => ",".to_string(),
|
||||||
|
"period" | "dot" => ".".to_string(),
|
||||||
|
"slash" => "/".to_string(),
|
||||||
|
"backslash" => "\\".to_string(),
|
||||||
|
"grave" | "backtick" => "`".to_string(),
|
||||||
|
"tilde" => "~".to_string(),
|
||||||
|
"at" => "@".to_string(),
|
||||||
|
"hash" | "pound" => "#".to_string(),
|
||||||
|
"dollar" => "$".to_string(),
|
||||||
|
"percent" => "%".to_string(),
|
||||||
|
"caret" => "^".to_string(),
|
||||||
|
"ampersand" => "&".to_string(),
|
||||||
|
"asterisk" | "star" => "*".to_string(),
|
||||||
|
"paren_left" | "parenleft" | "lparen" => "(".to_string(),
|
||||||
|
"paren_right" | "parenright" | "rparen" => ")".to_string(),
|
||||||
|
"underscore" => "_".to_string(),
|
||||||
|
"pipe" | "bar" => "|".to_string(),
|
||||||
|
"question" => "?".to_string(),
|
||||||
|
"exclam" | "exclamation" | "bang" => "!".to_string(),
|
||||||
|
"less" | "lessthan" => "<".to_string(),
|
||||||
|
"greater" | "greaterthan" => ">".to_string(),
|
||||||
|
"space" => " ".to_string(),
|
||||||
|
// Pass through everything else as-is
|
||||||
|
_ => name.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminal actions that can be bound to keys.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Action {
|
||||||
|
/// Create a new tab.
|
||||||
|
NewTab,
|
||||||
|
/// Switch to the next tab.
|
||||||
|
NextTab,
|
||||||
|
/// Switch to the previous tab.
|
||||||
|
PrevTab,
|
||||||
|
/// Switch to tab by index (1-9).
|
||||||
|
Tab1,
|
||||||
|
Tab2,
|
||||||
|
Tab3,
|
||||||
|
Tab4,
|
||||||
|
Tab5,
|
||||||
|
Tab6,
|
||||||
|
Tab7,
|
||||||
|
Tab8,
|
||||||
|
Tab9,
|
||||||
|
/// Split pane horizontally (new pane below).
|
||||||
|
SplitHorizontal,
|
||||||
|
/// Split pane vertically (new pane to the right).
|
||||||
|
SplitVertical,
|
||||||
|
/// Close the current pane (closes tab if last pane).
|
||||||
|
ClosePane,
|
||||||
|
/// Focus the pane above the current one.
|
||||||
|
FocusPaneUp,
|
||||||
|
/// Focus the pane below the current one.
|
||||||
|
FocusPaneDown,
|
||||||
|
/// Focus the pane to the left of the current one.
|
||||||
|
FocusPaneLeft,
|
||||||
|
/// Focus the pane to the right of the current one.
|
||||||
|
FocusPaneRight,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keybinding configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Keybindings {
|
||||||
|
/// Create new tab.
|
||||||
|
pub new_tab: Keybind,
|
||||||
|
/// Switch to next tab.
|
||||||
|
pub next_tab: Keybind,
|
||||||
|
/// Switch to previous tab.
|
||||||
|
pub prev_tab: Keybind,
|
||||||
|
/// Switch to tab 1.
|
||||||
|
pub tab_1: Keybind,
|
||||||
|
/// Switch to tab 2.
|
||||||
|
pub tab_2: Keybind,
|
||||||
|
/// Switch to tab 3.
|
||||||
|
pub tab_3: Keybind,
|
||||||
|
/// Switch to tab 4.
|
||||||
|
pub tab_4: Keybind,
|
||||||
|
/// Switch to tab 5.
|
||||||
|
pub tab_5: Keybind,
|
||||||
|
/// Switch to tab 6.
|
||||||
|
pub tab_6: Keybind,
|
||||||
|
/// Switch to tab 7.
|
||||||
|
pub tab_7: Keybind,
|
||||||
|
/// Switch to tab 8.
|
||||||
|
pub tab_8: Keybind,
|
||||||
|
/// Switch to tab 9.
|
||||||
|
pub tab_9: Keybind,
|
||||||
|
/// Split pane horizontally (new pane below).
|
||||||
|
pub split_horizontal: Keybind,
|
||||||
|
/// Split pane vertically (new pane to the right).
|
||||||
|
pub split_vertical: Keybind,
|
||||||
|
/// Close current pane (closes tab if last pane).
|
||||||
|
pub close_pane: Keybind,
|
||||||
|
/// Focus pane above.
|
||||||
|
pub focus_pane_up: Keybind,
|
||||||
|
/// Focus pane below.
|
||||||
|
pub focus_pane_down: Keybind,
|
||||||
|
/// Focus pane to the left.
|
||||||
|
pub focus_pane_left: Keybind,
|
||||||
|
/// Focus pane to the right.
|
||||||
|
pub focus_pane_right: Keybind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Keybindings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
new_tab: Keybind("ctrl+shift+t".to_string()),
|
||||||
|
next_tab: Keybind("ctrl+tab".to_string()),
|
||||||
|
prev_tab: Keybind("ctrl+shift+tab".to_string()),
|
||||||
|
tab_1: Keybind("alt+1".to_string()),
|
||||||
|
tab_2: Keybind("alt+2".to_string()),
|
||||||
|
tab_3: Keybind("alt+3".to_string()),
|
||||||
|
tab_4: Keybind("alt+4".to_string()),
|
||||||
|
tab_5: Keybind("alt+5".to_string()),
|
||||||
|
tab_6: Keybind("alt+6".to_string()),
|
||||||
|
tab_7: Keybind("alt+7".to_string()),
|
||||||
|
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()),
|
||||||
|
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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Keybindings {
|
||||||
|
/// Builds a lookup map from parsed keybinds to actions.
|
||||||
|
pub fn build_action_map(&self) -> HashMap<(bool, bool, bool, bool, String), Action> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
|
||||||
|
if let Some(parsed) = self.new_tab.parse() {
|
||||||
|
map.insert(parsed, Action::NewTab);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.next_tab.parse() {
|
||||||
|
map.insert(parsed, Action::NextTab);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.prev_tab.parse() {
|
||||||
|
map.insert(parsed, Action::PrevTab);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.tab_1.parse() {
|
||||||
|
map.insert(parsed, Action::Tab1);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.tab_2.parse() {
|
||||||
|
map.insert(parsed, Action::Tab2);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.tab_3.parse() {
|
||||||
|
map.insert(parsed, Action::Tab3);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.tab_4.parse() {
|
||||||
|
map.insert(parsed, Action::Tab4);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.tab_5.parse() {
|
||||||
|
map.insert(parsed, Action::Tab5);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.tab_6.parse() {
|
||||||
|
map.insert(parsed, Action::Tab6);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.tab_7.parse() {
|
||||||
|
map.insert(parsed, Action::Tab7);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.tab_8.parse() {
|
||||||
|
map.insert(parsed, Action::Tab8);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.tab_9.parse() {
|
||||||
|
map.insert(parsed, Action::Tab9);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.split_horizontal.parse() {
|
||||||
|
map.insert(parsed, Action::SplitHorizontal);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.split_vertical.parse() {
|
||||||
|
map.insert(parsed, Action::SplitVertical);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.close_pane.parse() {
|
||||||
|
map.insert(parsed, Action::ClosePane);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.focus_pane_up.parse() {
|
||||||
|
map.insert(parsed, Action::FocusPaneUp);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.focus_pane_down.parse() {
|
||||||
|
map.insert(parsed, Action::FocusPaneDown);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.focus_pane_left.parse() {
|
||||||
|
map.insert(parsed, Action::FocusPaneLeft);
|
||||||
|
}
|
||||||
|
if let Some(parsed) = self.focus_pane_right.parse() {
|
||||||
|
map.insert(parsed, Action::FocusPaneRight);
|
||||||
|
}
|
||||||
|
|
||||||
|
map
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main configuration struct for ZTerm.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Config {
|
||||||
|
/// Font size in points.
|
||||||
|
pub font_size: f32,
|
||||||
|
/// Position of the tab bar: "top", "bottom", or "hidden".
|
||||||
|
pub tab_bar_position: TabBarPosition,
|
||||||
|
/// Background opacity (0.0 = fully transparent, 1.0 = fully opaque).
|
||||||
|
/// Requires compositor support for transparency.
|
||||||
|
pub background_opacity: f32,
|
||||||
|
/// Keybindings.
|
||||||
|
pub keybindings: Keybindings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
font_size: 16.0,
|
||||||
|
tab_bar_position: TabBarPosition::Top,
|
||||||
|
background_opacity: 1.0,
|
||||||
|
keybindings: Keybindings::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Returns the path to the config file.
|
||||||
|
pub fn config_path() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|p| p.join("zterm").join("config.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads configuration from the default config file.
|
||||||
|
/// If the file doesn't exist, writes the default config to that location.
|
||||||
|
/// Returns default config if file can't be parsed.
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let Some(config_path) = Self::config_path() else {
|
||||||
|
log::warn!("Could not determine config directory, using defaults");
|
||||||
|
return Self::default();
|
||||||
|
};
|
||||||
|
|
||||||
|
if !config_path.exists() {
|
||||||
|
log::info!("No config file found at {:?}, creating with defaults", config_path);
|
||||||
|
let default_config = Self::default();
|
||||||
|
if let Err(e) = default_config.save() {
|
||||||
|
log::warn!("Failed to write default config: {}", e);
|
||||||
|
}
|
||||||
|
return default_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
match fs::read_to_string(&config_path) {
|
||||||
|
Ok(contents) => match serde_json::from_str(&contents) {
|
||||||
|
Ok(config) => {
|
||||||
|
log::info!("Loaded config from {:?}", config_path);
|
||||||
|
config
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to parse config file: {}", e);
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to read config file: {}", e);
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the current configuration to the default config file.
|
||||||
|
pub fn save(&self) -> Result<(), std::io::Error> {
|
||||||
|
let Some(config_path) = Self::config_path() else {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"Could not determine config directory",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create parent directories if they don't exist
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(self)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||||
|
|
||||||
|
fs::write(&config_path, json)?;
|
||||||
|
log::info!("Saved config to {:?}", config_path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+605
@@ -0,0 +1,605 @@
|
|||||||
|
//! 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(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Glyph rendering shader for terminal emulator
|
||||||
|
|
||||||
|
struct VertexInput {
|
||||||
|
@location(0) position: vec2<f32>,
|
||||||
|
@location(1) uv: vec2<f32>,
|
||||||
|
@location(2) color: vec4<f32>,
|
||||||
|
@location(3) bg_color: vec4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VertexOutput {
|
||||||
|
@builtin(position) clip_position: vec4<f32>,
|
||||||
|
@location(0) uv: vec2<f32>,
|
||||||
|
@location(1) color: vec4<f32>,
|
||||||
|
@location(2) bg_color: vec4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_main(in: VertexInput) -> VertexOutput {
|
||||||
|
var out: VertexOutput;
|
||||||
|
out.clip_position = vec4<f32>(in.position, 0.0, 1.0);
|
||||||
|
out.uv = in.uv;
|
||||||
|
out.color = in.color;
|
||||||
|
out.bg_color = in.bg_color;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0)
|
||||||
|
var atlas_texture: texture_2d<f32>;
|
||||||
|
@group(0) @binding(1)
|
||||||
|
var atlas_sampler: sampler;
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
|
// If UV is at origin (0,0), this is a background-only quad
|
||||||
|
let is_background_only = in.uv.x == 0.0 && in.uv.y == 0.0;
|
||||||
|
|
||||||
|
if is_background_only {
|
||||||
|
// Just render the background color (fully opaque)
|
||||||
|
return in.bg_color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample the glyph alpha from the atlas
|
||||||
|
let glyph_alpha = textureSample(atlas_texture, atlas_sampler, in.uv).r;
|
||||||
|
|
||||||
|
// Output foreground color with glyph alpha for blending
|
||||||
|
// The background was already rendered, so we just blend the glyph on top
|
||||||
|
return vec4<f32>(in.color.rgb, in.color.a * glyph_alpha);
|
||||||
|
}
|
||||||
+559
@@ -0,0 +1,559 @@
|
|||||||
|
//! Kitty keyboard protocol implementation.
|
||||||
|
//!
|
||||||
|
//! This module implements the progressive keyboard enhancement protocol
|
||||||
|
//! as specified at: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
||||||
|
|
||||||
|
use bitflags::bitflags;
|
||||||
|
|
||||||
|
bitflags! {
|
||||||
|
/// Keyboard enhancement flags for the Kitty keyboard protocol.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct KeyboardFlags: u8 {
|
||||||
|
/// Disambiguate escape codes (report Esc, alt+key, ctrl+key using CSI u).
|
||||||
|
const DISAMBIGUATE = 0b00001;
|
||||||
|
/// Report key repeat and release events.
|
||||||
|
const REPORT_EVENTS = 0b00010;
|
||||||
|
/// Report alternate keys (shifted key, base layout key).
|
||||||
|
const REPORT_ALTERNATES = 0b00100;
|
||||||
|
/// Report all keys as escape codes (including text-generating keys).
|
||||||
|
const REPORT_ALL_KEYS = 0b01000;
|
||||||
|
/// Report associated text with key events.
|
||||||
|
const REPORT_TEXT = 0b10000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key event types for the keyboard protocol.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum KeyEventType {
|
||||||
|
Press = 1,
|
||||||
|
Repeat = 2,
|
||||||
|
Release = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modifier flags for key events.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct Modifiers {
|
||||||
|
pub shift: bool,
|
||||||
|
pub alt: bool,
|
||||||
|
pub ctrl: bool,
|
||||||
|
pub super_key: bool,
|
||||||
|
pub hyper: bool,
|
||||||
|
pub meta: bool,
|
||||||
|
pub caps_lock: bool,
|
||||||
|
pub num_lock: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Modifiers {
|
||||||
|
/// Encodes modifiers as a decimal number (1 + bitfield).
|
||||||
|
/// Returns None if no modifiers are active.
|
||||||
|
pub fn encode(&self) -> Option<u8> {
|
||||||
|
let mut bits: u8 = 0;
|
||||||
|
if self.shift {
|
||||||
|
bits |= 1;
|
||||||
|
}
|
||||||
|
if self.alt {
|
||||||
|
bits |= 2;
|
||||||
|
}
|
||||||
|
if self.ctrl {
|
||||||
|
bits |= 4;
|
||||||
|
}
|
||||||
|
if self.super_key {
|
||||||
|
bits |= 8;
|
||||||
|
}
|
||||||
|
if self.hyper {
|
||||||
|
bits |= 16;
|
||||||
|
}
|
||||||
|
if self.meta {
|
||||||
|
bits |= 32;
|
||||||
|
}
|
||||||
|
if self.caps_lock {
|
||||||
|
bits |= 64;
|
||||||
|
}
|
||||||
|
if self.num_lock {
|
||||||
|
bits |= 128;
|
||||||
|
}
|
||||||
|
|
||||||
|
if bits == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(1 + bits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if any modifier is active.
|
||||||
|
pub fn any(&self) -> bool {
|
||||||
|
self.shift
|
||||||
|
|| self.alt
|
||||||
|
|| self.ctrl
|
||||||
|
|| self.super_key
|
||||||
|
|| self.hyper
|
||||||
|
|| self.meta
|
||||||
|
|| self.caps_lock
|
||||||
|
|| self.num_lock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Functional key codes from the Kitty keyboard protocol.
|
||||||
|
/// These are Unicode Private Use Area codepoints (57344 - 63743).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[repr(u32)]
|
||||||
|
pub enum FunctionalKey {
|
||||||
|
Escape = 27,
|
||||||
|
Enter = 13,
|
||||||
|
Tab = 9,
|
||||||
|
Backspace = 127,
|
||||||
|
Insert = 57348,
|
||||||
|
Delete = 57349,
|
||||||
|
Left = 57350,
|
||||||
|
Right = 57351,
|
||||||
|
Up = 57352,
|
||||||
|
Down = 57353,
|
||||||
|
PageUp = 57354,
|
||||||
|
PageDown = 57355,
|
||||||
|
Home = 57356,
|
||||||
|
End = 57357,
|
||||||
|
CapsLock = 57358,
|
||||||
|
ScrollLock = 57359,
|
||||||
|
NumLock = 57360,
|
||||||
|
PrintScreen = 57361,
|
||||||
|
Pause = 57362,
|
||||||
|
Menu = 57363,
|
||||||
|
F1 = 57364,
|
||||||
|
F2 = 57365,
|
||||||
|
F3 = 57366,
|
||||||
|
F4 = 57367,
|
||||||
|
F5 = 57368,
|
||||||
|
F6 = 57369,
|
||||||
|
F7 = 57370,
|
||||||
|
F8 = 57371,
|
||||||
|
F9 = 57372,
|
||||||
|
F10 = 57373,
|
||||||
|
F11 = 57374,
|
||||||
|
F12 = 57375,
|
||||||
|
F13 = 57376,
|
||||||
|
F14 = 57377,
|
||||||
|
F15 = 57378,
|
||||||
|
F16 = 57379,
|
||||||
|
F17 = 57380,
|
||||||
|
F18 = 57381,
|
||||||
|
F19 = 57382,
|
||||||
|
F20 = 57383,
|
||||||
|
F21 = 57384,
|
||||||
|
F22 = 57385,
|
||||||
|
F23 = 57386,
|
||||||
|
F24 = 57387,
|
||||||
|
F25 = 57388,
|
||||||
|
// Keypad keys
|
||||||
|
KpDecimal = 57409,
|
||||||
|
KpDivide = 57410,
|
||||||
|
KpMultiply = 57411,
|
||||||
|
KpSubtract = 57412,
|
||||||
|
KpAdd = 57413,
|
||||||
|
KpEnter = 57414,
|
||||||
|
KpEqual = 57415,
|
||||||
|
KpSeparator = 57416,
|
||||||
|
KpLeft = 57417,
|
||||||
|
KpRight = 57418,
|
||||||
|
KpUp = 57419,
|
||||||
|
KpDown = 57420,
|
||||||
|
KpPageUp = 57421,
|
||||||
|
KpPageDown = 57422,
|
||||||
|
KpHome = 57423,
|
||||||
|
KpEnd = 57424,
|
||||||
|
KpInsert = 57425,
|
||||||
|
KpDelete = 57426,
|
||||||
|
KpBegin = 57427,
|
||||||
|
// Media keys
|
||||||
|
MediaPlay = 57428,
|
||||||
|
MediaPause = 57429,
|
||||||
|
MediaPlayPause = 57430,
|
||||||
|
MediaReverse = 57431,
|
||||||
|
MediaStop = 57432,
|
||||||
|
MediaFastForward = 57433,
|
||||||
|
MediaRewind = 57434,
|
||||||
|
MediaTrackNext = 57435,
|
||||||
|
MediaTrackPrevious = 57436,
|
||||||
|
MediaRecord = 57437,
|
||||||
|
LowerVolume = 57438,
|
||||||
|
RaiseVolume = 57439,
|
||||||
|
MuteVolume = 57440,
|
||||||
|
// Modifier keys
|
||||||
|
LeftShift = 57441,
|
||||||
|
LeftControl = 57442,
|
||||||
|
LeftAlt = 57443,
|
||||||
|
LeftSuper = 57444,
|
||||||
|
LeftHyper = 57445,
|
||||||
|
LeftMeta = 57446,
|
||||||
|
RightShift = 57447,
|
||||||
|
RightControl = 57448,
|
||||||
|
RightAlt = 57449,
|
||||||
|
RightSuper = 57450,
|
||||||
|
RightHyper = 57451,
|
||||||
|
RightMeta = 57452,
|
||||||
|
IsoLevel3Shift = 57453,
|
||||||
|
IsoLevel5Shift = 57454,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keyboard protocol state.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct KeyboardState {
|
||||||
|
/// Current enhancement flags.
|
||||||
|
flags: KeyboardFlags,
|
||||||
|
/// Stack of pushed flag states (for push/pop).
|
||||||
|
stack: Vec<KeyboardFlags>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for KeyboardState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyboardState {
|
||||||
|
/// Maximum stack size to prevent DoS.
|
||||||
|
const MAX_STACK_SIZE: usize = 16;
|
||||||
|
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
flags: KeyboardFlags::empty(),
|
||||||
|
stack: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the current keyboard enhancement flags.
|
||||||
|
pub fn flags(&self) -> KeyboardFlags {
|
||||||
|
self.flags
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets keyboard flags using the specified mode.
|
||||||
|
/// mode 1: set all flags to the given value
|
||||||
|
/// mode 2: set bits that are set in flags, leave others unchanged
|
||||||
|
/// mode 3: reset bits that are set in flags, leave others unchanged
|
||||||
|
pub fn set_flags(&mut self, flags: u8, mode: u8) {
|
||||||
|
let new_flags = KeyboardFlags::from_bits_truncate(flags);
|
||||||
|
match mode {
|
||||||
|
1 => self.flags = new_flags,
|
||||||
|
2 => self.flags |= new_flags,
|
||||||
|
3 => self.flags &= !new_flags,
|
||||||
|
_ => self.flags = new_flags, // Default to mode 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes current flags onto the stack and optionally sets new flags.
|
||||||
|
pub fn push(&mut self, flags: Option<u8>) {
|
||||||
|
// Evict oldest entry if stack is full
|
||||||
|
if self.stack.len() >= Self::MAX_STACK_SIZE {
|
||||||
|
self.stack.remove(0);
|
||||||
|
}
|
||||||
|
self.stack.push(self.flags);
|
||||||
|
if let Some(f) = flags {
|
||||||
|
self.flags = KeyboardFlags::from_bits_truncate(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pops entries from the stack.
|
||||||
|
pub fn pop(&mut self, count: usize) {
|
||||||
|
let count = count.max(1);
|
||||||
|
for _ in 0..count {
|
||||||
|
if let Some(flags) = self.stack.pop() {
|
||||||
|
self.flags = flags;
|
||||||
|
} else {
|
||||||
|
// Stack is empty, reset all flags
|
||||||
|
self.flags = KeyboardFlags::empty();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the DISAMBIGUATE flag is set.
|
||||||
|
pub fn disambiguate(&self) -> bool {
|
||||||
|
self.flags.contains(KeyboardFlags::DISAMBIGUATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the REPORT_EVENTS flag is set.
|
||||||
|
pub fn report_events(&self) -> bool {
|
||||||
|
self.flags.contains(KeyboardFlags::REPORT_EVENTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the REPORT_ALTERNATES flag is set.
|
||||||
|
pub fn report_alternates(&self) -> bool {
|
||||||
|
self.flags.contains(KeyboardFlags::REPORT_ALTERNATES)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the REPORT_ALL_KEYS flag is set.
|
||||||
|
pub fn report_all_keys(&self) -> bool {
|
||||||
|
self.flags.contains(KeyboardFlags::REPORT_ALL_KEYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the REPORT_TEXT flag is set.
|
||||||
|
pub fn report_text(&self) -> bool {
|
||||||
|
self.flags.contains(KeyboardFlags::REPORT_TEXT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a key event according to the Kitty keyboard protocol.
|
||||||
|
pub struct KeyEncoder<'a> {
|
||||||
|
state: &'a KeyboardState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> KeyEncoder<'a> {
|
||||||
|
pub fn new(state: &'a KeyboardState) -> Self {
|
||||||
|
Self { state }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a functional key press to bytes.
|
||||||
|
pub fn encode_functional(
|
||||||
|
&self,
|
||||||
|
key: FunctionalKey,
|
||||||
|
modifiers: Modifiers,
|
||||||
|
event_type: KeyEventType,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let key_code = key as u32;
|
||||||
|
|
||||||
|
// Special handling for legacy keys in legacy mode
|
||||||
|
if self.state.flags().is_empty() {
|
||||||
|
return self.encode_legacy_functional(key, modifiers);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.encode_csi_u(key_code, modifiers, event_type, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a Unicode character key press.
|
||||||
|
pub fn encode_char(
|
||||||
|
&self,
|
||||||
|
c: char,
|
||||||
|
modifiers: Modifiers,
|
||||||
|
event_type: KeyEventType,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let key_code = c as u32;
|
||||||
|
|
||||||
|
// In legacy mode without REPORT_ALL_KEYS, just send the character
|
||||||
|
// (with legacy ctrl/alt handling)
|
||||||
|
if !self.state.report_all_keys() {
|
||||||
|
return self.encode_legacy_text(c, modifiers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// With REPORT_ALL_KEYS, encode as CSI u
|
||||||
|
let text = if self.state.report_text() {
|
||||||
|
Some(c)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
self.encode_csi_u(key_code, modifiers, event_type, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a key event as CSI u format.
|
||||||
|
fn encode_csi_u(
|
||||||
|
&self,
|
||||||
|
key_code: u32,
|
||||||
|
modifiers: Modifiers,
|
||||||
|
event_type: KeyEventType,
|
||||||
|
text: Option<char>,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut result = Vec::with_capacity(16);
|
||||||
|
result.extend_from_slice(b"\x1b[");
|
||||||
|
result.extend_from_slice(key_code.to_string().as_bytes());
|
||||||
|
|
||||||
|
let mod_value = modifiers.encode();
|
||||||
|
let has_event_type =
|
||||||
|
self.state.report_events() && event_type != KeyEventType::Press;
|
||||||
|
|
||||||
|
if mod_value.is_some() || has_event_type || text.is_some() {
|
||||||
|
result.push(b';');
|
||||||
|
if let Some(m) = mod_value {
|
||||||
|
result.extend_from_slice(m.to_string().as_bytes());
|
||||||
|
} else if has_event_type {
|
||||||
|
result.push(b'1'); // Default modifier value
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_event_type {
|
||||||
|
result.push(b':');
|
||||||
|
result.extend_from_slice((event_type as u8).to_string().as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(text_char) = text {
|
||||||
|
result.push(b';');
|
||||||
|
result.extend_from_slice((text_char as u32).to_string().as_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(b'u');
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes functional keys in legacy mode.
|
||||||
|
fn encode_legacy_functional(&self, key: FunctionalKey, modifiers: Modifiers) -> Vec<u8> {
|
||||||
|
let mod_param = modifiers.encode();
|
||||||
|
|
||||||
|
match key {
|
||||||
|
FunctionalKey::Escape => {
|
||||||
|
if modifiers.alt {
|
||||||
|
vec![0x1b, 0x1b]
|
||||||
|
} else {
|
||||||
|
vec![0x1b]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FunctionalKey::Enter => {
|
||||||
|
if modifiers.alt {
|
||||||
|
vec![0x1b, 0x0d]
|
||||||
|
} else {
|
||||||
|
vec![0x0d]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FunctionalKey::Tab => {
|
||||||
|
if modifiers.shift && !modifiers.alt && !modifiers.ctrl {
|
||||||
|
// Shift+Tab -> CSI Z
|
||||||
|
vec![0x1b, b'[', b'Z']
|
||||||
|
} else if modifiers.alt {
|
||||||
|
vec![0x1b, 0x09]
|
||||||
|
} else {
|
||||||
|
vec![0x09]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FunctionalKey::Backspace => {
|
||||||
|
if modifiers.ctrl {
|
||||||
|
if modifiers.alt {
|
||||||
|
vec![0x1b, 0x08]
|
||||||
|
} else {
|
||||||
|
vec![0x08]
|
||||||
|
}
|
||||||
|
} else if modifiers.alt {
|
||||||
|
vec![0x1b, 0x7f]
|
||||||
|
} else {
|
||||||
|
vec![0x7f]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Arrow keys
|
||||||
|
FunctionalKey::Up => self.encode_arrow(b'A', mod_param),
|
||||||
|
FunctionalKey::Down => self.encode_arrow(b'B', mod_param),
|
||||||
|
FunctionalKey::Right => self.encode_arrow(b'C', mod_param),
|
||||||
|
FunctionalKey::Left => self.encode_arrow(b'D', mod_param),
|
||||||
|
FunctionalKey::Home => self.encode_arrow(b'H', mod_param),
|
||||||
|
FunctionalKey::End => self.encode_arrow(b'F', mod_param),
|
||||||
|
// Function keys F1-F4 (SS3 in legacy mode without modifiers)
|
||||||
|
FunctionalKey::F1 => self.encode_f1_f4(b'P', mod_param),
|
||||||
|
FunctionalKey::F2 => self.encode_f1_f4(b'Q', mod_param),
|
||||||
|
FunctionalKey::F3 => self.encode_f1_f4(b'R', mod_param),
|
||||||
|
FunctionalKey::F4 => self.encode_f1_f4(b'S', mod_param),
|
||||||
|
// Function keys F5-F12 (CSI number ~)
|
||||||
|
FunctionalKey::F5 => self.encode_tilde(15, mod_param),
|
||||||
|
FunctionalKey::F6 => self.encode_tilde(17, mod_param),
|
||||||
|
FunctionalKey::F7 => self.encode_tilde(18, mod_param),
|
||||||
|
FunctionalKey::F8 => self.encode_tilde(19, mod_param),
|
||||||
|
FunctionalKey::F9 => self.encode_tilde(20, mod_param),
|
||||||
|
FunctionalKey::F10 => self.encode_tilde(21, mod_param),
|
||||||
|
FunctionalKey::F11 => self.encode_tilde(23, mod_param),
|
||||||
|
FunctionalKey::F12 => self.encode_tilde(24, mod_param),
|
||||||
|
// Navigation keys
|
||||||
|
FunctionalKey::Insert => self.encode_tilde(2, mod_param),
|
||||||
|
FunctionalKey::Delete => self.encode_tilde(3, mod_param),
|
||||||
|
FunctionalKey::PageUp => self.encode_tilde(5, mod_param),
|
||||||
|
FunctionalKey::PageDown => self.encode_tilde(6, mod_param),
|
||||||
|
// Other functional keys - encode as CSI u
|
||||||
|
_ => {
|
||||||
|
let key_code = key as u32;
|
||||||
|
self.encode_csi_u(key_code, modifiers, KeyEventType::Press, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes arrow/home/end keys: CSI 1;mod X 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()
|
||||||
|
} else {
|
||||||
|
// SS3 letter (cursor key mode) or CSI letter
|
||||||
|
vec![0x1b, b'[', letter]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes F1-F4: SS3 letter (no mods) or CSI 1;mod letter (with mods).
|
||||||
|
fn encode_f1_f4(&self, letter: u8, mod_param: Option<u8>) -> Vec<u8> {
|
||||||
|
if let Some(m) = mod_param {
|
||||||
|
let mut result = vec![0x1b, b'[', b'1', b';'];
|
||||||
|
result.extend_from_slice(m.to_string().as_bytes());
|
||||||
|
result.push(letter);
|
||||||
|
result
|
||||||
|
} else {
|
||||||
|
vec![0x1b, b'O', letter]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes CSI number ; modifier ~ format.
|
||||||
|
fn encode_tilde(&self, number: u8, mod_param: Option<u8>) -> Vec<u8> {
|
||||||
|
let mut result = vec![0x1b, b'['];
|
||||||
|
result.extend_from_slice(number.to_string().as_bytes());
|
||||||
|
if let Some(m) = mod_param {
|
||||||
|
result.push(b';');
|
||||||
|
result.extend_from_slice(m.to_string().as_bytes());
|
||||||
|
}
|
||||||
|
result.push(b'~');
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes text keys in legacy mode.
|
||||||
|
fn encode_legacy_text(&self, c: char, modifiers: Modifiers) -> Vec<u8> {
|
||||||
|
// For plain text without modifiers, just send UTF-8
|
||||||
|
if !modifiers.any() {
|
||||||
|
let mut buf = [0u8; 4];
|
||||||
|
let s = c.encode_utf8(&mut buf);
|
||||||
|
return s.as_bytes().to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ctrl modifier for ASCII keys
|
||||||
|
if modifiers.ctrl && !modifiers.shift && c.is_ascii_lowercase() {
|
||||||
|
let ctrl_code = (c as u8) - b'a' + 1;
|
||||||
|
if modifiers.alt {
|
||||||
|
return vec![0x1b, ctrl_code];
|
||||||
|
} else {
|
||||||
|
return vec![ctrl_code];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ctrl+space
|
||||||
|
if modifiers.ctrl && c == ' ' {
|
||||||
|
if modifiers.alt {
|
||||||
|
return vec![0x1b, 0x00];
|
||||||
|
} else {
|
||||||
|
return vec![0x00];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle alt modifier alone
|
||||||
|
if modifiers.alt && !modifiers.ctrl {
|
||||||
|
let mut buf = [0u8; 4];
|
||||||
|
let s = c.encode_utf8(&mut buf);
|
||||||
|
let mut result = vec![0x1b];
|
||||||
|
result.extend_from_slice(s.as_bytes());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle shift (just send the shifted character)
|
||||||
|
if modifiers.shift && !modifiers.ctrl && !modifiers.alt {
|
||||||
|
let shifted = c.to_uppercase().next().unwrap_or(c);
|
||||||
|
let mut buf = [0u8; 4];
|
||||||
|
let s = shifted.encode_utf8(&mut buf);
|
||||||
|
return s.as_bytes().to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For complex modifier combinations, use CSI u encoding even in "legacy" mode
|
||||||
|
// This provides better compatibility than dropping the key
|
||||||
|
let key_code = c as u32;
|
||||||
|
self.encode_csi_u(key_code, modifiers, KeyEventType::Press, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates the response for a keyboard mode query (CSI ? u).
|
||||||
|
pub fn query_response(flags: KeyboardFlags) -> Vec<u8> {
|
||||||
|
let mut result = vec![0x1b, b'[', b'?'];
|
||||||
|
result.extend_from_slice(flags.bits().to_string().as_bytes());
|
||||||
|
result.push(b'u');
|
||||||
|
result
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
//! ZTerm - A GPU-accelerated terminal emulator for Wayland.
|
||||||
|
//!
|
||||||
|
//! This library provides shared functionality between the daemon and client.
|
||||||
|
|
||||||
|
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;
|
||||||
+798
@@ -0,0 +1,798 @@
|
|||||||
|
//! ZTerm Client - GPU-accelerated terminal emulator that connects to the daemon.
|
||||||
|
|
||||||
|
use zterm::client::DaemonClient;
|
||||||
|
use zterm::config::{Action, Config};
|
||||||
|
use zterm::keyboard::{FunctionalKey, KeyEncoder, KeyEventType, KeyboardState, Modifiers};
|
||||||
|
use zterm::protocol::{ClientMessage, DaemonMessage, Direction, PaneId, PaneInfo, PaneSnapshot, WindowState};
|
||||||
|
use zterm::renderer::Renderer;
|
||||||
|
|
||||||
|
use polling::{Event, Events, Poller};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::os::fd::{AsRawFd, BorrowedFd};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use winit::application::ApplicationHandler;
|
||||||
|
use winit::dpi::PhysicalSize;
|
||||||
|
use winit::event::{ElementState, KeyEvent, Modifiers as WinitModifiers, MouseScrollDelta, WindowEvent};
|
||||||
|
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
|
||||||
|
use winit::keyboard::{Key, NamedKey};
|
||||||
|
use winit::platform::wayland::EventLoopBuilderExtWayland;
|
||||||
|
use winit::window::{Window, WindowId};
|
||||||
|
|
||||||
|
/// Main application state.
|
||||||
|
struct App {
|
||||||
|
window: Option<Arc<Window>>,
|
||||||
|
renderer: Option<Renderer>,
|
||||||
|
daemon_client: Option<DaemonClient>,
|
||||||
|
/// Current window state (tabs info) from daemon.
|
||||||
|
window_state: Option<WindowState>,
|
||||||
|
/// All pane snapshots from daemon.
|
||||||
|
panes: Vec<PaneSnapshot>,
|
||||||
|
/// Whether we need to redraw.
|
||||||
|
dirty: bool,
|
||||||
|
/// Current modifier state.
|
||||||
|
modifiers: WinitModifiers,
|
||||||
|
/// Keyboard state for encoding (tracks protocol mode from daemon).
|
||||||
|
keyboard_state: KeyboardState,
|
||||||
|
/// Application configuration.
|
||||||
|
config: Config,
|
||||||
|
/// Keybinding action map.
|
||||||
|
action_map: HashMap<(bool, bool, bool, bool, String), Action>,
|
||||||
|
/// Event loop proxy for waking from daemon poll thread.
|
||||||
|
event_loop_proxy: Option<EventLoopProxy<()>>,
|
||||||
|
/// Shutdown signal for daemon poll thread.
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAEMON_SOCKET_KEY: usize = 1;
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn new() -> Self {
|
||||||
|
let config = Config::load();
|
||||||
|
log::info!("Config: font_size={}", config.font_size);
|
||||||
|
|
||||||
|
// Build action map from keybindings
|
||||||
|
let action_map = config.keybindings.build_action_map();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
window: None,
|
||||||
|
renderer: None,
|
||||||
|
daemon_client: None,
|
||||||
|
window_state: None,
|
||||||
|
panes: Vec::new(),
|
||||||
|
dirty: true,
|
||||||
|
modifiers: WinitModifiers::default(),
|
||||||
|
keyboard_state: KeyboardState::new(),
|
||||||
|
config,
|
||||||
|
action_map,
|
||||||
|
event_loop_proxy: None,
|
||||||
|
shutdown: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_event_loop_proxy(&mut self, proxy: EventLoopProxy<()>) {
|
||||||
|
self.event_loop_proxy = Some(proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initialize(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
|
let init_start = std::time::Instant::now();
|
||||||
|
|
||||||
|
// Create window first so it appears immediately
|
||||||
|
let mut window_attributes = Window::default_attributes()
|
||||||
|
.with_title("ZTerm")
|
||||||
|
.with_inner_size(PhysicalSize::new(800, 600));
|
||||||
|
|
||||||
|
// Enable transparency if background opacity is less than 1.0
|
||||||
|
if self.config.background_opacity < 1.0 {
|
||||||
|
window_attributes = window_attributes.with_transparent(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = Arc::new(
|
||||||
|
event_loop
|
||||||
|
.create_window(window_attributes)
|
||||||
|
.expect("Failed to create window"),
|
||||||
|
);
|
||||||
|
log::debug!("Window created in {:?}", init_start.elapsed());
|
||||||
|
|
||||||
|
// Start daemon connection in parallel with renderer initialization
|
||||||
|
let daemon_start = std::time::Instant::now();
|
||||||
|
let mut daemon_client = match DaemonClient::connect() {
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to connect to daemon: {}", e);
|
||||||
|
event_loop.exit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
log::debug!("Daemon connected in {:?}", daemon_start.elapsed());
|
||||||
|
|
||||||
|
// Create renderer (this is the slow part - GPU initialization)
|
||||||
|
let renderer_start = std::time::Instant::now();
|
||||||
|
let renderer = pollster::block_on(Renderer::new(window.clone(), &self.config));
|
||||||
|
log::debug!("Renderer created in {:?}", renderer_start.elapsed());
|
||||||
|
|
||||||
|
// Calculate terminal size based on window size
|
||||||
|
let (cols, rows) = renderer.terminal_size();
|
||||||
|
|
||||||
|
// Send hello with our size
|
||||||
|
if let Err(e) = daemon_client.hello(cols, rows) {
|
||||||
|
log::error!("Failed to send hello: {}", e);
|
||||||
|
event_loop.exit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for initial state
|
||||||
|
match daemon_client.recv() {
|
||||||
|
Ok(DaemonMessage::FullState { window: win_state, panes }) => {
|
||||||
|
log::debug!("Received initial state with {} tabs, {} panes",
|
||||||
|
win_state.tabs.len(), panes.len());
|
||||||
|
self.window_state = Some(win_state);
|
||||||
|
self.panes = panes;
|
||||||
|
}
|
||||||
|
Ok(msg) => {
|
||||||
|
log::warn!("Unexpected initial message: {:?}", msg);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to receive initial state: {}", e);
|
||||||
|
event_loop.exit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to non-blocking mode for the event loop
|
||||||
|
if let Err(e) = daemon_client.set_nonblocking() {
|
||||||
|
log::error!("Failed to set non-blocking mode: {}", e);
|
||||||
|
event_loop.exit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up polling for daemon socket in a background thread
|
||||||
|
// This thread will wake the event loop when data is available
|
||||||
|
if let Some(proxy) = self.event_loop_proxy.clone() {
|
||||||
|
let daemon_fd = daemon_client.as_raw_fd();
|
||||||
|
let shutdown = self.shutdown.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let poller = match Poller::new() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to create poller: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// SAFETY: daemon_fd is valid for the lifetime of the daemon_client,
|
||||||
|
// and we signal shutdown before dropping daemon_client
|
||||||
|
unsafe {
|
||||||
|
if let Err(e) = poller.add(daemon_fd, Event::readable(DAEMON_SOCKET_KEY)) {
|
||||||
|
log::error!("Failed to add daemon socket to poller: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut events = Events::new();
|
||||||
|
|
||||||
|
while !shutdown.load(Ordering::Relaxed) {
|
||||||
|
events.clear();
|
||||||
|
|
||||||
|
// Wait for data with a timeout so we can check shutdown
|
||||||
|
match poller.wait(&mut events, Some(Duration::from_millis(100))) {
|
||||||
|
Ok(_) if !events.is_empty() => {
|
||||||
|
// Wake the event loop by sending an empty event
|
||||||
|
let _ = proxy.send_event(());
|
||||||
|
|
||||||
|
// Re-register for more events
|
||||||
|
// SAFETY: daemon_fd is still valid
|
||||||
|
unsafe {
|
||||||
|
let _ = poller.modify(
|
||||||
|
std::os::fd::BorrowedFd::borrow_raw(daemon_fd),
|
||||||
|
Event::readable(DAEMON_SOCKET_KEY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {} // Timeout, no events
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Poller error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::debug!("Daemon poll thread exiting");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.window = Some(window);
|
||||||
|
self.renderer = Some(renderer);
|
||||||
|
self.daemon_client = Some(daemon_client);
|
||||||
|
|
||||||
|
log::info!("Client initialized in {:?}: {}x{} cells", init_start.elapsed(), cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets all pane snapshots with their layout info for the active tab.
|
||||||
|
/// Returns (panes_with_info, active_pane_id) with cloned/owned data.
|
||||||
|
fn active_tab_panes(&self) -> (Vec<(PaneSnapshot, PaneInfo)>, PaneId) {
|
||||||
|
let Some(win) = self.window_state.as_ref() else {
|
||||||
|
return (Vec::new(), 0);
|
||||||
|
};
|
||||||
|
let Some(tab) = win.tabs.get(win.active_tab) else {
|
||||||
|
return (Vec::new(), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
let active_pane_id = tab.panes.get(tab.active_pane)
|
||||||
|
.map(|p| p.id)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let panes_with_info: Vec<(PaneSnapshot, PaneInfo)> = tab.panes.iter()
|
||||||
|
.filter_map(|pane_info| {
|
||||||
|
self.panes.iter()
|
||||||
|
.find(|snap| snap.pane_id == pane_info.id)
|
||||||
|
.map(|snap| (snap.clone(), pane_info.clone()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
(panes_with_info, active_pane_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the active pane ID and its snapshot.
|
||||||
|
fn get_active_pane(&self) -> Option<(PaneId, &PaneSnapshot)> {
|
||||||
|
let win = self.window_state.as_ref()?;
|
||||||
|
let tab = win.tabs.get(win.active_tab)?;
|
||||||
|
let pane_info = tab.panes.get(tab.active_pane)?;
|
||||||
|
let snapshot = self.panes.iter().find(|s| s.pane_id == pane_info.id)?;
|
||||||
|
Some((pane_info.id, snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_daemon(&mut self) {
|
||||||
|
let Some(client) = &mut self.daemon_client else { return };
|
||||||
|
|
||||||
|
// Read all available messages (non-blocking)
|
||||||
|
// The background thread wakes us when data is available
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
loop {
|
||||||
|
match client.try_recv() {
|
||||||
|
Ok(Some(msg)) => {
|
||||||
|
messages.push(msg);
|
||||||
|
}
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Daemon connection error: {}", e);
|
||||||
|
// Daemon disconnected - we'll handle this after the loop
|
||||||
|
messages.push(DaemonMessage::Shutdown);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now process messages without holding the client borrow
|
||||||
|
for msg in messages {
|
||||||
|
self.handle_daemon_message(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_daemon_message(&mut self, msg: DaemonMessage) {
|
||||||
|
match msg {
|
||||||
|
DaemonMessage::FullState { window, panes } => {
|
||||||
|
log::debug!("Received full state: {} tabs, {} panes",
|
||||||
|
window.tabs.len(), panes.len());
|
||||||
|
self.window_state = Some(window);
|
||||||
|
self.panes = panes;
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
DaemonMessage::PaneUpdate { pane_id, cells, cursor } => {
|
||||||
|
log::debug!("Received pane update for pane {}", pane_id);
|
||||||
|
if let Some(pane) = self.panes.iter_mut().find(|p| p.pane_id == pane_id) {
|
||||||
|
pane.cells = cells;
|
||||||
|
pane.cursor = cursor;
|
||||||
|
} else {
|
||||||
|
// New pane
|
||||||
|
self.panes.push(PaneSnapshot {
|
||||||
|
pane_id,
|
||||||
|
cells,
|
||||||
|
cursor,
|
||||||
|
scroll_offset: 0,
|
||||||
|
scrollback_len: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
DaemonMessage::TabChanged { active_tab } => {
|
||||||
|
log::debug!("Tab changed to {}", active_tab);
|
||||||
|
if let Some(ref mut win) = self.window_state {
|
||||||
|
win.active_tab = active_tab;
|
||||||
|
}
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
DaemonMessage::TabCreated { tab } => {
|
||||||
|
log::debug!("Tab created: {:?}", tab);
|
||||||
|
if let Some(ref mut win) = self.window_state {
|
||||||
|
win.tabs.push(tab);
|
||||||
|
}
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
DaemonMessage::TabClosed { tab_id } => {
|
||||||
|
log::debug!("Tab closed: {}", tab_id);
|
||||||
|
if let Some(ref mut win) = self.window_state {
|
||||||
|
win.tabs.retain(|t| t.id != tab_id);
|
||||||
|
// Adjust active tab if needed
|
||||||
|
if win.active_tab >= win.tabs.len() && !win.tabs.is_empty() {
|
||||||
|
win.active_tab = win.tabs.len() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove panes for closed tab
|
||||||
|
// Note: daemon should send updated panes, but we clean up just in case
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
DaemonMessage::PaneCreated { tab_id, pane } => {
|
||||||
|
log::debug!("Pane created in tab {}: {:?}", tab_id, pane);
|
||||||
|
if let Some(ref mut win) = self.window_state {
|
||||||
|
if let Some(tab) = win.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||||
|
tab.panes.push(pane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
DaemonMessage::PaneClosed { tab_id, pane_id } => {
|
||||||
|
log::debug!("Pane {} closed in tab {}", pane_id, tab_id);
|
||||||
|
if let Some(ref mut win) = self.window_state {
|
||||||
|
if let Some(tab) = win.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||||
|
tab.panes.retain(|p| p.id != pane_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also remove from pane snapshots
|
||||||
|
self.panes.retain(|p| p.pane_id != pane_id);
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
DaemonMessage::PaneFocused { tab_id, active_pane } => {
|
||||||
|
log::debug!("Pane focus changed in tab {}: pane {}", tab_id, active_pane);
|
||||||
|
if let Some(ref mut win) = self.window_state {
|
||||||
|
if let Some(tab) = win.tabs.iter_mut().find(|t| t.id == tab_id) {
|
||||||
|
tab.active_pane = active_pane;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
DaemonMessage::Shutdown => {
|
||||||
|
log::info!("Daemon shutting down");
|
||||||
|
self.daemon_client = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if the key event matches a keybinding and executes the action.
|
||||||
|
/// Returns true if the key was consumed by a keybinding.
|
||||||
|
fn check_keybinding(&mut self, event: &KeyEvent) -> bool {
|
||||||
|
// Only process key presses, not releases or repeats
|
||||||
|
if event.state != ElementState::Pressed || event.repeat {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mod_state = self.modifiers.state();
|
||||||
|
let ctrl = mod_state.control_key();
|
||||||
|
let alt = mod_state.alt_key();
|
||||||
|
let shift = mod_state.shift_key();
|
||||||
|
let super_key = mod_state.super_key();
|
||||||
|
|
||||||
|
// Get the key name
|
||||||
|
let key_name = match &event.logical_key {
|
||||||
|
Key::Named(named) => {
|
||||||
|
match named {
|
||||||
|
NamedKey::Tab => "tab".to_string(),
|
||||||
|
NamedKey::Enter => "enter".to_string(),
|
||||||
|
NamedKey::Escape => "escape".to_string(),
|
||||||
|
NamedKey::Backspace => "backspace".to_string(),
|
||||||
|
NamedKey::Delete => "delete".to_string(),
|
||||||
|
NamedKey::Insert => "insert".to_string(),
|
||||||
|
NamedKey::Home => "home".to_string(),
|
||||||
|
NamedKey::End => "end".to_string(),
|
||||||
|
NamedKey::PageUp => "pageup".to_string(),
|
||||||
|
NamedKey::PageDown => "pagedown".to_string(),
|
||||||
|
NamedKey::ArrowUp => "up".to_string(),
|
||||||
|
NamedKey::ArrowDown => "down".to_string(),
|
||||||
|
NamedKey::ArrowLeft => "left".to_string(),
|
||||||
|
NamedKey::ArrowRight => "right".to_string(),
|
||||||
|
NamedKey::Space => " ".to_string(),
|
||||||
|
NamedKey::F1 => "f1".to_string(),
|
||||||
|
NamedKey::F2 => "f2".to_string(),
|
||||||
|
NamedKey::F3 => "f3".to_string(),
|
||||||
|
NamedKey::F4 => "f4".to_string(),
|
||||||
|
NamedKey::F5 => "f5".to_string(),
|
||||||
|
NamedKey::F6 => "f6".to_string(),
|
||||||
|
NamedKey::F7 => "f7".to_string(),
|
||||||
|
NamedKey::F8 => "f8".to_string(),
|
||||||
|
NamedKey::F9 => "f9".to_string(),
|
||||||
|
NamedKey::F10 => "f10".to_string(),
|
||||||
|
NamedKey::F11 => "f11".to_string(),
|
||||||
|
NamedKey::F12 => "f12".to_string(),
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Key::Character(c) => c.to_lowercase(),
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Look up the action
|
||||||
|
let lookup = (ctrl, alt, shift, super_key, key_name);
|
||||||
|
let Some(action) = self.action_map.get(&lookup).copied() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute the action
|
||||||
|
self.execute_action(action);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_action(&mut self, action: Action) {
|
||||||
|
let Some(client) = &mut self.daemon_client else { return };
|
||||||
|
|
||||||
|
match action {
|
||||||
|
Action::NewTab => {
|
||||||
|
log::debug!("Action: NewTab");
|
||||||
|
let _ = client.create_tab();
|
||||||
|
}
|
||||||
|
Action::NextTab => {
|
||||||
|
log::debug!("Action: NextTab");
|
||||||
|
let _ = client.next_tab();
|
||||||
|
}
|
||||||
|
Action::PrevTab => {
|
||||||
|
log::debug!("Action: PrevTab");
|
||||||
|
let _ = client.prev_tab();
|
||||||
|
}
|
||||||
|
Action::Tab1 => { let _ = client.switch_tab_index(0); }
|
||||||
|
Action::Tab2 => { let _ = client.switch_tab_index(1); }
|
||||||
|
Action::Tab3 => { let _ = client.switch_tab_index(2); }
|
||||||
|
Action::Tab4 => { let _ = client.switch_tab_index(3); }
|
||||||
|
Action::Tab5 => { let _ = client.switch_tab_index(4); }
|
||||||
|
Action::Tab6 => { let _ = client.switch_tab_index(5); }
|
||||||
|
Action::Tab7 => { let _ = client.switch_tab_index(6); }
|
||||||
|
Action::Tab8 => { let _ = client.switch_tab_index(7); }
|
||||||
|
Action::Tab9 => { let _ = client.switch_tab_index(8); }
|
||||||
|
Action::SplitHorizontal => {
|
||||||
|
log::debug!("Action: SplitHorizontal");
|
||||||
|
let _ = client.split_horizontal();
|
||||||
|
}
|
||||||
|
Action::SplitVertical => {
|
||||||
|
log::debug!("Action: SplitVertical");
|
||||||
|
let _ = client.split_vertical();
|
||||||
|
}
|
||||||
|
Action::ClosePane => {
|
||||||
|
log::debug!("Action: ClosePane");
|
||||||
|
let _ = client.close_pane();
|
||||||
|
}
|
||||||
|
Action::FocusPaneUp => {
|
||||||
|
log::debug!("Action: FocusPaneUp");
|
||||||
|
let _ = client.focus_pane(Direction::Up);
|
||||||
|
}
|
||||||
|
Action::FocusPaneDown => {
|
||||||
|
log::debug!("Action: FocusPaneDown");
|
||||||
|
let _ = client.focus_pane(Direction::Down);
|
||||||
|
}
|
||||||
|
Action::FocusPaneLeft => {
|
||||||
|
log::debug!("Action: FocusPaneLeft");
|
||||||
|
let _ = client.focus_pane(Direction::Left);
|
||||||
|
}
|
||||||
|
Action::FocusPaneRight => {
|
||||||
|
log::debug!("Action: FocusPaneRight");
|
||||||
|
let _ = client.focus_pane(Direction::Right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_keyboard_input(&mut self, event: KeyEvent) {
|
||||||
|
// First check if this is a keybinding
|
||||||
|
if self.check_keybinding(&event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine event type
|
||||||
|
let event_type = match event.state {
|
||||||
|
ElementState::Pressed => {
|
||||||
|
if event.repeat {
|
||||||
|
KeyEventType::Repeat
|
||||||
|
} else {
|
||||||
|
KeyEventType::Press
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElementState::Released => KeyEventType::Release,
|
||||||
|
};
|
||||||
|
|
||||||
|
// In legacy mode, ignore release events
|
||||||
|
if event_type == KeyEventType::Release && !self.keyboard_state.report_events() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build modifiers from the tracked state
|
||||||
|
let mod_state = self.modifiers.state();
|
||||||
|
let modifiers = Modifiers {
|
||||||
|
shift: mod_state.shift_key(),
|
||||||
|
alt: mod_state.alt_key(),
|
||||||
|
ctrl: mod_state.control_key(),
|
||||||
|
super_key: mod_state.super_key(),
|
||||||
|
hyper: false,
|
||||||
|
meta: false,
|
||||||
|
caps_lock: false,
|
||||||
|
num_lock: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let encoder = KeyEncoder::new(&self.keyboard_state);
|
||||||
|
|
||||||
|
let bytes: Option<Vec<u8>> = match &event.logical_key {
|
||||||
|
Key::Named(named) => {
|
||||||
|
let func_key = match named {
|
||||||
|
NamedKey::Enter => Some(FunctionalKey::Enter),
|
||||||
|
NamedKey::Backspace => Some(FunctionalKey::Backspace),
|
||||||
|
NamedKey::Tab => Some(FunctionalKey::Tab),
|
||||||
|
NamedKey::Escape => Some(FunctionalKey::Escape),
|
||||||
|
NamedKey::Space => None,
|
||||||
|
NamedKey::ArrowUp => Some(FunctionalKey::Up),
|
||||||
|
NamedKey::ArrowDown => Some(FunctionalKey::Down),
|
||||||
|
NamedKey::ArrowRight => Some(FunctionalKey::Right),
|
||||||
|
NamedKey::ArrowLeft => Some(FunctionalKey::Left),
|
||||||
|
NamedKey::Home => Some(FunctionalKey::Home),
|
||||||
|
NamedKey::End => Some(FunctionalKey::End),
|
||||||
|
NamedKey::PageUp => Some(FunctionalKey::PageUp),
|
||||||
|
NamedKey::PageDown => Some(FunctionalKey::PageDown),
|
||||||
|
NamedKey::Insert => Some(FunctionalKey::Insert),
|
||||||
|
NamedKey::Delete => Some(FunctionalKey::Delete),
|
||||||
|
NamedKey::F1 => Some(FunctionalKey::F1),
|
||||||
|
NamedKey::F2 => Some(FunctionalKey::F2),
|
||||||
|
NamedKey::F3 => Some(FunctionalKey::F3),
|
||||||
|
NamedKey::F4 => Some(FunctionalKey::F4),
|
||||||
|
NamedKey::F5 => Some(FunctionalKey::F5),
|
||||||
|
NamedKey::F6 => Some(FunctionalKey::F6),
|
||||||
|
NamedKey::F7 => Some(FunctionalKey::F7),
|
||||||
|
NamedKey::F8 => Some(FunctionalKey::F8),
|
||||||
|
NamedKey::F9 => Some(FunctionalKey::F9),
|
||||||
|
NamedKey::F10 => Some(FunctionalKey::F10),
|
||||||
|
NamedKey::F11 => Some(FunctionalKey::F11),
|
||||||
|
NamedKey::F12 => Some(FunctionalKey::F12),
|
||||||
|
NamedKey::CapsLock => Some(FunctionalKey::CapsLock),
|
||||||
|
NamedKey::ScrollLock => Some(FunctionalKey::ScrollLock),
|
||||||
|
NamedKey::NumLock => Some(FunctionalKey::NumLock),
|
||||||
|
NamedKey::PrintScreen => Some(FunctionalKey::PrintScreen),
|
||||||
|
NamedKey::Pause => Some(FunctionalKey::Pause),
|
||||||
|
NamedKey::ContextMenu => Some(FunctionalKey::Menu),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(key) = func_key {
|
||||||
|
Some(encoder.encode_functional(key, modifiers, event_type))
|
||||||
|
} else if *named == NamedKey::Space {
|
||||||
|
Some(encoder.encode_char(' ', modifiers, event_type))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Key::Character(c) => {
|
||||||
|
if let Some(ch) = c.chars().next() {
|
||||||
|
let key_char = ch.to_lowercase().next().unwrap_or(ch);
|
||||||
|
Some(encoder.encode_char(key_char, modifiers, event_type))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(bytes) = bytes {
|
||||||
|
// Check scroll offset before borrowing client mutably
|
||||||
|
let scroll_reset = self.get_active_pane()
|
||||||
|
.filter(|(_, snapshot)| snapshot.scroll_offset > 0)
|
||||||
|
.map(|(pane_id, snapshot)| (pane_id, snapshot.scroll_offset));
|
||||||
|
|
||||||
|
// Now borrow client mutably
|
||||||
|
if let Some(client) = &mut self.daemon_client {
|
||||||
|
let _ = client.send_input(bytes);
|
||||||
|
|
||||||
|
// Reset scroll position when typing (go back to live terminal)
|
||||||
|
if let Some((active_pane_id, scroll_offset)) = scroll_reset {
|
||||||
|
let _ = client.send(&ClientMessage::Scroll {
|
||||||
|
pane_id: active_pane_id,
|
||||||
|
delta: -(scroll_offset as i32)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resize(&mut self, new_size: PhysicalSize<u32>) {
|
||||||
|
if new_size.width == 0 || new_size.height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(renderer) = &mut self.renderer {
|
||||||
|
renderer.resize(new_size.width, new_size.height);
|
||||||
|
|
||||||
|
let (cols, rows) = renderer.terminal_size();
|
||||||
|
|
||||||
|
if let Some(client) = &mut self.daemon_client {
|
||||||
|
let _ = client.send_resize(cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::debug!("Resized to {}x{} cells", cols, rows);
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApplicationHandler for App {
|
||||||
|
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
|
if self.window.is_none() {
|
||||||
|
self.initialize(event_loop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
||||||
|
match event {
|
||||||
|
WindowEvent::CloseRequested => {
|
||||||
|
log::info!("Window close requested");
|
||||||
|
event_loop.exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowEvent::Resized(new_size) => {
|
||||||
|
self.resize(new_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
|
||||||
|
log::info!("Scale factor changed to {}", scale_factor);
|
||||||
|
if let Some(renderer) = &mut self.renderer {
|
||||||
|
if renderer.set_scale_factor(scale_factor) {
|
||||||
|
let (cols, rows) = renderer.terminal_size();
|
||||||
|
|
||||||
|
if let Some(client) = &mut self.daemon_client {
|
||||||
|
let _ = client.send_resize(cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Terminal resized to {}x{} cells after scale change", cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(window) = &self.window {
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowEvent::ModifiersChanged(new_modifiers) => {
|
||||||
|
self.modifiers = new_modifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowEvent::MouseWheel { delta, .. } => {
|
||||||
|
// Handle mouse wheel for scrollback
|
||||||
|
let lines = match delta {
|
||||||
|
MouseScrollDelta::LineDelta(_, y) => {
|
||||||
|
// y > 0 means scrolling up (into history), y < 0 means down
|
||||||
|
(y * 3.0) as i32 // 3 lines per scroll notch
|
||||||
|
}
|
||||||
|
MouseScrollDelta::PixelDelta(pos) => {
|
||||||
|
// Convert pixels to lines (rough approximation)
|
||||||
|
(pos.y / 20.0) as i32
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if lines != 0 {
|
||||||
|
// Get the active pane ID to scroll
|
||||||
|
if let Some((active_pane_id, _)) = self.get_active_pane() {
|
||||||
|
if let Some(client) = &mut self.daemon_client {
|
||||||
|
let _ = client.send(&ClientMessage::Scroll {
|
||||||
|
pane_id: active_pane_id,
|
||||||
|
delta: lines
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(window) = &self.window {
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowEvent::KeyboardInput { event, .. } => {
|
||||||
|
self.handle_keyboard_input(event);
|
||||||
|
if let Some(window) = &self.window {
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowEvent::RedrawRequested => {
|
||||||
|
// Gather all panes for the active tab with their layout info (cloned to avoid borrow conflict)
|
||||||
|
let (panes_with_info, active_pane_id) = self.active_tab_panes();
|
||||||
|
let tabs = self.window_state.as_ref().map(|w| w.tabs.clone());
|
||||||
|
let active_tab = self.window_state.as_ref().map(|w| w.active_tab).unwrap_or(0);
|
||||||
|
|
||||||
|
if let Some(renderer) = &mut self.renderer {
|
||||||
|
if !panes_with_info.is_empty() {
|
||||||
|
let tabs = tabs.unwrap_or_default();
|
||||||
|
// Convert owned data to references for the renderer
|
||||||
|
let pane_refs: Vec<(&PaneSnapshot, &PaneInfo)> = panes_with_info.iter()
|
||||||
|
.map(|(snap, info)| (snap, info))
|
||||||
|
.collect();
|
||||||
|
match renderer.render_with_tabs(&pane_refs, active_pane_id, &tabs, active_tab) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(wgpu::SurfaceError::Lost) => {
|
||||||
|
renderer.resize(renderer.width, renderer.height);
|
||||||
|
}
|
||||||
|
Err(wgpu::SurfaceError::OutOfMemory) => {
|
||||||
|
log::error!("Out of GPU memory!");
|
||||||
|
event_loop.exit();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Render error: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.dirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
|
// Check if daemon is still connected
|
||||||
|
if self.daemon_client.is_none() {
|
||||||
|
log::info!("Lost connection to daemon, exiting");
|
||||||
|
event_loop.exit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll daemon for updates
|
||||||
|
self.poll_daemon();
|
||||||
|
|
||||||
|
// Request redraw if we have new content
|
||||||
|
if self.dirty {
|
||||||
|
if let Some(window) = &self.window {
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use WaitUntil to wake up periodically and check for daemon messages
|
||||||
|
// This is more compatible than relying on send_event across threads
|
||||||
|
event_loop.set_control_flow(ControlFlow::WaitUntil(
|
||||||
|
std::time::Instant::now() + Duration::from_millis(16)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_event(&mut self, _event_loop: &ActiveEventLoop, _event: ()) {
|
||||||
|
// Daemon poll thread woke us up - poll for messages
|
||||||
|
self.poll_daemon();
|
||||||
|
|
||||||
|
// Request redraw if we have new content
|
||||||
|
if self.dirty {
|
||||||
|
if let Some(window) = &self.window {
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for App {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Signal the daemon poll thread to exit
|
||||||
|
self.shutdown.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Initialize logging
|
||||||
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
|
log::info!("Starting ZTerm client");
|
||||||
|
|
||||||
|
// Create event loop with Wayland preference
|
||||||
|
let event_loop = EventLoop::builder()
|
||||||
|
.with_any_thread(true)
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create event loop");
|
||||||
|
|
||||||
|
// Use Wait instead of Poll to avoid busy-looping
|
||||||
|
// The daemon poll thread will wake us when data is available
|
||||||
|
event_loop.set_control_flow(ControlFlow::Wait);
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
|
||||||
|
// Give the app a proxy to wake the event loop from the daemon poll thread
|
||||||
|
app.set_event_loop_proxy(event_loop.create_proxy());
|
||||||
|
|
||||||
|
event_loop.run_app(&mut app).expect("Event loop error");
|
||||||
|
}
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
//! 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
//! PTY (pseudo-terminal) handling for shell communication.
|
||||||
|
|
||||||
|
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 thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum PtyError {
|
||||||
|
#[error("Failed to open PTY master: {0}")]
|
||||||
|
OpenMaster(#[source] rustix::io::Errno),
|
||||||
|
#[error("Failed to grant PTY: {0}")]
|
||||||
|
Grant(#[source] rustix::io::Errno),
|
||||||
|
#[error("Failed to unlock PTY: {0}")]
|
||||||
|
Unlock(#[source] rustix::io::Errno),
|
||||||
|
#[error("Failed to get PTS name: {0}")]
|
||||||
|
PtsName(#[source] rustix::io::Errno),
|
||||||
|
#[error("Failed to open PTS: {0}")]
|
||||||
|
OpenSlave(#[source] rustix::io::Errno),
|
||||||
|
#[error("Failed to fork: {0}")]
|
||||||
|
Fork(#[source] std::io::Error),
|
||||||
|
#[error("Failed to execute shell: {0}")]
|
||||||
|
Exec(#[source] std::io::Error),
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[source] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents the master side of a PTY pair.
|
||||||
|
pub struct Pty {
|
||||||
|
master: OwnedFd,
|
||||||
|
child_pid: rustix::process::Pid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pty {
|
||||||
|
/// Creates a new PTY and spawns a shell process.
|
||||||
|
pub fn spawn(shell: Option<&str>) -> Result<Self, PtyError> {
|
||||||
|
// Open the PTY master
|
||||||
|
let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC)
|
||||||
|
.map_err(PtyError::OpenMaster)?;
|
||||||
|
|
||||||
|
// Set non-blocking mode on master
|
||||||
|
fcntl_setfl(&master, OFlags::NONBLOCK).map_err(|e| PtyError::Io(e.into()))?;
|
||||||
|
|
||||||
|
// Grant and unlock the PTY
|
||||||
|
grantpt(&master).map_err(PtyError::Grant)?;
|
||||||
|
unlockpt(&master).map_err(PtyError::Unlock)?;
|
||||||
|
|
||||||
|
// Get the slave name
|
||||||
|
let slave_name = ptsname(&master, Vec::new()).map_err(PtyError::PtsName)?;
|
||||||
|
|
||||||
|
// Fork the process
|
||||||
|
// SAFETY: We're careful to only use async-signal-safe functions in the child
|
||||||
|
let fork_result = unsafe { libc::fork() };
|
||||||
|
|
||||||
|
match fork_result {
|
||||||
|
-1 => Err(PtyError::Fork(std::io::Error::last_os_error())),
|
||||||
|
0 => {
|
||||||
|
// Child process
|
||||||
|
Self::setup_child(&slave_name, shell);
|
||||||
|
}
|
||||||
|
pid => {
|
||||||
|
// Parent process
|
||||||
|
let child_pid = unsafe { rustix::process::Pid::from_raw_unchecked(pid) };
|
||||||
|
Ok(Self { master, child_pid })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets up the child process (runs in forked child).
|
||||||
|
fn setup_child(slave_name: &CString, shell: Option<&str>) -> ! {
|
||||||
|
// Create a new session
|
||||||
|
unsafe { libc::setsid() };
|
||||||
|
|
||||||
|
// Open the slave PTY using libc for async-signal-safety
|
||||||
|
let slave_fd = unsafe { libc::open(slave_name.as_ptr(), libc::O_RDWR) };
|
||||||
|
if slave_fd < 0 {
|
||||||
|
unsafe { libc::_exit(1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set as controlling terminal
|
||||||
|
unsafe { libc::ioctl(slave_fd, libc::TIOCSCTTY, 0) };
|
||||||
|
|
||||||
|
// Duplicate slave to stdin/stdout/stderr
|
||||||
|
unsafe {
|
||||||
|
libc::dup2(slave_fd, 0);
|
||||||
|
libc::dup2(slave_fd, 1);
|
||||||
|
libc::dup2(slave_fd, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the original slave fd if it's not 0, 1, or 2
|
||||||
|
if slave_fd > 2 {
|
||||||
|
unsafe { libc::close(slave_fd) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which shell to use
|
||||||
|
let shell_path = shell
|
||||||
|
.map(String::from)
|
||||||
|
.or_else(|| std::env::var("SHELL").ok())
|
||||||
|
.unwrap_or_else(|| "/bin/sh".to_string());
|
||||||
|
|
||||||
|
let shell_cstr = CString::new(shell_path.clone()).expect("Invalid shell path");
|
||||||
|
let shell_name = std::path::Path::new(&shell_path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("sh");
|
||||||
|
|
||||||
|
// Login shell (prepend with -)
|
||||||
|
let login_shell = CString::new(format!("-{}", shell_name)).expect("Invalid shell name");
|
||||||
|
|
||||||
|
// Execute the shell
|
||||||
|
let args = [login_shell.as_ptr(), std::ptr::null()];
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
libc::execvp(shell_cstr.as_ptr(), args.as_ptr());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If exec fails, exit
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the master file descriptor.
|
||||||
|
pub fn master_fd(&self) -> BorrowedFd<'_> {
|
||||||
|
self.master.as_fd()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads data from the PTY master.
|
||||||
|
/// Returns Ok(0) if no data is available (non-blocking).
|
||||||
|
pub fn read(&self, buf: &mut [u8]) -> Result<usize, PtyError> {
|
||||||
|
match read(&self.master, buf) {
|
||||||
|
Ok(n) => Ok(n),
|
||||||
|
Err(Errno::AGAIN) => Ok(0), // WOULDBLOCK is same as AGAIN on Linux
|
||||||
|
Err(e) => Err(PtyError::Io(e.into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes data to the PTY master.
|
||||||
|
pub fn write(&self, buf: &[u8]) -> Result<usize, PtyError> {
|
||||||
|
write(&self.master, buf).map_err(|e| PtyError::Io(e.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resizes the PTY window.
|
||||||
|
pub fn resize(&self, cols: u16, rows: u16) -> Result<(), PtyError> {
|
||||||
|
let winsize = libc::winsize {
|
||||||
|
ws_row: rows,
|
||||||
|
ws_col: cols,
|
||||||
|
ws_xpixel: 0,
|
||||||
|
ws_ypixel: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let fd = std::os::fd::AsRawFd::as_raw_fd(&self.master);
|
||||||
|
let result = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &winsize) };
|
||||||
|
|
||||||
|
if result == -1 {
|
||||||
|
Err(PtyError::Io(std::io::Error::last_os_error()))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the child process ID.
|
||||||
|
pub fn child_pid(&self) -> rustix::process::Pid {
|
||||||
|
self.child_pid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Pty {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Send SIGHUP to the child process
|
||||||
|
unsafe {
|
||||||
|
libc::kill(self.child_pid.as_raw_nonzero().get(), libc::SIGHUP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3666
File diff suppressed because it is too large
Load Diff
+160
@@ -0,0 +1,160 @@
|
|||||||
|
//! 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Vertex shader
|
||||||
|
|
||||||
|
struct VertexInput {
|
||||||
|
@location(0) position: vec2<f32>,
|
||||||
|
@location(1) color: vec4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VertexOutput {
|
||||||
|
@builtin(position) clip_position: vec4<f32>,
|
||||||
|
@location(0) color: vec4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_main(in: VertexInput) -> VertexOutput {
|
||||||
|
var out: VertexOutput;
|
||||||
|
out.clip_position = vec4<f32>(in.position, 0.0, 1.0);
|
||||||
|
out.color = in.color;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fragment shader
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
|
return in.color;
|
||||||
|
}
|
||||||
+982
@@ -0,0 +1,982 @@
|
|||||||
|
//! Terminal state management and escape sequence handling.
|
||||||
|
|
||||||
|
use crate::keyboard::{query_response, KeyboardState};
|
||||||
|
use vte::{Params, Parser, Perform};
|
||||||
|
|
||||||
|
/// A single cell in the terminal grid.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Cell {
|
||||||
|
pub character: char,
|
||||||
|
pub fg_color: Color,
|
||||||
|
pub bg_color: Color,
|
||||||
|
pub bold: bool,
|
||||||
|
pub italic: bool,
|
||||||
|
pub underline: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Cell {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
character: ' ',
|
||||||
|
fg_color: Color::Default,
|
||||||
|
bg_color: Color::Default,
|
||||||
|
bold: false,
|
||||||
|
italic: false,
|
||||||
|
underline: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Terminal colors.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub enum Color {
|
||||||
|
Default,
|
||||||
|
Rgb(u8, u8, u8),
|
||||||
|
Indexed(u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cursor shape styles (DECSCUSR).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Default)]
|
||||||
|
pub enum CursorShape {
|
||||||
|
/// Blinking block (and default)
|
||||||
|
#[default]
|
||||||
|
BlinkingBlock,
|
||||||
|
/// Steady block
|
||||||
|
SteadyBlock,
|
||||||
|
/// Blinking underline
|
||||||
|
BlinkingUnderline,
|
||||||
|
/// Steady underline
|
||||||
|
SteadyUnderline,
|
||||||
|
/// Blinking bar (beam)
|
||||||
|
BlinkingBar,
|
||||||
|
/// Steady bar (beam)
|
||||||
|
SteadyBar,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Color palette with 256 colors + default fg/bg.
|
||||||
|
pub struct ColorPalette {
|
||||||
|
/// 256 indexed colors (ANSI 0-15 + 216 color cube + 24 grayscale).
|
||||||
|
pub colors: [[u8; 3]; 256],
|
||||||
|
/// Default foreground color.
|
||||||
|
pub default_fg: [u8; 3],
|
||||||
|
/// Default background color.
|
||||||
|
pub default_bg: [u8; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ColorPalette {
|
||||||
|
fn default() -> Self {
|
||||||
|
let mut colors = [[0u8; 3]; 256];
|
||||||
|
|
||||||
|
// Standard ANSI colors (0-7)
|
||||||
|
colors[0] = [0, 0, 0]; // Black
|
||||||
|
colors[1] = [204, 0, 0]; // Red
|
||||||
|
colors[2] = [0, 204, 0]; // Green
|
||||||
|
colors[3] = [204, 204, 0]; // Yellow
|
||||||
|
colors[4] = [0, 0, 204]; // Blue
|
||||||
|
colors[5] = [204, 0, 204]; // Magenta
|
||||||
|
colors[6] = [0, 204, 204]; // Cyan
|
||||||
|
colors[7] = [204, 204, 204]; // White
|
||||||
|
|
||||||
|
// Bright ANSI colors (8-15)
|
||||||
|
colors[8] = [102, 102, 102]; // Bright Black (Gray)
|
||||||
|
colors[9] = [255, 0, 0]; // Bright Red
|
||||||
|
colors[10] = [0, 255, 0]; // Bright Green
|
||||||
|
colors[11] = [255, 255, 0]; // Bright Yellow
|
||||||
|
colors[12] = [0, 0, 255]; // Bright Blue
|
||||||
|
colors[13] = [255, 0, 255]; // Bright Magenta
|
||||||
|
colors[14] = [0, 255, 255]; // Bright Cyan
|
||||||
|
colors[15] = [255, 255, 255]; // Bright White
|
||||||
|
|
||||||
|
// 216 color cube (16-231)
|
||||||
|
for r in 0..6 {
|
||||||
|
for g in 0..6 {
|
||||||
|
for b in 0..6 {
|
||||||
|
let idx = 16 + r * 36 + g * 6 + b;
|
||||||
|
let to_val = |c: usize| if c == 0 { 0 } else { (55 + c * 40) as u8 };
|
||||||
|
colors[idx] = [to_val(r), to_val(g), to_val(b)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 24 grayscale colors (232-255)
|
||||||
|
for i in 0..24 {
|
||||||
|
let gray = (8 + i * 10) as u8;
|
||||||
|
colors[232 + i] = [gray, gray, gray];
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
colors,
|
||||||
|
default_fg: [230, 230, 230], // Light gray
|
||||||
|
default_bg: [26, 26, 26], // Dark gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColorPalette {
|
||||||
|
/// Parse a color specification like "#RRGGBB" or "rgb:RR/GG/BB".
|
||||||
|
pub fn parse_color_spec(spec: &str) -> Option<[u8; 3]> {
|
||||||
|
let spec = spec.trim();
|
||||||
|
|
||||||
|
if let Some(hex) = spec.strip_prefix('#') {
|
||||||
|
// #RRGGBB format
|
||||||
|
if hex.len() == 6 {
|
||||||
|
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||||
|
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||||
|
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||||
|
return Some([r, g, b]);
|
||||||
|
}
|
||||||
|
} else if let Some(rgb) = spec.strip_prefix("rgb:") {
|
||||||
|
// rgb:RR/GG/BB or rgb:RRRR/GGGG/BBBB format
|
||||||
|
let parts: Vec<&str> = rgb.split('/').collect();
|
||||||
|
if parts.len() == 3 {
|
||||||
|
let parse_component = |s: &str| -> Option<u8> {
|
||||||
|
let val = u16::from_str_radix(s, 16).ok()?;
|
||||||
|
// Scale to 8-bit if it's a 16-bit value
|
||||||
|
Some(if s.len() > 2 { (val >> 8) as u8 } else { val as u8 })
|
||||||
|
};
|
||||||
|
let r = parse_component(parts[0])?;
|
||||||
|
let g = parse_component(parts[1])?;
|
||||||
|
let b = parse_component(parts[2])?;
|
||||||
|
return Some([r, g, b]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get RGBA for a color, using the palette for indexed colors.
|
||||||
|
pub fn to_rgba(&self, color: &Color) -> [f32; 4] {
|
||||||
|
match color {
|
||||||
|
Color::Default => {
|
||||||
|
let [r, g, b] = self.default_fg;
|
||||||
|
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
|
||||||
|
}
|
||||||
|
Color::Rgb(r, g, b) => [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0],
|
||||||
|
Color::Indexed(idx) => {
|
||||||
|
let [r, g, b] = self.colors[*idx as usize];
|
||||||
|
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get RGBA for background, using palette default_bg for Color::Default.
|
||||||
|
pub fn to_rgba_bg(&self, color: &Color) -> [f32; 4] {
|
||||||
|
match color {
|
||||||
|
Color::Default => {
|
||||||
|
let [r, g, b] = self.default_bg;
|
||||||
|
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
|
||||||
|
}
|
||||||
|
Color::Rgb(r, g, b) => [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0],
|
||||||
|
Color::Indexed(idx) => {
|
||||||
|
let [r, g, b] = self.colors[*idx as usize];
|
||||||
|
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The terminal grid state.
|
||||||
|
pub struct Terminal {
|
||||||
|
/// Grid of cells (row-major order).
|
||||||
|
pub grid: Vec<Vec<Cell>>,
|
||||||
|
/// Number of columns.
|
||||||
|
pub cols: usize,
|
||||||
|
/// Number of rows.
|
||||||
|
pub rows: usize,
|
||||||
|
/// Current cursor column (0-indexed).
|
||||||
|
pub cursor_col: usize,
|
||||||
|
/// Current cursor row (0-indexed).
|
||||||
|
pub cursor_row: usize,
|
||||||
|
/// Cursor visibility.
|
||||||
|
pub cursor_visible: bool,
|
||||||
|
/// Cursor shape (block, underline, bar).
|
||||||
|
pub cursor_shape: CursorShape,
|
||||||
|
/// Current foreground color for new text.
|
||||||
|
pub current_fg: Color,
|
||||||
|
/// Current background color for new text.
|
||||||
|
pub current_bg: Color,
|
||||||
|
/// Current bold state.
|
||||||
|
pub current_bold: bool,
|
||||||
|
/// Current italic state.
|
||||||
|
pub current_italic: bool,
|
||||||
|
/// Current underline state.
|
||||||
|
pub current_underline: bool,
|
||||||
|
/// Whether the terminal content has changed.
|
||||||
|
pub dirty: bool,
|
||||||
|
/// Scroll region top (0-indexed, inclusive).
|
||||||
|
scroll_top: usize,
|
||||||
|
/// Scroll region bottom (0-indexed, inclusive).
|
||||||
|
scroll_bottom: usize,
|
||||||
|
/// Kitty keyboard protocol state.
|
||||||
|
pub keyboard: KeyboardState,
|
||||||
|
/// Response queue (bytes to send back to PTY).
|
||||||
|
response_queue: Vec<u8>,
|
||||||
|
/// Color palette (can be modified by OSC sequences).
|
||||||
|
pub palette: ColorPalette,
|
||||||
|
/// Scrollback buffer (lines that scrolled off the top).
|
||||||
|
pub scrollback: Vec<Vec<Cell>>,
|
||||||
|
/// Maximum number of lines to keep in scrollback.
|
||||||
|
pub scrollback_limit: usize,
|
||||||
|
/// Current scroll offset (0 = viewing live terminal, >0 = viewing history).
|
||||||
|
pub scroll_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Terminal {
|
||||||
|
/// Default scrollback limit (10,000 lines).
|
||||||
|
const DEFAULT_SCROLLBACK_LIMIT: usize = 10_000;
|
||||||
|
|
||||||
|
/// Creates a new terminal with the given dimensions.
|
||||||
|
pub fn new(cols: usize, rows: usize) -> Self {
|
||||||
|
let grid = vec![vec![Cell::default(); cols]; rows];
|
||||||
|
|
||||||
|
Self {
|
||||||
|
grid,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
cursor_col: 0,
|
||||||
|
cursor_row: 0,
|
||||||
|
cursor_visible: true,
|
||||||
|
cursor_shape: CursorShape::default(),
|
||||||
|
current_fg: Color::Default,
|
||||||
|
current_bg: Color::Default,
|
||||||
|
current_bold: false,
|
||||||
|
current_italic: false,
|
||||||
|
current_underline: false,
|
||||||
|
dirty: true,
|
||||||
|
scroll_top: 0,
|
||||||
|
scroll_bottom: rows.saturating_sub(1),
|
||||||
|
keyboard: KeyboardState::new(),
|
||||||
|
response_queue: Vec::new(),
|
||||||
|
palette: ColorPalette::default(),
|
||||||
|
scrollback: Vec::new(),
|
||||||
|
scrollback_limit: Self::DEFAULT_SCROLLBACK_LIMIT,
|
||||||
|
scroll_offset: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes any pending response bytes to send to the PTY.
|
||||||
|
pub fn take_response(&mut self) -> Option<Vec<u8>> {
|
||||||
|
if self.response_queue.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(std::mem::take(&mut self.response_queue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes raw bytes from the PTY using the provided parser.
|
||||||
|
pub fn process(&mut self, bytes: &[u8], parser: &mut Parser) {
|
||||||
|
for byte in bytes {
|
||||||
|
parser.advance(self, *byte);
|
||||||
|
}
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resizes the terminal grid.
|
||||||
|
pub fn resize(&mut self, cols: usize, rows: usize) {
|
||||||
|
if cols == self.cols && rows == self.rows {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new grid
|
||||||
|
let mut new_grid = vec![vec![Cell::default(); cols]; rows];
|
||||||
|
|
||||||
|
// Copy existing content
|
||||||
|
for row in 0..rows.min(self.rows) {
|
||||||
|
for col in 0..cols.min(self.cols) {
|
||||||
|
new_grid[row][col] = self.grid[row][col].clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.grid = new_grid;
|
||||||
|
self.cols = cols;
|
||||||
|
self.rows = rows;
|
||||||
|
|
||||||
|
// Reset scroll region to full screen
|
||||||
|
self.scroll_top = 0;
|
||||||
|
self.scroll_bottom = rows.saturating_sub(1);
|
||||||
|
|
||||||
|
// Adjust cursor position
|
||||||
|
self.cursor_col = self.cursor_col.min(cols.saturating_sub(1));
|
||||||
|
self.cursor_row = self.cursor_row.min(rows.saturating_sub(1));
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls the scroll region up by n lines.
|
||||||
|
fn scroll_up(&mut self, n: usize) {
|
||||||
|
let n = n.min(self.scroll_bottom - self.scroll_top + 1);
|
||||||
|
for _ in 0..n {
|
||||||
|
// Remove the top line of the scroll region
|
||||||
|
let removed_line = self.grid.remove(self.scroll_top);
|
||||||
|
|
||||||
|
// Save to scrollback only if scrolling from the very top of the screen
|
||||||
|
if self.scroll_top == 0 {
|
||||||
|
self.scrollback.push(removed_line);
|
||||||
|
// Trim scrollback if it exceeds the limit
|
||||||
|
if self.scrollback.len() > self.scrollback_limit {
|
||||||
|
self.scrollback.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert a new blank line at the bottom of the scroll region
|
||||||
|
self.grid
|
||||||
|
.insert(self.scroll_bottom, vec![Cell::default(); self.cols]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls the scroll region down by n lines.
|
||||||
|
fn scroll_down(&mut self, n: usize) {
|
||||||
|
let n = n.min(self.scroll_bottom - self.scroll_top + 1);
|
||||||
|
for _ in 0..n {
|
||||||
|
// Remove the bottom line of the scroll region
|
||||||
|
self.grid.remove(self.scroll_bottom);
|
||||||
|
// Insert a new blank line at the top of the scroll region
|
||||||
|
self.grid
|
||||||
|
.insert(self.scroll_top, vec![Cell::default(); self.cols]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls the viewport up (into scrollback history) by n lines.
|
||||||
|
/// Returns the new scroll offset.
|
||||||
|
pub fn scroll_viewport_up(&mut self, n: usize) -> usize {
|
||||||
|
let max_offset = self.scrollback.len();
|
||||||
|
self.scroll_offset = (self.scroll_offset + n).min(max_offset);
|
||||||
|
self.dirty = true;
|
||||||
|
self.scroll_offset
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls the viewport down (toward live terminal) by n lines.
|
||||||
|
/// Returns the new scroll offset.
|
||||||
|
pub fn scroll_viewport_down(&mut self, n: usize) -> usize {
|
||||||
|
self.scroll_offset = self.scroll_offset.saturating_sub(n);
|
||||||
|
self.dirty = true;
|
||||||
|
self.scroll_offset
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets viewport to show live terminal (scroll_offset = 0).
|
||||||
|
pub fn reset_scroll(&mut self) {
|
||||||
|
if self.scroll_offset != 0 {
|
||||||
|
self.scroll_offset = 0;
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the visible rows accounting for scroll offset.
|
||||||
|
/// This combines scrollback lines with the current grid.
|
||||||
|
pub fn visible_rows(&self) -> Vec<&Vec<Cell>> {
|
||||||
|
let mut rows = Vec::with_capacity(self.rows);
|
||||||
|
|
||||||
|
if self.scroll_offset == 0 {
|
||||||
|
// No scrollback viewing, just return the grid
|
||||||
|
for row in &self.grid {
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// We're viewing scrollback
|
||||||
|
// scroll_offset = how many lines back we're looking
|
||||||
|
let scrollback_len = self.scrollback.len();
|
||||||
|
|
||||||
|
for i in 0..self.rows {
|
||||||
|
// Calculate which line to show
|
||||||
|
// If scroll_offset = 5, we want to show 5 lines from scrollback at the top
|
||||||
|
let lines_from_scrollback = self.scroll_offset.min(self.rows);
|
||||||
|
|
||||||
|
if i < lines_from_scrollback {
|
||||||
|
// This row comes from scrollback
|
||||||
|
let scrollback_idx = scrollback_len - self.scroll_offset + i;
|
||||||
|
if scrollback_idx < scrollback_len {
|
||||||
|
rows.push(&self.scrollback[scrollback_idx]);
|
||||||
|
} else {
|
||||||
|
// Shouldn't happen, but fall back to grid
|
||||||
|
rows.push(&self.grid[i]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// This row comes from the grid
|
||||||
|
let grid_idx = i - lines_from_scrollback;
|
||||||
|
if grid_idx < self.grid.len() {
|
||||||
|
rows.push(&self.grid[grid_idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts n blank lines at the cursor position, scrolling lines below down.
|
||||||
|
fn insert_lines(&mut self, n: usize) {
|
||||||
|
if self.cursor_row < self.scroll_top || self.cursor_row > self.scroll_bottom {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let n = n.min(self.scroll_bottom - self.cursor_row + 1);
|
||||||
|
for _ in 0..n {
|
||||||
|
// Remove the bottom line of the scroll region
|
||||||
|
self.grid.remove(self.scroll_bottom);
|
||||||
|
// Insert a new blank line at the cursor row
|
||||||
|
self.grid
|
||||||
|
.insert(self.cursor_row, vec![Cell::default(); self.cols]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes n lines at the cursor position, scrolling lines below up.
|
||||||
|
fn delete_lines(&mut self, n: usize) {
|
||||||
|
if self.cursor_row < self.scroll_top || self.cursor_row > self.scroll_bottom {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let n = n.min(self.scroll_bottom - self.cursor_row + 1);
|
||||||
|
for _ in 0..n {
|
||||||
|
// Remove the line at cursor
|
||||||
|
self.grid.remove(self.cursor_row);
|
||||||
|
// Insert a new blank line at the bottom of the scroll region
|
||||||
|
self.grid
|
||||||
|
.insert(self.scroll_bottom, vec![Cell::default(); self.cols]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts n blank characters at the cursor, shifting existing chars right.
|
||||||
|
fn insert_characters(&mut self, n: usize) {
|
||||||
|
let row = &mut self.grid[self.cursor_row];
|
||||||
|
let n = n.min(self.cols - self.cursor_col);
|
||||||
|
// Remove n characters from the end
|
||||||
|
for _ in 0..n {
|
||||||
|
row.pop();
|
||||||
|
}
|
||||||
|
// Insert n blank characters at cursor position
|
||||||
|
for _ in 0..n {
|
||||||
|
row.insert(self.cursor_col, Cell::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes n characters at the cursor, shifting remaining chars left.
|
||||||
|
fn delete_characters(&mut self, n: usize) {
|
||||||
|
let row = &mut self.grid[self.cursor_row];
|
||||||
|
let n = n.min(self.cols - self.cursor_col);
|
||||||
|
// Remove n characters at cursor position
|
||||||
|
for _ in 0..n {
|
||||||
|
if self.cursor_col < row.len() {
|
||||||
|
row.remove(self.cursor_col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pad with blank characters at the end
|
||||||
|
while row.len() < self.cols {
|
||||||
|
row.push(Cell::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erases n characters at the cursor (replaces with spaces, doesn't shift).
|
||||||
|
fn erase_characters(&mut self, n: usize) {
|
||||||
|
let n = n.min(self.cols - self.cursor_col);
|
||||||
|
for i in 0..n {
|
||||||
|
if self.cursor_col + i < self.cols {
|
||||||
|
self.grid[self.cursor_row][self.cursor_col + i] = Cell::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the current line from cursor to end.
|
||||||
|
fn clear_line_from_cursor(&mut self) {
|
||||||
|
for col in self.cursor_col..self.cols {
|
||||||
|
self.grid[self.cursor_row][col] = Cell::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the entire screen.
|
||||||
|
fn clear_screen(&mut self) {
|
||||||
|
for row in &mut self.grid {
|
||||||
|
for cell in row {
|
||||||
|
*cell = Cell::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.cursor_col = 0;
|
||||||
|
self.cursor_row = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles Kitty keyboard protocol escape sequences.
|
||||||
|
fn handle_keyboard_protocol(&mut self, params: &[u16], intermediates: &[u8]) {
|
||||||
|
match intermediates {
|
||||||
|
// CSI ? u - Query current keyboard flags
|
||||||
|
[b'?'] => {
|
||||||
|
let response = query_response(self.keyboard.flags());
|
||||||
|
self.response_queue.extend(response);
|
||||||
|
}
|
||||||
|
// CSI = flags ; mode u - Set keyboard flags
|
||||||
|
[b'='] => {
|
||||||
|
let flags = params.first().copied().unwrap_or(0) as u8;
|
||||||
|
let mode = params.get(1).copied().unwrap_or(1) as u8;
|
||||||
|
self.keyboard.set_flags(flags, mode);
|
||||||
|
log::debug!(
|
||||||
|
"Keyboard flags set to {:?} (mode {})",
|
||||||
|
self.keyboard.flags(),
|
||||||
|
mode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// CSI > flags u - Push keyboard flags onto stack
|
||||||
|
[b'>'] => {
|
||||||
|
let flags = if params.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(params[0] as u8)
|
||||||
|
};
|
||||||
|
self.keyboard.push(flags);
|
||||||
|
log::debug!("Keyboard flags pushed: {:?}", self.keyboard.flags());
|
||||||
|
}
|
||||||
|
// CSI < number u - Pop keyboard flags from stack
|
||||||
|
[b'<'] => {
|
||||||
|
let count = params.first().copied().unwrap_or(1) as usize;
|
||||||
|
self.keyboard.pop(count);
|
||||||
|
log::debug!("Keyboard flags popped: {:?}", self.keyboard.flags());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Unknown intermediate, ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Perform for Terminal {
|
||||||
|
fn print(&mut self, c: char) {
|
||||||
|
if self.cursor_col >= self.cols {
|
||||||
|
self.cursor_col = 0;
|
||||||
|
self.cursor_row += 1;
|
||||||
|
if self.cursor_row > self.scroll_bottom {
|
||||||
|
self.scroll_up(1);
|
||||||
|
self.cursor_row = self.scroll_bottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.grid[self.cursor_row][self.cursor_col] = Cell {
|
||||||
|
character: c,
|
||||||
|
fg_color: self.current_fg,
|
||||||
|
bg_color: self.current_bg,
|
||||||
|
bold: self.current_bold,
|
||||||
|
italic: self.current_italic,
|
||||||
|
underline: self.current_underline,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.cursor_col += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(&mut self, byte: u8) {
|
||||||
|
match byte {
|
||||||
|
// Backspace
|
||||||
|
0x08 => {
|
||||||
|
if self.cursor_col > 0 {
|
||||||
|
self.cursor_col -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tab
|
||||||
|
0x09 => {
|
||||||
|
let next_tab = (self.cursor_col / 8 + 1) * 8;
|
||||||
|
self.cursor_col = next_tab.min(self.cols - 1);
|
||||||
|
}
|
||||||
|
// Line feed
|
||||||
|
0x0A => {
|
||||||
|
self.cursor_row += 1;
|
||||||
|
if self.cursor_row > self.scroll_bottom {
|
||||||
|
self.scroll_up(1);
|
||||||
|
self.cursor_row = self.scroll_bottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Carriage return
|
||||||
|
0x0D => {
|
||||||
|
self.cursor_col = 0;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hook(&mut self, _params: &Params, _intermediates: &[u8], _ignore: bool, _action: char) {}
|
||||||
|
|
||||||
|
fn put(&mut self, _byte: u8) {}
|
||||||
|
|
||||||
|
fn unhook(&mut self) {}
|
||||||
|
|
||||||
|
fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
|
||||||
|
// Handle OSC sequences
|
||||||
|
if params.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First param is the OSC number
|
||||||
|
let osc_num = match std::str::from_utf8(params[0]) {
|
||||||
|
Ok(s) => s.parse::<u32>().unwrap_or(u32::MAX),
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
match osc_num {
|
||||||
|
// OSC 4 - Set/query indexed color
|
||||||
|
4 => {
|
||||||
|
// Format: OSC 4 ; index ; color ST
|
||||||
|
// params[0] = "4", params[1] = "index", params[2] = "color"
|
||||||
|
if params.len() >= 3 {
|
||||||
|
if let Ok(index_str) = std::str::from_utf8(params[1]) {
|
||||||
|
if let Ok(index) = index_str.parse::<u8>() {
|
||||||
|
if let Ok(color_spec) = std::str::from_utf8(params[2]) {
|
||||||
|
if let Some(rgb) = ColorPalette::parse_color_spec(color_spec) {
|
||||||
|
self.palette.colors[index as usize] = rgb;
|
||||||
|
log::debug!("OSC 4: Set color {} to {:?}", index, rgb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// OSC 10 - Set/query default foreground color
|
||||||
|
10 => {
|
||||||
|
if params.len() >= 2 {
|
||||||
|
if let Ok(color_spec) = std::str::from_utf8(params[1]) {
|
||||||
|
if let Some(rgb) = ColorPalette::parse_color_spec(color_spec) {
|
||||||
|
self.palette.default_fg = rgb;
|
||||||
|
log::debug!("OSC 10: Set default foreground to {:?}", rgb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// OSC 11 - Set/query default background color
|
||||||
|
11 => {
|
||||||
|
if params.len() >= 2 {
|
||||||
|
if let Ok(color_spec) = std::str::from_utf8(params[1]) {
|
||||||
|
if let Some(rgb) = ColorPalette::parse_color_spec(color_spec) {
|
||||||
|
self.palette.default_bg = rgb;
|
||||||
|
log::debug!("OSC 11: Set default background to {:?}", rgb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// OSC 0, 1, 2 - Set window title (ignore for now)
|
||||||
|
0 | 1 | 2 => {}
|
||||||
|
_ => {
|
||||||
|
log::debug!("Unhandled OSC {}", osc_num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {
|
||||||
|
// For most commands, we just need the first value of each parameter group
|
||||||
|
let flat_params: Vec<u16> = params.iter().map(|p| p[0]).collect();
|
||||||
|
|
||||||
|
match action {
|
||||||
|
// Cursor Up
|
||||||
|
'A' => {
|
||||||
|
let n = flat_params.first().copied().unwrap_or(1).max(1) as usize;
|
||||||
|
self.cursor_row = self.cursor_row.saturating_sub(n);
|
||||||
|
}
|
||||||
|
// Cursor Down
|
||||||
|
'B' => {
|
||||||
|
let n = flat_params.first().copied().unwrap_or(1).max(1) as usize;
|
||||||
|
self.cursor_row = (self.cursor_row + n).min(self.rows - 1);
|
||||||
|
}
|
||||||
|
// Cursor Forward
|
||||||
|
'C' => {
|
||||||
|
let n = flat_params.first().copied().unwrap_or(1).max(1) as usize;
|
||||||
|
self.cursor_col = (self.cursor_col + n).min(self.cols - 1);
|
||||||
|
}
|
||||||
|
// Cursor Back
|
||||||
|
'D' => {
|
||||||
|
let n = flat_params.first().copied().unwrap_or(1).max(1) as usize;
|
||||||
|
self.cursor_col = self.cursor_col.saturating_sub(n);
|
||||||
|
}
|
||||||
|
// Cursor Position
|
||||||
|
'H' | 'f' => {
|
||||||
|
let row = flat_params.first().copied().unwrap_or(1).max(1) as usize;
|
||||||
|
let col = flat_params.get(1).copied().unwrap_or(1).max(1) as usize;
|
||||||
|
self.cursor_row = (row - 1).min(self.rows - 1);
|
||||||
|
self.cursor_col = (col - 1).min(self.cols - 1);
|
||||||
|
}
|
||||||
|
// Erase in Display
|
||||||
|
'J' => {
|
||||||
|
let mode = flat_params.first().copied().unwrap_or(0);
|
||||||
|
match mode {
|
||||||
|
0 => {
|
||||||
|
// Clear from cursor to end of screen
|
||||||
|
self.clear_line_from_cursor();
|
||||||
|
for row in (self.cursor_row + 1)..self.rows {
|
||||||
|
for cell in &mut self.grid[row] {
|
||||||
|
*cell = Cell::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
// Clear from start to cursor
|
||||||
|
for row in 0..self.cursor_row {
|
||||||
|
for cell in &mut self.grid[row] {
|
||||||
|
*cell = Cell::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for col in 0..=self.cursor_col {
|
||||||
|
self.grid[self.cursor_row][col] = Cell::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 | 3 => {
|
||||||
|
// Clear entire screen
|
||||||
|
self.clear_screen();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Erase in Line
|
||||||
|
'K' => {
|
||||||
|
let mode = flat_params.first().copied().unwrap_or(0);
|
||||||
|
match mode {
|
||||||
|
0 => self.clear_line_from_cursor(),
|
||||||
|
1 => {
|
||||||
|
for col in 0..=self.cursor_col {
|
||||||
|
self.grid[self.cursor_row][col] = Cell::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
for cell in &mut self.grid[self.cursor_row] {
|
||||||
|
*cell = Cell::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SGR (Select Graphic Rendition)
|
||||||
|
'm' => {
|
||||||
|
// Handle SGR with proper sub-parameter support
|
||||||
|
// VTE gives us parameter groups - each group can have sub-params (colon-separated)
|
||||||
|
// We need to handle both:
|
||||||
|
// - Legacy: ESC[38;5;196m -> groups: [38], [5], [196]
|
||||||
|
// - Modern: ESC[38:5:196m -> groups: [38, 5, 196]
|
||||||
|
// - Modern: ESC[38:2:r:g:bm -> groups: [38, 2, r, g, b]
|
||||||
|
|
||||||
|
let param_groups: Vec<Vec<u16>> = params.iter()
|
||||||
|
.map(|subparams| subparams.iter().copied().collect())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
log::debug!("SGR param_groups: {:?}", param_groups);
|
||||||
|
|
||||||
|
if param_groups.is_empty() {
|
||||||
|
self.current_fg = Color::Default;
|
||||||
|
self.current_bg = Color::Default;
|
||||||
|
self.current_bold = false;
|
||||||
|
self.current_italic = false;
|
||||||
|
self.current_underline = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
while i < param_groups.len() {
|
||||||
|
let group = ¶m_groups[i];
|
||||||
|
let code = group.first().copied().unwrap_or(0);
|
||||||
|
|
||||||
|
match code {
|
||||||
|
0 => {
|
||||||
|
self.current_fg = Color::Default;
|
||||||
|
self.current_bg = Color::Default;
|
||||||
|
self.current_bold = false;
|
||||||
|
self.current_italic = false;
|
||||||
|
self.current_underline = false;
|
||||||
|
}
|
||||||
|
1 => self.current_bold = true,
|
||||||
|
3 => self.current_italic = true,
|
||||||
|
4 => self.current_underline = true,
|
||||||
|
7 => {
|
||||||
|
// Reverse video - swap fg and bg
|
||||||
|
std::mem::swap(&mut self.current_fg, &mut self.current_bg);
|
||||||
|
}
|
||||||
|
22 => self.current_bold = false,
|
||||||
|
23 => self.current_italic = false,
|
||||||
|
24 => self.current_underline = false,
|
||||||
|
27 => {
|
||||||
|
// Reverse video off - swap back (simplified)
|
||||||
|
std::mem::swap(&mut self.current_fg, &mut self.current_bg);
|
||||||
|
}
|
||||||
|
30..=37 => self.current_fg = Color::Indexed((code - 30) as u8),
|
||||||
|
38 => {
|
||||||
|
// Foreground color - check for sub-parameters first (colon format)
|
||||||
|
if group.len() >= 3 && group[1] == 5 {
|
||||||
|
// Colon format: 38:5:index
|
||||||
|
self.current_fg = Color::Indexed(group[2] as u8);
|
||||||
|
} else if group.len() >= 5 && group[1] == 2 {
|
||||||
|
// Colon format: 38:2:r:g:b or 38:2:colorspace:r:g:b
|
||||||
|
// Check if we have colorspace indicator
|
||||||
|
if group.len() >= 6 {
|
||||||
|
// 38:2:colorspace:r:g:b
|
||||||
|
self.current_fg = Color::Rgb(
|
||||||
|
group[3] as u8,
|
||||||
|
group[4] as u8,
|
||||||
|
group[5] as u8,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 38:2:r:g:b
|
||||||
|
self.current_fg = Color::Rgb(
|
||||||
|
group[2] as u8,
|
||||||
|
group[3] as u8,
|
||||||
|
group[4] as u8,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if i + 2 < param_groups.len() {
|
||||||
|
// Semicolon format: check next groups
|
||||||
|
let mode = param_groups[i + 1].first().copied().unwrap_or(0);
|
||||||
|
if mode == 5 {
|
||||||
|
// 38;5;index
|
||||||
|
let idx = param_groups[i + 2].first().copied().unwrap_or(0);
|
||||||
|
self.current_fg = Color::Indexed(idx as u8);
|
||||||
|
i += 2;
|
||||||
|
} else if mode == 2 && i + 4 < param_groups.len() {
|
||||||
|
// 38;2;r;g;b
|
||||||
|
let r = param_groups[i + 2].first().copied().unwrap_or(0);
|
||||||
|
let g = param_groups[i + 3].first().copied().unwrap_or(0);
|
||||||
|
let b = param_groups[i + 4].first().copied().unwrap_or(0);
|
||||||
|
self.current_fg = Color::Rgb(r as u8, g as u8, b as u8);
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
39 => self.current_fg = Color::Default,
|
||||||
|
40..=47 => self.current_bg = Color::Indexed((code - 40) as u8),
|
||||||
|
48 => {
|
||||||
|
// Background color - check for sub-parameters first (colon format)
|
||||||
|
if group.len() >= 3 && group[1] == 5 {
|
||||||
|
// Colon format: 48:5:index
|
||||||
|
self.current_bg = Color::Indexed(group[2] as u8);
|
||||||
|
} else if group.len() >= 5 && group[1] == 2 {
|
||||||
|
// Colon format: 48:2:r:g:b or 48:2:colorspace:r:g:b
|
||||||
|
if group.len() >= 6 {
|
||||||
|
// 48:2:colorspace:r:g:b
|
||||||
|
self.current_bg = Color::Rgb(
|
||||||
|
group[3] as u8,
|
||||||
|
group[4] as u8,
|
||||||
|
group[5] as u8,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 48:2:r:g:b
|
||||||
|
self.current_bg = Color::Rgb(
|
||||||
|
group[2] as u8,
|
||||||
|
group[3] as u8,
|
||||||
|
group[4] as u8,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if i + 2 < param_groups.len() {
|
||||||
|
// Semicolon format: check next groups
|
||||||
|
let mode = param_groups[i + 1].first().copied().unwrap_or(0);
|
||||||
|
if mode == 5 {
|
||||||
|
// 48;5;index
|
||||||
|
let idx = param_groups[i + 2].first().copied().unwrap_or(0);
|
||||||
|
self.current_bg = Color::Indexed(idx as u8);
|
||||||
|
i += 2;
|
||||||
|
} else if mode == 2 && i + 4 < param_groups.len() {
|
||||||
|
// 48;2;r;g;b
|
||||||
|
let r = param_groups[i + 2].first().copied().unwrap_or(0);
|
||||||
|
let g = param_groups[i + 3].first().copied().unwrap_or(0);
|
||||||
|
let b = param_groups[i + 4].first().copied().unwrap_or(0);
|
||||||
|
self.current_bg = Color::Rgb(r as u8, g as u8, b as u8);
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
49 => self.current_bg = Color::Default,
|
||||||
|
90..=97 => {
|
||||||
|
self.current_fg = Color::Indexed((code - 90 + 8) as u8)
|
||||||
|
}
|
||||||
|
100..=107 => {
|
||||||
|
self.current_bg = Color::Indexed((code - 100 + 8) as u8)
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Set Scrolling Region (DECSTBM)
|
||||||
|
'r' => {
|
||||||
|
let top = flat_params.first().copied().unwrap_or(1).max(1) as usize;
|
||||||
|
let bottom = flat_params.get(1).copied().unwrap_or(self.rows as u16).max(1) as usize;
|
||||||
|
self.scroll_top = (top - 1).min(self.rows - 1);
|
||||||
|
self.scroll_bottom = (bottom - 1).min(self.rows - 1);
|
||||||
|
if self.scroll_top > self.scroll_bottom {
|
||||||
|
std::mem::swap(&mut self.scroll_top, &mut self.scroll_bottom);
|
||||||
|
}
|
||||||
|
// Move cursor to home position
|
||||||
|
self.cursor_row = 0;
|
||||||
|
self.cursor_col = 0;
|
||||||
|
}
|
||||||
|
// Scroll Up (SU)
|
||||||
|
'S' => {
|
||||||
|
let n = flat_params.first().copied().unwrap_or(1).max(1) as usize;
|
||||||
|
self.scroll_up(n);
|
||||||
|
}
|
||||||
|
// Scroll Down (SD)
|
||||||
|
'T' => {
|
||||||
|
let n = flat_params.first().copied().unwrap_or(1).max(1) as usize;
|
||||||
|
self.scroll_down(n);
|
||||||
|
}
|
||||||
|
// Insert Lines (IL)
|
||||||
|
'L' => {
|
||||||
|
let n = flat_params.first().copied().unwrap_or(1).max(1) as usize;
|
||||||
|
self.insert_lines(n);
|
||||||
|
}
|
||||||
|
// Delete Lines (DL)
|
||||||
|
'M' => {
|
||||||
|
let n = flat_params.first().copied().unwrap_or(1).max(1) as usize;
|
||||||
|
self.delete_lines(n);
|
||||||
|
}
|
||||||
|
// Insert Characters (ICH)
|
||||||
|
'@' => {
|
||||||
|
let n = flat_params.first().copied().unwrap_or(1).max(1) as usize;
|
||||||
|
self.insert_characters(n);
|
||||||
|
}
|
||||||
|
// Delete Characters (DCH)
|
||||||
|
'P' => {
|
||||||
|
let n = flat_params.first().copied().unwrap_or(1).max(1) as usize;
|
||||||
|
self.delete_characters(n);
|
||||||
|
}
|
||||||
|
// Erase Characters (ECH)
|
||||||
|
'X' => {
|
||||||
|
let n = flat_params.first().copied().unwrap_or(1).max(1) as usize;
|
||||||
|
self.erase_characters(n);
|
||||||
|
}
|
||||||
|
// Kitty keyboard protocol
|
||||||
|
'u' => {
|
||||||
|
self.handle_keyboard_protocol(&flat_params, intermediates);
|
||||||
|
}
|
||||||
|
// DECSCUSR - Set Cursor Style (CSI Ps SP q)
|
||||||
|
'q' if intermediates == [b' '] => {
|
||||||
|
let style = flat_params.first().copied().unwrap_or(0);
|
||||||
|
self.cursor_shape = match style {
|
||||||
|
0 | 1 => CursorShape::BlinkingBlock, // 0 = default (blinking block), 1 = blinking block
|
||||||
|
2 => CursorShape::SteadyBlock,
|
||||||
|
3 => CursorShape::BlinkingUnderline,
|
||||||
|
4 => CursorShape::SteadyUnderline,
|
||||||
|
5 => CursorShape::BlinkingBar,
|
||||||
|
6 => CursorShape::SteadyBar,
|
||||||
|
_ => CursorShape::BlinkingBlock,
|
||||||
|
};
|
||||||
|
log::debug!("DECSCUSR: cursor shape set to {:?}", self.cursor_shape);
|
||||||
|
}
|
||||||
|
// DEC Private Mode Set (CSI ? Ps h)
|
||||||
|
'h' if intermediates == [b'?'] => {
|
||||||
|
for ¶m in &flat_params {
|
||||||
|
match param {
|
||||||
|
25 => {
|
||||||
|
// DECTCEM - Show cursor
|
||||||
|
self.cursor_visible = true;
|
||||||
|
log::debug!("DECTCEM: cursor visible");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
log::debug!("Unhandled DEC private mode set: {}", param);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// DEC Private Mode Reset (CSI ? Ps l)
|
||||||
|
'l' if intermediates == [b'?'] => {
|
||||||
|
for ¶m in &flat_params {
|
||||||
|
match param {
|
||||||
|
25 => {
|
||||||
|
// DECTCEM - Hide cursor
|
||||||
|
self.cursor_visible = false;
|
||||||
|
log::debug!("DECTCEM: cursor hidden");
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
log::debug!("Unhandled DEC private mode reset: {}", param);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,963 @@
|
|||||||
|
//! 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user