image rendering and visual selection

This commit is contained in:
2026-07-07 14:50:49 +02:00
parent 04b6776ebf
commit 4727e51d1b
20 changed files with 4476 additions and 2427 deletions
+31 -20
View File
@@ -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<u8> {
}
/// Run a benchmark with multiple repetitions like Kitty does
fn run_benchmark<F>(name: &str, data: &[u8], repetitions: usize, mut setup: F)
fn run_benchmark<F>(name: &str, data: &[u8], repetitions: usize, mut setup: F)
where
F: FnMut() -> (Terminal, Parser),
{
let data_size = data.len();
let total_size = data_size * repetitions;
// Warmup run
let (mut terminal, mut parser) = setup();
parser.parse(data, &mut terminal);
// Timed runs
let start = Instant::now();
for _ in 0..repetitions {
@@ -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)");
}
+201 -90
View File
@@ -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<PathBuf> {
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<PathBuf> {
// 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<PathBuf> {
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<u8>, 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))
}
}
+23 -20
View File
@@ -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(())
+7 -1
View File
@@ -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(),
+76 -49
View File
@@ -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<PathBuf> {
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<PathBuf> {
// 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<PathBuf> {
/// Returns paths for (regular, bold, italic, bold_italic).
/// Any variant that can't be found will be None.
pub fn find_font_family_variants(family: &str) -> [Option<PathBuf>; 4] {
use fontconfig_sys as fcsys;
use fcsys::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<PathBuf>; 4] = [None, None, None, None];
// Style queries: (weight, slant) pairs for each variant
// FC_WEIGHT_REGULAR = 80, FC_WEIGHT_BOLD = 200
// FC_SLANT_ROMAN = 0, FC_SLANT_ITALIC = 100
@@ -135,37 +140,48 @@ pub fn find_font_family_variants(family: &str) -> [Option<PathBuf>; 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<PathBuf>; 4] {
}
FcPatternDestroy(matched);
}
FcPatternDestroy(pat);
}
}
results
}
@@ -189,38 +205,40 @@ pub fn find_font_family_variants(family: &str) -> [Option<PathBuf>; 4] {
/// Returns None if the file doesn't exist or can't be parsed.
pub fn load_font_variant(path: &std::path::Path) -> Option<FontVariant> {
let data = std::fs::read(path).ok()?.into_boxed_slice();
// Parse with ab_glyph
let font: FontRef<'static> = {
let font = FontRef::try_from_slice(&data).ok()?;
// SAFETY: We keep data alive in the FontVariant struct
unsafe { std::mem::transmute(font) }
};
// Parse with rustybuzz
let face: rustybuzz::Face<'static> = {
let face = rustybuzz::Face::from_slice(&data, 0)?;
// SAFETY: We keep data alive in the FontVariant struct
unsafe { std::mem::transmute(face) }
};
Some(FontVariant { data, font, face })
}
/// Load font variants for a font family.
/// Returns array of font variants, with index 0 being the regular font.
/// Falls back to hardcoded paths if fontconfig fails.
pub fn load_font_family(font_family: Option<&str>) -> (Box<[u8]>, FontRef<'static>, [Option<FontVariant>; 4]) {
pub fn load_font_family(
font_family: Option<&str>,
) -> (Box<[u8]>, FontRef<'static>, [Option<FontVariant>; 4]) {
// Try to use fontconfig to find the font family
if let Some(family) = font_family {
let paths = find_font_family_variants(family);
// Load the regular font (required)
if let Some(regular_path) = &paths[0] {
if let Some(regular) = load_font_variant(regular_path) {
let primary_font = regular.clone_font();
let font_data = regular.clone_data();
// Load other variants
let variants: [Option<FontVariant>; 4] = [
Some(regular),
@@ -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<FontVariant>; 4] = [
Some(regular_variant),
load_font_variant(std::path::Path::new(bold)),
load_font_variant(std::path::Path::new(italic)),
load_font_variant(std::path::Path::new(bold_italic)),
];
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<FontVariant>; 4] = [Some(regular_variant), None, None, None];
let variants: [Option<FontVariant>; 4] =
[Some(regular_variant), None, None, None];
return (font_data, primary_font, variants);
}
panic!("Failed to load any monospace font");
}
+7 -1
View File
@@ -157,6 +157,7 @@ struct GridParams {
selection_start_row: i32,
selection_end_col: i32,
selection_end_row: i32,
selection_row_max_col: array<i32, 256>,
}
// GPUCell instance data (matches Rust GPUCell struct)
@@ -187,7 +188,7 @@ struct SpriteInfo {
var<uniform> color_table: ColorTable;
@group(1) @binding(1)
var<uniform> grid_params: GridParams;
var<storage, read> grid_params: GridParams;
@group(1) @binding(2)
var<storage, read> cells: array<GPUCell>;
@@ -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);
+13 -10
View File
@@ -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::<GlyphVertex>() as wgpu::BufferAddress,
array_stride: std::mem::size_of::<GlyphVertex>()
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.
+279 -107
View File
@@ -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<u8>, Option<AnimationData>), 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<u32>,
/// 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<u32>,
/// 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<String> {
fn handle_animation_frame(
&mut self,
mut cmd: GraphicsCommand,
) -> Option<String> {
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<String> {
fn handle_animation_control(
&mut self,
cmd: &GraphicsCommand,
) -> Option<String> {
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)
})
}
+176 -89
View File
@@ -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::<ImageUniforms>() 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::<ImageUniforms>() 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
+1 -5
View File
@@ -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.
+2 -2
View File
@@ -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;
+460 -264
View File
File diff suppressed because it is too large Load Diff
+57 -36
View File
@@ -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,
})
}
}
+39 -24
View File
@@ -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<Self, PtyError> {
pub fn spawn(
shell: Option<&str>,
cols: u16,
rows: u16,
xpixel: u16,
ypixel: u16,
) -> Result<Self, PtyError> {
// 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<i32> {
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<String> {
let pgid = self.foreground_pgid()?;
// Read the command line from /proc/<pid>/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<String> {
let pgid = self.foreground_pgid()?;
// Read the cwd symlink from /proc/<pid>/cwd
let cwd_path = format!("/proc/{}/cwd", pgid);
std::fs::read_link(&cwd_path)
+2503 -1401
View File
File diff suppressed because it is too large Load Diff
+467 -247
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -113,7 +113,10 @@ impl StatuslineSection {
}
/// Add multiple components to this section.
pub fn with_components(mut self, components: Vec<StatuslineComponent>) -> Self {
pub fn with_components(
mut self,
components: Vec<StatuslineComponent>,
) -> Self {
self.components = components;
self
}
+90 -43
View File
@@ -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, &params.params[..params.num_params]
action,
primary,
secondary,
&params.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
);
}
}
}
}
}
+5 -2
View File
@@ -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);
+35 -15
View File
@@ -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"
);
}