Compare commits
11 Commits
32ce278743
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c31166c087 | |||
| f07b1724d9 | |||
| 1bbd8fd1b9 | |||
| 3164873a66 | |||
| 7f4249dde9 | |||
| a56a2108ac | |||
| fe0a21bdb9 | |||
| bc552f6f0f | |||
| d2939de936 | |||
| 350be6611c | |||
| 494b684d58 |
@@ -3,3 +3,4 @@
|
||||
/pkg
|
||||
Cargo.lock
|
||||
*.tar.*
|
||||
AGENTS.md
|
||||
|
||||
+7
-1
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
use zterm::vt_parser::*;
|
||||
fn main() {}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,3 +20,4 @@ pub mod statusline;
|
||||
pub mod terminal;
|
||||
pub mod simd_utf8;
|
||||
pub mod vt_parser;
|
||||
mod vt_test_osc;
|
||||
|
||||
+570
-289
File diff suppressed because it is too large
Load Diff
+76
-106
@@ -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();
|
||||
@@ -2141,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.
|
||||
///
|
||||
@@ -2150,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 {
|
||||
@@ -2162,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;
|
||||
}
|
||||
|
||||
@@ -2343,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2969,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.
|
||||
@@ -3680,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);
|
||||
@@ -3707,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 {
|
||||
@@ -4635,7 +4646,7 @@ impl Renderer {
|
||||
{
|
||||
let update_time = t0.elapsed();
|
||||
if update_time.as_micros() > 500 {
|
||||
log::info!("update_gpu_cells took {:?}", update_time);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4679,35 +4690,6 @@ impl Renderer {
|
||||
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) {
|
||||
@@ -4769,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);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5258,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(())
|
||||
}
|
||||
|
||||
|
||||
+22
-104
@@ -298,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.
|
||||
@@ -569,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>,
|
||||
@@ -587,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();
|
||||
|
||||
@@ -632,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
|
||||
@@ -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.
|
||||
#[inline]
|
||||
pub fn clear_dirty_lines(&mut self) {
|
||||
@@ -789,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;
|
||||
|
||||
@@ -1407,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)
|
||||
@@ -1533,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);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1692,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),
|
||||
);
|
||||
@@ -1808,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;
|
||||
@@ -1817,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' => {
|
||||
@@ -1829,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;
|
||||
@@ -1844,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;
|
||||
@@ -1852,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' => {
|
||||
@@ -1863,6 +1776,7 @@ impl Handler for Terminal {
|
||||
self.cursor_col,
|
||||
old_col
|
||||
);
|
||||
self.mark_line_dirty(self.cursor_row);
|
||||
}
|
||||
// Cursor Position
|
||||
'H' | 'f' => {
|
||||
@@ -1876,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' => {
|
||||
@@ -2273,6 +2188,7 @@ impl Handler for Terminal {
|
||||
} else {
|
||||
self.cursor_row += 1;
|
||||
}
|
||||
self.mark_line_dirty(self.cursor_row);
|
||||
}
|
||||
|
||||
fn newline(&mut self) {
|
||||
@@ -2282,6 +2198,7 @@ impl Handler for Terminal {
|
||||
} else {
|
||||
self.cursor_row += 1;
|
||||
}
|
||||
self.mark_line_dirty(self.cursor_row);
|
||||
}
|
||||
|
||||
fn reverse_index(&mut self) {
|
||||
@@ -2290,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) {
|
||||
|
||||
+64
-14
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user