//! Terminal state management and escape sequence handling. use crate::graphics::{GraphicsCommand, ImageStorage}; use crate::keyboard::{query_response, KeyboardState}; use crate::vt_parser::{CsiParams, Handler, Parser}; use unicode_width::UnicodeWidthChar; /// Commands that the terminal can send to the application. /// These are triggered by special escape sequences from programs like Neovim. #[derive(Clone, Debug, PartialEq)] pub enum TerminalCommand { /// Navigate to a neighboring pane in the given direction. /// Triggered by OSC 51;navigate; ST NavigatePane(Direction), /// Set custom statusline content for this pane. /// Triggered by OSC 51;statusline; ST /// Empty content clears the statusline (restores default). SetStatusline(Option), } /// Direction for pane navigation. #[derive(Clone, Copy, Debug, PartialEq)] pub enum Direction { Up, Down, Left, Right, } /// A single cell in the terminal grid. #[derive(Clone, Copy, Debug)] pub struct Cell { pub character: char, pub fg_color: Color, pub bg_color: Color, pub bold: bool, pub italic: bool, /// Underline style: 0=none, 1=single, 2=double, 3=curly, 4=dotted, 5=dashed pub underline_style: u8, /// Strikethrough decoration pub strikethrough: bool, /// If true, this cell is the continuation of a wide (double-width) character. /// The actual character is stored in the previous cell. pub wide_continuation: bool, } impl Default for Cell { fn default() -> Self { Self { character: ' ', fg_color: Color::Default, bg_color: Color::Default, bold: false, italic: false, underline_style: 0, strikethrough: false, wide_continuation: false, } } } /// Terminal colors. #[derive(Clone, Copy, Debug, PartialEq, Default)] pub enum Color { #[default] Default, Rgb(u8, u8, u8), Indexed(u8), } /// Cursor shape styles (DECSCUSR). #[derive(Clone, Copy, Debug, PartialEq, Default)] pub enum CursorShape { /// Blinking block (and default) #[default] BlinkingBlock, /// Steady block SteadyBlock, /// Blinking underline BlinkingUnderline, /// Steady underline SteadyUnderline, /// Blinking bar (beam) BlinkingBar, /// Steady bar (beam) SteadyBar, } /// Mouse tracking mode - determines what mouse events are reported to the application. #[derive(Clone, Copy, Debug, PartialEq, Default)] pub enum MouseTrackingMode { /// No mouse tracking (terminal handles selection). #[default] None, /// X10 compatibility mode - only report button press (mode 9). X10, /// Normal tracking mode - report button press and release (mode 1000). Normal, /// Button-event tracking - report press, release, and motion while button pressed (mode 1002). ButtonEvent, /// Any-event tracking - report all motion events (mode 1003). AnyEvent, } /// Mouse encoding format - how mouse events are encoded. #[derive(Clone, Copy, Debug, PartialEq, Default)] pub enum MouseEncoding { /// Default X10 encoding (limited to 223 rows/cols). #[default] X10, /// UTF-8 encoding (mode 1005) - deprecated, rarely used. Utf8, /// SGR extended encoding (mode 1006) - most common modern format. Sgr, /// URXVT encoding (mode 1015) - rarely used. Urxvt, } /// Color palette with 256 colors + default fg/bg. #[derive(Clone)] pub struct ColorPalette { /// 256 indexed colors (ANSI 0-15 + 216 color cube + 24 grayscale). pub colors: [[u8; 3]; 256], /// Default foreground color. pub default_fg: [u8; 3], /// Default background color. pub default_bg: [u8; 3], } impl Default for ColorPalette { fn default() -> Self { let mut colors = [[0u8; 3]; 256]; // Standard ANSI colors (0-7) colors[0] = [0, 0, 0]; // Black colors[1] = [204, 0, 0]; // Red colors[2] = [0, 204, 0]; // Green colors[3] = [204, 204, 0]; // Yellow colors[4] = [0, 0, 204]; // Blue colors[5] = [204, 0, 204]; // Magenta colors[6] = [0, 204, 204]; // Cyan colors[7] = [204, 204, 204]; // White // Bright ANSI colors (8-15) colors[8] = [102, 102, 102]; // Bright Black (Gray) colors[9] = [255, 0, 0]; // Bright Red colors[10] = [0, 255, 0]; // Bright Green colors[11] = [255, 255, 0]; // Bright Yellow colors[12] = [0, 0, 255]; // Bright Blue colors[13] = [255, 0, 255]; // Bright Magenta colors[14] = [0, 255, 255]; // Bright Cyan colors[15] = [255, 255, 255]; // Bright White // 216 color cube (16-231) for r in 0..6 { for g in 0..6 { for b in 0..6 { let idx = 16 + r * 36 + g * 6 + b; let to_val = |c: usize| if c == 0 { 0 } else { (55 + c * 40) as u8 }; colors[idx] = [to_val(r), to_val(g), to_val(b)]; } } } // 24 grayscale colors (232-255) for i in 0..24 { let gray = (8 + i * 10) as u8; colors[232 + i] = [gray, gray, gray]; } Self { colors, default_fg: [230, 230, 230], // Light gray default_bg: [26, 26, 26], // Dark gray } } } impl ColorPalette { /// Parse a color specification like "#RRGGBB" or "rgb:RR/GG/BB". pub fn parse_color_spec(spec: &str) -> Option<[u8; 3]> { let spec = spec.trim(); if let Some(hex) = spec.strip_prefix('#') { // #RRGGBB format if hex.len() == 6 { let r = u8::from_str_radix(&hex[0..2], 16).ok()?; let g = u8::from_str_radix(&hex[2..4], 16).ok()?; let b = u8::from_str_radix(&hex[4..6], 16).ok()?; return Some([r, g, b]); } } else if let Some(rgb) = spec.strip_prefix("rgb:") { // rgb:RR/GG/BB or rgb:RRRR/GGGG/BBBB format let parts: Vec<&str> = rgb.split('/').collect(); if parts.len() == 3 { let parse_component = |s: &str| -> Option { let val = u16::from_str_radix(s, 16).ok()?; // Scale to 8-bit if it's a 16-bit value Some(if s.len() > 2 { (val >> 8) as u8 } else { val as u8 }) }; let r = parse_component(parts[0])?; let g = parse_component(parts[1])?; let b = parse_component(parts[2])?; return Some([r, g, b]); } } None } /// Get RGBA for a color, using the palette for indexed colors. pub fn to_rgba(&self, color: &Color) -> [f32; 4] { match color { Color::Default => { let [r, g, b] = self.default_fg; [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] } Color::Rgb(r, g, b) => [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0], Color::Indexed(idx) => { let [r, g, b] = self.colors[*idx as usize]; [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] } } } /// Get RGBA for background, using palette default_bg for Color::Default. pub fn to_rgba_bg(&self, color: &Color) -> [f32; 4] { match color { Color::Default => { let [r, g, b] = self.default_bg; [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] } Color::Rgb(r, g, b) => [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0], Color::Indexed(idx) => { let [r, g, b] = self.colors[*idx as usize]; [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] } } } } /// Saved cursor state for DECSC/DECRC. #[derive(Clone, Debug, Default)] struct SavedCursor { col: usize, row: usize, fg: Color, bg: Color, bold: bool, italic: bool, underline_style: u8, strikethrough: bool, } /// Alternate screen buffer state. #[derive(Clone)] struct AlternateScreen { grid: Vec>, line_map: Vec, cursor_col: usize, cursor_row: usize, saved_cursor: SavedCursor, scroll_top: usize, scroll_bottom: usize, } /// Timing stats for performance debugging. #[derive(Debug, Default)] pub struct ProcessingStats { /// Total time spent in scroll_up operations (nanoseconds). pub scroll_up_ns: u64, /// Number of scroll_up calls. pub scroll_up_count: u32, /// Total time spent in scrollback operations (nanoseconds). pub scrollback_ns: u64, /// Time in VecDeque pop_front. pub pop_front_ns: u64, /// Time in VecDeque push_back. pub push_back_ns: u64, /// Time in mem::swap. pub swap_ns: u64, /// Total time spent in line clearing (nanoseconds). pub clear_line_ns: u64, /// Total time spent in text handler (nanoseconds). pub text_handler_ns: u64, /// Number of characters processed. pub chars_processed: u32, } impl ProcessingStats { pub fn reset(&mut self) { *self = Self::default(); } pub fn log_if_slow(&self, threshold_ms: u64) { let total_ms = (self.scroll_up_ns + self.text_handler_ns) / 1_000_000; if total_ms >= threshold_ms { log::info!( "TIMING: scroll_up={:.2}ms ({}x), scrollback={:.2}ms [pop={:.2}ms swap={:.2}ms push={:.2}ms], clear={:.2}ms, text={:.2}ms, chars={}", self.scroll_up_ns as f64 / 1_000_000.0, self.scroll_up_count, self.scrollback_ns as f64 / 1_000_000.0, self.pop_front_ns as f64 / 1_000_000.0, self.swap_ns as f64 / 1_000_000.0, self.push_back_ns as f64 / 1_000_000.0, self.clear_line_ns as f64 / 1_000_000.0, self.text_handler_ns as f64 / 1_000_000.0, self.chars_processed, ); } } } /// Kitty-style ring buffer for scrollback history. /// /// Pre-allocates all lines upfront to avoid allocation during scrolling. /// Uses modulo arithmetic for O(1) operations with no memory allocation or /// pointer chasing - just simple index arithmetic like Kitty's historybuf. /// /// Key insight from Kitty: When the buffer is full, instead of pop_front + push_back /// (which involves linked-list-style pointer updates in VecDeque), we just: /// 1. Calculate the insertion slot with modulo arithmetic /// 2. Increment the start pointer (also with modulo) /// /// This eliminates all per-scroll overhead that was causing timing variance. pub struct ScrollbackBuffer { /// Pre-allocated line storage. All lines are allocated upfront. lines: Vec>, /// Index of the oldest line (start of valid data). start: usize, /// Number of valid lines currently stored. count: usize, /// Maximum capacity (same as lines.len()). capacity: usize, } impl ScrollbackBuffer { /// Creates a new scrollback buffer with the given capacity. /// Lines are allocated lazily as needed to avoid slow startup. pub fn new(capacity: usize) -> Self { // Don't pre-allocate lines - allocate them lazily as content is added // This avoids allocating and zeroing potentially 20MB+ of memory at startup let lines = Vec::with_capacity(capacity.min(1024)); // Start with reasonable capacity Self { lines, start: 0, count: 0, capacity, } } /// Returns the number of lines currently stored. #[inline] pub fn len(&self) -> usize { self.count } /// Returns true if the buffer is empty. #[inline] pub fn is_empty(&self) -> bool { self.count == 0 } /// Returns true if the buffer is at capacity. #[inline] pub fn is_full(&self) -> bool { self.count == self.capacity } /// Push a line into the buffer, returning a mutable reference to write into. /// /// If the buffer is full, the oldest line is overwritten and its slot is returned /// for reuse (the caller can swap content into it). /// /// Lines are allocated lazily on first use to avoid slow startup. #[inline] pub fn push(&mut self, cols: usize) -> &mut Vec { if self.capacity == 0 { // Shouldn't happen in normal use, but handle gracefully panic!("Cannot push to zero-capacity scrollback buffer"); } // Calculate insertion index: (start + count) % capacity // This is where the new line goes let idx = (self.start + self.count) % self.capacity; if self.count == self.capacity { // Buffer is full - we're overwriting the oldest line // Advance start to point to the new oldest line self.start = (self.start + 1) % self.capacity; // count stays the same } else { // Buffer not full yet - allocate new line if needed if idx >= self.lines.len() { // Grow the lines vector and allocate the new line self.lines.push(vec![Cell::default(); cols]); } self.count += 1; } &mut self.lines[idx] } /// Get a line by logical index (0 = oldest, count-1 = newest). /// Returns None if index is out of bounds. #[inline] pub fn get(&self, index: usize) -> Option<&Vec> { if index >= self.count { return None; } // Map logical index to physical index let physical_idx = (self.start + index) % self.capacity; Some(&self.lines[physical_idx]) } /// Clear all lines from the buffer. /// Note: This doesn't deallocate - lines stay allocated for reuse. #[inline] pub fn clear(&mut self) { self.start = 0; self.count = 0; // Lines remain allocated but logically empty } } /// The terminal grid state. pub struct Terminal { /// Grid of cells (row-major order). /// Access via line_map for correct visual ordering. pub grid: Vec>, /// Maps visual row index to actual grid row index. /// This allows O(1) scrolling by rotating indices instead of moving cells. line_map: Vec, /// Number of columns. pub cols: usize, /// Number of rows. pub rows: usize, /// Current cursor column (0-indexed). pub cursor_col: usize, /// Current cursor row (0-indexed). pub cursor_row: usize, /// Cursor visibility. pub cursor_visible: bool, /// Cursor shape (block, underline, bar). pub cursor_shape: CursorShape, /// Current foreground color for new text. pub current_fg: Color, /// Current background color for new text. pub current_bg: Color, /// Current bold state. pub current_bold: bool, /// Current italic state. pub current_italic: bool, /// Current underline style (0=none, 1=single, 2=double, 3=curly, 4=dotted, 5=dashed). pub current_underline_style: u8, /// Current strikethrough state. pub current_strikethrough: bool, /// Whether the terminal content has changed. pub dirty: bool, /// Bitmap of dirty lines - bit N is set if line N needs redrawing. /// Supports up to 256 lines (4 x u64). pub dirty_lines: [u64; 4], /// Scroll region top (0-indexed, inclusive). scroll_top: usize, /// Scroll region bottom (0-indexed, inclusive). scroll_bottom: usize, /// Kitty keyboard protocol state. pub keyboard: KeyboardState, /// Response queue (bytes to send back to PTY). response_queue: Vec, /// Color palette (can be modified by OSC sequences). pub palette: ColorPalette, /// Scrollback buffer (lines that scrolled off the top). /// Uses a Kitty-style ring buffer for O(1) operations with no allocation. pub scrollback: ScrollbackBuffer, /// Current scroll offset (0 = viewing live terminal, >0 = viewing history). pub scroll_offset: usize, /// Mouse tracking mode (what events to report to application). pub mouse_tracking: MouseTrackingMode, /// Mouse encoding format (how to encode mouse events). pub mouse_encoding: MouseEncoding, /// Saved cursor state (DECSC/DECRC). saved_cursor: SavedCursor, /// Alternate screen buffer (for fullscreen apps like vim, less). alternate_screen: Option, /// Whether we're currently using the alternate screen. pub using_alternate_screen: bool, /// Application cursor keys mode (DECCKM) - arrows send ESC O instead of ESC [. pub application_cursor_keys: bool, /// Auto-wrap mode (DECAWM) - wrap at end of line. auto_wrap: bool, /// Bracketed paste mode - wrap pasted text with escape sequences. pub bracketed_paste: bool, /// Focus event reporting mode. pub focus_reporting: bool, /// Synchronized output mode (for reducing flicker). synchronized_output: bool, /// Pool of pre-allocated empty lines to avoid allocation during scrolling. /// When we need a new line, we pop from this pool instead of allocating. line_pool: Vec>, /// VT parser for escape sequence handling. parser: Option, /// Performance timing stats (for debugging). pub stats: ProcessingStats, /// Command queue for terminal-to-application communication. /// Commands are added by OSC handlers and consumed by the application. command_queue: Vec, /// Image storage for Kitty graphics protocol. pub image_storage: ImageStorage, /// Cell width in pixels (for image sizing). pub cell_width: f32, /// Cell height in pixels (for image sizing). pub cell_height: f32, } impl Terminal { /// Default scrollback limit (10,000 lines for better cache performance). pub const DEFAULT_SCROLLBACK_LIMIT: usize = 10_000; /// Size of the line pool for recycling allocations. /// This avoids allocation during the first N scrolls before scrollback is full. const LINE_POOL_SIZE: usize = 64; /// Creates a new terminal with the given dimensions and scrollback limit. pub fn new(cols: usize, rows: usize, scrollback_limit: usize) -> Self { log::info!("Terminal::new: cols={}, rows={}, scroll_bottom={}", cols, rows, rows.saturating_sub(1)); let grid = vec![vec![Cell::default(); cols]; rows]; let line_map: Vec = (0..rows).collect(); // Pre-allocate a pool of empty lines to avoid allocation during scrolling let line_pool: Vec> = (0..Self::LINE_POOL_SIZE) .map(|_| vec![Cell::default(); cols]) .collect(); Self { grid, line_map, cols, rows, cursor_col: 0, cursor_row: 0, cursor_visible: true, cursor_shape: CursorShape::default(), current_fg: Color::Default, current_bg: Color::Default, current_bold: false, current_italic: false, current_underline_style: 0, current_strikethrough: false, dirty: true, dirty_lines: [!0u64; 4], // All lines dirty initially scroll_top: 0, scroll_bottom: rows.saturating_sub(1), keyboard: KeyboardState::new(), response_queue: Vec::new(), palette: ColorPalette::default(), scrollback: ScrollbackBuffer::new(scrollback_limit), scroll_offset: 0, mouse_tracking: MouseTrackingMode::default(), mouse_encoding: MouseEncoding::default(), saved_cursor: SavedCursor::default(), alternate_screen: None, using_alternate_screen: false, application_cursor_keys: false, auto_wrap: true, // Auto-wrap is on by default bracketed_paste: false, focus_reporting: false, synchronized_output: false, line_pool, parser: Some(Parser::new()), stats: ProcessingStats::default(), command_queue: Vec::new(), image_storage: ImageStorage::new(), cell_width: 10.0, // Default, will be set by renderer cell_height: 20.0, // Default, will be set by renderer } } /// Return a line to the pool for reuse (if pool isn't full). #[allow(dead_code)] #[inline] fn return_line_to_pool(&mut self, line: Vec) { if self.line_pool.len() < Self::LINE_POOL_SIZE { self.line_pool.push(line); } // Otherwise, let the line be dropped } /// Mark a specific line as dirty (needs redrawing). #[inline] pub fn mark_line_dirty(&mut self, line: usize) { if line < 256 { let word = line / 64; let bit = line % 64; self.dirty_lines[word] |= 1u64 << bit; } } /// Mark all lines as dirty. #[inline] pub fn mark_all_lines_dirty(&mut self) { self.dirty_lines = [!0u64; 4]; } /// Check if a line is dirty. #[inline] pub fn is_line_dirty(&self, line: usize) -> bool { if line < 256 { let word = line / 64; let bit = line % 64; (self.dirty_lines[word] & (1u64 << bit)) != 0 } else { true // Lines beyond 256 are always considered dirty } } /// Clear all dirty line flags. #[inline] pub fn clear_dirty_lines(&mut self) { self.dirty_lines = [0u64; 4]; } /// Take all pending commands from the queue. /// Returns an empty Vec if no commands are pending. #[inline] pub fn take_commands(&mut self) -> Vec { std::mem::take(&mut self.command_queue) } /// Get the dirty lines bitmap (for passing to shm). #[inline] pub fn get_dirty_lines(&self) -> u64 { // Return first 64 lines worth of dirty bits (most common case) self.dirty_lines[0] } /// Check if synchronized output mode is active (rendering should be suppressed). /// This is set by CSI 2026 or DCS pending mode (=1s/=2s). #[inline] pub fn is_synchronized(&self) -> bool { self.synchronized_output } /// Get the actual grid row index for a visual row. #[inline] pub fn grid_row(&self, visual_row: usize) -> usize { self.line_map[visual_row] } /// Get a reference to a row by visual index. #[inline] pub fn row(&self, visual_row: usize) -> &Vec { &self.grid[self.line_map[visual_row]] } /// Get a mutable reference to a row by visual index. #[inline] pub fn row_mut(&mut self, visual_row: usize) -> &mut Vec { let idx = self.line_map[visual_row]; &mut self.grid[idx] } /// Clear a row (by actual grid index, not visual). #[inline] fn clear_grid_row(&mut self, grid_row: usize) { let blank = self.blank_cell(); let row = &mut self.grid[grid_row]; // Ensure row has correct width (may differ after swap with scrollback post-resize) row.resize(self.cols, blank.clone()); row.fill(blank); } /// Create a blank cell with the current background color (BCE - Background Color Erase). #[inline] fn blank_cell(&self) -> Cell { Cell { character: ' ', fg_color: Color::Default, bg_color: self.current_bg, bold: false, italic: false, underline_style: 0, strikethrough: false, wide_continuation: false, } } /// Takes any pending response bytes to send to the PTY. pub fn take_response(&mut self) -> Option> { if self.response_queue.is_empty() { None } else { Some(std::mem::take(&mut self.response_queue)) } } /// Processes raw bytes from the PTY using the internal VT parser. /// Uses Kitty-style architecture: UTF-8 decode until ESC, then parse escape sequences. pub fn process(&mut self, bytes: &[u8]) { // We need to temporarily take ownership of the parser to satisfy the borrow checker, // since parse() needs &mut self for both parser and handler (Terminal). // Use Option::take to avoid creating a new default parser each time. if let Some(mut parser) = self.parser.take() { parser.parse(bytes, self); self.parser = Some(parser); } self.dirty = true; } /// Resizes the terminal grid. pub fn resize(&mut self, cols: usize, rows: usize) { if cols == self.cols && rows == self.rows { return; } log::info!("Terminal::resize: {}x{} -> {}x{}", self.cols, self.rows, cols, rows); let old_cols = self.cols; let old_rows = self.rows; // Create new grid let mut new_grid = vec![vec![Cell::default(); cols]; rows]; // Copy existing content using line_map for correct visual ordering for visual_row in 0..rows.min(self.rows) { let old_grid_row = self.line_map[visual_row]; // Use actual row length - may differ from self.cols after scrollback swap let old_row_len = self.grid[old_grid_row].len(); for col in 0..cols.min(old_row_len) { new_grid[visual_row][col] = self.grid[old_grid_row][col].clone(); } } self.grid = new_grid; // Reset line_map to identity (0, 1, 2, ...) self.line_map = (0..rows).collect(); self.cols = cols; self.rows = rows; // Reset scroll region to full screen self.scroll_top = 0; self.scroll_bottom = rows.saturating_sub(1); // Adjust cursor position self.cursor_col = self.cursor_col.min(cols.saturating_sub(1)); self.cursor_row = self.cursor_row.min(rows.saturating_sub(1)); // Also resize the saved alternate screen if it exists if let Some(ref mut saved) = self.alternate_screen { let mut new_saved_grid = vec![vec![Cell::default(); cols]; rows]; for visual_row in 0..rows.min(old_rows) { let old_grid_row = saved.line_map.get(visual_row).copied().unwrap_or(visual_row); if old_grid_row < saved.grid.len() { for col in 0..cols.min(old_cols) { if col < saved.grid[old_grid_row].len() { new_saved_grid[visual_row][col] = saved.grid[old_grid_row][col].clone(); } } } } saved.grid = new_saved_grid; saved.line_map = (0..rows).collect(); saved.cursor_col = saved.cursor_col.min(cols.saturating_sub(1)); saved.cursor_row = saved.cursor_row.min(rows.saturating_sub(1)); saved.scroll_top = 0; saved.scroll_bottom = rows.saturating_sub(1); } self.dirty = true; self.mark_all_lines_dirty(); } /// Switch to alternate screen buffer. fn enter_alternate_screen(&mut self) { if self.using_alternate_screen { return; // Already in alternate screen } // Save main screen state self.alternate_screen = Some(AlternateScreen { grid: self.grid.clone(), line_map: self.line_map.clone(), cursor_col: self.cursor_col, cursor_row: self.cursor_row, saved_cursor: self.saved_cursor.clone(), scroll_top: self.scroll_top, scroll_bottom: self.scroll_bottom, }); // Clear the screen for alternate buffer self.grid = vec![vec![Cell::default(); self.cols]; self.rows]; self.line_map = (0..self.rows).collect(); self.cursor_col = 0; self.cursor_row = 0; // Reset scroll region to full screen for alternate buffer self.scroll_top = 0; self.scroll_bottom = self.rows.saturating_sub(1); // Reset scroll offset (can't view scrollback in alternate screen) self.scroll_offset = 0; self.using_alternate_screen = true; self.mark_all_lines_dirty(); self.dirty = true; log::debug!( "Entered alternate screen buffer: rows={}, cols={}, scroll_region={}-{}, dirty_lines={:016x}{:016x}{:016x}{:016x}", self.rows, self.cols, self.scroll_top, self.scroll_bottom, self.dirty_lines[3], self.dirty_lines[2], self.dirty_lines[1], self.dirty_lines[0] ); } /// Switch back to main screen buffer. fn leave_alternate_screen(&mut self) { if !self.using_alternate_screen { return; // Not in alternate screen } if let Some(saved) = self.alternate_screen.take() { self.grid = saved.grid; self.line_map = saved.line_map; self.saved_cursor = saved.saved_cursor; self.scroll_top = saved.scroll_top; self.scroll_bottom = saved.scroll_bottom; // Clamp cursor positions to current grid dimensions (defensive) self.cursor_col = saved.cursor_col.min(self.cols.saturating_sub(1)); self.cursor_row = saved.cursor_row.min(self.rows.saturating_sub(1)); } self.using_alternate_screen = false; self.mark_all_lines_dirty(); log::debug!("Left alternate screen buffer"); } /// Scrolls the scroll region up by n lines. /// Uses line_map rotation for O(n) instead of O(n*cols) cell copying. fn scroll_up(&mut self, n: usize) { let region_size = self.scroll_bottom - self.scroll_top + 1; let n = n.min(region_size); self.stats.scroll_up_count += n as u32; for _ in 0..n { // Save the top line's grid index before rotation let recycled_grid_row = self.line_map[self.scroll_top]; // Save to scrollback only if scrolling from the very top of the screen // AND not in alternate screen mode (alternate screen never uses scrollback) // AND scrollback is enabled (capacity > 0) if self.scroll_top == 0 && !self.using_alternate_screen && self.scrollback.capacity > 0 { // Get a slot in the ring buffer - this is O(1) with just modulo arithmetic // If buffer is full, this overwrites the oldest line (perfect for our swap) let cols = self.cols; let dest = self.scrollback.push(cols); // Swap grid row content into scrollback slot // The scrollback slot's old content (if any) moves to the grid row std::mem::swap(&mut self.grid[recycled_grid_row], dest); // Clear the grid row (now contains old scrollback data or empty) self.clear_grid_row(recycled_grid_row); } else { // Not saving to scrollback - just clear the line self.clear_grid_row(recycled_grid_row); } // Rotate line_map: shift all indices up within scroll region using memmove self.line_map.copy_within(self.scroll_top + 1..=self.scroll_bottom, self.scroll_top); self.line_map[self.scroll_bottom] = recycled_grid_row; } // Mark all lines dirty with a single bitmask operation instead of loop self.mark_region_dirty(self.scroll_top, self.scroll_bottom); } /// Mark a range of lines as dirty efficiently. #[inline] fn mark_region_dirty(&mut self, start: usize, end: usize) { // For small regions (< 64 lines), this is faster than individual calls for line in start..=end.min(255) { let word = line / 64; let bit = line % 64; self.dirty_lines[word] |= 1u64 << bit; } } /// Scrolls the scroll region down by n lines. /// Uses line_map rotation for O(n) instead of O(n*cols) cell copying. fn scroll_down(&mut self, n: usize) { let region_size = self.scroll_bottom - self.scroll_top + 1; let n = n.min(region_size); for _ in 0..n { // Save the bottom line's grid index before rotation let recycled_grid_row = self.line_map[self.scroll_bottom]; // Rotate line_map: shift all indices down within scroll region using memmove self.line_map.copy_within(self.scroll_top..self.scroll_bottom, self.scroll_top + 1); self.line_map[self.scroll_top] = recycled_grid_row; // Clear the recycled line (now at visual top of scroll region) self.clear_grid_row(recycled_grid_row); } // Mark all lines in the scroll region as dirty. self.mark_region_dirty(self.scroll_top, self.scroll_bottom); } /// Scrolls the viewport up (into scrollback history) by n lines. /// Returns the new scroll offset. /// Note: Scrollback is disabled in alternate screen mode. pub fn scroll_viewport_up(&mut self, n: usize) -> usize { // Alternate screen has no scrollback if self.using_alternate_screen { return 0; } let max_offset = self.scrollback.len(); let new_offset = (self.scroll_offset + n).min(max_offset); if new_offset != self.scroll_offset { self.scroll_offset = new_offset; self.dirty = true; self.mark_all_lines_dirty(); // All visible content changes when scrolling } self.scroll_offset } /// Scrolls the viewport down (toward live terminal) by n lines. /// Returns the new scroll offset. /// Note: Scrollback is disabled in alternate screen mode. pub fn scroll_viewport_down(&mut self, n: usize) -> usize { // Alternate screen has no scrollback if self.using_alternate_screen { return 0; } let new_offset = self.scroll_offset.saturating_sub(n); if new_offset != self.scroll_offset { self.scroll_offset = new_offset; self.dirty = true; self.mark_all_lines_dirty(); // All visible content changes when scrolling } self.scroll_offset } /// Resets viewport to show live terminal (scroll_offset = 0). pub fn reset_scroll(&mut self) { if self.scroll_offset != 0 { self.scroll_offset = 0; self.dirty = true; self.mark_all_lines_dirty(); // All visible content changes when scrolling } } /// Scroll the viewport by the given number of lines. /// Positive values scroll up (into history), negative values scroll down (toward live). pub fn scroll(&mut self, lines: i32) { if lines > 0 { self.scroll_viewport_up(lines as usize); } else if lines < 0 { self.scroll_viewport_down((-lines) as usize); } } /// Encode a mouse event based on current tracking mode and encoding. /// Returns the escape sequence to send to the application, or empty vec if no tracking. pub fn encode_mouse( &self, button: u8, col: u16, row: u16, pressed: bool, is_motion: bool, modifiers: u8, ) -> Vec { // Check if we should report this event based on tracking mode match self.mouse_tracking { MouseTrackingMode::None => return Vec::new(), MouseTrackingMode::X10 => { // X10 only reports button press, not release or motion if !pressed || is_motion { return Vec::new(); } } MouseTrackingMode::Normal => { // Normal reports press and release, not motion if is_motion { return Vec::new(); } } MouseTrackingMode::ButtonEvent => { // Button-event reports press, release, and motion while button pressed // The is_motion flag indicates motion events } MouseTrackingMode::AnyEvent => { // Any-event reports all motion } } // Build the button code // Bits 0-1: button (0=left, 1=middle, 2=right, 3=release) // Bit 2: shift // Bit 3: meta/alt // Bit 4: control // Bits 5-6: 00=press, 01=motion with button, 10=scroll let mut cb = button; // Handle release if !pressed && !is_motion { // For SGR encoding, we keep the button number // For X10/UTF-8 encoding, release is button 3 if self.mouse_encoding != MouseEncoding::Sgr { cb = 3; } } // Add modifiers cb |= modifiers << 2; // Add motion flag if is_motion { cb |= 32; } // Convert to 1-based coordinates let col = col.saturating_add(1); let row = row.saturating_add(1); match self.mouse_encoding { MouseEncoding::X10 => { // X10 encoding: ESC [ M Cb Cx Cy // Each value is encoded as a byte with 32 added // Limited to 223 columns/rows let cb = (cb + 32).min(255); let cx = ((col as u8).min(223) + 32).min(255); let cy = ((row as u8).min(223) + 32).min(255); vec![0x1b, b'[', b'M', cb, cx, cy] } MouseEncoding::Utf8 => { // UTF-8 encoding: ESC [ M Cb Cx Cy // Values > 127 are UTF-8 encoded // This is deprecated and rarely used let cb = cb + 32; let cx = (col as u8).saturating_add(32); let cy = (row as u8).saturating_add(32); vec![0x1b, b'[', b'M', cb, cx, cy] } MouseEncoding::Sgr => { // SGR encoding: ESC [ < Cb ; Cx ; Cy M/m // M for press, m for release // Most modern and recommended format let suffix = if pressed { b'M' } else { b'm' }; format!("\x1b[<{};{};{}{}", cb, col, row, suffix as char).into_bytes() } MouseEncoding::Urxvt => { // URXVT encoding: ESC [ Cb ; Cx ; Cy M // Similar to SGR but uses decimal with offset let cb = cb + 32; format!("\x1b[{};{};{}M", cb, col, row).into_bytes() } } } /// Returns the visible rows accounting for scroll offset. /// This combines scrollback lines with the current grid. pub fn visible_rows(&self) -> Vec<&Vec> { let mut rows = Vec::with_capacity(self.rows); if self.scroll_offset == 0 { // No scrollback viewing, just return the grid via line_map for visual_row in 0..self.rows { rows.push(&self.grid[self.line_map[visual_row]]); } } else { // We're viewing scrollback // scroll_offset = how many lines back we're looking let scrollback_len = self.scrollback.len(); for i in 0..self.rows { // Calculate which line to show // If scroll_offset = 5, we want to show 5 lines from scrollback at the top let lines_from_scrollback = self.scroll_offset.min(self.rows); if i < lines_from_scrollback { // This row comes from scrollback // Use ring buffer's get() method with logical index let scrollback_idx = scrollback_len - self.scroll_offset + i; if let Some(line) = self.scrollback.get(scrollback_idx) { rows.push(line); } else { // Shouldn't happen, but fall back to grid rows.push(&self.grid[self.line_map[i]]); } } else { // This row comes from the grid let grid_visual_idx = i - lines_from_scrollback; if grid_visual_idx < self.rows { rows.push(&self.grid[self.line_map[grid_visual_idx]]); } } } } rows } /// Inserts n blank lines at the cursor position, scrolling lines below down. /// Uses line_map rotation for efficiency. fn insert_lines(&mut self, n: usize) { if self.cursor_row < self.scroll_top || self.cursor_row > self.scroll_bottom { return; } let n = n.min(self.scroll_bottom - self.cursor_row + 1); for _ in 0..n { // Save the bottom line's grid index before rotation let recycled_grid_row = self.line_map[self.scroll_bottom]; // Rotate line_map: shift lines from cursor to bottom down by 1 // The bottom line becomes the new line at cursor position for i in (self.cursor_row + 1..=self.scroll_bottom).rev() { self.line_map[i] = self.line_map[i - 1]; } self.line_map[self.cursor_row] = recycled_grid_row; // Clear the recycled line (now at cursor position) self.clear_grid_row(recycled_grid_row); // Mark affected lines dirty for line in self.cursor_row..=self.scroll_bottom { self.mark_line_dirty(line); } } } /// Deletes n lines at the cursor position, scrolling lines below up. /// Uses line_map rotation for efficiency. fn delete_lines(&mut self, n: usize) { if self.cursor_row < self.scroll_top || self.cursor_row > self.scroll_bottom { return; } let n = n.min(self.scroll_bottom - self.cursor_row + 1); for _ in 0..n { // Save the line at cursor's grid index before rotation let recycled_grid_row = self.line_map[self.cursor_row]; // Rotate line_map: shift lines from cursor to bottom up by 1 // The cursor line becomes the new bottom line for i in self.cursor_row..self.scroll_bottom { self.line_map[i] = self.line_map[i + 1]; } self.line_map[self.scroll_bottom] = recycled_grid_row; // Clear the recycled line (now at bottom of scroll region) self.clear_grid_row(recycled_grid_row); // Mark affected lines dirty for line in self.cursor_row..=self.scroll_bottom { self.mark_line_dirty(line); } } } /// Inserts n blank characters at the cursor, shifting existing chars right. fn insert_characters(&mut self, n: usize) { let grid_row = self.line_map[self.cursor_row]; let blank = self.blank_cell(); let row = &mut self.grid[grid_row]; let n = n.min(self.cols - self.cursor_col); // Remove n characters from the end for _ in 0..n { row.pop(); } // Insert n blank characters at cursor position for _ in 0..n { row.insert(self.cursor_col, blank); } self.mark_line_dirty(self.cursor_row); } /// Deletes n characters at the cursor, shifting remaining chars left. fn delete_characters(&mut self, n: usize) { let grid_row = self.line_map[self.cursor_row]; let blank = self.blank_cell(); let row = &mut self.grid[grid_row]; let n = n.min(self.cols - self.cursor_col); // Remove n characters at cursor position for _ in 0..n { if self.cursor_col < row.len() { row.remove(self.cursor_col); } } // Pad with blank characters at the end while row.len() < self.cols { row.push(blank); } self.mark_line_dirty(self.cursor_row); } /// Erases n characters at the cursor (replaces with spaces, doesn't shift). fn erase_characters(&mut self, n: usize) { let grid_row = self.line_map[self.cursor_row]; let n = n.min(self.cols - self.cursor_col); let blank = self.blank_cell(); for i in 0..n { if self.cursor_col + i < self.cols { self.grid[grid_row][self.cursor_col + i] = blank; } } self.mark_line_dirty(self.cursor_row); } /// Clears the current line from cursor to end. fn clear_line_from_cursor(&mut self) { let grid_row = self.line_map[self.cursor_row]; let blank = self.blank_cell(); for col in self.cursor_col..self.cols { self.grid[grid_row][col] = blank; } self.mark_line_dirty(self.cursor_row); } /// Clears the entire screen, pushing current content to scrollback first (main screen only). fn clear_screen(&mut self) { // Push all visible lines to scrollback before clearing // This preserves the content in history so the user can scroll back to see it // BUT: Only do this for main screen, not alternate screen // AND only if scrollback is enabled if !self.using_alternate_screen && self.scrollback.capacity > 0 { for visual_row in 0..self.rows { let grid_row = self.line_map[visual_row]; // Get a slot in the ring buffer and swap content into it let cols = self.cols; let dest = self.scrollback.push(cols); std::mem::swap(&mut self.grid[grid_row], dest); } } // Now clear the grid with BCE let blank = self.blank_cell(); for row in &mut self.grid { row.fill(blank); } self.mark_all_lines_dirty(); self.cursor_col = 0; self.cursor_row = 0; } } impl Handler for Terminal { /// Handle a chunk of decoded text (Unicode codepoints). /// This includes control characters (0x00-0x1F except ESC). fn text(&mut self, chars: &[char]) { // Cache the current line to avoid repeated line_map lookups let mut cached_row = self.cursor_row; let mut grid_row = self.line_map[cached_row]; // Mark the initial line as dirty (like Kitty's init_text_loop_line) self.mark_line_dirty(cached_row); for &c in chars { match c { // Bell '\x07' => { // BEL - ignore for now (could trigger visual bell) } // Backspace '\x08' => { if self.cursor_col > 0 { self.cursor_col -= 1; } } // Tab '\x09' => { let next_tab = (self.cursor_col / 8 + 1) * 8; self.cursor_col = next_tab.min(self.cols - 1); } // Line feed, Vertical tab, Form feed '\x0A' | '\x0B' | '\x0C' => { let old_row = self.cursor_row; self.cursor_row += 1; if self.cursor_row > self.scroll_bottom { self.scroll_up(1); self.cursor_row = self.scroll_bottom; log::trace!("LF: scrolled at row {}, now at scroll_bottom {}", old_row, self.cursor_row); } // Update cache after line change cached_row = self.cursor_row; grid_row = self.line_map[cached_row]; // Mark the new line as dirty self.mark_line_dirty(cached_row); } // Carriage return '\x0D' => { self.cursor_col = 0; } // Fast path for printable ASCII (0x20-0x7E) - like Kitty // ASCII is always width 1, never zero-width, never wide c if c >= ' ' && c <= '~' => { // Handle wrap if self.cursor_col >= self.cols { if self.auto_wrap { self.cursor_col = 0; self.cursor_row += 1; if self.cursor_row > self.scroll_bottom { self.scroll_up(1); self.cursor_row = self.scroll_bottom; } cached_row = self.cursor_row; grid_row = self.line_map[cached_row]; self.mark_line_dirty(cached_row); } else { self.cursor_col = self.cols - 1; } } // Write character directly - no wide char handling needed for ASCII self.grid[grid_row][self.cursor_col] = Cell { character: c, fg_color: self.current_fg, bg_color: self.current_bg, bold: self.current_bold, italic: self.current_italic, underline_style: self.current_underline_style, strikethrough: self.current_strikethrough, wide_continuation: false, }; self.cursor_col += 1; } // Slow path for non-ASCII printable characters (including all Unicode) c if c > '~' => { // Determine character width using Unicode Standard Annex #11 let char_width = c.width().unwrap_or(1); // Skip zero-width characters (combining marks, etc.) if char_width == 0 { // TODO: Handle combining characters continue; } // Handle wrap if self.cursor_col >= self.cols { if self.auto_wrap { self.cursor_col = 0; self.cursor_row += 1; if self.cursor_row > self.scroll_bottom { self.scroll_up(1); self.cursor_row = self.scroll_bottom; } // Update cache after line change cached_row = self.cursor_row; grid_row = self.line_map[cached_row]; // Mark the new line as dirty self.mark_line_dirty(cached_row); } else { self.cursor_col = self.cols - 1; } } // For double-width characters at end of line, wrap first if char_width == 2 && self.cursor_col == self.cols - 1 { if self.auto_wrap { self.grid[grid_row][self.cursor_col] = Cell::default(); self.cursor_col = 0; self.cursor_row += 1; if self.cursor_row > self.scroll_bottom { self.scroll_up(1); self.cursor_row = self.scroll_bottom; } cached_row = self.cursor_row; grid_row = self.line_map[cached_row]; // Mark the new line as dirty self.mark_line_dirty(cached_row); } else { continue; // Can't fit } } // Write character directly using cached grid_row // Safety: ensure grid row has correct width (may differ after scrollback swap) if self.grid[grid_row].len() != self.cols { self.grid[grid_row].resize(self.cols, Cell::default()); } // Handle overwriting wide character cells if self.grid[grid_row][self.cursor_col].wide_continuation && self.cursor_col > 0 { self.grid[grid_row][self.cursor_col - 1] = Cell::default(); } if char_width == 1 && self.cursor_col + 1 < self.cols && self.grid[grid_row][self.cursor_col + 1].wide_continuation { self.grid[grid_row][self.cursor_col + 1] = Cell::default(); } self.grid[grid_row][self.cursor_col] = Cell { character: c, fg_color: self.current_fg, bg_color: self.current_bg, bold: self.current_bold, italic: self.current_italic, underline_style: self.current_underline_style, strikethrough: self.current_strikethrough, wide_continuation: false, }; self.cursor_col += 1; // For double-width, write continuation cell if char_width == 2 && self.cursor_col < self.cols { if self.cursor_col + 1 < self.cols && self.grid[grid_row][self.cursor_col + 1].wide_continuation { self.grid[grid_row][self.cursor_col + 1] = Cell::default(); } self.grid[grid_row][self.cursor_col] = Cell { character: c, fg_color: self.current_fg, bg_color: self.current_bg, bold: self.current_bold, italic: self.current_italic, underline_style: self.current_underline_style, strikethrough: self.current_strikethrough, wide_continuation: false, }; self.cursor_col += 1; } } // Other control chars - ignore _ => {} } } // Dirty lines are marked incrementally above - no need for mark_all_lines_dirty() } /// Handle control characters embedded in escape sequences. fn control(&mut self, byte: u8) { match byte { 0x08 => { if self.cursor_col > 0 { self.cursor_col -= 1; } } 0x09 => { let next_tab = (self.cursor_col / 8 + 1) * 8; self.cursor_col = next_tab.min(self.cols - 1); } 0x0A | 0x0B | 0x0C => { self.cursor_row += 1; if self.cursor_row > self.scroll_bottom { self.scroll_up(1); self.cursor_row = self.scroll_bottom; } } 0x0D => { self.cursor_col = 0; } _ => {} } } /// Handle a complete OSC sequence. fn osc(&mut self, data: &[u8]) { // Parse OSC format: "number;content" or "number;arg;content" // Split on ';' let parts: Vec<&[u8]> = data.splitn(3, |&b| b == b';').collect(); if parts.is_empty() { return; } // First part is the OSC number let osc_num = match std::str::from_utf8(parts[0]) { Ok(s) => s.parse::().unwrap_or(u32::MAX), Err(_) => return, }; match osc_num { // OSC 0, 1, 2 - Set window title (ignore for now) 0 | 1 | 2 => {} // OSC 4 - Set/query indexed color 4 => { // Format: OSC 4;index;color ST if parts.len() >= 3 { if let Ok(index_str) = std::str::from_utf8(parts[1]) { if let Ok(index) = index_str.parse::() { if let Ok(color_spec) = std::str::from_utf8(parts[2]) { if let Some(rgb) = ColorPalette::parse_color_spec(color_spec) { self.palette.colors[index as usize] = rgb; log::debug!("OSC 4: Set color {} to {:?}", index, rgb); } } } } } } // OSC 10 - Set/query default foreground color 10 => { if parts.len() >= 2 { if let Ok(color_spec) = std::str::from_utf8(parts[1]) { if let Some(rgb) = ColorPalette::parse_color_spec(color_spec) { self.palette.default_fg = rgb; log::debug!("OSC 10: Set default foreground to {:?}", rgb); } } } } // OSC 11 - Set/query default background color 11 => { if parts.len() >= 2 { if let Ok(color_spec) = std::str::from_utf8(parts[1]) { if let Some(rgb) = ColorPalette::parse_color_spec(color_spec) { self.palette.default_bg = rgb; log::debug!("OSC 11: Set default background to {:?}", rgb); } } } } // OSC 51 - ZTerm custom commands // Format: OSC 51;command;args ST // Currently supported: // OSC 51;navigate;up/down/left/right ST - Navigate to neighboring pane // OSC 51;statusline; ST - Set custom statusline (empty to clear) 51 => { if parts.len() >= 2 { if let Ok(command) = std::str::from_utf8(parts[1]) { match command { "navigate" => { if parts.len() >= 3 { if let Ok(direction_str) = std::str::from_utf8(parts[2]) { let direction = match direction_str { "up" => Some(Direction::Up), "down" => Some(Direction::Down), "left" => Some(Direction::Left), "right" => Some(Direction::Right), _ => None, }; if let Some(dir) = direction { log::debug!("OSC 51: Navigate {:?}", dir); self.command_queue.push(TerminalCommand::NavigatePane(dir)); } } } } "statusline" => { // OSC 51;statusline; ST // If content is empty or missing, clear the statusline // Content may be base64-encoded (prefixed with "b64:") to avoid // escape sequence interpretation issues in the terminal let prefix = b"51;statusline;"; let raw_content = if data.len() > prefix.len() && data.starts_with(prefix) { std::str::from_utf8(&data[prefix.len()..]).ok().map(|s| s.to_string()) } else if parts.len() >= 3 { std::str::from_utf8(parts[2]).ok().map(|s| s.to_string()) } else { None }; // Decode base64 if prefixed with "b64:" let content = raw_content.and_then(|s| { if let Some(encoded) = s.strip_prefix("b64:") { use base64::Engine; base64::engine::general_purpose::STANDARD .decode(encoded) .ok() .and_then(|bytes| String::from_utf8(bytes).ok()) } else { Some(s) } }); let statusline = content.filter(|s| !s.is_empty()); log::info!("OSC 51: Set statusline: {:?}", statusline.as_ref().map(|s| format!("{} bytes", s.len()))); self.command_queue.push(TerminalCommand::SetStatusline(statusline)); } _ => { log::debug!("OSC 51: Unknown command '{}'", command); } } } } } _ => { log::debug!("Unhandled OSC {}", osc_num); } } } /// Handle an APC (Application Program Command) sequence. /// Used for Kitty graphics protocol. fn apc(&mut self, data: &[u8]) { self.handle_apc(data); } /// Handle a DCS (Device Control String) sequence. /// Used for pending mode (synchronized output via DCS). fn dcs(&mut self, data: &[u8]) { // DCS pending mode: =1s to start, =2s to stop // This is an alternative to CSI 2026 for synchronized output if data.len() >= 3 && data[0] == b'=' && data[2] == b's' { match data[1] { b'1' => { // Start pending mode (pause rendering) if self.synchronized_output { log::warn!("Pending mode start requested while already in pending mode"); } self.synchronized_output = true; log::trace!("DCS pending mode started (=1s)"); } b'2' => { // Stop pending mode (resume rendering) if !self.synchronized_output { log::warn!("Pending mode stop requested while not in pending mode"); } self.synchronized_output = false; self.dirty = true; // Force a redraw log::trace!("DCS pending mode stopped (=2s)"); } _ => { log::debug!("Unknown DCS pending mode command: {:?}", data); } } } else { log::debug!("Unhandled DCS sequence: {:?}", std::str::from_utf8(data).unwrap_or("")); } } /// Handle a complete CSI sequence. fn csi(&mut self, params: &CsiParams) { let action = params.final_char as char; let primary = params.primary; let secondary = params.secondary; match action { // Cursor Up 'A' => { let n = params.get(0, 1).max(1) as usize; let old_row = self.cursor_row; self.cursor_row = self.cursor_row.saturating_sub(n); log::trace!("CSI A: cursor up {} from row {} to {}", n, old_row, self.cursor_row); } // Cursor Down 'B' => { let n = params.get(0, 1).max(1) as usize; let old_row = self.cursor_row; self.cursor_row = (self.cursor_row + n).min(self.rows - 1); log::trace!("CSI B: cursor down {} from row {} to {}", n, old_row, self.cursor_row); } // Cursor Forward 'C' => { let n = params.get(0, 1).max(1) as usize; let old_col = self.cursor_col; self.cursor_col = (self.cursor_col + n).min(self.cols - 1); log::trace!("CSI C: cursor forward {} from col {} to {}", n, old_col, self.cursor_col); } // Cursor Back 'D' => { let n = params.get(0, 1).max(1) as usize; self.cursor_col = self.cursor_col.saturating_sub(n); } // Cursor Next Line (CNL) 'E' => { let n = params.get(0, 1).max(1) as usize; self.cursor_col = 0; self.cursor_row = (self.cursor_row + n).min(self.rows - 1); } // Cursor Previous Line (CPL) 'F' => { let n = params.get(0, 1).max(1) as usize; self.cursor_col = 0; self.cursor_row = self.cursor_row.saturating_sub(n); } // Cursor Horizontal Absolute (CHA) 'G' => { let col = params.get(0, 1).max(1) as usize; let old_col = self.cursor_col; self.cursor_col = (col - 1).min(self.cols - 1); log::trace!("CSI G: cursor to col {} (was {})", self.cursor_col, old_col); } // Cursor Position 'H' | 'f' => { let row = params.get(0, 1).max(1) as usize; let col = params.get(1, 1).max(1) as usize; self.cursor_row = (row - 1).min(self.rows - 1); self.cursor_col = (col - 1).min(self.cols - 1); } // Erase in Display 'J' => { let mode = params.get(0, 0); let blank = self.blank_cell(); match mode { 0 => { // Clear from cursor to end of screen self.clear_line_from_cursor(); for visual_row in (self.cursor_row + 1)..self.rows { let grid_row = self.line_map[visual_row]; self.grid[grid_row].fill(blank); self.mark_line_dirty(visual_row); } } 1 => { // Clear from start to cursor for visual_row in 0..self.cursor_row { let grid_row = self.line_map[visual_row]; self.grid[grid_row].fill(blank); self.mark_line_dirty(visual_row); } let cursor_grid_row = self.line_map[self.cursor_row]; for col in 0..=self.cursor_col { self.grid[cursor_grid_row][col] = blank; } self.mark_line_dirty(self.cursor_row); } 2 | 3 => { // Clear entire screen self.clear_screen(); } _ => {} } } // Erase in Line 'K' => { let mode = params.get(0, 0); let blank = self.blank_cell(); match mode { 0 => self.clear_line_from_cursor(), 1 => { let grid_row = self.line_map[self.cursor_row]; for col in 0..=self.cursor_col { self.grid[grid_row][col] = blank; } self.mark_line_dirty(self.cursor_row); } 2 => { let grid_row = self.line_map[self.cursor_row]; self.grid[grid_row].fill(blank); self.mark_line_dirty(self.cursor_row); } _ => {} } } // Insert Lines (IL) 'L' => { let n = params.get(0, 1).max(1) as usize; self.insert_lines(n); } // Delete Lines (DL) 'M' => { let n = params.get(0, 1).max(1) as usize; self.delete_lines(n); } // Delete Characters (DCH) 'P' => { let n = params.get(0, 1).max(1) as usize; self.delete_characters(n); } // Scroll Up (SU) 'S' => { let n = params.get(0, 1).max(1) as usize; self.scroll_up(n); } // Scroll Down (SD) 'T' => { let n = params.get(0, 1).max(1) as usize; self.scroll_down(n); } // Erase Characters (ECH) 'X' => { let n = params.get(0, 1).max(1) as usize; self.erase_characters(n); } // Insert Characters (ICH) '@' => { let n = params.get(0, 1).max(1) as usize; self.insert_characters(n); } // Repeat preceding character (REP) 'b' => { let n = params.get(0, 1).max(1) as usize; if self.cursor_col > 0 { let grid_row = self.line_map[self.cursor_row]; let last_char = self.grid[grid_row][self.cursor_col - 1].character; for _ in 0..n { self.print_char(last_char); } } } // Device Attributes (DA) 'c' => { if primary == 0 || primary == b'?' { // Primary DA - respond as VT220 self.response_queue.extend_from_slice(b"\x1b[?62;c"); } else if primary == b'>' { // Secondary DA - respond with terminal version self.response_queue.extend_from_slice(b"\x1b[>0;0;0c"); } } // Vertical Position Absolute (VPA) 'd' => { let row = params.get(0, 1).max(1) as usize; self.cursor_row = (row - 1).min(self.rows - 1); } // SGR (Select Graphic Rendition) 'm' => { self.handle_sgr(params); } // Device Status Report (DSR) 'n' => { let param = params.get(0, 0); match param { 5 => { // Status report - respond with "OK" self.response_queue.extend_from_slice(b"\x1b[0n"); } 6 => { // Cursor position report let response = format!("\x1b[{};{}R", self.cursor_row + 1, self.cursor_col + 1); self.response_queue.extend_from_slice(response.as_bytes()); } _ => {} } } // DECSCUSR - Set Cursor Style (CSI Ps SP q) // Also handle CSI q (no space) as reset to default 'q' => { if secondary == b' ' || secondary == 0 { let style = params.get(0, 0); self.cursor_shape = match style { 0 | 1 => CursorShape::BlinkingBlock, 2 => CursorShape::SteadyBlock, 3 => CursorShape::BlinkingUnderline, 4 => CursorShape::SteadyUnderline, 5 => CursorShape::BlinkingBar, 6 => CursorShape::SteadyBar, _ => CursorShape::BlinkingBlock, }; } } // Set Scrolling Region (DECSTBM) 'r' => { let top = params.get(0, 1).max(1) as usize; let bottom = params.get(1, self.rows as i32).max(1) as usize; self.scroll_top = (top - 1).min(self.rows - 1); self.scroll_bottom = (bottom - 1).min(self.rows - 1); if self.scroll_top > self.scroll_bottom { std::mem::swap(&mut self.scroll_top, &mut self.scroll_bottom); } // Move cursor to home position self.cursor_row = 0; self.cursor_col = 0; } // Window manipulation (CSI Ps t) - XTWINOPS 't' => { let ps = params.get(0, 0); match ps { 14 => { // Report text area size in pixels: CSI 4 ; height ; width t let pixel_height = (self.rows as f32 * self.cell_height) as u32; let pixel_width = (self.cols as f32 * self.cell_width) as u32; let response = format!("\x1b[4;{};{}t", pixel_height, pixel_width); self.response_queue.extend_from_slice(response.as_bytes()); log::debug!("XTWINOPS 14: Reported text area size {}x{} pixels", pixel_width, pixel_height); } 16 => { // Report cell size in pixels: CSI 6 ; height ; width t let cell_h = self.cell_height as u32; let cell_w = self.cell_width as u32; let response = format!("\x1b[6;{};{}t", cell_h, cell_w); self.response_queue.extend_from_slice(response.as_bytes()); log::debug!("XTWINOPS 16: Reported cell size {}x{} pixels", cell_w, cell_h); } 18 => { // Report text area size in characters: CSI 8 ; rows ; cols t let response = format!("\x1b[8;{};{}t", self.rows, self.cols); self.response_queue.extend_from_slice(response.as_bytes()); log::debug!("XTWINOPS 18: Reported text area size {}x{} chars", self.cols, self.rows); } 22 | 23 => { // Save/restore window title - ignore } _ => { log::trace!("Window manipulation: ps={}", ps); } } } // Kitty keyboard protocol 'u' => { self.handle_keyboard_protocol_csi(params); } // DEC Private Mode Set (CSI ? Ps h) 'h' if primary == b'?' => { self.handle_dec_private_mode_set(params); } // DEC Private Mode Reset (CSI ? Ps l) 'l' if primary == b'?' => { self.handle_dec_private_mode_reset(params); } _ => { log::debug!( "Unhandled CSI: action='{}' primary={} secondary={} params={:?}", action, primary, secondary, ¶ms.params[..params.num_params] ); } } } fn save_cursor(&mut self) { self.saved_cursor = SavedCursor { col: self.cursor_col, row: self.cursor_row, fg: self.current_fg, bg: self.current_bg, bold: self.current_bold, italic: self.current_italic, underline_style: self.current_underline_style, strikethrough: self.current_strikethrough, }; log::debug!("ESC 7: Cursor saved at ({}, {})", self.cursor_col, self.cursor_row); } fn restore_cursor(&mut self) { self.cursor_col = self.saved_cursor.col.min(self.cols.saturating_sub(1)); self.cursor_row = self.saved_cursor.row.min(self.rows.saturating_sub(1)); self.current_fg = self.saved_cursor.fg; self.current_bg = self.saved_cursor.bg; self.current_bold = self.saved_cursor.bold; self.current_italic = self.saved_cursor.italic; self.current_underline_style = self.saved_cursor.underline_style; self.current_strikethrough = self.saved_cursor.strikethrough; log::debug!("ESC 8: Cursor restored to ({}, {})", self.cursor_col, self.cursor_row); } fn reset(&mut self) { self.current_fg = Color::Default; self.current_bg = Color::Default; self.current_bold = false; self.current_italic = false; self.current_underline_style = 0; self.current_strikethrough = false; self.cursor_col = 0; self.cursor_row = 0; self.cursor_visible = true; self.cursor_shape = CursorShape::default(); self.scroll_top = 0; self.scroll_bottom = self.rows.saturating_sub(1); self.mouse_tracking = MouseTrackingMode::None; self.mouse_encoding = MouseEncoding::X10; self.application_cursor_keys = false; self.auto_wrap = true; self.bracketed_paste = false; self.focus_reporting = false; self.synchronized_output = false; if self.using_alternate_screen { self.leave_alternate_screen(); } for row in &mut self.grid { for cell in row { *cell = Cell::default(); } } self.mark_all_lines_dirty(); log::debug!("ESC c: Full terminal reset"); } fn index(&mut self) { if self.cursor_row >= self.scroll_bottom { self.scroll_up(1); } else { self.cursor_row += 1; } } fn newline(&mut self) { self.cursor_col = 0; if self.cursor_row >= self.scroll_bottom { self.scroll_up(1); } else { self.cursor_row += 1; } } fn reverse_index(&mut self) { if self.cursor_row <= self.scroll_top { self.scroll_down(1); } else { self.cursor_row -= 1; } } fn set_tab_stop(&mut self) { // HTS - default tab stops every 8 columns } fn set_keypad_mode(&mut self, application: bool) { if application { log::debug!("ESC =: Application keypad mode"); } else { log::debug!("ESC >: Normal keypad mode"); } } fn designate_charset(&mut self, _set: u8, _charset: u8) { // UTF-8 internally, no-op } fn screen_alignment(&mut self) { for visual_row in 0..self.rows { let grid_row = self.line_map[visual_row]; for cell in &mut self.grid[grid_row] { *cell = Cell { character: 'E', fg_color: Color::Default, bg_color: Color::Default, bold: false, italic: false, underline_style: 0, strikethrough: false, wide_continuation: false, }; } self.mark_line_dirty(visual_row); } } } impl Terminal { /// Print a single character at the cursor position. /// Handles double-width characters (emoji, CJK) by occupying two cells. #[inline] fn print_char(&mut self, c: char) { // Determine character width using Unicode Standard Annex #11 // Width 2 = double-width (emoji, CJK, etc.) // Width 1 = normal width // Width 0 = combining/non-spacing marks (handled separately) let char_width = c.width().unwrap_or(1); // Skip zero-width characters (combining marks, etc.) if char_width == 0 { // TODO: Handle combining characters by attaching to previous cell return; } // Check if we need to wrap before printing if self.cursor_col >= self.cols { if self.auto_wrap { self.cursor_col = 0; self.cursor_row += 1; if self.cursor_row > self.scroll_bottom { self.scroll_up(1); self.cursor_row = self.scroll_bottom; } } else { self.cursor_col = self.cols - 1; } } // For double-width characters, check if there's room // If at the last column, we need to wrap first if char_width == 2 && self.cursor_col == self.cols - 1 { if self.auto_wrap { // Write a space in the last column and wrap let grid_row = self.line_map[self.cursor_row]; self.grid[grid_row][self.cursor_col] = Cell::default(); self.cursor_col = 0; self.cursor_row += 1; if self.cursor_row > self.scroll_bottom { self.scroll_up(1); self.cursor_row = self.scroll_bottom; } } else { // Can't fit, don't print return; } } let grid_row = self.line_map[self.cursor_row]; // If we're overwriting a wide character's continuation cell, // we need to clear the first cell of that wide character if self.grid[grid_row][self.cursor_col].wide_continuation && self.cursor_col > 0 { self.grid[grid_row][self.cursor_col - 1] = Cell::default(); } // If we're overwriting the first cell of a wide character, // we need to clear its continuation cell if char_width == 1 && self.cursor_col + 1 < self.cols && self.grid[grid_row][self.cursor_col + 1].wide_continuation { self.grid[grid_row][self.cursor_col + 1] = Cell::default(); } // Write the character to the first cell self.grid[grid_row][self.cursor_col] = Cell { character: c, fg_color: self.current_fg, bg_color: self.current_bg, bold: self.current_bold, italic: self.current_italic, underline_style: self.current_underline_style, strikethrough: self.current_strikethrough, wide_continuation: false, }; self.mark_line_dirty(self.cursor_row); self.cursor_col += 1; // For double-width characters, write a continuation marker to the second cell if char_width == 2 && self.cursor_col < self.cols { // If the next cell is the first cell of another wide character, // clear its continuation cell if self.cursor_col + 1 < self.cols && self.grid[grid_row][self.cursor_col + 1].wide_continuation { self.grid[grid_row][self.cursor_col + 1] = Cell::default(); } self.grid[grid_row][self.cursor_col] = Cell { character: ' ', // Placeholder - renderer will skip this fg_color: self.current_fg, bg_color: self.current_bg, bold: self.current_bold, italic: self.current_italic, underline_style: self.current_underline_style, strikethrough: self.current_strikethrough, wide_continuation: true, }; self.cursor_col += 1; } } /// Handle SGR (Select Graphic Rendition) parameters. fn handle_sgr(&mut self, params: &CsiParams) { if params.num_params == 0 { self.current_fg = Color::Default; self.current_bg = Color::Default; self.current_bold = false; self.current_italic = false; self.current_underline_style = 0; self.current_strikethrough = false; return; } let mut i = 0; while i < params.num_params { let code = params.params[i]; match code { 0 => { self.current_fg = Color::Default; self.current_bg = Color::Default; self.current_bold = false; self.current_italic = false; self.current_underline_style = 0; self.current_strikethrough = false; } 1 => self.current_bold = true, 3 => self.current_italic = true, 4 => { // Check for sub-parameter (4:x format for underline style) if i + 1 < params.num_params && params.is_sub_param[i + 1] { let style = params.params[i + 1]; // 0=none, 1=single, 2=double, 3=curly, 4=dotted, 5=dashed self.current_underline_style = (style as u8).min(5); i += 1; } else { // Plain SGR 4 = single underline self.current_underline_style = 1; } } 7 => std::mem::swap(&mut self.current_fg, &mut self.current_bg), 9 => self.current_strikethrough = true, 22 => self.current_bold = false, 23 => self.current_italic = false, 24 => self.current_underline_style = 0, 27 => std::mem::swap(&mut self.current_fg, &mut self.current_bg), 29 => self.current_strikethrough = false, 30..=37 => self.current_fg = Color::Indexed((code - 30) as u8), 38 => { // Extended foreground color if i + 1 < params.num_params && params.is_sub_param[i + 1] { let mode = params.params[i + 1]; if mode == 5 && i + 2 < params.num_params { self.current_fg = Color::Indexed(params.params[i + 2] as u8); i += 2; } else if mode == 2 && i + 4 < params.num_params { self.current_fg = Color::Rgb( params.params[i + 2] as u8, params.params[i + 3] as u8, params.params[i + 4] as u8, ); i += 4; } } else if i + 2 < params.num_params { let mode = params.params[i + 1]; if mode == 5 { self.current_fg = Color::Indexed(params.params[i + 2] as u8); i += 2; } else if mode == 2 && i + 4 < params.num_params { self.current_fg = Color::Rgb( params.params[i + 2] as u8, params.params[i + 3] as u8, params.params[i + 4] as u8, ); i += 4; } } } 39 => self.current_fg = Color::Default, 40..=47 => self.current_bg = Color::Indexed((code - 40) as u8), 48 => { // Extended background color if i + 1 < params.num_params && params.is_sub_param[i + 1] { let mode = params.params[i + 1]; if mode == 5 && i + 2 < params.num_params { self.current_bg = Color::Indexed(params.params[i + 2] as u8); i += 2; } else if mode == 2 && i + 4 < params.num_params { self.current_bg = Color::Rgb( params.params[i + 2] as u8, params.params[i + 3] as u8, params.params[i + 4] as u8, ); i += 4; } } else if i + 2 < params.num_params { let mode = params.params[i + 1]; if mode == 5 { self.current_bg = Color::Indexed(params.params[i + 2] as u8); i += 2; } else if mode == 2 && i + 4 < params.num_params { self.current_bg = Color::Rgb( params.params[i + 2] as u8, params.params[i + 3] as u8, params.params[i + 4] as u8, ); i += 4; } } } 49 => self.current_bg = Color::Default, 90..=97 => self.current_fg = Color::Indexed((code - 90 + 8) as u8), 100..=107 => self.current_bg = Color::Indexed((code - 100 + 8) as u8), _ => {} } i += 1; } } /// Handle Kitty keyboard protocol CSI sequences. fn handle_keyboard_protocol_csi(&mut self, params: &CsiParams) { match params.primary { b'?' => { let response = query_response(self.keyboard.flags()); self.response_queue.extend(response); } b'=' => { let flags = params.get(0, 0) as u8; let mode = params.get(1, 1) as u8; self.keyboard.set_flags(flags, mode); log::debug!("Keyboard flags set to {:?} (mode {})", self.keyboard.flags(), mode); } b'>' => { let flags = if params.num_params == 0 { None } else { Some(params.params[0] as u8) }; self.keyboard.push(flags); log::debug!("Keyboard flags pushed: {:?}", self.keyboard.flags()); } b'<' => { let count = params.get(0, 1) as usize; self.keyboard.pop(count); log::debug!("Keyboard flags popped: {:?}", self.keyboard.flags()); } _ => {} } } /// Handle DEC private mode set (CSI ? Ps h). fn handle_dec_private_mode_set(&mut self, params: &CsiParams) { for i in 0..params.num_params { match params.params[i] { 1 => { self.application_cursor_keys = true; log::debug!("DECCKM: Application cursor keys enabled"); } 7 => { self.auto_wrap = true; log::debug!("DECAWM: Auto-wrap enabled"); } 9 => { self.mouse_tracking = MouseTrackingMode::X10; log::debug!("Mouse tracking: X10 mode enabled"); } 25 => { self.cursor_visible = true; log::debug!("DECTCEM: cursor visible"); } 47 => self.enter_alternate_screen(), 1000 => { self.mouse_tracking = MouseTrackingMode::Normal; log::debug!("Mouse tracking: Normal mode enabled"); } 1002 => { self.mouse_tracking = MouseTrackingMode::ButtonEvent; log::debug!("Mouse tracking: Button-event mode enabled"); } 1003 => { self.mouse_tracking = MouseTrackingMode::AnyEvent; log::debug!("Mouse tracking: Any-event mode enabled"); } 1004 => { self.focus_reporting = true; log::debug!("Focus event reporting enabled"); } 1005 => { self.mouse_encoding = MouseEncoding::Utf8; log::debug!("Mouse encoding: UTF-8"); } 1006 => { self.mouse_encoding = MouseEncoding::Sgr; log::debug!("Mouse encoding: SGR"); } 1015 => { self.mouse_encoding = MouseEncoding::Urxvt; log::debug!("Mouse encoding: URXVT"); } 1047 => self.enter_alternate_screen(), 1048 => Handler::save_cursor(self), 1049 => { Handler::save_cursor(self); self.enter_alternate_screen(); } 2004 => { self.bracketed_paste = true; log::debug!("Bracketed paste mode enabled"); } 2026 => { self.synchronized_output = true; log::trace!("Synchronized output enabled"); } _ => log::debug!("Unhandled DEC private mode set: {}", params.params[i]), } } } /// Handle DEC private mode reset (CSI ? Ps l). fn handle_dec_private_mode_reset(&mut self, params: &CsiParams) { for i in 0..params.num_params { match params.params[i] { 1 => { self.application_cursor_keys = false; log::debug!("DECCKM: Normal cursor keys enabled"); } 7 => { self.auto_wrap = false; log::debug!("DECAWM: Auto-wrap disabled"); } 9 => { if self.mouse_tracking == MouseTrackingMode::X10 { self.mouse_tracking = MouseTrackingMode::None; log::debug!("Mouse tracking: X10 mode disabled"); } } 25 => { self.cursor_visible = false; log::debug!("DECTCEM: cursor hidden"); } 47 => self.leave_alternate_screen(), 1000 => { if self.mouse_tracking == MouseTrackingMode::Normal { self.mouse_tracking = MouseTrackingMode::None; log::debug!("Mouse tracking: Normal mode disabled"); } } 1002 => { if self.mouse_tracking == MouseTrackingMode::ButtonEvent { self.mouse_tracking = MouseTrackingMode::None; log::debug!("Mouse tracking: Button-event mode disabled"); } } 1003 => { if self.mouse_tracking == MouseTrackingMode::AnyEvent { self.mouse_tracking = MouseTrackingMode::None; log::debug!("Mouse tracking: Any-event mode disabled"); } } 1004 => { self.focus_reporting = false; log::debug!("Focus event reporting disabled"); } 1005 => { if self.mouse_encoding == MouseEncoding::Utf8 { self.mouse_encoding = MouseEncoding::X10; log::debug!("Mouse encoding: reset to X10"); } } 1006 => { if self.mouse_encoding == MouseEncoding::Sgr { self.mouse_encoding = MouseEncoding::X10; log::debug!("Mouse encoding: reset to X10"); } } 1015 => { if self.mouse_encoding == MouseEncoding::Urxvt { self.mouse_encoding = MouseEncoding::X10; log::debug!("Mouse encoding: reset to X10"); } } 1047 => self.leave_alternate_screen(), 1048 => Handler::restore_cursor(self), 1049 => { self.leave_alternate_screen(); Handler::restore_cursor(self); } 2004 => { self.bracketed_paste = false; log::debug!("Bracketed paste mode disabled"); } 2026 => { self.synchronized_output = false; log::trace!("Synchronized output disabled"); } _ => log::debug!("Unhandled DEC private mode reset: {}", params.params[i]), } } } /// Set cell dimensions (called by renderer after font metrics are calculated). pub fn set_cell_size(&mut self, width: f32, height: f32) { self.cell_width = width; self.cell_height = height; } /// Handle an APC (Application Program Command) sequence. /// This is used for the Kitty graphics protocol. fn handle_apc(&mut self, data: &[u8]) { // Kitty graphics protocol: APC starts with 'G' if let Some(cmd) = GraphicsCommand::parse(data) { log::debug!( "Graphics command: action={:?} format={:?} id={:?} size={}x{:?} C={} U={}", cmd.action, cmd.format, cmd.image_id, cmd.width.unwrap_or(0), cmd.height, cmd.cursor_movement, cmd.unicode_placeholder ); // Convert cursor_row to absolute row (accounting for scrollback) // This allows images to scroll with terminal content let absolute_row = self.scrollback.len() + self.cursor_row; // Process the command let (response, placement_result) = self.image_storage.process_command( cmd, self.cursor_col, absolute_row, self.cell_width, self.cell_height, ); // Queue the response to send back to the application if let Some(resp) = response { self.response_queue.extend_from_slice(resp.as_bytes()); } // Move cursor after image placement per Kitty protocol spec: // "After placing an image on the screen the cursor must be moved to the // right by the number of cols in the image placement rectangle and down // by the number of rows in the image placement rectangle." // However, if C=1 was specified, don't move the cursor. if let Some(placement) = placement_result { if !placement.suppress_cursor_move && !placement.virtual_placement { // Move cursor down by (rows - 1) since we're already on the first row // Then set cursor to the column after the image let new_row = self.cursor_row + placement.rows.saturating_sub(1); if new_row >= self.rows { // Need to scroll let scroll_amount = new_row - self.rows + 1; self.scroll_up(scroll_amount); self.cursor_row = self.rows - 1; } else { self.cursor_row = new_row; } // Move cursor to after the image (or stay at column 0 of next line) // Per protocol, cursor ends at the last row of the image log::debug!( "Cursor moved after image placement: row={} (moved {} rows)", self.cursor_row, placement.rows.saturating_sub(1) ); } } } } }