Compare commits

...

13 Commits

Author SHA1 Message Date
zach c31166c087 repaint when cursor moves 2026-06-04 14:53:05 +02:00
zach f07b1724d9 fix compiler warning 2026-06-04 14:45:35 +02:00
zach 1bbd8fd1b9 fix ahead/behind count in git status bar 2026-06-04 14:30:57 +02:00
zach 3164873a66 fix rendering bug 2026-06-04 14:05:45 +02:00
zach 7f4249dde9 agents 2026-06-04 11:16:38 +02:00
zach a56a2108ac use gix for git status 2026-06-03 23:16:40 +02:00
zach fe0a21bdb9 cleaned up debug code 2026-06-03 22:10:50 +02:00
zach bc552f6f0f cleaned up debug code 2026-06-03 17:13:20 +02:00
zach d2939de936 fix escape code leak 2026-04-11 00:39:21 +02:00
zach 350be6611c remove pkgbuild 2026-04-11 00:02:56 +02:00
zach 494b684d58 fix pkgbuild 2026-04-10 01:08:55 +02:00
Zacharias-Brohn 32ce278743 terminal is fixed and im not happy about it 2026-04-10 00:29:13 +02:00
Zacharias-Brohn 5129612f9f terminal is fixed and im not happy about it 2026-04-09 23:49:32 +02:00
15 changed files with 1131 additions and 717 deletions
+1
View File
@@ -3,3 +3,4 @@
/pkg
Cargo.lock
*.tar.*
AGENTS.md
+7 -1
View File
@@ -75,13 +75,19 @@ rustc-hash = "2"
# Base64 decoding for OSC statusline
base64 = "0.22"
# Pure-Rust git implementation (replaces git subprocess calls)
gix = { version = "0.84", features = ["max-performance", "parallel", "status", "blob-diff"] }
gix-diff = "0.64"
gix-status = "0.31"
gix-revwalk = "0.32"
# Image processing (Kitty graphics protocol)
image = { version = "0.25", default-features = false, features = ["png", "gif"] }
flate2 = "1"
# Video decoding for WebM support (video only, no audio)
# Requires system FFmpeg libraries (ffmpeg 5.x - 8.x supported)
ffmpeg-next = { version = "8.0", optional = true }
ffmpeg-next = { version = "8.1", optional = true }
[features]
default = ["webm"]
-31
View File
@@ -1,31 +0,0 @@
# Maintainer: Zach <zach@brohn.se>
pkgname=zterm
pkgver=0.1.0
pkgrel=1
pkgdesc="A GPU-accelerated terminal emulator for Wayland"
arch=('x86_64')
url="https://github.com/Zacharias-Brohn/zterm"
license=('MIT')
depends=(
'fontconfig'
'freetype2'
'wayland'
'libxkbcommon'
'vulkan-icd-loader'
)
makedepends=('rust' 'cargo')
source=()
build() {
cd "$startdir"
cargo build --release --features production
}
package() {
cd "$startdir"
install -Dm755 "target/release/zterm" "$pkgdir/usr/bin/zterm"
install -Dm644 "zterm.terminfo" "$pkgdir/usr/share/zterm/zterm.terminfo"
# Compile and install terminfo
tic -x -o "$pkgdir/usr/share/terminfo" zterm.terminfo
}
+2
View File
@@ -0,0 +1,2 @@
use zterm::vt_parser::*;
fn main() {}
-1
View File
@@ -1,7 +1,6 @@
use zterm::terminal::Terminal;
use zterm::vt_parser::Parser;
use std::time::Instant;
use std::io::Write;
const ASCII_PRINTABLE: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ `~!@#$%^&*()_+-=[]{}\\|;:'\",<.>/?";
const CONTROL_CHARS: &[u8] = b"\n\t";
-1
View File
@@ -521,7 +521,6 @@ pub fn render_box_char(
c: char,
cell_width: usize,
cell_height: usize,
font_size: f32,
dpi: f64,
) -> Option<(Vec<u8>, bool)> {
let w = cell_width;
-3
View File
@@ -387,7 +387,6 @@ impl Config {
};
if !config_path.exists() {
log::info!("No config file found at {:?}, creating with defaults", config_path);
let default_config = Self::default();
if let Err(e) = default_config.save() {
log::warn!("Failed to write default config: {}", e);
@@ -398,7 +397,6 @@ impl Config {
match fs::read_to_string(&config_path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(config) => {
log::info!("Loaded config from {:?}", config_path);
config
}
Err(e) => {
@@ -431,7 +429,6 @@ impl Config {
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
fs::write(&config_path, json)?;
log::info!("Saved config to {:?}", config_path);
Ok(())
}
}
+2 -19
View File
@@ -214,19 +214,6 @@ pub fn load_font_family(font_family: Option<&str>) -> (Box<[u8]>, FontRef<'stati
// Try to use fontconfig to find the font family
if let Some(family) = font_family {
let paths = find_font_family_variants(family);
log::info!("Font family '{}' resolved to:", family);
for (i, path) in paths.iter().enumerate() {
let style = match i {
0 => "Regular",
1 => "Bold",
2 => "Italic",
3 => "BoldItalic",
_ => "Unknown",
};
if let Some(p) = path {
log::info!(" {}: {:?}", style, p);
}
}
// Load the regular font (required)
if let Some(regular_path) = &paths[0] {
@@ -277,11 +264,7 @@ pub fn load_font_family(font_family: Option<&str>) -> (Box<[u8]>, FontRef<'stati
load_font_variant(std::path::Path::new(bold_italic)),
];
log::info!("Loaded font from fallback paths:");
log::info!(" Regular: {}", regular);
if variants[1].is_some() { log::info!(" Bold: {}", bold); }
if variants[2].is_some() { log::info!(" Italic: {}", italic); }
if variants[3].is_some() { log::info!(" BoldItalic: {}", bold_italic); }
return (font_data, primary_font, variants);
}
@@ -293,7 +276,7 @@ pub fn load_font_family(font_family: Option<&str>) -> (Box<[u8]>, FontRef<'stati
let primary_font = regular_variant.clone_font();
let font_data = regular_variant.clone_data();
let variants: [Option<FontVariant>; 4] = [Some(regular_variant), None, None, None];
log::info!("Loaded NotoSansMono as fallback");
return (font_data, primary_font, variants);
}
+5
View File
@@ -459,11 +459,16 @@ fn vs_cell_bg(
// For default background (type 0), use fully transparent so the window's
// clear color (which has background_opacity applied) shows through.
// UNLESS the grid params specify an opaque background (e.g. alternate screen).
// Only non-default backgrounds should be opaque.
// But NOT if the cell is selected (selection always has white bg)
let bg_type = cell.bg & 0xFFu;
if bg_type == COLOR_TYPE_DEFAULT && !is_reverse && !is_selected {
if grid_params.background_opacity < 1.0 {
bg.a = 0.0;
} else {
bg.a = 1.0;
}
}
// Calculate cursor color
+1
View File
@@ -20,3 +20,4 @@ pub mod statusline;
pub mod terminal;
pub mod simd_utf8;
pub mod vt_parser;
mod vt_test_osc;
+682 -319
View File
File diff suppressed because it is too large Load Diff
+143 -141
View File
@@ -13,7 +13,7 @@ use crate::gpu_types::{
EdgeGlowUniforms, QuadParams, StatuslineParams,
ATLAS_SIZE, MAX_ATLAS_LAYERS, ATLAS_BPP, MAX_EDGE_GLOWS,
COLOR_TYPE_DEFAULT, COLOR_TYPE_INDEXED, COLOR_TYPE_RGB,
ATTR_BOLD, ATTR_ITALIC, ATTR_STRIKE,
ATTR_BOLD, ATTR_ITALIC, ATTR_STRIKE, ATTR_REVERSE,
COLORED_GLYPH_FLAG,
CURSOR_SPRITE_BEAM, CURSOR_SPRITE_UNDERLINE, CURSOR_SPRITE_HOLLOW,
DECORATION_SPRITE_STRIKETHROUGH, DECORATION_SPRITE_UNDERLINE, DECORATION_SPRITE_DOUBLE_UNDERLINE,
@@ -198,6 +198,7 @@ enum SpriteTarget {
}
/// The terminal renderer.
#[allow(dead_code)]
pub struct Renderer {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
@@ -1552,6 +1553,53 @@ impl Renderer {
Some((col, row))
}
/// Converts a pixel position to a terminal cell position relative to a specific pane.
/// `pane_x` and `pane_y` are the pane's pixel offset within the grid area.
/// `pane_width` and `pane_height` are the pane's dimensions in pixels.
/// `pane_cols` and `pane_rows` are the pane's dimensions in cells.
/// Returns None if the position is outside this pane's area.
pub fn pane_pixel_to_cell(
&self,
x: f64,
y: f64,
pane_x: f32,
pane_y: f32,
pane_width: f32,
pane_height: f32,
pane_cols: usize,
pane_rows: usize,
) -> Option<(usize, usize)> {
let terminal_y_offset = self.terminal_y_offset();
let grid_x_offset = self.grid_x_offset();
let grid_y_offset = self.grid_y_offset();
// Convert pane pixel offset to screen pixel offset
let pane_screen_x = grid_x_offset + pane_x;
let pane_screen_y = terminal_y_offset + grid_y_offset + pane_y;
// Check if position is within this pane's screen bounds
if (x as f32) < pane_screen_x
|| (x as f32) >= pane_screen_x + pane_width
|| (y as f32) < pane_screen_y
|| (y as f32) >= pane_screen_y + pane_height
{
return None;
}
// Calculate cell position relative to pane origin
let local_x = (x as f32) - pane_screen_x;
let local_y = (y as f32) - pane_screen_y;
let col = (local_x / self.cell_metrics.cell_width as f32).floor() as usize;
let row = (local_y / self.cell_metrics.cell_height as f32).floor() as usize;
if col >= pane_cols || row >= pane_rows {
return None;
}
Some((col, row))
}
/// Updates the scale factor and recalculates font/cell dimensions.
/// Returns true if the cell dimensions changed (terminal needs resize).
pub fn set_scale_factor(&mut self, new_scale: f64) -> bool {
@@ -1589,11 +1637,6 @@ impl Renderer {
// Update the font units to pixels scale factor
self.font_units_to_px = self.font_size / self.primary_font.height_unscaled();
log::info!(
"Scale factor changed to {}: font {}px -> {}px, cell: {}x{}, baseline: {}",
new_scale, self.base_font_size, self.font_size, self.cell_metrics.cell_width, self.cell_metrics.cell_height, self.cell_metrics.baseline
);
// Reset atlas and all sprite/glyph caches (includes cursor sprite creation)
self.reset_atlas();
@@ -1648,11 +1691,6 @@ impl Renderer {
// Update the font units to pixels scale factor
self.font_units_to_px = self.font_size / self.primary_font.height_unscaled();
log::info!(
"Font size changed to {}px -> {}px, cell: {}x{}, baseline: {}",
size, self.font_size, self.cell_metrics.cell_width, self.cell_metrics.cell_height, self.cell_metrics.baseline
);
// Reset atlas and all sprite/glyph caches (includes cursor sprite creation)
self.reset_atlas();
@@ -1666,7 +1704,7 @@ impl Renderer {
/// NOTE: This should ONLY be called for font/scale changes, NOT when atlas is full
/// (for that case, we add a new layer via add_atlas_layer()).
fn reset_atlas(&mut self) {
log::info!("Resetting glyph atlas (font/scale changed)");
// Clear all glyph caches - they need to be re-rasterized at new size
self.char_cache.clear();
@@ -1718,11 +1756,12 @@ impl Renderer {
/// Pack cell attributes into u32 format for GPU.
/// underline_style: 0=none, 1=single, 2=double, 3=curly, 4=dotted, 5=dashed
#[inline]
fn pack_attrs(bold: bool, italic: bool, underline_style: u8, strikethrough: bool) -> u32 {
fn pack_attrs(bold: bool, italic: bool, underline_style: u8, strikethrough: bool, reverse: bool) -> u32 {
let mut attrs = (underline_style as u32) & 0x7; // 3 bits for decoration type
if bold { attrs |= ATTR_BOLD; }
if italic { attrs |= ATTR_ITALIC; }
if strikethrough { attrs |= ATTR_STRIKE; }
if reverse { attrs |= ATTR_REVERSE; }
attrs
}
@@ -1899,7 +1938,7 @@ impl Renderer {
bg: Self::pack_color(&cell.bg_color),
decoration_fg: 0,
sprite_idx: 0, // No glyph for continuation
attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline_style, cell.strikethrough),
attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline_style, cell.strikethrough, cell.reverse),
};
col += 1;
continue;
@@ -1972,7 +2011,7 @@ impl Renderer {
bg: Self::pack_color(&current_cell.bg_color),
decoration_fg: 0,
sprite_idx: final_sprite_idx,
attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline_style, cell.strikethrough),
attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline_style, cell.strikethrough, cell.reverse),
};
}
@@ -2023,7 +2062,7 @@ impl Renderer {
bg: Self::pack_color(&current_cell.bg_color),
decoration_fg: 0,
sprite_idx: sprite_idx | COLORED_GLYPH_FLAG,
attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline_style, cell.strikethrough),
attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline_style, cell.strikethrough, cell.reverse),
};
}
@@ -2052,7 +2091,7 @@ impl Renderer {
bg: Self::pack_color(&cell.bg_color),
decoration_fg: 0,
sprite_idx,
attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline_style, cell.strikethrough),
attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline_style, cell.strikethrough, cell.reverse),
};
col += 1;
}
@@ -2140,6 +2179,21 @@ impl Renderer {
self.pane_resources.retain(|id, _| active_pane_ids.contains(id));
}
/// Force a full GPU cell buffer rebuild on the next call to update_gpu_cells.
pub fn force_full_redraw(&mut self) {
self.cells_dirty = true;
}
/// Check if a full redraw is pending.
pub fn has_pending_redraw(&self) -> bool {
self.cells_dirty
}
/// Called after a render to clear the pending redraw flag.
pub fn clear_pending_redraw(&mut self) {
self.cells_dirty = false;
}
/// Update GPU cell buffer from terminal content.
/// Like Kitty, this only processes dirty lines to minimize work.
///
@@ -2149,10 +2203,6 @@ impl Renderer {
let rows = terminal.rows;
let total_cells = cols * rows;
// TEMPORARY DEBUG: Force full rebuild every frame to test if dirty-line tracking is the issue
// TODO: Remove this once the rendering bug is fixed
self.cells_dirty = true;
// Check if grid size changed - need full rebuild
let size_changed = self.last_grid_size != (cols, rows);
if size_changed {
@@ -2161,14 +2211,17 @@ impl Renderer {
self.cells_dirty = true;
}
// Check if this terminal has any dirty lines
let has_dirty = terminal.has_any_dirty_line();
// First pass: ensure all characters have sprites
// This needs mutable access to self for sprite creation
// Like Kitty's render_line(), detect PUA+space patterns for multi-cell rendering
// OPTIMIZATION: Only process dirty lines or when full rebuild is needed
// OPTIMIZATION: Use get_visible_row() to avoid Vec allocation
for row_idx in 0..rows {
// Skip clean lines (unless size changed, which sets cells_dirty)
if !self.cells_dirty && !terminal.is_line_dirty(row_idx) {
// Skip clean lines (unless size changed or terminal has dirty lines)
if !self.cells_dirty && !has_dirty && !terminal.is_line_dirty(row_idx) {
continue;
}
@@ -2342,61 +2395,21 @@ impl Renderer {
}
// Second pass: convert cells to GPU format
// OPTIMIZATION: Use get_visible_row() to avoid Vec allocation
// Always update self.gpu_cells from the current terminal to avoid
// stale data from a previous pane being written to the wrong GPU buffer.
let mut any_updated = false;
// DEBUG: Log grid dimensions and buffer state
static DEBUG_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let frame_num = DEBUG_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if frame_num % 60 == 0 { // Log every 60 frames (~1 second at 60fps)
log::info!("DEBUG update_gpu_cells: cols={} rows={} total={} gpu_cells.len={} cells_dirty={}",
cols, rows, total_cells, self.gpu_cells.len(), self.cells_dirty);
}
// If we did a full reset or size changed, update all lines
if self.cells_dirty {
static ROW_DEBUG_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let row_frame = ROW_DEBUG_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if row_frame % 60 == 0 {
let first_col: String = (0..rows).filter_map(|r| {
terminal.get_visible_row(r).and_then(|row| {
row.first().map(|cell| {
let c = cell.character;
if c == '\0' { ' ' } else { c }
})
})
}).collect();
log::info!("DEBUG col0: \"{}\"", first_col);
}
for row_idx in 0..rows {
if let Some(row) = terminal.get_visible_row(row_idx) {
let start = row_idx * cols;
let end = start + cols;
if end > self.gpu_cells.len() {
log::error!("DEBUG BUG: row_idx={} start={} end={} but gpu_cells.len={}",
row_idx, start, end, self.gpu_cells.len());
continue;
}
Self::cells_to_gpu_row_static(row, &mut self.gpu_cells[start..end], cols, &self.sprite_map);
}
}
self.cells_dirty = false;
any_updated = true;
} else {
// Only update dirty lines - use is_line_dirty() which handles all 256 lines
for row_idx in 0..rows {
if terminal.is_line_dirty(row_idx) {
if let Some(row) = terminal.get_visible_row(row_idx) {
let start = row_idx * cols;
let end = start + cols;
Self::cells_to_gpu_row_static(row, &mut self.gpu_cells[start..end], cols, &self.sprite_map);
any_updated = true;
}
}
}
}
@@ -2405,14 +2418,19 @@ impl Renderer {
/// Parse ANSI escape sequences from raw statusline content.
/// Returns a vector of (char, fg_color, bg_color, bold) tuples.
fn parse_ansi_statusline(content: &str) -> Vec<(char, StatuslineColor, StatuslineColor, bool)> {
fn parse_ansi_statusline(content: &str, is_light: bool) -> Vec<(char, StatuslineColor, StatuslineColor, bool)> {
let mut result = Vec::new();
let chars: Vec<char> = content.chars().collect();
let mut i = 0;
// Current styling state
let mut fg = StatuslineColor::Default;
let mut bg = StatuslineColor::Rgb(0x1a, 0x1a, 0x1a); // Default statusline background
let default_bg_color = if is_light {
StatuslineColor::Rgb(0xD0, 0xD0, 0xD0)
} else {
StatuslineColor::Rgb(0x1a, 0x1a, 0x1a)
};
let mut bg = default_bg_color.clone(); // Default statusline background
let mut bold = false;
while i < chars.len() {
@@ -2493,7 +2511,7 @@ impl Renderer {
}
}
}
49 => bg = StatuslineColor::Rgb(0x1a, 0x1a, 0x1a), // Reset to default statusline bg
49 => bg = default_bg_color.clone(), // Reset to default statusline bg
90..=97 => fg = StatuslineColor::Indexed((code - 90 + 8) as u8),
100..=107 => bg = StatuslineColor::Indexed((code - 100 + 8) as u8),
_ => {}
@@ -2527,7 +2545,7 @@ impl Renderer {
/// this is used to expand the middle gap to fill the full window width.
///
/// Returns the number of columns used.
fn update_statusline_cells(&mut self, content: &StatuslineContent, target_width: f32) -> usize {
fn update_statusline_cells(&mut self, content: &StatuslineContent, target_width: f32, is_light: bool) -> usize {
self.statusline_gpu_cells.clear();
// Calculate target columns based on window width
@@ -2539,14 +2557,19 @@ impl Renderer {
self.statusline_max_cols
};
// Default background color for statusline (dark gray)
let default_bg = Self::pack_statusline_color(StatuslineColor::Rgb(0x1a, 0x1a, 0x1a));
// Default background color for statusline
let default_bg_color = if is_light {
StatuslineColor::Rgb(0xD0, 0xD0, 0xD0)
} else {
StatuslineColor::Rgb(0x1a, 0x1a, 0x1a)
};
let default_bg = Self::pack_statusline_color(default_bg_color);
let _ = default_bg; // Silence unused warning - used by Sections path
match content {
StatuslineContent::Raw(ansi_content) => {
// Parse ANSI escape sequences to extract colors and text
let parsed = Self::parse_ansi_statusline(ansi_content);
let parsed = Self::parse_ansi_statusline(ansi_content, is_light);
// Find the middle gap (largest consecutive run of spaces)
// and expand it to fill the target width
@@ -2596,7 +2619,7 @@ impl Renderer {
let gap_bg = if best_gap_len > 0 && best_gap_start < parsed.len() {
parsed[best_gap_start].2
} else {
StatuslineColor::Rgb(0x1a, 0x1a, 0x1a)
default_bg_color.clone()
};
// The position right before right-hand content starts (end of gap)
@@ -2628,7 +2651,7 @@ impl Renderer {
let fg = Self::pack_statusline_color(*fg_color);
let bg = Self::pack_statusline_color(*bg_color);
let style = if *bold { FontStyle::Bold } else { FontStyle::Regular };
let attrs = Self::pack_attrs(*bold, false, 0, false);
let attrs = Self::pack_attrs(*bold, false, 0, false, false);
let (sprite_idx, is_colored) = if *c == ' ' || *c == '\0' {
(0, false)
@@ -2677,7 +2700,7 @@ impl Renderer {
let fg = Self::pack_statusline_color(fg_color);
let bg = Self::pack_statusline_color(bg_color);
let style = if bold { FontStyle::Bold } else { FontStyle::Regular };
let attrs = Self::pack_attrs(bold, false, 0, false);
let attrs = Self::pack_attrs(bold, false, 0, false, false);
let (sprite_idx, is_colored) = if c == ' ' || c == '\0' {
(0, false)
@@ -2715,7 +2738,7 @@ impl Renderer {
for component in section.components.iter() {
let component_fg = Self::pack_statusline_color(component.fg);
let style = if component.bold { FontStyle::Bold } else { FontStyle::Regular };
let attrs = Self::pack_attrs(component.bold, false, 0, false);
let attrs = Self::pack_attrs(component.bold, false, 0, false, false);
// Process characters with lookahead for multi-cell symbols
let chars: Vec<char> = component.text.chars().collect();
@@ -2844,7 +2867,7 @@ impl Renderer {
// Fill remaining width with default background cells
// This ensures the statusline covers the entire window width
let default_bg_packed = Self::pack_statusline_color(StatuslineColor::Default);
let default_bg_packed = default_bg;
while self.statusline_gpu_cells.len() < target_cols && self.statusline_gpu_cells.len() < self.statusline_max_cols {
self.statusline_gpu_cells.push(GPUCell {
fg: 0,
@@ -2958,7 +2981,6 @@ impl Renderer {
c,
self.cell_metrics.cell_width as usize,
self.cell_metrics.cell_height as usize,
self.font_size,
self.dpi,
) {
// Box-drawing bitmaps are already cell-sized and fill from top-left.
@@ -3669,7 +3691,7 @@ impl Renderer {
return;
}
log::info!("Adding atlas layer {} (was on layer {})", new_layer, self.atlas_current_layer);
// Create real texture for the new layer (replacing the dummy)
self.ensure_atlas_layer_capacity(new_layer);
@@ -3696,7 +3718,7 @@ impl Renderer {
return;
}
log::info!("Adding atlas layer {} (replacing dummy texture)", target_layer);
// Create new real texture (8192x8192)
let texture = self.device.create_texture(&wgpu::TextureDescriptor {
@@ -4330,10 +4352,17 @@ impl Renderer {
TabBarPosition::Hidden => unreachable!(),
};
let is_light = self.palette.is_light();
let tab_bar_bg = if is_light {
// Light mode statusline bg is approx 0xD0, linear is ~0.63076
const TAB_BAR_BG_LINEAR_LIGHT: f32 = 0.63076;
[TAB_BAR_BG_LINEAR_LIGHT, TAB_BAR_BG_LINEAR_LIGHT, TAB_BAR_BG_LINEAR_LIGHT, 1.0]
} else {
// Use same color as statusline: 0x1a1a1a (26, 26, 26) in sRGB
// Pre-computed linear RGB value for srgb_to_linear(26/255) ≈ 0.00972
const TAB_BAR_BG_LINEAR: f32 = 0.00972;
let tab_bar_bg = [TAB_BAR_BG_LINEAR, TAB_BAR_BG_LINEAR, TAB_BAR_BG_LINEAR, 1.0];
const TAB_BAR_BG_LINEAR_DARK: f32 = 0.00972;
[TAB_BAR_BG_LINEAR_DARK, TAB_BAR_BG_LINEAR_DARK, TAB_BAR_BG_LINEAR_DARK, 1.0]
};
// Draw tab bar background
log::debug!("render_panes: drawing tab bar at y={}, height={}, num_tabs={}, quads_before={}",
@@ -4353,23 +4382,23 @@ impl Renderer {
let tab_width = title_width.max(min_tab_width);
let tab_bg = if is_active {
// Active tab: brightest - significantly brighter than tab bar
// Active tab: brightest - matches terminal background or slightly brighter
let [r, g, b] = self.palette.default_bg;
let boost = 50.0_f32; // More visible for active tab
let boost = if is_light { 0.0_f32 } else { 50.0_f32 };
[
Self::srgb_to_linear((r as f32 + boost).min(255.0) / 255.0),
Self::srgb_to_linear((g as f32 + boost).min(255.0) / 255.0),
Self::srgb_to_linear((b as f32 + boost).min(255.0) / 255.0),
Self::srgb_to_linear((r as f32 + boost).clamp(0.0, 255.0) / 255.0),
Self::srgb_to_linear((g as f32 + boost).clamp(0.0, 255.0) / 255.0),
Self::srgb_to_linear((b as f32 + boost).clamp(0.0, 255.0) / 255.0),
1.0,
]
} else {
// Inactive tab: slightly brighter than tab bar background
// Inactive tab: between tab bar background and active tab
let [r, g, b] = self.palette.default_bg;
let boost = 30.0_f32;
let boost = if is_light { -30.0_f32 } else { 30.0_f32 };
[
Self::srgb_to_linear((r as f32 + boost).min(255.0) / 255.0),
Self::srgb_to_linear((g as f32 + boost).min(255.0) / 255.0),
Self::srgb_to_linear((b as f32 + boost).min(255.0) / 255.0),
Self::srgb_to_linear((r as f32 + boost).clamp(0.0, 255.0) / 255.0),
Self::srgb_to_linear((g as f32 + boost).clamp(0.0, 255.0) / 255.0),
Self::srgb_to_linear((b as f32 + boost).clamp(0.0, 255.0) / 255.0),
1.0,
]
};
@@ -4617,7 +4646,7 @@ impl Renderer {
{
let update_time = t0.elapsed();
if update_time.as_micros() > 500 {
log::info!("update_gpu_cells took {:?}", update_time);
}
}
@@ -4650,42 +4679,17 @@ impl Renderer {
CursorShape::BlinkingUnderline | CursorShape::SteadyUnderline => 1,
CursorShape::BlinkingBar | CursorShape::SteadyBar => 2,
},
background_opacity: self.background_opacity,
background_opacity: if terminal.using_alternate_screen {
1.0
} else {
self.background_opacity
},
selection_start_col: sel_start_col,
selection_start_row: sel_start_row,
selection_end_col: sel_end_col,
selection_end_row: sel_end_row,
};
// DEBUG: Log grid params every 60 frames
static PANE_DEBUG_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let pane_frame = PANE_DEBUG_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if pane_frame % 60 == 0 {
log::info!("DEBUG pane {}: grid_params cols={} rows={} gpu_cells.len={} expected={}",
info.pane_id, grid_params.cols, grid_params.rows,
self.gpu_cells.len(), (grid_params.cols * grid_params.rows) as usize);
// Sample a few cells to see if sprite indices look reasonable
if !self.gpu_cells.is_empty() {
let sample_indices = [0, 1, 2, cols as usize, cols as usize + 1];
for &idx in &sample_indices {
if idx < self.gpu_cells.len() {
let cell = &self.gpu_cells[idx];
let sprite_idx = cell.sprite_idx & !0x80000000;
log::info!("DEBUG cell[{}]: sprite_idx={} fg={:#x} bg={:#x}",
idx, sprite_idx, cell.fg, cell.bg);
if sprite_idx > 0 && (sprite_idx as usize) < self.sprite_info.len() {
let sprite = &self.sprite_info[sprite_idx as usize];
log::info!("DEBUG sprite[{}]: uv=({:.3},{:.3},{:.3},{:.3}) layer={} size=({:.1},{:.1})",
sprite_idx, sprite.uv[0], sprite.uv[1], sprite.uv[2], sprite.uv[3],
sprite.layer, sprite.size[0], sprite.size[1]);
}
}
}
}
}
// Upload this pane's cell data to its own buffer (like Kitty's send_cell_data_to_gpu)
// This happens BEFORE the render pass, so each pane has its own data
if let Some(pane_res) = self.pane_resources.get(&info.pane_id) {
@@ -4747,7 +4751,7 @@ impl Renderer {
{
let pane_loop_time = pane_loop_start.elapsed();
if pane_loop_time.as_micros() > 500 {
log::info!("pane_loop took {:?}", pane_loop_time);
}
}
@@ -4766,9 +4770,10 @@ impl Renderer {
// ═══════════════════════════════════════════════════════════════════
let statusline_cols = {
let statusline_y = self.statusline_y();
let is_light = self.palette.is_light();
// Update statusline GPU cells from content, passing window width for gap expansion
let cols = self.update_statusline_cells(statusline_content, width);
let cols = self.update_statusline_cells(statusline_content, width, is_light);
if cols > 0 {
// Upload statusline cells to GPU
@@ -5008,10 +5013,19 @@ impl Renderer {
{
let [bg_r, bg_g, bg_b] = self.palette.default_bg;
let bg_r_linear = Self::srgb_to_linear(bg_r as f32 / 255.0) as f64;
let bg_g_linear = Self::srgb_to_linear(bg_g as f32 / 255.0) as f64;
let bg_b_linear = Self::srgb_to_linear(bg_b as f32 / 255.0) as f64;
let mut bg_r_linear = Self::srgb_to_linear(bg_r as f32 / 255.0) as f64;
let mut bg_g_linear = Self::srgb_to_linear(bg_g as f32 / 255.0) as f64;
let mut bg_b_linear = Self::srgb_to_linear(bg_b as f32 / 255.0) as f64;
let bg_alpha = self.background_opacity as f64;
// If the compositor expects premultiplied alpha, we must premultiply the clear color.
// Otherwise, light backgrounds with opacity will look fully opaque or super-luminous.
if self.surface_config.alpha_mode == wgpu::CompositeAlphaMode::PreMultiplied {
bg_r_linear *= bg_alpha;
bg_g_linear *= bg_alpha;
bg_b_linear *= bg_alpha;
}
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Render Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
@@ -5226,19 +5240,7 @@ impl Renderer {
let after_submit = frame_start.elapsed();
output.present();
// Log timing if frame took more than 1ms (only with render_timing feature)
#[cfg(feature = "render_timing")]
{
let after_present = frame_start.elapsed();
if after_present.as_micros() > 1000 {
log::info!("render_panes: before_submit={:?} submit={:?} present={:?} total={:?}",
before_submit,
after_submit - before_submit,
after_present - after_submit,
after_present);
}
}
self.clear_pending_redraw();
Ok(())
}
+70 -112
View File
@@ -38,6 +38,7 @@ pub struct Cell {
pub bg_color: Color,
pub bold: bool,
pub italic: bool,
pub reverse: bool,
/// Underline style: 0=none, 1=single, 2=double, 3=curly, 4=dotted, 5=dashed
pub underline_style: u8,
/// Strikethrough decoration
@@ -45,6 +46,8 @@ pub struct Cell {
/// 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,
/// Indicates if the line wrapped after this cell.
pub wrapped: bool,
}
impl Default for Cell {
@@ -55,9 +58,11 @@ impl Default for Cell {
bg_color: Color::Default,
bold: false,
italic: false,
reverse: false,
underline_style: 0,
strikethrough: false,
wide_continuation: false,
wrapped: false,
}
}
}
@@ -181,6 +186,15 @@ impl Default for ColorPalette {
}
impl ColorPalette {
/// Return whether this palette is considered "light" based on default background luminance.
pub fn is_light(&self) -> bool {
let [r, g, b] = self.default_bg;
// Standard perceived luminance calculation
let luminance =
0.299 * (r as f32) + 0.587 * (g as f32) + 0.114 * (b as f32);
luminance > 128.0
}
/// 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();
@@ -269,6 +283,7 @@ struct SavedCursor {
strikethrough: bool,
origin_mode: bool,
auto_wrap: bool,
reverse: bool,
}
/// Alternate screen buffer state.
@@ -283,87 +298,6 @@ struct AlternateScreen {
scroll_bottom: usize,
}
/// Timing stats for performance debugging.
/// Only populated when the `render_timing` feature is enabled.
#[derive(Debug, Default)]
pub struct ProcessingStats {
#[cfg(feature = "render_timing")]
/// Total time spent in scroll_up operations (nanoseconds).
pub scroll_up_ns: u64,
#[cfg(feature = "render_timing")]
/// Number of scroll_up calls.
pub scroll_up_count: u32,
#[cfg(feature = "render_timing")]
/// Total time spent in scrollback operations (nanoseconds).
pub scrollback_ns: u64,
#[cfg(feature = "render_timing")]
/// Time in VecDeque pop_front.
pub pop_front_ns: u64,
#[cfg(feature = "render_timing")]
/// Time in VecDeque push_back.
pub push_back_ns: u64,
#[cfg(feature = "render_timing")]
/// Time in mem::swap.
pub swap_ns: u64,
#[cfg(feature = "render_timing")]
/// Total time spent in line clearing (nanoseconds).
pub clear_line_ns: u64,
#[cfg(feature = "render_timing")]
/// Total time spent in text handler (nanoseconds).
pub text_handler_ns: u64,
#[cfg(feature = "render_timing")]
/// Total time spent in CSI handler (nanoseconds).
pub csi_handler_ns: u64,
#[cfg(feature = "render_timing")]
/// Number of CSI sequences processed.
pub csi_count: u32,
#[cfg(feature = "render_timing")]
/// Number of characters processed.
pub chars_processed: u32,
#[cfg(feature = "render_timing")]
/// Total time spent in VT parser (consume_input) - nanoseconds.
pub vt_parser_ns: u64,
#[cfg(feature = "render_timing")]
/// Number of consume_input calls.
pub consume_input_count: u32,
}
impl ProcessingStats {
#[cfg(feature = "render_timing")]
pub fn reset(&mut self) {
*self = Self::default();
}
#[cfg(not(feature = "render_timing"))]
pub fn reset(&mut self) {}
#[cfg(feature = "render_timing")]
pub fn log_if_slow(&self, threshold_ms: u64) {
let total_ms =
(self.scroll_up_ns + self.text_handler_ns + self.csi_handler_ns)
/ 1_000_000;
if total_ms >= threshold_ms {
let vt_only_ns = self
.vt_parser_ns
.saturating_sub(self.text_handler_ns + self.csi_handler_ns);
log::info!(
"[PARSE_DETAIL] text={:.2}ms ({}chars) csi={:.2}ms ({}x) vt_only={:.2}ms ({}calls) scroll={:.2}ms ({}x)",
self.text_handler_ns as f64 / 1_000_000.0,
self.chars_processed,
self.csi_handler_ns as f64 / 1_000_000.0,
self.csi_count,
vt_only_ns as f64 / 1_000_000.0,
self.consume_input_count,
self.scroll_up_ns as f64 / 1_000_000.0,
self.scroll_up_count,
);
}
}
#[cfg(not(feature = "render_timing"))]
pub fn log_if_slow(&self, _threshold_ms: u64) {}
}
/// Kitty-style ring buffer for scrollback history.
///
/// Pre-allocates all lines upfront to avoid allocation during scrolling.
@@ -509,6 +443,8 @@ pub struct Terminal {
pub current_underline_style: u8,
/// Current strikethrough state.
pub current_strikethrough: bool,
/// Current reverse video state.
pub current_reverse: bool,
/// Whether the terminal content has changed.
pub dirty: bool,
/// Bitmap of dirty lines - bit N is set if line N needs redrawing.
@@ -552,7 +488,7 @@ pub struct Terminal {
/// Synchronized output mode (for reducing flicker).
synchronized_output: bool,
/// 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>,
@@ -570,12 +506,6 @@ impl Terminal {
/// 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();
@@ -594,6 +524,7 @@ impl Terminal {
current_italic: false,
current_underline_style: 0,
current_strikethrough: false,
current_reverse: false,
dirty: true,
dirty_lines: [!0u64; 4], // All lines dirty initially
scroll_top: 0,
@@ -614,7 +545,7 @@ impl Terminal {
bracketed_paste: false,
focus_reporting: false,
synchronized_output: false,
stats: ProcessingStats::default(),
command_queue: Vec::new(),
image_storage: ImageStorage::new(),
cell_width: 10.0, // Default, will be set by renderer
@@ -650,6 +581,12 @@ impl Terminal {
}
}
/// Check if any line is dirty.
#[inline]
pub fn has_any_dirty_line(&self) -> bool {
self.dirty_lines[0] != 0 || self.dirty_lines[1] != 0 || self.dirty_lines[2] != 0 || self.dirty_lines[3] != 0
}
/// Clear all dirty line flags.
#[inline]
pub fn clear_dirty_lines(&mut self) {
@@ -697,9 +634,11 @@ impl Terminal {
bg_color: self.current_bg,
bold: self.current_bold,
italic: self.current_italic,
reverse: self.current_reverse,
underline_style: self.current_underline_style,
strikethrough: self.current_strikethrough,
wide_continuation,
wrapped: false,
}
}
@@ -741,9 +680,11 @@ impl Terminal {
bg_color: self.current_bg,
bold: false,
italic: false,
reverse: false,
underline_style: 0,
strikethrough: false,
wide_continuation: false,
wrapped: false,
}
}
@@ -767,14 +708,6 @@ impl Terminal {
return;
}
log::info!(
"Terminal::resize: {}x{} -> {}x{}",
self.cols,
self.rows,
cols,
rows
);
let old_cols = self.cols;
let old_rows = self.rows;
@@ -1385,7 +1318,6 @@ impl Handler for Terminal {
// Look for patterns like "38;2;128" or "4;64;64m" - these are SGR parameters
if codepoints.len() >= 3 {
let has_semicolon = codepoints.iter().any(|&c| c == 0x3B); // ';'
let has_m = codepoints.iter().any(|&c| c == 0x6D); // 'm'
let mostly_digits = codepoints
.iter()
.filter(|&&c| c >= 0x30 && c <= 0x39)
@@ -1459,6 +1391,9 @@ impl Handler for Terminal {
// Handle wrap
if self.cursor_col >= self.cols {
if self.auto_wrap {
if self.cols > 0 {
self.grid[grid_row][0].wrapped = true;
}
self.cursor_col = 0;
self.advance_row();
cached_row = self.cursor_row;
@@ -1508,16 +1443,19 @@ impl Handler for Terminal {
if self.cursor_col > 0 {
self.cursor_col -= 1;
}
self.mark_line_dirty(self.cursor_row);
}
0x09 => {
let next_tab = (self.cursor_col / 8 + 1) * 8;
self.cursor_col = next_tab.min(self.cols - 1);
self.mark_line_dirty(self.cursor_row);
}
0x0A | 0x0B | 0x0C => {
self.advance_row();
}
0x0D => {
self.cursor_col = 0;
self.mark_line_dirty(self.cursor_row);
}
_ => {}
}
@@ -1667,12 +1605,6 @@ impl Handler for Terminal {
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),
);
@@ -1783,6 +1715,7 @@ impl Handler for Terminal {
if self.origin_mode { self.scroll_top } else { 0 };
self.cursor_row =
self.cursor_row.saturating_sub(n).max(min_row);
self.mark_line_dirty(self.cursor_row);
}
'B' => {
let n = params.get(0, 1).max(1) as usize;
@@ -1792,6 +1725,7 @@ impl Handler for Terminal {
self.rows - 1
};
self.cursor_row = (self.cursor_row + n).min(max_row);
self.mark_line_dirty(self.cursor_row);
}
// Cursor Forward
'C' => {
@@ -1804,11 +1738,13 @@ impl Handler for Terminal {
old_col,
self.cursor_col
);
self.mark_line_dirty(self.cursor_row);
}
// Cursor Back
'D' => {
let n = params.get(0, 1).max(1) as usize;
self.cursor_col = self.cursor_col.saturating_sub(n);
self.mark_line_dirty(self.cursor_row);
}
'E' => {
let n = params.get(0, 1).max(1) as usize;
@@ -1819,6 +1755,7 @@ impl Handler for Terminal {
};
self.cursor_col = 0;
self.cursor_row = (self.cursor_row + n).min(max_row);
self.mark_line_dirty(self.cursor_row);
}
'F' => {
let n = params.get(0, 1).max(1) as usize;
@@ -1827,6 +1764,7 @@ impl Handler for Terminal {
self.cursor_col = 0;
self.cursor_row =
self.cursor_row.saturating_sub(n).max(min_row);
self.mark_line_dirty(self.cursor_row);
}
// Cursor Horizontal Absolute (CHA)
'G' => {
@@ -1838,6 +1776,7 @@ impl Handler for Terminal {
self.cursor_col,
old_col
);
self.mark_line_dirty(self.cursor_row);
}
// Cursor Position
'H' | 'f' => {
@@ -1851,6 +1790,7 @@ impl Handler for Terminal {
self.cursor_row = (row - 1).min(self.rows - 1);
}
self.cursor_col = (col - 1).min(self.cols - 1);
self.mark_line_dirty(self.cursor_row);
}
// Erase in Display
'J' => {
@@ -1970,6 +1910,10 @@ impl Handler for Terminal {
// Handle wrap
if self.cursor_col >= self.cols {
if self.auto_wrap {
let gr = self.line_map[self.cursor_row];
if self.cols > 0 {
self.grid[gr][0].wrapped = true;
}
self.cursor_col = 0;
self.advance_row();
self.mark_line_dirty(self.cursor_row);
@@ -2167,15 +2111,17 @@ impl Handler for Terminal {
italic: self.current_italic,
underline_style: self.current_underline_style,
strikethrough: self.current_strikethrough,
reverse: self.current_reverse,
origin_mode: self.origin_mode,
auto_wrap: self.auto_wrap,
};
log::debug!(
"ESC 7: Cursor saved at ({}, {}), origin_mode={}, auto_wrap={}",
"ESC 7: Cursor saved at ({}, {}), origin_mode={}, auto_wrap={}, reverse={}",
self.cursor_col,
self.cursor_row,
self.origin_mode,
self.auto_wrap
self.auto_wrap,
self.current_reverse
);
}
@@ -2190,14 +2136,16 @@ impl Handler for Terminal {
self.current_italic = self.saved_cursor.italic;
self.current_underline_style = self.saved_cursor.underline_style;
self.current_strikethrough = self.saved_cursor.strikethrough;
self.current_reverse = self.saved_cursor.reverse;
self.origin_mode = self.saved_cursor.origin_mode;
self.auto_wrap = self.saved_cursor.auto_wrap;
log::debug!(
"ESC 8: Cursor restored to ({}, {}), origin_mode={}, auto_wrap={}",
"ESC 8: Cursor restored to ({}, {}), origin_mode={}, auto_wrap={}, reverse={}",
self.cursor_col,
self.cursor_row,
self.origin_mode,
self.auto_wrap
self.auto_wrap,
self.current_reverse
);
}
@@ -2240,6 +2188,7 @@ impl Handler for Terminal {
} else {
self.cursor_row += 1;
}
self.mark_line_dirty(self.cursor_row);
}
fn newline(&mut self) {
@@ -2249,6 +2198,7 @@ impl Handler for Terminal {
} else {
self.cursor_row += 1;
}
self.mark_line_dirty(self.cursor_row);
}
fn reverse_index(&mut self) {
@@ -2257,6 +2207,7 @@ impl Handler for Terminal {
} else {
self.cursor_row -= 1;
}
self.mark_line_dirty(self.cursor_row);
}
fn set_tab_stop(&mut self) {
@@ -2285,9 +2236,11 @@ impl Handler for Terminal {
bg_color: Color::Default,
bold: false,
italic: false,
reverse: false,
underline_style: 0,
strikethrough: false,
wide_continuation: false,
wrapped: false,
};
}
self.mark_line_dirty(visual_row);
@@ -2324,6 +2277,10 @@ impl Terminal {
// Check if we need to wrap before printing
if self.cursor_col >= self.cols {
if self.auto_wrap {
let grid_row = self.line_map[self.cursor_row];
if self.cols > 0 {
self.grid[grid_row][0].wrapped = true;
}
self.cursor_col = 0;
self.advance_row();
} else {
@@ -2338,6 +2295,7 @@ impl Terminal {
// 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.grid[grid_row][0].wrapped = true;
self.cursor_col = 0;
self.advance_row();
} else {
@@ -2460,15 +2418,14 @@ impl Terminal {
self.current_underline_style = 1;
}
}
7 => std::mem::swap(&mut self.current_fg, &mut self.current_bg),
7 => self.current_reverse = true,
9 => self.current_strikethrough = true,
21 => self.current_underline_style = 2, // Double underline
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)
}
27 => self.current_reverse = false,
28 => self.current_reverse = false,
29 => self.current_strikethrough = false,
// Standard foreground colors (30-37)
30..=37 => self.current_fg = Color::Indexed((code - 30) as u8),
@@ -2517,6 +2474,7 @@ impl Terminal {
self.current_italic = false;
self.current_underline_style = 0;
self.current_strikethrough = false;
self.current_reverse = false;
}
/// Handle Kitty keyboard protocol CSI sequences.
+64 -14
View File
@@ -482,7 +482,9 @@ impl SharedParser {
// Like Kitty line 1516: consume_input(self, ...)
let made_progress = self.consume_input(handler);
if made_progress {
parsed_any = true;
}
// Re-acquire lock
state = self.state.lock().unwrap();
@@ -635,14 +637,14 @@ impl SharedParser {
true
}
State::Csi => {
// Like Kitty lines 1465-1466:
// if (consume_csi(self)) { self->read.consumed = self->read.pos; if (self->csi.is_valid) dispatch_csi(self); SET_STATE(NORMAL); }
let state_before = *vte_state;
if Self::consume_csi_impl(
handler,
buf,
parse_pos,
parse_sz,
*parse_consumed,
parse_consumed,
vte_state,
csi,
escape_len,
) {
@@ -650,30 +652,41 @@ impl SharedParser {
if csi.is_valid {
handler.csi(csi);
}
if *vte_state == state_before {
*vte_state = State::Normal;
}
true
} else {
false
*vte_state != state_before
}
}
State::Osc => {
let state_before = *vte_state;
if Self::consume_osc_impl(
handler, buf, parse_pos, parse_sz, vte_state, osc_buffer,
handler,
buf,
parse_pos,
parse_sz,
parse_consumed,
vte_state,
osc_buffer,
escape_len,
) {
*parse_consumed = *parse_pos;
*vte_state = State::Normal;
true
} else {
false
*vte_state != state_before
}
}
State::Dcs | State::Apc | State::Pm | State::Sos => {
let state_before = *vte_state;
if Self::consume_string_impl(
handler,
buf,
parse_pos,
parse_sz,
parse_consumed,
vte_state,
string_buffer,
escape_len,
@@ -682,7 +695,7 @@ impl SharedParser {
*vte_state = State::Normal;
true
} else {
false
*vte_state != state_before
}
}
};
@@ -837,6 +850,11 @@ impl SharedParser {
b'\\' => {
*vte_state = State::Normal;
}
0x1B => {
// ESC followed by ESC. Start new escape sequence.
*vte_state = State::Escape;
return true;
}
_ => {
log::debug!("Unknown escape sequence: ESC {:02x}", ch);
*vte_state = State::Normal;
@@ -913,7 +931,8 @@ impl SharedParser {
buf: &[u8; BUF_SIZE],
parse_pos: &mut usize,
parse_sz: usize,
parse_consumed: usize,
parse_consumed: &mut usize,
vte_state: &mut State,
csi: &mut CsiParams,
escape_len: &mut usize,
) -> bool {
@@ -922,8 +941,15 @@ impl SharedParser {
*parse_pos += 1;
*escape_len += 1;
if ch == 0x1B {
*vte_state = State::Escape;
*parse_consumed = *parse_pos;
*escape_len = 0;
return false; // Aborted by new ESC
}
// Handle embedded control characters
if ch <= 0x1F && ch != 0x1B {
if ch <= 0x1F {
handler.control(ch);
continue;
}
@@ -1023,7 +1049,7 @@ impl SharedParser {
}
// Check max length
if *parse_pos - parse_consumed > MAX_ESCAPE_LEN {
if *parse_pos - *parse_consumed > MAX_ESCAPE_LEN {
log::debug!("CSI escape too long, ignoring");
return true;
}
@@ -1037,6 +1063,7 @@ impl SharedParser {
buf: &[u8; BUF_SIZE],
parse_pos: &mut usize,
parse_sz: usize,
parse_consumed: &mut usize,
vte_state: &mut State,
osc_buffer: &mut Vec<u8>,
escape_len: &mut usize,
@@ -1069,6 +1096,7 @@ impl SharedParser {
*parse_pos += 1;
handler.osc(osc_buffer);
*vte_state = State::Escape;
*parse_consumed = *parse_pos;
*escape_len = 0;
return false;
} else {
@@ -1098,6 +1126,7 @@ impl SharedParser {
buf: &[u8; BUF_SIZE],
parse_pos: &mut usize,
parse_sz: usize,
parse_consumed: &mut usize,
vte_state: &mut State,
string_buffer: &mut Vec<u8>,
escape_len: &mut usize,
@@ -1128,10 +1157,17 @@ impl SharedParser {
);
return true;
} else if *parse_pos + 1 < parse_sz {
// ESC not followed by \ - include in buffer
string_buffer.push(ch);
// ESC not followed by \ - abort string, start new escape
*parse_pos += 1;
*escape_len += 1;
Self::dispatch_string_command(
handler,
vte_state,
string_buffer,
);
*vte_state = State::Escape;
*parse_consumed = *parse_pos;
*escape_len = 0;
return false;
} else {
// ESC at end of buffer - need more data
return false;
@@ -1368,6 +1404,11 @@ impl Parser {
self.state = State::Normal;
1
}
0x1B => {
// ESC followed by ESC. Start new escape sequence.
self.state = State::Escape;
1
}
_ => {
// Unknown escape sequence, ignore and return to normal
log::debug!("Unknown escape sequence: ESC {:02x}", ch);
@@ -1442,8 +1483,17 @@ impl Parser {
return consumed;
}
if ch == 0x1B {
self.state = State::Escape;
self.escape_len = 0;
// Wait! If it's ESC, we consumed it. But the next loop in parse() will call consume_escape,
// which EXPECTS the char AFTER ESC.
// So consuming it is PERFECT!
return consumed;
}
// Handle control characters embedded in CSI (common to all states)
if ch <= 0x1F && ch != 0x1B {
if ch <= 0x1F {
handler.control(ch);
continue;
}
+79
View File
@@ -0,0 +1,79 @@
use crate::vt_parser::*;
#[allow(dead_code)]
pub struct DummyHandler {
pub text: String,
pub osc_calls: Vec<Vec<u8>>,
}
impl Handler for DummyHandler {
fn text(&mut self, codepoints: &[u32]) {
for &c in codepoints {
if let Some(ch) = char::from_u32(c) {
self.text.push(ch);
}
}
}
fn control(&mut self, _byte: u8) {}
fn csi(&mut self, _csi: &CsiParams) {}
fn osc(&mut self, osc: &[u8]) {
self.osc_calls.push(osc.to_vec());
}
fn save_cursor(&mut self) {}
fn restore_cursor(&mut self) {}
fn reset(&mut self) {}
fn index(&mut self) {}
fn newline(&mut self) {}
fn reverse_index(&mut self) {}
fn screen_alignment(&mut self) {}
fn set_tab_stop(&mut self) {}
fn set_keypad_mode(&mut self, _mode: bool) {}
fn add_vt_parser_ns(&mut self, _ns: u64) {}
}
#[test]
fn test_osc_leak_byte_by_byte() {
let parser = SharedParser::new();
let mut handler = DummyHandler { text: String::new(), osc_calls: Vec::new() };
let data = b"\x1b]4;1;#769E00\x1b\\\x1b]4;2;#93DE88\x1b\\";
for &byte in data {
let (ptr, _) = parser.create_write_buffer();
unsafe {
*ptr = byte;
}
parser.commit_write(1);
while parser.run_parse_pass(&mut handler) {}
}
println!("TEXT: {:?}", handler.text);
println!("OSC calls: {}", handler.osc_calls.len());
for call in &handler.osc_calls {
println!(" OSC: {:?}", std::str::from_utf8(call).unwrap());
}
assert_eq!(handler.text, "", "Text should be empty, but leaked escape sequence bytes!");
assert_eq!(handler.osc_calls.len(), 2, "Should have parsed exactly two OSC calls");
}
#[test]
fn test_csi_aborted_by_osc() {
let parser = SharedParser::new();
let mut handler = DummyHandler { text: String::new(), osc_calls: Vec::new() };
// An incomplete CSI sequence aborted by an OSC sequence
let data = b"\x1b[38;2;255;0;0\x1b]4;1;#769E00\x1b\\";
let (ptr, len) = parser.create_write_buffer();
assert!(len >= data.len());
unsafe {
std::ptr::copy_nonoverlapping(data.as_ptr(), ptr, data.len());
}
parser.commit_write(data.len());
while parser.run_parse_pass(&mut handler) {}
assert_eq!(handler.text, "", "Text should be empty, but leaked escape sequence bytes!");
assert_eq!(handler.osc_calls.len(), 1, "Should have parsed the OSC sequence even after aborting CSI");
}