Files
zterm/src/main.rs
T
2025-12-18 23:43:31 +01:00

2803 lines
109 KiB
Rust

//! 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<Box<[u8; PTY_BUF_SIZE]>>,
/// Metadata protected by mutex - offsets into the buffer
state: Mutex<BufferState>,
/// 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<SharedPtyBuffer>,
/// Selection state for this pane.
selection: Option<Selection>,
/// 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<String>,
}
impl Pane {
/// Create a new pane with its own terminal and PTY.
fn new(cols: usize, rows: usize, scrollback_lines: usize) -> Result<Self, String> {
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<SplitNode>,
/// Second child (right or bottom).
second: Box<SplitNode>,
},
}
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<PaneGeometry> {
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<PaneId> {
// 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<SplitNode> {
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<PaneId, Pane>,
/// 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<Self, String> {
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<PaneId> = 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<PaneId, String> {
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::<i32>() {
// 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<GitStatus> {
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<char> = 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<StatuslineSection> {
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<Arc<Window>>,
/// GPU renderer (None when headless).
renderer: Option<Renderer>,
/// All open tabs.
tabs: Vec<Tab>,
/// 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<EventLoopProxy<UserEvent>>,
/// Shutdown signal.
shutdown: Arc<AtomicBool>,
/// Current mouse cursor position.
cursor_position: PhysicalPosition<f64>,
/// 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<EdgeGlow>,
}
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<UserEvent>) {
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<usize> {
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<SharedPtyBuffer>) {
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<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);
}
// 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<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 {
// 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<UserEvent> 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<UserEvent>) -> Option<RecommendedWatcher> {
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<notify::Event, notify::Error>| {
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::<UserEvent>::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<EventLoopProxy<UserEvent>> = 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);
}
}
}