diff --git a/src/bin/bench_process.rs b/src/bin/bench_process.rs index de46956..e4c4f16 100644 --- a/src/bin/bench_process.rs +++ b/src/bin/bench_process.rs @@ -1,6 +1,6 @@ +use std::time::Instant; use zterm::terminal::Terminal; use zterm::vt_parser::Parser; -use std::time::Instant; const ASCII_PRINTABLE: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ `~!@#$%^&*()_+-=[]{}\\|;:'\",<.>/?"; const CONTROL_CHARS: &[u8] = b"\n\t"; @@ -25,17 +25,17 @@ fn random_string(len: usize, rng: &mut u64) -> Vec { } /// Run a benchmark with multiple repetitions like Kitty does -fn run_benchmark(name: &str, data: &[u8], repetitions: usize, mut setup: F) +fn run_benchmark(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 { @@ -43,18 +43,24 @@ where 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); + + 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; @@ -66,22 +72,22 @@ fn main() { 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) @@ -111,32 +117,37 @@ fn main() { } _ => { // 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[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(); + 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!( + "\nNote: These benchmarks include terminal state updates but NOT GPU rendering." + ); println!("Compare with: kitten __benchmark__ (without --render flag)"); } diff --git a/src/color_font.rs b/src/color_font.rs index 3e222c0..7b92c7c 100644 --- a/src/color_font.rs +++ b/src/color_font.rs @@ -16,11 +16,15 @@ use std::path::PathBuf; /// 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 { - use fontconfig_sys as fcsys; - use fcsys::*; use fcsys::constants::{FC_CHARSET, FC_COLOR, FC_FILE}; + use fcsys::*; + use fontconfig_sys as fcsys; - log::debug!("find_color_font_for_char: looking for color font for U+{:04X} '{}'", c as u32, c); + log::debug!( + "find_color_font_for_char: looking for color font for U+{:04X} '{}'", + c as u32, + c + ); unsafe { // Create a pattern @@ -43,7 +47,7 @@ pub fn find_color_font_for_char(c: char) -> Option { // 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 @@ -58,28 +62,54 @@ pub fn find_color_font_for_char(c: char) -> Option { 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); - + 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 { + 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); + 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"); + 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"); + 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); + log::debug!( + "find_color_font_for_char: no match found (result={:?})", + result + ); None }; @@ -131,11 +161,16 @@ impl ColorFontRenderer { // 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)); + self.faces + .insert(path.clone(), (ft_face, cairo_face)); true } Err(e) => { - log::warn!("Failed to create Cairo font face for {:?}: {:?}", path, e); + log::warn!( + "Failed to create Cairo font face for {:?}: {:?}", + path, + e + ); false } } @@ -160,35 +195,54 @@ impl ColorFontRenderer { cell_width: u32, cell_height: u32, ) -> Option<(u32, u32, Vec, f32, f32)> { - log::debug!("render_color_glyph: U+{:04X} '{}' font={:?}", c as u32, c, font_path); - + 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()); - + 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!"); + 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); + 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); + 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!"); + 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)?; @@ -198,19 +252,30 @@ impl ColorFontRenderer { // 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); - + + 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 { + + 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); + log::debug!( + "render_color_glyph: created Cairo surface {}x{}", + new_width, + new_height + ); self.surface = Some(surface); self.surface_size = (new_width, new_height); } @@ -220,9 +285,9 @@ impl ColorFontRenderer { } } } - + let surface = self.surface.as_mut()?; - + // Create Cairo context let cr = match cairo::Context::new(surface) { Ok(cr) => cr, @@ -231,30 +296,34 @@ impl ColorFontRenderer { 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()); + + 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; @@ -264,84 +333,111 @@ impl ColorFontRenderer { 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()); - + + 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()); - + 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()); - + 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()); - + 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); - + + 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); - + + 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_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 @@ -349,7 +445,7 @@ impl ColorFontRenderer { 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) @@ -357,13 +453,16 @@ impl ColorFontRenderer { 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] = + (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; @@ -375,24 +474,36 @@ impl ColorFontRenderer { } } } - - log::debug!("render_color_glyph: extracted {}x{} pixels, {} non-zero, has_color={}", - out_width, out_height, non_zero_pixels, has_color); - + + 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"); + 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); - + + 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)) } } diff --git a/src/config.rs b/src/config.rs index f641829..a0d3645 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,7 +8,9 @@ use std::fs; use std::path::PathBuf; /// Position of the tab bar. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, +)] #[serde(rename_all = "lowercase")] pub enum TabBarPosition { /// Tab bar at the top of the window. @@ -37,7 +39,7 @@ impl Keybind { /// - Symbol names: plus, minus, equal, bracket_left, bracket_right, etc. pub fn parse(&self) -> Option<(bool, bool, bool, bool, String)> { let lowercase = self.0.to_lowercase(); - + // Handle the special case where the key is "+" at the end // e.g., "ctrl+alt++" should parse as ctrl+alt with key "+" let (modifier_part, key) = if lowercase.ends_with("++") { @@ -63,16 +65,16 @@ impl Keybind { .unwrap_or_else(|| lowercase.clone()); ("", key) }; - + if key.is_empty() { return None; } - + let mut ctrl = false; let mut alt = false; let mut shift = false; let mut super_key = false; - + // Parse modifiers from the modifier part for part in modifier_part.split('+') { match part { @@ -81,13 +83,13 @@ impl Keybind { "shift" => shift = true, "super" | "meta" | "cmd" => super_key = true, "" => {} // Empty parts from splitting - _ => {} // Unknown modifiers ignored + _ => {} // Unknown modifiers ignored } } - + Some((ctrl, alt, shift, super_key, key)) } - + /// Normalizes key names to their canonical form. /// Supports both symbol names ("plus", "minus") and literal symbols ("+", "-"). /// Returns a static str for known keys, None for unknown (caller uses input). @@ -98,7 +100,7 @@ impl Keybind { "right" | "arrowright" | "arrow_right" => "right", "up" | "arrowup" | "arrow_up" => "up", "down" | "arrowdown" | "arrow_down" => "down", - + // Other special keys "enter" | "return" => "enter", "tab" => "tab", @@ -110,7 +112,7 @@ impl Keybind { "end" => "end", "pageup" | "page_up" | "pgup" => "pageup", "pagedown" | "page_down" | "pgdn" => "pagedown", - + // Function keys "f1" => "f1", "f2" => "f2", @@ -124,7 +126,7 @@ impl Keybind { "f10" => "f10", "f11" => "f11", "f12" => "f12", - + // Symbol name aliases "plus" => "+", "minus" => "-", @@ -283,9 +285,11 @@ impl Default for Keybindings { impl Keybindings { /// Builds a lookup map from parsed keybinds to actions. - pub fn build_action_map(&self) -> HashMap<(bool, bool, bool, bool, String), Action> { + pub fn build_action_map( + &self, + ) -> HashMap<(bool, bool, bool, bool, String), Action> { let mut map = HashMap::new(); - + let bindings: &[(&Keybind, Action)] = &[ (&self.new_tab, Action::NewTab), (&self.next_tab, Action::NextTab), @@ -309,13 +313,13 @@ impl Keybindings { (&self.copy, Action::Copy), (&self.paste, Action::Paste), ]; - + for (keybind, action) in bindings { if let Some(parsed) = keybind.parse() { map.insert(parsed, *action); } } - + map } } @@ -396,9 +400,7 @@ impl Config { match fs::read_to_string(&config_path) { Ok(contents) => match serde_json::from_str(&contents) { - Ok(config) => { - config - } + Ok(config) => config, Err(e) => { log::error!("Failed to parse config file: {}", e); Self::default() @@ -425,8 +427,9 @@ impl Config { fs::create_dir_all(parent)?; } - let json = serde_json::to_string_pretty(self) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let json = serde_json::to_string_pretty(self).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, e) + })?; fs::write(&config_path, json)?; Ok(()) diff --git a/src/edge_glow.rs b/src/edge_glow.rs index 349c0f6..c4bdd54 100644 --- a/src/edge_glow.rs +++ b/src/edge_glow.rs @@ -30,7 +30,13 @@ impl EdgeGlow { 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 { + 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(), diff --git a/src/font_loader.rs b/src/font_loader.rs index 091e0f6..7418ac3 100644 --- a/src/font_loader.rs +++ b/src/font_loader.rs @@ -53,12 +53,12 @@ impl FontVariant { /// 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 { - use fontconfig_sys as fcsys; use fcsys::*; + use fontconfig_sys as fcsys; unsafe { // Create a pattern @@ -93,7 +93,12 @@ pub fn find_font_for_char(_fc: &Fontconfig, c: char) -> Option { // 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 + 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())) @@ -119,13 +124,13 @@ pub fn find_font_for_char(_fc: &Fontconfig, c: char) -> Option { /// 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; 4] { - use fontconfig_sys as fcsys; + use fcsys::constants::{FC_FAMILY, FC_FILE, FC_SLANT, FC_WEIGHT}; use fcsys::*; - use fcsys::constants::{FC_FAMILY, FC_WEIGHT, FC_SLANT, FC_FILE}; + use fontconfig_sys as fcsys; use std::ffi::CString; - + let mut results: [Option; 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 @@ -135,37 +140,48 @@ pub fn find_font_family_variants(family: &str) -> [Option; 4] { (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); + 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 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); + 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)); } @@ -173,11 +189,11 @@ pub fn find_font_family_variants(family: &str) -> [Option; 4] { } FcPatternDestroy(matched); } - + FcPatternDestroy(pat); } } - + results } @@ -189,38 +205,40 @@ pub fn find_font_family_variants(family: &str) -> [Option; 4] { /// Returns None if the file doesn't exist or can't be parsed. pub fn load_font_variant(path: &std::path::Path) -> Option { 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; 4]) { +pub fn load_font_family( + font_family: Option<&str>, +) -> (Box<[u8]>, FontRef<'static>, [Option; 4]) { // Try to use fontconfig to find the font family if let Some(family) = font_family { let paths = find_font_family_variants(family); - + // 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; 4] = [ Some(regular), @@ -228,57 +246,66 @@ pub fn load_font_family(font_family: Option<&str>) -> (Box<[u8]>, FontRef<'stati 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); + 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"), + ( + "/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; 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)), ]; - - - + return (font_data, primary_font, variants); } } - + // Last resort: try NotoSansMono - let noto_regular = std::path::Path::new("/usr/share/fonts/noto/NotoSansMono-Regular.ttf"); + 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; 4] = [Some(regular_variant), None, None, None]; - + let variants: [Option; 4] = + [Some(regular_variant), None, None, None]; + return (font_data, primary_font, variants); } - + panic!("Failed to load any monospace font"); } diff --git a/src/glyph_shader.wgsl b/src/glyph_shader.wgsl index a1faf16..7f7d895 100644 --- a/src/glyph_shader.wgsl +++ b/src/glyph_shader.wgsl @@ -157,6 +157,7 @@ struct GridParams { selection_start_row: i32, selection_end_col: i32, selection_end_row: i32, + selection_row_max_col: array, } // GPUCell instance data (matches Rust GPUCell struct) @@ -187,7 +188,7 @@ struct SpriteInfo { var color_table: ColorTable; @group(1) @binding(1) -var grid_params: GridParams; +var grid_params: GridParams; @group(1) @binding(2) var cells: array; @@ -277,6 +278,11 @@ fn is_cell_selected(col: u32, row: u32) -> bool { if grid_params.selection_start_col < 0 || grid_params.selection_start_row < 0 { return false; } + + // Only highlight cells that have content in them or to their right on this row + if grid_params.selection_row_max_col[row] < 0 || col > u32(grid_params.selection_row_max_col[row]) { + return false; + } let sel_start_col = u32(grid_params.selection_start_col); let sel_start_row = u32(grid_params.selection_start_row); diff --git a/src/gpu_types.rs b/src/gpu_types.rs index 6f8a7d3..210f202 100644 --- a/src/gpu_types.rs +++ b/src/gpu_types.rs @@ -13,7 +13,8 @@ pub struct PaneId(pub u64); impl PaneId { /// Generate a new unique pane ID. pub fn new() -> Self { - static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + static COUNTER: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); Self(COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst)) } } @@ -56,19 +57,19 @@ 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_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) +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_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 +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; @@ -97,7 +98,8 @@ impl GlyphVertex { pub fn desc() -> wgpu::VertexBufferLayout<'static> { wgpu::VertexBufferLayout { - array_stride: std::mem::size_of::() as wgpu::BufferAddress, + array_stride: std::mem::size_of::() + as wgpu::BufferAddress, step_mode: wgpu::VertexStepMode::Vertex, attributes: &Self::ATTRIBS, } @@ -238,7 +240,7 @@ pub struct FontCellMetrics { /// 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)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] pub struct GridParams { pub cols: u32, pub rows: u32, @@ -253,6 +255,7 @@ pub struct GridParams { pub selection_start_row: i32, pub selection_end_col: i32, pub selection_end_row: i32, + pub selection_row_max_col: [i32; 256], } /// GPU quad instance for instanced rectangle rendering. diff --git a/src/graphics.rs b/src/graphics.rs index 17eebec..ddd46ec 100644 --- a/src/graphics.rs +++ b/src/graphics.rs @@ -11,7 +11,7 @@ use std::time::Instant; use base64::Engine; use flate2::read::ZlibDecoder; -use image::{codecs::gif::GifDecoder, AnimationDecoder, ImageFormat}; +use image::{AnimationDecoder, ImageFormat, codecs::gif::GifDecoder}; /// Action to perform with the graphics command. #[derive(Clone, Copy, Debug, PartialEq, Default)] @@ -221,8 +221,10 @@ impl GraphicsCommand { } } - let is_animation = - matches!(cmd.action, Action::AnimationFrame | Action::AnimationControl); + let is_animation = matches!( + cmd.action, + Action::AnimationFrame | Action::AnimationControl + ); // Second pass: parse all keys with correct interpretation for (key, value) in pairs { @@ -355,7 +357,11 @@ impl GraphicsCommand { } // Decode base64 payload - log::debug!("Parsing payload: len={}, content={:?}", payload_part.len(), std::str::from_utf8(payload_part).ok()); + log::debug!( + "Parsing payload: len={}, content={:?}", + payload_part.len(), + std::str::from_utf8(payload_part).ok() + ); if !payload_part.is_empty() { if let Ok(payload_str) = std::str::from_utf8(payload_part) { if let Ok(decoded) = base64_decode(payload_str) { @@ -455,8 +461,13 @@ pub fn decode_gif( return Err(GraphicsError::GifDecodeFailed); } - log::debug!("Decoded GIF: {}x{}, {} frames, {}ms total duration", - width, height, frames.len(), total_duration_ms); + log::debug!( + "Decoded GIF: {}x{}, {} frames, {}ms total duration", + width, + height, + frames.len(), + total_duration_ms + ); let first_frame = frames[0].data.clone(); @@ -484,7 +495,7 @@ pub fn decode_gif( pub fn decode_webm( path: &str, ) -> Result<(u32, u32, Vec, Option), GraphicsError> { - use ffmpeg::format::{input, Pixel}; + use ffmpeg::format::{Pixel, input}; use ffmpeg::media::Type; use ffmpeg::software::scaling::{ context::Context as ScalingContext, flag::Flags, @@ -823,7 +834,9 @@ pub struct ImageStorage { current_chunked_id: Option, /// Next auto-generated image ID. next_id: u32, - /// Flag indicating images have changed and need re-upload to GPU. + /// Images that have been updated and need re-upload to GPU. + pub dirty_images: std::collections::HashSet, + /// Flag indicating placements have changed and need re-render. pub dirty: bool, } @@ -843,6 +856,7 @@ impl ImageStorage { chunk_buffer: HashMap::new(), current_chunked_id: None, next_id: 1, + dirty_images: std::collections::HashSet::new(), dirty: false, } } @@ -861,12 +875,12 @@ impl ImageStorage { if cmd.more_chunks { // Use explicit image_id if provided, otherwise use the current chunked transfer ID let id = cmd.image_id.or(self.current_chunked_id).unwrap_or(0); - + // If this chunk has an explicit ID, it starts a new chunked transfer if cmd.image_id.is_some() { self.current_chunked_id = cmd.image_id; } - + let buffer = self.chunk_buffer.entry(id).or_default(); buffer.data.extend_from_slice(&cmd.payload); if buffer.command.is_none() { @@ -878,10 +892,10 @@ impl ImageStorage { // Check if this completes a chunked transfer // Use explicit image_id if provided, otherwise use the current chunked transfer ID let id = cmd.image_id.or(self.current_chunked_id).unwrap_or(0); - + // Clear the current chunked transfer ID since we're completing it self.current_chunked_id = None; - + if let Some(mut buffer) = self.chunk_buffer.remove(&id) { buffer.data.extend_from_slice(&cmd.payload); if let Some(mut buffered_cmd) = buffer.command { @@ -966,8 +980,15 @@ impl ImageStorage { cell_width, cell_height, ); - log::debug!("Placed image id={} at col={} row={}, cols={} rows={}, placements={}", - id, cursor_col, cursor_row, cols, rows, self.placements.len()); + log::debug!( + "Placed image id={} at col={} row={}, cols={} rows={}, placements={}", + id, + cursor_col, + cursor_row, + cols, + rows, + self.placements.len() + ); Some(PlacementResult { cols, rows, @@ -998,6 +1019,7 @@ impl ImageStorage { let virtual_placement = cmd.unicode_placeholder; if self.images.contains_key(&id) { + log::debug!("Put image {}: found in storage", id); let (cols, rows) = self.place_image( cmd, cursor_col, @@ -1013,6 +1035,11 @@ impl ImageStorage { }; (self.format_response(cmd, Ok(id)), Some(placement_result)) } else { + log::warn!( + "Put image {}: NOT found in storage! (storage size: {})", + id, + self.images.len() + ); ( self.format_response(cmd, Err(GraphicsError::ImageNotFound)), None, @@ -1022,37 +1049,47 @@ impl ImageStorage { /// Handle a delete command. fn handle_delete(&mut self, cmd: &GraphicsCommand) { + log::debug!( + "Delete command: target={:?}, id={:?}", + cmd.delete_target, + cmd.image_id + ); match &cmd.delete_target { DeleteTarget::All => { + log::debug!("Deleting all images and placements"); self.images.clear(); self.placements.clear(); self.dirty = true; } DeleteTarget::ById(id) => { let id = cmd.image_id.unwrap_or(*id); - self.images.remove(&id); + log::debug!("Removing all placements of image by id={}", id); self.placements.retain(|p| p.image_id != id); self.dirty = true; } DeleteTarget::AtCursor => { - // Would need cursor position - simplified for now + log::debug!("Deleting placements at cursor"); self.placements.clear(); self.dirty = true; } _ => { - // Other delete modes not yet implemented + log::debug!("Unhandled delete target: {:?}", cmd.delete_target); } } } /// Handle an animation frame command (a=f). /// This adds a frame to an existing image's animation. - fn handle_animation_frame(&mut self, mut cmd: GraphicsCommand) -> Option { + fn handle_animation_frame( + &mut self, + mut cmd: GraphicsCommand, + ) -> Option { let id = match cmd.image_id { Some(id) => id, None => { log::warn!("AnimationFrame without image_id"); - return self.format_response(&cmd, Err(GraphicsError::MissingId)); + return self + .format_response(&cmd, Err(GraphicsError::MissingId)); } }; @@ -1075,15 +1112,25 @@ impl ImageStorage { Ok(p) => p.trim().to_string(), Err(_) => { log::warn!("Invalid file path in animation frame"); - return self.format_response(&cmd, Err(GraphicsError::FileReadFailed)); + return self.format_response( + &cmd, + Err(GraphicsError::FileReadFailed), + ); } }; log::debug!("Reading animation frame from file: {}", path); match std::fs::read(&path) { Ok(data) => cmd.payload = data, Err(e) => { - log::warn!("Failed to read animation frame file {}: {}", path, e); - return self.format_response(&cmd, Err(GraphicsError::FileReadFailed)); + log::warn!( + "Failed to read animation frame file {}: {}", + path, + e + ); + return self.format_response( + &cmd, + Err(GraphicsError::FileReadFailed), + ); } } // Delete temp file after reading @@ -1095,20 +1142,38 @@ impl ImageStorage { let shm_name = match std::str::from_utf8(&cmd.payload) { Ok(p) => p.trim().to_string(), Err(_) => { - log::warn!("Invalid shared memory name in animation frame"); - return self.format_response(&cmd, Err(GraphicsError::FileReadFailed)); + log::warn!( + "Invalid shared memory name in animation frame" + ); + return self.format_response( + &cmd, + Err(GraphicsError::FileReadFailed), + ); } }; let shm_path = format!("/dev/shm/{}", shm_name); - log::debug!("Reading animation frame from shared memory: {}", shm_path); + log::debug!( + "Reading animation frame from shared memory: {}", + shm_path + ); match std::fs::read(&shm_path) { Ok(data) => { - log::debug!("Read {} bytes from shared memory", data.len()); + log::debug!( + "Read {} bytes from shared memory", + data.len() + ); cmd.payload = data; } Err(e) => { - log::warn!("Failed to read animation frame shm {}: {}", shm_path, e); - return self.format_response(&cmd, Err(GraphicsError::FileReadFailed)); + log::warn!( + "Failed to read animation frame shm {}: {}", + shm_path, + e + ); + return self.format_response( + &cmd, + Err(GraphicsError::FileReadFailed), + ); } } // Remove shared memory object after reading @@ -1144,7 +1209,10 @@ impl ImageStorage { Format::Gif => { // Unlikely, but handle it log::warn!("GIF format in animation frame - not supported"); - return self.format_response(&cmd, Err(GraphicsError::UnsupportedFormat)); + return self.format_response( + &cmd, + Err(GraphicsError::UnsupportedFormat), + ); } }; @@ -1153,18 +1221,23 @@ impl ImageStorage { Some(img) => img, None => { log::warn!("AnimationFrame for non-existent image {}", id); - return self.format_response(&cmd, Err(GraphicsError::ImageNotFound)); + return self + .format_response(&cmd, Err(GraphicsError::ImageNotFound)); } }; // Expected size for a full frame let expected_size = (image.width * image.height * 4) as usize; - + // Initialize animation if this image doesn't have one yet // This MUST happen before compositing so that frame 0 exists for c=1 if image.animation.is_none() { // Debug: check base image alpha values - let transparent_count = image.data.chunks(4).filter(|p| p.len() == 4 && p[3] < 255).count(); + let transparent_count = image + .data + .chunks(4) + .filter(|p| p.len() == 4 && p[3] < 255) + .count(); let total_pixels = image.data.len() / 4; log::debug!( "Creating animation base frame: {}/{} pixels have alpha < 255, data len = {}", @@ -1172,7 +1245,7 @@ impl ImageStorage { total_pixels, image.data.len() ); - + let base_frame = AnimationFrame { data: image.data.clone(), duration_ms: 100, // Default for base frame @@ -1187,7 +1260,7 @@ impl ImageStorage { loops_remaining: DEFAULT_ANIMATION_LOOPS, }); } - + // Composite the frame onto the base frame if needed // GIF animations typically use delta frames where transparent pixels // should show through to the previous frame @@ -1200,18 +1273,20 @@ impl ImageStorage { } else { (base_frame_num as usize).saturating_sub(1) }; - + if base_idx < anim.frames.len() { let base_data = &anim.frames[base_idx].data; - - if frame_data.len() == expected_size && base_data.len() == expected_size { + + if frame_data.len() == expected_size + && base_data.len() == expected_size + { // Both frames are full size - composite them // composition_mode: 0 = alpha blend, 1 = overwrite let mut composited = base_data.clone(); - + for i in (0..expected_size).step_by(4) { let src_a = frame_data[i + 3]; - + if src_a == 255 { // Fully opaque source - just copy composited[i] = frame_data[i]; @@ -1231,25 +1306,36 @@ impl ImageStorage { let src_g = frame_data[i + 1] as u32; let src_b = frame_data[i + 2] as u32; let src_a32 = src_a as u32; - + let dst_r = composited[i] as u32; let dst_g = composited[i + 1] as u32; let dst_b = composited[i + 2] as u32; let dst_a = composited[i + 3] as u32; - + // Standard alpha compositing: out = src + dst * (1 - src_a) let inv_a = 255 - src_a32; - composited[i] = ((src_r * src_a32 + dst_r * inv_a) / 255) as u8; - composited[i + 1] = ((src_g * src_a32 + dst_g * inv_a) / 255) as u8; - composited[i + 2] = ((src_b * src_a32 + dst_b * inv_a) / 255) as u8; - composited[i + 3] = (src_a32 + dst_a * inv_a / 255).min(255) as u8; + composited[i] = + ((src_r * src_a32 + dst_r * inv_a) / 255) + as u8; + composited[i + 1] = + ((src_g * src_a32 + dst_g * inv_a) / 255) + as u8; + composited[i + 2] = + ((src_b * src_a32 + dst_b * inv_a) / 255) + as u8; + composited[i + 3] = + (src_a32 + dst_a * inv_a / 255).min(255) + as u8; } } // else: src_a == 0, keep base pixel (already in composited) } - + // Debug: check alpha values - let transparent_count = composited.chunks(4).filter(|p| p.len() == 4 && p[3] < 255).count(); + let transparent_count = composited + .chunks(4) + .filter(|p| p.len() == 4 && p[3] < 255) + .count(); let total_pixels = composited.len() / 4; if transparent_count > 0 { log::debug!( @@ -1258,9 +1344,11 @@ impl ImageStorage { total_pixels ); } - + composited - } else if frame_data.len() < expected_size && base_data.len() == expected_size { + } else if frame_data.len() < expected_size + && base_data.len() == expected_size + { // Partial frame data - just use base for now log::debug!( "Frame data size {} < expected {}, using base frame {}", @@ -1277,7 +1365,10 @@ impl ImageStorage { } } else { // Base frame doesn't exist yet (shouldn't happen now), pad the data - log::warn!("Base frame {} doesn't exist, padding data", base_frame_num); + log::warn!( + "Base frame {} doesn't exist, padding data", + base_frame_num + ); let mut data = frame_data; data.resize(expected_size, 0); data @@ -1310,7 +1401,7 @@ impl ImageStorage { // Add the new frame (animation is guaranteed to exist now) if let Some(ref mut anim) = image.animation { let frame_num = cmd.edit_frame.unwrap_or(0); - + if frame_num > 0 && (frame_num as usize) <= anim.frames.len() { // Replace existing frame (1-indexed) anim.frames[frame_num as usize - 1] = frame; @@ -1319,7 +1410,7 @@ impl ImageStorage { anim.total_duration_ms += duration_ms as u64; anim.frames.push(frame); } - + log::debug!( "Added animation frame to image {}: now {} frames, {}ms total", id, @@ -1329,7 +1420,7 @@ impl ImageStorage { } self.dirty = true; - + // Return OK response (quiet mode respected) if cmd.quiet >= 1 { None @@ -1340,12 +1431,16 @@ impl ImageStorage { /// Handle an animation control command (a=a). /// This controls playback of an animated image. - fn handle_animation_control(&mut self, cmd: &GraphicsCommand) -> Option { + fn handle_animation_control( + &mut self, + cmd: &GraphicsCommand, + ) -> Option { let id = match cmd.image_id { Some(id) => id, None => { log::warn!("AnimationControl without image_id"); - return self.format_response(cmd, Err(GraphicsError::MissingId)); + return self + .format_response(cmd, Err(GraphicsError::MissingId)); } }; @@ -1361,7 +1456,8 @@ impl ImageStorage { Some(img) => img, None => { log::warn!("AnimationControl for non-existent image {}", id); - return self.format_response(cmd, Err(GraphicsError::ImageNotFound)); + return self + .format_response(cmd, Err(GraphicsError::ImageNotFound)); } }; @@ -1378,7 +1474,11 @@ impl ImageStorage { AnimationState::Loading } 3 => { - log::debug!("Animation {} running ({} frames)", id, anim.frames.len()); + log::debug!( + "Animation {} running ({} frames)", + id, + anim.frames.len() + ); // Reset frame start when starting animation anim.frame_start = None; anim.looping = true; @@ -1394,7 +1494,11 @@ impl ImageStorage { anim.current_frame = frame_num as usize - 1; // 1-indexed to 0-indexed // No need to clone - renderer uses current_frame_data() anim.frame_start = None; // Reset timing - log::debug!("Animation {} jumped to frame {}", id, frame_num); + log::debug!( + "Animation {} jumped to frame {}", + id, + frame_num + ); } } @@ -1407,7 +1511,11 @@ impl ImageStorage { anim.looping = true; anim.loops_remaining = Some(loop_count); } - log::debug!("Animation {} loop count set to {:?}", id, anim.loops_remaining); + log::debug!( + "Animation {} loop count set to {:?}", + id, + anim.loops_remaining + ); } self.dirty = true; @@ -1470,7 +1578,9 @@ impl ImageStorage { } // Delete temp file after reading - if cmd.transmission == Transmission::TempFile && file_path.is_none() { + if cmd.transmission == Transmission::TempFile + && file_path.is_none() + { let _ = std::fs::remove_file(&path); } } @@ -1504,7 +1614,9 @@ impl ImageStorage { // Payload is already the data // Try to detect format from magic bytes if format is default if cmd.format == Format::Rgba && cmd.payload.len() >= 6 { - if &cmd.payload[0..6] == b"GIF89a" || &cmd.payload[0..6] == b"GIF87a" { + if &cmd.payload[0..6] == b"GIF89a" + || &cmd.payload[0..6] == b"GIF87a" + { cmd.format = Format::Gif; } } @@ -1535,26 +1647,36 @@ impl ImageStorage { (w, h, d, None) } Format::Rgba => { - let w = cmd.width.ok_or(GraphicsError::MissingDimensions)?; - let h = cmd.height.ok_or(GraphicsError::MissingDimensions)?; + let w = + cmd.width.ok_or(GraphicsError::MissingDimensions)?; + let h = + cmd.height.ok_or(GraphicsError::MissingDimensions)?; let expected_size = (w * h * 4) as usize; if cmd.payload.len() != expected_size { log::warn!( "RGBA image size mismatch: declared {}x{} ({} bytes expected), got {} bytes", - w, h, expected_size, cmd.payload.len() + w, + h, + expected_size, + cmd.payload.len() ); return Err(GraphicsError::InvalidData); } (w, h, cmd.payload.clone(), None) } Format::Rgb => { - let w = cmd.width.ok_or(GraphicsError::MissingDimensions)?; - let h = cmd.height.ok_or(GraphicsError::MissingDimensions)?; + let w = + cmd.width.ok_or(GraphicsError::MissingDimensions)?; + let h = + cmd.height.ok_or(GraphicsError::MissingDimensions)?; let expected_size = (w * h * 3) as usize; if cmd.payload.len() != expected_size { log::warn!( "RGB image size mismatch: declared {}x{} ({} bytes expected), got {} bytes", - w, h, expected_size, cmd.payload.len() + w, + h, + expected_size, + cmd.payload.len() ); return Err(GraphicsError::InvalidData); } @@ -1581,6 +1703,7 @@ impl ImageStorage { animation, }, ); + self.dirty_images.insert(id); self.dirty = true; Ok(id) @@ -1631,8 +1754,13 @@ impl ImageStorage { }; // Handle relative positioning - let (final_col, final_row) = if let (Some(p_id), Some(q_id)) = (cmd.parent_image_id, cmd.parent_placement_id) { - if let Some(parent) = self.placements.iter().find(|p| p.image_id == p_id && p.placement_id == q_id) { + let (final_col, final_row) = if let Some(p_id) = cmd.parent_image_id { + let q_id = cmd.parent_placement_id.unwrap_or(0); + if let Some(parent) = self + .placements + .iter() + .find(|p| p.image_id == p_id && p.placement_id == q_id) + { let col = parent.col as i32 + cmd.h_offset; let row = parent.row as i32 + cmd.v_offset; (col.max(0) as usize, row.max(0) as usize) @@ -1671,12 +1799,9 @@ impl ImageStorage { y_offset: cmd.y_offset, }; - // Remove existing placement with same ID if present - if cmd.placement_id.is_some() { - self.placements.retain(|p| { - p.image_id != id || p.placement_id != placement.placement_id - }); - } + let pid = cmd.placement_id.unwrap_or(0); + self.placements + .retain(|p| p.image_id != id || p.placement_id != pid); self.placements.push(placement); self.dirty = true; @@ -1748,6 +1873,24 @@ impl ImageStorage { &self.placements } + /// Shift all image placements by a delta (e.g., when scrollback buffer wraps). + pub fn shift_placements(&mut self, delta: isize) { + if delta == 0 { + return; + } + + self.placements.retain_mut(|p| { + let new_row = p.row as isize + delta; + if new_row < 0 { + false + } else { + p.row = new_row as usize; + true + } + }); + self.dirty = true; + } + /// Get an image by ID. pub fn get_image(&self, id: u32) -> Option<&ImageData> { self.images.get(&id) @@ -1756,6 +1899,7 @@ impl ImageStorage { /// Clear the dirty flag. pub fn clear_dirty(&mut self) { self.dirty = false; + self.dirty_images.clear(); } /// Update animations and return list of image IDs that changed frames. @@ -1774,13 +1918,19 @@ impl ImageStorage { // Initialize frame start time if not set if anim.frame_start.is_none() { anim.frame_start = Some(now); - log::debug!("Animation {} started, {} frames, first frame {}ms", - id, anim.frames.len(), anim.frames[0].duration_ms); + log::debug!( + "Animation {} started, {} frames, first frame {}ms", + id, + anim.frames.len(), + anim.frames[0].duration_ms + ); } let frame_start = anim.frame_start.unwrap(); - let elapsed = now.duration_since(frame_start).as_millis() as u32; - let current_frame_duration = anim.frames[anim.current_frame].duration_ms; + let elapsed = + now.duration_since(frame_start).as_millis() as u32; + let current_frame_duration = + anim.frames[anim.current_frame].duration_ms; if elapsed >= current_frame_duration { // Advance to next frame @@ -1790,36 +1940,56 @@ impl ImageStorage { if anim.looping { // Check loop count if let Some(ref mut loops) = anim.loops_remaining { - if *loops > 0 { - log::debug!("Animation {} looping, {} loops remaining", id, *loops - 1); - *loops -= 1; - anim.current_frame = 0; - } else { - log::debug!("Animation {} stopped: no more loops", id); - // No more loops, stop - anim.state = AnimationState::Stopped; - continue; - } - - } else { - log::debug!("Animation {} looping indefinitely", id); - // Infinite looping - anim.current_frame = 0; - } - - } - log::debug!("Animation {} reached end, looping={}", id, anim.looping); - if !anim.looping { - log::debug!("Animation {} stopping (looping=false)", id); - } - // else: stay on last frame - } else { - + if *loops > 0 { + log::debug!( + "Animation {} looping, {} loops remaining", + id, + *loops - 1 + ); + *loops -= 1; + anim.current_frame = 0; + } else { + log::debug!( + "Animation {} stopped: no more loops", + id + ); + // No more loops, stop + anim.state = AnimationState::Stopped; + continue; + } + } else { + log::debug!( + "Animation {} looping indefinitely", + id + ); + // Infinite looping + anim.current_frame = 0; + } + } + log::debug!( + "Animation {} reached end, looping={}", + id, + anim.looping + ); + if !anim.looping { + log::debug!( + "Animation {} stopping (looping=false)", + id + ); + } + // else: stay on last frame + } else { anim.current_frame = next_frame; } - log::debug!("Animation {} frame {} -> {} (elapsed {}ms >= {}ms)", - id, old_frame, anim.current_frame, elapsed, current_frame_duration); + log::debug!( + "Animation {} frame {} -> {} (elapsed {}ms >= {}ms)", + id, + old_frame, + anim.current_frame, + elapsed, + current_frame_duration + ); // Just update frame index - no data clone needed! // The renderer will use current_frame_data() to get the right frame. @@ -1841,7 +2011,9 @@ impl ImageStorage { self.images.values().any(|img| { img.animation .as_ref() - .map(|a| a.state == AnimationState::Running && a.frames.len() > 1) + .map(|a| { + a.state == AnimationState::Running && a.frames.len() > 1 + }) .unwrap_or(false) }) } diff --git a/src/image_renderer.rs b/src/image_renderer.rs index 5e77997..f81c3ff 100644 --- a/src/image_renderer.rs +++ b/src/image_renderer.rs @@ -3,9 +3,9 @@ //! 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, PaneId}; use crate::graphics::{ImageData, ImagePlacement, ImageStorage}; +use std::collections::HashMap; // ═══════════════════════════════════════════════════════════════════════════════ // GPU IMAGE @@ -43,7 +43,6 @@ pub struct ImageRenderer { pub alignment: u64, } - impl ImageRenderer { /// Create a new ImageRenderer with the necessary GPU resources. pub fn new(device: &wgpu::Device) -> Self { @@ -60,42 +59,49 @@ impl ImageRenderer { }); // Create bind group layout for uniforms (binding 0) - let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Image Uniform Layout"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: true, - min_binding_size: None, - }, - count: None, - }], - }); - - // Create bind group layout for textures (binding 1, 2) - let texture_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Image Texture Layout"), - entries: &[ - 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, + let uniform_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Image Uniform Layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX + | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: true, + min_binding_size: None, }, count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); + }], + }); + + // Create bind group layout for textures (binding 1, 2) + let texture_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Image Texture Layout"), + entries: &[ + 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, + }, + ], + }); // Create a large uniform buffer for all image renders in a frame // Max 256 images per frame (65536 / 256) @@ -108,20 +114,26 @@ impl ImageRenderer { }); // Create the uniform bind group - let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Image Uniform Bind Group"), - layout: &uniform_layout, - entries: &[wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { - buffer: &uniform_buffer, - offset: 0, - size: std::num::NonZeroU64::new(std::mem::size_of::() as u64), - }), - }], - }); + let uniform_bind_group = + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Image Uniform Bind Group"), + layout: &uniform_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer( + wgpu::BufferBinding { + buffer: &uniform_buffer, + offset: 0, + size: std::num::NonZeroU64::new( + std::mem::size_of::() as u64, + ), + }, + ), + }], + }); - let alignment = device.limits().min_uniform_buffer_offset_alignment as u64; + let alignment = + device.limits().min_uniform_buffer_offset_alignment as u64; Self { uniform_layout, @@ -155,14 +167,28 @@ impl ImageRenderer { } /// Upload an image to the GPU, creating or updating its texture. - pub fn upload_image(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pane_id: PaneId, image: &ImageData) { - log::debug!("upload_image: pane_id={:?}, id={}, width={}, height={}, data_len={}", pane_id, image.id, image.width, image.height, image.data.len()); + pub fn upload_image( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + pane_id: PaneId, + image: &ImageData, + ) { + log::debug!( + "upload_image: pane_id={:?}, id={}, width={}, height={}, data_len={}", + pane_id, + image.id, + image.width, + image.height, + image.data.len() + ); // 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(&(pane_id, image.id)) { - if existing.width == image.width && existing.height == image.height { + if existing.width == image.width && existing.height == image.height + { // Same dimensions, just update the data queue.write_texture( wgpu::TexelCopyTextureInfo { @@ -200,7 +226,8 @@ impl ImageRenderer { sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); @@ -228,7 +255,10 @@ impl ImageRenderer { let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some(&format!("Image {} (pane {:?}) Bind Group", image.id, pane_id)), + label: Some(&format!( + "Image {} (pane {:?}) Bind Group", + image.id, pane_id + )), layout: &self.texture_layout, entries: &[ wgpu::BindGroupEntry { @@ -241,15 +271,17 @@ impl ImageRenderer { }, ], }); - - self.textures.insert((pane_id, image.id), GpuImage { - texture, - view, - bind_group, - width: image.width, - height: image.height, - }); + self.textures.insert( + (pane_id, image.id), + GpuImage { + texture, + view, + bind_group, + width: image.width, + height: image.height, + }, + ); log::debug!( "Uploaded image {} ({}x{}) to GPU", @@ -262,45 +294,73 @@ impl ImageRenderer { /// Remove an image from the GPU. pub fn remove_image(&mut self, pane_id: PaneId, image_id: u32) { if self.textures.remove(&(pane_id, image_id)).is_some() { - log::debug!("Removed image {} (pane {:?}) from GPU", image_id, pane_id); + log::debug!( + "Removed image {} (pane {:?}) from GPU", + image_id, + pane_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, pane_id: PaneId, storage: &mut ImageStorage) { + pub fn sync_images( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + pane_id: PaneId, + storage: &mut ImageStorage, + ) { // Update animations and get list of changed image IDs let changed_ids = storage.update_animations(); - log::debug!("Sync images: pane_id={:?}, changed_ids={:?}, dirty={}", pane_id, changed_ids, storage.dirty); - + log::debug!( + "Sync images: pane_id={:?}, changed_ids={:?}, dirty={}", + pane_id, + changed_ids, + storage.dirty + ); + // 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, pane_id, image); } } - + if !storage.dirty && changed_ids.is_empty() { + log::debug!( + "Sync images: skipping upload (not dirty, no animations)" + ); return; } - - // Upload all images (upload_image handles deduplication) - for image in storage.images().values() { - self.upload_image(device, queue, pane_id, image); + + // Upload images that were marked as dirty (newly transmitted or modified) + for id in &storage.dirty_images { + log::debug!("Sync images: uploading dirty image id={:?}", id); + if let Some(image) = storage.get_image(*id) { + self.upload_image(device, queue, pane_id, image); + } } - + storage.clear_dirty(); } - /// Remove images from the GPU that are not present in the provided set of active images. /// active_images is a set of (pane_id, image_id) tuples. - pub fn gc_images(&mut self, active_images: &std::collections::HashSet<(PaneId, u32)>) { - let gpu_ids: Vec<(PaneId, u32)> = self.textures.keys().copied().collect(); + pub fn gc_images( + &mut self, + active_images: &std::collections::HashSet<(PaneId, u32)>, + ) { + let gpu_ids: Vec<(PaneId, u32)> = + self.textures.keys().copied().collect(); let mut removed_count = 0; for id in gpu_ids { if !active_images.contains(&id) { + log::debug!( + "GC: removing image {:?} as it is no longer active", + id + ); self.remove_image(id.0, id.1); removed_count += 1; } @@ -327,40 +387,67 @@ impl ImageRenderer { visible_rows: usize, dim_factor: f32, ) -> Vec<(u32, ImageUniforms)> { - log::debug!("prepare_image_renders: pane={:?}, placements={}, scrollback={}, offset={}, rows={}", pane_id, placements.len(), scrollback_len, scroll_offset, visible_rows); + log::debug!( + "prepare_image_renders: pane={:?}, placements={}, scrollback={}, offset={}, rows={}", + pane_id, + placements.len(), + scrollback_len, + scroll_offset, + visible_rows + ); 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(&(pane_id, placement.image_id)) { - Some(img) => img, - None => { - log::debug!("Image {} not found in GPU cache for pane {:?}", placement.image_id, pane_id); - continue; - }, - }; + let gpu_image = + match self.textures.get(&(pane_id, placement.image_id)) { + Some(img) => img, + None => { + log::debug!( + "Image {} not found in GPU cache for pane {:?}", + placement.image_id, + pane_id + ); + continue; + } + }; // 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; + 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 { - log::debug!("Image {} culled: visible_row={}, image_bottom={}, visible_rows={}", placement.image_id, visible_row, image_bottom, visible_rows); + log::debug!( + "Image {} culled: visible_row={}, image_bottom={}, visible_rows={}", + placement.image_id, + visible_row, + image_bottom, + visible_rows + ); 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; + 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 + pane_x, + placement.col, + cell_width, + placement.x_offset, + pos_x ); // Calculate display size in pixels diff --git a/src/keyboard.rs b/src/keyboard.rs index 7514dc9..ecbbb11 100644 --- a/src/keyboard.rs +++ b/src/keyboard.rs @@ -73,11 +73,7 @@ impl Modifiers { bits |= 128; } - if bits == 0 { - None - } else { - Some(1 + bits) - } + if bits == 0 { None } else { Some(1 + bits) } } /// Returns true if any modifier is active. diff --git a/src/lib.rs b/src/lib.rs index 1f5246b..6bf925c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,8 +6,8 @@ pub mod box_drawing; pub mod color; pub mod color_font; pub mod config; -pub mod font_loader; pub mod edge_glow; +pub mod font_loader; pub mod gpu_types; pub mod graphics; pub mod image_renderer; @@ -16,8 +16,8 @@ pub mod pane_resources; pub mod pipeline; pub mod pty; pub mod renderer; +pub mod simd_utf8; pub mod statusline; pub mod terminal; -pub mod simd_utf8; pub mod vt_parser; mod vt_test_osc; diff --git a/src/main.rs b/src/main.rs index 0cfb572..902fc90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,9 +3,9 @@ //! Single-process architecture: owns PTY, terminal state, and rendering. //! Supports window close/reopen without losing terminal state. -use zterm::graphics::ImageStorage; -use zterm::vt_parser::SharedParser; use zterm::config::{Action, Config}; +use zterm::gpu_types::PaneId; +use zterm::graphics::ImageStorage; use zterm::keyboard::{ FunctionalKey, KeyEncoder, KeyEventType, KeyboardState, Modifiers, }; @@ -17,14 +17,14 @@ use zterm::renderer::{ use zterm::terminal::{ Direction, MouseTrackingMode, Terminal, TerminalCommand, }; -use zterm::gpu_types::PaneId; +use zterm::vt_parser::SharedParser; use std::collections::HashMap; use std::io::Write; use std::os::fd::AsRawFd; use std::process::{Command, Stdio}; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; @@ -448,13 +448,9 @@ impl SplitNode { None } } - SplitNode::Split { - first, second, .. - } => { - first - .find_pane_at_pixel(x, y) - .or_else(|| second.find_pane_at_pixel(x, y)) - } + SplitNode::Split { first, second, .. } => first + .find_pane_at_pixel(x, y) + .or_else(|| second.find_pane_at_pixel(x, y)), } } @@ -704,9 +700,9 @@ impl Tab { self.panes.get(&self.active_pane) } - /// Get the active pane mutably. - #[allow(dead_code)] - fn active_pane_mut(&mut self) -> Option<&mut Pane> { + /// Get the active pane mutably. + #[allow(dead_code)] + fn active_pane_mut(&mut self) -> Option<&mut Pane> { self.panes.get_mut(&self.active_pane) } @@ -1021,11 +1017,20 @@ struct WorkingChangeVisitor<'a> { count: &'a mut usize, } -impl<'a> gix_status::index_as_worktree_with_renames::VisitEntry<'a> for WorkingChangeVisitor<'a> { +impl<'a> gix_status::index_as_worktree_with_renames::VisitEntry<'a> + for WorkingChangeVisitor<'a> +{ type ContentChange = ::Output; type SubmoduleStatus = gix::submodule::Status; - fn visit_entry(&mut self, entry: gix_status::index_as_worktree_with_renames::Entry<'a, Self::ContentChange, Self::SubmoduleStatus>) { + fn visit_entry( + &mut self, + entry: gix_status::index_as_worktree_with_renames::Entry< + 'a, + Self::ContentChange, + Self::SubmoduleStatus, + >, + ) { if let Some(summary) = entry.summary() { match summary { gix_status::index_as_worktree_with_renames::Summary::Added @@ -1055,31 +1060,46 @@ fn get_git_status(cwd: &str) -> Option { .or(head_name_bstr.strip_prefix(b"refs/tags/")) .or_else(|| head_name_bstr.strip_prefix(b"refs/")) .map(|s| String::from_utf8_lossy(s).into_owned()) - .unwrap_or_else(|| String::from_utf8_lossy(head_name_bstr).into_owned()); + .unwrap_or_else(|| { + String::from_utf8_lossy(head_name_bstr).into_owned() + }); if head.is_empty() { return None; } - // Get ahead/behind against upstream using git's configured upstream + // Get ahead/behind against upstream using git's configured upstream let mut ahead = 0usize; let mut behind = 0usize; if let Ok(head_id) = repo.head_id() { if let Ok(Some(full_name)) = repo.head_name() { // Use gix's branch_remote_tracking_ref_name to get the remote tracking branch - if let Some(upstream_result) = repo.branch_remote_tracking_ref_name(full_name.as_ref(), gix::remote::Direction::Fetch) { + if let Some(upstream_result) = repo.branch_remote_tracking_ref_name( + full_name.as_ref(), + gix::remote::Direction::Fetch, + ) { if let Ok(upstream_ref_name) = upstream_result { - if let Ok(upstream_ref) = repo.find_reference(upstream_ref_name.as_ref()) { + if let Ok(upstream_ref) = + repo.find_reference(upstream_ref_name.as_ref()) + { let mut upstream_ref = upstream_ref; if let Ok(upstream_id) = upstream_ref.peel_to_id() { let head_id_detached = head_id.detach(); let upstream_id_detached = upstream_id.detach(); - + // Find merge base between HEAD and upstream - let mut graph = gix_revwalk::Graph::new(&repo, None); - if let Ok(merge_base_id) = repo.merge_base_with_graph(head_id_detached, upstream_id_detached, &mut graph) { - let merge_base_detached = merge_base_id.detach(); - + let mut graph = + gix_revwalk::Graph::new(&repo, None); + if let Ok(merge_base_id) = repo + .merge_base_with_graph( + head_id_detached, + upstream_id_detached, + &mut graph, + ) + { + let merge_base_detached = + merge_base_id.detach(); + // Count ahead: commits from merge_base to HEAD (exclusive of merge_base) let mut count = 0usize; let mut seen = std::collections::HashSet::new(); @@ -1092,10 +1112,15 @@ fn get_git_status(cwd: &str) -> Option { if current == merge_base_detached { break; } - if let Ok(commit) = repo.find_commit(current.clone()) { + if let Ok(commit) = + repo.find_commit(current.clone()) + { for parent_oid in commit.parent_ids() { - let parent_oid_detached = parent_oid.detach(); - if !seen.contains(&parent_oid_detached) { + let parent_oid_detached = + parent_oid.detach(); + if !seen + .contains(&parent_oid_detached) + { queue.push(parent_oid_detached); } } @@ -1116,10 +1141,15 @@ fn get_git_status(cwd: &str) -> Option { if current == merge_base_detached { break; } - if let Ok(commit) = repo.find_commit(current.clone()) { + if let Ok(commit) = + repo.find_commit(current.clone()) + { for parent_oid in commit.parent_ids() { - let parent_oid_detached = parent_oid.detach(); - if !seen.contains(&parent_oid_detached) { + let parent_oid_detached = + parent_oid.detach(); + if !seen + .contains(&parent_oid_detached) + { queue.push(parent_oid_detached); } } @@ -1150,36 +1180,62 @@ fn get_git_status(cwd: &str) -> Option { if let Ok(head_tree_id) = repo.head_tree_id() { // Count staging changes (HEAD tree vs index) if let Ok(index) = repo.index() { - let _ = repo.tree_index_status( - &head_tree_id, - &index, - None::<&mut gix::Pathspec<'_>>, - gix::status::tree_index::TrackRenames::AsConfigured, - |change: gix_diff::index::ChangeRef<'_, '_>, _tree_idx: &gix::index::State, _worktree_idx: &gix::index::State| { - match change { - gix_diff::index::ChangeRef::Addition { .. } => staging_changed += 1, - gix_diff::index::ChangeRef::Deletion { .. } => staging_changed += 1, - gix_diff::index::ChangeRef::Modification { .. } => staging_changed += 1, - gix_diff::index::ChangeRef::Rewrite { .. } => staging_changed += 1, - } - { let cf: std::ops::ControlFlow<()> = std::ops::ControlFlow::Continue(()); Ok::<_, Box>(cf) } - }, - ).ok(); + let _ = repo + .tree_index_status( + &head_tree_id, + &index, + None::<&mut gix::Pathspec<'_>>, + gix::status::tree_index::TrackRenames::AsConfigured, + |change: gix_diff::index::ChangeRef<'_, '_>, + _tree_idx: &gix::index::State, + _worktree_idx: &gix::index::State| { + match change { + gix_diff::index::ChangeRef::Addition { .. } => { + staging_changed += 1 + } + gix_diff::index::ChangeRef::Deletion { .. } => { + staging_changed += 1 + } + gix_diff::index::ChangeRef::Modification { + .. + } => staging_changed += 1, + gix_diff::index::ChangeRef::Rewrite { .. } => { + staging_changed += 1 + } + } + { + let cf: std::ops::ControlFlow<()> = + std::ops::ControlFlow::Continue(()); + Ok::<_, Box>( + cf, + ) + } + }, + ) + .ok(); } } // Count working changes (index vs worktree) if let Ok(index) = repo.index() { - let _ = repo.index_worktree_status( - &index, - Vec::<&str>::new(), - &mut WorkingChangeVisitor { count: &mut working_changed }, - gix_status::index_as_worktree::traits::FastEq, - gix::status::index_worktree::BuiltinSubmoduleStatus::new(repo.clone().into_sync(), gix::status::Submodule::AsConfigured { check_dirty: false }).ok()?, - &mut gix::progress::Discard, - &std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - Default::default(), - ).ok(); + let _ = repo + .index_worktree_status( + &index, + Vec::<&str>::new(), + &mut WorkingChangeVisitor { + count: &mut working_changed, + }, + gix_status::index_as_worktree::traits::FastEq, + gix::status::index_worktree::BuiltinSubmoduleStatus::new( + repo.clone().into_sync(), + gix::status::Submodule::AsConfigured { check_dirty: false }, + ) + .ok()?, + &mut gix::progress::Discard, + &std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + Default::default(), + ) + .ok(); } // Build status strings matching original format @@ -1529,7 +1585,7 @@ impl App { renderer.set_tab_bar_position(self.config.tab_bar_position); } - if font_size_changed { + if font_size_changed { self.config.font_size = new_font_size; // Font size change requires resize to recalculate cell dimensions self.resize_all_panes(); @@ -1610,7 +1666,7 @@ impl App { const INPUT_DELAY: Duration = Duration::from_millis(0); const PTY_KEY: usize = 0; const WAKEUP_KEY: usize = 1; - + let poller = match Poller::new() { Ok(p) => p, Err(e) => { @@ -1618,7 +1674,7 @@ impl App { return; } }; - + // Add PTY fd unsafe { if let Err(e) = poller.add(pty_fd, Event::readable(PTY_KEY)) { @@ -1631,23 +1687,23 @@ impl App { return; } } - + let mut events = Events::new(); let mut last_tick_at = std::time::Instant::now(); let mut has_pending_data = false; - + while !shutdown.load(Ordering::Relaxed) { events.clear(); - + // Check if we have space - if not, disable PTY polling until woken let has_space = shared_parser.has_space(); - + // Set up poll events: always listen on wakeup_fd, only listen on pty_fd if we have space unsafe { let pty_event = if has_space { Event::readable(PTY_KEY) } else { Event::none(PTY_KEY) }; let _ = poller.modify(std::os::fd::BorrowedFd::borrow_raw(pty_fd), pty_event); } - + // Kitty-style timeout: if we have pending data OR buffer is full, use a timeout. // When buffer is full, we need to periodically re-check if space became available // (don't rely solely on wakeup - that can lead to deadlock). @@ -1658,14 +1714,14 @@ impl App { } else { None // Block indefinitely until data arrives }; - + let wait_start = std::time::Instant::now(); match poller.wait(&mut events, timeout) { Ok(_) => { let wait_time = wait_start.elapsed(); let mut got_wakeup = false; let mut got_pty_data = false; - + for ev in events.iter() { if ev.key == WAKEUP_KEY { got_wakeup = true; @@ -1674,17 +1730,17 @@ impl App { got_pty_data = true; } } - + // Log long waits (only with render_timing feature) #[cfg(feature = "render_timing")] if wait_time.as_millis() > 50 { log::warn!("[IO-{}] Long wait: {:?} has_space={} has_pending={} got_wakeup={} got_pty={} timeout={:?}", pane_id.0, wait_time, has_space, has_pending_data, got_wakeup, got_pty_data, timeout); } - + #[cfg(not(feature = "render_timing"))] let _ = wait_time; // silence unused warning - + // Drain wakeup fd if signaled if got_wakeup { log::trace!("[IO-{}] Got wakeup from main thread", pane_id.0); @@ -1697,7 +1753,7 @@ impl App { ); } } - + // Read PTY data if: // 1. Poll said PTY is readable, OR // 2. We just got woken up (space became available) - PTY might have data @@ -1705,7 +1761,7 @@ impl App { // The PTY fd is non-blocking, so reading when empty just returns EAGAIN let fresh_has_space = shared_parser.has_space(); let should_try_read = (got_pty_data || got_wakeup) && fresh_has_space; - + if should_try_read { let mut bytes_this_loop: i64 = 0; loop { @@ -1739,7 +1795,7 @@ impl App { has_pending_data = true; log::trace!("[IO-{}] Buffer full after wakeup, will tick", pane_id.0); } - + // Send Tick to main thread if we have pending data and enough time passed // Like Kitty: just send the wakeup, don't try to deduplicate if has_pending_data { @@ -1760,7 +1816,7 @@ impl App { } } } - + log::debug!("PTY I/O thread for pane {} exiting", pane_id.0); }) .expect("Failed to spawn PTY I/O thread"); @@ -1974,8 +2030,10 @@ impl App { .saturating_sub(pane.last_scrollback_len) as isize; if let Some(ref mut selection) = pane.selection { - selection.start.row = selection.start.row.saturating_sub(lines_added); - selection.end.row = selection.end.row.saturating_sub(lines_added); + selection.start.row = + selection.start.row.saturating_sub(lines_added); + selection.end.row = + selection.end.row.saturating_sub(lines_added); } pane.last_scrollback_len = scrollback_len; } @@ -1987,7 +2045,11 @@ impl App { let active_tab_idx = self.active_tab; let fade_duration_ms = self.config.inactive_pane_fade_ms; let inactive_dim = self.config.inactive_pane_dim; - let has_pending_redraw = self.renderer.as_ref().map(|r| r.has_pending_redraw()).unwrap_or(false); + let has_pending_redraw = self + .renderer + .as_ref() + .map(|r| r.has_pending_redraw()) + .unwrap_or(false); if let Some(renderer) = &mut self.renderer { if let Some(tab) = self.tabs.get_mut(active_tab_idx) { @@ -1996,36 +2058,53 @@ impl App { let active_pane_id = tab.active_pane; // Early return: skip rendering if nothing is dirty and no animations - let has_dirty_content = geometries.iter().any(|(pane_id, _)| { - tab.panes - .get(pane_id) - .map(|p| { - let dl = &p.terminal.dirty_lines; - dl[0] != 0 || dl[1] != 0 || dl[2] != 0 || dl[3] != 0 - }) - .unwrap_or(false) - }); + let has_dirty_content = + geometries.iter().any(|(pane_id, _)| { + tab.panes + .get(pane_id) + .map(|p| { + let dl = &p.terminal.dirty_lines; + dl[0] != 0 + || dl[1] != 0 + || dl[2] != 0 + || dl[3] != 0 + }) + .unwrap_or(false) + }); // Check if any pane fade animation is in progress let fade_in_progress = geometries.iter().any(|(pane_id, _)| { tab.panes .get(pane_id) - .map(|p| { - p.is_fade_in_progress(fade_duration_ms) - }) + .map(|p| p.is_fade_in_progress(fade_duration_ms)) .unwrap_or(false) }); - let has_selection = tab.panes.values().any(|p| p.selection.is_some()); + let has_selection = + tab.panes.values().any(|p| p.selection.is_some()); // Check if any images have running animations let image_animation_in_progress = tab.panes.values().any(|p| { p.terminal.image_storage.has_animations() - || p.terminal.alternate_screen.as_ref().map_or(false, |alt| alt.image_storage.has_animations()) + || p.terminal + .alternate_screen + .as_ref() + .map_or(false, |alt| { + alt.image_storage.has_animations() + }) }); - let has_dirty_terminal = tab.panes.values().any(|p| p.terminal.dirty); + let has_dirty_terminal = + tab.panes.values().any(|p| p.terminal.dirty); - if !has_dirty_content && !has_dirty_terminal && !self.needs_redraw && self.edge_glows.is_empty() && !fade_in_progress && !has_selection && !has_pending_redraw && !image_animation_in_progress { + if !has_dirty_content + && !has_dirty_terminal + && !self.needs_redraw + && self.edge_glows.is_empty() + && !fade_in_progress + && !has_selection + && !has_pending_redraw + && !image_animation_in_progress + { return false; } @@ -2035,6 +2114,10 @@ impl App { let mut dim_factors: Vec<(PaneId, f32)> = Vec::new(); for (pane_id, _) in &geometries { if let Some(pane) = tab.panes.get_mut(pane_id) { + if pane.terminal.using_alternate_screen { + pane.selection = None; + pane.is_selecting = false; + } let is_active = *pane_id == active_pane_id; let dim_factor = pane.calculate_dim_factor( is_active, @@ -2043,30 +2126,34 @@ impl App { ); dim_factors.push((*pane_id, dim_factor)); - // Sync terminal images to GPU (Kitty graphics protocol) - let storage = if pane.terminal.using_alternate_screen { - pane.terminal.alternate_screen.as_mut().map(|alt| &mut alt.image_storage).unwrap_or(&mut pane.terminal.image_storage) - } else { - &mut pane.terminal.image_storage - }; - renderer.sync_images(*pane_id, storage); - } - } - - // Garbage collect unused images across all panes - let mut image_storages = Vec::new(); - for (id, _) in &geometries { - if let Some(pane) = tab.panes.get(id) { - image_storages.push((*id, &pane.terminal.image_storage)); - if let Some(alt) = &pane.terminal.alternate_screen { - image_storages.push((*id, &alt.image_storage)); - } - } - } - renderer.gc_images(&image_storages); + // Sync terminal images to GPU (Kitty graphics protocol) + let storage = if pane.terminal.using_alternate_screen { + pane.terminal + .alternate_screen + .as_mut() + .map(|alt| &mut alt.image_storage) + .unwrap_or(&mut pane.terminal.image_storage) + } else { + &mut pane.terminal.image_storage + }; + renderer.sync_images(*pane_id, storage); + } + } + // Garbage collect unused images across all panes + let mut image_storages = Vec::new(); + for (id, _) in &geometries { + if let Some(pane) = tab.panes.get(id) { + image_storages + .push((*id, &pane.terminal.image_storage)); + if let Some(alt) = &pane.terminal.alternate_screen { + image_storages.push((*id, &alt.image_storage)); + } + } + } + renderer.gc_images(&image_storages); - // Clear custom statusline if the foreground process is no longer neovim/vim + // Clear custom statusline if the foreground process is no longer neovim/vim if let Some(pane) = tab.panes.get_mut(&active_pane_id) { if pane.custom_statusline.is_some() { @@ -2106,9 +2193,10 @@ impl App { inactive_dim }); - let selection = pane.selection.as_ref().and_then(|sel| { - sel.to_screen_coords(scroll_offset, geom.rows) - }); + let selection = + pane.selection.as_ref().and_then(|sel| { + sel.to_screen_coords(scroll_offset, geom.rows) + }); let render_info = PaneRenderInfo { pane_id: pane_id.0, @@ -2173,9 +2261,9 @@ impl App { StatuslineContent::Sections(Vec::new()) } }) - .unwrap_or_default(); + .unwrap_or_default(); - match renderer.render_panes( + match renderer.render_panes( &pane_render_data, num_tabs, active_tab_idx, @@ -2183,28 +2271,30 @@ impl App { self.config.edge_glow_intensity, &statusline_content, ) { - Ok(_) => { + Ok(_) => { // Clear dirty lines after successful render (like Kitty's linebuf_mark_line_clean) for (pane_id, _) in &geometries { if let Some(pane) = tab.panes.get_mut(pane_id) { - pane.terminal.clear_dirty_lines(); - pane.terminal.dirty = false; - } + pane.terminal.clear_dirty_lines(); + pane.terminal.dirty = false; + } } // Clear pending redraw and needs_redraw if let Some(renderer) = &mut self.renderer { renderer.clear_pending_redraw(); } // Only clear needs_redraw if no pane fade is still in progress - let any_fade_in_progress = geometries.iter().any(|(pane_id, _)| { - tab.panes - .get(pane_id) - .map(|p| { - p.is_fade_in_progress(fade_duration_ms) - }) - .unwrap_or(false) - }); - let any_selection = tab.panes.values().any(|p| p.selection.is_some()); + let any_fade_in_progress = + geometries.iter().any(|(pane_id, _)| { + tab.panes + .get(pane_id) + .map(|p| { + p.is_fade_in_progress(fade_duration_ms) + }) + .unwrap_or(false) + }); + let any_selection = + tab.panes.values().any(|p| p.selection.is_some()); if !any_fade_in_progress && !any_selection { self.needs_redraw = false; } @@ -2521,7 +2611,7 @@ impl App { false }; - if navigated { + if navigated { self.needs_redraw = true; } @@ -2835,6 +2925,8 @@ impl App { if pane.terminal.scroll_offset > 0 { pane.terminal.scroll_offset = 0; } + pane.selection = None; + pane.is_selecting = false; } } self.write_to_pty(&bytes); @@ -2846,7 +2938,7 @@ impl ApplicationHandler for App { fn resumed(&mut self, event_loop: &ActiveEventLoop) { #[cfg(feature = "render_timing")] let start = std::time::Instant::now(); - if self.window.is_none() { + if self.window.is_none() { self.create_window(event_loop); } } @@ -2854,13 +2946,11 @@ impl ApplicationHandler for App { fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { match event { UserEvent::ShowWindow => { - if self.window.is_none() { self.create_window(event_loop); } } UserEvent::Tick => { - // Check for fatal render errors from previous frames if self.render_fatal_error { log::error!("Fatal render error occurred, exiting"); @@ -2953,14 +3043,16 @@ impl ApplicationHandler for App { }) }); let any_animations = self.tabs.iter().any(|tab| { - tab.panes.values().any(|pane| pane.terminal.image_storage.has_animations()) + tab.panes.values().any(|pane| { + pane.terminal.image_storage.has_animations() + }) }); let need_render = any_dirty || self.needs_redraw || !self.edge_glows.is_empty() || any_animations; - let should_render = need_render + let should_render = need_render && any_input && any_not_synchronized && time_since_last_render >= REPAINT_DELAY @@ -3015,7 +3107,6 @@ impl ApplicationHandler for App { ) { match event { WindowEvent::CloseRequested => { - self.destroy_window(); // Don't exit - keep running headless } @@ -3025,7 +3116,6 @@ impl ApplicationHandler for App { } WindowEvent::ScaleFactorChanged { scale_factor, .. } => { - let should_resize = if let Some(renderer) = &mut self.renderer { renderer.set_scale_factor(scale_factor) } else { @@ -3065,34 +3155,52 @@ impl ApplicationHandler for App { if lines != 0 { if let Some(tab) = self.active_tab() { - if let Some(pane_geom) = tab.split_root.find_pane_at_pixel( - self.cursor_position.x, - self.cursor_position.y, - ) { + if let Some(pane_geom) = + tab.split_root.find_pane_at_pixel( + self.cursor_position.x, + self.cursor_position.y, + ) + { if let Some(renderer) = &self.renderer { - if let Some((col, row)) = renderer.pane_pixel_to_cell( - self.cursor_position.x, - self.cursor_position.y, - pane_geom.x, - pane_geom.y, - pane_geom.width, - pane_geom.height, - pane_geom.cols, - pane_geom.rows, - ) { - if self.has_mouse_tracking_for_pane(pane_geom.pane_id) { - let button = if lines > 0 { 64 } else { 65 }; + if let Some((col, row)) = renderer + .pane_pixel_to_cell( + self.cursor_position.x, + self.cursor_position.y, + pane_geom.x, + pane_geom.y, + pane_geom.width, + pane_geom.height, + pane_geom.cols, + pane_geom.rows, + ) + { + if self.has_mouse_tracking_for_pane( + pane_geom.pane_id, + ) { + let button = + if lines > 0 { 64 } else { 65 }; let count = lines.abs().min(3); - let modifiers = self.get_mouse_modifiers(); + let modifiers = + self.get_mouse_modifiers(); for _ in 0..count { - if let Some(active_tab) = self.active_tab_mut() { - if let Some(pane) = active_tab.panes.get_mut(&pane_geom.pane_id) { - let seq = pane.terminal.encode_mouse( - button, col as u16, row as u16, true, false, - modifiers, - ); + if let Some(active_tab) = + self.active_tab_mut() + { + if let Some(pane) = active_tab + .panes + .get_mut(&pane_geom.pane_id) + { + let seq = pane + .terminal + .encode_mouse( + button, col as u16, + row as u16, true, + false, modifiers, + ); if !seq.is_empty() { - let _ = pane.pty.write(&seq); + let _ = pane + .pty + .write(&seq); } } } @@ -3140,10 +3248,13 @@ impl ApplicationHandler for App { self.active_pane().map(|p| p.is_selecting).unwrap_or(false); let mouse_pane_is_selecting = pane_geom .as_ref() - .and_then(|g| self.active_tab().and_then(|t| t.panes.get(&g.pane_id))) + .and_then(|g| { + self.active_tab().and_then(|t| t.panes.get(&g.pane_id)) + }) .map(|p| p.is_selecting) .unwrap_or(false); - let is_selecting = active_is_selecting || mouse_pane_is_selecting; + let is_selecting = + active_is_selecting || mouse_pane_is_selecting; let mouse_tracking = pane_geom .as_ref() @@ -3154,19 +3265,28 @@ impl ApplicationHandler for App { let modifiers = self.get_mouse_modifiers(); // Send mouse drag/motion events to PTY for apps like Neovim if let Some(renderer) = &self.renderer { - if let Some(geom) = pane_geom { - if let Some((col, row)) = renderer.pane_pixel_to_cell( - position.x, position.y, - geom.x, geom.y, - geom.width, geom.height, - geom.cols, geom.rows, - ) { + if let Some(geom) = pane_geom { + if let Some((col, row)) = renderer + .pane_pixel_to_cell( + position.x, + position.y, + geom.x, + geom.y, + geom.width, + geom.height, + geom.cols, + geom.rows, + ) + { // Button 0 (left) with motion flag - if let Some(active_tab) = self.active_tab_mut() { - if let Some(pane) = active_tab.panes.get_mut(&geom.pane_id) { + if let Some(active_tab) = self.active_tab_mut() + { + if let Some(pane) = + active_tab.panes.get_mut(&geom.pane_id) + { let seq = pane.terminal.encode_mouse( - 0, col as u16, row as u16, true, true, - modifiers, + 0, col as u16, row as u16, true, + true, modifiers, ); if !seq.is_empty() { let _ = pane.pty.write(&seq); @@ -3179,22 +3299,35 @@ impl ApplicationHandler for App { // Also update terminal-native selection for rendering if let Some(renderer) = &self.renderer { if let Some(geom) = pane_geom { - if let Some((col, screen_row)) = renderer.pane_pixel_to_cell( - position.x, position.y, - geom.x, geom.y, - geom.width, geom.height, - geom.cols, geom.rows, - ) { - let scroll_offset = if let Some(tab) = self.active_tab() { - tab.panes.get(&geom.pane_id) - .map(|p| p.terminal.scroll_offset) - .unwrap_or(0) - } else { 0 }; - let content_row = - screen_row as isize - scroll_offset as isize; + if let Some((col, screen_row)) = renderer + .pane_pixel_to_cell( + position.x, + position.y, + geom.x, + geom.y, + geom.width, + geom.height, + geom.cols, + geom.rows, + ) + { + let scroll_offset = + if let Some(tab) = self.active_tab() { + tab.panes + .get(&geom.pane_id) + .map(|p| p.terminal.scroll_offset) + .unwrap_or(0) + } else { + 0 + }; + let content_row = screen_row as isize + - scroll_offset as isize; - if let Some(active_tab) = self.active_tab_mut() { - if let Some(pane) = active_tab.panes.get_mut(&geom.pane_id) { + if let Some(active_tab) = self.active_tab_mut() + { + if let Some(pane) = + active_tab.panes.get_mut(&geom.pane_id) + { if let Some(ref mut selection) = pane.selection { @@ -3203,7 +3336,9 @@ impl ApplicationHandler for App { row: content_row, }; // Force GPU buffer upload so selection renders correctly - if let Some(renderer) = &mut self.renderer { + if let Some(renderer) = + &mut self.renderer + { renderer.force_full_redraw(); } } @@ -3216,22 +3351,35 @@ impl ApplicationHandler for App { // Terminal-native selection if let Some(renderer) = &self.renderer { if let Some(geom) = pane_geom { - if let Some((col, screen_row)) = renderer.pane_pixel_to_cell( - position.x, position.y, - geom.x, geom.y, - geom.width, geom.height, - geom.cols, geom.rows, - ) { - let scroll_offset = if let Some(tab) = self.active_tab() { - tab.panes.get(&geom.pane_id) - .map(|p| p.terminal.scroll_offset) - .unwrap_or(0) - } else { 0 }; - let content_row = - screen_row as isize - scroll_offset as isize; + if let Some((col, screen_row)) = renderer + .pane_pixel_to_cell( + position.x, + position.y, + geom.x, + geom.y, + geom.width, + geom.height, + geom.cols, + geom.rows, + ) + { + let scroll_offset = + if let Some(tab) = self.active_tab() { + tab.panes + .get(&geom.pane_id) + .map(|p| p.terminal.scroll_offset) + .unwrap_or(0) + } else { + 0 + }; + let content_row = screen_row as isize + - scroll_offset as isize; - if let Some(active_tab) = self.active_tab_mut() { - if let Some(pane) = active_tab.panes.get_mut(&geom.pane_id) { + if let Some(active_tab) = self.active_tab_mut() + { + if let Some(pane) = + active_tab.panes.get_mut(&geom.pane_id) + { if let Some(ref mut selection) = pane.selection { @@ -3239,7 +3387,9 @@ impl ApplicationHandler for App { col, row: content_row, }; - if let Some(renderer) = &mut self.renderer { + if let Some(renderer) = + &mut self.renderer + { renderer.force_full_redraw(); } self.request_redraw(); @@ -3272,25 +3422,43 @@ impl ApplicationHandler for App { if let Some(geom) = pane_geom { if let Some((start_col, start_screen_row)) = renderer.pane_pixel_to_cell( - down_pos.x, down_pos.y, - geom.x, geom.y, - geom.width, geom.height, - geom.cols, geom.rows, + down_pos.x, + down_pos.y, + geom.x, + geom.y, + geom.width, + geom.height, + geom.cols, + geom.rows, ) { if let Some((end_col, end_screen_row)) = renderer.pane_pixel_to_cell( - position.x, position.y, - geom.x, geom.y, - geom.width, geom.height, - geom.cols, geom.rows, + position.x, + position.y, + geom.x, + geom.y, + geom.width, + geom.height, + geom.cols, + geom.rows, ) { - let scroll_offset = if let Some(tab) = self.active_tab() { - tab.panes.get(&geom.pane_id) - .map(|p| p.terminal.scroll_offset) - .unwrap_or(0) as isize - } else { 0 }; + let scroll_offset = + if let Some(tab) = + self.active_tab() + { + tab.panes + .get(&geom.pane_id) + .map(|p| { + p.terminal + .scroll_offset + }) + .unwrap_or(0) + as isize + } else { + 0 + }; let start_pos = CellPosition { col: start_col, row: start_screen_row as isize @@ -3302,20 +3470,33 @@ impl ApplicationHandler for App { - scroll_offset, }; - if let Some(active_tab) = self.active_tab_mut() + if let Some(active_tab) = + self.active_tab_mut() { - if let Some(pane) = - active_tab.panes.get_mut(&geom.pane_id) + if let Some(pane) = active_tab + .panes + .get_mut(&geom.pane_id) { - pane.selection = - Some(Selection { - start: start_pos, - end: end_pos, - }); - pane.is_selecting = true; + if !pane + .terminal + .using_alternate_screen + { + pane.selection = + Some(Selection { + start: + start_pos, + end: end_pos, + }); + pane.is_selecting = + true; + } // Force GPU buffer upload so selection renders correctly - if let Some(renderer) = &mut self.renderer { - renderer.force_full_redraw(); + if let Some(renderer) = + &mut self.renderer + { + renderer + .force_full_redraw( + ); } self.request_redraw(); } @@ -3337,7 +3518,7 @@ impl ApplicationHandler for App { _ => return, }; - // Find the pane under the mouse for pane-aware coordinate conversion + // Find the pane under the mouse for pane-aware coordinate conversion let pane_geom = if let Some(tab) = self.active_tab() { tab.split_root.find_pane_at_pixel( self.cursor_position.x, @@ -3358,24 +3539,32 @@ impl ApplicationHandler for App { } } - let mouse_tracking = pane_geom - .as_ref() - .map(|g| self.has_mouse_tracking_for_pane(g.pane_id)) - .unwrap_or(false); - if mouse_tracking { + let mouse_tracking = pane_geom + .as_ref() + .map(|g| self.has_mouse_tracking_for_pane(g.pane_id)) + .unwrap_or(false); + if mouse_tracking { if let Some(renderer) = &self.renderer { if let Some(geom) = pane_geom { - if let Some((col, row)) = renderer.pane_pixel_to_cell( - self.cursor_position.x, - self.cursor_position.y, - geom.x, geom.y, - geom.width, geom.height, - geom.cols, geom.rows, - ) { + if let Some((col, row)) = renderer + .pane_pixel_to_cell( + self.cursor_position.x, + self.cursor_position.y, + geom.x, + geom.y, + geom.width, + geom.height, + geom.cols, + geom.rows, + ) + { let pressed = state == ElementState::Pressed; let modifiers = self.get_mouse_modifiers(); - if let Some(active_tab) = self.active_tab_mut() { - if let Some(pane) = active_tab.panes.get_mut(&geom.pane_id) { + if let Some(active_tab) = self.active_tab_mut() + { + if let Some(pane) = + active_tab.panes.get_mut(&geom.pane_id) + { let seq = pane.terminal.encode_mouse( button_code, col as u16, @@ -3385,10 +3574,15 @@ impl ApplicationHandler for App { modifiers, ); if !seq.is_empty() { - let _ = pane.pty.write(&seq); - } + let _ = pane.pty.write(&seq); + } if button == MouseButton::Left { - pane.is_selecting = pressed; + if !pane + .terminal + .using_alternate_screen + { + pane.is_selecting = pressed; + } } } } @@ -3471,21 +3665,26 @@ impl ApplicationHandler for App { dl[0] != 0 || dl[1] != 0 || dl[2] != 0 || dl[3] != 0 }) }); - let need_render = any_dirty + let need_render = any_dirty || self.needs_redraw || !self.edge_glows.is_empty() || self.tabs.iter().any(|tab| { - tab.panes.values().any(|pane| pane.terminal.image_storage.has_animations()) + tab.panes.values().any(|pane| { + pane.terminal.image_storage.has_animations() + }) }) || self.tabs.iter().any(|tab| { tab.panes.values().any(|pane| { pane.selection.is_some() || pane.is_selecting }) }) - || self.renderer.as_ref().map(|r| r.has_pending_redraw()).unwrap_or(false); + || self + .renderer + .as_ref() + .map(|r| r.has_pending_redraw()) + .unwrap_or(false); - if need_render { - } + if need_render {} if !need_render { return; @@ -3506,7 +3705,7 @@ impl ApplicationHandler for App { self.total_render_ns = 0; self.parse_count = 0; self.render_count = 0; - self.last_stats_log = std::time::Instant::now(); + self.last_stats_log = std::time::Instant::now(); } } @@ -3514,7 +3713,7 @@ impl ApplicationHandler for App { } } - fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { // Check if all tabs have exited if self.tabs.is_empty() { event_loop.exit(); @@ -3546,7 +3745,6 @@ impl ApplicationHandler for App { } if self.tabs.is_empty() { - event_loop.exit(); return; } @@ -3583,7 +3781,9 @@ fn setup_config_watcher( let watch_path = match config_path.parent() { Some(parent) => parent.to_path_buf(), None => { - log::warn!("Could not determine config directory, config hot-reload disabled"); + log::warn!( + "Could not determine config directory, config hot-reload disabled" + ); return None; } }; @@ -3637,7 +3837,6 @@ fn setup_config_watcher( return None; } - Some(watcher) } @@ -3647,11 +3846,8 @@ fn main() { ) .init(); - - // Check for existing instance if signal_existing_instance() { - return; } diff --git a/src/pipeline.rs b/src/pipeline.rs index 0c28eca..658d900 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -24,12 +24,30 @@ impl<'a> PipelineBuilder<'a> { layout: &'a wgpu::PipelineLayout, format: wgpu::TextureFormat, ) -> Self { - Self { device, shader, layout, format } + 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, &[]) + 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. @@ -42,38 +60,41 @@ impl<'a> PipelineBuilder<'a> { 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, - }) + 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, + }) } } diff --git a/src/pty.rs b/src/pty.rs index 4e20b89..a44ffca 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -1,8 +1,8 @@ //! PTY (pseudo-terminal) handling for shell communication. -use rustix::fs::{fcntl_setfl, OFlags}; -use rustix::io::{read, write, Errno}; -use rustix::pty::{grantpt, openpt, ptsname, unlockpt, OpenptFlags}; +use rustix::fs::{OFlags, fcntl_setfl}; +use rustix::io::{Errno, read, write}; +use rustix::pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt}; use std::ffi::CString; use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd, RawFd}; use thiserror::Error; @@ -36,21 +36,31 @@ pub struct Pty { impl Pty { /// Creates a new PTY and spawns a shell process. /// The initial terminal size should be provided so the shell starts with the correct dimensions. - pub fn spawn(shell: Option<&str>, cols: u16, rows: u16, xpixel: u16, ypixel: u16) -> Result { + pub fn spawn( + shell: Option<&str>, + cols: u16, + rows: u16, + xpixel: u16, + ypixel: u16, + ) -> Result { // Open the PTY master - let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC) - .map_err(PtyError::OpenMaster)?; + let master = openpt( + OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC, + ) + .map_err(PtyError::OpenMaster)?; // Set non-blocking mode on master - fcntl_setfl(&master, OFlags::NONBLOCK).map_err(|e| PtyError::Io(e.into()))?; + fcntl_setfl(&master, OFlags::NONBLOCK) + .map_err(|e| PtyError::Io(e.into()))?; // Grant and unlock the PTY grantpt(&master).map_err(PtyError::Grant)?; unlockpt(&master).map_err(PtyError::Unlock)?; // Get the slave name - let slave_name = ptsname(&master, Vec::new()).map_err(PtyError::PtsName)?; - + let slave_name = + ptsname(&master, Vec::new()).map_err(PtyError::PtsName)?; + // Set the terminal size BEFORE forking so the child inherits the correct size. // This prevents race conditions where the shell's .zshrc runs before the parent // can call resize(), causing programs like fastfetch to get wrong dimensions. @@ -78,7 +88,8 @@ impl Pty { } pid => { // Parent process - let child_pid = unsafe { rustix::process::Pid::from_raw_unchecked(pid) }; + let child_pid = + unsafe { rustix::process::Pid::from_raw_unchecked(pid) }; Ok(Self { master, child_pid }) } } @@ -125,14 +136,16 @@ impl Pty { .or_else(|| std::env::var("SHELL").ok()) .unwrap_or_else(|| "/bin/sh".to_string()); - let shell_cstr = CString::new(shell_path.clone()).expect("Invalid shell path"); + let shell_cstr = + CString::new(shell_path.clone()).expect("Invalid shell path"); let shell_name = std::path::Path::new(&shell_path) .file_name() .and_then(|n| n.to_str()) .unwrap_or("sh"); // Login shell (prepend with -) - let login_shell = CString::new(format!("-{}", shell_name)).expect("Invalid shell name"); + let login_shell = CString::new(format!("-{}", shell_name)) + .expect("Invalid shell name"); // Execute the shell let args = [login_shell.as_ptr(), std::ptr::null()]; @@ -166,7 +179,13 @@ impl Pty { } /// Resizes the PTY window. - pub fn resize(&self, cols: u16, rows: u16, xpixel: u16, ypixel: u16) -> Result<(), PtyError> { + pub fn resize( + &self, + cols: u16, + rows: u16, + xpixel: u16, + ypixel: u16, + ) -> Result<(), PtyError> { let winsize = libc::winsize { ws_row: rows, ws_col: cols, @@ -188,7 +207,7 @@ impl Pty { pub fn child_pid(&self) -> rustix::process::Pid { self.child_pid } - + /// Check if the child process has exited. pub fn child_exited(&self) -> bool { let mut status: libc::c_int = 0; @@ -204,24 +223,20 @@ impl Pty { // If it returns -1, there was an error (child might have already been reaped) result != 0 } - + /// Get the foreground process group ID of this PTY. /// Returns None if the query fails. pub fn foreground_pgid(&self) -> Option { let fd = self.master.as_raw_fd(); let pgid = unsafe { libc::tcgetpgrp(fd) }; - if pgid > 0 { - Some(pgid) - } else { - None - } + if pgid > 0 { Some(pgid) } else { None } } - + /// Get the name of the foreground process running in this PTY. /// Returns the process name (e.g., "nvim", "zsh") or None if unavailable. pub fn foreground_process_name(&self) -> Option { let pgid = self.foreground_pgid()?; - + // Read the command line from /proc//comm // (comm gives just the process name, cmdline gives full command) let comm_path = format!("/proc/{}/comm", pgid); @@ -229,12 +244,12 @@ impl Pty { .ok() .map(|s| s.trim().to_string()) } - + /// Get the current working directory of the foreground process. /// Returns the path or None if unavailable. pub fn foreground_cwd(&self) -> Option { let pgid = self.foreground_pgid()?; - + // Read the cwd symlink from /proc//cwd let cwd_path = format!("/proc/{}/cwd", pgid); std::fs::read_link(&cwd_path) diff --git a/src/renderer.rs b/src/renderer.rs index 860f1b7..ea0c337 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -3,43 +3,43 @@ use crate::box_drawing::{is_box_drawing, render_box_char}; use crate::color::LinearPalette; -use crate::color_font::{find_color_font_for_char, ColorFontRenderer}; +use crate::color_font::{ColorFontRenderer, find_color_font_for_char}; use crate::config::{Config, TabBarPosition}; -use crate::font_loader::{find_font_for_char, load_font_family, FontVariant}; -use crate::pane_resources::PaneGpuResources; -use crate::pipeline::PipelineBuilder; +use crate::font_loader::{FontVariant, find_font_for_char, load_font_family}; use crate::gpu_types::{ - GlowInstance, GlyphVertex, GridParams, ImageUniforms, - EdgeGlowUniforms, QuadParams, StatuslineParams, - ATLAS_SIZE, MAX_ATLAS_LAYERS, ATLAS_BPP, MAX_EDGE_GLOWS, - COLOR_TYPE_DEFAULT, COLOR_TYPE_INDEXED, COLOR_TYPE_RGB, - ATTR_BOLD, ATTR_ITALIC, ATTR_STRIKE, ATTR_REVERSE, - COLORED_GLYPH_FLAG, - CURSOR_SPRITE_BEAM, CURSOR_SPRITE_UNDERLINE, CURSOR_SPRITE_HOLLOW, - DECORATION_SPRITE_STRIKETHROUGH, DECORATION_SPRITE_UNDERLINE, DECORATION_SPRITE_DOUBLE_UNDERLINE, - DECORATION_SPRITE_UNDERCURL, DECORATION_SPRITE_DOTTED, DECORATION_SPRITE_DASHED, - FIRST_GLYPH_SPRITE, + ATLAS_BPP, ATLAS_SIZE, ATTR_BOLD, ATTR_ITALIC, ATTR_REVERSE, ATTR_STRIKE, + COLOR_TYPE_DEFAULT, COLOR_TYPE_INDEXED, COLOR_TYPE_RGB, COLORED_GLYPH_FLAG, + CURSOR_SPRITE_BEAM, CURSOR_SPRITE_HOLLOW, CURSOR_SPRITE_UNDERLINE, + DECORATION_SPRITE_DASHED, DECORATION_SPRITE_DOTTED, + DECORATION_SPRITE_DOUBLE_UNDERLINE, DECORATION_SPRITE_STRIKETHROUGH, + DECORATION_SPRITE_UNDERCURL, DECORATION_SPRITE_UNDERLINE, EdgeGlowUniforms, + FIRST_GLYPH_SPRITE, GlowInstance, GlyphVertex, GridParams, ImageUniforms, + MAX_ATLAS_LAYERS, MAX_EDGE_GLOWS, QuadParams, StatuslineParams, }; use crate::graphics::ImageStorage; use crate::image_renderer::ImageRenderer; +use crate::pane_resources::PaneGpuResources; +use crate::pipeline::PipelineBuilder; use crate::terminal::{Color, ColorPalette, CursorShape, Direction, Terminal}; use ab_glyph::{Font, FontRef, GlyphId, ScaleFont}; +use rustc_hash::FxHashMap; use rustybuzz::UnicodeBuffer; -use ttf_parser::Tag; use std::cell::{OnceCell, RefCell}; use std::collections::HashSet; use std::num::NonZeroU32; -use rustc_hash::FxHashMap; use std::path::PathBuf; use std::sync::Arc; +use ttf_parser::Tag; // Fontconfig for dynamic font fallback use fontconfig::Fontconfig; // Re-export types for backwards compatibility pub use crate::edge_glow::EdgeGlow; -pub use crate::statusline::{StatuslineColor, StatuslineComponent, StatuslineSection, StatuslineContent}; pub use crate::gpu_types::{FontCellMetrics, GPUCell, Quad, SpriteInfo}; +pub use crate::statusline::{ + StatuslineColor, StatuslineComponent, StatuslineContent, StatuslineSection, +}; /// Pane geometry for multi-pane rendering. /// Describes where to render a pane within the window. @@ -178,13 +178,28 @@ impl SpriteKey { /// Uses cell_index=255 as sentinel to distinguish from multi-cell cell 0 #[inline] fn single(ch: char, style: FontStyle, colored: bool) -> Self { - Self { ch, cell_index: 255, style, colored } + Self { + ch, + cell_index: 255, + style, + colored, + } } - + /// Create a key for a multi-cell sprite #[inline] - fn multi(ch: char, cell_index: u8, style: FontStyle, colored: bool) -> Self { - Self { ch, cell_index, style, colored } + fn multi( + ch: char, + cell_index: u8, + style: FontStyle, + colored: bool, + ) -> Self { + Self { + ch, + cell_index, + style, + colored, + } } } @@ -232,7 +247,8 @@ pub struct Renderer { atlas_current_layer: u32, // Font and shaping - #[allow(dead_code)] // Kept alive for rustybuzz::Face and FontRef which borrow it + #[allow(dead_code)] + // Kept alive for rustybuzz::Face and FontRef which borrow it font_data: Box<[u8]>, /// Primary font for rasterization (borrows font_data) primary_font: FontRef<'static>, @@ -254,7 +270,7 @@ pub struct Renderer { shaping_ctx: ShapingContext, /// OpenType features for shaping (shared across all font variants) shaping_features: Vec, - char_cache: FxHashMap, // cache char -> rendered glyph + char_cache: FxHashMap, // cache char -> rendered glyph ligature_cache: FxHashMap, // cache multi-char -> shaped glyphs /// Glyph cache keyed by (font_style, font_index, glyph_id) /// font_style is FontStyle as usize, font_index is 0 for primary, 1+ for fallbacks @@ -309,7 +325,6 @@ pub struct Renderer { // ═══════════════════════════════════════════════════════════════════════════════ // KITTY-STYLE INSTANCED RENDERING STATE // ═══════════════════════════════════════════════════════════════════════════════ - /// Sprite map: maps glyph content + style to sprite index. /// The sprite index is used in GPUCell.sprite_idx to reference the glyph in the atlas. sprite_map: FxHashMap, @@ -351,7 +366,6 @@ pub struct Renderer { // ═══════════════════════════════════════════════════════════════════════════════ // PER-PANE GPU RESOURCES (Like Kitty's VAO per window) // ═══════════════════════════════════════════════════════════════════════════════ - /// Bind group layout for instanced rendering - needed to create per-pane bind groups. instanced_bind_group_layout: wgpu::BindGroupLayout, /// Per-pane GPU resources, keyed by pane_id. @@ -361,7 +375,6 @@ pub struct Renderer { // ═══════════════════════════════════════════════════════════════════════════════ // STATUSLINE RENDERING (dedicated shader and pipeline) // ═══════════════════════════════════════════════════════════════════════════════ - /// GPU cells for the statusline (single row). statusline_gpu_cells: Vec, /// GPU buffer for statusline cells. @@ -392,7 +405,6 @@ pub struct Renderer { // ═══════════════════════════════════════════════════════════════════════════════ // INSTANCED QUAD RENDERING (for rectangles, borders, overlays, tab bar) // ═══════════════════════════════════════════════════════════════════════════════ - /// GPU quads for rectangle rendering. quads: Vec, /// GPU buffer for quad instances. @@ -405,7 +417,7 @@ pub struct Renderer { quad_pipeline: wgpu::RenderPipeline, /// Bind group for quad rendering. quad_bind_group: wgpu::BindGroup, - + /// GPU quads for overlay rendering (rendered on top of everything). overlay_quads: Vec, /// GPU buffer for overlay quad instances (separate from main quads). @@ -416,7 +428,10 @@ pub struct Renderer { impl Renderer { /// Creates a new renderer for the given window. - pub async fn new(window: Arc, config: &Config) -> Self { + pub async fn new( + window: Arc, + config: &Config, + ) -> Self { let size = window.inner_size(); let scale_factor = window.scale_factor(); @@ -473,12 +488,20 @@ impl Renderer { // Select alpha mode for transparency support // Prefer PreMultiplied for proper transparency blending, fall back to others let alpha_mode = if config.background_opacity < 1.0 { - if surface_caps.alpha_modes.contains(&wgpu::CompositeAlphaMode::PreMultiplied) { + if surface_caps + .alpha_modes + .contains(&wgpu::CompositeAlphaMode::PreMultiplied) + { wgpu::CompositeAlphaMode::PreMultiplied - } else if surface_caps.alpha_modes.contains(&wgpu::CompositeAlphaMode::PostMultiplied) { + } else if surface_caps + .alpha_modes + .contains(&wgpu::CompositeAlphaMode::PostMultiplied) + { wgpu::CompositeAlphaMode::PostMultiplied } else { - log::warn!("Transparency requested but compositor doesn't support alpha blending"); + log::warn!( + "Transparency requested but compositor doesn't support alpha blending" + ); surface_caps.alpha_modes[0] } } else { @@ -492,7 +515,10 @@ impl Renderer { height: size.height.max(1), // Use Immediate for lowest latency (no vsync wait) // Fall back to Mailbox if Immediate not supported - present_mode: if surface_caps.present_modes.contains(&wgpu::PresentMode::Immediate) { + present_mode: if surface_caps + .present_modes + .contains(&wgpu::PresentMode::Immediate) + { wgpu::PresentMode::Immediate } else { wgpu::PresentMode::Mailbox @@ -504,7 +530,8 @@ impl Renderer { surface.configure(&device, &surface_config); // Load primary font and font variants (regular, bold, italic, bold-italic) - let (font_data, primary_font, font_variants) = load_font_family(config.font_family.as_deref()); + let (font_data, primary_font, font_variants) = + load_font_family(config.font_family.as_deref()); // Fontconfig will be initialized lazily on first fallback font lookup // Start with empty fallback fonts - will be loaded on-demand via fontconfig @@ -525,23 +552,24 @@ impl Renderer { // Create shaping context using the regular font variant's face // The face is borrowed from font_variants[0], which is always Some let shaping_ctx = { - let regular_variant = font_variants[0].as_ref() + let regular_variant = font_variants[0] + .as_ref() .expect("Regular font variant should always be present"); - ShapingContext { - face: regular_variant.face().clone(), + ShapingContext { + face: regular_variant.face().clone(), features: shaping_features.clone(), } }; // Calculate cell dimensions from font metrics using ab_glyph - // + // // The config font_size is in pixels. Scale by display scale factor for HiDPI. // Round to integer for pixel-perfect glyph rendering. let base_font_size = config.font_size; let font_size = (base_font_size * scale_factor as f32).round(); - + let scaled_font = primary_font.as_scaled(font_size); - + // Get advance width for 'M' (em width) // Like Kitty, use ceil() to ensure glyphs always fit in cells let m_glyph_id = primary_font.glyph_id('M'); @@ -551,25 +579,28 @@ impl Renderer { // ab_glyph's height() = ascent - descent (where descent is negative) // Like Kitty, use ceil() to ensure glyphs always fit let cell_height = scaled_font.height().ceil() as u32; - + // Calculate baseline offset from top of cell. // The baseline is where the bottom of uppercase letters sit. // ascent is the distance from baseline to top of tallest glyph. let baseline = scaled_font.ascent().ceil() as u32; - + // Calculate underline position and thickness (like Kitty's freetype.c) // Use DPI-aware thickness calculation: thickness_pts * dpi / 72.0 - let underline_thickness = ((1.0 * dpi / 72.0).round() as u32).max(1).min(cell_height); + let underline_thickness = + ((1.0 * dpi / 72.0).round() as u32).max(1).min(cell_height); // Underline position is typically just below the baseline // Kitty computes: ascender - underline_position from font metrics // Since we don't have direct access to OS/2 table, use baseline + small offset - let underline_position = (baseline + underline_thickness).min(cell_height - 1); - + let underline_position = + (baseline + underline_thickness).min(cell_height - 1); + // Calculate strikethrough position and thickness (like Kitty) // Kitty: strikethrough_position = floor(baseline * 0.65) if not in font metrics - let strikethrough_position = ((baseline as f32 * 0.65).floor() as u32).min(cell_height - 1); + let strikethrough_position = + ((baseline as f32 * 0.65).floor() as u32).min(cell_height - 1); let strikethrough_thickness = underline_thickness; // Same as underline by default - + // Create FontCellMetrics struct (like Kitty) let cell_metrics = FontCellMetrics { cell_width, @@ -580,7 +611,7 @@ impl Renderer { strikethrough_position, strikethrough_thickness, }; - + // Calculate the correct scale factor for converting font units to pixels. // This matches ab_glyph's calculation: scale / height_unscaled // where height_unscaled = ascent - descent (the font's natural line height). @@ -590,61 +621,68 @@ impl Renderer { // Unlike a texture_2d_array, adding a new layer just means creating a new texture // without copying existing data. wgpu requires bind group arrays to have exactly // `count` textures, so we fill unused slots with 1x1 dummy textures. - let mut atlas_textures: Vec = Vec::with_capacity(MAX_ATLAS_LAYERS as usize); - let mut atlas_views: Vec = Vec::with_capacity(MAX_ATLAS_LAYERS as usize); - + let mut atlas_textures: Vec = + Vec::with_capacity(MAX_ATLAS_LAYERS as usize); + let mut atlas_views: Vec = + Vec::with_capacity(MAX_ATLAS_LAYERS as usize); + // Helper to create a real atlas layer (8192x8192) - let create_atlas_layer = |device: &wgpu::Device| -> (wgpu::Texture, wgpu::TextureView) { - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("Glyph Atlas Layer"), - size: wgpu::Extent3d { - width: ATLAS_SIZE, - height: ATLAS_SIZE, - 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: &[], - }); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - (texture, view) - }; - + let create_atlas_layer = + |device: &wgpu::Device| -> (wgpu::Texture, wgpu::TextureView) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Glyph Atlas Layer"), + size: wgpu::Extent3d { + width: ATLAS_SIZE, + height: ATLAS_SIZE, + 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: &[], + }); + let view = texture + .create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) + }; + // Helper to create a dummy texture (1x1) for unused slots - let create_dummy_texture = |device: &wgpu::Device| -> (wgpu::Texture, wgpu::TextureView) { - let texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("Dummy Atlas Texture"), - size: wgpu::Extent3d { - width: 1, - height: 1, - 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, - view_formats: &[], - }); - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - (texture, view) - }; - + let create_dummy_texture = + |device: &wgpu::Device| -> (wgpu::Texture, wgpu::TextureView) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Dummy Atlas Texture"), + size: wgpu::Extent3d { + width: 1, + height: 1, + 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, + view_formats: &[], + }); + let view = texture + .create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) + }; + // First texture is real (layer 0) let (tex, view) = create_atlas_layer(&device); atlas_textures.push(tex); atlas_views.push(view); - + // Fill remaining slots with dummy textures for _ in 1..MAX_ATLAS_LAYERS { let (tex, view) = create_dummy_texture(&device); atlas_textures.push(tex); atlas_views.push(view); } - + let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor { address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, @@ -663,7 +701,9 @@ impl Renderer { binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, + sample_type: wgpu::TextureSampleType::Float { + filterable: false, + }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, @@ -672,74 +712,96 @@ impl Renderer { wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + ty: wgpu::BindingType::Sampler( + wgpu::SamplerBindingType::NonFiltering, + ), count: None, }, ], }); // Create bind group with TextureViewArray - let atlas_view_refs: Vec<&wgpu::TextureView> = atlas_views.iter().collect(); - let glyph_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Glyph Bind Group"), - layout: &glyph_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureViewArray(&atlas_view_refs), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&atlas_sampler), - }, - ], - }); + let atlas_view_refs: Vec<&wgpu::TextureView> = + atlas_views.iter().collect(); + let glyph_bind_group = + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Glyph Bind Group"), + layout: &glyph_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureViewArray( + &atlas_view_refs, + ), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler( + &atlas_sampler, + ), + }, + ], + }); // Create shader - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("Glyph Shader"), - source: wgpu::ShaderSource::Wgsl(include_str!("glyph_shader.wgsl").into()), - }); + let shader = + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Glyph Shader"), + source: wgpu::ShaderSource::Wgsl( + include_str!("glyph_shader.wgsl").into(), + ), + }); - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("Glyph Pipeline Layout"), - bind_group_layouts: &[&glyph_bind_group_layout], - immediate_size: 0, - }); + let pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Glyph Pipeline Layout"), + bind_group_layouts: &[&glyph_bind_group_layout], + immediate_size: 0, + }); - let glyph_pipeline = PipelineBuilder::new(&device, &shader, &pipeline_layout, surface_config.format) - .build_full( - "Glyph Pipeline", - "vs_main", - "fs_main", - wgpu::BlendState::ALPHA_BLENDING, - wgpu::PrimitiveTopology::TriangleList, - &[GlyphVertex::desc()], - ); + let glyph_pipeline = PipelineBuilder::new( + &device, + &shader, + &pipeline_layout, + surface_config.format, + ) + .build_full( + "Glyph Pipeline", + "vs_main", + "fs_main", + wgpu::BlendState::ALPHA_BLENDING, + wgpu::PrimitiveTopology::TriangleList, + &[GlyphVertex::desc()], + ); // ═══════════════════════════════════════════════════════════════════════════════ // EDGE GLOW PIPELINE SETUP // ═══════════════════════════════════════════════════════════════════════════════ // Create edge glow shader - let edge_glow_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("Edge Glow Shader"), - source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()), - }); + let edge_glow_shader = + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Edge Glow Shader"), + source: wgpu::ShaderSource::Wgsl( + include_str!("shader.wgsl").into(), + ), + }); // Create uniform buffer for edge glow parameters - let edge_glow_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Edge Glow Uniform Buffer"), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); + let edge_glow_uniform_buffer = + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Edge Glow Uniform Buffer"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); // Create bind group layout for edge glow - let edge_glow_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Edge Glow Bind Group Layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { + let edge_glow_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Edge Glow Bind Group Layout"), + entries: &[wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { @@ -748,62 +810,70 @@ impl Renderer { min_binding_size: None, }, count: None, - }, - ], - }); + }], + }); // Create bind group for edge glow - let edge_glow_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Edge Glow Bind Group"), - layout: &edge_glow_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { + let edge_glow_bind_group = + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Edge Glow Bind Group"), + layout: &edge_glow_bind_group_layout, + entries: &[wgpu::BindGroupEntry { binding: 0, resource: edge_glow_uniform_buffer.as_entire_binding(), - }, - ], - }); + }], + }); // Create pipeline layout for edge glow - let edge_glow_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("Edge Glow Pipeline Layout"), - bind_group_layouts: &[&edge_glow_bind_group_layout], - immediate_size: 0, - }); + let edge_glow_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Edge Glow Pipeline Layout"), + bind_group_layouts: &[&edge_glow_bind_group_layout], + immediate_size: 0, + }); // Create edge glow render pipeline - let edge_glow_pipeline = PipelineBuilder::new(&device, &edge_glow_shader, &edge_glow_pipeline_layout, surface_config.format) - .build_full( - "Edge Glow Pipeline", - "vs_main", - "fs_main", - wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING, - wgpu::PrimitiveTopology::TriangleList, - &[], - ); + let edge_glow_pipeline = PipelineBuilder::new( + &device, + &edge_glow_shader, + &edge_glow_pipeline_layout, + surface_config.format, + ) + .build_full( + "Edge Glow Pipeline", + "vs_main", + "fs_main", + wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING, + wgpu::PrimitiveTopology::TriangleList, + &[], + ); // ═══════════════════════════════════════════════════════════════════════════════ // IMAGE PIPELINE SETUP (Kitty Graphics Protocol) // ═══════════════════════════════════════════════════════════════════════════════ // Create image shader - let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("Image Shader"), - source: wgpu::ShaderSource::Wgsl(include_str!("image_shader.wgsl").into()), - }); + let image_shader = + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Image Shader"), + source: wgpu::ShaderSource::Wgsl( + include_str!("image_shader.wgsl").into(), + ), + }); // Create ImageRenderer (handles sampler and bind group layout) let image_renderer = ImageRenderer::new(&device); // Create pipeline layout for images - let image_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("Image Pipeline Layout"), - bind_group_layouts: &[ - image_renderer.uniform_layout(), - image_renderer.texture_layout(), - ], - immediate_size: 0, - }); + let image_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Image Pipeline Layout"), + bind_group_layouts: &[ + image_renderer.uniform_layout(), + image_renderer.texture_layout(), + ], + immediate_size: 0, + }); // Create image render pipeline // Premultiplied alpha blending (shader outputs premultiplied) @@ -819,8 +889,13 @@ impl Renderer { operation: wgpu::BlendOperation::Add, }, }; - let image_pipeline = PipelineBuilder::new(&device, &image_shader, &image_pipeline_layout, surface_config.format) - .build("Image Pipeline", "vs_main", "fs_main", image_blend); + let image_pipeline = PipelineBuilder::new( + &device, + &image_shader, + &image_pipeline_layout, + surface_config.format, + ) + .build("Image Pipeline", "vs_main", "fs_main", image_blend); // Create initial buffers with some capacity let initial_vertex_capacity = 4096; @@ -828,7 +903,8 @@ impl Renderer { let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Glyph Vertex Buffer"), - size: (initial_vertex_capacity * std::mem::size_of::()) as u64, + size: (initial_vertex_capacity * std::mem::size_of::()) + as u64, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -858,12 +934,15 @@ impl Renderer { // Statusline cell buffer - single row, max 500 columns let statusline_max_cols = 500; - let statusline_cell_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Statusline Cell Buffer"), - size: (statusline_max_cols * std::mem::size_of::()) as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); + let statusline_cell_buffer = + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Statusline Cell Buffer"), + size: (statusline_max_cols * std::mem::size_of::()) + as u64, + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); // Sprite storage buffer - holds SpriteInfo array let sprite_buffer = device.create_buffer(&wgpu::BufferDescriptor { @@ -874,207 +953,245 @@ impl Renderer { }); // Grid parameters uniform buffer - let grid_params_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Grid Params Buffer"), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); + let grid_params_buffer = + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Grid Params Buffer"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); // Color table uniform buffer - 258 colors * 16 bytes (vec4) - let color_table_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Color Table Buffer"), - size: (258 * 16) as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); + let color_table_buffer = + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Color Table Buffer"), + size: (258 * 16) as u64, + usage: wgpu::BufferUsages::UNIFORM + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); // Create bind group layout for instanced rendering (@group(1)) - let instanced_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Instanced Bind Group Layout"), - entries: &[ - // @binding(0): color_table (uniform) - 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, + let instanced_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Instanced Bind Group Layout"), + entries: &[ + // @binding(0): color_table (uniform) + 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, }, - count: None, - }, - // @binding(1): grid_params (uniform) - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::VERTEX, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, + // @binding(1): grid_params (storage, read-only) + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { + read_only: true, + }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, - count: None, - }, - // @binding(2): cells (storage, read-only) - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::VERTEX, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, - min_binding_size: None, + // @binding(2): cells (storage, read-only) + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { + read_only: true, + }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, - count: None, - }, - // @binding(3): sprites (storage, read-only) - wgpu::BindGroupLayoutEntry { - binding: 3, - visibility: wgpu::ShaderStages::VERTEX, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, - min_binding_size: None, + // @binding(3): sprites (storage, read-only) + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { + read_only: true, + }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, - count: None, - }, - ], - }); + ], + }); // Create bind group for instanced rendering - let instanced_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Instanced Bind Group"), - layout: &instanced_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: color_table_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: grid_params_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: cell_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: sprite_buffer.as_entire_binding(), - }, - ], - }); + let instanced_bind_group = + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Instanced Bind Group"), + layout: &instanced_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: color_table_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: grid_params_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: cell_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: sprite_buffer.as_entire_binding(), + }, + ], + }); // ═══════════════════════════════════════════════════════════════════════════════ // STATUSLINE RENDERING SETUP (dedicated shader and pipeline) // ═══════════════════════════════════════════════════════════════════════════════ - - let statusline_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("Statusline Shader"), - source: wgpu::ShaderSource::Wgsl(include_str!("statusline_shader.wgsl").into()), - }); - + + let statusline_shader = + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Statusline Shader"), + source: wgpu::ShaderSource::Wgsl( + include_str!("statusline_shader.wgsl").into(), + ), + }); + // Statusline params uniform buffer - let statusline_params_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Statusline Params Buffer"), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - + let statusline_params_buffer = + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Statusline Params Buffer"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + // Statusline sprite buffer (separate from terminal sprites) let statusline_sprite_buffer_capacity = 256; // Smaller than terminal - statusline has fewer glyphs - let statusline_sprite_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Statusline Sprite Buffer"), - size: (statusline_sprite_buffer_capacity * std::mem::size_of::()) as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - + let statusline_sprite_buffer = + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Statusline Sprite Buffer"), + size: (statusline_sprite_buffer_capacity + * std::mem::size_of::()) + as u64, + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + // Create bind group layout for statusline rendering (@group(1)) // Same bindings as instanced_bind_group_layout but with StatuslineParams instead of GridParams - let statusline_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Statusline Bind Group Layout"), - entries: &[ - // @binding(0): color_table (uniform) - 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, + let statusline_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Statusline Bind Group Layout"), + entries: &[ + // @binding(0): color_table (uniform) + 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, }, - count: None, - }, - // @binding(1): statusline_params (uniform) - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::VERTEX, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, + // @binding(1): statusline_params (uniform) + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, - count: None, - }, - // @binding(2): cells (storage, read-only) - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::VERTEX, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, - min_binding_size: None, + // @binding(2): cells (storage, read-only) + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { + read_only: true, + }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, - count: None, - }, - // @binding(3): sprites (storage, read-only) - wgpu::BindGroupLayoutEntry { - binding: 3, - visibility: wgpu::ShaderStages::VERTEX, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, - min_binding_size: None, + // @binding(3): sprites (storage, read-only) + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { + read_only: true, + }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, - count: None, - }, - ], - }); - + ], + }); + // Create bind group for statusline rendering - let statusline_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Statusline Bind Group"), - layout: &statusline_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: color_table_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: statusline_params_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: statusline_cell_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: statusline_sprite_buffer.as_entire_binding(), - }, - ], - }); - + let statusline_bind_group = + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Statusline Bind Group"), + layout: &statusline_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: color_table_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: statusline_params_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: statusline_cell_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: statusline_sprite_buffer.as_entire_binding(), + }, + ], + }); + // Create pipeline layout for statusline rendering - let statusline_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("Statusline Pipeline Layout"), - bind_group_layouts: &[&glyph_bind_group_layout, &statusline_bind_group_layout], - immediate_size: 0, - }); - + let statusline_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Statusline Pipeline Layout"), + bind_group_layouts: &[ + &glyph_bind_group_layout, + &statusline_bind_group_layout, + ], + immediate_size: 0, + }); + // Statusline pipelines share shader and layout - let statusline_builder = PipelineBuilder::new(&device, &statusline_shader, &statusline_pipeline_layout, surface_config.format); + let statusline_builder = PipelineBuilder::new( + &device, + &statusline_shader, + &statusline_pipeline_layout, + surface_config.format, + ); let statusline_bg_pipeline = statusline_builder.build( "Statusline Background Pipeline", "vs_statusline_bg", @@ -1090,14 +1207,23 @@ impl Renderer { // Create pipeline layout for instanced cell rendering // Uses @group(0) for atlas texture/sampler and @group(1) for cell data - let instanced_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("Instanced Pipeline Layout"), - bind_group_layouts: &[&glyph_bind_group_layout, &instanced_bind_group_layout], - immediate_size: 0, - }); + let instanced_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Instanced Pipeline Layout"), + bind_group_layouts: &[ + &glyph_bind_group_layout, + &instanced_bind_group_layout, + ], + immediate_size: 0, + }); // Cell pipelines share shader and layout - let cell_builder = PipelineBuilder::new(&device, &shader, &instanced_pipeline_layout, surface_config.format); + let cell_builder = PipelineBuilder::new( + &device, + &shader, + &instanced_pipeline_layout, + surface_config.format, + ); let cell_bg_pipeline = cell_builder.build( "Cell Background Pipeline", "vs_cell_bg", @@ -1115,15 +1241,18 @@ impl Renderer { // INSTANCED QUAD RENDERING SETUP // For rectangles, borders, overlays, and tab bar backgrounds // ═══════════════════════════════════════════════════════════════════════════════ - - let quad_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("Quad Shader"), - source: wgpu::ShaderSource::Wgsl(include_str!("quad_shader.wgsl").into()), - }); - + + let quad_shader = + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Quad Shader"), + source: wgpu::ShaderSource::Wgsl( + include_str!("quad_shader.wgsl").into(), + ), + }); + // Maximum number of quads we can render in one batch let max_quads: usize = 256; - + // Quad buffer for instance data let quad_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Quad Buffer"), @@ -1131,92 +1260,112 @@ impl Renderer { usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - + // Quad params uniform buffer - let quad_params_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Quad Params Buffer"), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - + let quad_params_buffer = + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Quad Params Buffer"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + // Bind group layout for quad rendering - let quad_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Quad Bind Group Layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::VERTEX, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, + let quad_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Quad Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::VERTEX, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, - min_binding_size: None, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { + read_only: true, + }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, - count: None, - }, - ], - }); - + ], + }); + // Bind group for quad rendering - let quad_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Quad Bind Group"), - layout: &quad_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: quad_params_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: quad_buffer.as_entire_binding(), - }, - ], - }); - + let quad_bind_group = + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Quad Bind Group"), + layout: &quad_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: quad_params_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: quad_buffer.as_entire_binding(), + }, + ], + }); + // Overlay quad buffer for instance data (separate from main quads) - let overlay_quad_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Overlay Quad Buffer"), - size: (max_quads * std::mem::size_of::()) as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - + let overlay_quad_buffer = + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Overlay Quad Buffer"), + size: (max_quads * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + // Bind group for overlay quad rendering (uses same params buffer but different quad buffer) - let overlay_quad_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Overlay Quad Bind Group"), - layout: &quad_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: quad_params_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: overlay_quad_buffer.as_entire_binding(), - }, - ], - }); - + let overlay_quad_bind_group = + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Overlay Quad Bind Group"), + layout: &quad_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: quad_params_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: overlay_quad_buffer.as_entire_binding(), + }, + ], + }); + // Pipeline layout for quad rendering - let quad_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("Quad Pipeline Layout"), - bind_group_layouts: &[&quad_bind_group_layout], - immediate_size: 0, - }); - + let quad_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Quad Pipeline Layout"), + bind_group_layouts: &[&quad_bind_group_layout], + immediate_size: 0, + }); + // Quad pipeline - let quad_pipeline = PipelineBuilder::new(&device, &quad_shader, &quad_pipeline_layout, surface_config.format) - .build("Quad Pipeline", "vs_quad", "fs_quad", wgpu::BlendState::ALPHA_BLENDING); + let quad_pipeline = PipelineBuilder::new( + &device, + &quad_shader, + &quad_pipeline_layout, + surface_config.format, + ) + .build( + "Quad Pipeline", + "vs_quad", + "fs_quad", + wgpu::BlendState::ALPHA_BLENDING, + ); let mut renderer = Self { surface, @@ -1321,12 +1470,12 @@ impl Renderer { overlay_quad_buffer, overlay_quad_bind_group, }; - + // Create pre-rendered cursor sprites at fixed indices (like Kitty's send_prerendered_sprites) renderer.create_cursor_sprites(); // Create pre-rendered decoration sprites (underline, undercurl, strikethrough, etc.) renderer.create_decoration_sprites(); - + renderer } @@ -1348,7 +1497,11 @@ impl Renderer { pub fn statusline_y(&self) -> f32 { match self.tab_bar_position { TabBarPosition::Top => self.tab_bar_height(), - TabBarPosition::Bottom => self.height as f32 - self.tab_bar_height() - self.statusline_height(), + TabBarPosition::Bottom => { + self.height as f32 + - self.tab_bar_height() + - self.statusline_height() + } TabBarPosition::Hidden => 0.0, } } @@ -1357,7 +1510,9 @@ impl Renderer { /// Accounts for both the tab bar and the statusline. pub fn terminal_y_offset(&self) -> f32 { match self.tab_bar_position { - TabBarPosition::Top => self.tab_bar_height() + self.statusline_height(), + TabBarPosition::Top => { + self.tab_bar_height() + self.statusline_height() + } TabBarPosition::Hidden => self.statusline_height(), _ => 0.0, } @@ -1366,7 +1521,10 @@ impl Renderer { /// Sets the current selection range for highlighting. /// Pass None to clear the selection. /// The selection is specified as (start_col, start_row, end_col, end_row) in normalized order. - pub fn set_selection(&mut self, selection: Option<(usize, usize, usize, usize)>) { + pub fn set_selection( + &mut self, + selection: Option<(usize, usize, usize, usize)>, + ) { self.selection = selection; } @@ -1383,9 +1541,13 @@ impl Renderer { /// Calculates terminal dimensions in cells, accounting for tab bar and statusline. pub fn terminal_size(&self) -> (usize, usize) { - let available_height = self.height as f32 - self.tab_bar_height() - self.statusline_height(); - let cols = (self.width as f32 / self.cell_metrics.cell_width as f32).floor() as usize; - let rows = (available_height / self.cell_metrics.cell_height as f32).floor() as usize; + let available_height = self.height as f32 + - self.tab_bar_height() + - self.statusline_height(); + let cols = (self.width as f32 / self.cell_metrics.cell_width as f32) + .floor() as usize; + let rows = (available_height / self.cell_metrics.cell_height as f32) + .floor() as usize; (cols.max(1), rows.max(1)) } @@ -1393,7 +1555,9 @@ impl Renderer { /// This is the space available for panes before any cell alignment. pub fn available_grid_space(&self) -> (f32, f32) { let available_width = self.width as f32; - let available_height = self.height as f32 - self.tab_bar_height() - self.statusline_height(); + let available_height = self.height as f32 + - self.tab_bar_height() + - self.statusline_height(); (available_width, available_height) } @@ -1425,7 +1589,9 @@ impl Renderer { let (_, rows) = self.terminal_size(); rows as f32 * self.cell_metrics.cell_height as f32 }; - let available_height = self.height as f32 - self.tab_bar_height() - self.statusline_height(); + let available_height = self.height as f32 + - self.tab_bar_height() + - self.statusline_height(); (available_height - used_height) / 2.0 } @@ -1433,59 +1599,97 @@ impl Renderer { /// Takes pane coordinates in grid-relative space and transforms them to screen coordinates, /// extending to fill the terminal grid area (but not into tab bar or statusline). /// Returns (screen_x, screen_y, width, height) for the glow mask area. - pub fn calculate_edge_glow_bounds(&self, pane_x: f32, pane_y: f32, pane_width: f32, pane_height: f32) -> (f32, f32, f32, f32) { + pub fn calculate_edge_glow_bounds( + &self, + pane_x: f32, + pane_y: f32, + pane_width: f32, + pane_height: f32, + ) -> (f32, f32, f32, f32) { let grid_x_offset = self.grid_x_offset(); let grid_y_offset = self.grid_y_offset(); let terminal_y_offset = self.terminal_y_offset(); let (available_width, available_height) = self.available_grid_space(); - + // Calculate the terminal grid area boundaries in screen coordinates // This is the area where content is rendered, excluding tab bar and statusline let grid_top = terminal_y_offset; let grid_bottom = terminal_y_offset + available_height; let grid_left = 0.0_f32; let grid_right = self.width as f32; - - log::debug!("calculate_edge_glow_bounds: pane=({}, {}, {}, {})", pane_x, pane_y, pane_width, pane_height); - log::debug!(" grid area: top={}, bottom={}, left={}, right={}", grid_top, grid_bottom, grid_left, grid_right); - log::debug!(" offsets: grid_x={}, grid_y={}, terminal_y={}", grid_x_offset, grid_y_offset, terminal_y_offset); - + + log::debug!( + "calculate_edge_glow_bounds: pane=({}, {}, {}, {})", + pane_x, + pane_y, + pane_width, + pane_height + ); + log::debug!( + " grid area: top={}, bottom={}, left={}, right={}", + grid_top, + grid_bottom, + grid_left, + grid_right + ); + log::debug!( + " offsets: grid_x={}, grid_y={}, terminal_y={}", + grid_x_offset, + grid_y_offset, + terminal_y_offset + ); + // Transform pane coordinates to screen space (same as border rendering) let mut screen_x = grid_x_offset + pane_x; let mut screen_y = terminal_y_offset + grid_y_offset + pane_y; let mut width = pane_width; let mut height = pane_height; - - log::debug!(" initial screen: ({}, {}, {}, {})", screen_x, screen_y, width, height); - + + log::debug!( + " initial screen: ({}, {}, {}, {})", + screen_x, + screen_y, + width, + height + ); + // Use a larger epsilon to account for cell-alignment gaps in split panes // With cell-aligned splits, gaps can be up to one cell height - let epsilon = (self.cell_metrics.cell_height.max(self.cell_metrics.cell_width)) as f32; - + let epsilon = (self + .cell_metrics + .cell_height + .max(self.cell_metrics.cell_width)) as f32; + // Left edge at screen boundary - extend to screen left edge if pane_x < epsilon { width += screen_x - grid_left; screen_x = grid_left; } - + // Right edge at screen boundary - extend to screen right edge if (pane_x + pane_width) >= available_width - epsilon { width = grid_right - screen_x; } - + // Top edge at grid boundary - extend to grid top (respects tab bar/statusline at top) if pane_y < epsilon { height += screen_y - grid_top; screen_y = grid_top; } - + // Bottom edge at grid boundary - extend to grid bottom (respects tab bar/statusline at bottom) if (pane_y + pane_height) >= available_height - epsilon { height = grid_bottom - screen_y; } - - log::debug!(" final screen: ({}, {}, {}, {})", screen_x, screen_y, width, height); - + + log::debug!( + " final screen: ({}, {}, {}, {})", + screen_x, + screen_y, + width, + height + ); + (screen_x, screen_y, width, height) } @@ -1495,7 +1699,13 @@ impl Renderer { /// This delegates to calculate_edge_glow_bounds as the logic is identical. /// Returns (screen_x, screen_y, width, height) for the overlay area. #[inline] - pub fn calculate_dim_overlay_bounds(&self, pane_x: f32, pane_y: f32, pane_width: f32, pane_height: f32) -> (f32, f32, f32, f32) { + pub fn calculate_dim_overlay_bounds( + &self, + pane_x: f32, + pane_y: f32, + pane_width: f32, + pane_height: f32, + ) -> (f32, f32, f32, f32) { self.calculate_edge_glow_bounds(pane_x, pane_y, pane_width, pane_height) } @@ -1542,8 +1752,10 @@ impl Renderer { } // Calculate cell position - let col = (grid_x / self.cell_metrics.cell_width as f32).floor() as usize; - let row = (grid_y / self.cell_metrics.cell_height as f32).floor() as usize; + let col = + (grid_x / self.cell_metrics.cell_width as f32).floor() as usize; + let row = + (grid_y / self.cell_metrics.cell_height as f32).floor() as usize; // Get terminal dimensions to check bounds let (max_cols, max_rows) = self.terminal_size(); @@ -1593,8 +1805,10 @@ impl Renderer { let local_x = (x as f32) - pane_screen_x; let local_y = (y as f32) - pane_screen_y; - let col = (local_x / self.cell_metrics.cell_width as f32).floor() as usize; - let row = (local_y / self.cell_metrics.cell_height as f32).floor() as usize; + let col = + (local_x / self.cell_metrics.cell_width as f32).floor() as usize; + let row = + (local_y / self.cell_metrics.cell_height as f32).floor() as usize; if col >= pane_cols || row >= pane_rows { return None; @@ -1615,7 +1829,7 @@ impl Renderer { self.scale_factor = new_scale; self.dpi = 96.0 * new_scale; - + // Font size in pixels, rounded for pixel-perfect rendering self.font_size = (self.base_font_size * new_scale as f32).round(); @@ -1623,22 +1837,30 @@ impl Renderer { // Like Kitty, use ceil() to ensure glyphs always fit let scaled_font = self.primary_font.as_scaled(self.font_size); let m_glyph_id = self.primary_font.glyph_id('M'); - self.cell_metrics.cell_width = scaled_font.h_advance(m_glyph_id).ceil() as u32; + self.cell_metrics.cell_width = + scaled_font.h_advance(m_glyph_id).ceil() as u32; self.cell_metrics.cell_height = scaled_font.height().ceil() as u32; - + // Update baseline - critical for correct glyph positioning! // Like Kitty, baseline is the font's ascent (distance from baseline to top of glyphs). self.cell_metrics.baseline = scaled_font.ascent().ceil() as u32; - + // Update underline/strikethrough metrics - let underline_thickness = ((1.0 * self.dpi / 72.0).round() as u32).max(1).min(self.cell_metrics.cell_height); + let underline_thickness = ((1.0 * self.dpi / 72.0).round() as u32) + .max(1) + .min(self.cell_metrics.cell_height); self.cell_metrics.underline_thickness = underline_thickness; - self.cell_metrics.underline_position = (self.cell_metrics.baseline + underline_thickness).min(self.cell_metrics.cell_height - 1); - self.cell_metrics.strikethrough_position = ((self.cell_metrics.baseline as f32 * 0.65).floor() as u32).min(self.cell_metrics.cell_height - 1); + self.cell_metrics.underline_position = (self.cell_metrics.baseline + + underline_thickness) + .min(self.cell_metrics.cell_height - 1); + self.cell_metrics.strikethrough_position = + ((self.cell_metrics.baseline as f32 * 0.65).floor() as u32) + .min(self.cell_metrics.cell_height - 1); self.cell_metrics.strikethrough_thickness = underline_thickness; - + // Update the font units to pixels scale factor - self.font_units_to_px = self.font_size / self.primary_font.height_unscaled(); + self.font_units_to_px = + self.font_size / self.primary_font.height_unscaled(); // Reset atlas and all sprite/glyph caches (includes cursor sprite creation) self.reset_atlas(); @@ -1669,7 +1891,7 @@ impl Renderer { let old_cell_height = self.cell_metrics.cell_height; self.base_font_size = size; - + // Font size in pixels, rounded for pixel-perfect rendering self.font_size = (size * self.scale_factor as f32).round(); @@ -1677,22 +1899,30 @@ impl Renderer { // Like Kitty, use ceil() to ensure glyphs always fit let scaled_font = self.primary_font.as_scaled(self.font_size); let m_glyph_id = self.primary_font.glyph_id('M'); - self.cell_metrics.cell_width = scaled_font.h_advance(m_glyph_id).ceil() as u32; + self.cell_metrics.cell_width = + scaled_font.h_advance(m_glyph_id).ceil() as u32; self.cell_metrics.cell_height = scaled_font.height().ceil() as u32; - + // Update baseline - critical for correct glyph positioning! // Like Kitty, baseline is the font's ascent (distance from baseline to top of glyphs). self.cell_metrics.baseline = scaled_font.ascent().ceil() as u32; - + // Update underline/strikethrough metrics - let underline_thickness = ((1.0 * self.dpi / 72.0).round() as u32).max(1).min(self.cell_metrics.cell_height); + let underline_thickness = ((1.0 * self.dpi / 72.0).round() as u32) + .max(1) + .min(self.cell_metrics.cell_height); self.cell_metrics.underline_thickness = underline_thickness; - self.cell_metrics.underline_position = (self.cell_metrics.baseline + underline_thickness).min(self.cell_metrics.cell_height - 1); - self.cell_metrics.strikethrough_position = ((self.cell_metrics.baseline as f32 * 0.65).floor() as u32).min(self.cell_metrics.cell_height - 1); + self.cell_metrics.underline_position = (self.cell_metrics.baseline + + underline_thickness) + .min(self.cell_metrics.cell_height - 1); + self.cell_metrics.strikethrough_position = + ((self.cell_metrics.baseline as f32 * 0.65).floor() as u32) + .min(self.cell_metrics.cell_height - 1); self.cell_metrics.strikethrough_thickness = underline_thickness; - + // Update the font units to pixels scale factor - self.font_units_to_px = self.font_size / self.primary_font.height_unscaled(); + self.font_units_to_px = + self.font_size / self.primary_font.height_unscaled(); // Reset atlas and all sprite/glyph caches (includes cursor sprite creation) self.reset_atlas(); @@ -1707,32 +1937,30 @@ impl Renderer { /// NOTE: This should ONLY be called for font/scale changes, NOT when atlas is full /// (for that case, we add a new layer via add_atlas_layer()). fn reset_atlas(&mut self) { - - // Clear all glyph caches - they need to be re-rasterized at new size self.char_cache.clear(); self.ligature_cache.clear(); self.glyph_cache.clear(); - + // Also clear sprite map since sprite indices are now invalid self.sprite_map.clear(); self.sprite_info.clear(); self.sprite_info.push(SpriteInfo::default()); // Index 0 = no glyph self.next_sprite_idx = 1; self.cells_dirty = true; // Force re-upload of cell data - + // Also clear statusline sprite tracking - they share the same atlas self.statusline_sprite_map.clear(); self.statusline_sprite_info.clear(); self.statusline_sprite_info.push(SpriteInfo::default()); // Index 0 = no glyph self.statusline_next_sprite_idx = 1; - + // Reset atlas cursor and go back to layer 0 self.atlas_cursor_x = 0; self.atlas_cursor_y = 0; self.atlas_row_height = 0; self.atlas_current_layer = 0; - + // Create pre-rendered cursor sprites at fixed indices (like Kitty) self.create_cursor_sprites(); // Create pre-rendered decoration sprites (underline, undercurl, strikethrough, etc.) @@ -1751,7 +1979,10 @@ impl Renderer { Color::Default => COLOR_TYPE_DEFAULT, Color::Indexed(idx) => COLOR_TYPE_INDEXED | ((*idx as u32) << 8), Color::Rgb(r, g, b) => { - COLOR_TYPE_RGB | ((*r as u32) << 8) | ((*g as u32) << 16) | ((*b as u32) << 24) + COLOR_TYPE_RGB + | ((*r as u32) << 8) + | ((*g as u32) << 16) + | ((*b as u32) << 24) } } } @@ -1759,12 +1990,26 @@ impl Renderer { /// Pack cell attributes into u32 format for GPU. /// underline_style: 0=none, 1=single, 2=double, 3=curly, 4=dotted, 5=dashed #[inline] - fn pack_attrs(bold: bool, italic: bool, underline_style: u8, strikethrough: bool, reverse: bool) -> u32 { + fn pack_attrs( + bold: bool, + italic: bool, + underline_style: u8, + strikethrough: bool, + reverse: bool, + ) -> u32 { let mut attrs = (underline_style as u32) & 0x7; // 3 bits for decoration type - if bold { attrs |= ATTR_BOLD; } - if italic { attrs |= ATTR_ITALIC; } - if strikethrough { attrs |= ATTR_STRIKE; } - if reverse { attrs |= ATTR_REVERSE; } + if bold { + attrs |= ATTR_BOLD; + } + if italic { + attrs |= ATTR_ITALIC; + } + if strikethrough { + attrs |= ATTR_STRIKE; + } + if reverse { + attrs |= ATTR_REVERSE; + } attrs } @@ -1773,29 +2018,39 @@ impl Renderer { fn pack_statusline_color(color: StatuslineColor) -> u32 { match color { StatuslineColor::Default => COLOR_TYPE_DEFAULT, - StatuslineColor::Indexed(idx) => COLOR_TYPE_INDEXED | ((idx as u32) << 8), + StatuslineColor::Indexed(idx) => { + COLOR_TYPE_INDEXED | ((idx as u32) << 8) + } StatuslineColor::Rgb(r, g, b) => { - COLOR_TYPE_RGB | ((r as u32) << 8) | ((g as u32) << 16) | ((b as u32) << 24) + COLOR_TYPE_RGB + | ((r as u32) << 8) + | ((g as u32) << 16) + | ((b as u32) << 24) } } } /// Get or create a sprite index for a character. /// Returns (sprite_idx, is_colored). - /// + /// /// This uses the same approach as Kitty: shape the text with HarfBuzz using /// the appropriate font variant (regular, bold, italic, bold-italic), then /// rasterize the resulting glyph ID with the styled font. - /// + /// /// The `target` parameter specifies which sprite buffer to use: /// - `SpriteTarget::Terminal` uses the main terminal sprite buffer /// - `SpriteTarget::Statusline` uses the separate statusline sprite buffer - fn get_or_create_sprite_for(&mut self, c: char, style: FontStyle, target: SpriteTarget) -> (u32, bool) { + fn get_or_create_sprite_for( + &mut self, + c: char, + style: FontStyle, + target: SpriteTarget, + ) -> (u32, bool) { // Skip spaces and null characters - they use sprite index 0 if c == ' ' || c == '\0' { return (0, false); } - + // Select the appropriate sprite tracking based on target let (sprite_map, _sprite_info, _next_sprite_idx) = match target { SpriteTarget::Terminal => ( @@ -1809,22 +2064,22 @@ impl Renderer { &mut self.statusline_next_sprite_idx, ), }; - + // Check if we already have this sprite let key = SpriteKey::single(c, style, false); - + if let Some(&idx) = sprite_map.get(&key) { // Check if it's a colored glyph let is_colored = (idx & COLORED_GLYPH_FLAG) != 0; return (idx, is_colored); } - + // Check for emoji with color key let color_key = SpriteKey::single(c, style, true); if let Some(&idx) = sprite_map.get(&color_key) { return (idx, true); } - + // Need to rasterize this glyph // For box-drawing and multi-cell symbols (PUA/dingbats), use rasterize_char // which has full font fallback and color font support. @@ -1837,14 +2092,15 @@ impl Renderer { // This gets us the correct glyph ID for the styled font variant let char_str = c.to_string(); let shaped = self.shape_text_with_style(&char_str, style); - + if shaped.glyphs.is_empty() { // Fallback to regular rasterization if shaping fails self.rasterize_char(c) } else { // Get the glyph ID from shaping - let (glyph_id, _x_advance, _x_offset, _y_offset, _cluster) = shaped.glyphs[0]; - + let (glyph_id, _x_advance, _x_offset, _y_offset, _cluster) = + shaped.glyphs[0]; + // If glyph_id is 0, the font doesn't have this character (.notdef) // Fall back to rasterize_char which has full font fallback support if glyph_id == 0 { @@ -1855,12 +2111,12 @@ impl Renderer { } } }; - + // If glyph has no size, return 0 if glyph.size[0] <= 0.0 || glyph.size[1] <= 0.0 { return (0, false); } - + // Create sprite info from glyph info // In Kitty's model, glyphs are pre-positioned in cell-sized sprites, // so no offset is needed - the shader just maps sprite to cell 1:1 @@ -1870,7 +2126,7 @@ impl Renderer { _padding: 0.0, size: glyph.size, }; - + // Re-borrow the sprite tracking for the target (needed after self borrows above) let (sprite_map, sprite_info, next_sprite_idx) = match target { SpriteTarget::Terminal => ( @@ -1884,43 +2140,47 @@ impl Renderer { &mut self.statusline_next_sprite_idx, ), }; - + // Allocate new sprite index let sprite_idx = *next_sprite_idx; *next_sprite_idx += 1; - + // Add to sprite info array (ensure we have enough capacity) while sprite_info.len() <= sprite_idx as usize { sprite_info.push(SpriteInfo::default()); } sprite_info[sprite_idx as usize] = sprite; - + // Mark as colored if glyph is colored (emoji rendered via color font) let final_idx = if glyph.is_colored { sprite_idx | COLORED_GLYPH_FLAG } else { sprite_idx }; - + // Cache the mapping let cache_key = SpriteKey::single(c, style, glyph.is_colored); sprite_map.insert(cache_key, final_idx); - + (final_idx, glyph.is_colored) } - + /// Get or create a sprite index for a character in the terminal sprite buffer. /// Returns (sprite_idx, is_colored). - /// + /// /// This is a convenience wrapper around `get_or_create_sprite_for` that uses /// the terminal sprite buffer. - fn get_or_create_sprite(&mut self, c: char, style: FontStyle) -> (u32, bool) { + fn get_or_create_sprite( + &mut self, + c: char, + style: FontStyle, + ) -> (u32, bool) { self.get_or_create_sprite_for(c, style, SpriteTarget::Terminal) } /// Convert terminal cells to GPU cells for a visible row. /// This is called when terminal content changes to update the GPU buffer. - /// + /// /// Note: This method cannot take &mut self because it's called from update_gpu_cells /// which needs to borrow both self (for sprite lookups) and self.gpu_cells (for output). /// Instead, we pass in the necessary state explicitly. @@ -1933,7 +2193,7 @@ impl Renderer { let mut col = 0; while col < cols.min(row.len()) { let cell = &row[col]; - + // Skip wide character continuations - they share the sprite of the previous cell if cell.wide_continuation { gpu_row[col] = GPUCell { @@ -1941,64 +2201,89 @@ impl Renderer { bg: Self::pack_color(&cell.bg_color), decoration_fg: 0, sprite_idx: 0, // No glyph for continuation - attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline_style, cell.strikethrough, cell.reverse), + attrs: Self::pack_attrs( + cell.bold, + cell.italic, + cell.underline_style, + cell.strikethrough, + cell.reverse, + ), }; col += 1; continue; } - + // Get font style let style = FontStyle::from_flags(cell.bold, cell.italic); let c = cell.character; - + // Check for symbol+empty multi-cell pattern // Like Kitty, look for symbol character followed by empty cells - if c != ' ' && c != '\0' && Self::is_multicell_symbol(c) && !is_box_drawing(c) { + if c != ' ' + && c != '\0' + && Self::is_multicell_symbol(c) + && !is_box_drawing(c) + { // Count trailing empty cells to determine if this is a multi-cell group let mut num_empty = 0; const MAX_EXTRA_CELLS: usize = 4; - - while col + num_empty + 1 < row.len() && num_empty < MAX_EXTRA_CELLS { + + while col + num_empty + 1 < row.len() + && num_empty < MAX_EXTRA_CELLS + { let next_char = row[col + num_empty + 1].character; // Check for space, en-space, or empty/null cell - if next_char == ' ' || next_char == '\u{2002}' || next_char == '\0' { + if next_char == ' ' + || next_char == '\u{2002}' + || next_char == '\0' + { num_empty += 1; } else { break; } } - + if num_empty > 0 { let total_cells = 1 + num_empty; - + // Try to find multi-cell sprites - check non-colored first (more common), then colored let first_key_normal = SpriteKey::multi(c, 0, style, false); - - let (first_sprite, is_colored) = if let Some(&sprite) = sprite_map.get(&first_key_normal) { + + let (first_sprite, is_colored) = if let Some(&sprite) = + sprite_map.get(&first_key_normal) + { (Some(sprite), false) } else { - let first_key_colored = SpriteKey::multi(c, 0, style, true); - if let Some(&sprite) = sprite_map.get(&first_key_colored) { + let first_key_colored = + SpriteKey::multi(c, 0, style, true); + if let Some(&sprite) = + sprite_map.get(&first_key_colored) + { (Some(sprite), true) } else { (None, false) } }; - + if let Some(first_sprite) = first_sprite { // Use multi-cell sprites for each cell in the group for cell_idx in 0..total_cells { if col + cell_idx >= cols { break; } - + let sprite_idx = if cell_idx == 0 { first_sprite } else { - let key = SpriteKey::multi(c, cell_idx as u8, style, is_colored); + let key = SpriteKey::multi( + c, + cell_idx as u8, + style, + is_colored, + ); sprite_map.get(&key).copied().unwrap_or(0) }; - + // For colored glyphs (emoji), set the COLORED_GLYPH_FLAG so the shader // knows to use the atlas color directly instead of applying fg color let final_sprite_idx = if is_colored { @@ -2006,7 +2291,7 @@ impl Renderer { } else { sprite_idx }; - + // Use the symbol cell's foreground color for all cells in the group let current_cell = &row[col + cell_idx]; gpu_row[col + cell_idx] = GPUCell { @@ -2014,67 +2299,89 @@ impl Renderer { bg: Self::pack_color(¤t_cell.bg_color), decoration_fg: 0, sprite_idx: final_sprite_idx, - attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline_style, cell.strikethrough, cell.reverse), + attrs: Self::pack_attrs( + cell.bold, + cell.italic, + cell.underline_style, + cell.strikethrough, + cell.reverse, + ), }; } - + col += total_cells; continue; } } } - + // Check for emoji multi-cell pattern (colored glyphs followed by empty cells) // This is separate from PUA because emoji detection happens via sprite lookup if c != ' ' && c != '\0' { let mut num_empty = 0; const MAX_EXTRA_CELLS: usize = 1; // Emoji are 2 cells wide - - while col + num_empty + 1 < row.len() && num_empty < MAX_EXTRA_CELLS { + + while col + num_empty + 1 < row.len() + && num_empty < MAX_EXTRA_CELLS + { let next_cell = &row[col + num_empty + 1]; let next_char = next_cell.character; - if next_char == ' ' || next_char == '\u{2002}' || next_char == '\0' { + if next_char == ' ' + || next_char == '\u{2002}' + || next_char == '\0' + { num_empty += 1; } else { break; } } - + if num_empty > 0 { // Check if we have colored multi-cell sprites for this character let first_key = SpriteKey::multi(c, 0, style, true); - + if let Some(&first_sprite) = sprite_map.get(&first_key) { let total_cells = 1 + num_empty; - + for cell_idx in 0..total_cells { if col + cell_idx >= cols { break; } - + let sprite_idx = if cell_idx == 0 { first_sprite } else { - let key = SpriteKey::multi(c, cell_idx as u8, style, true); + let key = SpriteKey::multi( + c, + cell_idx as u8, + style, + true, + ); sprite_map.get(&key).copied().unwrap_or(0) }; - + let current_cell = &row[col + cell_idx]; gpu_row[col + cell_idx] = GPUCell { fg: Self::pack_color(&cell.fg_color), bg: Self::pack_color(¤t_cell.bg_color), decoration_fg: 0, sprite_idx: sprite_idx | COLORED_GLYPH_FLAG, - attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline_style, cell.strikethrough, cell.reverse), + attrs: Self::pack_attrs( + cell.bold, + cell.italic, + cell.underline_style, + cell.strikethrough, + cell.reverse, + ), }; } - + col += total_cells; continue; } } } - + // Regular character lookup let sprite_idx = if c == ' ' || c == '\0' { 0 @@ -2088,17 +2395,24 @@ impl Renderer { sprite_map.get(&color_key).copied().unwrap_or(0) } }; - + gpu_row[col] = GPUCell { fg: Self::pack_color(&cell.fg_color), bg: Self::pack_color(&cell.bg_color), decoration_fg: 0, - sprite_idx, - attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline_style, cell.strikethrough, cell.reverse), + sprite_idx: sprite_idx, + attrs: Self::pack_attrs( + cell.bold, + cell.italic, + cell.underline_style, + cell.strikethrough, + cell.reverse, + ), }; + col += 1; } - + // Fill remaining columns with empty cells for col_idx in row.len()..cols { gpu_row[col_idx] = GPUCell::default(); @@ -2111,75 +2425,96 @@ impl Renderer { /// Get or create GPU resources for a pane. /// Like Kitty's create_cell_vao(), this allocates per-pane buffers and bind group. - /// + /// /// Following Kitty's approach: we check if size matches exactly and reallocate if needed. /// This is simpler than tracking capacity with headroom. - fn get_or_create_pane_resources(&mut self, pane_id: u64, required_cells: usize) -> &PaneGpuResources { + fn get_or_create_pane_resources( + &mut self, + pane_id: u64, + required_cells: usize, + ) -> &PaneGpuResources { // Check if we need to create or resize (like Kitty's alloc_buffer size check) let needs_create = match self.pane_resources.get(&pane_id) { None => true, - Some(res) => res.capacity != required_cells, // Reallocate if size changed (Kitty's approach) + Some(res) => res.capacity != required_cells, // Reallocate if size changed (Kitty's approach) }; - + if needs_create { // Create new buffers with exact size needed (like Kitty - no headroom) let capacity = required_cells; - - let cell_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some(&format!("Pane {} Cell Buffer", pane_id)), - size: (capacity * std::mem::size_of::()) as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - - let grid_params_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some(&format!("Pane {} Grid Params Buffer", pane_id)), - size: std::mem::size_of::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - + + let cell_buffer = + self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some(&format!("Pane {} Cell Buffer", pane_id)), + size: (capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let grid_params_buffer = + self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some(&format!( + "Pane {} Grid Params Buffer", + pane_id + )), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + // Create bind group referencing this pane's buffers + shared resources - let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some(&format!("Pane {} Bind Group", pane_id)), - layout: &self.instanced_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: self.color_table_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: grid_params_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: cell_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: self.sprite_buffer.as_entire_binding(), - }, - ], - }); - - self.pane_resources.insert(pane_id, PaneGpuResources { - cell_buffer, - grid_params_buffer, - bind_group, - capacity, - }); + let bind_group = + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(&format!("Pane {} Bind Group", pane_id)), + layout: &self.instanced_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self + .color_table_buffer + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: grid_params_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: cell_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: self.sprite_buffer.as_entire_binding(), + }, + ], + }); + + self.pane_resources.insert( + pane_id, + PaneGpuResources { + cell_buffer, + grid_params_buffer, + bind_group, + capacity, + }, + ); } - + self.pane_resources.get(&pane_id).unwrap() } - + /// Remove GPU resources for panes that no longer exist. /// Like Kitty's remove_vao(), this frees GPU resources when panes are destroyed. - /// + /// /// Call this after rendering with a set of active pane IDs. - pub fn cleanup_unused_pane_resources(&mut self, active_pane_ids: &std::collections::HashSet) { - self.pane_resources.retain(|id, _| active_pane_ids.contains(id)); + pub fn cleanup_unused_pane_resources( + &mut self, + active_pane_ids: &std::collections::HashSet, + ) { + self.pane_resources + .retain(|id, _| active_pane_ids.contains(id)); } /// Force a full GPU cell buffer rebuild on the next call to update_gpu_cells. @@ -2187,7 +2522,7 @@ impl Renderer { self.cells_dirty = true; } - /// Check if a full redraw is pending. + /// Check if a full redraw is pending. pub fn has_pending_redraw(&self) -> bool { self.cells_dirty } @@ -2199,13 +2534,13 @@ impl Renderer { /// Update GPU cell buffer from terminal content. /// Like Kitty, this only processes dirty lines to minimize work. - /// + /// /// Returns true if any cells were updated (buffer needs upload to GPU). pub fn update_gpu_cells(&mut self, terminal: &Terminal) -> bool { let cols = terminal.cols; let rows = terminal.rows; let total_cells = cols * rows; - + // Check if grid size changed - need full rebuild let size_changed = self.last_grid_size != (cols, rows); if size_changed { @@ -2213,10 +2548,10 @@ impl Renderer { self.last_grid_size = (cols, rows); self.cells_dirty = true; } - + // Check if this terminal has any dirty lines let has_dirty = terminal.has_any_dirty_line(); - + // First pass: ensure all characters have sprites // This needs mutable access to self for sprite creation // Like Kitty's render_line(), detect PUA+space patterns for multi-cell rendering @@ -2224,74 +2559,103 @@ impl Renderer { // OPTIMIZATION: Use get_visible_row() to avoid Vec allocation for row_idx in 0..rows { // Skip clean lines (unless size changed or terminal has dirty lines) - if !self.cells_dirty && !has_dirty && !terminal.is_line_dirty(row_idx) { + if !self.cells_dirty + && !has_dirty + && !terminal.is_line_dirty(row_idx) + { continue; } - + let Some(row) = terminal.get_visible_row(row_idx) else { continue; }; - + let mut col = 0; while col < row.len() { let cell = &row[col]; - - if cell.character == ' ' || cell.character == '\0' || cell.wide_continuation { + + if cell.character == ' ' + || cell.character == '\0' + || cell.wide_continuation + { col += 1; continue; } - + let c = cell.character; let style = FontStyle::from_flags(cell.bold, cell.italic); - + // Check if this is a symbol that might need multi-cell rendering // Like Kitty's render_line() at fonts.c:1873-1912 // This includes PUA characters and dingbats if Self::is_multicell_symbol(c) && !is_box_drawing(c) { // Get the glyph's natural width to determine desired cells let glyph_width = self.get_glyph_width(c); - let desired_cells = (glyph_width / self.cell_metrics.cell_width as f32).ceil() as usize; - + let desired_cells = + (glyph_width / self.cell_metrics.cell_width as f32) + .ceil() as usize; + if desired_cells > 1 { // Count trailing empty cells (spaces or null characters) // Like Kitty's loop at fonts.c:1888-1903, but also including empty cells let mut num_empty = 0; const MAX_EXTRA_CELLS: usize = 4; // Like Kitty's MAX_NUM_EXTRA_GLYPHS_PUA - + while col + num_empty + 1 < row.len() && num_empty + 1 < desired_cells && num_empty < MAX_EXTRA_CELLS { let next_char = row[col + num_empty + 1].character; - log::debug!(" next char at col {}: U+{:04X} '{}'", - col + num_empty + 1, next_char as u32, next_char); + log::debug!( + " next char at col {}: U+{:04X} '{}'", + col + num_empty + 1, + next_char as u32, + next_char + ); // Check for space, en-space, or empty/null cell - if next_char == ' ' || next_char == '\u{2002}' || next_char == '\0' { + if next_char == ' ' + || next_char == '\u{2002}' + || next_char == '\0' + { num_empty += 1; } else { break; } } - - log::debug!(" found {} trailing empty cells", num_empty); - + + log::debug!( + " found {} trailing empty cells", + num_empty + ); + if num_empty > 0 { // We have symbol + empty cells - render as multi-cell let total_cells = 1 + num_empty; - + // Check if we already have sprites for this multi-cell group // PUA symbols are not colored - let first_key = SpriteKey::multi(c, 0, style, false); - + let first_key = + SpriteKey::multi(c, 0, style, false); + if self.sprite_map.get(&first_key).is_none() { // Need to rasterize - let cell_sprites = self.rasterize_pua_multicell(c, total_cells); - + let cell_sprites = self + .rasterize_pua_multicell(c, total_cells); + // Store each cell's sprite with a unique key - for (cell_idx, glyph) in cell_sprites.into_iter().enumerate() { - if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { - let key = SpriteKey::multi(c, cell_idx as u8, style, false); - + for (cell_idx, glyph) in + cell_sprites.into_iter().enumerate() + { + if glyph.size[0] > 0.0 + && glyph.size[1] > 0.0 + { + let key = SpriteKey::multi( + c, + cell_idx as u8, + style, + false, + ); + // Create sprite info from glyph info let sprite = SpriteInfo { uv: glyph.uv, @@ -2299,133 +2663,185 @@ impl Renderer { _padding: 0.0, size: glyph.size, }; - + // Use next_sprite_idx like get_or_create_sprite does let sprite_idx = self.next_sprite_idx; self.next_sprite_idx += 1; - + // Ensure sprite_info array is large enough - while self.sprite_info.len() <= sprite_idx as usize { - self.sprite_info.push(SpriteInfo::default()); + while self.sprite_info.len() + <= sprite_idx as usize + { + self.sprite_info + .push(SpriteInfo::default()); } - self.sprite_info[sprite_idx as usize] = sprite; + self.sprite_info[sprite_idx as usize] = + sprite; self.sprite_map.insert(key, sprite_idx); } } } - + // Skip the spaces we consumed col += total_cells; continue; } } } - + // Regular character - create sprite as normal - let (sprite_idx, is_colored) = self.get_or_create_sprite(c, style); - + let (sprite_idx, is_colored) = + self.get_or_create_sprite(c, style); + // DEBUG: Log colored glyph detection if is_colored { - log::debug!("EMOJI MULTICELL CHECK: col={} char=U+{:04X} '{}' sprite_idx={} is_colored={}", - col, c as u32, c, sprite_idx, is_colored); + log::debug!( + "EMOJI MULTICELL CHECK: col={} char=U+{:04X} '{}' sprite_idx={} is_colored={}", + col, + c as u32, + c, + sprite_idx, + is_colored + ); } - + // If this is a colored glyph (emoji) followed by empty cells, create multi-cell sprites if is_colored && sprite_idx != 0 { // Count trailing empty cells for potential multi-cell emoji let mut num_empty = 0; const MAX_EXTRA_CELLS: usize = 1; // Emoji are typically 2 cells wide - - while col + num_empty + 1 < row.len() && num_empty < MAX_EXTRA_CELLS { + + while col + num_empty + 1 < row.len() + && num_empty < MAX_EXTRA_CELLS + { let next_cell = &row[col + num_empty + 1]; let next_char = next_cell.character; - log::debug!(" checking next cell at col={}: char=U+{:04X} '{}' wide_cont={}", - col + num_empty + 1, next_char as u32, next_char, next_cell.wide_continuation); - if next_char == ' ' || next_char == '\u{2002}' || next_char == '\0' { + log::debug!( + " checking next cell at col={}: char=U+{:04X} '{}' wide_cont={}", + col + num_empty + 1, + next_char as u32, + next_char, + next_cell.wide_continuation + ); + if next_char == ' ' + || next_char == '\u{2002}' + || next_char == '\0' + { num_empty += 1; } else { break; } } - + log::debug!(" found {} trailing empty cells", num_empty); - + if num_empty > 0 { let total_cells = 1 + num_empty; - log::debug!(" creating multi-cell sprites for {} cells", total_cells); - + log::debug!( + " creating multi-cell sprites for {} cells", + total_cells + ); + // Check if we already have multi-cell sprites for this emoji let first_key = SpriteKey::multi(c, 0, style, true); - + if self.sprite_map.get(&first_key).is_none() { - log::debug!(" rasterizing multi-cell emoji U+{:04X}", c as u32); - let cell_sprites = self.rasterize_emoji_multicell(c, total_cells); - log::debug!(" got {} cell sprites", cell_sprites.len()); - - for (cell_idx, glyph) in cell_sprites.into_iter().enumerate() { - log::debug!(" cell {} sprite size: {:?}", cell_idx, glyph.size); + log::debug!( + " rasterizing multi-cell emoji U+{:04X}", + c as u32 + ); + let cell_sprites = + self.rasterize_emoji_multicell(c, total_cells); + log::debug!( + " got {} cell sprites", + cell_sprites.len() + ); + + for (cell_idx, glyph) in + cell_sprites.into_iter().enumerate() + { + log::debug!( + " cell {} sprite size: {:?}", + cell_idx, + glyph.size + ); if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { - let key = SpriteKey::multi(c, cell_idx as u8, style, true); - + let key = SpriteKey::multi( + c, + cell_idx as u8, + style, + true, + ); + let sprite = SpriteInfo { uv: glyph.uv, layer: glyph.layer, _padding: 0.0, size: glyph.size, }; - + // Use next_sprite_idx like get_or_create_sprite does let idx = self.next_sprite_idx; self.next_sprite_idx += 1; - + // Ensure sprite_info array is large enough - while self.sprite_info.len() <= idx as usize { - self.sprite_info.push(SpriteInfo::default()); + while self.sprite_info.len() <= idx as usize + { + self.sprite_info + .push(SpriteInfo::default()); } self.sprite_info[idx as usize] = sprite; self.sprite_map.insert(key, idx); } } } - + col += total_cells; continue; } } - + col += 1; } } - + // Second pass: convert cells to GPU format // Always update self.gpu_cells from the current terminal to avoid // stale data from a previous pane being written to the wrong GPU buffer. let mut any_updated = false; - + for row_idx in 0..rows { if let Some(row) = terminal.get_visible_row(row_idx) { let start = row_idx * cols; let end = start + cols; - + if end > self.gpu_cells.len() { continue; } - - Self::cells_to_gpu_row_static(row, &mut self.gpu_cells[start..end], cols, &self.sprite_map); + + Self::cells_to_gpu_row_static( + row, + &mut self.gpu_cells[start..end], + cols, + &self.sprite_map, + ); any_updated = true; } } - + any_updated } /// Parse ANSI escape sequences from raw statusline content. /// Returns a vector of (char, fg_color, bg_color, bold) tuples. - fn parse_ansi_statusline(content: &str, is_light: bool) -> Vec<(char, StatuslineColor, StatuslineColor, bool)> { + fn parse_ansi_statusline( + content: &str, + is_light: bool, + ) -> Vec<(char, StatuslineColor, StatuslineColor, bool)> { let mut result = Vec::new(); let chars: Vec = content.chars().collect(); let mut i = 0; - + // Current styling state let mut fg = StatuslineColor::Default; let default_bg_color = if is_light { @@ -2435,24 +2851,25 @@ impl Renderer { }; let mut bg = default_bg_color.clone(); // Default statusline background let mut bold = false; - + while i < chars.len() { let c = chars[i]; - + // Check for escape sequence (ESC = 0x1B) if c == '\x1b' && i + 1 < chars.len() && chars[i + 1] == '[' { // Parse CSI sequence: ESC [ params m i += 2; // Skip ESC [ - + // Collect parameters let mut params: Vec = Vec::new(); let mut current_param: u16 = 0; let mut has_digit = false; - + while i < chars.len() { let pc = chars[i]; if pc.is_ascii_digit() { - current_param = current_param * 10 + (pc as u16 - '0' as u16); + current_param = + current_param * 10 + (pc as u16 - '0' as u16); has_digit = true; i += 1; } else if pc == ';' || pc == ':' { @@ -2464,7 +2881,7 @@ impl Renderer { // SGR sequence complete params.push(if has_digit { current_param } else { 0 }); i += 1; - + // Process SGR parameters let mut pi = 0; while pi < params.len() { @@ -2477,15 +2894,23 @@ impl Renderer { } 1 => bold = true, 22 => bold = false, - 30..=37 => fg = StatuslineColor::Indexed((code - 30) as u8), + 30..=37 => { + fg = StatuslineColor::Indexed( + (code - 30) as u8, + ) + } 38 => { // Extended foreground color if pi + 1 < params.len() { let mode = params[pi + 1]; if mode == 5 && pi + 2 < params.len() { - fg = StatuslineColor::Indexed(params[pi + 2] as u8); + fg = StatuslineColor::Indexed( + params[pi + 2] as u8, + ); pi += 2; - } else if mode == 2 && pi + 4 < params.len() { + } else if mode == 2 + && pi + 4 < params.len() + { fg = StatuslineColor::Rgb( params[pi + 2] as u8, params[pi + 3] as u8, @@ -2496,15 +2921,23 @@ impl Renderer { } } 39 => fg = StatuslineColor::Default, - 40..=47 => bg = StatuslineColor::Indexed((code - 40) as u8), + 40..=47 => { + bg = StatuslineColor::Indexed( + (code - 40) as u8, + ) + } 48 => { // Extended background color if pi + 1 < params.len() { let mode = params[pi + 1]; if mode == 5 && pi + 2 < params.len() { - bg = StatuslineColor::Indexed(params[pi + 2] as u8); + bg = StatuslineColor::Indexed( + params[pi + 2] as u8, + ); pi += 2; - } else if mode == 2 && pi + 4 < params.len() { + } else if mode == 2 + && pi + 4 < params.len() + { bg = StatuslineColor::Rgb( params[pi + 2] as u8, params[pi + 3] as u8, @@ -2515,8 +2948,16 @@ impl Renderer { } } 49 => bg = default_bg_color.clone(), // Reset to default statusline bg - 90..=97 => fg = StatuslineColor::Indexed((code - 90 + 8) as u8), - 100..=107 => bg = StatuslineColor::Indexed((code - 100 + 8) as u8), + 90..=97 => { + fg = StatuslineColor::Indexed( + (code - 90 + 8) as u8, + ) + } + 100..=107 => { + bg = StatuslineColor::Indexed( + (code - 100 + 8) as u8, + ) + } _ => {} } pi += 1; @@ -2537,20 +2978,25 @@ impl Renderer { i += 1; } } - + result } /// Update statusline GPU cells from StatuslineContent. /// This converts the statusline sections/components into GPUCell format for instanced rendering. - /// + /// /// `target_width` is the desired width in pixels - for Raw content (like neovim statuslines), /// this is used to expand the middle gap to fill the full window width. - /// + /// /// Returns the number of columns used. - fn update_statusline_cells(&mut self, content: &StatuslineContent, target_width: f32, is_light: bool) -> usize { + fn update_statusline_cells( + &mut self, + content: &StatuslineContent, + target_width: f32, + is_light: bool, + ) -> usize { self.statusline_gpu_cells.clear(); - + // Calculate target columns based on window width // Use ceil() to ensure we cover the entire window edge-to-edge // (the rightmost cell may extend slightly past the window, which is fine) @@ -2559,7 +3005,7 @@ impl Renderer { } else { self.statusline_max_cols }; - + // Default background color for statusline let default_bg_color = if is_light { StatuslineColor::Rgb(0xD0, 0xD0, 0xD0) @@ -2568,16 +3014,17 @@ impl Renderer { }; let default_bg = Self::pack_statusline_color(default_bg_color); let _ = default_bg; // Silence unused warning - used by Sections path - + match content { StatuslineContent::Raw(ansi_content) => { // Parse ANSI escape sequences to extract colors and text - let parsed = Self::parse_ansi_statusline(ansi_content, is_light); - + let parsed = + Self::parse_ansi_statusline(ansi_content, is_light); + // Find the middle gap (largest consecutive run of spaces) // and expand it to fill the target width let current_len = parsed.len(); - + if current_len < target_cols && current_len > 0 { // Find the largest gap of consecutive spaces let mut best_gap_start = 0; @@ -2585,7 +3032,7 @@ impl Renderer { let mut current_gap_start = 0; let mut current_gap_len = 0; let mut in_gap = false; - + for (i, (c, _, _, _)) in parsed.iter().enumerate() { if *c == ' ' { if !in_gap { @@ -2597,7 +3044,9 @@ impl Renderer { } else { if in_gap && current_gap_len > best_gap_len { // Prefer gaps in the middle (not at start or end) - let is_middle = current_gap_start > 0 && (current_gap_start + current_gap_len) < current_len; + let is_middle = current_gap_start > 0 + && (current_gap_start + current_gap_len) + < current_len; if is_middle || best_gap_len == 0 { best_gap_start = current_gap_start; best_gap_len = current_gap_len; @@ -2614,27 +3063,34 @@ impl Renderer { best_gap_len = current_gap_len; } } - + // Calculate how many extra spaces we need let extra_spaces = target_cols.saturating_sub(current_len); - + // Get the background color for padding (from the gap area) - let gap_bg = if best_gap_len > 0 && best_gap_start < parsed.len() { - parsed[best_gap_start].2 - } else { - default_bg_color.clone() - }; - + let gap_bg = + if best_gap_len > 0 && best_gap_start < parsed.len() { + parsed[best_gap_start].2 + } else { + default_bg_color.clone() + }; + // The position right before right-hand content starts (end of gap) let gap_end = best_gap_start + best_gap_len; - + // Render with expanded gap - insert extra padding at the END of the gap - for (i, (c, fg_color, bg_color, bold)) in parsed.iter().enumerate() { + for (i, (c, fg_color, bg_color, bold)) in + parsed.iter().enumerate() + { // Insert extra padding right before the right-hand content - if i == gap_end && extra_spaces > 0 && best_gap_len > 0 { - let padding_bg = Self::pack_statusline_color(gap_bg); + if i == gap_end && extra_spaces > 0 && best_gap_len > 0 + { + let padding_bg = + Self::pack_statusline_color(gap_bg); for _ in 0..extra_spaces { - if self.statusline_gpu_cells.len() >= self.statusline_max_cols { + if self.statusline_gpu_cells.len() + >= self.statusline_max_cols + { break; } self.statusline_gpu_cells.push(GPUCell { @@ -2646,28 +3102,40 @@ impl Renderer { }); } } - - if self.statusline_gpu_cells.len() >= self.statusline_max_cols { + + if self.statusline_gpu_cells.len() + >= self.statusline_max_cols + { break; } - + let fg = Self::pack_statusline_color(*fg_color); let bg = Self::pack_statusline_color(*bg_color); - let style = if *bold { FontStyle::Bold } else { FontStyle::Regular }; - let attrs = Self::pack_attrs(*bold, false, 0, false, false); - - let (sprite_idx, is_colored) = if *c == ' ' || *c == '\0' { - (0, false) + let style = if *bold { + FontStyle::Bold } else { - self.get_or_create_sprite_for(*c, style, SpriteTarget::Statusline) + FontStyle::Regular }; - + let attrs = + Self::pack_attrs(*bold, false, 0, false, false); + + let (sprite_idx, is_colored) = + if *c == ' ' || *c == '\0' { + (0, false) + } else { + self.get_or_create_sprite_for( + *c, + style, + SpriteTarget::Statusline, + ) + }; + let final_sprite_idx = if is_colored { sprite_idx | COLORED_GLYPH_FLAG } else { sprite_idx }; - + self.statusline_gpu_cells.push(GPUCell { fg, bg, @@ -2676,12 +3144,17 @@ impl Renderer { attrs, }); } - + // If gap is at the very end (right content is empty), add padding after everything - if gap_end == parsed.len() && extra_spaces > 0 && best_gap_len > 0 { + if gap_end == parsed.len() + && extra_spaces > 0 + && best_gap_len > 0 + { let padding_bg = Self::pack_statusline_color(gap_bg); for _ in 0..extra_spaces { - if self.statusline_gpu_cells.len() >= self.statusline_max_cols { + if self.statusline_gpu_cells.len() + >= self.statusline_max_cols + { break; } self.statusline_gpu_cells.push(GPUCell { @@ -2696,27 +3169,39 @@ impl Renderer { } else { // No expansion needed, render as-is for (c, fg_color, bg_color, bold) in parsed { - if self.statusline_gpu_cells.len() >= self.statusline_max_cols { + if self.statusline_gpu_cells.len() + >= self.statusline_max_cols + { break; } - + let fg = Self::pack_statusline_color(fg_color); let bg = Self::pack_statusline_color(bg_color); - let style = if bold { FontStyle::Bold } else { FontStyle::Regular }; - let attrs = Self::pack_attrs(bold, false, 0, false, false); - - let (sprite_idx, is_colored) = if c == ' ' || c == '\0' { + let style = if bold { + FontStyle::Bold + } else { + FontStyle::Regular + }; + let attrs = + Self::pack_attrs(bold, false, 0, false, false); + + let (sprite_idx, is_colored) = if c == ' ' || c == '\0' + { (0, false) } else { - self.get_or_create_sprite_for(c, style, SpriteTarget::Statusline) + self.get_or_create_sprite_for( + c, + style, + SpriteTarget::Statusline, + ) }; - + let final_sprite_idx = if is_colored { sprite_idx | COLORED_GLYPH_FLAG } else { sprite_idx }; - + self.statusline_gpu_cells.push(GPUCell { fg, bg, @@ -2730,85 +3215,135 @@ impl Renderer { StatuslineContent::Sections(sections) => { for (section_idx, section) in sections.iter().enumerate() { let section_bg = Self::pack_statusline_color(section.bg); - + // Get next section's background for powerline arrow transition let next_section_bg = if section_idx + 1 < sections.len() { - Self::pack_statusline_color(sections[section_idx + 1].bg) + Self::pack_statusline_color( + sections[section_idx + 1].bg, + ) } else { default_bg }; - + for component in section.components.iter() { - let component_fg = Self::pack_statusline_color(component.fg); - let style = if component.bold { FontStyle::Bold } else { FontStyle::Regular }; - let attrs = Self::pack_attrs(component.bold, false, 0, false, false); - + let component_fg = + Self::pack_statusline_color(component.fg); + let style = if component.bold { + FontStyle::Bold + } else { + FontStyle::Regular + }; + let attrs = Self::pack_attrs( + component.bold, + false, + 0, + false, + false, + ); + // Process characters with lookahead for multi-cell symbols let chars: Vec = component.text.chars().collect(); let mut char_idx = 0; - + while char_idx < chars.len() { - if self.statusline_gpu_cells.len() >= self.statusline_max_cols { + if self.statusline_gpu_cells.len() + >= self.statusline_max_cols + { break; } - + let c = chars[char_idx]; - + // Check for multi-cell symbol pattern - let is_powerline_char = ('\u{E0B0}'..='\u{E0BF}').contains(&c); - let is_multicell_with_space = !is_powerline_char - && Self::is_multicell_symbol(c) + let is_powerline_char = + ('\u{E0B0}'..='\u{E0BF}').contains(&c); + let is_multicell_with_space = !is_powerline_char + && Self::is_multicell_symbol(c) && !is_box_drawing(c) - && char_idx + 1 < chars.len() + && char_idx + 1 < chars.len() && chars[char_idx + 1] == ' '; - + if is_multicell_with_space { // Render as 2-cell symbol let multi_style = FontStyle::Regular; - + // Check if we already have multi-cell sprites - let first_key = SpriteKey::multi(c, 0, multi_style, false); - - if self.statusline_sprite_map.get(&first_key).is_none() { + let first_key = + SpriteKey::multi(c, 0, multi_style, false); + + if self + .statusline_sprite_map + .get(&first_key) + .is_none() + { // Need to rasterize multi-cell sprites - let cell_sprites = self.rasterize_pua_multicell(c, 2); - - for (cell_i, glyph) in cell_sprites.into_iter().enumerate() { - if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { - let key = SpriteKey::multi(c, cell_i as u8, multi_style, false); - + let cell_sprites = + self.rasterize_pua_multicell(c, 2); + + for (cell_i, glyph) in + cell_sprites.into_iter().enumerate() + { + if glyph.size[0] > 0.0 + && glyph.size[1] > 0.0 + { + let key = SpriteKey::multi( + c, + cell_i as u8, + multi_style, + false, + ); + let sprite = SpriteInfo { uv: glyph.uv, layer: glyph.layer, _padding: 0.0, size: glyph.size, }; - + // Use statusline sprite tracking - let sprite_idx = self.statusline_next_sprite_idx; - self.statusline_next_sprite_idx += 1; - + let sprite_idx = + self.statusline_next_sprite_idx; + self.statusline_next_sprite_idx += + 1; + // Ensure sprite_info array is large enough - while self.statusline_sprite_info.len() <= sprite_idx as usize { + while self + .statusline_sprite_info + .len() + <= sprite_idx as usize + { self.statusline_sprite_info.push(SpriteInfo::default()); } - self.statusline_sprite_info[sprite_idx as usize] = sprite; - - self.statusline_sprite_map.insert(key, sprite_idx); + self.statusline_sprite_info + [sprite_idx as usize] = sprite; + + self.statusline_sprite_map + .insert(key, sprite_idx); } } } - + // Add GPUCells for both parts for cell_i in 0..2 { - if self.statusline_gpu_cells.len() >= self.statusline_max_cols { + if self.statusline_gpu_cells.len() + >= self.statusline_max_cols + { break; } - - let key = SpriteKey::multi(c, cell_i as u8, multi_style, false); - - let sprite_idx = self.statusline_sprite_map.get(&key).copied().unwrap_or(0); - + + let key = SpriteKey::multi( + c, + cell_i as u8, + multi_style, + false, + ); + + let sprite_idx = self + .statusline_sprite_map + .get(&key) + .copied() + .unwrap_or(0); + self.statusline_gpu_cells.push(GPUCell { fg: component_fg, bg: section_bg, @@ -2817,25 +3352,30 @@ impl Renderer { attrs, }); } - + // Skip symbol and space char_idx += 2; continue; } - + // Regular character - let (sprite_idx, is_colored) = if c == ' ' || c == '\0' { - (0, false) - } else { - self.get_or_create_sprite_for(c, style, SpriteTarget::Statusline) - }; - + let (sprite_idx, is_colored) = + if c == ' ' || c == '\0' { + (0, false) + } else { + self.get_or_create_sprite_for( + c, + style, + SpriteTarget::Statusline, + ) + }; + let final_sprite_idx = if is_colored { sprite_idx | COLORED_GLYPH_FLAG } else { sprite_idx }; - + self.statusline_gpu_cells.push(GPUCell { fg: component_fg, bg: section_bg, @@ -2843,21 +3383,32 @@ impl Renderer { sprite_idx: final_sprite_idx, attrs, }); - + char_idx += 1; } } - + // Add powerline arrow at end of section if it has a background - let has_bg = matches!(section.bg, StatuslineColor::Indexed(_) | StatuslineColor::Rgb(_, _, _)); - if has_bg && self.statusline_gpu_cells.len() < self.statusline_max_cols { + let has_bg = matches!( + section.bg, + StatuslineColor::Indexed(_) + | StatuslineColor::Rgb(_, _, _) + ); + if has_bg + && self.statusline_gpu_cells.len() + < self.statusline_max_cols + { // The powerline arrow character let arrow_char = '\u{E0B0}'; - let (sprite_idx, _) = self.get_or_create_sprite_for(arrow_char, FontStyle::Regular, SpriteTarget::Statusline); - + let (sprite_idx, _) = self.get_or_create_sprite_for( + arrow_char, + FontStyle::Regular, + SpriteTarget::Statusline, + ); + // Arrow foreground is current section's bg, arrow background is next section's bg self.statusline_gpu_cells.push(GPUCell { - fg: section_bg, // Arrow takes section bg color as its foreground + fg: section_bg, // Arrow takes section bg color as its foreground bg: next_section_bg, // Background is the next section's background decoration_fg: 0, sprite_idx, @@ -2867,11 +3418,13 @@ impl Renderer { } } } - + // Fill remaining width with default background cells // This ensures the statusline covers the entire window width let default_bg_packed = default_bg; - while self.statusline_gpu_cells.len() < target_cols && self.statusline_gpu_cells.len() < self.statusline_max_cols { + while self.statusline_gpu_cells.len() < target_cols + && self.statusline_gpu_cells.len() < self.statusline_max_cols + { self.statusline_gpu_cells.push(GPUCell { fg: 0, bg: default_bg_packed, @@ -2880,7 +3433,7 @@ impl Renderer { attrs: 0, }); } - + self.statusline_gpu_cells.len() } @@ -2926,7 +3479,7 @@ impl Renderer { /// bitmap/bounding box width, not the advance width. fn get_glyph_width(&self, c: char) -> f32 { use ab_glyph::Font; - + // Try primary font first let glyph_id = self.primary_font.glyph_id(c); if glyph_id.0 != 0 { @@ -2941,7 +3494,7 @@ impl Renderer { } return scaled.h_advance(glyph_id); } - + // Try fallback fonts for (_, fallback_font) in &self.fallback_fonts { let fb_glyph_id = fallback_font.glyph_id(c); @@ -2958,7 +3511,7 @@ impl Renderer { return scaled.h_advance(fb_glyph_id); } } - + // Default to one cell width if glyph not found self.cell_metrics.cell_width as f32 } @@ -2970,12 +3523,20 @@ impl Renderer { if let Some(info) = self.char_cache.get(&c) { // Log cache hits for emoji to debug first-emoji issue if info.is_colored { - log::debug!("CACHE HIT for color glyph U+{:04X} '{}'", c as u32, c); + log::debug!( + "CACHE HIT for color glyph U+{:04X} '{}'", + c as u32, + c + ); } return *info; } - - log::debug!("CACHE MISS for U+{:04X} '{}' - will rasterize", c as u32, c); + + log::debug!( + "CACHE MISS for U+{:04X} '{}' - will rasterize", + c as u32, + c + ); // Check if this is a box-drawing character - render procedurally // Box-drawing characters are already cell-sized, positioned at (0,0) @@ -2999,16 +3560,22 @@ impl Renderer { // (tofu/fallback) that isn't a proper color emoji. Go straight to fontconfig. let char_str = c.to_string(); let is_emoji = emojis::get(&char_str).is_some(); - + // Track whether we found the glyph in a regular font let mut found_in_regular_font = false; - + // Rasterize glyph data: (width, height, bitmap, offset_x, offset_y) let raster_result: Option<(u32, u32, Vec, f32, f32)> = if is_emoji { // Emoji: skip primary font, will be handled by fontconfig color font path below - log::debug!("Character U+{:04X} is emoji, skipping primary font check", c as u32); + log::debug!( + "Character U+{:04X} is emoji, skipping primary font check", + c as u32 + ); None - } else if { let glyph_id = self.primary_font.glyph_id(c); glyph_id.0 != 0 } { + } else if { + let glyph_id = self.primary_font.glyph_id(c); + glyph_id.0 != 0 + } { // Primary font has this glyph (non-emoji) let glyph_id = self.primary_font.glyph_id(c); found_in_regular_font = true; @@ -3020,7 +3587,10 @@ impl Renderer { for (_, fallback_font) in &self.fallback_fonts { let fb_glyph_id = fallback_font.glyph_id(c); if fb_glyph_id.0 != 0 { - result = self.rasterize_glyph_ab(&fallback_font.clone(), fb_glyph_id); + result = self.rasterize_glyph_ab( + &fallback_font.clone(), + fb_glyph_id, + ); found_in_regular_font = true; break; } @@ -3031,7 +3601,9 @@ impl Renderer { if result.is_none() { // Lazy-initialize fontconfig on first use let fc = self.fontconfig.get_or_init(|| { - log::debug!("Initializing fontconfig for fallback font lookup"); + log::debug!( + "Initializing fontconfig for fallback font lookup" + ); Fontconfig::new() }); if let Some(fc) = fc { @@ -3044,20 +3616,29 @@ impl Renderer { if let Ok(data) = std::fs::read(&path) { let data: Box<[u8]> = data.into_boxed_slice(); - if let Ok(font) = FontRef::try_from_slice(&data) { - log::debug!("Loaded fallback font via fontconfig: {}", path.display()); + if let Ok(font) = FontRef::try_from_slice(&data) + { + log::debug!( + "Loaded fallback font via fontconfig: {}", + path.display() + ); // Check if this font actually has the glyph let fb_glyph_id = font.glyph_id(c); if fb_glyph_id.0 != 0 { - result = self.rasterize_glyph_ab(&font, fb_glyph_id); + result = self.rasterize_glyph_ab( + &font, + fb_glyph_id, + ); found_in_regular_font = true; } // Cache the font for future use // SAFETY: We're storing data alongside the FontRef that borrows it - let font_static: FontRef<'static> = unsafe { std::mem::transmute(font) }; - self.fallback_fonts.push((data, font_static)); + let font_static: FontRef<'static> = + unsafe { std::mem::transmute(font) }; + self.fallback_fonts + .push((data, font_static)); } } } @@ -3068,62 +3649,97 @@ impl Renderer { // Don't fall back to .notdef yet - we may still try color fonts below result }; - + // If no regular font has this glyph, try color fonts (emoji) as last resort // This handles cases where no font at all was found via normal fontconfig if !found_in_regular_font { - log::debug!("Character U+{:04X} '{}' not found in regular fonts, trying dedicated color font query", c as u32, c); - + log::debug!( + "Character U+{:04X} '{}' not found in regular fonts, trying dedicated color font query", + c as u32, + c + ); + // Check color font cache or query fontconfig for color font explicitly - let color_path = self.color_font_cache.entry(c).or_insert_with(|| { - let path = find_color_font_for_char(c); - log::debug!("Fontconfig color font query for U+{:04X}: {:?}", c as u32, path); - path - }).clone(); - + let color_path = self + .color_font_cache + .entry(c) + .or_insert_with(|| { + let path = find_color_font_for_char(c); + log::debug!( + "Fontconfig color font query for U+{:04X}: {:?}", + c as u32, + path + ); + path + }) + .clone(); + if let Some(ref path) = color_path { - log::debug!("Found color font for U+{:04X}: {:?}", c as u32, path); - + log::debug!( + "Found color font for U+{:04X}: {:?}", + c as u32, + path + ); + // Render color glyph in a separate scope to release borrow before atlas ops let color_glyph_data: Option<(u32, u32, Vec, f32, f32)> = { - let mut renderer_cell = self.color_font_renderer.borrow_mut(); + let mut renderer_cell = + self.color_font_renderer.borrow_mut(); if renderer_cell.is_none() { *renderer_cell = ColorFontRenderer::new().ok(); if renderer_cell.is_some() { - log::debug!("Initialized color font renderer for emoji support"); + log::debug!( + "Initialized color font renderer for emoji support" + ); } else { - log::warn!("Failed to initialize color font renderer"); + log::warn!( + "Failed to initialize color font renderer" + ); } } - + if let Some(ref mut renderer) = *renderer_cell { - log::debug!("Attempting to render color glyph for U+{:04X} with font_size={}, cell={}x{}", - c as u32, self.font_size, self.cell_metrics.cell_width, self.cell_metrics.cell_height); - + log::debug!( + "Attempting to render color glyph for U+{:04X} with font_size={}, cell={}x{}", + c as u32, + self.font_size, + self.cell_metrics.cell_width, + self.cell_metrics.cell_height + ); + renderer.render_color_glyph( - path, c, self.font_size, self.cell_metrics.cell_width, self.cell_metrics.cell_height + path, + c, + self.font_size, + self.cell_metrics.cell_width, + self.cell_metrics.cell_height, ) } else { None } }; // renderer_cell borrow ends here - + if let Some((w, h, rgba, ox, oy)) = color_glyph_data { - log::debug!("Successfully rendered color glyph U+{:04X}: {}x{} pixels, offset=({}, {})", - c as u32, w, h, ox, oy); - - // Place the color glyph in a cell-sized canvas at baseline - let canvas = self.place_color_glyph_in_cell_canvas( - &rgba, w, h, ox, oy + log::debug!( + "Successfully rendered color glyph U+{:04X}: {}x{} pixels, offset=({}, {})", + c as u32, + w, + h, + ox, + oy ); + + // Place the color glyph in a cell-sized canvas at baseline + let canvas = self + .place_color_glyph_in_cell_canvas(&rgba, w, h, ox, oy); let info = self.upload_cell_canvas_to_atlas(&canvas, true); - + self.char_cache.insert(c, info); return info; } } } - + // Fall back to .notdef from primary font if we still have no glyph let raster_result = raster_result.or_else(|| { let notdef_glyph_id = self.primary_font.glyph_id(c); @@ -3131,7 +3747,9 @@ impl Renderer { }); // Handle rasterization result - let Some((glyph_width, glyph_height, bitmap, offset_x, offset_y)) = raster_result else { + let Some((glyph_width, glyph_height, bitmap, offset_x, offset_y)) = + raster_result + else { // Empty glyph (e.g., space) self.char_cache.insert(c, GlyphInfo::EMPTY); return GlyphInfo::EMPTY; @@ -3147,82 +3765,117 @@ impl Renderer { // PUA glyphs (Nerd Fonts), dingbats, and other symbols that are wider than // one cell should be rescaled to fit when rendered standalone (not part of // a multi-cell group). - let (final_bitmap, final_width, final_height, final_offset_x, final_offset_y) = - if Self::is_multicell_symbol(c) { - let cell_w = self.cell_metrics.cell_width as f32; - // Use just the glyph bitmap width for comparison, not offset_x + width - // offset_x is the left bearing which can be negative - let glyph_w = glyph_width as f32; - - log::debug!("Scaling check for U+{:04X}: glyph_width={}, cell_width={}, offset_x={:.1}", - c as u32, glyph_width, self.cell_metrics.cell_width, offset_x); - - if glyph_w > cell_w { - // Glyph is wider than cell - rescale to fit - // Calculate scale factor to fit within cell width with small margin - let target_width = cell_w * 0.95; // Leave 5% margin - let scale_factor = target_width / glyph_w; - - log::debug!("Scaling U+{:04X} by factor {:.2} (glyph_w={:.1} > cell_w={:.1})", - c as u32, scale_factor, glyph_w, cell_w); - - // Rescale bitmap using simple nearest-neighbor (good enough for icons) - let new_width = (glyph_width as f32 * scale_factor).ceil() as u32; - let new_height = (glyph_height as f32 * scale_factor).ceil() as u32; - - if new_width > 0 && new_height > 0 { - let mut scaled_bitmap = vec![0u8; (new_width * new_height) as usize]; - - for y in 0..new_height { - for x in 0..new_width { - // Map to source coordinates - let src_x = ((x as f32 / scale_factor) as u32).min(glyph_width - 1); - let src_y = ((y as f32 / scale_factor) as u32).min(glyph_height - 1); - let src_idx = (src_y * glyph_width + src_x) as usize; - let dst_idx = (y * new_width + x) as usize; - scaled_bitmap[dst_idx] = bitmap[src_idx]; - } + let ( + final_bitmap, + final_width, + final_height, + final_offset_x, + final_offset_y, + ) = if Self::is_multicell_symbol(c) { + let cell_w = self.cell_metrics.cell_width as f32; + // Use just the glyph bitmap width for comparison, not offset_x + width + // offset_x is the left bearing which can be negative + let glyph_w = glyph_width as f32; + + log::debug!( + "Scaling check for U+{:04X}: glyph_width={}, cell_width={}, offset_x={:.1}", + c as u32, + glyph_width, + self.cell_metrics.cell_width, + offset_x + ); + + if glyph_w > cell_w { + // Glyph is wider than cell - rescale to fit + // Calculate scale factor to fit within cell width with small margin + let target_width = cell_w * 0.95; // Leave 5% margin + let scale_factor = target_width / glyph_w; + + log::debug!( + "Scaling U+{:04X} by factor {:.2} (glyph_w={:.1} > cell_w={:.1})", + c as u32, + scale_factor, + glyph_w, + cell_w + ); + + // Rescale bitmap using simple nearest-neighbor (good enough for icons) + let new_width = + (glyph_width as f32 * scale_factor).ceil() as u32; + let new_height = + (glyph_height as f32 * scale_factor).ceil() as u32; + + if new_width > 0 && new_height > 0 { + let mut scaled_bitmap = + vec![0u8; (new_width * new_height) as usize]; + + for y in 0..new_height { + for x in 0..new_width { + // Map to source coordinates + let src_x = ((x as f32 / scale_factor) as u32) + .min(glyph_width - 1); + let src_y = ((y as f32 / scale_factor) as u32) + .min(glyph_height - 1); + let src_idx = + (src_y * glyph_width + src_x) as usize; + let dst_idx = (y * new_width + x) as usize; + scaled_bitmap[dst_idx] = bitmap[src_idx]; } - - // Adjust offset to center the scaled glyph - let new_offset_x = (cell_w - new_width as f32) / 2.0; - let new_offset_y = offset_y * scale_factor; - - (scaled_bitmap, new_width, new_height, new_offset_x, new_offset_y) - } else { - (bitmap, glyph_width, glyph_height, offset_x, offset_y) } + + // Adjust offset to center the scaled glyph + let new_offset_x = (cell_w - new_width as f32) / 2.0; + let new_offset_y = offset_y * scale_factor; + + ( + scaled_bitmap, + new_width, + new_height, + new_offset_x, + new_offset_y, + ) } else { (bitmap, glyph_width, glyph_height, offset_x, offset_y) } } else { (bitmap, glyph_width, glyph_height, offset_x, offset_y) - }; + } + } else { + (bitmap, glyph_width, glyph_height, offset_x, offset_y) + }; // Place the glyph in a cell-sized canvas at the correct baseline position let canvas = self.place_glyph_in_cell_canvas( - &final_bitmap, final_width, final_height, final_offset_x, final_offset_y + &final_bitmap, + final_width, + final_height, + final_offset_x, + final_offset_y, ); let info = self.upload_cell_canvas_to_atlas(&canvas, false); self.char_cache.insert(c, info); info } - + /// Rasterize a PUA character into a multi-cell canvas and return GlyphInfo for each cell. /// This is used when a PUA glyph is followed by space(s) - the glyph spans multiple cells. - /// + /// /// Like Kitty's approach: /// 1. Render the glyph to a canvas sized for `num_cells` cells /// 2. Center the glyph horizontally within the canvas /// 3. Extract each cell's portion as a separate sprite - /// + /// /// Returns a Vec of GlyphInfo, one for each cell. - fn rasterize_pua_multicell(&mut self, c: char, num_cells: usize) -> Vec { + fn rasterize_pua_multicell( + &mut self, + c: char, + num_cells: usize, + ) -> Vec { let cell_w = self.cell_metrics.cell_width as usize; let cell_h = self.cell_metrics.cell_height as usize; let canvas_width = cell_w * num_cells; - + // First, rasterize the glyph at full size let raster_result: Option<(u32, u32, Vec, f32, f32)> = { let glyph_id = self.primary_font.glyph_id(c); @@ -3234,34 +3887,42 @@ impl Renderer { for (_, fallback_font) in &self.fallback_fonts { let fb_glyph_id = fallback_font.glyph_id(c); if fb_glyph_id.0 != 0 { - result = self.rasterize_glyph_ab(&fallback_font.clone(), fb_glyph_id); + result = self.rasterize_glyph_ab( + &fallback_font.clone(), + fb_glyph_id, + ); break; } } result } }; - - let Some((glyph_width, glyph_height, bitmap, _offset_x, offset_y)) = raster_result else { + + let Some((glyph_width, glyph_height, bitmap, _offset_x, offset_y)) = + raster_result + else { // Empty glyph - return empty sprites for each cell return vec![GlyphInfo::EMPTY; num_cells]; }; - + if bitmap.is_empty() || glyph_width == 0 || glyph_height == 0 { return vec![GlyphInfo::EMPTY; num_cells]; } - + // Create a multi-cell canvas let mut canvas = vec![0u8; canvas_width * cell_h]; - + // Position glyph at x=0 (left-aligned), like Kitty's model where // glyphs are positioned at origin without offset adjustments let dest_x = 0i32; - + // Calculate vertical position using baseline, same as single-cell rendering // dest_y = baseline - glyph_height - offset_y - let dest_y = (self.cell_metrics.baseline as f32 - glyph_height as f32 - offset_y).round() as i32; - + let dest_y = (self.cell_metrics.baseline as f32 + - glyph_height as f32 + - offset_y) + .round() as i32; + // Copy glyph bitmap to the multi-cell canvas for gy in 0..glyph_height as i32 { let cy = dest_y + gy; @@ -3278,15 +3939,15 @@ impl Renderer { canvas[dst_idx] = canvas[dst_idx].max(bitmap[src_idx]); } } - + // Extract each cell's portion as a separate sprite let mut sprites = Vec::with_capacity(num_cells); - + for cell_idx in 0..num_cells { // Extract this cell's portion from the canvas let mut cell_canvas = vec![0u8; cell_w * cell_h]; let cell_start_x = cell_idx * cell_w; - + for y in 0..cell_h { for x in 0..cell_w { let src_idx = y * canvas_width + cell_start_x + x; @@ -3294,79 +3955,98 @@ impl Renderer { cell_canvas[dst_idx] = canvas[src_idx]; } } - + // Upload this cell's sprite to the atlas let info = self.upload_cell_canvas_to_atlas(&cell_canvas, false); sprites.push(info); } - + sprites } - + /// Rasterize an emoji into a multi-cell canvas and return GlyphInfo for each cell. /// This uses the Cairo color font renderer since emoji are color glyphs. - /// + /// /// Returns a Vec of GlyphInfo, one for each cell. - fn rasterize_emoji_multicell(&mut self, c: char, num_cells: usize) -> Vec { + fn rasterize_emoji_multicell( + &mut self, + c: char, + num_cells: usize, + ) -> Vec { let cell_w = self.cell_metrics.cell_width as usize; let cell_h = self.cell_metrics.cell_height as usize; let canvas_width = cell_w * num_cells; - + // Find a color font for this emoji (find_color_font_for_char handles fontconfig internally) let Some(font_path) = find_color_font_for_char(c) else { log::debug!("No color font found for emoji U+{:04X}", c as u32); - return vec![GlyphInfo { - uv: [0.0, 0.0, 0.0, 0.0], - layer: 0.0, - size: [0.0, 0.0], - is_colored: true, - }; num_cells]; + return vec![ + GlyphInfo { + uv: [0.0, 0.0, 0.0, 0.0], + layer: 0.0, + size: [0.0, 0.0], + is_colored: true, + }; + num_cells + ]; }; - + // Render the emoji using Cairo at full multi-cell size let color_glyph_data: Option<(u32, u32, Vec, f32, f32)> = { let mut renderer_cell = self.color_font_renderer.borrow_mut(); if renderer_cell.is_none() { *renderer_cell = ColorFontRenderer::new().ok(); } - + if let Some(ref mut renderer) = *renderer_cell { // Render at multi-cell width renderer.render_color_glyph( - &font_path, c, self.font_size, - (cell_w * num_cells) as u32, cell_h as u32 + &font_path, + c, + self.font_size, + (cell_w * num_cells) as u32, + cell_h as u32, ) } else { None } }; - - let Some((glyph_width, glyph_height, rgba, offset_x, offset_y)) = color_glyph_data else { + + let Some((glyph_width, glyph_height, rgba, offset_x, offset_y)) = + color_glyph_data + else { log::debug!("Failed to render emoji U+{:04X}", c as u32); - return vec![GlyphInfo { - uv: [0.0, 0.0, 0.0, 0.0], - layer: 0.0, - size: [0.0, 0.0], - is_colored: true, - }; num_cells]; + return vec![ + GlyphInfo { + uv: [0.0, 0.0, 0.0, 0.0], + layer: 0.0, + size: [0.0, 0.0], + is_colored: true, + }; + num_cells + ]; }; - + if rgba.is_empty() || glyph_width == 0 || glyph_height == 0 { - return vec![GlyphInfo { - uv: [0.0, 0.0, 0.0, 0.0], - layer: 0.0, - size: [0.0, 0.0], - is_colored: true, - }; num_cells]; + return vec![ + GlyphInfo { + uv: [0.0, 0.0, 0.0, 0.0], + layer: 0.0, + size: [0.0, 0.0], + is_colored: true, + }; + num_cells + ]; } - + // Create a multi-cell RGBA canvas let mut canvas = vec![0u8; canvas_width * cell_h * 4]; - + // Position the glyph - for color glyphs, offset_y is ascent (distance from baseline to TOP) let dest_x = offset_x.round() as i32; - let dest_y = (self.cell_metrics.baseline as f32 - offset_y).round() as i32; - + let dest_y = + (self.cell_metrics.baseline as f32 - offset_y).round() as i32; + // Copy the RGBA bitmap to the multi-cell canvas for gy in 0..glyph_height as i32 { let cy = dest_y + gy; @@ -3378,7 +4058,8 @@ impl Renderer { if cx < 0 || cx >= canvas_width as i32 { continue; } - let src_idx = (gy as u32 * glyph_width + gx as u32) as usize * 4; + let src_idx = + (gy as u32 * glyph_width + gx as u32) as usize * 4; let dst_idx = (cy as usize * canvas_width + cx as usize) * 4; if src_idx + 3 < rgba.len() && dst_idx + 3 < canvas.len() { canvas[dst_idx] = rgba[src_idx]; @@ -3388,20 +4069,22 @@ impl Renderer { } } } - + // Extract each cell's portion as a separate sprite let mut sprites = Vec::with_capacity(num_cells); - + for cell_idx in 0..num_cells { // Extract this cell's RGBA portion from the canvas let mut cell_canvas = vec![0u8; cell_w * cell_h * 4]; let cell_start_x = cell_idx * cell_w; - + for y in 0..cell_h { for x in 0..cell_w { let src_idx = (y * canvas_width + cell_start_x + x) * 4; let dst_idx = (y * cell_w + x) * 4; - if src_idx + 3 < canvas.len() && dst_idx + 3 < cell_canvas.len() { + if src_idx + 3 < canvas.len() + && dst_idx + 3 < cell_canvas.len() + { cell_canvas[dst_idx] = canvas[src_idx]; cell_canvas[dst_idx + 1] = canvas[src_idx + 1]; cell_canvas[dst_idx + 2] = canvas[src_idx + 2]; @@ -3409,32 +4092,37 @@ impl Renderer { } } } - + // Upload this cell's sprite to the atlas (colored = true for RGBA) let info = self.upload_cell_canvas_to_atlas(&cell_canvas, true); sprites.push(info); } - + sprites } - + /// Rasterize a glyph using ab_glyph with pixel-perfect alignment. /// Returns (width, height, bitmap, offset_x, offset_y) or None if glyph has no outline. /// offset_x is the left bearing (horizontal offset from cursor), snapped to integer pixels /// offset_y is compatible with fontdue's ymin (distance from baseline to glyph bottom, negative for descenders) - fn rasterize_glyph_ab(&self, font: &FontRef<'_>, glyph_id: GlyphId) -> Option<(u32, u32, Vec, f32, f32)> { + fn rasterize_glyph_ab( + &self, + font: &FontRef<'_>, + glyph_id: GlyphId, + ) -> Option<(u32, u32, Vec, f32, f32)> { // First, get the unpositioned glyph bounds to determine pixel-aligned position - let unpositioned = glyph_id.with_scale_and_position(self.font_size, ab_glyph::point(0.0, 0.0)); + let unpositioned = glyph_id + .with_scale_and_position(self.font_size, ab_glyph::point(0.0, 0.0)); let outlined_check = font.outline_glyph(unpositioned)?; let raw_bounds = outlined_check.px_bounds(); - + // Snap to integer pixel boundaries for crisp rendering. // Floor the min coordinates to ensure the glyph bitmap starts at an integer pixel. // This prevents antialiasing artifacts where horizontal/vertical lines appear // to have uneven thickness due to fractional pixel positioning. let snapped_min_x = raw_bounds.min.x.floor(); let snapped_min_y = raw_bounds.min.y.floor(); - + // Position the glyph so its bounds start at integer pixels. // We offset by the fractional part to align to pixel grid. let offset_to_snap_x = snapped_min_x - raw_bounds.min.x; @@ -3443,20 +4131,20 @@ impl Renderer { self.font_size, ab_glyph::point(offset_to_snap_x, offset_to_snap_y), ); - + let outlined = font.outline_glyph(snapped_glyph)?; let bounds = outlined.px_bounds(); - + // Now bounds.min.x and bounds.min.y should be very close to integers let width = bounds.width().ceil() as u32; let height = bounds.height().ceil() as u32; - + if width == 0 || height == 0 { return None; } - + let mut bitmap = vec![0u8; (width * height) as usize]; - + outlined.draw(|x, y, coverage| { let x = x as u32; let y = y as u32; @@ -3465,30 +4153,30 @@ impl Renderer { bitmap[idx] = (coverage * 255.0) as u8; } }); - + // Use the snapped (integer) offsets for positioning. // offset_x = left bearing, snapped to integer pixels // offset_y = distance from baseline to glyph BOTTOM (fontdue's ymin convention) // // ab_glyph's bounds.min.y is the TOP of the glyph (negative = above baseline) // ab_glyph's bounds.max.y is the BOTTOM of the glyph (positive = below baseline) - // + // // We use the snapped bounds which are now at integer pixel positions. let offset_x = snapped_min_x; - let offset_y = -(raw_bounds.max.y + offset_to_snap_y); // Snap the bottom too - + let offset_y = -(raw_bounds.max.y + offset_to_snap_y); // Snap the bottom too + Some((width, height, bitmap, offset_x, offset_y)) } /// Place a glyph bitmap into a cell-sized canvas at the correct baseline position. /// This follows Kitty's model where sprites are always cell-sized. - /// + /// /// Parameters: /// - bitmap: The rasterized glyph bitmap (grayscale) /// - glyph_width, glyph_height: Dimensions of the bitmap /// - offset_x: Left bearing (horizontal offset from cell origin) /// - offset_y: Distance from baseline to glyph bottom (negative = below baseline) - /// + /// /// Returns: Cell-sized canvas with the glyph positioned at baseline fn place_glyph_in_cell_canvas( &self, @@ -3501,7 +4189,7 @@ impl Renderer { let cell_w = self.cell_metrics.cell_width as usize; let cell_h = self.cell_metrics.cell_height as usize; let mut canvas = vec![0u8; cell_w * cell_h]; - + // Calculate destination position in the cell canvas. // baseline is the Y position where the baseline sits (from top of cell). // offset_y is the distance from baseline to glyph bottom. @@ -3509,8 +4197,11 @@ impl Renderer { // = baseline - glyph_height - offset_y // Since offset_y can be negative (for descenders), this works correctly. let dest_x = offset_x.round() as i32; - let dest_y = (self.cell_metrics.baseline as f32 - glyph_height as f32 - offset_y).round() as i32; - + let dest_y = (self.cell_metrics.baseline as f32 + - glyph_height as f32 + - offset_y) + .round() as i32; + // Copy the glyph bitmap to the canvas, clipping to cell bounds for gy in 0..glyph_height as i32 { let cy = dest_y + gy; @@ -3528,7 +4219,7 @@ impl Renderer { canvas[dst_idx] = canvas[dst_idx].max(bitmap[src_idx]); } } - + canvas } @@ -3545,12 +4236,13 @@ impl Renderer { let cell_w = self.cell_metrics.cell_width as usize; let cell_h = self.cell_metrics.cell_height as usize; let mut canvas = vec![0u8; cell_w * cell_h * 4]; // RGBA - + // For color glyphs, offset_y is the ascent (distance from baseline to TOP of glyph) // So dest_y = baseline - offset_y positions the top of the glyph correctly let dest_x = offset_x.round() as i32; - let dest_y = (self.cell_metrics.baseline as f32 - offset_y).round() as i32; - + let dest_y = + (self.cell_metrics.baseline as f32 - offset_y).round() as i32; + // Copy the RGBA bitmap to the canvas for gy in 0..glyph_height as i32 { let cy = dest_y + gy; @@ -3562,7 +4254,8 @@ impl Renderer { if cx < 0 || cx >= cell_w as i32 { continue; } - let src_idx = (gy as u32 * glyph_width + gx as u32) as usize * 4; + let src_idx = + (gy as u32 * glyph_width + gx as u32) as usize * 4; let dst_idx = (cy as usize * cell_w + cx as usize) * 4; // For color glyphs, just copy the RGBA values // (could do alpha blending if needed, but single glyph per cell) @@ -3574,7 +4267,7 @@ impl Renderer { } } } - + canvas } @@ -3582,17 +4275,21 @@ impl Renderer { /// Returns GlyphInfo with UV coordinates pointing to the uploaded sprite. /// Like Kitty's send_sprite_to_gpu(), uploads immediately to the GPU texture /// using write_texture with only the cell-sized region (not the full layer). - fn upload_cell_canvas_to_atlas(&mut self, canvas: &[u8], is_colored: bool) -> GlyphInfo { + fn upload_cell_canvas_to_atlas( + &mut self, + canvas: &[u8], + is_colored: bool, + ) -> GlyphInfo { let cell_w = self.cell_metrics.cell_width; let cell_h = self.cell_metrics.cell_height; - + // Check if we need to move to next row if self.atlas_cursor_x + cell_w > ATLAS_SIZE { self.atlas_cursor_x = 0; self.atlas_cursor_y += self.atlas_row_height + 1; self.atlas_row_height = 0; } - + // Check if current layer is full - add a new layer (like Kitty) if self.atlas_cursor_y + cell_h > ATLAS_SIZE { self.add_atlas_layer(); @@ -3600,21 +4297,23 @@ impl Renderer { self.atlas_cursor_y = 0; self.atlas_row_height = 0; } - + let layer = self.atlas_current_layer; - + // Prepare the sprite data in RGBA format (cell_w * cell_h * 4 bytes) // This is a small buffer that will be uploaded directly to the GPU let sprite_size = (cell_w * cell_h * ATLAS_BPP) as usize; let mut sprite_data = vec![0u8; sprite_size]; - + if is_colored { // RGBA canvas - copy directly for y in 0..cell_h as usize { for x in 0..cell_w as usize { let src_idx = (y * cell_w as usize + x) * 4; let dst_idx = (y * cell_w as usize + x) * 4; - if src_idx + 3 < canvas.len() && dst_idx + 3 < sprite_data.len() { + if src_idx + 3 < canvas.len() + && dst_idx + 3 < sprite_data.len() + { sprite_data[dst_idx] = canvas[src_idx]; sprite_data[dst_idx + 1] = canvas[src_idx + 1]; sprite_data[dst_idx + 2] = canvas[src_idx + 2]; @@ -3628,8 +4327,9 @@ impl Renderer { for x in 0..cell_w as usize { let src_idx = y * cell_w as usize + x; let dst_idx = (y * cell_w as usize + x) * 4; - if src_idx < canvas.len() && dst_idx + 3 < sprite_data.len() { - sprite_data[dst_idx] = 255; // R + if src_idx < canvas.len() && dst_idx + 3 < sprite_data.len() + { + sprite_data[dst_idx] = 255; // R sprite_data[dst_idx + 1] = 255; // G sprite_data[dst_idx + 2] = 255; // B sprite_data[dst_idx + 3] = canvas[src_idx]; // A @@ -3637,7 +4337,7 @@ impl Renderer { } } } - + // Upload immediately to GPU - like Kitty's glTexSubImage3D call // This uploads only the cell-sized region, not the full 8192x8192 layer // With Vec, we select the texture by layer index and always use z=0 @@ -3664,18 +4364,18 @@ impl Renderer { depth_or_array_layers: 1, }, ); - + // Calculate UV coordinates let uv_x = self.atlas_cursor_x as f32 / ATLAS_SIZE as f32; let uv_y = self.atlas_cursor_y as f32 / ATLAS_SIZE as f32; let uv_w = cell_w as f32 / ATLAS_SIZE as f32; let uv_h = cell_h as f32 / ATLAS_SIZE as f32; let layer_f = layer as f32; - + // Update atlas cursor self.atlas_cursor_x += cell_w + 1; self.atlas_row_height = self.atlas_row_height.max(cell_h); - + GlyphInfo { uv: [uv_x, uv_y, uv_w, uv_h], size: [cell_w as f32, cell_h as f32], @@ -3683,30 +4383,31 @@ impl Renderer { layer: layer_f, } } - + /// Add a new layer to the atlas (like Kitty's realloc_sprite_texture). /// This switches to the next layer, creating the real texture if needed. fn add_atlas_layer(&mut self) { let new_layer = self.atlas_current_layer + 1; - + if new_layer >= MAX_ATLAS_LAYERS { - log::error!("Atlas layer limit reached ({} layers), cannot add more", MAX_ATLAS_LAYERS); + log::error!( + "Atlas layer limit reached ({} layers), cannot add more", + MAX_ATLAS_LAYERS + ); return; } - - - + // Create real texture for the new layer (replacing the dummy) self.ensure_atlas_layer_capacity(new_layer); - + // Now switch to the new layer self.atlas_current_layer = new_layer; } - + /// Ensure the atlas has a real texture at the given layer index. /// With our Vec approach, this just replaces the dummy texture at that index /// with a real one. No copying of existing data is needed - O(1) operation. - /// + /// /// We track which layers are "real" vs "dummy" by checking atlas_current_layer. /// Layers 0..=atlas_current_layer are real, layers above are dummies. fn ensure_atlas_layer_capacity(&mut self, target_layer: u32) { @@ -3715,14 +4416,16 @@ impl Renderer { if target_layer <= self.atlas_current_layer { return; } - + if target_layer >= MAX_ATLAS_LAYERS { - log::error!("Atlas layer limit reached: {} >= {}", target_layer, MAX_ATLAS_LAYERS); + log::error!( + "Atlas layer limit reached: {} >= {}", + target_layer, + MAX_ATLAS_LAYERS + ); return; } - - - + // Create new real texture (8192x8192) let texture = self.device.create_texture(&wgpu::TextureDescriptor { label: Some("Glyph Atlas Layer"), @@ -3735,35 +4438,41 @@ impl Renderer { sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - + // Replace dummy texture at this index with real texture self.atlas_textures[target_layer as usize] = texture; self.atlas_views[target_layer as usize] = view; - + // Recreate bind group with updated views (cheap - just metadata) self.glyph_bind_group = self.create_atlas_bind_group(); } - + /// Create the glyph bind group with all atlas texture views. /// Called during initialization and when adding new atlas layers. fn create_atlas_bind_group(&self) -> wgpu::BindGroup { - let view_refs: Vec<&wgpu::TextureView> = self.atlas_views.iter().collect(); - + let view_refs: Vec<&wgpu::TextureView> = + self.atlas_views.iter().collect(); + self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("Glyph Bind Group"), layout: &self.glyph_bind_group_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, - resource: wgpu::BindingResource::TextureViewArray(&view_refs), + resource: wgpu::BindingResource::TextureViewArray( + &view_refs, + ), }, wgpu::BindGroupEntry { binding: 1, - resource: wgpu::BindingResource::Sampler(&self.atlas_sampler), + resource: wgpu::BindingResource::Sampler( + &self.atlas_sampler, + ), }, ], }) @@ -3776,24 +4485,23 @@ impl Renderer { let cell_w = self.cell_metrics.cell_width as usize; let cell_h = self.cell_metrics.cell_height as usize; let cell_area = cell_w * cell_h; - + // Calculate DPI-aware cursor thicknesses (Kitty-style: thickness_pts * dpi / 72.0) - let beam_thickness = (1.5 * self.dpi / 72.0) - .round() - .max(1.0) - .min(cell_w as f64) as usize; - let underline_thickness = (2.0 * self.dpi / 72.0) - .round() - .max(1.0) - .min(cell_h as f64) as usize; + let beam_thickness = + (1.5 * self.dpi / 72.0).round().max(1.0).min(cell_w as f64) + as usize; + let underline_thickness = + (2.0 * self.dpi / 72.0).round().max(1.0).min(cell_h as f64) + as usize; let hollow_thickness = (1.0 * self.dpi / 72.0) .round() .max(1.0) - .min(cell_w.min(cell_h) as f64) as usize; - + .min(cell_w.min(cell_h) as f64) + as usize; + // Create grayscale canvas for each cursor type let mut canvas = vec![0u8; cell_area]; - + // === Beam cursor (vertical bar on left edge) === // Like Kitty's add_beam_cursor / vert() function canvas.fill(0); @@ -3804,7 +4512,7 @@ impl Renderer { } let beam_info = self.upload_cell_canvas_to_atlas(&canvas, false); let beam_sprite = SpriteInfo::from(beam_info); - + // === Underline cursor (horizontal bar at bottom) === // Like Kitty's add_underline_cursor / horz() function canvas.fill(0); @@ -3816,7 +4524,7 @@ impl Renderer { } let underline_info = self.upload_cell_canvas_to_atlas(&canvas, false); let underline_sprite = SpriteInfo::from(underline_info); - + // === Hollow cursor (rectangle outline) === // Like Kitty's add_hollow_cursor function canvas.fill(0); @@ -3846,7 +4554,7 @@ impl Renderer { } let hollow_info = self.upload_cell_canvas_to_atlas(&canvas, false); let hollow_sprite = SpriteInfo::from(hollow_info); - + // Store sprites at their fixed indices // sprite_info[0] = no glyph (already set) // sprite_info[1] = beam cursor (CURSOR_SPRITE_BEAM) @@ -3859,10 +4567,12 @@ impl Renderer { self.sprite_info[CURSOR_SPRITE_UNDERLINE as usize] = underline_sprite; self.sprite_info[CURSOR_SPRITE_HOLLOW as usize] = hollow_sprite; self.next_sprite_idx = FIRST_GLYPH_SPRITE; - + log::debug!( "Created cursor sprites: beam={}px wide, underline={}px tall, hollow={}px border", - beam_thickness, underline_thickness, hollow_thickness + beam_thickness, + underline_thickness, + hollow_thickness ); } @@ -3873,40 +4583,49 @@ impl Renderer { let cell_w = self.cell_metrics.cell_width as usize; let cell_h = self.cell_metrics.cell_height as usize; let cell_area = cell_w * cell_h; - + let underline_pos = self.cell_metrics.underline_position as usize; let underline_thick = self.cell_metrics.underline_thickness as usize; let strike_pos = self.cell_metrics.strikethrough_position as usize; let strike_thick = self.cell_metrics.strikethrough_thickness as usize; - + // Helper: draw horizontal line at y_start for 'thickness' rows - let draw_hline = |canvas: &mut [u8], y_start: usize, thickness: usize| { - for y in y_start..(y_start + thickness).min(cell_h) { - for x in 0..cell_w { - canvas[y * cell_w + x] = 255; + let draw_hline = + |canvas: &mut [u8], y_start: usize, thickness: usize| { + for y in y_start..(y_start + thickness).min(cell_h) { + for x in 0..cell_w { + canvas[y * cell_w + x] = 255; + } } - } - }; - + }; + // Create canvas for decorations let mut canvas = vec![0u8; cell_area]; - + // === Strikethrough (like Kitty's add_strikethrough) === canvas.fill(0); let strike_half = strike_thick / 2; - let strike_top = if strike_half > strike_pos { 0 } else { strike_pos - strike_half }; + let strike_top = if strike_half > strike_pos { + 0 + } else { + strike_pos - strike_half + }; draw_hline(&mut canvas, strike_top, strike_thick); let strike_info = self.upload_cell_canvas_to_atlas(&canvas, false); let strike_sprite = SpriteInfo::from(strike_info); - + // === Single Underline (like Kitty's add_straight_underline) === canvas.fill(0); let under_half = underline_thick / 2; - let under_top = if under_half > underline_pos { 0 } else { underline_pos - under_half }; + let under_top = if under_half > underline_pos { + 0 + } else { + underline_pos - under_half + }; draw_hline(&mut canvas, under_top, underline_thick); let underline_info = self.upload_cell_canvas_to_atlas(&canvas, false); let underline_sprite = SpriteInfo::from(underline_info); - + // === Double Underline (like Kitty's add_double_underline) === canvas.fill(0); // Two lines: one at underline_pos - thickness, one at underline_pos @@ -3916,50 +4635,61 @@ impl Renderer { // Ensure at least 2 pixels gap between lines let (top, bottom) = if bottom.saturating_sub(top) < 2 { let bottom = (bottom + 1).min(cell_h - 1); - let top = if bottom >= 2 { top } else { top.saturating_sub(1) }; + let top = if bottom >= 2 { + top + } else { + top.saturating_sub(1) + }; (top, bottom) } else { (top, bottom) }; // Draw single-pixel lines at top and bottom if top < cell_h { - for x in 0..cell_w { canvas[top * cell_w + x] = 255; } + for x in 0..cell_w { + canvas[top * cell_w + x] = 255; + } } if bottom < cell_h && bottom != top { - for x in 0..cell_w { canvas[bottom * cell_w + x] = 255; } + for x in 0..cell_w { + canvas[bottom * cell_w + x] = 255; + } } let double_info = self.upload_cell_canvas_to_atlas(&canvas, false); let double_sprite = SpriteInfo::from(double_info); - + // === Undercurl (like Kitty's add_curl_underline with Wu antialiasing) === // This follows Kitty's decorations.c add_curl_underline() exactly canvas.fill(0); - + let max_x = cell_w.saturating_sub(1); let max_y = cell_h.saturating_sub(1); - + // Wave factor: 2*PI for one full wave per cell (like Kitty's default undercurl_style) let xfactor = 2.0 * std::f64::consts::PI / max_x as f64; - + // Calculate position and thickness like Kitty does let d_quot = underline_thick / 2; let d_rem = underline_thick % 2; let position = underline_pos.min(cell_h.saturating_sub(d_quot + d_rem)); - let thickness = underline_thick.max(1).min(cell_h.saturating_sub(position + 1)); - + let thickness = underline_thick + .max(1) + .min(cell_h.saturating_sub(position + 1)); + // max_height is the descender space from the font - let max_height = cell_h.saturating_sub(position.saturating_sub(thickness / 2)); + let max_height = + cell_h.saturating_sub(position.saturating_sub(thickness / 2)); // half_height is the wave amplitude (1/4 of available space so it's not too large) let half_height = (max_height / 4).max(1); - + // Adjust thickness like Kitty: reduce slightly for thinner appearance // Note: thickness CAN become 0, which means only antialiased edges are drawn (1px line) let thickness = if thickness < 3 { - thickness.saturating_sub(1) // Can become 0 for thin 1px line + thickness.saturating_sub(1) // Can become 0 for thin 1px line } else { thickness.saturating_sub(2) }; - + // Center the wave vertically in the underline area let position = position + half_height * 2; let position = if position + half_height > max_y { @@ -3967,35 +4697,36 @@ impl Renderer { } else { position }; - + // Helper to add intensity at a position (like Kitty's add_intensity) - let add_intensity = |canvas: &mut [u8], x: usize, y: i32, val: u8, position: usize| { - let y = (y + position as i32).clamp(0, max_y as i32) as usize; - if y < cell_h && x < cell_w { - let idx = y * cell_w + x; - canvas[idx] = canvas[idx].saturating_add(val); - } - }; - + let add_intensity = + |canvas: &mut [u8], x: usize, y: i32, val: u8, position: usize| { + let y = (y + position as i32).clamp(0, max_y as i32) as usize; + if y < cell_h && x < cell_w { + let idx = y * cell_w + x; + canvas[idx] = canvas[idx].saturating_add(val); + } + }; + // Draw antialiased cosine wave using Wu algorithm (like Kitty) // Cosine waves always have slope <= 1 so are never steep for x in 0..cell_w { let y = (half_height as f64) * (x as f64 * xfactor).cos(); - let y1 = (y - thickness as f64).floor() as i32; // upper bound - let y2 = y.ceil() as i32; // lower bound - + let y1 = (y - thickness as f64).floor() as i32; // upper bound + let y2 = y.ceil() as i32; // lower bound + // Wu antialiasing intensity based on fractional part let frac = (y - y.floor()).abs(); let intensity = (255.0 * frac) as u8; - let i1 = 255u8.saturating_sub(intensity); // upper edge intensity - let i2 = intensity; // lower edge intensity - + let i1 = 255u8.saturating_sub(intensity); // upper edge intensity + let i2 = intensity; // lower edge intensity + // Draw antialiased upper bound add_intensity(&mut canvas, x, y1, i1, position); - - // Draw antialiased lower bound + + // Draw antialiased lower bound add_intensity(&mut canvas, x, y2, i2, position); - + // Fill between upper and lower bound with full intensity for t in 1..=thickness { add_intensity(&mut canvas, x, y1 + t as i32, 255, position); @@ -4003,12 +4734,12 @@ impl Renderer { } let curl_info = self.upload_cell_canvas_to_atlas(&canvas, false); let curl_sprite = SpriteInfo::from(curl_info); - + // === Dotted Underline (like Kitty's add_dotted_underline) === canvas.fill(0); let num_dots = (cell_w / (2 * underline_thick.max(1))).max(1); let dot_size = (cell_w / (2 * num_dots)).max(1); - + // Distribute dots evenly for y in under_top..(under_top + underline_thick).min(cell_h) { let mut x = dot_size / 2; // Start with half gap @@ -4023,13 +4754,13 @@ impl Renderer { } let dotted_info = self.upload_cell_canvas_to_atlas(&canvas, false); let dotted_sprite = SpriteInfo::from(dotted_info); - + // === Dashed Underline (like Kitty's add_dashed_underline) === canvas.fill(0); let quarter_width = cell_w / 4; let dash_width = cell_w.saturating_sub(3 * quarter_width); let second_dash_start = 3 * quarter_width; - + for y in under_top..(under_top + underline_thick).min(cell_h) { // First dash at start for x in 0..dash_width { @@ -4038,29 +4769,36 @@ impl Renderer { } } // Second dash - for x in second_dash_start..(second_dash_start + dash_width).min(cell_w) { + for x in + second_dash_start..(second_dash_start + dash_width).min(cell_w) + { canvas[y * cell_w + x] = 255; } } let dashed_info = self.upload_cell_canvas_to_atlas(&canvas, false); let dashed_sprite = SpriteInfo::from(dashed_info); - + // Store sprites at their fixed indices // Ensure sprite_info has enough capacity while self.sprite_info.len() < FIRST_GLYPH_SPRITE as usize { self.sprite_info.push(SpriteInfo::default()); } - self.sprite_info[DECORATION_SPRITE_STRIKETHROUGH as usize] = strike_sprite; - self.sprite_info[DECORATION_SPRITE_UNDERLINE as usize] = underline_sprite; - self.sprite_info[DECORATION_SPRITE_DOUBLE_UNDERLINE as usize] = double_sprite; + self.sprite_info[DECORATION_SPRITE_STRIKETHROUGH as usize] = + strike_sprite; + self.sprite_info[DECORATION_SPRITE_UNDERLINE as usize] = + underline_sprite; + self.sprite_info[DECORATION_SPRITE_DOUBLE_UNDERLINE as usize] = + double_sprite; self.sprite_info[DECORATION_SPRITE_UNDERCURL as usize] = curl_sprite; self.sprite_info[DECORATION_SPRITE_DOTTED as usize] = dotted_sprite; self.sprite_info[DECORATION_SPRITE_DASHED as usize] = dashed_sprite; self.next_sprite_idx = FIRST_GLYPH_SPRITE; - + log::debug!( "Created decoration sprites: underline at y={}, strikethrough at y={}, thickness={}px", - underline_pos, strike_pos, underline_thick + underline_pos, + strike_pos, + underline_thick ); } @@ -4075,7 +4813,11 @@ impl Renderer { /// Get or rasterize a glyph by its glyph ID from a specific font variant. /// Uses bold/italic font if available, otherwise falls back to regular. - fn get_glyph_by_id_with_style(&mut self, glyph_id: u16, style: FontStyle) -> GlyphInfo { + fn get_glyph_by_id_with_style( + &mut self, + glyph_id: u16, + style: FontStyle, + ) -> GlyphInfo { // Cache key: (font_style, font_index, glyph_id) // font_index 0 = primary/regular font let cache_key = (style as usize, 0usize, glyph_id); @@ -4097,7 +4839,9 @@ impl Renderer { let ab_glyph_id = GlyphId(glyph_id); let raster_result = self.rasterize_glyph_ab(&font, ab_glyph_id); - let Some((glyph_width, glyph_height, bitmap, offset_x, offset_y)) = raster_result else { + let Some((glyph_width, glyph_height, bitmap, offset_x, offset_y)) = + raster_result + else { // Empty glyph (e.g., space) self.glyph_cache.insert(cache_key, GlyphInfo::EMPTY); return GlyphInfo::EMPTY; @@ -4111,7 +4855,11 @@ impl Renderer { // Place the glyph in a cell-sized canvas at the correct baseline position let canvas = self.place_glyph_in_cell_canvas( - &bitmap, glyph_width, glyph_height, offset_x, offset_y + &bitmap, + glyph_width, + glyph_height, + offset_x, + offset_y, ); let info = self.upload_cell_canvas_to_atlas(&canvas, false); @@ -4130,7 +4878,11 @@ impl Renderer { /// Shape a text string using HarfBuzz/rustybuzz with a specific font style. /// Uses the bold/italic font variant if available, otherwise falls back to regular. - fn shape_text_with_style(&mut self, text: &str, style: FontStyle) -> ShapedGlyphs { + fn shape_text_with_style( + &mut self, + text: &str, + style: FontStyle, + ) -> ShapedGlyphs { // For now, we'll create a cache key that includes style // TODO: Could optimize by having separate caches per style let cache_key = format!("{}\x00{}", style as usize, text); @@ -4152,7 +4904,8 @@ impl Renderer { }; // Shape with OpenType features enabled (liga, calt, dlig) - let glyph_buffer = rustybuzz::shape(face, &self.shaping_features, buffer); + let glyph_buffer = + rustybuzz::shape(face, &self.shaping_features, buffer); let glyph_infos = glyph_buffer.glyph_infos(); let glyph_positions = glyph_buffer.glyph_positions(); @@ -4175,7 +4928,6 @@ impl Renderer { shaped } - /// Convert sRGB component (0.0-1.0) to linear RGB. /// This is needed because we're rendering to an sRGB surface. #[inline] @@ -4201,7 +4953,6 @@ impl Renderer { 1.0 - (snapped / screen_height) * 2.0 } - /// Draw a filled rectangle. fn render_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) { // Add quad to the batch for instanced rendering @@ -4217,7 +4968,14 @@ impl Renderer { } /// Draw a filled rectangle to the overlay layer (rendered on top of everything). - fn render_overlay_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) { + fn render_overlay_rect( + &mut self, + x: f32, + y: f32, + w: f32, + h: f32, + color: [f32; 4], + ) { // Add quad to the overlay batch for instanced rendering (rendered last) self.overlay_quads.push(Quad { x, @@ -4231,7 +4989,12 @@ impl Renderer { /// Prepare edge glow uniform data for shader-based rendering. /// Returns the uniform data to be uploaded to the GPU. /// Prepare combined edge glow uniform data for all active glows. - fn prepare_edge_glow_uniforms(&self, glows: &[EdgeGlow], terminal_y_offset: f32, intensity: f32) -> EdgeGlowUniforms { + fn prepare_edge_glow_uniforms( + &self, + glows: &[EdgeGlow], + terminal_y_offset: f32, + intensity: f32, + ) -> EdgeGlowUniforms { // Use the same color as the active pane border (palette color 4 - typically blue) // Use pre-computed linear palette let [color_r, color_g, color_b, _] = self.linear_palette.color_table[4]; @@ -4300,7 +5063,11 @@ impl Renderer { /// - `statusline_content`: Content to render in the statusline pub fn render_panes( &mut self, - panes: &[(&Terminal, PaneRenderInfo, Option<(usize, usize, usize, usize)>)], + panes: &[( + &Terminal, + PaneRenderInfo, + Option<(usize, usize, usize, usize)>, + )], num_tabs: usize, active_tab: usize, edge_glows: &[EdgeGlow], @@ -4309,13 +5076,16 @@ impl Renderer { ) -> Result<(), wgpu::SurfaceError> { #[cfg(feature = "render_timing")] let frame_start = std::time::Instant::now(); - + // Sync palette from first terminal (update both sRGB and linear versions) if let Some((terminal, _, _)) = panes.first() { self.palette = terminal.palette.clone(); self.linear_palette = LinearPalette::from_palette(&self.palette); - log::debug!("render_panes: synced palette from first terminal, default_bg={:?}, default_fg={:?}", - self.palette.default_bg, self.palette.default_fg); + log::debug!( + "render_panes: synced palette from first terminal, default_bg={:?}, default_fg={:?}", + self.palette.default_bg, + self.palette.default_fg + ); } else { log::debug!("render_panes: no panes, using existing palette"); } @@ -4340,7 +5110,7 @@ impl Renderer { let height = self.height as f32; let tab_bar_height = self.tab_bar_height(); let terminal_y_offset = self.terminal_y_offset(); - + // Grid centering offsets - center the cell grid in the window let grid_x_offset = self.grid_x_offset(); let grid_y_offset = self.grid_y_offset(); @@ -4359,19 +5129,37 @@ impl Renderer { let tab_bar_bg = if is_light { // Light mode statusline bg is approx 0xD0, linear is ~0.63076 const TAB_BAR_BG_LINEAR_LIGHT: f32 = 0.63076; - [TAB_BAR_BG_LINEAR_LIGHT, TAB_BAR_BG_LINEAR_LIGHT, TAB_BAR_BG_LINEAR_LIGHT, 1.0] + [ + TAB_BAR_BG_LINEAR_LIGHT, + TAB_BAR_BG_LINEAR_LIGHT, + TAB_BAR_BG_LINEAR_LIGHT, + 1.0, + ] } else { // Use same color as statusline: 0x1a1a1a (26, 26, 26) in sRGB // Pre-computed linear RGB value for srgb_to_linear(26/255) ≈ 0.00972 const TAB_BAR_BG_LINEAR_DARK: f32 = 0.00972; - [TAB_BAR_BG_LINEAR_DARK, TAB_BAR_BG_LINEAR_DARK, TAB_BAR_BG_LINEAR_DARK, 1.0] + [ + TAB_BAR_BG_LINEAR_DARK, + TAB_BAR_BG_LINEAR_DARK, + TAB_BAR_BG_LINEAR_DARK, + 1.0, + ] }; // Draw tab bar background - log::debug!("render_panes: drawing tab bar at y={}, height={}, num_tabs={}, quads_before={}", - tab_bar_y, tab_bar_height, num_tabs, self.quads.len()); + log::debug!( + "render_panes: drawing tab bar at y={}, height={}, num_tabs={}, quads_before={}", + tab_bar_y, + tab_bar_height, + num_tabs, + self.quads.len() + ); self.render_rect(0.0, tab_bar_y, width, tab_bar_height, tab_bar_bg); - log::debug!("render_panes: after tab bar rect, quads_count={}", self.quads.len()); + log::debug!( + "render_panes: after tab bar rect, quads_count={}", + self.quads.len() + ); // Render each tab let mut tab_x = 4.0_f32; @@ -4381,7 +5169,8 @@ impl Renderer { for idx in 0..num_tabs { let is_active = idx == active_tab; let title = format!(" {} ", idx + 1); - let title_width = title.chars().count() as f32 * self.cell_metrics.cell_width as f32; + let title_width = title.chars().count() as f32 + * self.cell_metrics.cell_width as f32; let tab_width = title_width.max(min_tab_width); let tab_bg = if is_active { @@ -4389,9 +5178,15 @@ impl Renderer { let [r, g, b] = self.palette.default_bg; let boost = if is_light { 0.0_f32 } else { 50.0_f32 }; [ - Self::srgb_to_linear((r as f32 + boost).clamp(0.0, 255.0) / 255.0), - Self::srgb_to_linear((g as f32 + boost).clamp(0.0, 255.0) / 255.0), - Self::srgb_to_linear((b as f32 + boost).clamp(0.0, 255.0) / 255.0), + Self::srgb_to_linear( + (r as f32 + boost).clamp(0.0, 255.0) / 255.0, + ), + Self::srgb_to_linear( + (g as f32 + boost).clamp(0.0, 255.0) / 255.0, + ), + Self::srgb_to_linear( + (b as f32 + boost).clamp(0.0, 255.0) / 255.0, + ), 1.0, ] } else { @@ -4399,9 +5194,15 @@ impl Renderer { let [r, g, b] = self.palette.default_bg; let boost = if is_light { -30.0_f32 } else { 30.0_f32 }; [ - Self::srgb_to_linear((r as f32 + boost).clamp(0.0, 255.0) / 255.0), - Self::srgb_to_linear((g as f32 + boost).clamp(0.0, 255.0) / 255.0), - Self::srgb_to_linear((b as f32 + boost).clamp(0.0, 255.0) / 255.0), + Self::srgb_to_linear( + (r as f32 + boost).clamp(0.0, 255.0) / 255.0, + ), + Self::srgb_to_linear( + (g as f32 + boost).clamp(0.0, 255.0) / 255.0, + ), + Self::srgb_to_linear( + (b as f32 + boost).clamp(0.0, 255.0) / 255.0, + ), 1.0, ] }; @@ -4413,10 +5214,18 @@ impl Renderer { }; // Draw tab background - self.render_rect(tab_x, tab_bar_y + 2.0, tab_width, tab_bar_height - 4.0, tab_bg); + self.render_rect( + tab_x, + tab_bar_y + 2.0, + tab_width, + tab_bar_height - 4.0, + tab_bg, + ); // Render tab title text - let text_y = tab_bar_y + (tab_bar_height - self.cell_metrics.cell_height as f32) / 2.0; + let text_y = tab_bar_y + + (tab_bar_height - self.cell_metrics.cell_height as f32) + / 2.0; let text_x = tab_x + (tab_width - title_width) / 2.0; for (char_idx, c) in title.chars().enumerate() { @@ -4426,14 +5235,22 @@ impl Renderer { let glyph = self.rasterize_char(c); if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { // In Kitty's model, glyphs are cell-sized and positioned at (0,0) - let char_x = text_x + char_idx as f32 * self.cell_metrics.cell_width as f32; + let char_x = text_x + + char_idx as f32 + * self.cell_metrics.cell_width as f32; let glyph_x = char_x.round(); let glyph_y = text_y.round(); let left = Self::pixel_to_ndc_x(glyph_x, width); - let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); + let right = Self::pixel_to_ndc_x( + glyph_x + glyph.size[0], + width, + ); let top = Self::pixel_to_ndc_y(glyph_y, height); - let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); + let bottom = Self::pixel_to_ndc_y( + glyph_y + glyph.size[1], + height, + ); let base_idx = self.glyph_vertices.len() as u32; self.glyph_vertices.push(GlyphVertex { @@ -4450,7 +5267,10 @@ impl Renderer { }); self.glyph_vertices.push(GlyphVertex { position: [right, bottom], - uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], + uv: [ + glyph.uv[0] + glyph.uv[2], + glyph.uv[1] + glyph.uv[3], + ], color: tab_fg, bg_color: [0.0, 0.0, 0.0, 0.0], }); @@ -4461,8 +5281,12 @@ impl Renderer { bg_color: [0.0, 0.0, 0.0, 0.0], }); self.glyph_indices.extend_from_slice(&[ - base_idx, base_idx + 1, base_idx + 2, - base_idx, base_idx + 2, base_idx + 3, + base_idx, + base_idx + 1, + base_idx + 2, + base_idx, + base_idx + 2, + base_idx + 3, ]); } } @@ -4495,15 +5319,20 @@ impl Renderer { if panes.len() > 1 { // Tolerance for detecting adjacent panes (should be touching or very close) let adjacency_tolerance = 1.0; - + // Calculate grid boundaries for extending borders to screen edges // Same technique as edge glow and dim overlay - let (available_width, available_height) = self.available_grid_space(); + let (available_width, available_height) = + self.available_grid_space(); let grid_top = terminal_y_offset; let grid_bottom = terminal_y_offset + available_height; let grid_left = 0.0_f32; let grid_right = width; - let epsilon = (self.cell_metrics.cell_height.max(self.cell_metrics.cell_width)) as f32; + let epsilon = (self + .cell_metrics + .cell_height + .max(self.cell_metrics.cell_width)) + as f32; // Check each pair of panes to find adjacent ones for i in 0..panes.len() { @@ -4542,13 +5371,22 @@ impl Renderer { top = grid_top; } // Bottom edge: extend if both panes reach grid bottom - if (info_a.y + info_a.height) >= available_height - epsilon - && (info_b.y + info_b.height) >= available_height - epsilon { + if (info_a.y + info_a.height) + >= available_height - epsilon + && (info_b.y + info_b.height) + >= available_height - epsilon + { bottom = grid_bottom; } // Draw vertical border centered on their shared edge let border_x = a_right - border_thickness / 2.0; - self.render_overlay_rect(border_x, top, border_thickness, bottom - top, border_color); + self.render_overlay_rect( + border_x, + top, + border_thickness, + bottom - top, + border_color, + ); } } // Pane B is to the left of pane A @@ -4560,12 +5398,21 @@ impl Renderer { if info_a.y < epsilon && info_b.y < epsilon { top = grid_top; } - if (info_a.y + info_a.height) >= available_height - epsilon - && (info_b.y + info_b.height) >= available_height - epsilon { + if (info_a.y + info_a.height) + >= available_height - epsilon + && (info_b.y + info_b.height) + >= available_height - epsilon + { bottom = grid_bottom; } let border_x = b_right - border_thickness / 2.0; - self.render_overlay_rect(border_x, top, border_thickness, bottom - top, border_color); + self.render_overlay_rect( + border_x, + top, + border_thickness, + bottom - top, + border_color, + ); } } @@ -4582,13 +5429,22 @@ impl Renderer { left = grid_left; } // Right edge: extend if both panes reach grid right - if (info_a.x + info_a.width) >= available_width - epsilon - && (info_b.x + info_b.width) >= available_width - epsilon { + if (info_a.x + info_a.width) + >= available_width - epsilon + && (info_b.x + info_b.width) + >= available_width - epsilon + { right = grid_right; } // Draw horizontal border centered on their shared edge let border_y = a_bottom - border_thickness / 2.0; - self.render_overlay_rect(left, border_y, right - left, border_thickness, border_color); + self.render_overlay_rect( + left, + border_y, + right - left, + border_thickness, + border_color, + ); } } // Pane B is above pane A @@ -4600,12 +5456,21 @@ impl Renderer { if info_a.x < epsilon && info_b.x < epsilon { left = grid_left; } - if (info_a.x + info_a.width) >= available_width - epsilon - && (info_b.x + info_b.width) >= available_width - epsilon { + if (info_a.x + info_a.width) + >= available_width - epsilon + && (info_b.x + info_b.width) + >= available_width - epsilon + { right = grid_right; } let border_y = b_bottom - border_thickness / 2.0; - self.render_overlay_rect(left, border_y, right - left, border_thickness, border_color); + self.render_overlay_rect( + left, + border_y, + right - left, + border_thickness, + border_color, + ); } } } @@ -4627,7 +5492,7 @@ impl Renderer { dim_overlay: Option<(f32, f32, f32, f32, [f32; 4])>, // (x, y, w, h, color) } let mut pane_render_list: Vec = Vec::new(); - + #[cfg(feature = "render_timing")] let pane_loop_start = std::time::Instant::now(); // First pass: collect pane data, ensure GPU resources exist, and upload data @@ -4637,9 +5502,16 @@ impl Renderer { let pane_y = terminal_y_offset + grid_y_offset + info.y; let pane_width = info.width; let pane_height = info.height; - - log::debug!("render_panes: pane {} at ({}, {}), size {}x{}, bottom_edge={}", - info.pane_id, pane_x, pane_y, pane_width, pane_height, pane_y + pane_height); + + log::debug!( + "render_panes: pane {} at ({}, {}), size {}x{}, bottom_edge={}", + info.pane_id, + pane_x, + pane_y, + pane_width, + pane_height, + pane_y + pane_height + ); // Update GPU cells for this terminal (populates self.gpu_cells) #[cfg(feature = "render_timing")] @@ -4648,38 +5520,70 @@ impl Renderer { #[cfg(feature = "render_timing")] { let update_time = t0.elapsed(); - if update_time.as_micros() > 500 { - - } + if update_time.as_micros() > 500 {} } - - let cols = terminal.cols as u32; - let rows = terminal.rows as u32; - + + let cols = terminal.cols; + let rows = terminal.rows; + // Use the actual gpu_cells size for buffer allocation (terminal.cols * terminal.rows) // This may differ from pane pixel dimensions due to rounding let actual_cells = self.gpu_cells.len(); - + // Ensure this pane has GPU resources (like Kitty's create_cell_vao) // This creates or resizes buffers as needed - let _pane_res = self.get_or_create_pane_resources(info.pane_id, actual_cells); - + let _pane_res = + self.get_or_create_pane_resources(info.pane_id, actual_cells); + // Build grid params for this pane - let (sel_start_col, sel_start_row, sel_end_col, sel_end_row) = match selection { - Some((sc, sr, ec, er)) => (*sc as i32, *sr as i32, *ec as i32, *er as i32), - None => (-1, -1, -1, -1), - }; + let mut selection_row_max_col = [-1i32; 256]; + if let Some((_sc, sr, _ec, er)) = selection { + for row in *sr..=*er { + if row < 256 && row < rows as usize { + let mut max_col = -1i32; + let row_cells = &terminal.grid[row as usize]; + for col in 0..row_cells.len() { + let cell = &row_cells[col]; + if cell.character != ' ' || cell.wide_continuation { + max_col = col as i32; + } + } + selection_row_max_col[row as usize] = max_col; + } + } + } + + let (sel_start_col, sel_start_row, sel_end_col, sel_end_row) = + match selection { + Some((sc, sr, ec, er)) => { + (*sc as i32, *sr as i32, *ec as i32, *er as i32) + } + None => (-1, -1, -1, -1), + }; let grid_params = GridParams { - cols, - rows, + cols: cols as u32, + rows: rows as u32, cell_width: self.cell_metrics.cell_width, cell_height: self.cell_metrics.cell_height, // Hide cursor when scrolled into scrollback buffer or when cursor is explicitly hidden - cursor_col: if terminal.cursor_visible && terminal.scroll_offset == 0 { terminal.cursor_col as i32 } else { -1 }, - cursor_row: if terminal.cursor_visible && terminal.scroll_offset == 0 { terminal.cursor_row as i32 } else { -1 }, + cursor_col: if terminal.cursor_visible + && terminal.scroll_offset == 0 + { + terminal.cursor_col as i32 + } else { + -1 + }, + cursor_row: if terminal.cursor_visible + && terminal.scroll_offset == 0 + { + terminal.cursor_row as i32 + } else { + -1 + }, cursor_style: match terminal.cursor_shape { CursorShape::BlinkingBlock | CursorShape::SteadyBlock => 0, - CursorShape::BlinkingUnderline | CursorShape::SteadyUnderline => 1, + CursorShape::BlinkingUnderline + | CursorShape::SteadyUnderline => 1, CursorShape::BlinkingBar | CursorShape::SteadyBar => 2, }, background_opacity: if terminal.using_alternate_screen { @@ -4691,24 +5595,31 @@ impl Renderer { selection_start_row: sel_start_row, selection_end_col: sel_end_col, selection_end_row: sel_end_row, + selection_row_max_col, }; // Upload this pane's cell data to its own buffer (like Kitty's send_cell_data_to_gpu) // This happens BEFORE the render pass, so each pane has its own data if let Some(pane_res) = self.pane_resources.get(&info.pane_id) { // Safety check: verify buffer can hold the data - let data_size = self.gpu_cells.len() * std::mem::size_of::(); - let buffer_size = pane_res.capacity * std::mem::size_of::(); + let data_size = + self.gpu_cells.len() * std::mem::size_of::(); + let buffer_size = + pane_res.capacity * std::mem::size_of::(); if data_size > buffer_size { // This shouldn't happen if get_or_create_pane_resources worked correctly eprintln!( "BUG: Buffer size mismatch for pane {}: data={} bytes, buffer={} bytes, gpu_cells.len()={}, capacity={}", - info.pane_id, data_size, buffer_size, self.gpu_cells.len(), pane_res.capacity + info.pane_id, + data_size, + buffer_size, + self.gpu_cells.len(), + pane_res.capacity ); // Skip this pane to avoid crash - will be fixed next frame continue; } - + self.queue.write_buffer( &pane_res.cell_buffer, 0, @@ -4720,52 +5631,67 @@ impl Renderer { bytemuck::bytes_of(&grid_params), ); } - + // Build dim overlay if needed - use calculate_dim_overlay_bounds to extend // edge panes to fill the terminal grid area (matching edge glow behavior) let dim_overlay = if info.dim_factor < 1.0 { let overlay_alpha = 1.0 - info.dim_factor; let overlay_color = [0.0, 0.0, 0.0, overlay_alpha]; // Pass raw grid-relative coordinates, the helper transforms to screen space - let (ox, oy, ow, oh) = self.calculate_dim_overlay_bounds(info.x, info.y, info.width, info.height); + let (ox, oy, ow, oh) = self.calculate_dim_overlay_bounds( + info.x, + info.y, + info.width, + info.height, + ); Some((ox, oy, ow, oh, overlay_color)) } else { None }; - + // Viewport dimensions for Kitty-style NDC rendering // The viewport is set to the pane's pixel area, so the shader works in pure NDC space // Cell dimensions are already integers like Kitty - no floating-point accumulation errors - let viewport_width = (cols * self.cell_metrics.cell_width) as f32; - let viewport_height = (rows * self.cell_metrics.cell_height) as f32; + let viewport_width = + (cols * self.cell_metrics.cell_width as usize) as f32; + let viewport_height = + (rows * self.cell_metrics.cell_height as usize) as f32; // Also round the viewport position to pixel boundaries let viewport_x = pane_x.round(); let viewport_y = pane_y.round(); - + pane_render_list.push(PaneRenderData { pane_id: info.pane_id, - cols, - rows, - viewport: (viewport_x, viewport_y, viewport_width, viewport_height), + cols: cols as u32, + rows: rows as u32, + viewport: ( + viewport_x, + viewport_y, + viewport_width, + viewport_height, + ), dim_overlay, }); } #[cfg(feature = "render_timing")] { let pane_loop_time = pane_loop_start.elapsed(); - if pane_loop_time.as_micros() > 500 { - - } + if pane_loop_time.as_micros() > 500 {} } - + // Clean up resources for panes that no longer exist (like Kitty's remove_vao) - let active_pane_ids: std::collections::HashSet = pane_render_list.iter().map(|p| p.pane_id).collect(); + let active_pane_ids: std::collections::HashSet = + pane_render_list.iter().map(|p| p.pane_id).collect(); self.cleanup_unused_pane_resources(&active_pane_ids); // ═══════════════════════════════════════════════════════════════════ // UPLOAD SHARED DATA (color table - uses pre-computed linear palette) // ═══════════════════════════════════════════════════════════════════ - self.queue.write_buffer(&self.color_table_buffer, 0, bytemuck::cast_slice(&self.linear_palette.color_table)); + self.queue.write_buffer( + &self.color_table_buffer, + 0, + bytemuck::cast_slice(&self.linear_palette.color_table), + ); // ═══════════════════════════════════════════════════════════════════ // PREPARE STATUSLINE FOR RENDERING (dedicated shader) @@ -4774,10 +5700,14 @@ impl Renderer { let statusline_cols = { let statusline_y = self.statusline_y(); let is_light = self.palette.is_light(); - + // Update statusline GPU cells from content, passing window width for gap expansion - let cols = self.update_statusline_cells(statusline_content, width, is_light); - + let cols = self.update_statusline_cells( + statusline_content, + width, + is_light, + ); + if cols > 0 { // Upload statusline cells to GPU self.queue.write_buffer( @@ -4785,7 +5715,7 @@ impl Renderer { 0, bytemuck::cast_slice(&self.statusline_gpu_cells), ); - + // Create params for statusline shader let statusline_params = StatuslineParams { char_count: cols as u32, @@ -4796,7 +5726,7 @@ impl Renderer { y_offset: statusline_y, _padding: [0.0, 0.0], }; - + // Upload statusline params self.queue.write_buffer( &self.statusline_params_buffer, @@ -4804,10 +5734,10 @@ impl Renderer { bytemuck::cast_slice(&[statusline_params]), ); } - + cols }; - + // Upload terminal sprites (shared between all panes) // Must happen after all sprites have been created // Resize sprite buffer if needed @@ -4815,98 +5745,144 @@ impl Renderer { let required_sprites = self.sprite_info.len(); if required_sprites > self.sprite_buffer_capacity { // Need to resize - create a new larger buffer - let new_capacity = (required_sprites * 3 / 2).max(self.sprite_buffer_capacity * 2); - self.sprite_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Sprite Storage Buffer"), - size: (new_capacity * std::mem::size_of::()) as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); + let new_capacity = (required_sprites * 3 / 2) + .max(self.sprite_buffer_capacity * 2); + self.sprite_buffer = + self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Sprite Storage Buffer"), + size: (new_capacity * std::mem::size_of::()) + as u64, + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); self.sprite_buffer_capacity = new_capacity; - + // Recreate all per-pane bind groups since they reference the sprite buffer - let pane_ids: Vec = self.pane_resources.keys().cloned().collect(); + let pane_ids: Vec = + self.pane_resources.keys().cloned().collect(); for pane_id in pane_ids { if let Some(pane_res) = self.pane_resources.get(&pane_id) { - let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some(&format!("Pane {} Bind Group", pane_id)), - layout: &self.instanced_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: self.color_table_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: pane_res.grid_params_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: pane_res.cell_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: self.sprite_buffer.as_entire_binding(), - }, - ], - }); + let bind_group = self.device.create_bind_group( + &wgpu::BindGroupDescriptor { + label: Some(&format!( + "Pane {} Bind Group", + pane_id + )), + layout: &self.instanced_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self + .color_table_buffer + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: pane_res + .grid_params_buffer + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: pane_res + .cell_buffer + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: self + .sprite_buffer + .as_entire_binding(), + }, + ], + }, + ); // Update the bind group in pane_resources - if let Some(pane_res_mut) = self.pane_resources.get_mut(&pane_id) { + if let Some(pane_res_mut) = + self.pane_resources.get_mut(&pane_id) + { pane_res_mut.bind_group = bind_group; } } } } - - self.queue.write_buffer(&self.sprite_buffer, 0, bytemuck::cast_slice(&self.sprite_info)); + + self.queue.write_buffer( + &self.sprite_buffer, + 0, + bytemuck::cast_slice(&self.sprite_info), + ); } - + // Upload statusline sprites (separate buffer from terminal) if !self.statusline_sprite_info.is_empty() { let required_sprites = self.statusline_sprite_info.len(); if required_sprites > self.statusline_sprite_buffer_capacity { // Need to resize - create a new larger buffer - let new_capacity = (required_sprites * 3 / 2).max(self.statusline_sprite_buffer_capacity * 2); - self.statusline_sprite_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Statusline Sprite Buffer"), - size: (new_capacity * std::mem::size_of::()) as u64, - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); + let new_capacity = (required_sprites * 3 / 2) + .max(self.statusline_sprite_buffer_capacity * 2); + self.statusline_sprite_buffer = + self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Statusline Sprite Buffer"), + size: (new_capacity * std::mem::size_of::()) + as u64, + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); self.statusline_sprite_buffer_capacity = new_capacity; - + // Recreate statusline bind group since it references the sprite buffer - self.statusline_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Statusline Bind Group"), - layout: &self.statusline_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: self.color_table_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: self.statusline_params_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: self.statusline_cell_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: self.statusline_sprite_buffer.as_entire_binding(), - }, - ], - }); + self.statusline_bind_group = + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Statusline Bind Group"), + layout: &self.statusline_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self + .color_table_buffer + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: self + .statusline_params_buffer + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: self + .statusline_cell_buffer + .as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: self + .statusline_sprite_buffer + .as_entire_binding(), + }, + ], + }); } - - self.queue.write_buffer(&self.statusline_sprite_buffer, 0, bytemuck::cast_slice(&self.statusline_sprite_info)); + + self.queue.write_buffer( + &self.statusline_sprite_buffer, + 0, + bytemuck::cast_slice(&self.statusline_sprite_info), + ); } // ═══════════════════════════════════════════════════════════════════ // PREPARE IMAGE RENDERS (Kitty Graphics Protocol) // ═══════════════════════════════════════════════════════════════════ - let mut image_renders: Vec<(crate::gpu_types::PaneId, u32, u64, [u32; 4])> = Vec::new(); + let mut image_renders: Vec<( + crate::gpu_types::PaneId, + u32, + u64, + [u32; 4], + )> = Vec::new(); let mut current_uniform_offset = 0; for (terminal, info, _) in panes { @@ -4917,14 +5893,24 @@ impl Renderer { // Compute scissor rect to clip image to pane boundaries let scissor_x = (pane_x.round() as i32).max(0) as u32; let scissor_y = (pane_y.round() as i32).max(0) as u32; - let scissor_w = (info.width.round() as i32).max(0).min((width as i32) - (scissor_x as i32)) as u32; - let scissor_h = (info.height.round() as i32).max(0).min((height as i32) - (scissor_y as i32)) as u32; + let scissor_w = (info.width.round() as i32) + .max(0) + .min((width as i32) - (scissor_x as i32)) + as u32; + let scissor_h = (info.height.round() as i32) + .max(0) + .min((height as i32) - (scissor_y as i32)) + as u32; let scissor = [scissor_x, scissor_y, scissor_w, scissor_h]; let renders = self.image_renderer.prepare_image_renders( crate::gpu_types::PaneId(info.pane_id), if terminal.using_alternate_screen { - terminal.alternate_screen.as_ref().map(|alt| alt.image_storage.placements()).unwrap_or_default() + terminal + .alternate_screen + .as_ref() + .map(|alt| alt.image_storage.placements()) + .unwrap_or_default() } else { terminal.image_storage.placements() }, @@ -4934,8 +5920,16 @@ impl Renderer { self.cell_metrics.cell_height as f32, width, height, - if terminal.using_alternate_screen { 0 } else { terminal.scrollback.len() }, - if terminal.using_alternate_screen { 0 } else { terminal.scroll_offset }, + if terminal.using_alternate_screen { + 0 + } else { + terminal.scrollback.len() + }, + if terminal.using_alternate_screen { + 0 + } else { + terminal.scroll_offset + }, info.rows, info.dim_factor, ); @@ -4945,8 +5939,13 @@ impl Renderer { current_uniform_offset, bytemuck::cast_slice(&[uniforms]), ); - image_renders.push((crate::gpu_types::PaneId(info.pane_id), id, current_uniform_offset, scissor)); - + image_renders.push(( + crate::gpu_types::PaneId(info.pane_id), + id, + current_uniform_offset, + scissor, + )); + // Align offset to device's min_uniform_buffer_offset_alignment current_uniform_offset += self.image_renderer.alignment; } @@ -4955,11 +5954,16 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════════ // PREPARE EDGE GLOW UNIFORMS (combined for all active glows) // ═══════════════════════════════════════════════════════════════════ - let edge_glow_uniforms = if !edge_glows.is_empty() && edge_glow_intensity > 0.0 { - Some(self.prepare_edge_glow_uniforms(edge_glows, terminal_y_offset, edge_glow_intensity)) - } else { - None - }; + let edge_glow_uniforms = + if !edge_glows.is_empty() && edge_glow_intensity > 0.0 { + Some(self.prepare_edge_glow_uniforms( + edge_glows, + terminal_y_offset, + edge_glow_intensity, + )) + } else { + None + }; // ═══════════════════════════════════════════════════════════════════ // SUBMIT TO GPU @@ -4967,31 +5971,43 @@ impl Renderer { let bg_vertex_count = self.bg_vertices.len(); let glyph_vertex_count = self.glyph_vertices.len(); let total_vertex_count = bg_vertex_count + glyph_vertex_count; - let total_index_count = self.bg_indices.len() + self.glyph_indices.len(); + let total_index_count = + self.bg_indices.len() + self.glyph_indices.len(); // Resize buffers if needed if total_vertex_count > self.vertex_capacity { self.vertex_capacity = total_vertex_count * 2; - self.vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Glyph Vertex Buffer"), - size: (self.vertex_capacity * std::mem::size_of::()) as u64, - usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); + self.vertex_buffer = + self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Glyph Vertex Buffer"), + size: (self.vertex_capacity + * std::mem::size_of::()) + as u64, + usage: wgpu::BufferUsages::VERTEX + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); } if total_index_count > self.index_capacity { self.index_capacity = total_index_count * 2; - self.index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Glyph Index Buffer"), - size: (self.index_capacity * std::mem::size_of::()) as u64, - usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); + self.index_buffer = + self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Glyph Index Buffer"), + size: (self.index_capacity * std::mem::size_of::()) + as u64, + usage: wgpu::BufferUsages::INDEX + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); } // Upload vertices: bg, then glyph - self.queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.bg_vertices)); + self.queue.write_buffer( + &self.vertex_buffer, + 0, + bytemuck::cast_slice(&self.bg_vertices), + ); self.queue.write_buffer( &self.vertex_buffer, (bg_vertex_count * std::mem::size_of::()) as u64, @@ -4999,13 +6015,19 @@ impl Renderer { ); // Upload indices: bg, then glyph (adjusted) - self.queue.write_buffer(&self.index_buffer, 0, bytemuck::cast_slice(&self.bg_indices)); + self.queue.write_buffer( + &self.index_buffer, + 0, + bytemuck::cast_slice(&self.bg_indices), + ); let glyph_vertex_offset = bg_vertex_count as u32; let bg_index_bytes = self.bg_indices.len() * std::mem::size_of::(); if !self.glyph_indices.is_empty() { - let adjusted_indices: Vec = self.glyph_indices.iter() + let adjusted_indices: Vec = self + .glyph_indices + .iter() .map(|i| i + glyph_vertex_offset) .collect(); self.queue.write_buffer( @@ -5021,13 +6043,21 @@ impl Renderer { screen_height: height, _padding: [0.0, 0.0], }; - self.queue.write_buffer(&self.quad_params_buffer, 0, bytemuck::cast_slice(&[quad_params])); - + self.queue.write_buffer( + &self.quad_params_buffer, + 0, + bytemuck::cast_slice(&[quad_params]), + ); + // Upload quads if we have any if !self.quads.is_empty() { - self.queue.write_buffer(&self.quad_buffer, 0, bytemuck::cast_slice(&self.quads)); + self.queue.write_buffer( + &self.quad_buffer, + 0, + bytemuck::cast_slice(&self.quads), + ); } - + // Upload overlay quads if we have any (will be rendered after main quads) // We reuse the same buffer, uploading overlay quads when needed during rendering @@ -5035,52 +6065,65 @@ impl Renderer { // like Kitty's send_sprite_to_gpu() - no batched layer uploads needed // Create command encoder and render - let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("Render Encoder"), - }); + let mut encoder = self.device.create_command_encoder( + &wgpu::CommandEncoderDescriptor { + label: Some("Render Encoder"), + }, + ); { let [bg_r, bg_g, bg_b] = self.palette.default_bg; - let mut bg_r_linear = Self::srgb_to_linear(bg_r as f32 / 255.0) as f64; - let mut bg_g_linear = Self::srgb_to_linear(bg_g as f32 / 255.0) as f64; - let mut bg_b_linear = Self::srgb_to_linear(bg_b as f32 / 255.0) as f64; + let mut bg_r_linear = + Self::srgb_to_linear(bg_r as f32 / 255.0) as f64; + let mut bg_g_linear = + Self::srgb_to_linear(bg_g as f32 / 255.0) as f64; + let mut bg_b_linear = + Self::srgb_to_linear(bg_b as f32 / 255.0) as f64; let bg_alpha = self.background_opacity as f64; - + // If the compositor expects premultiplied alpha, we must premultiply the clear color. // Otherwise, light backgrounds with opacity will look fully opaque or super-luminous. - if self.surface_config.alpha_mode == wgpu::CompositeAlphaMode::PreMultiplied { + if self.surface_config.alpha_mode + == wgpu::CompositeAlphaMode::PreMultiplied + { bg_r_linear *= bg_alpha; bg_g_linear *= bg_alpha; bg_b_linear *= bg_alpha; } - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Render Pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { - r: bg_r_linear, - g: bg_g_linear, - b: bg_b_linear, - a: bg_alpha, - }), - store: wgpu::StoreOp::Store, - }, - depth_slice: None, - })], - depth_stencil_attachment: None, - occlusion_query_set: None, - timestamp_writes: None, - multiview_mask: None, - }); + let mut render_pass = + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Render Pass"), + color_attachments: &[Some( + wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: bg_r_linear, + g: bg_g_linear, + b: bg_b_linear, + a: bg_alpha, + }), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + }, + )], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + multiview_mask: None, + }); render_pass.set_pipeline(&self.glyph_pipeline); render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); - render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32); - + render_pass.set_index_buffer( + self.index_buffer.slice(..), + wgpu::IndexFormat::Uint32, + ); + // ═══════════════════════════════════════════════════════════════════ // INSTANCED QUAD RENDERING (tab bar backgrounds, borders, etc.) // Rendered FIRST so backgrounds appear behind text @@ -5090,50 +6133,61 @@ impl Renderer { render_pass.set_bind_group(0, &self.quad_bind_group, &[]); render_pass.draw(0..4, 0..self.quads.len() as u32); } - + // Draw bg + glyph indices (tab bar text uses legacy vertex rendering) // Rendered AFTER quads so text appears on top of backgrounds render_pass.set_pipeline(&self.glyph_pipeline); render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); - render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32); + render_pass.set_index_buffer( + self.index_buffer.slice(..), + wgpu::IndexFormat::Uint32, + ); render_pass.draw_indexed(0..total_index_count as u32, 0, 0..1); // ═══════════════════════════════════════════════════════════════════ // INSTANCED CELL RENDERING (Like Kitty's per-window VAO approach) // Each pane has its own bind group with its own buffers. // Data was already uploaded before the render pass started. - // + // // Kitty-style viewport approach: set viewport to pane area so shader // can work in pure NDC space (-1 to +1), avoiding floating-point // precision issues that cause wobbly/misaligned text. // ═══════════════════════════════════════════════════════════════════ for pane_data in &pane_render_list { let instance_count = pane_data.cols * pane_data.rows; - + // Get this pane's bind group (data already uploaded) - if let Some(pane_res) = self.pane_resources.get(&pane_data.pane_id) { + if let Some(pane_res) = + self.pane_resources.get(&pane_data.pane_id) + { // Set viewport to this pane's area (Kitty-style) let (vp_x, vp_y, vp_w, vp_h) = pane_data.viewport; render_pass.set_viewport(vp_x, vp_y, vp_w, vp_h, 0.0, 1.0); - + // Set scissor rect to clip rendering to pane bounds - let scissor_x = (vp_x.round().max(0.0) as u32).min(self.width); - let scissor_y = (vp_y.round().max(0.0) as u32).min(self.height); - let scissor_w = (vp_w.round() as u32).min(self.width.saturating_sub(scissor_x)); - let scissor_h = (vp_h.round() as u32).min(self.height.saturating_sub(scissor_y)); - + let scissor_x = + (vp_x.round().max(0.0) as u32).min(self.width); + let scissor_y = + (vp_y.round().max(0.0) as u32).min(self.height); + let scissor_w = (vp_w.round() as u32) + .min(self.width.saturating_sub(scissor_x)); + let scissor_h = (vp_h.round() as u32) + .min(self.height.saturating_sub(scissor_y)); + if scissor_w == 0 || scissor_h == 0 { continue; } - render_pass.set_scissor_rect(scissor_x, scissor_y, scissor_w, scissor_h); - + render_pass.set_scissor_rect( + scissor_x, scissor_y, scissor_w, scissor_h, + ); + // Draw cell backgrounds render_pass.set_pipeline(&self.cell_bg_pipeline); render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); // Atlas (shared) render_pass.set_bind_group(1, &pane_res.bind_group, &[]); // This pane's data render_pass.draw(0..4, 0..instance_count); // 4 vertices per quad, N instances - + // Draw cell glyphs render_pass.set_pipeline(&self.cell_glyph_pipeline); render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); // Atlas (shared) @@ -5141,49 +6195,70 @@ impl Renderer { render_pass.draw(0..4, 0..instance_count); // 4 vertices per quad, N instances } } - + // Restore full-screen viewport and scissor for remaining rendering (statusline, overlays) - render_pass.set_viewport(0.0, 0.0, self.width as f32, self.height as f32, 0.0, 1.0); + render_pass.set_viewport( + 0.0, + 0.0, + self.width as f32, + self.height as f32, + 0.0, + 1.0, + ); render_pass.set_scissor_rect(0, 0, self.width, self.height); - + // ═══════════════════════════════════════════════════════════════════ // STATUSLINE RENDERING (dedicated shader) // Render the statusline using its own pipelines // ═══════════════════════════════════════════════════════════════════ if statusline_cols > 0 { let instance_count = statusline_cols as u32; - + // Draw statusline backgrounds render_pass.set_pipeline(&self.statusline_bg_pipeline); render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); // Atlas render_pass.set_bind_group(1, &self.statusline_bind_group, &[]); // Statusline data render_pass.draw(0..4, 0..instance_count); - + // Draw statusline glyphs render_pass.set_pipeline(&self.statusline_glyph_pipeline); render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); // Atlas render_pass.set_bind_group(1, &self.statusline_bind_group, &[]); // Statusline data render_pass.draw(0..4, 0..instance_count); } - + // ═══════════════════════════════════════════════════════════════════ // ADD DIM OVERLAYS FOR INACTIVE PANES // ═══════════════════════════════════════════════════════════════════ for pane_data in &pane_render_list { if let Some((x, y, w, h, color)) = pane_data.dim_overlay { - self.overlay_quads.push(Quad { x, y, width: w, height: h, color }); + self.overlay_quads.push(Quad { + x, + y, + width: w, + height: h, + color, + }); } } - + // ═══════════════════════════════════════════════════════════════════ // INSTANCED OVERLAY QUAD RENDERING (dimming overlays, borders) // Rendered last so overlays appear on top of everything // ═══════════════════════════════════════════════════════════════════ if !self.overlay_quads.is_empty() { // Upload overlay quads to the SEPARATE overlay buffer to avoid overwriting tab bar quads - self.queue.write_buffer(&self.overlay_quad_buffer, 0, bytemuck::cast_slice(&self.overlay_quads)); + self.queue.write_buffer( + &self.overlay_quad_buffer, + 0, + bytemuck::cast_slice(&self.overlay_quads), + ); render_pass.set_pipeline(&self.quad_pipeline); - render_pass.set_bind_group(0, &self.overlay_quad_bind_group, &[]); + render_pass.set_bind_group( + 0, + &self.overlay_quad_bind_group, + &[], + ); render_pass.draw(0..4, 0..self.overlay_quads.len() as u32); } } @@ -5194,28 +6269,38 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════════ for (pane_id, image_id, offset, scissor) in &image_renders { // Check if we have the GPU texture for this image - if let Some(gpu_image) = self.image_renderer.get(*pane_id, image_id) { + if let Some(gpu_image) = self.image_renderer.get(*pane_id, image_id) + { // Create a render pass for this image (load existing content) - let mut image_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Image Pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Load, // Preserve existing content - store: wgpu::StoreOp::Store, - }, - depth_slice: None, - })], - depth_stencil_attachment: None, - occlusion_query_set: None, - timestamp_writes: None, - multiview_mask: None, - }); + let mut image_pass = + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Image Pass"), + color_attachments: &[Some( + wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, // Preserve existing content + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + }, + )], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + multiview_mask: None, + }); - image_pass.set_scissor_rect(scissor[0], scissor[1], scissor[2], scissor[3]); + image_pass.set_scissor_rect( + scissor[0], scissor[1], scissor[2], scissor[3], + ); image_pass.set_pipeline(&self.image_pipeline); - image_pass.set_bind_group(0, self.image_renderer.uniform_bind_group(), &[*offset as u32]); + image_pass.set_bind_group( + 0, + self.image_renderer.uniform_bind_group(), + &[*offset as u32], + ); image_pass.set_bind_group(1, &gpu_image.bind_group, &[]); image_pass.draw(0..4, 0..1); // Triangle strip quad } @@ -5234,22 +6319,25 @@ impl Renderer { ); // Render pass for this edge glow (load existing content) - let mut glow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Edge Glow Pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Load, // Preserve existing content - store: wgpu::StoreOp::Store, - }, - depth_slice: None, - })], - depth_stencil_attachment: None, - occlusion_query_set: None, - timestamp_writes: None, - multiview_mask: None, - }); + let mut glow_pass = + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Edge Glow Pass"), + color_attachments: &[Some( + wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, // Preserve existing content + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + }, + )], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + multiview_mask: None, + }); glow_pass.set_pipeline(&self.edge_glow_pipeline); glow_pass.set_bind_group(0, &self.edge_glow_bind_group, &[]); @@ -5270,19 +6358,33 @@ impl Renderer { /// Sync images from terminal's image storage to GPU. /// Uploads new/changed images. /// Also updates animation frames. - pub fn sync_images(&mut self, pane_id: crate::gpu_types::PaneId, storage: &mut ImageStorage) { - self.image_renderer.sync_images(&self.device, &self.queue, pane_id, storage); + pub fn sync_images( + &mut self, + pane_id: crate::gpu_types::PaneId, + storage: &mut ImageStorage, + ) { + self.image_renderer.sync_images( + &self.device, + &self.queue, + pane_id, + storage, + ); } /// Remove images from the GPU that are not present in any of the provided storages. - pub fn gc_images(&mut self, storages: &[(crate::gpu_types::PaneId, &ImageStorage)]) { + pub fn gc_images( + &mut self, + storages: &[(crate::gpu_types::PaneId, &ImageStorage)], + ) { let mut active_ids = std::collections::HashSet::new(); for (pane_id, storage) in storages { for id in storage.images().keys() { active_ids.insert((*pane_id, *id)); } + for placement in storage.placements() { + active_ids.insert((*pane_id, placement.image_id)); + } } self.image_renderer.gc_images(&active_ids); } - } diff --git a/src/simd_utf8.rs b/src/simd_utf8.rs index 38c01c9..60e63ea 100644 --- a/src/simd_utf8.rs +++ b/src/simd_utf8.rs @@ -41,7 +41,7 @@ impl SimdCapabilities { has_avx2: is_x86_feature_detected!("avx2"), } } - + #[cfg(not(any(target_arch = "x86_64", target_arch = "x86")))] pub fn detect() -> Self { Self { @@ -53,7 +53,8 @@ impl SimdCapabilities { } // Global cached capabilities (initialized on first use) -static SIMD_CAPS: std::sync::OnceLock = std::sync::OnceLock::new(); +static SIMD_CAPS: std::sync::OnceLock = + std::sync::OnceLock::new(); /// Get cached SIMD capabilities. pub fn simd_caps() -> &'static SimdCapabilities { @@ -93,7 +94,7 @@ unsafe fn find_byte_sse(haystack: &[u8], needle: u8) -> Option { let mut offset = 0; let len = haystack.len(); let ptr = haystack.as_ptr(); - + while offset + 16 <= len { let chunk = _mm_loadu_si128(ptr.add(offset) as *const __m128i); let cmp = _mm_cmpeq_epi8(chunk, needle_vec); @@ -103,7 +104,7 @@ unsafe fn find_byte_sse(haystack: &[u8], needle: u8) -> Option { } offset += 16; } - + for i in offset..len { if *ptr.add(i) == needle { return Some(i); @@ -120,7 +121,7 @@ unsafe fn find_byte_avx2(haystack: &[u8], needle: u8) -> Option { let mut offset = 0; let len = haystack.len(); let ptr = haystack.as_ptr(); - + while offset + 32 <= len { let chunk = _mm256_loadu_si256(ptr.add(offset) as *const __m256i); let cmp = _mm256_cmpeq_epi8(chunk, needle_vec); @@ -130,7 +131,7 @@ unsafe fn find_byte_avx2(haystack: &[u8], needle: u8) -> Option { } offset += 32; } - + // Handle remainder with SSE while offset + 16 <= len { let chunk = _mm_loadu_si128(ptr.add(offset) as *const __m128i); @@ -142,7 +143,7 @@ unsafe fn find_byte_avx2(haystack: &[u8], needle: u8) -> Option { } offset += 16; } - + for i in offset..len { if *ptr.add(i) == needle { return Some(i); @@ -153,10 +154,14 @@ unsafe fn find_byte_avx2(haystack: &[u8], needle: u8) -> Option { /// Find the first occurrence of either byte `a` or byte `b` in the haystack. /// Returns the index of the first match, or None if not found. -/// +/// /// This is equivalent to Kitty's `find_either_of_two_bytes` function. #[inline] -pub fn find_either_of_two_bytes(haystack: &[u8], a: u8, b: u8) -> Option { +pub fn find_either_of_two_bytes( + haystack: &[u8], + a: u8, + b: u8, +) -> Option { #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] { let caps = simd_caps(); @@ -174,21 +179,29 @@ pub fn find_either_of_two_bytes(haystack: &[u8], a: u8, b: u8) -> Option /// Scalar fallback for find_either_of_two_bytes. #[inline] -fn find_either_of_two_bytes_scalar(haystack: &[u8], a: u8, b: u8) -> Option { +fn find_either_of_two_bytes_scalar( + haystack: &[u8], + a: u8, + b: u8, +) -> Option { haystack.iter().position(|&byte| byte == a || byte == b) } /// SSE implementation of find_either_of_two_bytes. #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] #[target_feature(enable = "sse2", enable = "sse4.1")] -unsafe fn find_either_of_two_bytes_sse(haystack: &[u8], a: u8, b: u8) -> Option { +unsafe fn find_either_of_two_bytes_sse( + haystack: &[u8], + a: u8, + b: u8, +) -> Option { let a_vec = _mm_set1_epi8(a as i8); let b_vec = _mm_set1_epi8(b as i8); - + let mut offset = 0; let len = haystack.len(); let ptr = haystack.as_ptr(); - + // Process 16 bytes at a time while offset + 16 <= len { let chunk = _mm_loadu_si128(ptr.add(offset) as *const __m128i); @@ -201,7 +214,7 @@ unsafe fn find_either_of_two_bytes_sse(haystack: &[u8], a: u8, b: u8) -> Option< } offset += 16; } - + // Handle remainder with scalar for i in offset..len { let byte = *ptr.add(i); @@ -215,14 +228,18 @@ unsafe fn find_either_of_two_bytes_sse(haystack: &[u8], a: u8, b: u8) -> Option< /// AVX2 implementation of find_either_of_two_bytes. #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] #[target_feature(enable = "avx2")] -unsafe fn find_either_of_two_bytes_avx2(haystack: &[u8], a: u8, b: u8) -> Option { +unsafe fn find_either_of_two_bytes_avx2( + haystack: &[u8], + a: u8, + b: u8, +) -> Option { let a_vec = _mm256_set1_epi8(a as i8); let b_vec = _mm256_set1_epi8(b as i8); - + let mut offset = 0; let len = haystack.len(); let ptr = haystack.as_ptr(); - + // Process 32 bytes at a time while offset + 32 <= len { let chunk = _mm256_loadu_si256(ptr.add(offset) as *const __m256i); @@ -235,7 +252,7 @@ unsafe fn find_either_of_two_bytes_avx2(haystack: &[u8], a: u8, b: u8) -> Option } offset += 32; } - + // Handle remainder with SSE (16 bytes) while offset + 16 <= len { let chunk = _mm_loadu_si128(ptr.add(offset) as *const __m128i); @@ -250,7 +267,7 @@ unsafe fn find_either_of_two_bytes_avx2(haystack: &[u8], a: u8, b: u8) -> Option } offset += 16; } - + // Handle remainder with scalar for i in offset..len { let byte = *ptr.add(i); @@ -289,7 +306,9 @@ pub fn find_c0_control(haystack: &[u8]) -> Option { /// Scalar fallback for find_c0_control. #[inline] fn find_c0_control_scalar(haystack: &[u8]) -> Option { - haystack.iter().position(|&byte| byte < 0x20 || byte == 0x7F) + haystack + .iter() + .position(|&byte| byte < 0x20 || byte == 0x7F) } /// SSE implementation of find_c0_control. @@ -303,11 +322,11 @@ unsafe fn find_c0_control_sse(haystack: &[u8]) -> Option { let threshold = _mm_set1_epi8(-96i8); // 0x20 - 0x80 = -96 in signed let bias = _mm_set1_epi8(-128i8); // 0x80 as i8 let del = _mm_set1_epi8(0x7F); - + let mut offset = 0; let len = haystack.len(); let ptr = haystack.as_ptr(); - + while offset + 16 <= len { let chunk = _mm_loadu_si128(ptr.add(offset) as *const __m128i); // Convert to signed range for comparison: chunk_signed = chunk + 0x80 (wrapping) @@ -325,7 +344,7 @@ unsafe fn find_c0_control_sse(haystack: &[u8]) -> Option { } offset += 16; } - + // Handle remainder for i in offset..len { let byte = *ptr.add(i); @@ -343,11 +362,11 @@ unsafe fn find_c0_control_avx2(haystack: &[u8]) -> Option { let threshold = _mm256_set1_epi8(-96i8); // 0x20 - 0x80 = -96 in signed let bias = _mm256_set1_epi8(-128i8); // 0x80 as i8 let del = _mm256_set1_epi8(0x7F); - + let mut offset = 0; let len = haystack.len(); let ptr = haystack.as_ptr(); - + while offset + 32 <= len { let chunk = _mm256_loadu_si256(ptr.add(offset) as *const __m256i); let chunk_signed = _mm256_add_epi8(chunk, bias); @@ -360,7 +379,7 @@ unsafe fn find_c0_control_avx2(haystack: &[u8]) -> Option { } offset += 32; } - + // Handle remainder with SSE path while offset + 16 <= len { let chunk = _mm_loadu_si128(ptr.add(offset) as *const __m128i); @@ -377,7 +396,7 @@ unsafe fn find_c0_control_avx2(haystack: &[u8]) -> Option { } offset += 16; } - + // Handle remainder for i in offset..len { let byte = *ptr.add(i); @@ -417,7 +436,11 @@ pub fn xor_mask(data: &mut [u8], mask: [u8; 4], start_offset: usize) -> usize { /// Scalar fallback for xor_mask. #[inline] -fn xor_mask_scalar(data: &mut [u8], mask: [u8; 4], start_offset: usize) -> usize { +fn xor_mask_scalar( + data: &mut [u8], + mask: [u8; 4], + start_offset: usize, +) -> usize { let mut offset = start_offset; for byte in data.iter_mut() { *byte ^= mask[offset & 3]; @@ -429,27 +452,43 @@ fn xor_mask_scalar(data: &mut [u8], mask: [u8; 4], start_offset: usize) -> usize /// SSE implementation of xor_mask. #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] #[target_feature(enable = "sse2")] -unsafe fn xor_mask_sse(data: &mut [u8], mask: [u8; 4], start_offset: usize) -> usize { +unsafe fn xor_mask_sse( + data: &mut [u8], + mask: [u8; 4], + start_offset: usize, +) -> usize { let len = data.len(); let ptr = data.as_mut_ptr(); let mut pos = 0; let mut offset = start_offset; - + // Handle unaligned prefix to get to mask-aligned position while pos < len && (offset & 3) != 0 { *ptr.add(pos) ^= mask[offset & 3]; pos += 1; offset += 1; } - + // Create 16-byte mask vector (repeat 4-byte mask 4 times) let mask_vec = _mm_set_epi8( - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, ); - + // Process 16 bytes at a time while pos + 16 <= len { let chunk = _mm_loadu_si128(ptr.add(pos) as *const __m128i); @@ -458,45 +497,73 @@ unsafe fn xor_mask_sse(data: &mut [u8], mask: [u8; 4], start_offset: usize) -> u pos += 16; offset += 16; } - + // Handle remainder while pos < len { *ptr.add(pos) ^= mask[offset & 3]; pos += 1; offset += 1; } - + offset & 3 } /// AVX2 implementation of xor_mask. #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] #[target_feature(enable = "avx2")] -unsafe fn xor_mask_avx2(data: &mut [u8], mask: [u8; 4], start_offset: usize) -> usize { +unsafe fn xor_mask_avx2( + data: &mut [u8], + mask: [u8; 4], + start_offset: usize, +) -> usize { let len = data.len(); let ptr = data.as_mut_ptr(); let mut pos = 0; let mut offset = start_offset; - + // Handle unaligned prefix while pos < len && (offset & 3) != 0 { *ptr.add(pos) ^= mask[offset & 3]; pos += 1; offset += 1; } - + // Create 32-byte mask vector (repeat 4-byte mask 8 times) let mask_vec = _mm256_set_epi8( - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, ); - + // Process 32 bytes at a time while pos + 32 <= len { let chunk = _mm256_loadu_si256(ptr.add(pos) as *const __m256i); @@ -505,14 +572,26 @@ unsafe fn xor_mask_avx2(data: &mut [u8], mask: [u8; 4], start_offset: usize) -> pos += 32; offset += 32; } - + // Process 16 bytes if remaining while pos + 16 <= len { let mask_vec_128 = _mm_set_epi8( - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, - mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, + mask[3] as i8, + mask[2] as i8, + mask[1] as i8, + mask[0] as i8, ); let chunk = _mm_loadu_si128(ptr.add(pos) as *const __m128i); let xored = _mm_xor_si128(chunk, mask_vec_128); @@ -520,14 +599,14 @@ unsafe fn xor_mask_avx2(data: &mut [u8], mask: [u8; 4], start_offset: usize) -> pos += 16; offset += 16; } - + // Handle remainder while pos < len { *ptr.add(pos) ^= mask[offset & 3]; pos += 1; offset += 1; } - + offset & 3 } @@ -549,20 +628,23 @@ const UTF8_REJECT: u8 = 12; /// UTF-8 state transition table (Bjoern Hoehrmann's DFA). static UTF8_DECODE_TABLE: [u8; 364] = [ // Character class lookup (0-255) - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, - 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, - 8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2, - 10,3,3,3,3,3,3,3,3,3,3,3,3,4,3,3, 11,6,6,6,5,8,8,8,8,8,8,8,8,8,8,8, - // State transition table - 0,12,24,36,60,96,84,12,12,12,48,72, 12,12,12,12,12,12,12,12,12,12,12,12, - 12, 0,12,12,12,12,12, 0,12, 0,12,12, 12,24,12,12,12,12,12,24,12,24,12,12, - 12,12,12,12,12,12,12,24,12,12,12,12, 12,24,12,12,12,12,12,12,12,24,12,12, - 12,12,12,12,12,12,12,36,12,36,12,12, 12,36,12,12,12,12,12,36,12,36,12,12, - 12,36,12,12,12,12,12,12,12,12,12,12, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 9, 9, 9, 9, 9, 9, + 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 8, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 10, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 3, 3, 11, 6, 6, 6, 5, 8, 8, 8, 8, 8, + 8, 8, 8, 8, 8, 8, // State transition table + 0, 12, 24, 36, 60, 96, 84, 12, 12, 12, 48, 72, 12, 12, 12, 12, 12, 12, 12, + 12, 12, 12, 12, 12, 12, 0, 12, 12, 12, 12, 12, 0, 12, 0, 12, 12, 12, 24, + 12, 12, 12, 12, 12, 24, 12, 24, 12, 12, 12, 12, 12, 12, 12, 12, 12, 24, 12, + 12, 12, 12, 12, 24, 12, 12, 12, 12, 12, 12, 12, 24, 12, 12, 12, 12, 12, 12, + 12, 12, 12, 36, 12, 36, 12, 12, 12, 36, 12, 12, 12, 12, 12, 36, 12, 36, 12, + 12, 12, 36, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, ]; /// Decode a single UTF-8 byte using DFA. @@ -579,7 +661,7 @@ fn decode_utf8_byte(state: &mut u8, codep: &mut u32, byte: u8) -> u8 { } /// SIMD UTF-8 decoder. -/// +/// /// Processes input in 16-byte (SSE) or 32-byte (AVX2) chunks, using SIMD for: /// - Fast ESC (0x1B) detection /// - Pure ASCII fast path @@ -600,17 +682,21 @@ impl SimdUtf8Decoder { /// Decode UTF-8 bytes until ESC is found. /// Returns (bytes_consumed, found_esc). - /// + /// /// Output codepoints are written to the output buffer as u32 values. /// Uses AVX2 (32 bytes at a time) if available, otherwise SSE (16 bytes). #[inline] - pub fn decode_to_esc(&mut self, src: &[u8], output: &mut Vec) -> (usize, bool) { + pub fn decode_to_esc( + &mut self, + src: &[u8], + output: &mut Vec, + ) -> (usize, bool) { output.clear(); if src.is_empty() { return (0, false); } output.reserve(src.len()); - + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] { let caps = simd_caps(); @@ -621,18 +707,22 @@ impl SimdUtf8Decoder { return unsafe { self.decode_to_esc_simd(src, output) }; } } - + // Fallback to scalar self.decode_to_esc_scalar(src, output) } /// Scalar fallback decoder. - fn decode_to_esc_scalar(&mut self, src: &[u8], output: &mut Vec) -> (usize, bool) { + fn decode_to_esc_scalar( + &mut self, + src: &[u8], + output: &mut Vec, + ) -> (usize, bool) { let mut pos = 0; - + while pos < src.len() { let byte = src[pos]; - + if byte == 0x1B { if self.state.cur != UTF8_ACCEPT { output.push(0xFFFD); @@ -640,11 +730,15 @@ impl SimdUtf8Decoder { } return (pos + 1, true); } - + pos += 1; self.state.prev = self.state.cur; - - match decode_utf8_byte(&mut self.state.cur, &mut self.state.codep, byte) { + + match decode_utf8_byte( + &mut self.state.cur, + &mut self.state.codep, + byte, + ) { UTF8_ACCEPT => { output.push(self.state.codep); } @@ -659,7 +753,7 @@ impl SimdUtf8Decoder { _ => {} } } - + (pos, false) } @@ -667,9 +761,13 @@ impl SimdUtf8Decoder { /// Based on Kitty's simd-string-impl.h #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] #[target_feature(enable = "sse2", enable = "ssse3", enable = "sse4.1")] - unsafe fn decode_to_esc_simd(&mut self, src: &[u8], output: &mut Vec) -> (usize, bool) { + unsafe fn decode_to_esc_simd( + &mut self, + src: &[u8], + output: &mut Vec, + ) -> (usize, bool) { let mut num_consumed: usize = 0; - + // Finish any trailing sequence from previous call if self.state.cur != UTF8_ACCEPT { num_consumed = self.scalar_decode_to_accept(src, output); @@ -677,7 +775,7 @@ impl SimdUtf8Decoder { return (num_consumed, false); } } - + // SIMD constants let esc_vec = _mm_set1_epi8(0x1Bu8 as i8); let zero = _mm_setzero_si128(); @@ -685,34 +783,41 @@ impl SimdUtf8Decoder { let two = _mm_set1_epi8(2); let three = _mm_set1_epi8(3); let four = _mm_set1_epi8(4); - let numbered = _mm_set_epi8(15,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0); - + let numbered = + _mm_set_epi8(15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0); + let limit = src.as_ptr().add(src.len()); let mut p = src.as_ptr().add(num_consumed); let mut sentinel_found = false; - + while p < limit && !sentinel_found { let remaining = limit.offset_from(p) as usize; let mut chunk_src_sz = remaining.min(16); - + // Load chunk (potentially partial) let mut vec = if chunk_src_sz == 16 { _mm_loadu_si128(p as *const __m128i) } else { // Partial load - zero-extend let mut buf = [0u8; 16]; - std::ptr::copy_nonoverlapping(p, buf.as_mut_ptr(), chunk_src_sz); + std::ptr::copy_nonoverlapping( + p, + buf.as_mut_ptr(), + chunk_src_sz, + ); _mm_loadu_si128(buf.as_ptr() as *const __m128i) }; - + let start_of_current_chunk = p; p = p.add(chunk_src_sz); - + // Check for ESC let esc_cmp = _mm_cmpeq_epi8(vec, esc_vec); let num_bytes_to_first_esc = Self::bytes_to_first_match(esc_cmp); - - if num_bytes_to_first_esc >= 0 && (num_bytes_to_first_esc as usize) < chunk_src_sz { + + if num_bytes_to_first_esc >= 0 + && (num_bytes_to_first_esc as usize) < chunk_src_sz + { sentinel_found = true; chunk_src_sz = num_bytes_to_first_esc as usize; num_consumed += chunk_src_sz + 1; // +1 for ESC @@ -722,187 +827,258 @@ impl SimdUtf8Decoder { } else { num_consumed += chunk_src_sz; } - + // Zero out bytes past chunk_src_sz if chunk_src_sz < 16 { vec = Self::zero_last_n_bytes(vec, 16 - chunk_src_sz); } - + // Check for trailing incomplete sequence let mut num_trailing_bytes = 0usize; let mut check_for_trailing = !sentinel_found; - + 'classification: loop { // Check if pure ASCII (no high bits set) let ascii_mask = _mm_movemask_epi8(vec); if ascii_mask == 0 { // Pure ASCII - fast output Self::output_plain_ascii(vec, chunk_src_sz, output); - + // Handle trailing bytes if num_trailing_bytes > 0 && p < limit { p = p.sub(num_trailing_bytes); } break 'classification; } - + // Classify bytes by whether they start 2, 3, or 4 byte sequences let state_80 = _mm_set1_epi8(0x80u8 as i8); let vec_signed = _mm_add_epi8(vec, state_80); - + // state now has 0x80 on all bytes let mut state = state_80; - + // 2-byte sequence starters (0xC0-0xDF, but 0xC0-0xC1 invalid) - let c2_start = _mm_cmplt_epi8(_mm_set1_epi8((0xC0 - 1 - 0x80) as i8), vec_signed); - state = _mm_blendv_epi8(state, _mm_set1_epi8(0xC2u8 as i8), c2_start); - + let c2_start = _mm_cmplt_epi8( + _mm_set1_epi8((0xC0 - 1 - 0x80) as i8), + vec_signed, + ); + state = _mm_blendv_epi8( + state, + _mm_set1_epi8(0xC2u8 as i8), + c2_start, + ); + // 3-byte sequence starters (0xE0-0xEF) - let e3_start = _mm_cmplt_epi8(_mm_set1_epi8((0xE0 - 1 - 0x80) as i8), vec_signed); - state = _mm_blendv_epi8(state, _mm_set1_epi8(0xE3u8 as i8), e3_start); - + let e3_start = _mm_cmplt_epi8( + _mm_set1_epi8((0xE0 - 1 - 0x80) as i8), + vec_signed, + ); + state = _mm_blendv_epi8( + state, + _mm_set1_epi8(0xE3u8 as i8), + e3_start, + ); + // 4-byte sequence starters (0xF0-0xFF, but 0xF5+ invalid) - let f4_start = _mm_cmplt_epi8(_mm_set1_epi8((0xF0 - 1 - 0x80) as i8), vec_signed); - state = _mm_blendv_epi8(state, _mm_set1_epi8(0xF4u8 as i8), f4_start); - + let f4_start = _mm_cmplt_epi8( + _mm_set1_epi8((0xF0 - 1 - 0x80) as i8), + vec_signed, + ); + state = _mm_blendv_epi8( + state, + _mm_set1_epi8(0xF4u8 as i8), + f4_start, + ); + // mask = upper 5 bits of state (indicates byte type) let mask = _mm_and_si128(state, _mm_set1_epi8(0xF8u8 as i8)); // count = lower 3 bits of state (sequence length) let count = _mm_and_si128(state, _mm_set1_epi8(0x07)); - + // Propagate counts: count[i] = remaining bytes in sequence at position i // count_subs1[i] = count[i] - 1, saturating let count_subs1 = _mm_subs_epu8(count, one); // counts[i] = count[i] + count_subs1[i-1] - let mut counts = _mm_add_epi8(count, _mm_srli_si128(count_subs1, 1)); + let mut counts = + _mm_add_epi8(count, _mm_srli_si128(count_subs1, 1)); // counts[i] += counts_subs2[i-2] (for 3 and 4 byte sequences) - counts = _mm_add_epi8(counts, _mm_srli_si128(_mm_subs_epu8(counts, two), 2)); - + counts = _mm_add_epi8( + counts, + _mm_srli_si128(_mm_subs_epu8(counts, two), 2), + ); + // Check for trailing incomplete sequence if check_for_trailing { let last_byte_idx = _mm_set1_epi8((chunk_src_sz - 1) as i8); let at_last_byte = _mm_cmpeq_epi8(numbered, last_byte_idx); let counts_at_last = _mm_and_si128(counts, at_last_byte); let has_trailing = _mm_cmplt_epi8(one, counts_at_last); - + if _mm_testz_si128(has_trailing, has_trailing) == 0 { // We have a trailing incomplete sequence check_for_trailing = false; - - let last_byte = *start_of_current_chunk.add(chunk_src_sz - 1); + + let last_byte = + *start_of_current_chunk.add(chunk_src_sz - 1); if last_byte >= 0xC0 { num_trailing_bytes = 1; - } else if chunk_src_sz > 1 && *start_of_current_chunk.add(chunk_src_sz - 2) >= 0xE0 { + } else if chunk_src_sz > 1 + && *start_of_current_chunk.add(chunk_src_sz - 2) + >= 0xE0 + { num_trailing_bytes = 2; - } else if chunk_src_sz > 2 && *start_of_current_chunk.add(chunk_src_sz - 3) >= 0xF0 { + } else if chunk_src_sz > 2 + && *start_of_current_chunk.add(chunk_src_sz - 3) + >= 0xF0 + { num_trailing_bytes = 3; } - + chunk_src_sz -= num_trailing_bytes; num_consumed -= num_trailing_bytes; - + if chunk_src_sz == 0 { // Fall back to scalar for trailing bytes let slice = std::slice::from_raw_parts( start_of_current_chunk, - num_trailing_bytes + num_trailing_bytes, ); self.scalar_decode_all(slice, output); num_consumed += num_trailing_bytes; break 'classification; } - + vec = Self::zero_last_n_bytes(vec, 16 - chunk_src_sz); continue 'classification; } } - + // Validation: ASCII bytes should have counts[i] == 0 let count_gt_zero = _mm_cmpgt_epi8(counts, zero); let count_mask = _mm_movemask_epi8(count_gt_zero); if ascii_mask != count_mask { // Invalid UTF-8 - fall back to scalar let slice = std::slice::from_raw_parts( - start_of_current_chunk, - chunk_src_sz + num_trailing_bytes + start_of_current_chunk, + chunk_src_sz + num_trailing_bytes, ); self.scalar_decode_all(slice, output); num_consumed += num_trailing_bytes; break 'classification; } - + // Build chunk_is_invalid vector let mut chunk_invalid = zero; - + // Validate 2-byte starters: 0xC0, 0xC1 are invalid - chunk_invalid = _mm_or_si128(chunk_invalid, - _mm_and_si128(c2_start, _mm_cmplt_epi8(vec, _mm_set1_epi8(0xC2u8 as i8)))); - + chunk_invalid = _mm_or_si128( + chunk_invalid, + _mm_and_si128( + c2_start, + _mm_cmplt_epi8(vec, _mm_set1_epi8(0xC2u8 as i8)), + ), + ); + // Validate 4-byte starters: 0xF5+ are invalid - chunk_invalid = _mm_or_si128(chunk_invalid, - _mm_and_si128(f4_start, _mm_cmpgt_epi8(vec, _mm_set1_epi8(0xF4u8 as i8)))); - + chunk_invalid = _mm_or_si128( + chunk_invalid, + _mm_and_si128( + f4_start, + _mm_cmpgt_epi8(vec, _mm_set1_epi8(0xF4u8 as i8)), + ), + ); + // Validate continuation bytes don't have starter bytes let cont_has_starter = _mm_andnot_si128( _mm_cmplt_epi8(vec, _mm_set1_epi8(0xC0u8 as i8)), - _mm_cmpgt_epi8(counts, count) + _mm_cmpgt_epi8(counts, count), ); chunk_invalid = _mm_or_si128(chunk_invalid, cont_has_starter); - + // Validate E0 second bytes (must be >= 0xA0) - let e0_starters = _mm_cmpeq_epi8(vec, _mm_set1_epi8(0xE0u8 as i8)); + let e0_starters = + _mm_cmpeq_epi8(vec, _mm_set1_epi8(0xE0u8 as i8)); let e0_followers = _mm_srli_si128(e0_starters, 1); - let e0_invalid = _mm_and_si128(e0_followers, - _mm_cmplt_epi8(_mm_and_si128(e0_followers, vec), _mm_set1_epi8(0xA0u8 as i8))); + let e0_invalid = _mm_and_si128( + e0_followers, + _mm_cmplt_epi8( + _mm_and_si128(e0_followers, vec), + _mm_set1_epi8(0xA0u8 as i8), + ), + ); chunk_invalid = _mm_or_si128(chunk_invalid, e0_invalid); - + // Validate ED second bytes (must be < 0xA0, i.e. <= 0x9F) - let ed_starters = _mm_cmpeq_epi8(vec, _mm_set1_epi8(0xEDu8 as i8)); + let ed_starters = + _mm_cmpeq_epi8(vec, _mm_set1_epi8(0xEDu8 as i8)); let ed_followers = _mm_srli_si128(ed_starters, 1); - let ed_invalid = _mm_and_si128(ed_followers, - _mm_cmpgt_epi8(_mm_and_si128(ed_followers, vec), _mm_set1_epi8(0x9Fu8 as i8))); + let ed_invalid = _mm_and_si128( + ed_followers, + _mm_cmpgt_epi8( + _mm_and_si128(ed_followers, vec), + _mm_set1_epi8(0x9Fu8 as i8), + ), + ); chunk_invalid = _mm_or_si128(chunk_invalid, ed_invalid); - + // Validate F0 second bytes (must be >= 0x90) - let f0_starters = _mm_cmpeq_epi8(vec, _mm_set1_epi8(0xF0u8 as i8)); + let f0_starters = + _mm_cmpeq_epi8(vec, _mm_set1_epi8(0xF0u8 as i8)); let f0_followers = _mm_srli_si128(f0_starters, 1); - let f0_invalid = _mm_and_si128(f0_followers, - _mm_cmplt_epi8(_mm_and_si128(f0_followers, vec), _mm_set1_epi8(0x90u8 as i8))); + let f0_invalid = _mm_and_si128( + f0_followers, + _mm_cmplt_epi8( + _mm_and_si128(f0_followers, vec), + _mm_set1_epi8(0x90u8 as i8), + ), + ); chunk_invalid = _mm_or_si128(chunk_invalid, f0_invalid); - + // Validate F4 second bytes (must be < 0x90, i.e. <= 0x8F) - let f4_starters = _mm_cmpeq_epi8(vec, _mm_set1_epi8(0xF4u8 as i8)); + let f4_starters = + _mm_cmpeq_epi8(vec, _mm_set1_epi8(0xF4u8 as i8)); let f4_followers = _mm_srli_si128(f4_starters, 1); - let f4_invalid = _mm_and_si128(f4_followers, - _mm_cmpgt_epi8(_mm_and_si128(f4_followers, vec), _mm_set1_epi8(0x8Fu8 as i8))); + let f4_invalid = _mm_and_si128( + f4_followers, + _mm_cmpgt_epi8( + _mm_and_si128(f4_followers, vec), + _mm_set1_epi8(0x8Fu8 as i8), + ), + ); chunk_invalid = _mm_or_si128(chunk_invalid, f4_invalid); - + // If invalid, fall back to scalar if _mm_testz_si128(chunk_invalid, chunk_invalid) == 0 { let slice = std::slice::from_raw_parts( - start_of_current_chunk, - chunk_src_sz + num_trailing_bytes + start_of_current_chunk, + chunk_src_sz + num_trailing_bytes, ); self.scalar_decode_all(slice, output); num_consumed += num_trailing_bytes; break 'classification; } - + // Mask control bits to get payload only vec = _mm_andnot_si128(mask, vec); - + // Build output vectors - let vec_non_ascii = _mm_andnot_si128(_mm_cmpeq_epi8(counts, zero), vec); - + let vec_non_ascii = + _mm_andnot_si128(_mm_cmpeq_epi8(counts, zero), vec); + // output1: lowest byte of each codepoint // For count==1 positions: OR with shifted bits from count==2 position let count1_locs = _mm_cmpeq_epi8(counts, one); let shifted_6 = _mm_and_si128( _mm_slli_epi16(_mm_srli_si128(vec_non_ascii, 1), 6), - _mm_set1_epi8(0xC0u8 as i8) + _mm_set1_epi8(0xC0u8 as i8), ); - let output1 = _mm_blendv_epi8(vec, _mm_or_si128(vec, shifted_6), count1_locs); - + let output1 = _mm_blendv_epi8( + vec, + _mm_or_si128(vec, shifted_6), + count1_locs, + ); + // output2: middle byte (for 3 and 4 byte sequences) let count2_locs = _mm_cmpeq_epi8(counts, two); let count3_locs = _mm_cmpeq_epi8(counts, three); @@ -910,23 +1086,35 @@ impl SimdUtf8Decoder { output2 = _mm_srli_epi32(output2, 2); // bits 5,4,3,2 let shifted_4 = _mm_and_si128( _mm_set1_epi8(0xF0u8 as i8), - _mm_slli_epi16(_mm_srli_si128(_mm_and_si128(count3_locs, vec_non_ascii), 1), 4) + _mm_slli_epi16( + _mm_srli_si128( + _mm_and_si128(count3_locs, vec_non_ascii), + 1, + ), + 4, + ), ); output2 = _mm_or_si128(output2, shifted_4); output2 = _mm_and_si128(output2, count2_locs); output2 = _mm_srli_si128(output2, 1); - + // output3: highest byte (for 4 byte sequences) let count4_locs = _mm_cmpeq_epi8(counts, four); let mut output3 = _mm_and_si128(three, _mm_srli_epi32(vec, 4)); // bits 5,6 from count==3 let shifted_2 = _mm_and_si128( _mm_set1_epi8(0xFCu8 as i8), - _mm_slli_epi16(_mm_srli_si128(_mm_and_si128(count4_locs, vec_non_ascii), 1), 2) + _mm_slli_epi16( + _mm_srli_si128( + _mm_and_si128(count4_locs, vec_non_ascii), + 1, + ), + 2, + ), ); output3 = _mm_or_si128(output3, shifted_2); output3 = _mm_and_si128(output3, count3_locs); output3 = _mm_srli_si128(output3, 2); - + // Shuffle to remove continuation bytes // shifts = number of bytes to skip for each position let mut shifts = count_subs1; @@ -935,57 +1123,63 @@ impl SimdUtf8Decoder { shifts = _mm_add_epi8(shifts, _mm_srli_si128(shifts, 2)); shifts = _mm_add_epi8(shifts, _mm_srli_si128(shifts, 4)); shifts = _mm_add_epi8(shifts, _mm_srli_si128(shifts, 8)); - + // Zero shifts for discarded continuation bytes (where counts >= 2) shifts = _mm_and_si128(shifts, _mm_cmplt_epi8(counts, two)); - + // Move shifts leftward based on bit patterns // This is Kitty's move() macro shifts = Self::move_shifts_by_1(shifts); shifts = Self::move_shifts_by_2(shifts); shifts = Self::move_shifts_by_4(shifts); shifts = Self::move_shifts_by_8(shifts); - + // Add byte numbers to create shuffle mask shifts = _mm_add_epi8(shifts, numbered); - + // Shuffle the output vectors let output1 = _mm_shuffle_epi8(output1, shifts); let output2 = _mm_shuffle_epi8(output2, shifts); let output3 = _mm_shuffle_epi8(output3, shifts); - + // Count discarded bytes to get codepoint count let num_discarded = Self::sum_bytes(count_subs1); let num_codepoints = chunk_src_sz - num_discarded; - + // Output unicode codepoints - Self::output_unicode(output1, output2, output3, num_codepoints, output); - + Self::output_unicode( + output1, + output2, + output3, + num_codepoints, + output, + ); + // Handle trailing bytes if num_trailing_bytes > 0 && p < limit { p = p.sub(num_trailing_bytes); } - + break 'classification; } } - + (num_consumed, sentinel_found) } - + /// move() macro from Kitty: move shifts leftward based on bit pattern /// move(shifts, one_byte, 1) #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] #[target_feature(enable = "sse2", enable = "ssse3", enable = "sse4.1")] #[inline] unsafe fn move_shifts_by_1(shifts: __m128i) -> __m128i { - // blendv_epi8(shifts, shift_left_by_one_byte(shifts), + // blendv_epi8(shifts, shift_left_by_one_byte(shifts), // shift_left_by_one_byte(shift_left_by_bits16(shifts, 7))) let selector = _mm_slli_si128(_mm_slli_epi16(shifts, 7), 1); let shifted = _mm_slli_si128(shifts, 1); _mm_blendv_epi8(shifts, shifted, selector) } - + /// move(shifts, two_bytes, 2) #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] #[target_feature(enable = "sse2", enable = "ssse3", enable = "sse4.1")] @@ -995,7 +1189,7 @@ impl SimdUtf8Decoder { let shifted = _mm_slli_si128(shifts, 2); _mm_blendv_epi8(shifts, shifted, selector) } - + /// move(shifts, four_bytes, 3) #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] #[target_feature(enable = "sse2", enable = "ssse3", enable = "sse4.1")] @@ -1005,7 +1199,7 @@ impl SimdUtf8Decoder { let shifted = _mm_slli_si128(shifts, 4); _mm_blendv_epi8(shifts, shifted, selector) } - + /// move(shifts, eight_bytes, 4) #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] #[target_feature(enable = "sse2", enable = "ssse3", enable = "sse4.1")] @@ -1015,7 +1209,7 @@ impl SimdUtf8Decoder { let shifted = _mm_slli_si128(shifts, 8); _mm_blendv_epi8(shifts, shifted, selector) } - + /// Find first matching byte position, returns -1 if none found #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] #[target_feature(enable = "sse2", enable = "sse4.1")] @@ -1027,7 +1221,7 @@ impl SimdUtf8Decoder { _mm_movemask_epi8(cmp_result).trailing_zeros() as i32 } } - + /// Zero the last n bytes of the vector. /// E.g., zero_last_n_bytes(vec, 3) zeros bytes at indices 13, 14, 15. /// This matches Kitty's implementation which uses shift_left_by_bytes (actually _mm_srli_si128). @@ -1060,7 +1254,7 @@ impl SimdUtf8Decoder { }; _mm_and_si128(mask, vec) } - + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] #[target_feature(enable = "sse2")] #[inline] @@ -1070,47 +1264,51 @@ impl SimdUtf8Decoder { let upper = _mm_cvtsi128_si32(_mm_srli_si128(sum, 8)) as usize; lower + upper } - + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] #[target_feature(enable = "sse2", enable = "sse4.1")] #[inline] - unsafe fn output_plain_ascii(vec: __m128i, src_sz: usize, output: &mut Vec) { + unsafe fn output_plain_ascii( + vec: __m128i, + src_sz: usize, + output: &mut Vec, + ) { output.reserve(src_sz); - + // Process 4 bytes at a time let mut v = vec; let mut remaining = src_sz; - + while remaining > 0 { let unpacked = _mm_cvtepu8_epi32(v); let to_write = remaining.min(4); - + let mut buf = [0u32; 4]; _mm_storeu_si128(buf.as_mut_ptr() as *mut __m128i, unpacked); output.extend_from_slice(&buf[..to_write]); - + remaining = remaining.saturating_sub(4); v = _mm_srli_si128(v, 4); } } - + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] #[target_feature(enable = "sse2", enable = "sse4.1")] #[inline] unsafe fn output_unicode( output1: __m128i, - output2: __m128i, + output2: __m128i, output3: __m128i, num_codepoints: usize, - output: &mut Vec + output: &mut Vec, ) { output.reserve(num_codepoints); - + let mut o1 = output1; let mut o2 = output2; let mut o3 = output3; let mut remaining = num_codepoints; - + while remaining > 0 { // Unpack lowest 4 bytes to 4 u32s let unpacked1 = _mm_cvtepu8_epi32(o1); @@ -1120,23 +1318,28 @@ impl SimdUtf8Decoder { // Shift right by 2 bytes for output3 - puts bytes in position 2 (bits 16-23) let unpacked3 = _mm_cvtepu8_epi32(_mm_srli_si128(o3, 0)); let unpacked3 = _mm_slli_epi32(unpacked3, 16); - - let unpacked = _mm_or_si128(_mm_or_si128(unpacked1, unpacked2), unpacked3); - + + let unpacked = + _mm_or_si128(_mm_or_si128(unpacked1, unpacked2), unpacked3); + let to_write = remaining.min(4); let mut buf = [0u32; 4]; _mm_storeu_si128(buf.as_mut_ptr() as *mut __m128i, unpacked); output.extend_from_slice(&buf[..to_write]); - + remaining = remaining.saturating_sub(4); o1 = _mm_srli_si128(o1, 4); o2 = _mm_srli_si128(o2, 4); o3 = _mm_srli_si128(o3, 4); } } - + /// Scalar decode until state is ACCEPT. - fn scalar_decode_to_accept(&mut self, src: &[u8], output: &mut Vec) -> usize { + fn scalar_decode_to_accept( + &mut self, + src: &[u8], + output: &mut Vec, + ) -> usize { let mut pos = 0; while pos < src.len() && self.state.cur != UTF8_ACCEPT { let byte = src[pos]; @@ -1147,7 +1350,11 @@ impl SimdUtf8Decoder { } pos += 1; self.state.prev = self.state.cur; - match decode_utf8_byte(&mut self.state.cur, &mut self.state.codep, byte) { + match decode_utf8_byte( + &mut self.state.cur, + &mut self.state.codep, + byte, + ) { UTF8_ACCEPT => output.push(self.state.codep), UTF8_REJECT => { output.push(0xFFFD); @@ -1162,9 +1369,13 @@ impl SimdUtf8Decoder { } pos } - + /// Scalar decode all bytes. - fn scalar_decode_all(&mut self, src: &[u8], output: &mut Vec) -> usize { + fn scalar_decode_all( + &mut self, + src: &[u8], + output: &mut Vec, + ) -> usize { let mut pos = 0; while pos < src.len() { let byte = src[pos]; @@ -1177,7 +1388,11 @@ impl SimdUtf8Decoder { } pos += 1; self.state.prev = self.state.cur; - match decode_utf8_byte(&mut self.state.cur, &mut self.state.codep, byte) { + match decode_utf8_byte( + &mut self.state.cur, + &mut self.state.codep, + byte, + ) { UTF8_ACCEPT => output.push(self.state.codep), UTF8_REJECT => { output.push(0xFFFD); @@ -1213,7 +1428,7 @@ pub fn codepoints_to_chars(codepoints: &[u32], chars: &mut Vec) { #[cfg(test)] mod tests { use super::*; - + #[test] fn test_ascii() { let mut decoder = SimdUtf8Decoder::new(); @@ -1222,10 +1437,11 @@ mod tests { let (consumed, found_esc) = decoder.decode_to_esc(input, &mut output); assert_eq!(consumed, 13); assert!(!found_esc); - let chars: Vec = output.iter().filter_map(|&cp| char::from_u32(cp)).collect(); + let chars: Vec = + output.iter().filter_map(|&cp| char::from_u32(cp)).collect(); assert_eq!(chars.iter().collect::(), "Hello, World!"); } - + #[test] fn test_with_esc() { let mut decoder = SimdUtf8Decoder::new(); @@ -1234,10 +1450,11 @@ mod tests { let (consumed, found_esc) = decoder.decode_to_esc(input, &mut output); assert_eq!(consumed, 6); // "Hello" + ESC assert!(found_esc); - let chars: Vec = output.iter().filter_map(|&cp| char::from_u32(cp)).collect(); + let chars: Vec = + output.iter().filter_map(|&cp| char::from_u32(cp)).collect(); assert_eq!(chars.iter().collect::(), "Hello"); } - + #[test] fn test_utf8_2byte() { let mut decoder = SimdUtf8Decoder::new(); @@ -1246,10 +1463,11 @@ mod tests { let (consumed, found_esc) = decoder.decode_to_esc(input, &mut output); assert_eq!(consumed, 5); // c, a, f, é (2 bytes) assert!(!found_esc); - let chars: Vec = output.iter().filter_map(|&cp| char::from_u32(cp)).collect(); + let chars: Vec = + output.iter().filter_map(|&cp| char::from_u32(cp)).collect(); assert_eq!(chars.iter().collect::(), "café"); } - + #[test] fn test_utf8_3byte() { let mut decoder = SimdUtf8Decoder::new(); @@ -1258,10 +1476,11 @@ mod tests { let (consumed, found_esc) = decoder.decode_to_esc(input, &mut output); assert_eq!(consumed, 9); // 3 chars * 3 bytes assert!(!found_esc); - let chars: Vec = output.iter().filter_map(|&cp| char::from_u32(cp)).collect(); + let chars: Vec = + output.iter().filter_map(|&cp| char::from_u32(cp)).collect(); assert_eq!(chars.iter().collect::(), "日本語"); } - + #[test] fn test_utf8_4byte() { let mut decoder = SimdUtf8Decoder::new(); @@ -1270,10 +1489,11 @@ mod tests { let (consumed, found_esc) = decoder.decode_to_esc(input, &mut output); assert_eq!(consumed, 8); // 2 chars * 4 bytes assert!(!found_esc); - let chars: Vec = output.iter().filter_map(|&cp| char::from_u32(cp)).collect(); + let chars: Vec = + output.iter().filter_map(|&cp| char::from_u32(cp)).collect(); assert_eq!(chars.iter().collect::(), "🎉🚀"); } - + #[test] fn test_invalid_utf8() { let mut decoder = SimdUtf8Decoder::new(); @@ -1284,29 +1504,29 @@ mod tests { // Should have replacement characters assert!(output.iter().any(|&cp| cp == 0xFFFD)); } - + // ======================================================================== // Tests for find_byte // ======================================================================== - + #[test] fn test_find_byte_first() { let haystack = b"hello world"; assert_eq!(find_byte(haystack, b'h'), Some(0)); } - + #[test] fn test_find_byte_middle() { let haystack = b"hello world"; assert_eq!(find_byte(haystack, b'w'), Some(6)); } - + #[test] fn test_find_byte_not_found() { let haystack = b"hello world"; assert_eq!(find_byte(haystack, b'x'), None); } - + #[test] fn test_find_byte_long() { // Test with > 32 bytes to exercise AVX2 path @@ -1314,41 +1534,41 @@ mod tests { haystack[75] = b'Z'; assert_eq!(find_byte(&haystack, b'Z'), Some(75)); } - + // ======================================================================== // Tests for find_either_of_two_bytes // ======================================================================== - + #[test] fn test_find_either_of_two_bytes_first() { let haystack = b"hello world"; assert_eq!(find_either_of_two_bytes(haystack, b'h', b'x'), Some(0)); } - + #[test] fn test_find_either_of_two_bytes_second() { let haystack = b"hello world"; assert_eq!(find_either_of_two_bytes(haystack, b'x', b'h'), Some(0)); } - + #[test] fn test_find_either_of_two_bytes_middle() { let haystack = b"hello world"; assert_eq!(find_either_of_two_bytes(haystack, b'w', b'o'), Some(4)); // first 'o' at index 4 } - + #[test] fn test_find_either_of_two_bytes_not_found() { let haystack = b"hello world"; assert_eq!(find_either_of_two_bytes(haystack, b'x', b'y'), None); } - + #[test] fn test_find_either_of_two_bytes_esc() { let haystack = b"hello\x1bworld"; assert_eq!(find_either_of_two_bytes(haystack, 0x1B, b'\n'), Some(5)); } - + #[test] fn test_find_either_of_two_bytes_long() { // Test with > 32 bytes to exercise AVX2 path @@ -1356,53 +1576,53 @@ mod tests { haystack[50] = b'X'; assert_eq!(find_either_of_two_bytes(&haystack, b'X', b'Y'), Some(50)); } - + #[test] fn test_find_either_of_two_bytes_empty() { let haystack = b""; assert_eq!(find_either_of_two_bytes(haystack, b'a', b'b'), None); } - + // ======================================================================== // Tests for find_c0_control // ======================================================================== - + #[test] fn test_find_c0_control_newline() { let haystack = b"hello\nworld"; assert_eq!(find_c0_control(haystack), Some(5)); } - + #[test] fn test_find_c0_control_tab() { let haystack = b"hello\tworld"; assert_eq!(find_c0_control(haystack), Some(5)); } - + #[test] fn test_find_c0_control_del() { let haystack = b"hello\x7fworld"; assert_eq!(find_c0_control(haystack), Some(5)); } - + #[test] fn test_find_c0_control_bell() { let haystack = b"hello\x07world"; assert_eq!(find_c0_control(haystack), Some(5)); } - + #[test] fn test_find_c0_control_esc() { let haystack = b"hello\x1bworld"; assert_eq!(find_c0_control(haystack), Some(5)); } - + #[test] fn test_find_c0_control_none() { let haystack = b"hello world!"; assert_eq!(find_c0_control(haystack), None); } - + #[test] fn test_find_c0_control_long() { // Test with > 32 bytes to exercise AVX2 path @@ -1410,17 +1630,17 @@ mod tests { haystack[60] = b'\n'; assert_eq!(find_c0_control(&haystack), Some(60)); } - + #[test] fn test_find_c0_control_at_start() { let haystack = b"\x00hello"; assert_eq!(find_c0_control(haystack), Some(0)); } - + // ======================================================================== // Tests for xor_mask // ======================================================================== - + #[test] fn test_xor_mask_basic() { let mut data = vec![0u8; 8]; @@ -1428,7 +1648,7 @@ mod tests { xor_mask(&mut data, mask, 0); assert_eq!(data, vec![0x12, 0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78]); } - + #[test] fn test_xor_mask_offset() { let mut data = vec![0u8; 8]; @@ -1437,35 +1657,35 @@ mod tests { // Starting at offset 1: 0x34, 0x56, 0x78, 0x12, 0x34, ... assert_eq!(data, vec![0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78, 0x12]); } - + #[test] fn test_xor_mask_roundtrip() { let original = b"Hello, World!".to_vec(); let mut data = original.clone(); let mask = [0xAB, 0xCD, 0xEF, 0x01]; - + // XOR once xor_mask(&mut data, mask, 0); assert_ne!(data, original); - + // XOR again to get back original xor_mask(&mut data, mask, 0); assert_eq!(data, original); } - + #[test] fn test_xor_mask_long() { // Test with > 32 bytes to exercise AVX2 path let mut data = vec![0xFFu8; 100]; let mask = [0x12, 0x34, 0x56, 0x78]; xor_mask(&mut data, mask, 0); - + // Verify pattern for (i, &byte) in data.iter().enumerate() { assert_eq!(byte, 0xFF ^ mask[i % 4]); } } - + #[test] fn test_xor_mask_empty() { let mut data: Vec = vec![]; diff --git a/src/statusline.rs b/src/statusline.rs index 3c80ed4..de3b811 100644 --- a/src/statusline.rs +++ b/src/statusline.rs @@ -113,7 +113,10 @@ impl StatuslineSection { } /// Add multiple components to this section. - pub fn with_components(mut self, components: Vec) -> Self { + pub fn with_components( + mut self, + components: Vec, + ) -> Self { self.components = components; self } diff --git a/src/terminal.rs b/src/terminal.rs index 81e8966..f4bf852 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -1,7 +1,7 @@ //! Terminal state management and escape sequence handling. use crate::graphics::{GraphicsCommand, ImageStorage}; -use crate::keyboard::{query_response, KeyboardState}; +use crate::keyboard::{KeyboardState, query_response}; use crate::vt_parser::{CsiParams, Handler}; use unicode_width::UnicodeWidthChar; @@ -545,7 +545,7 @@ impl Terminal { bracketed_paste: false, focus_reporting: false, synchronized_output: false, - + command_queue: Vec::new(), image_storage: ImageStorage::new(), cell_width: 10.0, // Default, will be set by renderer @@ -584,7 +584,10 @@ impl Terminal { /// Check if any line is dirty. #[inline] pub fn has_any_dirty_line(&self) -> bool { - self.dirty_lines[0] != 0 || self.dirty_lines[1] != 0 || self.dirty_lines[2] != 0 || self.dirty_lines[3] != 0 + self.dirty_lines[0] != 0 + || self.dirty_lines[1] != 0 + || self.dirty_lines[2] != 0 + || self.dirty_lines[3] != 0 } /// Clear all dirty line flags. @@ -804,8 +807,14 @@ impl Terminal { self.dirty = true; log::debug!( "Entered alternate screen buffer: rows={}, cols={}, scroll_region={}-{}, dirty_lines={:016x}{:016x}{:016x}{:016x}", - self.rows, self.cols, self.scroll_top, self.scroll_bottom, - self.dirty_lines[3], self.dirty_lines[2], self.dirty_lines[1], self.dirty_lines[0] + self.rows, + self.cols, + self.scroll_top, + self.scroll_bottom, + self.dirty_lines[3], + self.dirty_lines[2], + self.dirty_lines[1], + self.dirty_lines[0] ); } @@ -826,9 +835,11 @@ impl Terminal { self.cursor_row = saved.cursor_row.min(self.rows.saturating_sub(1)); } + // Wipe alternate screen and its image storage to reclaim memory + self.alternate_screen = None; self.using_alternate_screen = false; self.mark_all_lines_dirty(); - log::debug!("Left alternate screen buffer"); + log::debug!("Left alternate screen buffer and cleared its storage"); } /// Scrolls the scroll region up by n lines. @@ -855,6 +866,9 @@ impl Terminal { { // Get a slot in the ring buffer - this is O(1) with just modulo arithmetic // If buffer is full, this overwrites the oldest line (perfect for our swap) + if self.scrollback.is_full() { + self.image_storage.shift_placements(-1); + } let cols = self.cols; let dest = self.scrollback.push(cols); // Swap grid row content into scrollback slot @@ -862,6 +876,10 @@ impl Terminal { std::mem::swap(&mut self.grid[recycled_grid_row], dest); // Clear the grid row (now contains old scrollback data or empty) self.clear_grid_row(recycled_grid_row); + if self.scroll_offset > 0 { + self.scroll_offset = + (self.scroll_offset + 1).min(self.scrollback.capacity); + } } else { // Not saving to scrollback - just clear the line self.clear_grid_row(recycled_grid_row); @@ -1296,6 +1314,9 @@ impl Terminal { for visual_row in 0..self.rows { let grid_row = self.line_map[visual_row]; // Get a slot in the ring buffer and swap content into it + if self.scrollback.is_full() { + self.image_storage.shift_placements(-1); + } let cols = self.cols; let dest = self.scrollback.push(cols); std::mem::swap(&mut self.grid[grid_row], dest); @@ -1331,8 +1352,12 @@ impl Handler for Terminal { .iter() .filter_map(|&c| char::from_u32(c)) .collect(); - log::error!("DEBUG CSI LEAK: text handler received CSI-like content: {:?} at ({}, {})", - text, self.cursor_col, self.cursor_row); + log::error!( + "DEBUG CSI LEAK: text handler received CSI-like content: {:?} at ({}, {})", + text, + self.cursor_col, + self.cursor_row + ); } } @@ -1675,7 +1700,9 @@ impl Handler for Terminal { b'1' => { // Start pending mode (pause rendering) if self.synchronized_output { - log::warn!("Pending mode start requested while already in pending mode"); + log::warn!( + "Pending mode start requested while already in pending mode" + ); } self.synchronized_output = true; log::trace!("DCS pending mode started (=1s)"); @@ -1683,7 +1710,9 @@ impl Handler for Terminal { b'2' => { // Stop pending mode (resume rendering) if !self.synchronized_output { - log::warn!("Pending mode stop requested while not in pending mode"); + log::warn!( + "Pending mode stop requested while not in pending mode" + ); } self.synchronized_output = false; self.dirty = true; // Force a redraw @@ -1739,7 +1768,7 @@ impl Handler for Terminal { "CSI C: cursor forward {} from col {} to {}", n, old_col, - self.cursor_col + self.cursor_col, ); self.mark_line_dirty(self.cursor_row); } @@ -1777,7 +1806,7 @@ impl Handler for Terminal { log::trace!( "CSI G: cursor to col {} (was {})", self.cursor_col, - old_col + old_col, ); self.mark_line_dirty(self.cursor_row); } @@ -1793,6 +1822,13 @@ impl Handler for Terminal { self.cursor_row = (row - 1).min(self.rows - 1); } self.cursor_col = (col - 1).min(self.cols - 1); + log::debug!( + "CSI H/f: cursor to row {}, col {} (was {}, {})", + self.cursor_row, + self.cursor_col, + self.cursor_row, + self.cursor_col + ); self.mark_line_dirty(self.cursor_row); } // Erase in Display @@ -2092,7 +2128,10 @@ impl Handler for Terminal { _ => { log::debug!( "Unhandled CSI: action='{}' primary={} secondary={} params={:?}", - action, primary, secondary, ¶ms.params[..params.num_params] + action, + primary, + secondary, + ¶ms.params[..params.num_params] ); } } @@ -2244,8 +2283,9 @@ impl Handler for Terminal { strikethrough: false, wide_continuation: false, wrapped: false, - }; + } } + self.mark_line_dirty(visual_row); } } @@ -2732,7 +2772,11 @@ impl Terminal { log::debug!( "Routing image command to {}: cursor_col={}, absolute_row={}, using_alt={}", - if self.using_alternate_screen { "alternate" } else { "main" }, + if self.using_alternate_screen { + "alternate" + } else { + "main" + }, self.cursor_col, absolute_row, self.using_alternate_screen @@ -2765,35 +2809,38 @@ impl Terminal { self.response_queue.extend_from_slice(resp.as_bytes()); } - // Move cursor after image placement per Kitty protocol spec: - // "After placing an image on the screen the cursor must be moved to the - // right by the number of cols in the image placement rectangle and down - // by the number of rows in the image placement rectangle." - // However, if C=1 was specified, don't move the cursor. - if let Some(placement) = placement_result { - self.dirty = true; - if !placement.suppress_cursor_move - && !placement.virtual_placement - { - // Move cursor to the right and and down by the image dimensions - self.cursor_col += placement.cols; - let new_row = self.cursor_row + placement.rows; - if new_row >= self.rows { - // Need to scroll - let scroll_amount = new_row - self.rows + 1; - self.scroll_up(scroll_amount); - self.cursor_row = self.rows - 1; - } else { - self.cursor_row = new_row; - } - // If cursor is now beyond the right edge, it will be handled by the normal - // cursor movement logic (wrapping/scrolling) if applicable. - log::debug!( - "Cursor moved after image placement: col={}, row={} (moved {}x{} cells)", - self.cursor_col, self.cursor_row, placement.cols, placement.rows - ); - } + // Move cursor after image placement per Kitty protocol spec: + // "After placing an image on the screen the cursor must be moved to the + // right by the number of cols in the image placement rectangle and down + // by the number of rows in the image placement rectangle." + // However, if C=1 was specified, don't move the cursor. + if let Some(placement) = placement_result { + self.dirty = true; + if !placement.suppress_cursor_move + && !placement.virtual_placement + { + // Move cursor to the right and and down by the image dimensions + self.cursor_col += placement.cols; + let new_row = self.cursor_row + placement.rows; + if new_row >= self.rows { + // Need to scroll + let scroll_amount = new_row - self.rows + 1; + self.scroll_up(scroll_amount); + self.cursor_row = self.rows - 1; + } else { + self.cursor_row = new_row; } + // If cursor is now beyond the right edge, it will be handled by the normal + // cursor movement logic (wrapping/scrolling) if applicable. + log::debug!( + "Cursor moved after image placement: col={}, row={} (moved {}x{} cells)", + self.cursor_col, + self.cursor_row, + placement.cols, + placement.rows + ); + } + } } } } diff --git a/src/vt_parser.rs b/src/vt_parser.rs index c6223ec..ff1e92d 100644 --- a/src/vt_parser.rs +++ b/src/vt_parser.rs @@ -550,8 +550,11 @@ impl SharedParser { } } else if buffer_was_ever_full { // Buffer was full but nothing consumed - stuck in partial sequence? - log::warn!("[PARSE] Buffer was full but read_consumed=0! read_pos={} read_sz={}", - state.read_pos, state.read_sz); + log::warn!( + "[PARSE] Buffer was full but read_consumed=0! read_pos={} read_sz={}", + state.read_pos, + state.read_sz + ); } drop(state); diff --git a/src/vt_test_osc.rs b/src/vt_test_osc.rs index e2f9115..6b32221 100644 --- a/src/vt_test_osc.rs +++ b/src/vt_test_osc.rs @@ -33,47 +33,67 @@ impl Handler for DummyHandler { #[test] fn test_osc_leak_byte_by_byte() { let parser = SharedParser::new(); - let mut handler = DummyHandler { text: String::new(), osc_calls: Vec::new() }; - + let mut handler = DummyHandler { + text: String::new(), + osc_calls: Vec::new(), + }; + let data = b"\x1b]4;1;#769E00\x1b\\\x1b]4;2;#93DE88\x1b\\"; - + for &byte in data { let (ptr, _) = parser.create_write_buffer(); unsafe { *ptr = byte; } parser.commit_write(1); - + while parser.run_parse_pass(&mut handler) {} } - + println!("TEXT: {:?}", handler.text); println!("OSC calls: {}", handler.osc_calls.len()); for call in &handler.osc_calls { println!(" OSC: {:?}", std::str::from_utf8(call).unwrap()); } - - assert_eq!(handler.text, "", "Text should be empty, but leaked escape sequence bytes!"); - assert_eq!(handler.osc_calls.len(), 2, "Should have parsed exactly two OSC calls"); + + assert_eq!( + handler.text, "", + "Text should be empty, but leaked escape sequence bytes!" + ); + assert_eq!( + handler.osc_calls.len(), + 2, + "Should have parsed exactly two OSC calls" + ); } #[test] fn test_csi_aborted_by_osc() { let parser = SharedParser::new(); - let mut handler = DummyHandler { text: String::new(), osc_calls: Vec::new() }; - + let mut handler = DummyHandler { + text: String::new(), + osc_calls: Vec::new(), + }; + // An incomplete CSI sequence aborted by an OSC sequence let data = b"\x1b[38;2;255;0;0\x1b]4;1;#769E00\x1b\\"; - + let (ptr, len) = parser.create_write_buffer(); assert!(len >= data.len()); unsafe { std::ptr::copy_nonoverlapping(data.as_ptr(), ptr, data.len()); } parser.commit_write(data.len()); - + while parser.run_parse_pass(&mut handler) {} - - assert_eq!(handler.text, "", "Text should be empty, but leaked escape sequence bytes!"); - assert_eq!(handler.osc_calls.len(), 1, "Should have parsed the OSC sequence even after aborting CSI"); + + assert_eq!( + handler.text, "", + "Text should be empty, but leaked escape sequence bytes!" + ); + assert_eq!( + handler.osc_calls.len(), + 1, + "Should have parsed the OSC sequence even after aborting CSI" + ); }