initial commit

This commit is contained in:
Zacharias-Brohn
2025-12-12 22:11:20 +01:00
commit 5d47177fbf
19 changed files with 11695 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
+1
View File
@@ -0,0 +1 @@
max_width = 80
Generated
+2637
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -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"
+25
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}
+48
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+160
View File
@@ -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;
}
}
+26
View File
@@ -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
View File
@@ -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 = &param_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 &param 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 &param 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) {}
}
+963
View File
@@ -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();
}
}
}