2487 lines
99 KiB
Rust
2487 lines
99 KiB
Rust
//! 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;<direction> ST
|
|
NavigatePane(Direction),
|
|
/// Set custom statusline content for this pane.
|
|
/// Triggered by OSC 51;statusline;<content> ST
|
|
/// Empty content clears the statusline (restores default).
|
|
SetStatusline(Option<String>),
|
|
}
|
|
|
|
/// 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<u8> {
|
|
let val = u16::from_str_radix(s, 16).ok()?;
|
|
// Scale to 8-bit if it's a 16-bit value
|
|
Some(if s.len() > 2 { (val >> 8) as u8 } else { val as u8 })
|
|
};
|
|
let r = parse_component(parts[0])?;
|
|
let g = parse_component(parts[1])?;
|
|
let b = parse_component(parts[2])?;
|
|
return Some([r, g, b]);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Get RGBA for a color, using the palette for indexed colors.
|
|
pub fn to_rgba(&self, color: &Color) -> [f32; 4] {
|
|
match color {
|
|
Color::Default => {
|
|
let [r, g, b] = self.default_fg;
|
|
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
|
|
}
|
|
Color::Rgb(r, g, b) => [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0],
|
|
Color::Indexed(idx) => {
|
|
let [r, g, b] = self.colors[*idx as usize];
|
|
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get RGBA for background, using palette default_bg for Color::Default.
|
|
pub fn to_rgba_bg(&self, color: &Color) -> [f32; 4] {
|
|
match color {
|
|
Color::Default => {
|
|
let [r, g, b] = self.default_bg;
|
|
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
|
|
}
|
|
Color::Rgb(r, g, b) => [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0],
|
|
Color::Indexed(idx) => {
|
|
let [r, g, b] = self.colors[*idx as usize];
|
|
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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<Vec<Cell>>,
|
|
line_map: Vec<usize>,
|
|
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<Vec<Cell>>,
|
|
/// 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<Cell> {
|
|
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<Cell>> {
|
|
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<Vec<Cell>>,
|
|
/// Maps visual row index to actual grid row index.
|
|
/// This allows O(1) scrolling by rotating indices instead of moving cells.
|
|
line_map: Vec<usize>,
|
|
/// 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<u8>,
|
|
/// 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<AlternateScreen>,
|
|
/// 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<Vec<Cell>>,
|
|
/// VT parser for escape sequence handling.
|
|
parser: Option<Parser>,
|
|
/// 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<TerminalCommand>,
|
|
/// 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<usize> = (0..rows).collect();
|
|
|
|
// Pre-allocate a pool of empty lines to avoid allocation during scrolling
|
|
let line_pool: Vec<Vec<Cell>> = (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<Cell>) {
|
|
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<TerminalCommand> {
|
|
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<Cell> {
|
|
&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<Cell> {
|
|
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<Vec<u8>> {
|
|
if self.response_queue.is_empty() {
|
|
None
|
|
} else {
|
|
Some(std::mem::take(&mut self.response_queue))
|
|
}
|
|
}
|
|
|
|
/// Processes raw bytes from the PTY using the 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<u8> {
|
|
// 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<Cell>> {
|
|
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::<u32>().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::<u8>() {
|
|
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;<content> 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;<content> 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("<invalid utf8>"));
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|