image rendering and visual selection
This commit is contained in:
+31
-20
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+57
-36
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+467
-247
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user