//! ZTerm - GPU-accelerated terminal emulator. //! //! Single-process architecture: owns PTY, terminal state, and rendering. //! Supports window close/reopen without losing terminal state. use zterm::config::{Action, Config}; use zterm::keyboard::{FunctionalKey, KeyEncoder, KeyEventType, KeyboardState, Modifiers}; use zterm::pty::Pty; use zterm::renderer::{EdgeGlow, PaneRenderInfo, Renderer, StatuslineComponent, StatuslineContent, StatuslineSection}; use zterm::terminal::{Direction, Terminal, TerminalCommand, MouseTrackingMode}; use std::collections::HashMap; use std::io::Write; use std::os::fd::AsRawFd; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use polling::{Event, Events, Poller}; use winit::application::ApplicationHandler; use winit::dpi::{PhysicalPosition, PhysicalSize}; use winit::event::{ElementState, KeyEvent, MouseButton, Modifiers as WinitModifiers, MouseScrollDelta, WindowEvent}; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy}; use winit::keyboard::{Key, NamedKey}; use winit::platform::wayland::EventLoopBuilderExtWayland; use winit::window::{Window, WindowId}; /// Kitty-style single-buffer for PTY I/O with zero-copy reads and writes. /// /// Uses a single buffer with separate read/write regions: /// - I/O thread writes to `buf[write_offset..]` /// - Main thread reads from `buf[0..read_len]` /// - After main thread consumes data, buffer compacts via memmove /// /// When buffer is full, I/O thread waits on an eventfd. Main thread signals /// the eventfd after consuming data to wake up the I/O thread. /// /// This gives us: /// - Zero-copy writes (I/O reads directly into buffer) /// - Zero-copy reads (main thread gets slice, no allocation) /// - Single 1MB buffer (vs 8MB for double-buffering) /// - No busy-waiting when buffer is full const PTY_BUF_SIZE: usize = 1024 * 1024; // 1MB like Kitty struct SharedPtyBuffer { /// The actual buffer. UnsafeCell because we need disjoint mutable access: /// I/O thread writes to [write_pending..], main thread reads [0..read_available] buf: std::cell::UnsafeCell>, /// Metadata protected by mutex - offsets into the buffer state: Mutex, /// Eventfd to wake up I/O thread when space becomes available wakeup_fd: i32, } // SAFETY: We ensure disjoint access - I/O thread only writes past read_available, // main thread only reads up to read_available. Mutex protects metadata updates. unsafe impl Sync for SharedPtyBuffer {} unsafe impl Send for SharedPtyBuffer {} struct BufferState { /// Bytes available for main thread to read (I/O has written, main hasn't consumed) read_available: usize, /// Bytes written by I/O thread but not yet made available to main thread write_pending: usize, /// Whether the I/O thread is waiting for space waiting_for_space: bool, } impl SharedPtyBuffer { fn new() -> Self { // Create eventfd for wakeup signaling let wakeup_fd = unsafe { libc::eventfd(0, libc::EFD_NONBLOCK | libc::EFD_CLOEXEC) }; if wakeup_fd < 0 { panic!("Failed to create eventfd: {}", std::io::Error::last_os_error()); } Self { buf: std::cell::UnsafeCell::new(Box::new([0u8; PTY_BUF_SIZE])), state: Mutex::new(BufferState { read_available: 0, write_pending: 0, waiting_for_space: false, }), wakeup_fd, } } /// Get the wakeup fd for the I/O thread to poll on. fn wakeup_fd(&self) -> i32 { self.wakeup_fd } /// Check if there's space and mark as waiting if not. /// Returns true if there's space, false if waiting. fn check_space_or_wait(&self) -> bool { let mut state = self.state.lock().unwrap(); let has_space = state.read_available + state.write_pending < PTY_BUF_SIZE; if !has_space { state.waiting_for_space = true; } has_space } /// Get a write buffer for the I/O thread to read PTY data into. /// Returns (pointer, available_space). Caller must call commit_write() after. /// /// SAFETY: The returned pointer is valid until commit_write() is called. /// Only one thread should call this at a time (the I/O thread). fn create_write_buffer(&self) -> (*mut u8, usize) { let state = self.state.lock().unwrap(); let write_offset = state.read_available + state.write_pending; let available = PTY_BUF_SIZE.saturating_sub(write_offset); if available == 0 { return (std::ptr::null_mut(), 0); } // SAFETY: We have exclusive write access to buf[write_offset..] because: // - Main thread only reads [0..read_available] // - We're the only writer past read_available + write_pending let ptr = unsafe { (*self.buf.get()).as_mut_ptr().add(write_offset) }; (ptr, available) } /// Commit bytes written by the I/O thread. fn commit_write(&self, len: usize) { let mut state = self.state.lock().unwrap(); state.write_pending += len; } /// Read from PTY fd into the buffer. Called by I/O thread. /// Returns number of bytes read, 0 if no space/would block, -1 on error. fn read_from_fd(&self, fd: i32) -> isize { let (ptr, available) = self.create_write_buffer(); if available == 0 { return 0; // Buffer full } let result = unsafe { libc::read(fd, ptr as *mut libc::c_void, available) }; if result > 0 { self.commit_write(result as usize); } result } /// Drain the wakeup eventfd. Called by I/O thread after waking up. fn drain_wakeup(&self) { let mut buf = 0u64; unsafe { libc::read(self.wakeup_fd, &mut buf as *mut u64 as *mut libc::c_void, 8); } } /// Make pending writes available for reading, get slice to read. /// Returns None if no data available. /// /// SAFETY: The returned slice is valid until consume() is called. /// Only the main thread should call this. fn get_read_slice(&self) -> Option<&[u8]> { let mut state = self.state.lock().unwrap(); // Move pending writes to readable state.read_available += state.write_pending; state.write_pending = 0; if state.read_available == 0 { return None; } // SAFETY: We have exclusive read access to [0..read_available] because: // - I/O thread only writes past read_available // - We're the only reader let slice = unsafe { std::slice::from_raw_parts((*self.buf.get()).as_ptr(), state.read_available) }; Some(slice) } /// Consume all read data, making space for new writes. /// Called after parsing is complete. Wakes up I/O thread if it was waiting. fn consume_all(&self) { let should_wakeup; { let mut state = self.state.lock().unwrap(); // If there's pending write data, we need to move it to the front if state.write_pending > 0 { // SAFETY: Memmove handles overlapping regions unsafe { let buf = &mut *self.buf.get(); std::ptr::copy( buf.as_ptr().add(state.read_available), buf.as_mut_ptr(), state.write_pending, ); } } state.read_available = 0; // write_pending stays the same but is now at offset 0 should_wakeup = state.waiting_for_space; state.waiting_for_space = false; } // Wake up I/O thread if it was waiting for space if should_wakeup { let val = 1u64; unsafe { libc::write(self.wakeup_fd, &val as *const u64 as *const libc::c_void, 8); } } } } impl Drop for SharedPtyBuffer { fn drop(&mut self) { unsafe { libc::close(self.wakeup_fd); } } } /// Unique identifier for a pane. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct PaneId(u64); impl PaneId { fn new() -> Self { use std::sync::atomic::AtomicU64; static NEXT_ID: AtomicU64 = AtomicU64::new(1); Self(NEXT_ID.fetch_add(1, Ordering::Relaxed)) } } /// A single pane containing a terminal and its PTY. struct Pane { /// Unique identifier for this pane. id: PaneId, /// Terminal state (grid, cursor, scrollback, etc.). terminal: Terminal, /// PTY connection to the shell. pty: Pty, /// Raw file descriptor for the PTY (for polling). pty_fd: i32, /// Shared buffer for this pane's PTY I/O. pty_buffer: Arc, /// Selection state for this pane. selection: Option, /// Whether we're currently selecting in this pane. is_selecting: bool, /// Last scrollback length for tracking changes. last_scrollback_len: u32, /// When the focus animation started (for smooth fade). focus_animation_start: std::time::Instant, /// Whether this pane was focused before the current animation. was_focused: bool, /// Custom statusline content set by applications (e.g., neovim). /// Contains raw ANSI escape sequences for colors. /// When Some, this overrides the default CWD/git statusline. custom_statusline: Option, } impl Pane { /// Create a new pane with its own terminal and PTY. fn new(cols: usize, rows: usize, scrollback_lines: usize) -> Result { let terminal = Terminal::new(cols, rows, scrollback_lines); // Calculate pixel dimensions (use default cell size estimate) let default_cell_width = 10u16; let default_cell_height = 20u16; let width_px = cols as u16 * default_cell_width; let height_px = rows as u16 * default_cell_height; // Spawn PTY with initial size - this sets the size BEFORE forking, // so the shell inherits the correct terminal dimensions immediately. // This prevents race conditions where .zshrc runs before resize(). let pty = Pty::spawn( None, cols as u16, rows as u16, width_px, height_px ).map_err(|e| format!("Failed to spawn PTY: {}", e))?; let pty_fd = pty.as_raw_fd(); Ok(Self { id: PaneId::new(), terminal, pty, pty_fd, pty_buffer: Arc::new(SharedPtyBuffer::new()), selection: None, is_selecting: false, last_scrollback_len: 0, focus_animation_start: std::time::Instant::now(), was_focused: true, // New panes start as focused custom_statusline: None, }) } /// Resize the terminal and PTY. /// Only sends SIGWINCH to the PTY if the size actually changed. fn resize(&mut self, cols: usize, rows: usize, width_px: u16, height_px: u16) { // Check if size actually changed before sending SIGWINCH // This prevents spurious signals that can interrupt programs like fastfetch let size_changed = cols != self.terminal.cols || rows != self.terminal.rows; self.terminal.resize(cols, rows); if size_changed { if let Err(e) = self.pty.resize(cols as u16, rows as u16, width_px, height_px) { log::warn!("Failed to resize PTY: {}", e); } } } /// Write data to the PTY. fn write_to_pty(&mut self, data: &[u8]) { if let Err(e) = self.pty.write(data) { log::warn!("Failed to write to PTY: {}", e); } } /// Check if the shell has exited. fn child_exited(&self) -> bool { self.pty.child_exited() } /// Check if the foreground process matches any of the given program names. /// Used for pass-through keybindings (e.g., passing Alt+Arrow to Neovim). fn foreground_matches(&self, programs: &[String]) -> bool { if programs.is_empty() { return false; } if let Some(fg_name) = self.pty.foreground_process_name() { programs.iter().any(|p| p == &fg_name) } else { false } } /// Calculate the current dim factor based on animation progress. /// Returns a value between `inactive_dim` (for unfocused) and 1.0 (for focused). fn calculate_dim_factor(&mut self, is_focused: bool, fade_duration_ms: u64, inactive_dim: f32) -> f32 { // Detect focus change if is_focused != self.was_focused { self.focus_animation_start = std::time::Instant::now(); self.was_focused = is_focused; } // If no animation (instant), return target value immediately if fade_duration_ms == 0 { return if is_focused { 1.0 } else { inactive_dim }; } let elapsed = self.focus_animation_start.elapsed().as_millis() as f32; let duration = fade_duration_ms as f32; let progress = (elapsed / duration).min(1.0); // Smooth easing (ease-out cubic) let eased = 1.0 - (1.0 - progress).powi(3); if is_focused { // Fading in: from inactive_dim to 1.0 inactive_dim + (1.0 - inactive_dim) * eased } else { // Fading out: from 1.0 to inactive_dim 1.0 - (1.0 - inactive_dim) * eased } } } /// Geometry of a pane in pixels. #[derive(Debug, Clone, Copy)] struct PaneGeometry { /// Left edge in pixels. x: f32, /// Top edge in pixels. y: f32, /// Width in pixels. width: f32, /// Height in pixels. height: f32, /// Number of columns. cols: usize, /// Number of rows. rows: usize, } /// A node in the split tree - either a split or a leaf (pane). enum SplitNode { /// A leaf node containing a pane. Leaf { pane_id: PaneId, /// Cached geometry, updated during layout. geometry: PaneGeometry, }, /// A split node with two children. Split { /// True for horizontal split (panes side-by-side), false for vertical (panes stacked). horizontal: bool, /// Size ratio of the first child (0.0 to 1.0). ratio: f32, /// First child (left or top). first: Box, /// Second child (right or bottom). second: Box, }, } impl SplitNode { /// Create a new leaf node. fn leaf(pane_id: PaneId) -> Self { SplitNode::Leaf { pane_id, geometry: PaneGeometry { x: 0.0, y: 0.0, width: 0.0, height: 0.0, cols: 0, rows: 0, }, } } /// Split this node, replacing it with a split containing the original and a new pane. /// Returns the new node that should replace this one. fn split(self, new_pane_id: PaneId, horizontal: bool) -> Self { SplitNode::Split { horizontal, ratio: 0.5, first: Box::new(self), second: Box::new(SplitNode::leaf(new_pane_id)), } } /// Calculate layout for all nodes given the available space. /// Returns the actual used (width, height) after cell alignment. /// Note: border_width is kept for API compatibility but borders are now overlaid on panes. fn layout(&mut self, x: f32, y: f32, width: f32, height: f32, cell_width: f32, cell_height: f32, _border_width: f32) -> (f32, f32) { match self { SplitNode::Leaf { geometry, .. } => { // Calculate how many cells fit let cols = (width / cell_width).floor() as usize; let rows = (height / cell_height).floor() as usize; // Store the full allocated dimensions (not just cell-aligned) // This ensures edge glow and pane dimming cover the full pane area *geometry = PaneGeometry { x, y, width, // Full allocated width height, // Full allocated height cols: cols.max(1), rows: rows.max(1), }; // Return cell-aligned dimensions for layout calculations let actual_width = cols.max(1) as f32 * cell_width; let actual_height = rows.max(1) as f32 * cell_height; (actual_width, actual_height) } SplitNode::Split { horizontal, ratio, first, second } => { if *horizontal { // Side-by-side split (horizontal means panes are side-by-side) // No border space reserved - border will be overlaid on pane edges let total_cols = (width / cell_width).floor() as usize; // Distribute columns by ratio let first_cols = ((total_cols as f32) * *ratio).round() as usize; let second_cols = total_cols.saturating_sub(first_cols); // Convert back to pixel widths let first_alloc_width = first_cols.max(1) as f32 * cell_width; let second_alloc_width = second_cols.max(1) as f32 * cell_width; // Layout panes flush against each other (border overlays the edge) let (first_actual_w, first_actual_h) = first.layout(x, y, first_alloc_width, height, cell_width, cell_height, _border_width); let (second_actual_w, second_actual_h) = second.layout(x + first_actual_w, y, second_alloc_width, height, cell_width, cell_height, _border_width); // Total used size: both panes (no border gap) (first_actual_w + second_actual_w, first_actual_h.max(second_actual_h)) } else { // Stacked split (vertical means panes are stacked) // No border space reserved - border will be overlaid on pane edges let total_rows = (height / cell_height).floor() as usize; // Distribute rows by ratio let first_rows = ((total_rows as f32) * *ratio).round() as usize; let second_rows = total_rows.saturating_sub(first_rows); // Convert back to pixel heights let first_alloc_height = first_rows.max(1) as f32 * cell_height; let second_alloc_height = second_rows.max(1) as f32 * cell_height; // Layout panes flush against each other (border overlays the edge) let (first_actual_w, first_actual_h) = first.layout(x, y, width, first_alloc_height, cell_width, cell_height, _border_width); let (second_actual_w, second_actual_h) = second.layout(x, y + first_actual_h, width, second_alloc_height, cell_width, cell_height, _border_width); // Total used size: both panes (no border gap) (first_actual_w.max(second_actual_w), first_actual_h + second_actual_h) } } } } /// Find the geometry for a specific pane. fn find_geometry(&self, target_id: PaneId) -> Option { match self { SplitNode::Leaf { pane_id, geometry } => { if *pane_id == target_id { Some(*geometry) } else { None } } SplitNode::Split { first, second, .. } => { first.find_geometry(target_id).or_else(|| second.find_geometry(target_id)) } } } /// Collect all pane geometries. fn collect_geometries(&self, geometries: &mut Vec<(PaneId, PaneGeometry)>) { match self { SplitNode::Leaf { pane_id, geometry } => { geometries.push((*pane_id, *geometry)); } SplitNode::Split { first, second, .. } => { first.collect_geometries(geometries); second.collect_geometries(geometries); } } } /// Find a neighbor pane in the given direction. /// Returns the pane ID of the neighbor, if any. fn find_neighbor(&self, target_id: PaneId, direction: Direction) -> Option { // First, find the geometry of the target pane let target_geom = self.find_geometry(target_id)?; // Collect all geometries let mut all_geoms = Vec::new(); self.collect_geometries(&mut all_geoms); // Find the best candidate in the given direction let mut best: Option<(PaneId, f32)> = None; for (pane_id, geom) in all_geoms { if pane_id == target_id { continue; } let is_neighbor = match direction { Direction::Up => { // Neighbor is above: its bottom edge is near our top edge geom.y + geom.height <= target_geom.y + 5.0 && Self::overlaps_horizontally(&geom, &target_geom) } Direction::Down => { // Neighbor is below: its top edge is near our bottom edge geom.y >= target_geom.y + target_geom.height - 5.0 && Self::overlaps_horizontally(&geom, &target_geom) } Direction::Left => { // Neighbor is to the left: its right edge is near our left edge geom.x + geom.width <= target_geom.x + 5.0 && Self::overlaps_vertically(&geom, &target_geom) } Direction::Right => { // Neighbor is to the right: its left edge is near our right edge geom.x >= target_geom.x + target_geom.width - 5.0 && Self::overlaps_vertically(&geom, &target_geom) } }; if is_neighbor { // Calculate distance (for choosing closest) let distance = match direction { Direction::Up => target_geom.y - (geom.y + geom.height), Direction::Down => geom.y - (target_geom.y + target_geom.height), Direction::Left => target_geom.x - (geom.x + geom.width), Direction::Right => geom.x - (target_geom.x + target_geom.width), }; if distance >= 0.0 { if best.is_none() || distance < best.unwrap().1 { best = Some((pane_id, distance)); } } } } best.map(|(id, _)| id) } fn overlaps_horizontally(a: &PaneGeometry, b: &PaneGeometry) -> bool { let a_left = a.x; let a_right = a.x + a.width; let b_left = b.x; let b_right = b.x + b.width; a_left < b_right && a_right > b_left } fn overlaps_vertically(a: &PaneGeometry, b: &PaneGeometry) -> bool { let a_top = a.y; let a_bottom = a.y + a.height; let b_top = b.y; let b_bottom = b.y + b.height; a_top < b_bottom && a_bottom > b_top } /// Remove a pane from the tree. Returns the new tree root (or None if tree is empty). fn remove_pane(self, target_id: PaneId) -> Option { match self { SplitNode::Leaf { pane_id, .. } => { if pane_id == target_id { None // Remove this leaf } else { Some(self) // Keep this leaf } } SplitNode::Split { horizontal, ratio, first, second } => { // Check if target is in first or second subtree let first_has_target = first.contains_pane(target_id); let second_has_target = second.contains_pane(target_id); if first_has_target { match first.remove_pane(target_id) { Some(new_first) => Some(SplitNode::Split { horizontal, ratio, first: Box::new(new_first), second, }), None => Some(*second), // First child removed, promote second } } else if second_has_target { match second.remove_pane(target_id) { Some(new_second) => Some(SplitNode::Split { horizontal, ratio, first, second: Box::new(new_second), }), None => Some(*first), // Second child removed, promote first } } else { Some(SplitNode::Split { horizontal, ratio, first, second }) } } } } /// Check if this tree contains the given pane. fn contains_pane(&self, target_id: PaneId) -> bool { match self { SplitNode::Leaf { pane_id, .. } => *pane_id == target_id, SplitNode::Split { first, second, .. } => { first.contains_pane(target_id) || second.contains_pane(target_id) } } } /// Split the pane with the given ID. fn split_pane(self, target_id: PaneId, new_pane_id: PaneId, horizontal: bool) -> Self { match self { SplitNode::Leaf { pane_id, geometry } => { if pane_id == target_id { SplitNode::Leaf { pane_id, geometry }.split(new_pane_id, horizontal) } else { SplitNode::Leaf { pane_id, geometry } } } SplitNode::Split { horizontal: h, ratio, first, second } => { SplitNode::Split { horizontal: h, ratio, first: Box::new(first.split_pane(target_id, new_pane_id, horizontal)), second: Box::new(second.split_pane(target_id, new_pane_id, horizontal)), } } } } } /// Unique identifier for a tab. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct TabId(u64); impl TabId { fn new() -> Self { use std::sync::atomic::AtomicU64; static NEXT_ID: AtomicU64 = AtomicU64::new(1); Self(NEXT_ID.fetch_add(1, Ordering::Relaxed)) } } /// A single tab containing one or more panes arranged in a split tree. struct Tab { /// Unique identifier for this tab. #[allow(dead_code)] id: TabId, /// All panes in this tab, keyed by PaneId. panes: HashMap, /// The split tree structure. split_root: SplitNode, /// Currently active pane ID. active_pane: PaneId, /// Tab title (from OSC or shell). #[allow(dead_code)] title: String, /// Actual used grid dimensions (width, height) after cell alignment. /// Used for centering the grid in the window. grid_used_dimensions: (f32, f32), } impl Tab { /// Create a new tab with a single pane. fn new(cols: usize, rows: usize, scrollback_lines: usize) -> Result { let pane = Pane::new(cols, rows, scrollback_lines)?; let pane_id = pane.id; let mut panes = HashMap::new(); panes.insert(pane_id, pane); Ok(Self { id: TabId::new(), panes, split_root: SplitNode::leaf(pane_id), active_pane: pane_id, title: String::from("zsh"), grid_used_dimensions: (0.0, 0.0), // Will be set on first resize }) } /// Get the active pane. fn active_pane(&self) -> Option<&Pane> { self.panes.get(&self.active_pane) } /// Get the active pane mutably. fn active_pane_mut(&mut self) -> Option<&mut Pane> { self.panes.get_mut(&self.active_pane) } /// Resize all panes based on new window dimensions. fn resize(&mut self, width: f32, height: f32, cell_width: f32, cell_height: f32, border_width: f32) { // Recalculate layout - returns actual used dimensions for centering let used_dims = self.split_root.layout(0.0, 0.0, width, height, cell_width, cell_height, border_width); // Store the used dimensions for this tab self.grid_used_dimensions = used_dims; // Resize each pane's terminal based on its geometry let mut geometries = Vec::new(); self.split_root.collect_geometries(&mut geometries); for (pane_id, geom) in geometries { if let Some(pane) = self.panes.get_mut(&pane_id) { // Report pixel dimensions as exact cell grid size (cols * cell_width, rows * cell_height) // This ensures applications like kitten icat calculate image placement correctly let pixel_width = (geom.cols as f32 * cell_width) as u16; let pixel_height = (geom.rows as f32 * cell_height) as u16; pane.resize(geom.cols, geom.rows, pixel_width, pixel_height); } } } /// Write data to the active pane's PTY. fn write_to_pty(&mut self, data: &[u8]) { if let Some(pane) = self.active_pane_mut() { pane.write_to_pty(data); } } /// Check if any pane's shell has exited and clean up. /// Returns true if all panes have exited (tab should close). fn check_exited_panes(&mut self) -> bool { // Collect exited pane IDs let exited: Vec = self.panes .iter() .filter(|(_, pane)| pane.child_exited()) .map(|(id, _)| *id) .collect(); // Remove exited panes for pane_id in exited { self.remove_pane(pane_id); } self.panes.is_empty() } /// Split the active pane. fn split(&mut self, horizontal: bool, cols: usize, rows: usize, scrollback_lines: usize) -> Result { let new_pane = Pane::new(cols, rows, scrollback_lines)?; let new_pane_id = new_pane.id; // Add to panes map self.panes.insert(new_pane_id, new_pane); // Update split tree let old_root = std::mem::replace(&mut self.split_root, SplitNode::leaf(PaneId(0))); self.split_root = old_root.split_pane(self.active_pane, new_pane_id, horizontal); // Focus the new pane self.active_pane = new_pane_id; Ok(new_pane_id) } /// Remove a pane from the tab. fn remove_pane(&mut self, pane_id: PaneId) { // Remove from map self.panes.remove(&pane_id); // Update split tree let old_root = std::mem::replace(&mut self.split_root, SplitNode::leaf(PaneId(0))); if let Some(new_root) = old_root.remove_pane(pane_id) { self.split_root = new_root; } // If we removed the active pane, select a new one if self.active_pane == pane_id { if let Some(first_pane_id) = self.panes.keys().next() { self.active_pane = *first_pane_id; } } } /// Close the active pane. fn close_active_pane(&mut self) { let pane_id = self.active_pane; self.remove_pane(pane_id); } /// Navigate to a neighbor pane in the given direction. fn focus_neighbor(&mut self, direction: Direction) { if let Some(neighbor_id) = self.split_root.find_neighbor(self.active_pane, direction) { self.active_pane = neighbor_id; } } /// Get pane by ID. fn get_pane(&self, pane_id: PaneId) -> Option<&Pane> { self.panes.get(&pane_id) } /// Get pane by ID mutably. fn get_pane_mut(&mut self, pane_id: PaneId) -> Option<&mut Pane> { self.panes.get_mut(&pane_id) } /// Collect all pane geometries for rendering. fn collect_pane_geometries(&self) -> Vec<(PaneId, PaneGeometry)> { let mut geometries = Vec::new(); self.split_root.collect_geometries(&mut geometries); geometries } /// Check if all panes have exited (tab should be closed). fn child_exited(&mut self) -> bool { self.check_exited_panes() } } /// PID file location for single-instance support. fn pid_file_path() -> std::path::PathBuf { let runtime_dir = std::env::var("XDG_RUNTIME_DIR") .unwrap_or_else(|_| "/tmp".to_string()); std::path::PathBuf::from(runtime_dir).join("zterm.pid") } /// Check if another instance is running and signal it to show window. /// Returns true if we signaled an existing instance (and should exit). fn signal_existing_instance() -> bool { let pid_path = pid_file_path(); if let Ok(contents) = std::fs::read_to_string(&pid_path) { if let Ok(pid) = contents.trim().parse::() { // Check if process is alive let alive = unsafe { libc::kill(pid, 0) == 0 }; if alive { // Send SIGUSR1 to show window log::info!("Signaling existing instance (PID {})", pid); unsafe { libc::kill(pid, libc::SIGUSR1) }; return true; } else { // Stale PID file, remove it let _ = std::fs::remove_file(&pid_path); } } } false } /// Write our PID to the PID file. fn write_pid_file() -> std::io::Result<()> { let pid = std::process::id(); std::fs::write(pid_file_path(), pid.to_string()) } /// Remove the PID file on exit. fn remove_pid_file() { let _ = std::fs::remove_file(pid_file_path()); } /// Build a statusline section for the current working directory. /// /// Transforms the path into styled segments within a section: /// - Replaces $HOME prefix with "~" /// - Each directory segment gets " " prefix and its own color /// - Arrow separator "" between segments inherits previous segment's color /// - Colors cycle through palette indices 2-7 (skipping 0-1 which are often close to white) /// - Last segment is bold /// - Section has a dark gray background color (#282828) /// - Section ends with powerline arrow transition fn build_cwd_section(cwd: &str) -> StatuslineSection { // Colors to cycle through (skip 0 and 1 which are often near-white in custom schemes) const COLORS: [u8; 6] = [2, 3, 4, 5, 6, 7]; let mut components = Vec::new(); // Get home directory and replace prefix with ~ let display_path = if let Ok(home) = std::env::var("HOME") { if cwd.starts_with(&home) { format!("~{}", &cwd[home.len()..]) } else { cwd.to_string() } } else { cwd.to_string() }; // Split path into segments let segments: Vec<&str> = display_path .split('/') .filter(|s| !s.is_empty()) .collect(); if segments.is_empty() { // Root directory components.push(StatuslineComponent::new(" \u{F07C} / ").fg(COLORS[0])); return StatuslineSection::with_rgb_bg(0x28, 0x28, 0x28).with_components(components); } // Add leading space for padding components.push(StatuslineComponent::new(" ")); let last_idx = segments.len() - 1; for (i, segment) in segments.iter().enumerate() { // Cycle through colors for each segment let color = COLORS[i % COLORS.len()]; if i > 0 { // Add arrow separator with previous segment's color // U+E0B1 is the powerline thin chevron right let prev_color = COLORS[(i - 1) % COLORS.len()]; components.push(StatuslineComponent::new(" \u{E0B1} ").fg(prev_color)); } // Directory segment with folder icon prefix // U+F07C is the folder-open icon from Nerd Fonts let text = format!("\u{F07C} {}", segment); let component = if i == last_idx { // Last segment is bold StatuslineComponent::new(text).fg(color).bold() } else { StatuslineComponent::new(text).fg(color) }; components.push(component); } // Add trailing space for padding before the powerline arrow components.push(StatuslineComponent::new(" ")); // Use dark gray (#282828) as section background StatuslineSection::with_rgb_bg(0x28, 0x28, 0x28).with_components(components) } /// Git repository status information. #[derive(Debug, Default)] struct GitStatus { /// Current branch or HEAD reference. head: String, /// Number of commits ahead of upstream. ahead: usize, /// Number of commits behind upstream. behind: usize, /// Working directory changes (modified, deleted, untracked, etc.). working_changed: usize, /// Working directory status string (e.g., "~1 +2 -1"). working_string: String, /// Staged changes count. staging_changed: usize, /// Staging status string. staging_string: String, /// Number of stashed changes. stash_count: usize, } /// Get git status for a directory. /// Returns None if not in a git repository. fn get_git_status(cwd: &str) -> Option { use std::process::Command; // Check if we're in a git repo and get the branch name let head_output = Command::new("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(cwd) .output() .ok()?; if !head_output.status.success() { return None; } let head = String::from_utf8_lossy(&head_output.stdout).trim().to_string(); // Get ahead/behind status let mut ahead = 0; let mut behind = 0; if let Ok(output) = Command::new("git") .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]) .current_dir(cwd) .output() { if output.status.success() { let counts = String::from_utf8_lossy(&output.stdout); let parts: Vec<&str> = counts.trim().split_whitespace().collect(); if parts.len() == 2 { ahead = parts[0].parse().unwrap_or(0); behind = parts[1].parse().unwrap_or(0); } } } // Get working directory and staging status using git status --porcelain let mut working_modified = 0; let mut working_added = 0; let mut working_deleted = 0; let mut staging_modified = 0; let mut staging_added = 0; let mut staging_deleted = 0; if let Ok(output) = Command::new("git") .args(["status", "--porcelain"]) .current_dir(cwd) .output() { if output.status.success() { let status = String::from_utf8_lossy(&output.stdout); for line in status.lines() { if line.len() < 2 { continue; } let chars: Vec = line.chars().collect(); let staging_char = chars[0]; let working_char = chars[1]; // Staging status (first column) match staging_char { 'M' => staging_modified += 1, 'A' => staging_added += 1, 'D' => staging_deleted += 1, 'R' => staging_modified += 1, // renamed 'C' => staging_added += 1, // copied _ => {} } // Working directory status (second column) match working_char { 'M' => working_modified += 1, 'D' => working_deleted += 1, '?' => working_added += 1, // untracked _ => {} } } } } // Build status strings like oh-my-posh format let working_changed = working_modified + working_added + working_deleted; let mut working_parts = Vec::new(); if working_modified > 0 { working_parts.push(format!("~{}", working_modified)); } if working_added > 0 { working_parts.push(format!("+{}", working_added)); } if working_deleted > 0 { working_parts.push(format!("-{}", working_deleted)); } let working_string = working_parts.join(" "); let staging_changed = staging_modified + staging_added + staging_deleted; let mut staging_parts = Vec::new(); if staging_modified > 0 { staging_parts.push(format!("~{}", staging_modified)); } if staging_added > 0 { staging_parts.push(format!("+{}", staging_added)); } if staging_deleted > 0 { staging_parts.push(format!("-{}", staging_deleted)); } let staging_string = staging_parts.join(" "); // Get stash count let mut stash_count = 0; if let Ok(output) = Command::new("git") .args(["stash", "list"]) .current_dir(cwd) .output() { if output.status.success() { let stash = String::from_utf8_lossy(&output.stdout); stash_count = stash.lines().count(); } } Some(GitStatus { head, ahead, behind, working_changed, working_string, staging_changed, staging_string, stash_count, }) } /// Build a statusline section for git status. /// Returns None if not in a git repository. fn build_git_section(cwd: &str) -> Option { let status = get_git_status(cwd)?; // Determine foreground color based on state (matching oh-my-posh template) // Priority order (last match wins in oh-my-posh): // 1. Default: #0da300 (green) // 2. If working or staging changed: #FF9248 (orange) // 3. If both ahead and behind: #ff4500 (red-orange) // 4. If ahead or behind: #B388FF (purple) let fg_color: (u8, u8, u8) = if status.ahead > 0 && status.behind > 0 { (0xff, 0x45, 0x00) // #ff4500 - red-orange } else if status.ahead > 0 || status.behind > 0 { (0xB3, 0x88, 0xFF) // #B388FF - purple } else if status.working_changed > 0 || status.staging_changed > 0 { (0xFF, 0x92, 0x48) // #FF9248 - orange } else { (0x0d, 0xa3, 0x00) // #0da300 - green }; let mut components = Vec::new(); // Leading space components.push(StatuslineComponent::new(" ").rgb_fg(fg_color.0, fg_color.1, fg_color.2)); // Branch name (HEAD) // Use git branch icon U+E0A0 let head_text = format!("\u{E0A0} {}", status.head); components.push(StatuslineComponent::new(head_text).rgb_fg(fg_color.0, fg_color.1, fg_color.2)); // Branch status (ahead/behind) if status.ahead > 0 || status.behind > 0 { let mut branch_status = String::new(); if status.ahead > 0 { branch_status.push_str(&format!(" ↑{}", status.ahead)); } if status.behind > 0 { branch_status.push_str(&format!(" ↓{}", status.behind)); } components.push(StatuslineComponent::new(branch_status).rgb_fg(fg_color.0, fg_color.1, fg_color.2)); } // Working directory changes - U+F044 is the edit/pencil icon if status.working_changed > 0 { let working_text = format!(" \u{F044} {}", status.working_string); components.push(StatuslineComponent::new(working_text).rgb_fg(fg_color.0, fg_color.1, fg_color.2)); } // Separator between working and staging (if both have changes) if status.working_changed > 0 && status.staging_changed > 0 { components.push(StatuslineComponent::new(" |").rgb_fg(fg_color.0, fg_color.1, fg_color.2)); } // Staged changes - U+F046 is the check/staged icon if status.staging_changed > 0 { let staging_text = format!(" \u{F046} {}", status.staging_string); components.push(StatuslineComponent::new(staging_text).rgb_fg(fg_color.0, fg_color.1, fg_color.2)); } // Stash count - U+EB4B is the stash icon if status.stash_count > 0 { let stash_text = format!(" \u{EB4B} {}", status.stash_count); components.push(StatuslineComponent::new(stash_text).rgb_fg(fg_color.0, fg_color.1, fg_color.2)); } // Trailing space components.push(StatuslineComponent::new(" ").rgb_fg(fg_color.0, fg_color.1, fg_color.2)); // Background: #232323 Some(StatuslineSection::with_rgb_bg(0x23, 0x23, 0x23).with_components(components)) } /// A cell position in the terminal grid. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct CellPosition { col: usize, row: isize, } /// Selection state for mouse text selection. #[derive(Clone, Debug)] struct Selection { start: CellPosition, end: CellPosition, } impl Selection { fn normalized(&self) -> (CellPosition, CellPosition) { if self.start.row < self.end.row || (self.start.row == self.end.row && self.start.col <= self.end.col) { (self.start, self.end) } else { (self.end, self.start) } } fn to_screen_coords(&self, current_scroll_offset: usize, visible_rows: usize) -> Option<(usize, usize, usize, usize)> { let (start, end) = self.normalized(); let scroll_offset = current_scroll_offset as isize; let screen_start_row = start.row + scroll_offset; let screen_end_row = end.row + scroll_offset; if screen_end_row < 0 || screen_start_row >= visible_rows as isize { return None; } let screen_start_row = screen_start_row.max(0) as usize; let screen_end_row = (screen_end_row as usize).min(visible_rows.saturating_sub(1)); let start_col = if start.row + scroll_offset < 0 { 0 } else { start.col }; let end_col = if end.row + scroll_offset >= visible_rows as isize { usize::MAX } else { end.col }; Some((start_col, screen_start_row, end_col, screen_end_row)) } } /// User event for the event loop. #[derive(Debug, Clone)] enum UserEvent { /// Signal received to show the window. ShowWindow, /// PTY has data available for a specific pane. PtyReadable(PaneId), /// Config file was modified and should be reloaded. ConfigReloaded, } /// Main application state. struct App { /// Window (None when headless/closed). window: Option>, /// GPU renderer (None when headless). renderer: Option, /// All open tabs. tabs: Vec, /// Index of the currently active tab. active_tab: usize, /// Application configuration. config: Config, /// Keybinding action map. action_map: HashMap<(bool, bool, bool, bool, String), Action>, /// Current modifier state. modifiers: WinitModifiers, /// Keyboard state for encoding. keyboard_state: KeyboardState, /// Event loop proxy for signaling from other threads. event_loop_proxy: Option>, /// Shutdown signal. shutdown: Arc, /// Current mouse cursor position. cursor_position: PhysicalPosition, /// Frame counter for FPS logging. frame_count: u64, /// Last time we logged FPS. last_frame_log: std::time::Instant, /// Whether window should be created on next opportunity. should_create_window: bool, /// Edge glow animations (for when navigation fails). Multiple can be active simultaneously. edge_glows: Vec, } impl App { fn new() -> Self { let config = Config::load(); log::info!("Config: font_size={}", config.font_size); let action_map = config.keybindings.build_action_map(); log::info!("Action map built with {} bindings:", action_map.len()); for (key, action) in &action_map { log::info!(" {:?} => {:?}", key, action); } Self { window: None, renderer: None, tabs: Vec::new(), active_tab: 0, config, action_map, modifiers: WinitModifiers::default(), keyboard_state: KeyboardState::new(), event_loop_proxy: None, shutdown: Arc::new(AtomicBool::new(false)), cursor_position: PhysicalPosition::new(0.0, 0.0), frame_count: 0, last_frame_log: std::time::Instant::now(), should_create_window: false, edge_glows: Vec::new(), } } fn set_event_loop_proxy(&mut self, proxy: EventLoopProxy) { self.event_loop_proxy = Some(proxy); } /// Reload configuration from disk and apply changes. fn reload_config(&mut self) { log::info!("Reloading configuration..."); let new_config = Config::load(); // Check what changed and apply updates let font_size_changed = (new_config.font_size - self.config.font_size).abs() > 0.01; let opacity_changed = (new_config.background_opacity - self.config.background_opacity).abs() > 0.01; let tab_bar_changed = new_config.tab_bar_position != self.config.tab_bar_position; // Update the config self.config = new_config; // Rebuild action map for keybindings self.action_map = self.config.keybindings.build_action_map(); // Apply renderer changes if we have a renderer if let Some(renderer) = &mut self.renderer { if opacity_changed { renderer.set_background_opacity(self.config.background_opacity); log::info!("Updated background opacity to {}", self.config.background_opacity); } if tab_bar_changed { renderer.set_tab_bar_position(self.config.tab_bar_position); log::info!("Updated tab bar position to {:?}", self.config.tab_bar_position); } if font_size_changed { renderer.set_font_size(self.config.font_size); log::info!("Updated font size to {}", self.config.font_size); // Font size change requires resize to recalculate cell dimensions self.resize_all_panes(); } } // Request redraw to apply visual changes if let Some(window) = &self.window { window.request_redraw(); } log::info!("Configuration reloaded successfully"); } /// Create a new tab and start its I/O thread. /// Returns the index of the new tab. fn create_tab(&mut self, cols: usize, rows: usize) -> Option { log::info!("Creating new tab with {}x{} terminal", cols, rows); match Tab::new(cols, rows, self.config.scrollback_lines) { Ok(tab) => { let tab_idx = self.tabs.len(); // Start I/O threads for all panes in this tab for pane in tab.panes.values() { self.start_pane_io_thread(pane); } self.tabs.push(tab); self.active_tab = tab_idx; log::info!("Tab {} created (total: {})", tab_idx, self.tabs.len()); Some(tab_idx) } Err(e) => { log::error!("Failed to create tab: {}", e); None } } } /// Start background I/O thread for a pane's PTY. fn start_pane_io_thread(&self, pane: &Pane) { self.start_pane_io_thread_with_info(pane.id, pane.pty_fd, pane.pty_buffer.clone()); } /// Start background I/O thread for a pane's PTY with explicit info. fn start_pane_io_thread_with_info(&self, pane_id: PaneId, pty_fd: i32, pty_buffer: Arc) { let Some(proxy) = self.event_loop_proxy.clone() else { return }; let shutdown = self.shutdown.clone(); let wakeup_fd = pty_buffer.wakeup_fd(); std::thread::Builder::new() .name(format!("pty-io-{}", pane_id.0)) .spawn(move || { const INPUT_DELAY: Duration = Duration::from_millis(3); const PTY_KEY: usize = 0; const WAKEUP_KEY: usize = 1; let poller = match Poller::new() { Ok(p) => p, Err(e) => { log::error!("Failed to create PTY poller: {}", e); return; } }; // Add PTY fd unsafe { if let Err(e) = poller.add(pty_fd, Event::readable(PTY_KEY)) { log::error!("Failed to add PTY to poller: {}", e); return; } // Add wakeup fd - used to wake us when buffer space becomes available if let Err(e) = poller.add(wakeup_fd, Event::readable(WAKEUP_KEY)) { log::error!("Failed to add wakeup fd to poller: {}", e); return; } } let mut events = Events::new(); let mut last_wakeup_at = std::time::Instant::now(); let mut has_pending_wakeup = false; while !shutdown.load(Ordering::Relaxed) { events.clear(); // Check if we have space - if not, disable PTY polling until woken let has_space = pty_buffer.check_space_or_wait(); // Set up poll events: always listen on wakeup_fd, only listen on pty_fd if we have space unsafe { let pty_event = if has_space { Event::readable(PTY_KEY) } else { Event::none(PTY_KEY) }; let _ = poller.modify(std::os::fd::BorrowedFd::borrow_raw(pty_fd), pty_event); } let timeout = if has_pending_wakeup { let elapsed = last_wakeup_at.elapsed(); Some(INPUT_DELAY.saturating_sub(elapsed)) } else { None // Block indefinitely until data or wakeup }; match poller.wait(&mut events, timeout) { Ok(_) => { let mut got_wakeup = false; let mut got_pty_data = false; for ev in events.iter() { if ev.key == WAKEUP_KEY { got_wakeup = true; } if ev.key == PTY_KEY && ev.readable { got_pty_data = true; } } // Drain wakeup fd if signaled if got_wakeup { pty_buffer.drain_wakeup(); // Re-arm wakeup fd unsafe { let _ = poller.modify( std::os::fd::BorrowedFd::borrow_raw(wakeup_fd), Event::readable(WAKEUP_KEY), ); } } // Read PTY data if available and we have space if got_pty_data && has_space { loop { let result = pty_buffer.read_from_fd(pty_fd); if result < 0 { let err = std::io::Error::last_os_error(); if err.kind() == std::io::ErrorKind::Interrupted { continue; } if err.kind() == std::io::ErrorKind::WouldBlock { break; } log::debug!("PTY read error: {}", err); break; } else if result == 0 { break; } else { has_pending_wakeup = true; // Check if buffer became full if !pty_buffer.check_space_or_wait() { break; } } } } // Send wakeup to main thread if we have pending data and enough time passed if has_pending_wakeup { let now = std::time::Instant::now(); if now.duration_since(last_wakeup_at) >= INPUT_DELAY { let _ = proxy.send_event(UserEvent::PtyReadable(pane_id)); last_wakeup_at = now; has_pending_wakeup = false; } } } Err(e) => { if e.kind() != std::io::ErrorKind::Interrupted { log::error!("PTY poll error: {}", e); break; } } } } log::debug!("PTY I/O thread for pane {} exiting", pane_id.0); }) .expect("Failed to spawn PTY I/O thread"); } /// Create the window and renderer. fn create_window(&mut self, event_loop: &ActiveEventLoop) { if self.window.is_some() { return; // Window already exists } log::info!("Creating window"); let mut window_attributes = Window::default_attributes() .with_title("ZTerm") .with_inner_size(PhysicalSize::new(800, 600)); 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"), ); let renderer = pollster::block_on(Renderer::new(window.clone(), &self.config)); let (cols, rows) = renderer.terminal_size(); // Create first tab if no tabs exist if self.tabs.is_empty() { self.create_tab(cols, rows); } else { // Resize existing tabs to match window self.resize_all_panes(); } self.window = Some(window); self.renderer = Some(renderer); self.should_create_window = false; log::info!("Window created: {}x{} cells", cols, rows); } /// Destroy the window but keep terminal state. fn destroy_window(&mut self) { log::info!("Destroying window (keeping terminal alive)"); self.renderer = None; self.window = None; } /// Resize all panes in all tabs based on renderer dimensions. fn resize_all_panes(&mut self) { // Extract values we need from renderer first // Use raw available pixel space so layout can handle cell alignment properly let (cell_width, cell_height, available_width, available_height) = { let Some(renderer) = &self.renderer else { return }; let cell_width = renderer.cell_width; let cell_height = renderer.cell_height; let (available_width, available_height) = renderer.available_grid_space(); (cell_width, cell_height, available_width, available_height) }; let border_width = 2.0; // Border width in pixels for tab in self.tabs.iter_mut() { tab.resize(available_width, available_height, cell_width, cell_height, border_width); // Update cell size on all terminals (needed for Kitty graphics protocol) for pane in tab.panes.values_mut() { pane.terminal.set_cell_size(cell_width, cell_height); } } // Update the renderer with the active tab's used dimensions for proper centering if let Some(tab) = self.tabs.get(self.active_tab) { let used_dims = tab.grid_used_dimensions; if let Some(renderer) = &mut self.renderer { renderer.set_grid_used_dimensions(used_dims.0, used_dims.1); } } } /// Process PTY data for a specific pane. /// Returns true if any data was processed. fn poll_pane(&mut self, pane_id: PaneId) -> bool { // Find the pane across all tabs and process data let mut processed = false; let mut commands = Vec::new(); for tab in &mut self.tabs { if let Some(pane) = tab.get_pane_mut(pane_id) { // Get slice of pending data - zero copy! let Some(data) = pane.pty_buffer.get_read_slice() else { return false; }; let len = data.len(); let process_start = std::time::Instant::now(); pane.terminal.process(data); let process_time_ns = process_start.elapsed().as_nanos() as u64; // Consume the data now that we're done parsing pane.pty_buffer.consume_all(); if process_time_ns > 5_000_000 { log::info!("PTY: process={:.2}ms bytes={}", process_time_ns as f64 / 1_000_000.0, len); } // Collect any commands from the terminal commands = pane.terminal.take_commands(); processed = true; break; } } // Handle commands outside the borrow for cmd in commands { self.handle_terminal_command(pane_id, cmd); } processed } /// Handle a command from the terminal (triggered by OSC sequences). fn handle_terminal_command(&mut self, pane_id: PaneId, cmd: TerminalCommand) { match cmd { TerminalCommand::NavigatePane(direction) => { log::debug!("Terminal requested pane navigation: {:?}", direction); self.focus_pane(direction); } TerminalCommand::SetStatusline(statusline) => { log::debug!("Pane {:?} set statusline: {:?}", pane_id, statusline.as_ref().map(|s| s.len())); // Find the pane and set its custom statusline for tab in &mut self.tabs { if let Some(pane) = tab.get_pane_mut(pane_id) { pane.custom_statusline = statusline; break; } } } } } /// Send bytes to the active tab's PTY. fn write_to_pty(&mut self, data: &[u8]) { if let Some(tab) = self.tabs.get_mut(self.active_tab) { tab.write_to_pty(data); } } /// Get the active tab, if any. fn active_tab(&self) -> Option<&Tab> { self.tabs.get(self.active_tab) } /// Get the active tab mutably, if any. fn active_tab_mut(&mut self) -> Option<&mut Tab> { self.tabs.get_mut(self.active_tab) } fn resize(&mut self, new_size: PhysicalSize) { if new_size.width == 0 || new_size.height == 0 { return; } if let Some(renderer) = &mut self.renderer { renderer.resize(new_size.width, new_size.height); } // Resize all panes self.resize_all_panes(); if let Some(renderer) = &self.renderer { let (cols, rows) = renderer.terminal_size(); log::debug!("Resized to {}x{} cells", cols, rows); } } fn get_scroll_offset(&self) -> usize { self.active_tab() .and_then(|t| t.active_pane()) .map(|p| p.terminal.scroll_offset) .unwrap_or(0) } fn has_mouse_tracking(&self) -> bool { self.active_tab() .and_then(|t| t.active_pane()) .map(|p| p.terminal.mouse_tracking != MouseTrackingMode::None) .unwrap_or(false) } fn get_mouse_modifiers(&self) -> u8 { let mod_state = self.modifiers.state(); let mut mods = 0u8; if mod_state.shift_key() { mods |= 1; } if mod_state.alt_key() { mods |= 2; } if mod_state.control_key() { mods |= 4; } mods } fn send_mouse_event(&mut self, button: u8, col: u16, row: u16, pressed: bool, is_motion: bool) { let seq = { let Some(tab) = self.active_tab() else { return }; let Some(pane) = tab.active_pane() else { return }; pane.terminal.encode_mouse(button, col, row, pressed, is_motion, self.get_mouse_modifiers()) }; if !seq.is_empty() { self.write_to_pty(&seq); } } fn check_keybinding(&mut self, event: &KeyEvent) -> bool { 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(); 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, }; let lookup = (ctrl, alt, shift, super_key, key_name.clone()); log::debug!("Keybind lookup: {:?}", lookup); let Some(action) = self.action_map.get(&lookup).copied() else { return false; }; log::info!("Executing action: {:?}", action); self.execute_action(action); true } fn execute_action(&mut self, action: Action) { match action { Action::Copy => { self.copy_selection_to_clipboard(); } Action::Paste => { self.paste_from_clipboard(); } Action::NewTab => { if let Some(renderer) = &self.renderer { let (cols, rows) = renderer.terminal_size(); self.create_tab(cols, rows); // Resize the new tab to calculate pane geometries self.resize_all_panes(); if let Some(window) = &self.window { window.request_redraw(); } } } Action::ClosePane => { self.close_active_pane(); } Action::NextTab => { if !self.tabs.is_empty() { let next_tab = (self.active_tab + 1) % self.tabs.len(); self.switch_to_tab(next_tab); } } Action::PrevTab => { if !self.tabs.is_empty() { let prev_tab = if self.active_tab == 0 { self.tabs.len() - 1 } else { self.active_tab - 1 }; self.switch_to_tab(prev_tab); } } Action::Tab1 => self.switch_to_tab(0), Action::Tab2 => self.switch_to_tab(1), Action::Tab3 => self.switch_to_tab(2), Action::Tab4 => self.switch_to_tab(3), Action::Tab5 => self.switch_to_tab(4), Action::Tab6 => self.switch_to_tab(5), Action::Tab7 => self.switch_to_tab(6), Action::Tab8 => self.switch_to_tab(7), Action::Tab9 => self.switch_to_tab(8), Action::SplitHorizontal => { self.split_pane(true); } Action::SplitVertical => { self.split_pane(false); } Action::FocusPaneUp => { self.focus_pane_or_pass_key(Direction::Up, b'A'); } Action::FocusPaneDown => { self.focus_pane_or_pass_key(Direction::Down, b'B'); } Action::FocusPaneLeft => { self.focus_pane_or_pass_key(Direction::Left, b'D'); } Action::FocusPaneRight => { self.focus_pane_or_pass_key(Direction::Right, b'C'); } } } fn split_pane(&mut self, horizontal: bool) { // Get terminal dimensions let (cols, rows) = if let Some(renderer) = &self.renderer { renderer.terminal_size() } else { return; }; let scrollback_lines = self.config.scrollback_lines; let active_tab = self.active_tab; // Create the new pane and get its info for the I/O thread let new_pane_info = if let Some(tab) = self.tabs.get_mut(active_tab) { match tab.split(horizontal, cols, rows, scrollback_lines) { Ok(new_pane_id) => { // Get the info we need to start the I/O thread tab.get_pane(new_pane_id).map(|pane| { (pane.id, pane.pty_fd, pane.pty_buffer.clone()) }) } Err(e) => { log::error!("Failed to split pane: {}", e); None } } } else { None }; // Start I/O thread for the new pane (outside the tab borrow) if let Some((pane_id, pty_fd, pty_buffer)) = new_pane_info { self.start_pane_io_thread_with_info(pane_id, pty_fd, pty_buffer); // Recalculate layout self.resize_all_panes(); if let Some(window) = &self.window { window.request_redraw(); } log::info!("Split pane (horizontal={}), new pane {}", horizontal, pane_id.0); } } /// Focus neighbor pane or pass keys through to applications like Neovim. /// If the foreground process matches `pass_keys_to_programs`, send the Alt+Arrow /// escape sequence to the PTY. Otherwise, focus the neighboring pane. fn focus_pane_or_pass_key(&mut self, direction: Direction, arrow_letter: u8) { // Check if we should pass keys to the foreground process let should_pass = if let Some(tab) = self.tabs.get(self.active_tab) { if let Some(pane) = tab.active_pane() { pane.foreground_matches(&self.config.pass_keys_to_programs) } else { false } } else { false }; if should_pass { // Send Alt+Arrow escape sequence: \x1b[1;3X where X is A/B/C/D let escape_seq = [0x1b, b'[', b'1', b';', b'3', arrow_letter]; self.write_to_pty(&escape_seq); } else { self.focus_pane(direction); } } fn focus_pane(&mut self, direction: Direction) { // Get current active pane geometry before attempting navigation let active_pane_geom = if let Some(tab) = self.tabs.get(self.active_tab) { tab.split_root.find_geometry(tab.active_pane) } else { None }; let navigated = if let Some(tab) = self.tabs.get_mut(self.active_tab) { let old_pane = tab.active_pane; tab.focus_neighbor(direction); tab.active_pane != old_pane } else { false }; if !navigated { // No neighbor in that direction - trigger edge glow animation // Use renderer's helper to calculate proper screen-space glow bounds if let (Some(geom), Some(renderer)) = (active_pane_geom, &self.renderer) { let (glow_x, glow_y, glow_width, glow_height) = renderer.calculate_edge_glow_bounds(geom.x, geom.y, geom.width, geom.height); self.edge_glows.push(EdgeGlow::new( direction, glow_x, glow_y, glow_width, glow_height, )); } } if let Some(window) = &self.window { window.request_redraw(); } } fn close_active_pane(&mut self) { let should_close_tab = if let Some(tab) = self.tabs.get_mut(self.active_tab) { tab.close_active_pane(); tab.panes.is_empty() } else { false }; if should_close_tab { self.tabs.remove(self.active_tab); if !self.tabs.is_empty() && self.active_tab >= self.tabs.len() { self.active_tab = self.tabs.len() - 1; } } else { // Recalculate layout after removing pane self.resize_all_panes(); } if let Some(window) = &self.window { window.request_redraw(); } } fn switch_to_tab(&mut self, idx: usize) { if idx < self.tabs.len() { self.active_tab = idx; // Update grid dimensions for proper centering of the new active tab self.update_active_tab_grid_dimensions(); if let Some(window) = &self.window { window.request_redraw(); } } } /// Update the renderer's grid dimensions based on the active tab's stored dimensions. fn update_active_tab_grid_dimensions(&mut self) { if let Some(tab) = self.tabs.get(self.active_tab) { let used_dims = tab.grid_used_dimensions; if let Some(renderer) = &mut self.renderer { renderer.set_grid_used_dimensions(used_dims.0, used_dims.1); } } } fn paste_from_clipboard(&mut self) { let output = match Command::new("wl-paste") .arg("--no-newline") .output() { Ok(output) => output, Err(e) => { log::warn!("Failed to run wl-paste: {}", e); return; } }; if output.status.success() && !output.stdout.is_empty() { self.write_to_pty(&output.stdout); } } fn copy_selection_to_clipboard(&mut self) { let Some(tab) = self.active_tab() else { return }; let Some(pane) = tab.active_pane() else { return }; let Some(selection) = &pane.selection else { return }; let terminal = &pane.terminal; let (start, end) = selection.normalized(); let mut text = String::new(); let scroll_offset = terminal.scroll_offset as isize; let rows = terminal.rows; let screen_start_row = (start.row + scroll_offset).max(0) as usize; let screen_end_row = ((end.row + scroll_offset).max(0) as usize).min(rows.saturating_sub(1)); let visible_rows = terminal.visible_rows(); for screen_row in screen_start_row..=screen_end_row { if screen_row >= visible_rows.len() { break; } let content_row = screen_row as isize - scroll_offset; if content_row < start.row || content_row > end.row { continue; } let row_cells = visible_rows[screen_row]; let cols = row_cells.len(); let col_start = if content_row == start.row { start.col } else { 0 }; let col_end = if content_row == end.row { end.col } else { cols.saturating_sub(1) }; let mut line = String::new(); for col in col_start..=col_end.min(cols.saturating_sub(1)) { let c = row_cells[col].character; if c != '\0' { line.push(c); } } text.push_str(line.trim_end()); if content_row < end.row { text.push('\n'); } } if text.is_empty() { return; } match Command::new("wl-copy") .stdin(Stdio::piped()) .spawn() { Ok(mut child) => { if let Some(mut stdin) = child.stdin.take() { let _ = stdin.write_all(text.as_bytes()); } let _ = child.wait(); } Err(e) => { log::warn!("Failed to run wl-copy: {}", e); } } } fn handle_keyboard_input(&mut self, event: KeyEvent) { if self.check_keybinding(&event) { return; } let event_type = match event.state { ElementState::Pressed => { if event.repeat { KeyEventType::Repeat } else { KeyEventType::Press } } ElementState::Released => KeyEventType::Release, }; if event_type == KeyEventType::Release && !self.keyboard_state.report_events() { return; } 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> = 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 { // Reset scroll when typing if let Some(tab) = self.active_tab_mut() { if let Some(pane) = tab.active_pane_mut() { if pane.terminal.scroll_offset > 0 { pane.terminal.scroll_offset = 0; } } } self.write_to_pty(&bytes); } } } impl ApplicationHandler for App { fn resumed(&mut self, event_loop: &ActiveEventLoop) { let start = std::time::Instant::now(); if self.window.is_none() { self.create_window(event_loop); } log::info!("App resumed (window creation): {:?}", start.elapsed()); } fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { match event { UserEvent::ShowWindow => { log::info!("Received signal to show window"); if self.window.is_none() { self.create_window(event_loop); } } UserEvent::PtyReadable(pane_id) => { // I/O thread has batched wakeups - read all available data now let start = std::time::Instant::now(); self.poll_pane(pane_id); let process_time = start.elapsed(); // Check if terminal is in synchronized output mode (DCS pending mode or CSI 2026) // If so, skip the redraw - rendering will happen when sync mode ends let synchronized = self.tabs.iter() .flat_map(|tab| tab.panes.values()) .find(|pane| pane.id == pane_id) .map(|pane| pane.terminal.is_synchronized()) .unwrap_or(false); // Request redraw to display the new content (unless in sync mode) if !synchronized { if let Some(window) = &self.window { window.request_redraw(); } } if process_time.as_millis() > 5 { log::info!("PTY process took {:?}", process_time); } } UserEvent::ConfigReloaded => { self.reload_config(); } } } fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { match event { WindowEvent::CloseRequested => { log::info!("Window close requested - hiding window"); self.destroy_window(); // Don't exit - keep running headless } WindowEvent::Resized(new_size) => { self.resize(new_size); } WindowEvent::ScaleFactorChanged { scale_factor, .. } => { log::info!("Scale factor changed to {}", scale_factor); let should_resize = if let Some(renderer) = &mut self.renderer { renderer.set_scale_factor(scale_factor) } else { false }; if should_resize { self.resize_all_panes(); } if let Some(window) = &self.window { window.request_redraw(); } } WindowEvent::ModifiersChanged(new_modifiers) => { self.modifiers = new_modifiers; } WindowEvent::MouseWheel { delta, .. } => { let lines = match delta { MouseScrollDelta::LineDelta(_, y) => (y * 3.0) as i32, MouseScrollDelta::PixelDelta(pos) => (pos.y / 20.0) as i32, }; if lines != 0 { if self.has_mouse_tracking() { if let Some(renderer) = &self.renderer { if let Some((col, row)) = renderer.pixel_to_cell( self.cursor_position.x, self.cursor_position.y ) { let button = if lines > 0 { 64 } else { 65 }; let count = lines.abs().min(3); for _ in 0..count { self.send_mouse_event(button, col as u16, row as u16, true, false); } } } } else if let Some(tab) = self.active_tab_mut() { // Positive lines = scroll wheel up = go into history (increase offset) if let Some(pane) = tab.active_pane_mut() { pane.terminal.scroll(lines); } } if let Some(window) = &self.window { window.request_redraw(); } } } WindowEvent::CursorMoved { position, .. } => { self.cursor_position = position; let is_selecting = self.active_tab() .and_then(|t| t.active_pane()) .map(|p| p.is_selecting) .unwrap_or(false); if is_selecting && !self.has_mouse_tracking() { if let Some(renderer) = &self.renderer { if let Some((col, screen_row)) = renderer.pixel_to_cell(position.x, position.y) { let scroll_offset = self.get_scroll_offset(); let content_row = screen_row as isize - scroll_offset as isize; if let Some(tab) = self.active_tab_mut() { if let Some(pane) = tab.active_pane_mut() { if let Some(ref mut selection) = pane.selection { selection.end = CellPosition { col, row: content_row }; if let Some(window) = &self.window { window.request_redraw(); } } } } } } } } WindowEvent::MouseInput { state, button, .. } => { let button_code = match button { MouseButton::Left => 0, MouseButton::Middle => 1, MouseButton::Right => 2, _ => return, }; if self.has_mouse_tracking() { if let Some(renderer) = &self.renderer { if let Some((col, row)) = renderer.pixel_to_cell( self.cursor_position.x, self.cursor_position.y ) { let pressed = state == ElementState::Pressed; self.send_mouse_event(button_code, col as u16, row as u16, pressed, false); if button == MouseButton::Left { if let Some(tab) = self.active_tab_mut() { if let Some(pane) = tab.active_pane_mut() { pane.is_selecting = pressed; } } } } } if let Some(tab) = self.active_tab_mut() { if let Some(pane) = tab.active_pane_mut() { pane.selection = None; } } } else if button == MouseButton::Left { match state { ElementState::Pressed => { if let Some(renderer) = &self.renderer { if let Some((col, screen_row)) = renderer.pixel_to_cell( self.cursor_position.x, self.cursor_position.y ) { let scroll_offset = self.get_scroll_offset(); let content_row = screen_row as isize - scroll_offset as isize; let pos = CellPosition { col, row: content_row }; log::debug!("Selection started at col={}, content_row={}, screen_row={}, scroll_offset={}", col, content_row, screen_row, scroll_offset); if let Some(tab) = self.active_tab_mut() { if let Some(pane) = tab.active_pane_mut() { pane.selection = Some(Selection { start: pos, end: pos }); pane.is_selecting = true; } } } } } ElementState::Released => { let was_selecting = self.active_tab() .and_then(|t| t.active_pane()) .map(|p| p.is_selecting) .unwrap_or(false); if was_selecting { if let Some(tab) = self.active_tab_mut() { if let Some(pane) = tab.active_pane_mut() { pane.is_selecting = false; } } self.copy_selection_to_clipboard(); } } } } if let Some(window) = &self.window { window.request_redraw(); } } WindowEvent::KeyboardInput { event, .. } => { self.handle_keyboard_input(event); if let Some(window) = &self.window { window.request_redraw(); } } WindowEvent::RedrawRequested => { let frame_start = std::time::Instant::now(); self.frame_count += 1; if self.last_frame_log.elapsed() >= Duration::from_secs(1) { log::debug!("FPS: {}", self.frame_count); self.frame_count = 0; self.last_frame_log = std::time::Instant::now(); } // Note: poll_pane() is called from UserEvent::PtyReadable, not here. // This avoids double-processing and keeps rendering fast. // Send any terminal responses back to PTY (for active pane) if let Some(tab) = self.active_tab_mut() { if let Some(pane) = tab.active_pane_mut() { if let Some(response) = pane.terminal.take_response() { pane.write_to_pty(&response); } // Track scrollback changes for selection adjustment let scrollback_len = pane.terminal.scrollback.len() as u32; if scrollback_len != pane.last_scrollback_len { let lines_added = scrollback_len.saturating_sub(pane.last_scrollback_len) as isize; if let Some(ref mut selection) = pane.selection { selection.start.row -= lines_added; selection.end.row -= lines_added; } pane.last_scrollback_len = scrollback_len; } } } // Render all panes let render_start = std::time::Instant::now(); let num_tabs = self.tabs.len(); let active_tab_idx = self.active_tab; let fade_duration_ms = self.config.inactive_pane_fade_ms; let inactive_dim = self.config.inactive_pane_dim; if let Some(renderer) = &mut self.renderer { if let Some(tab) = self.tabs.get_mut(active_tab_idx) { // Collect all pane geometries let geometries = tab.collect_pane_geometries(); let active_pane_id = tab.active_pane; // First pass: sync images and calculate dim factors (needs mutable access) let mut dim_factors: Vec<(PaneId, f32)> = Vec::new(); for (pane_id, _) in &geometries { if let Some(pane) = tab.panes.get_mut(pane_id) { let is_active = *pane_id == active_pane_id; let dim_factor = pane.calculate_dim_factor(is_active, fade_duration_ms, inactive_dim); dim_factors.push((*pane_id, dim_factor)); // Sync terminal images to GPU (Kitty graphics protocol) renderer.sync_images(&mut pane.terminal.image_storage); } } // Clear custom statusline if the foreground process is no longer neovim/vim // This handles the case where neovim exits but didn't send a clear command if let Some(pane) = tab.panes.get_mut(&active_pane_id) { if pane.custom_statusline.is_some() { if let Some(proc_name) = pane.pty.foreground_process_name() { let is_vim = proc_name == "nvim" || proc_name == "vim" || proc_name == "vi"; if !is_vim { pane.custom_statusline = None; } } } } // Build render info for all panes let mut pane_render_data: Vec<(&Terminal, PaneRenderInfo, Option<(usize, usize, usize, usize)>)> = Vec::new(); for (pane_id, geom) in &geometries { if let Some(pane) = tab.panes.get(pane_id) { let is_active = *pane_id == active_pane_id; let scroll_offset = pane.terminal.scroll_offset; // Get pre-calculated dim factor let dim_factor = dim_factors.iter() .find(|(id, _)| id == pane_id) .map(|(_, f)| *f) .unwrap_or(if is_active { 1.0 } else { inactive_dim }); // Convert selection to screen coords for this pane let selection = if is_active { let sel = pane.selection.as_ref() .and_then(|sel| sel.to_screen_coords(scroll_offset, geom.rows)); if pane.selection.is_some() { log::debug!("Render: pane.selection={:?}, scroll_offset={}, rows={}, screen_coords={:?}", pane.selection, scroll_offset, geom.rows, sel); } sel } else { None }; let render_info = PaneRenderInfo { pane_id: pane_id.0, x: geom.x, y: geom.y, width: geom.width, height: geom.height, cols: geom.cols, rows: geom.rows, is_active, dim_factor, }; pane_render_data.push((&pane.terminal, render_info, selection)); } } // Request redraw if any animation is in progress let animation_in_progress = dim_factors.iter().any(|(id, factor)| { let is_active = *id == active_pane_id; if is_active { *factor < 1.0 } else { *factor > inactive_dim } }); if animation_in_progress { if let Some(window) = &self.window { window.request_redraw(); } } // Handle edge glow animations let glow_in_progress = !self.edge_glows.is_empty(); // Check if any pane has animated images let image_animation_in_progress = tab.panes.values().any(|pane| { pane.terminal.image_storage.has_animations() }); // Get the statusline content for the active pane // If the pane has a custom statusline (from neovim), use raw ANSI content let statusline_content: StatuslineContent = tab.panes.get(&active_pane_id) .map(|pane| { if let Some(ref custom) = pane.custom_statusline { // Use raw ANSI content directly - no parsing into sections StatuslineContent::Raw(custom.clone()) } else if let Some(cwd) = pane.pty.foreground_cwd() { // Default: CWD and git sections let mut sections = vec![build_cwd_section(&cwd)]; if let Some(git_section) = build_git_section(&cwd) { sections.push(git_section); } StatuslineContent::Sections(sections) } else { StatuslineContent::Sections(Vec::new()) } }) .unwrap_or_default(); match renderer.render_panes(&pane_render_data, num_tabs, active_tab_idx, &self.edge_glows, self.config.edge_glow_intensity, &statusline_content) { 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); } } // Request redraw if edge glow or image animation is in progress if glow_in_progress || image_animation_in_progress { if let Some(window) = &self.window { window.request_redraw(); } } } } // Clean up finished edge glow animations self.edge_glows.retain(|g| !g.is_finished()); let render_time = render_start.elapsed(); let frame_time = frame_start.elapsed(); if frame_time.as_millis() > 10 { log::info!("Slow frame: total={:?} render={:?}", frame_time, render_time); } } _ => {} } } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { // Check if all tabs have exited if self.tabs.is_empty() { log::info!("All tabs closed, exiting"); event_loop.exit(); return; } // Check for exited tabs and remove them let mut i = 0; let mut tabs_removed = false; while i < self.tabs.len() { if self.tabs[i].child_exited() { log::info!("Tab {} shell exited", i); self.tabs.remove(i); tabs_removed = true; if self.active_tab >= self.tabs.len() && !self.tabs.is_empty() { self.active_tab = self.tabs.len() - 1; } } else { i += 1; } } // Update grid dimensions if tabs were removed if tabs_removed && !self.tabs.is_empty() { self.update_active_tab_grid_dimensions(); } if self.tabs.is_empty() { log::info!("All tabs closed, exiting"); event_loop.exit(); return; } // Batching is done in the I/O thread (Kitty-style). // We just wait for events here. event_loop.set_control_flow(ControlFlow::Wait); } } impl Drop for App { fn drop(&mut self) { self.shutdown.store(true, Ordering::Relaxed); remove_pid_file(); } } /// Set up a file watcher to monitor the config file for changes. /// Returns the watcher (must be kept alive for watching to continue). fn setup_config_watcher(proxy: EventLoopProxy) -> Option { let config_path = match Config::config_path() { Some(path) => path, None => { log::warn!("Could not determine config path, config hot-reload disabled"); return None; } }; // Watch the parent directory since the file might be replaced atomically let watch_path = match config_path.parent() { Some(parent) => parent.to_path_buf(), None => { log::warn!("Could not determine config directory, config hot-reload disabled"); return None; } }; let config_filename = config_path.file_name().map(|s| s.to_os_string()); let mut watcher = match notify::recommended_watcher(move |res: Result| { match res { Ok(event) => { // Only trigger on modify/create events for the config file use notify::EventKind; match event.kind { EventKind::Modify(_) | EventKind::Create(_) => { // Check if the event is for our config file let is_config_file = event.paths.iter().any(|p| { p.file_name().map(|s| s.to_os_string()) == config_filename }); if is_config_file { log::debug!("Config file changed, triggering reload"); let _ = proxy.send_event(UserEvent::ConfigReloaded); } } _ => {} } } Err(e) => { log::warn!("Config watcher error: {:?}", e); } } }) { Ok(w) => w, Err(e) => { log::warn!("Failed to create config watcher: {:?}", e); return None; } }; if let Err(e) = watcher.watch(&watch_path, RecursiveMode::NonRecursive) { log::warn!("Failed to watch config directory {:?}: {:?}", watch_path, e); return None; } log::info!("Config hot-reload enabled, watching {:?}", watch_path); Some(watcher) } fn main() { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); log::info!("Starting ZTerm"); // Check for existing instance if signal_existing_instance() { log::info!("Signaled existing instance, exiting"); return; } // Write PID file if let Err(e) = write_pid_file() { log::warn!("Failed to write PID file: {}", e); } // Set up SIGUSR1 handler unsafe { libc::signal(libc::SIGUSR1, handle_sigusr1 as usize); } // Create event loop let event_loop = EventLoop::::with_user_event() .with_any_thread(true) .build() .expect("Failed to create event loop"); event_loop.set_control_flow(ControlFlow::Wait); let mut app = App::new(); let proxy = event_loop.create_proxy(); app.set_event_loop_proxy(proxy.clone()); // Store proxy for signal handler (uses the global static defined below) unsafe { EVENT_PROXY = Some(proxy.clone()); } // Set up config file watcher for hot-reloading let _config_watcher = setup_config_watcher(proxy); event_loop.run_app(&mut app).expect("Event loop error"); } // Global static for signal handler access static mut EVENT_PROXY: Option> = None; extern "C" fn handle_sigusr1(_: i32) { // Signal handler - must be async-signal-safe // We can only set a flag here, the actual window creation happens in the event loop unsafe { if let Some(ref proxy) = EVENT_PROXY { let _ = proxy.send_event(UserEvent::ShowWindow); } } }