tick system, AVX2 UTF-8 decoder, uh faster in general

This commit is contained in:
Zacharias-Brohn
2025-12-22 00:22:55 +01:00
parent f6a5e23f3d
commit 73b52ab341
30 changed files with 10231 additions and 5210 deletions
+137 -30
View File
@@ -1,36 +1,143 @@
use zterm::terminal::Terminal;
use zterm::vt_parser::Parser;
use std::time::Instant;
use std::io::Write;
fn main() {
// Generate seq 1 100000 output
let mut data = Vec::new();
for i in 1..=100000 {
writeln!(&mut data, "{}", i).unwrap();
const ASCII_PRINTABLE: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ `~!@#$%^&*()_+-=[]{}\\|;:'\",<.>/?";
const CONTROL_CHARS: &[u8] = b"\n\t";
// Match Kitty's default repetitions
const REPETITIONS: usize = 100;
fn random_string(len: usize, rng: &mut u64) -> Vec<u8> {
let alphabet_len = (ASCII_PRINTABLE.len() + CONTROL_CHARS.len()) as u64;
let mut result = Vec::with_capacity(len);
for _ in 0..len {
// Simple LCG random
*rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
let idx = ((*rng >> 33) % alphabet_len) as usize;
if idx < ASCII_PRINTABLE.len() {
result.push(ASCII_PRINTABLE[idx]);
} else {
result.push(CONTROL_CHARS[idx - ASCII_PRINTABLE.len()]);
}
}
println!("Data size: {} bytes", data.len());
// Test with different terminal sizes to see scroll impact
for rows in [24, 100, 1000] {
let mut terminal = Terminal::new(80, rows, 10000);
let start = Instant::now();
terminal.process(&data);
let elapsed = start.elapsed();
println!("Terminal {}x{}: {:?} ({:.2} MB/s)",
80, rows,
elapsed,
(data.len() as f64 / 1024.0 / 1024.0) / elapsed.as_secs_f64()
);
}
// Test with scrollback disabled
println!("\nWith scrollback disabled:");
let mut terminal = Terminal::new(80, 24, 0);
let start = Instant::now();
terminal.process(&data);
let elapsed = start.elapsed();
println!("Terminal 80x24, no scrollback: {:?} ({:.2} MB/s)",
elapsed,
(data.len() as f64 / 1024.0 / 1024.0) / elapsed.as_secs_f64()
);
result
}
/// Run a benchmark with multiple repetitions like Kitty does
fn run_benchmark<F>(name: &str, data: &[u8], repetitions: usize, mut setup: F)
where
F: FnMut() -> (Terminal, Parser),
{
let data_size = data.len();
let total_size = data_size * repetitions;
// Warmup run
let (mut terminal, mut parser) = setup();
parser.parse(data, &mut terminal);
// Timed runs
let start = Instant::now();
for _ in 0..repetitions {
let (mut terminal, mut parser) = setup();
parser.parse(data, &mut terminal);
}
let elapsed = start.elapsed();
let mb = total_size as f64 / 1024.0 / 1024.0;
let rate = mb / elapsed.as_secs_f64();
println!(" {:<24} : {:>6.2}s @ {:.1} MB/s ({} reps, {:.2} MB each)",
name, elapsed.as_secs_f64(), rate, repetitions, data_size as f64 / 1024.0 / 1024.0);
}
fn main() {
println!("=== ZTerm VT Parser Benchmark ===");
println!("Matching Kitty's kitten __benchmark__ methodology\n");
// Benchmark 1: Only ASCII chars (matches Kitty's simple_ascii)
println!("--- Only ASCII chars ---");
let target_sz = 1024 * 2048 + 13;
let mut rng: u64 = 12345;
let mut ascii_data = Vec::with_capacity(target_sz);
let alphabet = [ASCII_PRINTABLE, CONTROL_CHARS].concat();
for _ in 0..target_sz {
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
let idx = ((rng >> 33) % alphabet.len() as u64) as usize;
ascii_data.push(alphabet[idx]);
}
run_benchmark("Only ASCII chars", &ascii_data, REPETITIONS, || {
(Terminal::new(80, 25, 20000), Parser::new())
});
// Benchmark 2: CSI codes with few chars (matches Kitty's ascii_with_csi)
println!("\n--- CSI codes with few chars ---");
let target_sz = 1024 * 1024 + 17;
let mut csi_data = Vec::with_capacity(target_sz + 100);
let mut rng: u64 = 12345; // Fixed seed for reproducibility
while csi_data.len() < target_sz {
// Simple LCG random for chunk selection
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
let q = ((rng >> 33) % 100) as u32;
match q {
0..=9 => {
// 10%: random ASCII text (1-72 chars)
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
let len = ((rng >> 33) % 72 + 1) as usize;
csi_data.extend(random_string(len, &mut rng));
}
10..=29 => {
// 20%: cursor movement
csi_data.extend_from_slice(b"\x1b[m\x1b[?1h\x1b[H");
}
30..=39 => {
// 10%: basic SGR attributes
csi_data.extend_from_slice(b"\x1b[1;2;3;4:3;31m");
}
40..=49 => {
// 10%: SGR with 256-color + RGB (colon-separated subparams)
csi_data.extend_from_slice(b"\x1b[38:5:24;48:2:125:136:147m");
}
50..=59 => {
// 10%: SGR with underline color
csi_data.extend_from_slice(b"\x1b[58;5;44;2m");
}
60..=79 => {
// 20%: cursor movement + erase
csi_data.extend_from_slice(b"\x1b[m\x1b[10A\x1b[3E\x1b[2K");
}
_ => {
// 20%: reset + cursor + repeat + mode
csi_data.extend_from_slice(b"\x1b[39m\x1b[10`a\x1b[100b\x1b[?1l");
}
}
}
csi_data.extend_from_slice(b"\x1b[m");
run_benchmark("CSI codes with few chars", &csi_data, REPETITIONS, || {
(Terminal::new(80, 25, 20000), Parser::new())
});
// Benchmark 3: Long escape codes (matches Kitty's long_escape_codes)
println!("\n--- Long escape codes ---");
let mut long_esc_data = Vec::new();
let long_content: String = (0..8024).map(|i| ASCII_PRINTABLE[i % ASCII_PRINTABLE.len()] as char).collect();
for _ in 0..1024 {
// OSC 6 - document reporting, ignored after parsing
long_esc_data.extend_from_slice(b"\x1b]6;");
long_esc_data.extend_from_slice(long_content.as_bytes());
long_esc_data.push(0x07); // BEL terminator
}
run_benchmark("Long escape codes", &long_esc_data, REPETITIONS, || {
(Terminal::new(80, 25, 20000), Parser::new())
});
println!("\n=== Benchmark Complete ===");
println!("\nNote: These benchmarks include terminal state updates but NOT GPU rendering.");
println!("Compare with: kitten __benchmark__ (without --render flag)");
}
+1610
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
//! Linear color palette for GPU rendering.
//!
//! Provides pre-computed sRGB to linear RGB conversion for efficient GPU color handling.
use crate::terminal::ColorPalette;
/// Pre-computed linear RGB color palette.
/// Avoids repeated sRGB→linear conversions during rendering.
/// The color_table contains [258][4] floats: 256 indexed colors + default fg (256) + default bg (257).
#[derive(Clone)]
pub struct LinearPalette {
/// Pre-computed linear RGBA colors ready for GPU upload.
/// Index 0-255: palette colors, 256: default_fg, 257: default_bg
pub color_table: [[f32; 4]; 258],
}
impl LinearPalette {
/// Convert sRGB component (0.0-1.0) to linear RGB.
#[inline]
pub fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
/// Convert an sRGB [u8; 3] color to linear [f32; 4] with alpha=1.0.
#[inline]
pub fn rgb_to_linear(rgb: [u8; 3]) -> [f32; 4] {
[
Self::srgb_to_linear(rgb[0] as f32 / 255.0),
Self::srgb_to_linear(rgb[1] as f32 / 255.0),
Self::srgb_to_linear(rgb[2] as f32 / 255.0),
1.0,
]
}
/// Create a LinearPalette from a ColorPalette.
pub fn from_palette(palette: &ColorPalette) -> Self {
let mut color_table = [[0.0f32; 4]; 258];
for i in 0..256 {
color_table[i] = Self::rgb_to_linear(palette.colors[i]);
}
color_table[256] = Self::rgb_to_linear(palette.default_fg);
color_table[257] = Self::rgb_to_linear(palette.default_bg);
Self { color_table }
}
}
impl Default for LinearPalette {
fn default() -> Self {
Self::from_palette(&ColorPalette::default())
}
}
+398
View File
@@ -0,0 +1,398 @@
//! Color font (emoji) rendering using FreeType + Cairo.
//!
//! This module provides color emoji rendering support by using FreeType to load
//! color fonts (COLR, CBDT, sbix formats) and Cairo to render them.
use cairo::{Format, ImageSurface};
use freetype::Library as FtLibrary;
use std::collections::HashMap;
use std::ffi::CStr;
use std::path::PathBuf;
// ═══════════════════════════════════════════════════════════════════════════════
// COLOR FONT LOOKUP
// ═══════════════════════════════════════════════════════════════════════════════
/// Find a color font (emoji font) that contains the given character using fontconfig.
/// Returns the path to the font file if found.
pub fn find_color_font_for_char(c: char) -> Option<PathBuf> {
use fontconfig_sys as fcsys;
use fcsys::*;
use fcsys::constants::{FC_CHARSET, FC_COLOR, FC_FILE};
log::debug!("find_color_font_for_char: looking for color font for U+{:04X} '{}'", c as u32, c);
unsafe {
// Create a pattern
let pat = FcPatternCreate();
if pat.is_null() {
log::debug!("find_color_font_for_char: FcPatternCreate failed");
return None;
}
// Create a charset with the target character
let charset = FcCharSetCreate();
if charset.is_null() {
FcPatternDestroy(pat);
log::debug!("find_color_font_for_char: FcCharSetCreate failed");
return None;
}
// Add the character to the charset
FcCharSetAddChar(charset, c as u32);
// Add the charset to the pattern
FcPatternAddCharSet(pat, FC_CHARSET.as_ptr() as *const i8, charset);
// Request a color font
FcPatternAddBool(pat, FC_COLOR.as_ptr() as *const i8, 1); // FcTrue = 1
// Run substitutions
FcConfigSubstitute(std::ptr::null_mut(), pat, FcMatchPattern);
FcDefaultSubstitute(pat);
// Find matching font
let mut result = FcResultNoMatch;
let matched = FcFontMatch(std::ptr::null_mut(), pat, &mut result);
let font_path = if !matched.is_null() && result == FcResultMatch {
// Check if the matched font is actually a color font
let mut is_color: i32 = 0;
let has_color = FcPatternGetBool(matched, FC_COLOR.as_ptr() as *const i8, 0, &mut is_color) == FcResultMatch && is_color != 0;
log::debug!("find_color_font_for_char: matched font, is_color={}", has_color);
if has_color {
// Get the file path from the matched pattern
let mut file_ptr: *mut u8 = std::ptr::null_mut();
if FcPatternGetString(matched, FC_FILE.as_ptr() as *const i8, 0, &mut file_ptr) == FcResultMatch {
let path_cstr = CStr::from_ptr(file_ptr as *const i8);
let path = PathBuf::from(path_cstr.to_string_lossy().into_owned());
log::debug!("find_color_font_for_char: found color font {:?}", path);
Some(path)
} else {
log::debug!("find_color_font_for_char: couldn't get file path");
None
}
} else {
log::debug!("find_color_font_for_char: matched font is not a color font");
None
}
} else {
log::debug!("find_color_font_for_char: no match found (result={:?})", result);
None
};
// Cleanup
if !matched.is_null() {
FcPatternDestroy(matched);
}
FcCharSetDestroy(charset);
FcPatternDestroy(pat);
font_path
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// COLOR FONT RENDERER
// ═══════════════════════════════════════════════════════════════════════════════
/// Lazy-initialized color font renderer using FreeType + Cairo.
/// Only created when a color emoji is first encountered.
/// Cairo is required for proper color font rendering (COLR, CBDT, sbix formats).
pub struct ColorFontRenderer {
/// FreeType library instance
ft_library: FtLibrary,
/// Loaded FreeType faces and their Cairo font faces, keyed by font path
faces: HashMap<PathBuf, (freetype::Face, cairo::FontFace)>,
/// Reusable Cairo surface for rendering
surface: Option<ImageSurface>,
/// Current surface dimensions
surface_size: (i32, i32),
}
impl ColorFontRenderer {
pub fn new() -> Result<Self, freetype::Error> {
let ft_library = FtLibrary::init()?;
Ok(Self {
ft_library,
faces: HashMap::new(),
surface: None,
surface_size: (0, 0),
})
}
/// Ensure faces are loaded and return font size to set
fn ensure_faces_loaded(&mut self, path: &PathBuf) -> bool {
if !self.faces.contains_key(path) {
match self.ft_library.new_face(path, 0) {
Ok(ft_face) => {
// Create Cairo font face from FreeType face
match cairo::FontFace::create_from_ft(&ft_face) {
Ok(cairo_face) => {
self.faces.insert(path.clone(), (ft_face, cairo_face));
true
}
Err(e) => {
log::warn!("Failed to create Cairo font face for {:?}: {:?}", path, e);
false
}
}
}
Err(e) => {
log::warn!("Failed to load color font {:?}: {:?}", path, e);
false
}
}
} else {
true
}
}
/// Render a color glyph using FreeType + Cairo.
/// Returns (width, height, RGBA bitmap, offset_x, offset_y) or None if rendering fails.
pub fn render_color_glyph(
&mut self,
font_path: &PathBuf,
c: char,
font_size_px: f32,
cell_width: u32,
cell_height: u32,
) -> Option<(u32, u32, Vec<u8>, f32, f32)> {
log::debug!("render_color_glyph: U+{:04X} '{}' font={:?}", c as u32, c, font_path);
// Ensure faces are loaded
if !self.ensure_faces_loaded(font_path) {
log::debug!("render_color_glyph: failed to load faces");
return None;
}
log::debug!("render_color_glyph: faces loaded successfully, faces count={}", self.faces.len());
// Get glyph index from FreeType face
// Note: We do NOT call set_pixel_sizes here because CBDT (bitmap) fonts have fixed sizes
// and will fail. Cairo handles font sizing internally.
let glyph_index = {
let face_entry = self.faces.get(font_path);
if face_entry.is_none() {
log::debug!("render_color_glyph: face not found in hashmap after ensure_faces_loaded!");
return None;
}
let (ft_face, _) = face_entry?;
log::debug!("render_color_glyph: got ft_face, getting char index for U+{:04X}", c as u32);
let idx = ft_face.get_char_index(c as usize);
log::debug!("render_color_glyph: FreeType glyph index for U+{:04X} = {:?}", c as u32, idx);
if idx.is_none() {
log::debug!("render_color_glyph: glyph index is None - char not in font!");
return None;
}
idx?
};
// Clone the Cairo font face (it's reference-counted)
let cairo_face = {
let (_, cairo_face) = self.faces.get(font_path)?;
cairo_face.clone()
};
// For emoji, we typically render at 2x cell width (double-width character)
let render_width = (cell_width * 2).max(cell_height) as i32;
let render_height = cell_height as i32;
log::debug!("render_color_glyph: render size {}x{}", render_width, render_height);
// Ensure we have a large enough surface
let surface_width = render_width.max(256);
let surface_height = render_height.max(256);
if self.surface.is_none() || self.surface_size.0 < surface_width || self.surface_size.1 < surface_height {
let new_width = surface_width.max(self.surface_size.0);
let new_height = surface_height.max(self.surface_size.1);
match ImageSurface::create(Format::ARgb32, new_width, new_height) {
Ok(surface) => {
log::debug!("render_color_glyph: created Cairo surface {}x{}", new_width, new_height);
self.surface = Some(surface);
self.surface_size = (new_width, new_height);
}
Err(e) => {
log::warn!("Failed to create Cairo surface: {:?}", e);
return None;
}
}
}
let surface = self.surface.as_mut()?;
// Create Cairo context
let cr = match cairo::Context::new(surface) {
Ok(cr) => cr,
Err(e) => {
log::warn!("Failed to create Cairo context: {:?}", e);
return None;
}
};
// Clear the surface
cr.set_operator(cairo::Operator::Clear);
cr.paint().ok()?;
cr.set_operator(cairo::Operator::Over);
// Set the font face and initial size
cr.set_font_face(&cairo_face);
// Target dimensions for the glyph (2 cells wide, 1 cell tall for emoji)
let target_width = render_width as f64;
let target_height = render_height as f64;
// Start with the requested font size and reduce until glyph fits
// This matches Kitty's fit_cairo_glyph() approach
let mut current_size = font_size_px as f64;
let min_size = 2.0;
cr.set_font_size(current_size);
let mut glyph = cairo::Glyph::new(glyph_index as u64, 0.0, 0.0);
let mut text_extents = cr.glyph_extents(&[glyph]).ok()?;
while current_size > min_size && (text_extents.width() > target_width || text_extents.height() > target_height) {
let ratio = (target_width / text_extents.width()).min(target_height / text_extents.height());
let new_size = (ratio * current_size).max(min_size);
if new_size >= current_size {
current_size -= 2.0;
} else {
current_size = new_size;
}
cr.set_font_size(current_size);
text_extents = cr.glyph_extents(&[glyph]).ok()?;
}
log::debug!("render_color_glyph: fitted font size {:.1} (from {:.1}), glyph extents {:.1}x{:.1}",
current_size, font_size_px, text_extents.width(), text_extents.height());
// Get font metrics for positioning with the final size
let font_extents = cr.font_extents().ok()?;
log::debug!("render_color_glyph: font extents - ascent={:.1}, descent={:.1}, height={:.1}",
font_extents.ascent(), font_extents.descent(), font_extents.height());
// Create glyph with positioning at baseline
// y position should be at baseline (ascent from top)
glyph = cairo::Glyph::new(glyph_index as u64, 0.0, font_extents.ascent());
// Get final glyph extents for sizing
text_extents = cr.glyph_extents(&[glyph]).ok()?;
log::debug!("render_color_glyph: text extents - width={:.1}, height={:.1}, x_bearing={:.1}, y_bearing={:.1}, x_advance={:.1}",
text_extents.width(), text_extents.height(),
text_extents.x_bearing(), text_extents.y_bearing(),
text_extents.x_advance());
// Set source color to white - the atlas stores colors directly for emoji
cr.set_source_rgba(1.0, 1.0, 1.0, 1.0);
// Render the glyph
if let Err(e) = cr.show_glyphs(&[glyph]) {
log::warn!("render_color_glyph: show_glyphs failed: {:?}", e);
return None;
}
log::debug!("render_color_glyph: cairo show_glyphs succeeded");
// Flush and get surface reference again
drop(cr); // Drop the context before accessing surface data
let surface = self.surface.as_mut()?;
surface.flush();
// Calculate actual glyph bounds
let glyph_width = text_extents.width().ceil() as u32;
let glyph_height = text_extents.height().ceil() as u32;
log::debug!("render_color_glyph: glyph size {}x{}", glyph_width, glyph_height);
if glyph_width == 0 || glyph_height == 0 {
log::debug!("render_color_glyph: zero size glyph, returning None");
return None;
}
// The actual rendered area - use the text extents to determine position
let x_offset = text_extents.x_bearing();
let y_offset = text_extents.y_bearing();
// Calculate source rectangle in the surface
let src_x = x_offset.max(0.0) as i32;
let src_y = (font_extents.ascent() + y_offset).max(0.0) as i32;
log::debug!("render_color_glyph: source rect starts at ({}, {})", src_x, src_y);
// Get surface data
let stride = surface.stride() as usize;
let surface_data = surface.data().ok()?;
// Extract the glyph region and convert ARGB -> RGBA
let out_width = glyph_width.min(render_width as u32);
let out_height = glyph_height.min(render_height as u32);
let mut rgba = vec![0u8; (out_width * out_height * 4) as usize];
let mut non_zero_pixels = 0u32;
let mut has_color = false;
for y in 0..out_height as i32 {
for x in 0..out_width as i32 {
let src_pixel_x = src_x + x;
let src_pixel_y = src_y + y;
if src_pixel_x >= 0 && src_pixel_x < self.surface_size.0
&& src_pixel_y >= 0 && src_pixel_y < self.surface_size.1 {
let src_idx = (src_pixel_y as usize) * stride + (src_pixel_x as usize) * 4;
let dst_idx = (y as usize * out_width as usize + x as usize) * 4;
if src_idx + 3 < surface_data.len() {
// Cairo uses ARGB in native byte order (on little-endian: BGRA in memory)
// We need to convert to RGBA
let b = surface_data[src_idx];
let g = surface_data[src_idx + 1];
let r = surface_data[src_idx + 2];
let a = surface_data[src_idx + 3];
if a > 0 {
non_zero_pixels += 1;
// Check if this is actual color (not just white/gray)
if r != g || g != b {
has_color = true;
}
}
// Un-premultiply alpha if needed (Cairo uses premultiplied alpha)
if a > 0 && a < 255 {
let inv_alpha = 255.0 / a as f32;
rgba[dst_idx] = (r as f32 * inv_alpha).min(255.0) as u8;
rgba[dst_idx + 1] = (g as f32 * inv_alpha).min(255.0) as u8;
rgba[dst_idx + 2] = (b as f32 * inv_alpha).min(255.0) as u8;
rgba[dst_idx + 3] = a;
} else {
rgba[dst_idx] = r;
rgba[dst_idx + 1] = g;
rgba[dst_idx + 2] = b;
rgba[dst_idx + 3] = a;
}
}
}
}
}
log::debug!("render_color_glyph: extracted {}x{} pixels, {} non-zero, has_color={}",
out_width, out_height, non_zero_pixels, has_color);
// Check if we actually got any non-transparent pixels
let has_content = rgba.chunks(4).any(|p| p[3] > 0);
if !has_content {
log::debug!("render_color_glyph: no visible content, returning None");
return None;
}
// Kitty convention: bitmap_top = -y_bearing (distance from baseline to glyph top)
let offset_x = text_extents.x_bearing() as f32;
let offset_y = -text_extents.y_bearing() as f32;
log::debug!("render_color_glyph: SUCCESS - returning {}x{} glyph, offset=({:.1}, {:.1})",
out_width, out_height, offset_x, offset_y);
Some((out_width, out_height, rgba, offset_x, offset_y))
}
}
+99 -128
View File
@@ -52,11 +52,15 @@ impl Keybind {
let key_part = &lowercase[last_plus + 1..];
let mod_part = &lowercase[..last_plus];
// Normalize symbol names to actual characters
let key = Self::normalize_key_name(key_part);
let key = Self::normalize_key_name(key_part)
.map(|s| s.to_string())
.unwrap_or_else(|| key_part.to_string());
(mod_part, key)
} else {
// No modifiers, just a key
let key = Self::normalize_key_name(&lowercase);
let key = Self::normalize_key_name(&lowercase)
.map(|s| s.to_string())
.unwrap_or_else(|| lowercase.clone());
("", key)
};
@@ -86,77 +90,78 @@ impl Keybind {
/// Normalizes key names to their canonical form.
/// Supports both symbol names ("plus", "minus") and literal symbols ("+", "-").
fn normalize_key_name(name: &str) -> String {
match name {
/// Returns a static str for known keys, None for unknown (caller uses input).
fn normalize_key_name(name: &str) -> Option<&'static str> {
Some(match name {
// Arrow keys
"left" | "arrowleft" | "arrow_left" => "left".to_string(),
"right" | "arrowright" | "arrow_right" => "right".to_string(),
"up" | "arrowup" | "arrow_up" => "up".to_string(),
"down" | "arrowdown" | "arrow_down" => "down".to_string(),
"left" | "arrowleft" | "arrow_left" => "left",
"right" | "arrowright" | "arrow_right" => "right",
"up" | "arrowup" | "arrow_up" => "up",
"down" | "arrowdown" | "arrow_down" => "down",
// Other special keys
"enter" | "return" => "enter".to_string(),
"tab" => "tab".to_string(),
"escape" | "esc" => "escape".to_string(),
"backspace" | "back" => "backspace".to_string(),
"delete" | "del" => "delete".to_string(),
"insert" | "ins" => "insert".to_string(),
"home" => "home".to_string(),
"end" => "end".to_string(),
"pageup" | "page_up" | "pgup" => "pageup".to_string(),
"pagedown" | "page_down" | "pgdn" => "pagedown".to_string(),
"enter" | "return" => "enter",
"tab" => "tab",
"escape" | "esc" => "escape",
"backspace" | "back" => "backspace",
"delete" | "del" => "delete",
"insert" | "ins" => "insert",
"home" => "home",
"end" => "end",
"pageup" | "page_up" | "pgup" => "pageup",
"pagedown" | "page_down" | "pgdn" => "pagedown",
// Function keys
"f1" => "f1".to_string(),
"f2" => "f2".to_string(),
"f3" => "f3".to_string(),
"f4" => "f4".to_string(),
"f5" => "f5".to_string(),
"f6" => "f6".to_string(),
"f7" => "f7".to_string(),
"f8" => "f8".to_string(),
"f9" => "f9".to_string(),
"f10" => "f10".to_string(),
"f11" => "f11".to_string(),
"f12" => "f12".to_string(),
"f1" => "f1",
"f2" => "f2",
"f3" => "f3",
"f4" => "f4",
"f5" => "f5",
"f6" => "f6",
"f7" => "f7",
"f8" => "f8",
"f9" => "f9",
"f10" => "f10",
"f11" => "f11",
"f12" => "f12",
// Symbol name aliases
"plus" => "+".to_string(),
"minus" => "-".to_string(),
"equal" | "equals" => "=".to_string(),
"bracket_left" | "bracketleft" | "lbracket" => "[".to_string(),
"bracket_right" | "bracketright" | "rbracket" => "]".to_string(),
"brace_left" | "braceleft" | "lbrace" => "{".to_string(),
"brace_right" | "braceright" | "rbrace" => "}".to_string(),
"semicolon" => ";".to_string(),
"colon" => ":".to_string(),
"apostrophe" | "quote" => "'".to_string(),
"quotedbl" | "doublequote" => "\"".to_string(),
"comma" => ",".to_string(),
"period" | "dot" => ".".to_string(),
"slash" => "/".to_string(),
"backslash" => "\\".to_string(),
"grave" | "backtick" => "`".to_string(),
"tilde" => "~".to_string(),
"at" => "@".to_string(),
"hash" | "pound" => "#".to_string(),
"dollar" => "$".to_string(),
"percent" => "%".to_string(),
"caret" => "^".to_string(),
"ampersand" => "&".to_string(),
"asterisk" | "star" => "*".to_string(),
"paren_left" | "parenleft" | "lparen" => "(".to_string(),
"paren_right" | "parenright" | "rparen" => ")".to_string(),
"underscore" => "_".to_string(),
"pipe" | "bar" => "|".to_string(),
"question" => "?".to_string(),
"exclam" | "exclamation" | "bang" => "!".to_string(),
"less" | "lessthan" => "<".to_string(),
"greater" | "greaterthan" => ">".to_string(),
"space" => " ".to_string(),
// Pass through everything else as-is
_ => name.to_string(),
}
"plus" => "+",
"minus" => "-",
"equal" | "equals" => "=",
"bracket_left" | "bracketleft" | "lbracket" => "[",
"bracket_right" | "bracketright" | "rbracket" => "]",
"brace_left" | "braceleft" | "lbrace" => "{",
"brace_right" | "braceright" | "rbrace" => "}",
"semicolon" => ";",
"colon" => ":",
"apostrophe" | "quote" => "'",
"quotedbl" | "doublequote" => "\"",
"comma" => ",",
"period" | "dot" => ".",
"slash" => "/",
"backslash" => "\\",
"grave" | "backtick" => "`",
"tilde" => "~",
"at" => "@",
"hash" | "pound" => "#",
"dollar" => "$",
"percent" => "%",
"caret" => "^",
"ampersand" => "&",
"asterisk" | "star" => "*",
"paren_left" | "parenleft" | "lparen" => "(",
"paren_right" | "parenright" | "rparen" => ")",
"underscore" => "_",
"pipe" | "bar" => "|",
"question" => "?",
"exclam" | "exclamation" | "bang" => "!",
"less" | "lessthan" => "<",
"greater" | "greaterthan" => ">",
"space" => " ",
// Unknown - caller handles passthrough
_ => return None,
})
}
}
@@ -281,68 +286,34 @@ impl Keybindings {
pub fn build_action_map(&self) -> HashMap<(bool, bool, bool, bool, String), Action> {
let mut map = HashMap::new();
if let Some(parsed) = self.new_tab.parse() {
map.insert(parsed, Action::NewTab);
}
if let Some(parsed) = self.next_tab.parse() {
map.insert(parsed, Action::NextTab);
}
if let Some(parsed) = self.prev_tab.parse() {
map.insert(parsed, Action::PrevTab);
}
if let Some(parsed) = self.tab_1.parse() {
map.insert(parsed, Action::Tab1);
}
if let Some(parsed) = self.tab_2.parse() {
map.insert(parsed, Action::Tab2);
}
if let Some(parsed) = self.tab_3.parse() {
map.insert(parsed, Action::Tab3);
}
if let Some(parsed) = self.tab_4.parse() {
map.insert(parsed, Action::Tab4);
}
if let Some(parsed) = self.tab_5.parse() {
map.insert(parsed, Action::Tab5);
}
if let Some(parsed) = self.tab_6.parse() {
map.insert(parsed, Action::Tab6);
}
if let Some(parsed) = self.tab_7.parse() {
map.insert(parsed, Action::Tab7);
}
if let Some(parsed) = self.tab_8.parse() {
map.insert(parsed, Action::Tab8);
}
if let Some(parsed) = self.tab_9.parse() {
map.insert(parsed, Action::Tab9);
}
if let Some(parsed) = self.split_horizontal.parse() {
map.insert(parsed, Action::SplitHorizontal);
}
if let Some(parsed) = self.split_vertical.parse() {
map.insert(parsed, Action::SplitVertical);
}
if let Some(parsed) = self.close_pane.parse() {
map.insert(parsed, Action::ClosePane);
}
if let Some(parsed) = self.focus_pane_up.parse() {
map.insert(parsed, Action::FocusPaneUp);
}
if let Some(parsed) = self.focus_pane_down.parse() {
map.insert(parsed, Action::FocusPaneDown);
}
if let Some(parsed) = self.focus_pane_left.parse() {
map.insert(parsed, Action::FocusPaneLeft);
}
if let Some(parsed) = self.focus_pane_right.parse() {
map.insert(parsed, Action::FocusPaneRight);
}
if let Some(parsed) = self.copy.parse() {
map.insert(parsed, Action::Copy);
}
if let Some(parsed) = self.paste.parse() {
map.insert(parsed, Action::Paste);
let bindings: &[(&Keybind, Action)] = &[
(&self.new_tab, Action::NewTab),
(&self.next_tab, Action::NextTab),
(&self.prev_tab, Action::PrevTab),
(&self.tab_1, Action::Tab1),
(&self.tab_2, Action::Tab2),
(&self.tab_3, Action::Tab3),
(&self.tab_4, Action::Tab4),
(&self.tab_5, Action::Tab5),
(&self.tab_6, Action::Tab6),
(&self.tab_7, Action::Tab7),
(&self.tab_8, Action::Tab8),
(&self.tab_9, Action::Tab9),
(&self.split_horizontal, Action::SplitHorizontal),
(&self.split_vertical, Action::SplitVertical),
(&self.close_pane, Action::ClosePane),
(&self.focus_pane_up, Action::FocusPaneUp),
(&self.focus_pane_down, Action::FocusPaneDown),
(&self.focus_pane_left, Action::FocusPaneLeft),
(&self.focus_pane_right, Action::FocusPaneRight),
(&self.copy, Action::Copy),
(&self.paste, Action::Paste),
];
for (keybind, action) in bindings {
if let Some(parsed) = keybind.parse() {
map.insert(parsed, *action);
}
}
map
+55
View File
@@ -0,0 +1,55 @@
//! Edge glow animation for visual feedback.
//!
//! Creates an organic glow effect when navigation fails: a single light node appears at center,
//! then splits into two that travel outward to the corners while fading.
use crate::terminal::Direction;
/// Edge glow animation state for visual feedback when navigation fails.
/// Creates an organic glow effect: a single light node appears at center,
/// then splits into two that travel outward to the corners while fading.
/// Animation logic is handled in the shader (shader.wgsl).
#[derive(Debug, Clone, Copy)]
pub struct EdgeGlow {
/// Which edge to glow (based on the direction the user tried to navigate).
pub direction: Direction,
/// When the animation started.
pub start_time: std::time::Instant,
/// Pane bounds - left edge in pixels.
pub pane_x: f32,
/// Pane bounds - top edge in pixels.
pub pane_y: f32,
/// Pane bounds - width in pixels.
pub pane_width: f32,
/// Pane bounds - height in pixels.
pub pane_height: f32,
}
impl EdgeGlow {
/// Duration of the glow animation in milliseconds.
pub const DURATION_MS: u64 = 500;
/// Create a new edge glow animation constrained to a pane's bounds.
pub fn new(direction: Direction, pane_x: f32, pane_y: f32, pane_width: f32, pane_height: f32) -> Self {
Self {
direction,
start_time: std::time::Instant::now(),
pane_x,
pane_y,
pane_width,
pane_height,
}
}
/// Get the current animation progress (0.0 to 1.0).
pub fn progress(&self) -> f32 {
let elapsed = self.start_time.elapsed().as_millis() as f32;
let duration = Self::DURATION_MS as f32;
(elapsed / duration).min(1.0)
}
/// Check if the animation has completed.
pub fn is_finished(&self) -> bool {
self.progress() >= 1.0
}
}
+301
View File
@@ -0,0 +1,301 @@
//! Font loading and discovery using fontconfig.
//!
//! This module provides font loading utilities including:
//! - Finding fonts by family name with style variants (regular, bold, italic, bold-italic)
//! - Finding fonts that contain specific characters (for fallback)
//! - Loading font data for use with ab_glyph and rustybuzz
use ab_glyph::FontRef;
use fontconfig::Fontconfig;
use std::ffi::CStr;
use std::path::PathBuf;
// ═══════════════════════════════════════════════════════════════════════════════
// FONT VARIANT
// ═══════════════════════════════════════════════════════════════════════════════
/// A font variant with its data and parsed references.
pub struct FontVariant {
/// Owned font data (kept alive for the lifetime of the font references).
#[allow(dead_code)]
data: Box<[u8]>,
/// ab_glyph font reference for rasterization.
font: FontRef<'static>,
/// rustybuzz face for text shaping.
face: rustybuzz::Face<'static>,
}
impl FontVariant {
/// Get a reference to the ab_glyph font.
pub fn font(&self) -> &FontRef<'static> {
&self.font
}
/// Get a reference to the rustybuzz face.
pub fn face(&self) -> &rustybuzz::Face<'static> {
&self.face
}
/// Clone the font reference (ab_glyph FontRef is Clone).
pub fn clone_font(&self) -> FontRef<'static> {
self.font.clone()
}
/// Clone the font data.
pub fn clone_data(&self) -> Box<[u8]> {
self.data.clone()
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// FONT DISCOVERY
// ═══════════════════════════════════════════════════════════════════════════════
/// Find a font that contains the given character using fontconfig.
/// Returns the path to the font file if found.
///
/// Note: For emoji, use `find_color_font_for_char` from the color_font module instead,
/// which explicitly requests color fonts.
pub fn find_font_for_char(_fc: &Fontconfig, c: char) -> Option<PathBuf> {
use fontconfig_sys as fcsys;
use fcsys::*;
unsafe {
// Create a pattern
let pat = FcPatternCreate();
if pat.is_null() {
return None;
}
// Create a charset with the target character
let charset = FcCharSetCreate();
if charset.is_null() {
FcPatternDestroy(pat);
return None;
}
// Add the character to the charset
FcCharSetAddChar(charset, c as u32);
// Add the charset to the pattern
let fc_charset_cstr = CStr::from_bytes_with_nul(b"charset\0").unwrap();
FcPatternAddCharSet(pat, fc_charset_cstr.as_ptr(), charset);
// Run substitutions
FcConfigSubstitute(std::ptr::null_mut(), pat, FcMatchPattern);
FcDefaultSubstitute(pat);
// Find matching font
let mut result = FcResultNoMatch;
let matched = FcFontMatch(std::ptr::null_mut(), pat, &mut result);
let font_result = if !matched.is_null() && result == FcResultMatch {
// Get the file path from the matched pattern
let mut file_ptr: *mut FcChar8 = std::ptr::null_mut();
let fc_file_cstr = CStr::from_bytes_with_nul(b"file\0").unwrap();
if FcPatternGetString(matched, fc_file_cstr.as_ptr(), 0, &mut file_ptr) == FcResultMatch
{
let path_cstr = CStr::from_ptr(file_ptr as *const i8);
Some(PathBuf::from(path_cstr.to_string_lossy().into_owned()))
} else {
None
}
} else {
None
};
// Cleanup
if !matched.is_null() {
FcPatternDestroy(matched);
}
FcCharSetDestroy(charset);
FcPatternDestroy(pat);
font_result
}
}
/// Find font files for a font family using fontconfig.
/// Returns paths for (regular, bold, italic, bold_italic).
/// Any variant that can't be found will be None.
pub fn find_font_family_variants(family: &str) -> [Option<PathBuf>; 4] {
use fontconfig_sys as fcsys;
use fcsys::*;
use fcsys::constants::{FC_FAMILY, FC_WEIGHT, FC_SLANT, FC_FILE};
use std::ffi::CString;
let mut results: [Option<PathBuf>; 4] = [None, None, None, None];
// Style queries: (weight, slant) pairs for each variant
// FC_WEIGHT_REGULAR = 80, FC_WEIGHT_BOLD = 200
// FC_SLANT_ROMAN = 0, FC_SLANT_ITALIC = 100
let styles: [(i32, i32); 4] = [
(80, 0), // Regular
(200, 0), // Bold
(80, 100), // Italic
(200, 100), // BoldItalic
];
unsafe {
let family_cstr = match CString::new(family) {
Ok(s) => s,
Err(_) => return results,
};
for (idx, (weight, slant)) in styles.iter().enumerate() {
let pat = FcPatternCreate();
if pat.is_null() {
continue;
}
// Set family name
FcPatternAddString(pat, FC_FAMILY.as_ptr() as *const i8, family_cstr.as_ptr() as *const u8);
// Set weight
FcPatternAddInteger(pat, FC_WEIGHT.as_ptr() as *const i8, *weight);
// Set slant
FcPatternAddInteger(pat, FC_SLANT.as_ptr() as *const i8, *slant);
FcConfigSubstitute(std::ptr::null_mut(), pat, FcMatchPattern);
FcDefaultSubstitute(pat);
let mut result: FcResult = FcResultMatch;
let matched = FcFontMatch(std::ptr::null_mut(), pat, &mut result);
if result == FcResultMatch && !matched.is_null() {
let mut file_ptr: *mut u8 = std::ptr::null_mut();
if FcPatternGetString(matched, FC_FILE.as_ptr() as *const i8, 0, &mut file_ptr) == FcResultMatch {
if !file_ptr.is_null() {
let path_cstr = std::ffi::CStr::from_ptr(file_ptr as *const i8);
if let Ok(path_str) = path_cstr.to_str() {
results[idx] = Some(PathBuf::from(path_str));
}
}
}
FcPatternDestroy(matched);
}
FcPatternDestroy(pat);
}
}
results
}
// ═══════════════════════════════════════════════════════════════════════════════
// FONT LOADING
// ═══════════════════════════════════════════════════════════════════════════════
/// Try to load a font file and create both ab_glyph and rustybuzz handles.
/// Returns None if the file doesn't exist or can't be parsed.
pub fn load_font_variant(path: &std::path::Path) -> Option<FontVariant> {
let data = std::fs::read(path).ok()?.into_boxed_slice();
// Parse with ab_glyph
let font: FontRef<'static> = {
let font = FontRef::try_from_slice(&data).ok()?;
// SAFETY: We keep data alive in the FontVariant struct
unsafe { std::mem::transmute(font) }
};
// Parse with rustybuzz
let face: rustybuzz::Face<'static> = {
let face = rustybuzz::Face::from_slice(&data, 0)?;
// SAFETY: We keep data alive in the FontVariant struct
unsafe { std::mem::transmute(face) }
};
Some(FontVariant { data, font, face })
}
/// Load font variants for a font family.
/// Returns array of font variants, with index 0 being the regular font.
/// Falls back to hardcoded paths if fontconfig fails.
pub fn load_font_family(font_family: Option<&str>) -> (Box<[u8]>, FontRef<'static>, [Option<FontVariant>; 4]) {
// 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] {
if let Some(regular) = load_font_variant(regular_path) {
let primary_font = regular.clone_font();
let font_data = regular.clone_data();
// Load other variants
let variants: [Option<FontVariant>; 4] = [
Some(regular),
paths[1].as_ref().and_then(|p| load_font_variant(p)),
paths[2].as_ref().and_then(|p| load_font_variant(p)),
paths[3].as_ref().and_then(|p| load_font_variant(p)),
];
return (font_data, primary_font, variants);
}
}
log::warn!("Failed to load font family '{}', falling back to defaults", family);
}
// Fallback: try hardcoded paths
let fallback_fonts = [
("/usr/share/fonts/TTF/0xProtoNerdFont-Regular.ttf",
"/usr/share/fonts/TTF/0xProtoNerdFont-Bold.ttf",
"/usr/share/fonts/TTF/0xProtoNerdFont-Italic.ttf",
"/usr/share/fonts/TTF/0xProtoNerdFont-BoldItalic.ttf"),
("/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Regular.ttf",
"/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Bold.ttf",
"/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Italic.ttf",
"/usr/share/fonts/TTF/JetBrainsMonoNerdFont-BoldItalic.ttf"),
("/usr/share/fonts/TTF/JetBrainsMono-Regular.ttf",
"/usr/share/fonts/TTF/JetBrainsMono-Bold.ttf",
"/usr/share/fonts/TTF/JetBrainsMono-Italic.ttf",
"/usr/share/fonts/TTF/JetBrainsMono-BoldItalic.ttf"),
];
for (regular, bold, italic, bold_italic) in fallback_fonts {
let regular_path = std::path::Path::new(regular);
if let Some(regular_variant) = load_font_variant(regular_path) {
let primary_font = regular_variant.clone_font();
let font_data = regular_variant.clone_data();
let variants: [Option<FontVariant>; 4] = [
Some(regular_variant),
load_font_variant(std::path::Path::new(bold)),
load_font_variant(std::path::Path::new(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);
}
}
// Last resort: try NotoSansMono
let noto_regular = std::path::Path::new("/usr/share/fonts/noto/NotoSansMono-Regular.ttf");
if let Some(regular_variant) = load_font_variant(noto_regular) {
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);
}
panic!("Failed to load any monospace font");
}
+6 -6
View File
@@ -88,7 +88,7 @@ fn vs_main(in: VertexInput) -> VertexOutput {
}
@group(0) @binding(0)
var atlas_texture: texture_2d_array<f32>;
var atlas_textures: binding_array<texture_2d<f32>>;
@group(0) @binding(1)
var atlas_sampler: sampler;
@@ -103,7 +103,7 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
}
// Sample from RGBA atlas (layer 0 for legacy rendering)
let glyph_sample = textureSample(atlas_texture, atlas_sampler, in.uv, 0);
let glyph_sample = textureSample(atlas_textures[0], atlas_sampler, in.uv);
// Detect color glyphs: regular glyphs are stored as white (1,1,1) with alpha
// Color glyphs have actual RGB colors. Check if any RGB channel is not white.
@@ -697,7 +697,7 @@ fn fs_cell(in: CellVertexOutput) -> @location(0) vec4<f32> {
} else {
// Non-block cursors (bar, underline) - sample from pre-rendered cursor sprite
// The cursor_uv was calculated in the vertex shader
let cursor_sample = textureSample(atlas_texture, atlas_sampler, in.cursor_uv, in.cursor_layer);
let cursor_sample = textureSample(atlas_textures[in.cursor_layer], atlas_sampler, in.cursor_uv);
let cursor_alpha = cursor_sample.a;
if cursor_alpha > 0.0 {
@@ -720,7 +720,7 @@ fn fs_cell(in: CellVertexOutput) -> @location(0) vec4<f32> {
let has_glyph = in.uv.x != 0.0 || in.uv.y != 0.0;
if has_glyph {
let glyph_sample = textureSample(atlas_texture, atlas_sampler, in.uv, in.glyph_layer);
let glyph_sample = textureSample(atlas_textures[in.glyph_layer], atlas_sampler, in.uv);
if in.is_colored_glyph == 1u {
// Colored glyph (emoji) - use atlas color directly
@@ -744,7 +744,7 @@ fn fs_cell(in: CellVertexOutput) -> @location(0) vec4<f32> {
// Sample and blend underline decoration if present
if in.has_underline > 0u {
let underline_sample = textureSample(atlas_texture, atlas_sampler, in.underline_uv, in.underline_layer);
let underline_sample = textureSample(atlas_textures[in.underline_layer], atlas_sampler, in.underline_uv);
let underline_alpha = underline_sample.a;
if underline_alpha > 0.0 {
@@ -758,7 +758,7 @@ fn fs_cell(in: CellVertexOutput) -> @location(0) vec4<f32> {
// Sample and blend strikethrough decoration if present
if in.has_strikethrough > 0u {
let strike_sample = textureSample(atlas_texture, atlas_sampler, in.strike_uv, in.strike_layer);
let strike_sample = textureSample(atlas_textures[in.strike_layer], atlas_sampler, in.strike_uv);
let strike_alpha = strike_sample.a;
if strike_alpha > 0.0 {
+291
View File
@@ -0,0 +1,291 @@
//! GPU data structures for terminal rendering.
//!
//! Contains vertex formats, uniform structures, and constants for wgpu rendering.
//! All structures use `#[repr(C)]` and implement `bytemuck::Pod` for GPU compatibility.
use bytemuck::{Pod, Zeroable};
// ═══════════════════════════════════════════════════════════════════════════════
// CONSTANTS
// ═══════════════════════════════════════════════════════════════════════════════
/// Size of the glyph atlas texture (like Kitty's max_texture_size).
/// 8192x8192 provides massive capacity before needing additional layers.
pub const ATLAS_SIZE: u32 = 8192;
/// Maximum number of atlas layers (like Kitty's max_array_len).
/// With 8192x8192 per layer, this provides virtually unlimited glyph storage.
pub const MAX_ATLAS_LAYERS: u32 = 64;
/// Bytes per pixel in the RGBA atlas (4 for RGBA8).
pub const ATLAS_BPP: u32 = 4;
/// Maximum number of simultaneous edge glows.
pub const MAX_EDGE_GLOWS: usize = 16;
/// Color type constants for packed color encoding.
pub const COLOR_TYPE_DEFAULT: u32 = 0;
pub const COLOR_TYPE_INDEXED: u32 = 1;
pub const COLOR_TYPE_RGB: u32 = 2;
/// Attribute bit flags.
pub const ATTR_BOLD: u32 = 0x8;
pub const ATTR_ITALIC: u32 = 0x10;
pub const ATTR_REVERSE: u32 = 0x20;
pub const ATTR_STRIKE: u32 = 0x40;
pub const ATTR_DIM: u32 = 0x80;
pub const ATTR_UNDERLINE: u32 = 0x1; // Part of decoration mask
pub const ATTR_SELECTED: u32 = 0x100; // Cell is selected (for selection highlighting)
/// Flag for colored glyphs (emoji).
pub const COLORED_GLYPH_FLAG: u32 = 0x80000000;
/// Pre-rendered cursor sprite indices (like Kitty's cursor_shape_map).
/// These sprites are created at fixed indices in the sprite array after initialization.
/// Index 0 is reserved for "no glyph" (empty cell).
pub const CURSOR_SPRITE_BEAM: u32 = 1; // Bar/beam cursor (vertical line on left)
pub const CURSOR_SPRITE_UNDERLINE: u32 = 2; // Underline cursor (horizontal line at bottom)
pub const CURSOR_SPRITE_HOLLOW: u32 = 3; // Hollow/unfocused cursor (outline rectangle)
/// Pre-rendered decoration sprite indices (like Kitty's decoration sprites).
/// These are created after cursor sprites and used for text decorations.
/// The shader uses these to render underlines, strikethrough, etc.
pub const DECORATION_SPRITE_STRIKETHROUGH: u32 = 4; // Strikethrough line
pub const DECORATION_SPRITE_UNDERLINE: u32 = 5; // Single underline
pub const DECORATION_SPRITE_DOUBLE_UNDERLINE: u32 = 6; // Double underline
pub const DECORATION_SPRITE_UNDERCURL: u32 = 7; // Wavy/curly underline
pub const DECORATION_SPRITE_DOTTED: u32 = 8; // Dotted underline
pub const DECORATION_SPRITE_DASHED: u32 = 9; // Dashed underline
/// First available sprite index for regular glyphs (after reserved cursor and decoration sprites)
pub const FIRST_GLYPH_SPRITE: u32 = 10;
// ═══════════════════════════════════════════════════════════════════════════════
// VERTEX STRUCTURES
// ═══════════════════════════════════════════════════════════════════════════════
/// Vertex for rendering textured quads.
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct GlyphVertex {
pub position: [f32; 2],
pub uv: [f32; 2],
pub color: [f32; 4],
pub bg_color: [f32; 4],
}
impl GlyphVertex {
pub const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
0 => Float32x2, // position
1 => Float32x2, // uv
2 => Float32x4, // color (fg)
3 => Float32x4, // bg_color
];
pub fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<GlyphVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &Self::ATTRIBS,
}
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// EDGE GLOW STRUCTURES
// ═══════════════════════════════════════════════════════════════════════════════
/// Per-glow instance data (48 bytes, aligned to 16 bytes).
/// Must match GlowInstance in shader.wgsl exactly.
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct GlowInstance {
pub direction: u32,
pub progress: f32,
pub color_r: f32,
pub color_g: f32,
pub color_b: f32,
// Pane bounds in pixels
pub pane_x: f32,
pub pane_y: f32,
pub pane_width: f32,
pub pane_height: f32,
pub _padding1: f32,
pub _padding2: f32,
pub _padding3: f32,
}
/// GPU-compatible edge glow uniform data.
/// Must match the layout in shader.wgsl exactly.
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct EdgeGlowUniforms {
pub screen_width: f32,
pub screen_height: f32,
pub terminal_y_offset: f32,
pub glow_intensity: f32,
pub glow_count: u32,
pub _padding: [u32; 3], // Pad to 16-byte alignment before array
pub glows: [GlowInstance; MAX_EDGE_GLOWS],
}
// ═══════════════════════════════════════════════════════════════════════════════
// IMAGE STRUCTURES
// ═══════════════════════════════════════════════════════════════════════════════
/// GPU-compatible image uniform data.
/// Must match the layout in image_shader.wgsl exactly.
#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct ImageUniforms {
pub screen_width: f32,
pub screen_height: f32,
pub pos_x: f32,
pub pos_y: f32,
pub display_width: f32,
pub display_height: f32,
pub src_x: f32,
pub src_y: f32,
pub src_width: f32,
pub src_height: f32,
pub _padding1: f32,
pub _padding2: f32,
}
// ═══════════════════════════════════════════════════════════════════════════════
// INSTANCED CELL RENDERING STRUCTURES
// ═══════════════════════════════════════════════════════════════════════════════
/// GPU cell data for instanced rendering.
/// Matches GPUCell in glyph_shader.wgsl exactly.
///
/// Like Kitty, we store a sprite_idx that references pre-rendered glyphs in the atlas.
/// This allows us to update GPU buffers with a simple memcpy when content changes,
/// rather than rebuilding vertex buffers every frame.
#[repr(C)]
#[derive(Copy, Clone, Debug, Default, Pod, Zeroable)]
pub struct GPUCell {
/// Foreground color (packed: type in low byte, then RGB or index)
pub fg: u32,
/// Background color (packed: type in low byte, then RGB or index)
pub bg: u32,
/// Decoration foreground color (for underlines, etc.)
pub decoration_fg: u32,
/// Sprite index in the sprite info array. High bit set = colored glyph.
/// 0 = no glyph (space or empty)
pub sprite_idx: u32,
/// Cell attributes (bold, italic, reverse, etc.)
pub attrs: u32,
}
/// Sprite info for glyph positioning.
/// Matches SpriteInfo in glyph_shader.wgsl exactly.
///
/// In Kitty's model, sprites are always cell-sized and glyphs are pre-positioned
/// within the sprite at the correct baseline. The shader just maps the sprite
/// to the cell 1:1, with no offset math needed.
#[repr(C)]
#[derive(Copy, Clone, Debug, Default, Pod, Zeroable)]
pub struct SpriteInfo {
/// UV coordinates in atlas (x, y, width, height) - normalized 0-1
pub uv: [f32; 4],
/// Atlas layer index (z-coordinate for texture array) and padding
/// layer is the first f32, second f32 is unused padding
pub layer: f32,
pub _padding: f32,
/// Size in pixels (width, height) - always matches cell dimensions
pub size: [f32; 2],
}
/// Font cell metrics with integer dimensions (like Kitty's FontCellMetrics).
/// Using integers ensures pixel-perfect alignment and avoids floating-point precision issues.
#[derive(Copy, Clone, Debug)]
pub struct FontCellMetrics {
/// Cell width in pixels (computed using ceil from font advance).
pub cell_width: u32,
/// Cell height in pixels (computed using ceil from font height).
pub cell_height: u32,
/// Baseline offset from top of cell in pixels.
pub baseline: u32,
/// Y position for underline (from top of cell, in pixels).
/// Computed from font metrics: ascender - underline_position.
pub underline_position: u32,
/// Thickness of underline in pixels.
pub underline_thickness: u32,
/// Y position for strikethrough (from top of cell, in pixels).
/// Typically around 65% of baseline from top.
pub strikethrough_position: u32,
/// Thickness of strikethrough in pixels.
pub strikethrough_thickness: u32,
}
/// Grid parameters uniform for instanced rendering.
/// Matches GridParams in glyph_shader.wgsl exactly.
/// Uses Kitty-style NDC positioning: viewport is set per-pane, so shader
/// works in pure NDC space without needing pixel offsets.
/// Cell dimensions are integers like Kitty for pixel-perfect rendering.
#[repr(C)]
#[derive(Copy, Clone, Debug, Default, Pod, Zeroable)]
pub struct GridParams {
pub cols: u32,
pub rows: u32,
pub cell_width: u32,
pub cell_height: u32,
pub cursor_col: i32,
pub cursor_row: i32,
pub cursor_style: u32,
pub background_opacity: f32,
// Selection range (-1 values mean no selection)
pub selection_start_col: i32,
pub selection_start_row: i32,
pub selection_end_col: i32,
pub selection_end_row: i32,
}
/// GPU quad instance for instanced rectangle rendering.
/// Matches Quad in glyph_shader.wgsl exactly.
#[repr(C)]
#[derive(Copy, Clone, Debug, Default, Pod, Zeroable)]
pub struct Quad {
/// X position in pixels
pub x: f32,
/// Y position in pixels
pub y: f32,
/// Width in pixels
pub width: f32,
/// Height in pixels
pub height: f32,
/// Color (linear RGBA)
pub color: [f32; 4],
}
/// Parameters for quad rendering.
/// Matches QuadParams in glyph_shader.wgsl exactly.
#[repr(C)]
#[derive(Copy, Clone, Debug, Default, Pod, Zeroable)]
pub struct QuadParams {
pub screen_width: f32,
pub screen_height: f32,
pub _padding: [f32; 2],
}
/// Parameters for statusline rendering.
/// Matches StatuslineParams in statusline_shader.wgsl exactly.
#[repr(C)]
#[derive(Copy, Clone, Debug, Default, Pod, Zeroable)]
pub struct StatuslineParams {
/// Number of characters in statusline
pub char_count: u32,
/// Cell width in pixels
pub cell_width: f32,
/// Cell height in pixels
pub cell_height: f32,
/// Screen width in pixels
pub screen_width: f32,
/// Screen height in pixels
pub screen_height: f32,
/// Y offset from top of screen in pixels
pub y_offset: f32,
/// Padding for alignment (to match shader struct layout)
pub _padding: [f32; 2],
}
+347
View File
@@ -0,0 +1,347 @@
//! Image rendering for the Kitty Graphics Protocol.
//!
//! This module handles GPU-accelerated rendering of images in the terminal,
//! supporting the Kitty Graphics Protocol for inline image display.
use std::collections::HashMap;
use crate::gpu_types::ImageUniforms;
use crate::graphics::{ImageData, ImagePlacement, ImageStorage};
// ═══════════════════════════════════════════════════════════════════════════════
// GPU IMAGE
// ═══════════════════════════════════════════════════════════════════════════════
/// Cached GPU texture for an image.
pub struct GpuImage {
pub texture: wgpu::Texture,
pub view: wgpu::TextureView,
pub uniform_buffer: wgpu::Buffer,
pub bind_group: wgpu::BindGroup,
pub width: u32,
pub height: u32,
}
// ═══════════════════════════════════════════════════════════════════════════════
// IMAGE RENDERER
// ═══════════════════════════════════════════════════════════════════════════════
/// Manages GPU resources for image rendering.
/// Handles uploading, caching, and preparing images for rendering.
pub struct ImageRenderer {
/// Bind group layout for image rendering.
bind_group_layout: wgpu::BindGroupLayout,
/// Sampler for image textures.
sampler: wgpu::Sampler,
/// Cached GPU textures for images, keyed by image ID.
textures: HashMap<u32, GpuImage>,
}
impl ImageRenderer {
/// Create a new ImageRenderer with the necessary GPU resources.
pub fn new(device: &wgpu::Device) -> Self {
// Create sampler for images (linear filtering for smooth scaling)
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Image Sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
});
// Create bind group layout for images
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Image Bind Group Layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
Self {
bind_group_layout,
sampler,
textures: HashMap::new(),
}
}
/// Get the bind group layout for creating the image pipeline.
pub fn bind_group_layout(&self) -> &wgpu::BindGroupLayout {
&self.bind_group_layout
}
/// Get a GPU image by ID.
pub fn get(&self, image_id: &u32) -> Option<&GpuImage> {
self.textures.get(image_id)
}
/// Upload an image to the GPU, creating or updating its texture.
pub fn upload_image(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, image: &ImageData) {
// Get current frame data (handles animation frames automatically)
let data = image.current_frame_data();
// Check if we already have this image
if let Some(existing) = self.textures.get(&image.id) {
if existing.width == image.width && existing.height == image.height {
// Same dimensions, just update the data
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &existing.texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
data,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(image.width * 4),
rows_per_image: Some(image.height),
},
wgpu::Extent3d {
width: image.width,
height: image.height,
depth_or_array_layers: 1,
},
);
return;
}
// Different dimensions, need to recreate
}
// Create new texture
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(&format!("Image {}", image.id)),
size: wgpu::Extent3d {
width: image.width,
height: image.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
// Upload the data
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
data,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(image.width * 4),
rows_per_image: Some(image.height),
},
wgpu::Extent3d {
width: image.width,
height: image.height,
depth_or_array_layers: 1,
},
);
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
// Create per-image uniform buffer
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(&format!("Image {} Uniform Buffer", image.id)),
size: std::mem::size_of::<ImageUniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Create bind group for this image with its own uniform buffer
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(&format!("Image {} Bind Group", image.id)),
layout: &self.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
self.textures.insert(image.id, GpuImage {
texture,
view,
uniform_buffer,
bind_group,
width: image.width,
height: image.height,
});
log::debug!(
"Uploaded image {} ({}x{}) to GPU",
image.id,
image.width,
image.height
);
}
/// Remove an image from the GPU.
pub fn remove_image(&mut self, image_id: u32) {
if self.textures.remove(&image_id).is_some() {
log::debug!("Removed image {} from GPU", image_id);
}
}
/// Sync images from terminal's image storage to GPU.
/// Uploads new/changed images and removes deleted ones.
/// Also updates animation frames.
pub fn sync_images(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, storage: &mut ImageStorage) {
// Update animations and get list of changed image IDs
let changed_ids = storage.update_animations();
// Re-upload frames that changed due to animation
for id in &changed_ids {
if let Some(image) = storage.get_image(*id) {
self.upload_image(device, queue, image);
}
}
if !storage.dirty && changed_ids.is_empty() {
return;
}
// Upload all images (upload_image handles deduplication)
for image in storage.images().values() {
self.upload_image(device, queue, image);
}
// Remove textures for deleted images
let current_ids: std::collections::HashSet<u32> = storage.images().keys().copied().collect();
let gpu_ids: Vec<u32> = self.textures.keys().copied().collect();
for id in gpu_ids {
if !current_ids.contains(&id) {
self.remove_image(id);
}
}
storage.clear_dirty();
}
/// Prepare image renders for a pane.
/// Returns a Vec of (image_id, uniforms) for deferred rendering.
pub fn prepare_image_renders(
&self,
placements: &[ImagePlacement],
pane_x: f32,
pane_y: f32,
cell_width: f32,
cell_height: f32,
screen_width: f32,
screen_height: f32,
scrollback_len: usize,
scroll_offset: usize,
visible_rows: usize,
) -> Vec<(u32, ImageUniforms)> {
let mut renders = Vec::new();
for placement in placements {
// Check if we have the GPU texture for this image
let gpu_image = match self.textures.get(&placement.image_id) {
Some(img) => img,
None => continue, // Skip if not uploaded yet
};
// Convert absolute row to visible screen row
// placement.row is absolute (scrollback_len_at_placement + cursor_row)
// visible_row = absolute_row - scrollback_len + scroll_offset
let absolute_row = placement.row as isize;
let visible_row = absolute_row - scrollback_len as isize + scroll_offset as isize;
// Check if image is visible on screen
// Image spans from visible_row to visible_row + placement.rows
let image_bottom = visible_row + placement.rows as isize;
if image_bottom < 0 || visible_row >= visible_rows as isize {
continue; // Image is completely off-screen
}
// Calculate display position in pixels
let pos_x = pane_x + (placement.col as f32 * cell_width) + placement.x_offset as f32;
let pos_y = pane_y + (visible_row as f32 * cell_height) + placement.y_offset as f32;
log::debug!(
"Image render: pane_x={} col={} cell_width={} x_offset={} => pos_x={}",
pane_x, placement.col, cell_width, placement.x_offset, pos_x
);
// Calculate display size in pixels
let display_width = placement.cols as f32 * cell_width;
let display_height = placement.rows as f32 * cell_height;
// Calculate source rectangle in normalized coordinates
let src_x = placement.src_x as f32 / gpu_image.width as f32;
let src_y = placement.src_y as f32 / gpu_image.height as f32;
let src_width = if placement.src_width == 0 {
1.0 - src_x
} else {
placement.src_width as f32 / gpu_image.width as f32
};
let src_height = if placement.src_height == 0 {
1.0 - src_y
} else {
placement.src_height as f32 / gpu_image.height as f32
};
let uniforms = ImageUniforms {
screen_width,
screen_height,
pos_x,
pos_y,
display_width,
display_height,
src_x,
src_y,
src_width,
src_height,
_padding1: 0.0,
_padding2: 0.0,
};
renders.push((placement.image_id, uniforms));
}
renders
}
}
+11
View File
@@ -2,10 +2,21 @@
//!
//! Single-process architecture: one process owns PTY, terminal state, and rendering.
pub mod box_drawing;
pub mod color;
pub mod color_font;
pub mod config;
pub mod font_loader;
pub mod edge_glow;
pub mod gpu_types;
pub mod graphics;
pub mod image_renderer;
pub mod keyboard;
pub mod pane_resources;
pub mod pipeline;
pub mod pty;
pub mod renderer;
pub mod statusline;
pub mod terminal;
pub mod simd_utf8;
pub mod vt_parser;
+559 -529
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
//! Per-pane GPU resources for multi-pane terminal rendering.
//!
//! This module provides GPU resource management for individual terminal panes,
//! following Kitty's VAO-per-window approach where each pane gets its own
//! buffers and bind group for independent rendering.
// ═══════════════════════════════════════════════════════════════════════════════
// PER-PANE GPU RESOURCES (Like Kitty's VAO per window)
// ═══════════════════════════════════════════════════════════════════════════════
/// GPU resources for a single pane.
/// Like Kitty's VAO, each pane gets its own buffers and bind group.
/// This allows uploading each pane's cell data independently before rendering.
pub struct PaneGpuResources {
/// Cell storage buffer - contains GPUCell array for this pane's visible cells.
pub cell_buffer: wgpu::Buffer,
/// Grid parameters uniform buffer for this pane.
pub grid_params_buffer: wgpu::Buffer,
/// Bind group for instanced rendering (@group(1)) - references this pane's buffers.
pub bind_group: wgpu::BindGroup,
/// Buffer capacity (max cells) - used to detect when buffer needs resizing.
pub capacity: usize,
}
+79
View File
@@ -0,0 +1,79 @@
//! Render pipeline builder for wgpu.
//!
//! This module provides a builder pattern for creating wgpu render pipelines
//! with common settings, reducing boilerplate when creating multiple pipelines.
// ═══════════════════════════════════════════════════════════════════════════════
// PIPELINE BUILDER
// ═══════════════════════════════════════════════════════════════════════════════
/// Builder for creating render pipelines with common settings.
/// Captures the device, shader, layout, and format that are shared across many pipelines.
pub struct PipelineBuilder<'a> {
device: &'a wgpu::Device,
shader: &'a wgpu::ShaderModule,
layout: &'a wgpu::PipelineLayout,
format: wgpu::TextureFormat,
}
impl<'a> PipelineBuilder<'a> {
/// Create a new pipeline builder with shared settings.
pub fn new(
device: &'a wgpu::Device,
shader: &'a wgpu::ShaderModule,
layout: &'a wgpu::PipelineLayout,
format: wgpu::TextureFormat,
) -> Self {
Self { device, shader, layout, format }
}
/// Build a pipeline with TriangleStrip topology and no vertex buffers (most common case).
pub fn build(&self, label: &str, vs_entry: &str, fs_entry: &str, blend: wgpu::BlendState) -> wgpu::RenderPipeline {
self.build_full(label, vs_entry, fs_entry, blend, wgpu::PrimitiveTopology::TriangleStrip, &[])
}
/// Build a pipeline with custom topology and vertex buffers.
pub fn build_full(
&self,
label: &str,
vs_entry: &str,
fs_entry: &str,
blend: wgpu::BlendState,
topology: wgpu::PrimitiveTopology,
vertex_buffers: &[wgpu::VertexBufferLayout<'_>],
) -> wgpu::RenderPipeline {
self.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(self.layout),
vertex: wgpu::VertexState {
module: self.shader,
entry_point: Some(vs_entry),
buffers: vertex_buffers,
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: self.shader,
entry_point: Some(fs_entry),
targets: &[Some(wgpu::ColorTargetState {
format: self.format,
blend: Some(blend),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState {
topology,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
})
}
}
+511 -3971
View File
File diff suppressed because it is too large Load Diff
+1476
View File
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
//! Statusline types and rendering.
//!
//! Provides data structures for building structured statusline content with
//! powerline-style sections and components.
/// Color specification for statusline components.
/// Uses the terminal's indexed color palette (0-255), RGB, or default fg.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatuslineColor {
/// Use the default foreground color.
Default,
/// Use an indexed color from the 256-color palette (0-15 for ANSI colors).
Indexed(u8),
/// Use an RGB color.
Rgb(u8, u8, u8),
}
impl Default for StatuslineColor {
fn default() -> Self {
StatuslineColor::Default
}
}
/// A single component/segment of the statusline.
/// Components are rendered left-to-right with optional separators.
#[derive(Debug, Clone)]
pub struct StatuslineComponent {
/// The text content of this component.
pub text: String,
/// Foreground color for this component.
pub fg: StatuslineColor,
/// Whether this text should be bold.
pub bold: bool,
}
impl StatuslineComponent {
/// Create a new statusline component with default styling.
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
fg: StatuslineColor::Default,
bold: false,
}
}
/// Set the foreground color using an indexed palette color.
pub fn fg(mut self, color_index: u8) -> Self {
self.fg = StatuslineColor::Indexed(color_index);
self
}
/// Set the foreground color using RGB values.
pub fn rgb_fg(mut self, r: u8, g: u8, b: u8) -> Self {
self.fg = StatuslineColor::Rgb(r, g, b);
self
}
/// Set bold styling.
pub fn bold(mut self) -> Self {
self.bold = true;
self
}
/// Create a separator component (e.g., "/", " > ", etc.).
pub fn separator(text: impl Into<String>) -> Self {
Self {
text: text.into(),
fg: StatuslineColor::Indexed(8), // Dim gray by default
bold: false,
}
}
}
/// A section of the statusline with its own background color.
/// Sections are rendered left-to-right and end with a powerline transition arrow.
#[derive(Debug, Clone)]
pub struct StatuslineSection {
/// The components within this section.
pub components: Vec<StatuslineComponent>,
/// Background color for this section.
pub bg: StatuslineColor,
}
impl StatuslineSection {
/// Create a new section with the given indexed background color.
pub fn new(bg_color: u8) -> Self {
Self {
components: Vec::new(),
bg: StatuslineColor::Indexed(bg_color),
}
}
/// Create a new section with an RGB background color.
pub fn with_rgb_bg(r: u8, g: u8, b: u8) -> Self {
Self {
components: Vec::new(),
bg: StatuslineColor::Rgb(r, g, b),
}
}
/// Create a new section with the default (transparent) background.
pub fn transparent() -> Self {
Self {
components: Vec::new(),
bg: StatuslineColor::Default,
}
}
/// Add a component to this section.
pub fn push(mut self, component: StatuslineComponent) -> Self {
self.components.push(component);
self
}
/// Add multiple components to this section.
pub fn with_components(mut self, components: Vec<StatuslineComponent>) -> Self {
self.components = components;
self
}
}
/// Content to display in the statusline.
/// Either structured sections (for ZTerm's default CWD/git display) or raw ANSI
/// content (from neovim or other programs that provide their own statusline).
#[derive(Debug, Clone)]
pub enum StatuslineContent {
/// Structured sections with powerline-style transitions.
Sections(Vec<StatuslineSection>),
/// Raw ANSI-formatted string (rendered as-is without section styling).
Raw(String),
}
impl Default for StatuslineContent {
fn default() -> Self {
StatuslineContent::Sections(Vec::new())
}
}
+3 -3
View File
@@ -103,7 +103,7 @@ struct ColorTable {
// ═══════════════════════════════════════════════════════════════════════════════
@group(0) @binding(0)
var atlas_texture: texture_2d_array<f32>;
var atlas_textures: binding_array<texture_2d<f32>>;
@group(0) @binding(1)
var atlas_sampler: sampler;
@@ -312,8 +312,8 @@ fn fs_statusline(in: VertexOutput) -> @location(0) vec4<f32> {
return in.bg_color;
}
// Sample glyph from atlas (using layer for texture array)
let glyph_sample = textureSample(atlas_texture, atlas_sampler, in.uv, in.glyph_layer);
// Sample glyph from atlas (using layer to index texture array)
let glyph_sample = textureSample(atlas_textures[in.glyph_layer], atlas_sampler, in.uv);
if in.is_colored_glyph == 1u {
// Colored glyph (emoji) - use atlas color directly
+324 -317
View File
@@ -2,7 +2,7 @@
use crate::graphics::{GraphicsCommand, ImageStorage};
use crate::keyboard::{query_response, KeyboardState};
use crate::vt_parser::{CsiParams, Handler, Parser};
use crate::vt_parser::{CsiParams, Handler};
use unicode_width::UnicodeWidthChar;
/// Commands that the terminal can send to the application.
@@ -265,50 +265,80 @@ struct AlternateScreen {
}
/// 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) / 1_000_000;
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!(
"TIMING: scroll_up={:.2}ms ({}x), scrollback={:.2}ms [pop={:.2}ms swap={:.2}ms push={:.2}ms], clear={:.2}ms, text={:.2}ms, chars={}",
self.scroll_up_ns as f64 / 1_000_000.0,
self.scroll_up_count,
self.scrollback_ns as f64 / 1_000_000.0,
self.pop_front_ns as f64 / 1_000_000.0,
self.swap_ns as f64 / 1_000_000.0,
self.push_back_ns as f64 / 1_000_000.0,
self.clear_line_ns as f64 / 1_000_000.0,
"[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.
@@ -496,11 +526,6 @@ pub struct Terminal {
pub focus_reporting: bool,
/// Synchronized output mode (for reducing flicker).
synchronized_output: bool,
/// Pool of pre-allocated empty lines to avoid allocation during scrolling.
/// When we need a new line, we pop from this pool instead of allocating.
line_pool: Vec<Vec<Cell>>,
/// VT parser for escape sequence handling.
parser: Option<Parser>,
/// Performance timing stats (for debugging).
pub stats: ProcessingStats,
/// Command queue for terminal-to-application communication.
@@ -517,21 +542,12 @@ pub struct Terminal {
impl Terminal {
/// Default scrollback limit (10,000 lines for better cache performance).
pub const DEFAULT_SCROLLBACK_LIMIT: usize = 10_000;
/// Size of the line pool for recycling allocations.
/// This avoids allocation during the first N scrolls before scrollback is full.
const LINE_POOL_SIZE: usize = 64;
/// Creates a new terminal with the given dimensions and scrollback limit.
pub fn new(cols: usize, rows: usize, scrollback_limit: usize) -> Self {
log::info!("Terminal::new: cols={}, rows={}, scroll_bottom={}", cols, rows, rows.saturating_sub(1));
let grid = vec![vec![Cell::default(); cols]; rows];
let line_map: Vec<usize> = (0..rows).collect();
// Pre-allocate a pool of empty lines to avoid allocation during scrolling
let line_pool: Vec<Vec<Cell>> = (0..Self::LINE_POOL_SIZE)
.map(|_| vec![Cell::default(); cols])
.collect();
Self {
grid,
@@ -567,8 +583,6 @@ impl Terminal {
bracketed_paste: false,
focus_reporting: false,
synchronized_output: false,
line_pool,
parser: Some(Parser::new()),
stats: ProcessingStats::default(),
command_queue: Vec::new(),
image_storage: ImageStorage::new(),
@@ -577,16 +591,6 @@ impl Terminal {
}
}
/// Return a line to the pool for reuse (if pool isn't full).
#[allow(dead_code)]
#[inline]
fn return_line_to_pool(&mut self, line: Vec<Cell>) {
if self.line_pool.len() < Self::LINE_POOL_SIZE {
self.line_pool.push(line);
}
// Otherwise, let the line be dropped
}
/// Mark a specific line as dirty (needs redrawing).
#[inline]
pub fn mark_line_dirty(&mut self, line: usize) {
@@ -642,6 +646,32 @@ impl Terminal {
self.synchronized_output
}
/// Advance cursor to next row, scrolling if necessary.
/// This is the common pattern: increment row, scroll if past scroll_bottom.
#[inline]
fn advance_row(&mut self) {
self.cursor_row += 1;
if self.cursor_row > self.scroll_bottom {
self.scroll_up(1);
self.cursor_row = self.scroll_bottom;
}
}
/// Create a cell with current text attributes.
#[inline]
fn make_cell(&self, character: char, wide_continuation: bool) -> Cell {
Cell {
character,
fg_color: self.current_fg,
bg_color: self.current_bg,
bold: self.current_bold,
italic: self.current_italic,
underline_style: self.current_underline_style,
strikethrough: self.current_strikethrough,
wide_continuation,
}
}
/// Get the actual grid row index for a visual row.
#[inline]
pub fn grid_row(&self, visual_row: usize) -> usize {
@@ -667,7 +697,7 @@ impl Terminal {
let blank = self.blank_cell();
let row = &mut self.grid[grid_row];
// Ensure row has correct width (may differ after swap with scrollback post-resize)
row.resize(self.cols, blank.clone());
row.resize(self.cols, blank);
row.fill(blank);
}
@@ -695,16 +725,8 @@ impl Terminal {
}
}
/// Processes raw bytes from the PTY using the internal VT parser.
/// Uses Kitty-style architecture: UTF-8 decode until ESC, then parse escape sequences.
pub fn process(&mut self, bytes: &[u8]) {
// We need to temporarily take ownership of the parser to satisfy the borrow checker,
// since parse() needs &mut self for both parser and handler (Terminal).
// Use Option::take to avoid creating a new default parser each time.
if let Some(mut parser) = self.parser.take() {
parser.parse(bytes, self);
self.parser = Some(parser);
}
/// Mark terminal as dirty (needs redraw). Called after parsing.
pub fn mark_dirty(&mut self) {
self.dirty = true;
}
@@ -836,7 +858,8 @@ impl Terminal {
let region_size = self.scroll_bottom - self.scroll_top + 1;
let n = n.min(region_size);
self.stats.scroll_up_count += n as u32;
#[cfg(feature = "render_timing")]
{ self.stats.scroll_up_count += n as u32; }
for _ in 0..n {
// Save the top line's grid index before rotation
@@ -869,14 +892,36 @@ impl Terminal {
self.mark_region_dirty(self.scroll_top, self.scroll_bottom);
}
/// Mark a range of lines as dirty efficiently.
/// Mark a range of lines as dirty efficiently using bitmask operations.
#[inline]
fn mark_region_dirty(&mut self, start: usize, end: usize) {
// For small regions (< 64 lines), this is faster than individual calls
for line in start..=end.min(255) {
let word = line / 64;
let bit = line % 64;
self.dirty_lines[word] |= 1u64 << bit;
let end = end.min(255);
if start > end {
return;
}
// Process each 64-bit word that overlaps with [start, end]
let start_word = start / 64;
let end_word = end / 64;
for word_idx in start_word..=end_word.min(3) {
let word_start = word_idx * 64;
let word_end = word_start + 63;
// Calculate bit range within this word
let bit_start = if start > word_start { start - word_start } else { 0 };
let bit_end = if end < word_end { end - word_start } else { 63 };
// Create mask for bits [bit_start, bit_end]
// mask = ((1 << (bit_end - bit_start + 1)) - 1) << bit_start
let num_bits = bit_end - bit_start + 1;
let mask = if num_bits >= 64 {
!0u64
} else {
((1u64 << num_bits) - 1) << bit_start
};
self.dirty_lines[word_idx] |= mask;
}
}
@@ -1097,6 +1142,39 @@ impl Terminal {
rows
}
/// Get a single visible row by index without allocation.
/// Returns None if row_idx is out of bounds.
#[inline]
pub fn get_visible_row(&self, row_idx: usize) -> Option<&Vec<Cell>> {
if row_idx >= self.rows {
return None;
}
if self.scroll_offset == 0 {
// No scrollback viewing, just return from grid via line_map
Some(&self.grid[self.line_map[row_idx]])
} else {
// We're viewing scrollback
let scrollback_len = self.scrollback.len();
let lines_from_scrollback = self.scroll_offset.min(self.rows);
if row_idx < lines_from_scrollback {
// This row comes from scrollback
let scrollback_idx = scrollback_len - self.scroll_offset + row_idx;
self.scrollback.get(scrollback_idx)
.or_else(|| Some(&self.grid[self.line_map[row_idx]]))
} else {
// This row comes from the grid
let grid_visual_idx = row_idx - lines_from_scrollback;
if grid_visual_idx < self.rows {
Some(&self.grid[self.line_map[grid_visual_idx]])
} else {
None
}
}
}
}
/// Inserts n blank lines at the cursor position, scrolling lines below down.
/// Uses line_map rotation for efficiency.
@@ -1119,11 +1197,11 @@ impl Terminal {
// Clear the recycled line (now at cursor position)
self.clear_grid_row(recycled_grid_row);
// Mark affected lines dirty
for line in self.cursor_row..=self.scroll_bottom {
self.mark_line_dirty(line);
}
}
// Mark affected lines dirty once after all rotations
for line in self.cursor_row..=self.scroll_bottom {
self.mark_line_dirty(line);
}
}
@@ -1148,11 +1226,11 @@ impl Terminal {
// Clear the recycled line (now at bottom of scroll region)
self.clear_grid_row(recycled_grid_row);
// Mark affected lines dirty
for line in self.cursor_row..=self.scroll_bottom {
self.mark_line_dirty(line);
}
}
// Mark affected lines dirty once after all rotations
for line in self.cursor_row..=self.scroll_bottom {
self.mark_line_dirty(line);
}
}
@@ -1162,14 +1240,10 @@ impl Terminal {
let blank = self.blank_cell();
let row = &mut self.grid[grid_row];
let n = n.min(self.cols - self.cursor_col);
// Remove n characters from the end
for _ in 0..n {
row.pop();
}
// Insert n blank characters at cursor position
for _ in 0..n {
row.insert(self.cursor_col, blank);
}
// Truncate n characters from the end
row.truncate(self.cols - n);
// Insert n blank characters at cursor position (single O(cols) operation)
row.splice(self.cursor_col..self.cursor_col, std::iter::repeat(blank).take(n));
self.mark_line_dirty(self.cursor_row);
}
@@ -1179,16 +1253,11 @@ impl Terminal {
let blank = self.blank_cell();
let row = &mut self.grid[grid_row];
let n = n.min(self.cols - self.cursor_col);
// Remove n characters at cursor position
for _ in 0..n {
if self.cursor_col < row.len() {
row.remove(self.cursor_col);
}
}
let end = (self.cursor_col + n).min(row.len());
// Remove n characters at cursor position (single O(cols) operation)
row.drain(self.cursor_col..end);
// Pad with blank characters at the end
while row.len() < self.cols {
row.push(blank);
}
row.resize(self.cols, blank);
self.mark_line_dirty(self.cursor_row);
}
@@ -1197,21 +1266,18 @@ impl Terminal {
let grid_row = self.line_map[self.cursor_row];
let n = n.min(self.cols - self.cursor_col);
let blank = self.blank_cell();
for i in 0..n {
if self.cursor_col + i < self.cols {
self.grid[grid_row][self.cursor_col + i] = blank;
}
}
// Fill range with blanks (bounds already guaranteed by min above)
self.grid[grid_row][self.cursor_col..self.cursor_col + n].fill(blank);
self.mark_line_dirty(self.cursor_row);
}
/// Clears the current line from cursor to end.
#[inline]
fn clear_line_from_cursor(&mut self) {
let grid_row = self.line_map[self.cursor_row];
let blank = self.blank_cell();
for col in self.cursor_col..self.cols {
self.grid[grid_row][col] = blank;
}
// Use slice fill for efficiency
self.grid[grid_row][self.cursor_col..].fill(blank);
self.mark_line_dirty(self.cursor_row);
}
@@ -1243,9 +1309,12 @@ impl Terminal {
}
impl Handler for Terminal {
/// Handle a chunk of decoded text (Unicode codepoints).
/// Handle a chunk of decoded text (Unicode codepoints as u32).
/// This includes control characters (0x00-0x1F except ESC).
fn text(&mut self, chars: &[char]) {
fn text(&mut self, codepoints: &[u32]) {
#[cfg(feature = "render_timing")]
let start = std::time::Instant::now();
// Cache the current line to avoid repeated line_map lookups
let mut cached_row = self.cursor_row;
let mut grid_row = self.line_map[cached_row];
@@ -1253,25 +1322,27 @@ impl Handler for Terminal {
// Mark the initial line as dirty (like Kitty's init_text_loop_line)
self.mark_line_dirty(cached_row);
for &c in chars {
match c {
for &cp in codepoints {
// Fast path for ASCII control characters and printable ASCII
// These are the most common cases, so check them first using u32 directly
match cp {
// Bell
'\x07' => {
0x07 => {
// BEL - ignore for now (could trigger visual bell)
}
// Backspace
'\x08' => {
0x08 => {
if self.cursor_col > 0 {
self.cursor_col -= 1;
}
}
// Tab
'\x09' => {
0x09 => {
let next_tab = (self.cursor_col / 8 + 1) * 8;
self.cursor_col = next_tab.min(self.cols - 1);
}
// Line feed, Vertical tab, Form feed
'\x0A' | '\x0B' | '\x0C' => {
0x0A | 0x0B | 0x0C => {
let old_row = self.cursor_row;
self.cursor_row += 1;
if self.cursor_row > self.scroll_bottom {
@@ -1286,21 +1357,17 @@ impl Handler for Terminal {
self.mark_line_dirty(cached_row);
}
// Carriage return
'\x0D' => {
0x0D => {
self.cursor_col = 0;
}
// Fast path for printable ASCII (0x20-0x7E) - like Kitty
// ASCII is always width 1, never zero-width, never wide
c if c >= ' ' && c <= '~' => {
cp if cp >= 0x20 && cp <= 0x7E => {
// Handle wrap
if self.cursor_col >= self.cols {
if self.auto_wrap {
self.cursor_col = 0;
self.cursor_row += 1;
if self.cursor_row > self.scroll_bottom {
self.scroll_up(1);
self.cursor_row = self.scroll_bottom;
}
self.advance_row();
cached_row = self.cursor_row;
grid_row = self.line_map[cached_row];
self.mark_line_dirty(cached_row);
@@ -1310,111 +1377,21 @@ impl Handler for Terminal {
}
// Write character directly - no wide char handling needed for ASCII
self.grid[grid_row][self.cursor_col] = Cell {
character: c,
fg_color: self.current_fg,
bg_color: self.current_bg,
bold: self.current_bold,
italic: self.current_italic,
underline_style: self.current_underline_style,
strikethrough: self.current_strikethrough,
wide_continuation: false,
};
// SAFETY: cp is in 0x20..=0x7E which are valid ASCII chars
let c = unsafe { char::from_u32_unchecked(cp) };
self.grid[grid_row][self.cursor_col] = self.make_cell(c, false);
self.cursor_col += 1;
}
// Slow path for non-ASCII printable characters (including all Unicode)
c if c > '~' => {
// Determine character width using Unicode Standard Annex #11
let char_width = c.width().unwrap_or(1);
// Skip zero-width characters (combining marks, etc.)
if char_width == 0 {
// TODO: Handle combining characters
continue;
}
// Handle wrap
if self.cursor_col >= self.cols {
if self.auto_wrap {
self.cursor_col = 0;
self.cursor_row += 1;
if self.cursor_row > self.scroll_bottom {
self.scroll_up(1);
self.cursor_row = self.scroll_bottom;
}
// Update cache after line change
cached_row = self.cursor_row;
grid_row = self.line_map[cached_row];
// Mark the new line as dirty
self.mark_line_dirty(cached_row);
} else {
self.cursor_col = self.cols - 1;
}
}
// For double-width characters at end of line, wrap first
if char_width == 2 && self.cursor_col == self.cols - 1 {
if self.auto_wrap {
self.grid[grid_row][self.cursor_col] = Cell::default();
self.cursor_col = 0;
self.cursor_row += 1;
if self.cursor_row > self.scroll_bottom {
self.scroll_up(1);
self.cursor_row = self.scroll_bottom;
}
cached_row = self.cursor_row;
grid_row = self.line_map[cached_row];
// Mark the new line as dirty
self.mark_line_dirty(cached_row);
} else {
continue; // Can't fit
}
}
// Write character directly using cached grid_row
// Safety: ensure grid row has correct width (may differ after scrollback swap)
if self.grid[grid_row].len() != self.cols {
self.grid[grid_row].resize(self.cols, Cell::default());
}
// Handle overwriting wide character cells
if self.grid[grid_row][self.cursor_col].wide_continuation && self.cursor_col > 0 {
self.grid[grid_row][self.cursor_col - 1] = Cell::default();
}
if char_width == 1 && self.cursor_col + 1 < self.cols
&& self.grid[grid_row][self.cursor_col + 1].wide_continuation {
self.grid[grid_row][self.cursor_col + 1] = Cell::default();
}
self.grid[grid_row][self.cursor_col] = Cell {
character: c,
fg_color: self.current_fg,
bg_color: self.current_bg,
bold: self.current_bold,
italic: self.current_italic,
underline_style: self.current_underline_style,
strikethrough: self.current_strikethrough,
wide_continuation: false,
};
self.cursor_col += 1;
// For double-width, write continuation cell
if char_width == 2 && self.cursor_col < self.cols {
if self.cursor_col + 1 < self.cols
&& self.grid[grid_row][self.cursor_col + 1].wide_continuation {
self.grid[grid_row][self.cursor_col + 1] = Cell::default();
}
self.grid[grid_row][self.cursor_col] = Cell {
character: c,
fg_color: self.current_fg,
bg_color: self.current_bg,
bold: self.current_bold,
italic: self.current_italic,
underline_style: self.current_underline_style,
strikethrough: self.current_strikethrough,
wide_continuation: false,
};
self.cursor_col += 1;
// Delegates to print_char() which handles wide characters, wrapping, etc.
cp if cp > 0x7E => {
// Convert to char, using replacement character for invalid codepoints
let c = char::from_u32(cp).unwrap_or('\u{FFFD}');
self.print_char(c);
// Update cached values since print_char may have scrolled or wrapped
if cached_row != self.cursor_row {
cached_row = self.cursor_row;
grid_row = self.line_map[cached_row];
}
}
// Other control chars - ignore
@@ -1422,6 +1399,12 @@ impl Handler for Terminal {
}
}
// Dirty lines are marked incrementally above - no need for mark_all_lines_dirty()
#[cfg(feature = "render_timing")]
{
self.stats.text_handler_ns += start.elapsed().as_nanos() as u64;
self.stats.chars_processed += codepoints.len() as u32;
}
}
/// Handle control characters embedded in escape sequences.
@@ -1437,11 +1420,7 @@ impl Handler for Terminal {
self.cursor_col = next_tab.min(self.cols - 1);
}
0x0A | 0x0B | 0x0C => {
self.cursor_row += 1;
if self.cursor_row > self.scroll_bottom {
self.scroll_up(1);
self.cursor_row = self.scroll_bottom;
}
self.advance_row();
}
0x0D => {
self.cursor_col = 0;
@@ -1617,7 +1596,11 @@ impl Handler for Terminal {
}
/// Handle a complete CSI sequence.
#[inline]
fn csi(&mut self, params: &CsiParams) {
#[cfg(feature = "render_timing")]
let start = std::time::Instant::now();
let action = params.final_char as char;
let primary = params.primary;
let secondary = params.secondary;
@@ -1766,13 +1749,40 @@ impl Handler for Terminal {
self.insert_characters(n);
}
// Repeat preceding character (REP)
// Optimized like Kitty: batch writes for ASCII, avoid per-char overhead
'b' => {
let n = params.get(0, 1).max(1) as usize;
if self.cursor_col > 0 {
let n = (params.get(0, 1).max(1) as usize).min(65535); // Like Kitty's CSI_REP_MAX_REPETITIONS
if self.cursor_col > 0 && n > 0 {
let grid_row = self.line_map[self.cursor_row];
let last_char = self.grid[grid_row][self.cursor_col - 1].character;
for _ in 0..n {
self.print_char(last_char);
let last_cp = last_char as u32;
// Fast path for ASCII: direct grid write, no width lookup
if last_cp >= 0x20 && last_cp <= 0x7E {
let cell = self.make_cell(last_char, false);
self.mark_line_dirty(self.cursor_row);
for _ in 0..n {
// Handle wrap
if self.cursor_col >= self.cols {
if self.auto_wrap {
self.cursor_col = 0;
self.advance_row();
self.mark_line_dirty(self.cursor_row);
} else {
self.cursor_col = self.cols - 1;
}
}
// Direct write - recompute grid_row in case of scroll
let gr = self.line_map[self.cursor_row];
self.grid[gr][self.cursor_col] = cell.clone();
self.cursor_col += 1;
}
} else {
// Slow path for non-ASCII: use print_char for proper width handling
for _ in 0..n {
self.print_char(last_char);
}
}
}
}
@@ -1893,6 +1903,12 @@ impl Handler for Terminal {
);
}
}
#[cfg(feature = "render_timing")]
{
self.stats.csi_handler_ns += start.elapsed().as_nanos() as u64;
self.stats.csi_count += 1;
}
}
fn save_cursor(&mut self) {
@@ -2012,6 +2028,15 @@ impl Handler for Terminal {
self.mark_line_dirty(visual_row);
}
}
#[cfg(feature = "render_timing")]
fn add_vt_parser_ns(&mut self, ns: u64) {
self.stats.vt_parser_ns += ns;
self.stats.consume_input_count += 1;
}
#[cfg(not(feature = "render_timing"))]
fn add_vt_parser_ns(&mut self, _ns: u64) {}
}
impl Terminal {
@@ -2035,11 +2060,7 @@ impl Terminal {
if self.cursor_col >= self.cols {
if self.auto_wrap {
self.cursor_col = 0;
self.cursor_row += 1;
if self.cursor_row > self.scroll_bottom {
self.scroll_up(1);
self.cursor_row = self.scroll_bottom;
}
self.advance_row();
} else {
self.cursor_col = self.cols - 1;
}
@@ -2053,11 +2074,7 @@ impl Terminal {
let grid_row = self.line_map[self.cursor_row];
self.grid[grid_row][self.cursor_col] = Cell::default();
self.cursor_col = 0;
self.cursor_row += 1;
if self.cursor_row > self.scroll_bottom {
self.scroll_up(1);
self.cursor_row = self.scroll_bottom;
}
self.advance_row();
} else {
// Can't fit, don't print
return;
@@ -2080,16 +2097,7 @@ impl Terminal {
}
// Write the character to the first cell
self.grid[grid_row][self.cursor_col] = Cell {
character: c,
fg_color: self.current_fg,
bg_color: self.current_bg,
bold: self.current_bold,
italic: self.current_italic,
underline_style: self.current_underline_style,
strikethrough: self.current_strikethrough,
wide_continuation: false,
};
self.grid[grid_row][self.cursor_col] = self.make_cell(c, false);
self.mark_line_dirty(self.cursor_row);
self.cursor_col += 1;
@@ -2102,53 +2110,79 @@ impl Terminal {
self.grid[grid_row][self.cursor_col + 1] = Cell::default();
}
self.grid[grid_row][self.cursor_col] = Cell {
character: ' ', // Placeholder - renderer will skip this
fg_color: self.current_fg,
bg_color: self.current_bg,
bold: self.current_bold,
italic: self.current_italic,
underline_style: self.current_underline_style,
strikethrough: self.current_strikethrough,
wide_continuation: true,
};
self.grid[grid_row][self.cursor_col] = self.make_cell(' ', true);
self.cursor_col += 1;
}
}
/// Parse extended color (SGR 38/48) and return the color and number of params consumed.
/// Returns (Color, params_consumed) or None if parsing failed.
///
/// SAFETY: Caller must ensure i < params.num_params
#[inline(always)]
fn parse_extended_color(params: &CsiParams, i: usize) -> Option<(Color, usize)> {
let num = params.num_params;
let p = &params.params;
let is_sub = &params.is_sub_param;
// Check for sub-parameter format (38:2:r:g:b or 38:5:idx)
if i + 1 < num && is_sub[i + 1] {
let mode = p[i + 1];
if mode == 5 && i + 2 < num {
return Some((Color::Indexed(p[i + 2] as u8), 2));
} else if mode == 2 && i + 4 < num {
return Some((Color::Rgb(
p[i + 2] as u8,
p[i + 3] as u8,
p[i + 4] as u8,
), 4));
}
} else if i + 2 < num {
// Regular format (38;2;r;g;b or 38;5;idx)
let mode = p[i + 1];
if mode == 5 {
return Some((Color::Indexed(p[i + 2] as u8), 2));
} else if mode == 2 && i + 4 < num {
return Some((Color::Rgb(
p[i + 2] as u8,
p[i + 3] as u8,
p[i + 4] as u8,
), 4));
}
}
None
}
/// Handle SGR (Select Graphic Rendition) parameters.
/// This is a hot path - called for every color/style change in terminal output.
#[inline(always)]
fn handle_sgr(&mut self, params: &CsiParams) {
if params.num_params == 0 {
self.current_fg = Color::Default;
self.current_bg = Color::Default;
self.current_bold = false;
self.current_italic = false;
self.current_underline_style = 0;
self.current_strikethrough = false;
let num = params.num_params;
// Fast path: SGR 0 (reset) with no params or explicit 0
if num == 0 {
self.reset_sgr_attributes();
return;
}
let p = &params.params;
let is_sub = &params.is_sub_param;
let mut i = 0;
while i < params.num_params {
let code = params.params[i];
while i < num {
// SAFETY: i < num <= MAX_CSI_PARAMS, so index is always valid
let code = p[i];
match code {
0 => {
self.current_fg = Color::Default;
self.current_bg = Color::Default;
self.current_bold = false;
self.current_italic = false;
self.current_underline_style = 0;
self.current_strikethrough = false;
}
0 => self.reset_sgr_attributes(),
1 => self.current_bold = true,
// 2 => dim (not currently rendered)
3 => self.current_italic = true,
4 => {
// Check for sub-parameter (4:x format for underline style)
if i + 1 < params.num_params && params.is_sub_param[i + 1] {
let style = params.params[i + 1];
if i + 1 < num && is_sub[i + 1] {
// 0=none, 1=single, 2=double, 3=curly, 4=dotted, 5=dashed
self.current_underline_style = (style as u8).min(5);
self.current_underline_style = (p[i + 1] as u8).min(5);
i += 1;
} else {
// Plain SGR 4 = single underline
@@ -2157,84 +2191,55 @@ impl Terminal {
}
7 => std::mem::swap(&mut self.current_fg, &mut self.current_bg),
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),
29 => self.current_strikethrough = false,
// Standard foreground colors (30-37)
30..=37 => self.current_fg = Color::Indexed((code - 30) as u8),
38 => {
// Extended foreground color
if i + 1 < params.num_params && params.is_sub_param[i + 1] {
let mode = params.params[i + 1];
if mode == 5 && i + 2 < params.num_params {
self.current_fg = Color::Indexed(params.params[i + 2] as u8);
i += 2;
} else if mode == 2 && i + 4 < params.num_params {
self.current_fg = Color::Rgb(
params.params[i + 2] as u8,
params.params[i + 3] as u8,
params.params[i + 4] as u8,
);
i += 4;
}
} else if i + 2 < params.num_params {
let mode = params.params[i + 1];
if mode == 5 {
self.current_fg = Color::Indexed(params.params[i + 2] as u8);
i += 2;
} else if mode == 2 && i + 4 < params.num_params {
self.current_fg = Color::Rgb(
params.params[i + 2] as u8,
params.params[i + 3] as u8,
params.params[i + 4] as u8,
);
i += 4;
}
if let Some((color, consumed)) = Self::parse_extended_color(params, i) {
self.current_fg = color;
i += consumed;
}
}
39 => self.current_fg = Color::Default,
// Standard background colors (40-47)
40..=47 => self.current_bg = Color::Indexed((code - 40) as u8),
48 => {
// Extended background color
if i + 1 < params.num_params && params.is_sub_param[i + 1] {
let mode = params.params[i + 1];
if mode == 5 && i + 2 < params.num_params {
self.current_bg = Color::Indexed(params.params[i + 2] as u8);
i += 2;
} else if mode == 2 && i + 4 < params.num_params {
self.current_bg = Color::Rgb(
params.params[i + 2] as u8,
params.params[i + 3] as u8,
params.params[i + 4] as u8,
);
i += 4;
}
} else if i + 2 < params.num_params {
let mode = params.params[i + 1];
if mode == 5 {
self.current_bg = Color::Indexed(params.params[i + 2] as u8);
i += 2;
} else if mode == 2 && i + 4 < params.num_params {
self.current_bg = Color::Rgb(
params.params[i + 2] as u8,
params.params[i + 3] as u8,
params.params[i + 4] as u8,
);
i += 4;
}
if let Some((color, consumed)) = Self::parse_extended_color(params, i) {
self.current_bg = color;
i += consumed;
}
}
49 => self.current_bg = Color::Default,
// Bright foreground colors (90-97)
90..=97 => self.current_fg = Color::Indexed((code - 90 + 8) as u8),
// Bright background colors (100-107)
100..=107 => self.current_bg = Color::Indexed((code - 100 + 8) as u8),
_ => {}
}
i += 1;
}
}
/// Reset all SGR attributes to defaults.
#[inline(always)]
fn reset_sgr_attributes(&mut self) {
self.current_fg = Color::Default;
self.current_bg = Color::Default;
self.current_bold = false;
self.current_italic = false;
self.current_underline_style = 0;
self.current_strikethrough = false;
}
/// Handle Kitty keyboard protocol CSI sequences.
#[inline]
fn handle_keyboard_protocol_csi(&mut self, params: &CsiParams) {
match params.primary {
b'?' => {
@@ -2266,6 +2271,7 @@ impl Terminal {
}
/// Handle DEC private mode set (CSI ? Ps h).
#[inline]
fn handle_dec_private_mode_set(&mut self, params: &CsiParams) {
for i in 0..params.num_params {
match params.params[i] {
@@ -2334,6 +2340,7 @@ impl Terminal {
}
/// Handle DEC private mode reset (CSI ? Ps l).
#[inline]
fn handle_dec_private_mode_reset(&mut self, params: &CsiParams) {
for i in 0..params.num_params {
match params.params[i] {
+951 -224
View File
File diff suppressed because it is too large Load Diff