Compare commits

...

11 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
14 changed files with 910 additions and 655 deletions
+1
View File
@@ -3,3 +3,4 @@
/pkg /pkg
Cargo.lock Cargo.lock
*.tar.* *.tar.*
AGENTS.md
+7 -1
View File
@@ -75,13 +75,19 @@ rustc-hash = "2"
# Base64 decoding for OSC statusline # Base64 decoding for OSC statusline
base64 = "0.22" 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 processing (Kitty graphics protocol)
image = { version = "0.25", default-features = false, features = ["png", "gif"] } image = { version = "0.25", default-features = false, features = ["png", "gif"] }
flate2 = "1" flate2 = "1"
# Video decoding for WebM support (video only, no audio) # Video decoding for WebM support (video only, no audio)
# Requires system FFmpeg libraries (ffmpeg 5.x - 8.x supported) # 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] [features]
default = ["webm"] 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::terminal::Terminal;
use zterm::vt_parser::Parser; use zterm::vt_parser::Parser;
use std::time::Instant; use std::time::Instant;
use std::io::Write;
const ASCII_PRINTABLE: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ `~!@#$%^&*()_+-=[]{}\\|;:'\",<.>/?"; const ASCII_PRINTABLE: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ `~!@#$%^&*()_+-=[]{}\\|;:'\",<.>/?";
const CONTROL_CHARS: &[u8] = b"\n\t"; const CONTROL_CHARS: &[u8] = b"\n\t";
-1
View File
@@ -521,7 +521,6 @@ pub fn render_box_char(
c: char, c: char,
cell_width: usize, cell_width: usize,
cell_height: usize, cell_height: usize,
font_size: f32,
dpi: f64, dpi: f64,
) -> Option<(Vec<u8>, bool)> { ) -> Option<(Vec<u8>, bool)> {
let w = cell_width; let w = cell_width;
-3
View File
@@ -387,7 +387,6 @@ impl Config {
}; };
if !config_path.exists() { if !config_path.exists() {
log::info!("No config file found at {:?}, creating with defaults", config_path);
let default_config = Self::default(); let default_config = Self::default();
if let Err(e) = default_config.save() { if let Err(e) = default_config.save() {
log::warn!("Failed to write default config: {}", e); log::warn!("Failed to write default config: {}", e);
@@ -398,7 +397,6 @@ impl Config {
match fs::read_to_string(&config_path) { match fs::read_to_string(&config_path) {
Ok(contents) => match serde_json::from_str(&contents) { Ok(contents) => match serde_json::from_str(&contents) {
Ok(config) => { Ok(config) => {
log::info!("Loaded config from {:?}", config_path);
config config
} }
Err(e) => { Err(e) => {
@@ -431,7 +429,6 @@ impl Config {
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
fs::write(&config_path, json)?; fs::write(&config_path, json)?;
log::info!("Saved config to {:?}", config_path);
Ok(()) 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 // Try to use fontconfig to find the font family
if let Some(family) = font_family { if let Some(family) = font_family {
let paths = find_font_family_variants(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) // Load the regular font (required)
if let Some(regular_path) = &paths[0] { 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)), 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); 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 primary_font = regular_variant.clone_font();
let font_data = regular_variant.clone_data(); let font_data = regular_variant.clone_data();
let variants: [Option<FontVariant>; 4] = [Some(regular_variant), None, None, None]; let variants: [Option<FontVariant>; 4] = [Some(regular_variant), None, None, None];
log::info!("Loaded NotoSansMono as fallback");
return (font_data, primary_font, variants); return (font_data, primary_font, variants);
} }
+1
View File
@@ -20,3 +20,4 @@ pub mod statusline;
pub mod terminal; pub mod terminal;
pub mod simd_utf8; pub mod simd_utf8;
pub mod vt_parser; pub mod vt_parser;
mod vt_test_osc;
+643 -362
View File
File diff suppressed because it is too large Load Diff
+87 -117
View File
@@ -198,6 +198,7 @@ enum SpriteTarget {
} }
/// The terminal renderer. /// The terminal renderer.
#[allow(dead_code)]
pub struct Renderer { pub struct Renderer {
surface: wgpu::Surface<'static>, surface: wgpu::Surface<'static>,
device: wgpu::Device, device: wgpu::Device,
@@ -1552,6 +1553,53 @@ impl Renderer {
Some((col, row)) 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. /// Updates the scale factor and recalculates font/cell dimensions.
/// Returns true if the cell dimensions changed (terminal needs resize). /// Returns true if the cell dimensions changed (terminal needs resize).
pub fn set_scale_factor(&mut self, new_scale: f64) -> bool { 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 // Update the font units to pixels scale factor
self.font_units_to_px = self.font_size / self.primary_font.height_unscaled(); 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) // Reset atlas and all sprite/glyph caches (includes cursor sprite creation)
self.reset_atlas(); self.reset_atlas();
@@ -1648,11 +1691,6 @@ impl Renderer {
// Update the font units to pixels scale factor // Update the font units to pixels scale factor
self.font_units_to_px = self.font_size / self.primary_font.height_unscaled(); 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) // Reset atlas and all sprite/glyph caches (includes cursor sprite creation)
self.reset_atlas(); self.reset_atlas();
@@ -1666,7 +1704,7 @@ impl Renderer {
/// NOTE: This should ONLY be called for font/scale changes, NOT when atlas is full /// 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()). /// (for that case, we add a new layer via add_atlas_layer()).
fn reset_atlas(&mut self) { 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 // Clear all glyph caches - they need to be re-rasterized at new size
self.char_cache.clear(); self.char_cache.clear();
@@ -2141,6 +2179,21 @@ impl Renderer {
self.pane_resources.retain(|id, _| active_pane_ids.contains(id)); 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. /// Update GPU cell buffer from terminal content.
/// Like Kitty, this only processes dirty lines to minimize work. /// Like Kitty, this only processes dirty lines to minimize work.
/// ///
@@ -2150,10 +2203,6 @@ impl Renderer {
let rows = terminal.rows; let rows = terminal.rows;
let total_cells = cols * 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 // Check if grid size changed - need full rebuild
let size_changed = self.last_grid_size != (cols, rows); let size_changed = self.last_grid_size != (cols, rows);
if size_changed { if size_changed {
@@ -2162,14 +2211,17 @@ impl Renderer {
self.cells_dirty = true; 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 // First pass: ensure all characters have sprites
// This needs mutable access to self for sprite creation // This needs mutable access to self for sprite creation
// Like Kitty's render_line(), detect PUA+space patterns for multi-cell rendering // 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: Only process dirty lines or when full rebuild is needed
// OPTIMIZATION: Use get_visible_row() to avoid Vec allocation // OPTIMIZATION: Use get_visible_row() to avoid Vec allocation
for row_idx in 0..rows { for row_idx in 0..rows {
// Skip clean lines (unless size changed, which sets cells_dirty) // Skip clean lines (unless size changed or terminal has dirty lines)
if !self.cells_dirty && !terminal.is_line_dirty(row_idx) { if !self.cells_dirty && !has_dirty && !terminal.is_line_dirty(row_idx) {
continue; continue;
} }
@@ -2343,61 +2395,21 @@ impl Renderer {
} }
// Second pass: convert cells to GPU format // 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; let mut any_updated = false;
// DEBUG: Log grid dimensions and buffer state for row_idx in 0..rows {
static DEBUG_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); if let Some(row) = terminal.get_visible_row(row_idx) {
let frame_num = DEBUG_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let start = row_idx * cols;
if frame_num % 60 == 0 { // Log every 60 frames (~1 second at 60fps) let end = start + cols;
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 end > self.gpu_cells.len() {
} continue;
// 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;
}
} }
Self::cells_to_gpu_row_static(row, &mut self.gpu_cells[start..end], cols, &self.sprite_map);
any_updated = true;
} }
} }
@@ -2969,7 +2981,6 @@ impl Renderer {
c, c,
self.cell_metrics.cell_width as usize, self.cell_metrics.cell_width as usize,
self.cell_metrics.cell_height as usize, self.cell_metrics.cell_height as usize,
self.font_size,
self.dpi, self.dpi,
) { ) {
// Box-drawing bitmaps are already cell-sized and fill from top-left. // Box-drawing bitmaps are already cell-sized and fill from top-left.
@@ -3680,7 +3691,7 @@ impl Renderer {
return; 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) // Create real texture for the new layer (replacing the dummy)
self.ensure_atlas_layer_capacity(new_layer); self.ensure_atlas_layer_capacity(new_layer);
@@ -3707,7 +3718,7 @@ impl Renderer {
return; return;
} }
log::info!("Adding atlas layer {} (replacing dummy texture)", target_layer);
// Create new real texture (8192x8192) // Create new real texture (8192x8192)
let texture = self.device.create_texture(&wgpu::TextureDescriptor { let texture = self.device.create_texture(&wgpu::TextureDescriptor {
@@ -4635,7 +4646,7 @@ impl Renderer {
{ {
let update_time = t0.elapsed(); let update_time = t0.elapsed();
if update_time.as_micros() > 500 { if update_time.as_micros() > 500 {
log::info!("update_gpu_cells took {:?}", update_time);
} }
} }
@@ -4678,36 +4689,7 @@ impl Renderer {
selection_end_col: sel_end_col, selection_end_col: sel_end_col,
selection_end_row: sel_end_row, 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) // 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 // 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) { if let Some(pane_res) = self.pane_resources.get(&info.pane_id) {
@@ -4769,7 +4751,7 @@ impl Renderer {
{ {
let pane_loop_time = pane_loop_start.elapsed(); let pane_loop_time = pane_loop_start.elapsed();
if pane_loop_time.as_micros() > 500 { if pane_loop_time.as_micros() > 500 {
log::info!("pane_loop took {:?}", pane_loop_time);
} }
} }
@@ -5257,20 +5239,8 @@ impl Renderer {
#[cfg(feature = "render_timing")] #[cfg(feature = "render_timing")]
let after_submit = frame_start.elapsed(); let after_submit = frame_start.elapsed();
output.present(); 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(()) Ok(())
} }
+22 -104
View File
@@ -298,87 +298,6 @@ struct AlternateScreen {
scroll_bottom: usize, 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. /// Kitty-style ring buffer for scrollback history.
/// ///
/// Pre-allocates all lines upfront to avoid allocation during scrolling. /// Pre-allocates all lines upfront to avoid allocation during scrolling.
@@ -569,7 +488,7 @@ pub struct Terminal {
/// Synchronized output mode (for reducing flicker). /// Synchronized output mode (for reducing flicker).
synchronized_output: bool, synchronized_output: bool,
/// Performance timing stats (for debugging). /// Performance timing stats (for debugging).
pub stats: ProcessingStats,
/// Command queue for terminal-to-application communication. /// Command queue for terminal-to-application communication.
/// Commands are added by OSC handlers and consumed by the application. /// Commands are added by OSC handlers and consumed by the application.
command_queue: Vec<TerminalCommand>, command_queue: Vec<TerminalCommand>,
@@ -587,12 +506,6 @@ impl Terminal {
/// Creates a new terminal with the given dimensions and scrollback limit. /// Creates a new terminal with the given dimensions and scrollback limit.
pub fn new(cols: usize, rows: usize, scrollback_limit: usize) -> Self { 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 grid = vec![vec![Cell::default(); cols]; rows];
let line_map: Vec<usize> = (0..rows).collect(); let line_map: Vec<usize> = (0..rows).collect();
@@ -632,7 +545,7 @@ impl Terminal {
bracketed_paste: false, bracketed_paste: false,
focus_reporting: false, focus_reporting: false,
synchronized_output: false, synchronized_output: false,
stats: ProcessingStats::default(),
command_queue: Vec::new(), command_queue: Vec::new(),
image_storage: ImageStorage::new(), image_storage: ImageStorage::new(),
cell_width: 10.0, // Default, will be set by renderer cell_width: 10.0, // Default, will be set by renderer
@@ -668,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. /// Clear all dirty line flags.
#[inline] #[inline]
pub fn clear_dirty_lines(&mut self) { pub fn clear_dirty_lines(&mut self) {
@@ -789,14 +708,6 @@ impl Terminal {
return; return;
} }
log::info!(
"Terminal::resize: {}x{} -> {}x{}",
self.cols,
self.rows,
cols,
rows
);
let old_cols = self.cols; let old_cols = self.cols;
let old_rows = self.rows; let old_rows = self.rows;
@@ -1407,7 +1318,6 @@ impl Handler for Terminal {
// Look for patterns like "38;2;128" or "4;64;64m" - these are SGR parameters // Look for patterns like "38;2;128" or "4;64;64m" - these are SGR parameters
if codepoints.len() >= 3 { if codepoints.len() >= 3 {
let has_semicolon = codepoints.iter().any(|&c| c == 0x3B); // ';' let has_semicolon = codepoints.iter().any(|&c| c == 0x3B); // ';'
let has_m = codepoints.iter().any(|&c| c == 0x6D); // 'm'
let mostly_digits = codepoints let mostly_digits = codepoints
.iter() .iter()
.filter(|&&c| c >= 0x30 && c <= 0x39) .filter(|&&c| c >= 0x30 && c <= 0x39)
@@ -1533,16 +1443,19 @@ impl Handler for Terminal {
if self.cursor_col > 0 { if self.cursor_col > 0 {
self.cursor_col -= 1; self.cursor_col -= 1;
} }
self.mark_line_dirty(self.cursor_row);
} }
0x09 => { 0x09 => {
let next_tab = (self.cursor_col / 8 + 1) * 8; let next_tab = (self.cursor_col / 8 + 1) * 8;
self.cursor_col = next_tab.min(self.cols - 1); self.cursor_col = next_tab.min(self.cols - 1);
self.mark_line_dirty(self.cursor_row);
} }
0x0A | 0x0B | 0x0C => { 0x0A | 0x0B | 0x0C => {
self.advance_row(); self.advance_row();
} }
0x0D => { 0x0D => {
self.cursor_col = 0; self.cursor_col = 0;
self.mark_line_dirty(self.cursor_row);
} }
_ => {} _ => {}
} }
@@ -1692,12 +1605,6 @@ impl Handler for Terminal {
let statusline = let statusline =
content.filter(|s| !s.is_empty()); 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( self.command_queue.push(
TerminalCommand::SetStatusline(statusline), TerminalCommand::SetStatusline(statusline),
); );
@@ -1808,6 +1715,7 @@ impl Handler for Terminal {
if self.origin_mode { self.scroll_top } else { 0 }; if self.origin_mode { self.scroll_top } else { 0 };
self.cursor_row = self.cursor_row =
self.cursor_row.saturating_sub(n).max(min_row); self.cursor_row.saturating_sub(n).max(min_row);
self.mark_line_dirty(self.cursor_row);
} }
'B' => { 'B' => {
let n = params.get(0, 1).max(1) as usize; let n = params.get(0, 1).max(1) as usize;
@@ -1817,6 +1725,7 @@ impl Handler for Terminal {
self.rows - 1 self.rows - 1
}; };
self.cursor_row = (self.cursor_row + n).min(max_row); self.cursor_row = (self.cursor_row + n).min(max_row);
self.mark_line_dirty(self.cursor_row);
} }
// Cursor Forward // Cursor Forward
'C' => { 'C' => {
@@ -1829,11 +1738,13 @@ impl Handler for Terminal {
old_col, old_col,
self.cursor_col self.cursor_col
); );
self.mark_line_dirty(self.cursor_row);
} }
// Cursor Back // Cursor Back
'D' => { 'D' => {
let n = params.get(0, 1).max(1) as usize; let n = params.get(0, 1).max(1) as usize;
self.cursor_col = self.cursor_col.saturating_sub(n); self.cursor_col = self.cursor_col.saturating_sub(n);
self.mark_line_dirty(self.cursor_row);
} }
'E' => { 'E' => {
let n = params.get(0, 1).max(1) as usize; let n = params.get(0, 1).max(1) as usize;
@@ -1844,6 +1755,7 @@ impl Handler for Terminal {
}; };
self.cursor_col = 0; self.cursor_col = 0;
self.cursor_row = (self.cursor_row + n).min(max_row); self.cursor_row = (self.cursor_row + n).min(max_row);
self.mark_line_dirty(self.cursor_row);
} }
'F' => { 'F' => {
let n = params.get(0, 1).max(1) as usize; let n = params.get(0, 1).max(1) as usize;
@@ -1852,6 +1764,7 @@ impl Handler for Terminal {
self.cursor_col = 0; self.cursor_col = 0;
self.cursor_row = self.cursor_row =
self.cursor_row.saturating_sub(n).max(min_row); self.cursor_row.saturating_sub(n).max(min_row);
self.mark_line_dirty(self.cursor_row);
} }
// Cursor Horizontal Absolute (CHA) // Cursor Horizontal Absolute (CHA)
'G' => { 'G' => {
@@ -1863,6 +1776,7 @@ impl Handler for Terminal {
self.cursor_col, self.cursor_col,
old_col old_col
); );
self.mark_line_dirty(self.cursor_row);
} }
// Cursor Position // Cursor Position
'H' | 'f' => { 'H' | 'f' => {
@@ -1876,6 +1790,7 @@ impl Handler for Terminal {
self.cursor_row = (row - 1).min(self.rows - 1); self.cursor_row = (row - 1).min(self.rows - 1);
} }
self.cursor_col = (col - 1).min(self.cols - 1); self.cursor_col = (col - 1).min(self.cols - 1);
self.mark_line_dirty(self.cursor_row);
} }
// Erase in Display // Erase in Display
'J' => { 'J' => {
@@ -2273,6 +2188,7 @@ impl Handler for Terminal {
} else { } else {
self.cursor_row += 1; self.cursor_row += 1;
} }
self.mark_line_dirty(self.cursor_row);
} }
fn newline(&mut self) { fn newline(&mut self) {
@@ -2282,6 +2198,7 @@ impl Handler for Terminal {
} else { } else {
self.cursor_row += 1; self.cursor_row += 1;
} }
self.mark_line_dirty(self.cursor_row);
} }
fn reverse_index(&mut self) { fn reverse_index(&mut self) {
@@ -2290,6 +2207,7 @@ impl Handler for Terminal {
} else { } else {
self.cursor_row -= 1; self.cursor_row -= 1;
} }
self.mark_line_dirty(self.cursor_row);
} }
fn set_tab_stop(&mut self) { fn set_tab_stop(&mut self) {
+66 -16
View File
@@ -482,7 +482,9 @@ impl SharedParser {
// Like Kitty line 1516: consume_input(self, ...) // Like Kitty line 1516: consume_input(self, ...)
let made_progress = self.consume_input(handler); let made_progress = self.consume_input(handler);
parsed_any = true; if made_progress {
parsed_any = true;
}
// Re-acquire lock // Re-acquire lock
state = self.state.lock().unwrap(); state = self.state.lock().unwrap();
@@ -635,14 +637,14 @@ impl SharedParser {
true true
} }
State::Csi => { State::Csi => {
// Like Kitty lines 1465-1466: let state_before = *vte_state;
// if (consume_csi(self)) { self->read.consumed = self->read.pos; if (self->csi.is_valid) dispatch_csi(self); SET_STATE(NORMAL); }
if Self::consume_csi_impl( if Self::consume_csi_impl(
handler, handler,
buf, buf,
parse_pos, parse_pos,
parse_sz, parse_sz,
*parse_consumed, parse_consumed,
vte_state,
csi, csi,
escape_len, escape_len,
) { ) {
@@ -650,30 +652,41 @@ impl SharedParser {
if csi.is_valid { if csi.is_valid {
handler.csi(csi); handler.csi(csi);
} }
*vte_state = State::Normal; if *vte_state == state_before {
*vte_state = State::Normal;
}
true true
} else { } else {
false *vte_state != state_before
} }
} }
State::Osc => { State::Osc => {
let state_before = *vte_state;
if Self::consume_osc_impl( 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, escape_len,
) { ) {
*parse_consumed = *parse_pos; *parse_consumed = *parse_pos;
*vte_state = State::Normal; *vte_state = State::Normal;
true true
} else { } else {
false *vte_state != state_before
} }
} }
State::Dcs | State::Apc | State::Pm | State::Sos => { State::Dcs | State::Apc | State::Pm | State::Sos => {
let state_before = *vte_state;
if Self::consume_string_impl( if Self::consume_string_impl(
handler, handler,
buf, buf,
parse_pos, parse_pos,
parse_sz, parse_sz,
parse_consumed,
vte_state, vte_state,
string_buffer, string_buffer,
escape_len, escape_len,
@@ -682,7 +695,7 @@ impl SharedParser {
*vte_state = State::Normal; *vte_state = State::Normal;
true true
} else { } else {
false *vte_state != state_before
} }
} }
}; };
@@ -837,6 +850,11 @@ impl SharedParser {
b'\\' => { b'\\' => {
*vte_state = State::Normal; *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); log::debug!("Unknown escape sequence: ESC {:02x}", ch);
*vte_state = State::Normal; *vte_state = State::Normal;
@@ -913,7 +931,8 @@ impl SharedParser {
buf: &[u8; BUF_SIZE], buf: &[u8; BUF_SIZE],
parse_pos: &mut usize, parse_pos: &mut usize,
parse_sz: usize, parse_sz: usize,
parse_consumed: usize, parse_consumed: &mut usize,
vte_state: &mut State,
csi: &mut CsiParams, csi: &mut CsiParams,
escape_len: &mut usize, escape_len: &mut usize,
) -> bool { ) -> bool {
@@ -922,8 +941,15 @@ impl SharedParser {
*parse_pos += 1; *parse_pos += 1;
*escape_len += 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 // Handle embedded control characters
if ch <= 0x1F && ch != 0x1B { if ch <= 0x1F {
handler.control(ch); handler.control(ch);
continue; continue;
} }
@@ -1023,7 +1049,7 @@ impl SharedParser {
} }
// Check max length // 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"); log::debug!("CSI escape too long, ignoring");
return true; return true;
} }
@@ -1037,6 +1063,7 @@ impl SharedParser {
buf: &[u8; BUF_SIZE], buf: &[u8; BUF_SIZE],
parse_pos: &mut usize, parse_pos: &mut usize,
parse_sz: usize, parse_sz: usize,
parse_consumed: &mut usize,
vte_state: &mut State, vte_state: &mut State,
osc_buffer: &mut Vec<u8>, osc_buffer: &mut Vec<u8>,
escape_len: &mut usize, escape_len: &mut usize,
@@ -1069,6 +1096,7 @@ impl SharedParser {
*parse_pos += 1; *parse_pos += 1;
handler.osc(osc_buffer); handler.osc(osc_buffer);
*vte_state = State::Escape; *vte_state = State::Escape;
*parse_consumed = *parse_pos;
*escape_len = 0; *escape_len = 0;
return false; return false;
} else { } else {
@@ -1098,6 +1126,7 @@ impl SharedParser {
buf: &[u8; BUF_SIZE], buf: &[u8; BUF_SIZE],
parse_pos: &mut usize, parse_pos: &mut usize,
parse_sz: usize, parse_sz: usize,
parse_consumed: &mut usize,
vte_state: &mut State, vte_state: &mut State,
string_buffer: &mut Vec<u8>, string_buffer: &mut Vec<u8>,
escape_len: &mut usize, escape_len: &mut usize,
@@ -1128,10 +1157,17 @@ impl SharedParser {
); );
return true; return true;
} else if *parse_pos + 1 < parse_sz { } else if *parse_pos + 1 < parse_sz {
// ESC not followed by \ - include in buffer // ESC not followed by \ - abort string, start new escape
string_buffer.push(ch);
*parse_pos += 1; *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 { } else {
// ESC at end of buffer - need more data // ESC at end of buffer - need more data
return false; return false;
@@ -1368,6 +1404,11 @@ impl Parser {
self.state = State::Normal; self.state = State::Normal;
1 1
} }
0x1B => {
// ESC followed by ESC. Start new escape sequence.
self.state = State::Escape;
1
}
_ => { _ => {
// Unknown escape sequence, ignore and return to normal // Unknown escape sequence, ignore and return to normal
log::debug!("Unknown escape sequence: ESC {:02x}", ch); log::debug!("Unknown escape sequence: ESC {:02x}", ch);
@@ -1442,8 +1483,17 @@ impl Parser {
return consumed; 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) // Handle control characters embedded in CSI (common to all states)
if ch <= 0x1F && ch != 0x1B { if ch <= 0x1F {
handler.control(ch); handler.control(ch);
continue; 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");
}