diff --git a/Cargo.toml b/Cargo.toml index 792026d..c1f198e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ libc = "0.2" bitflags = "2" # Font rasterization and shaping -fontdue = "0.9" +ab_glyph = "0.2" rustybuzz = "0.20" ttf-parser = "0.25" fontconfig = "0.10" @@ -47,9 +47,22 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" bincode = "1" dirs = "6" +notify = "7" # Shared memory for fast IPC memmap2 = "0.9" # Fast byte searching memchr = "2" + +# Image processing (Kitty graphics protocol) +image = { version = "0.25", default-features = false, features = ["png", "gif"] } +flate2 = "1" + +# Video decoding for WebM support (video only, no audio) +# Requires system FFmpeg libraries (ffmpeg 5.x - 8.x supported) +ffmpeg-next = { version = "8.0", optional = true } + +[features] +default = ["webm"] +webm = ["ffmpeg-next"] diff --git a/src/bin/test_ligatures.rs b/src/bin/test_ligatures.rs deleted file mode 100644 index 9accba2..0000000 --- a/src/bin/test_ligatures.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Test ligature detection - compare individual vs combined shaping -use rustybuzz::{Face, UnicodeBuffer, Feature}; -use ttf_parser::Tag; -use fontdue::Font; -use std::fs; - -fn main() { - let path = "/usr/share/fonts/TTF/0xProtoNerdFontMono-Regular.ttf"; - println!("Using font: {}", path); - - let font_data = fs::read(path).expect("Failed to read font"); - let face = Face::from_slice(&font_data, 0).expect("Failed to parse font"); - let fontdue_font = Font::from_bytes(&font_data[..], fontdue::FontSettings::default()).unwrap(); - - let font_size = 16.0; - let units_per_em = face.units_per_em() as f32; - - println!("Font units per em: {}", units_per_em); - - // Get cell width from a regular character - let (hyphen_metrics, _) = fontdue_font.rasterize('-', font_size); - let cell_width = hyphen_metrics.advance_width; - println!("Cell width (from '-'): {:.2}px", cell_width); - - let features = vec![ - Feature::new(Tag::from_bytes(b"liga"), 1, ..), - Feature::new(Tag::from_bytes(b"calt"), 1, ..), - Feature::new(Tag::from_bytes(b"dlig"), 1, ..), - ]; - - let test_strings = ["->", "=>", "==", "!=", ">=", "<="]; - - for s in &test_strings { - // Shape combined string - let mut buffer = UnicodeBuffer::new(); - buffer.push_str(s); - let combined = rustybuzz::shape(&face, &features, buffer); - let combined_infos = combined.glyph_infos(); - let combined_positions = combined.glyph_positions(); - - // Shape each character individually - let mut individual_glyphs = Vec::new(); - for c in s.chars() { - let mut buf = UnicodeBuffer::new(); - buf.push_str(&c.to_string()); - let shaped = rustybuzz::shape(&face, &features, buf); - individual_glyphs.push(shaped.glyph_infos()[0].glyph_id); - } - - println!("\n'{}' analysis:", s); - println!(" Combined glyphs: {:?}", combined_infos.iter().map(|i| i.glyph_id).collect::>()); - println!(" Individual glyphs: {:?}", individual_glyphs); - - // Show advances for each glyph - for (i, (info, pos)) in combined_infos.iter().zip(combined_positions.iter()).enumerate() { - let advance_px = pos.x_advance as f32 * font_size / units_per_em; - println!(" Glyph {}: id={}, advance={} units ({:.2}px)", i, info.glyph_id, pos.x_advance, advance_px); - - // Rasterize and show metrics - let (metrics, _) = fontdue_font.rasterize_indexed(info.glyph_id as u16, font_size); - println!(" Rasterized: {}x{} px, xmin={}, ymin={}, advance_width={:.2}", - metrics.width, metrics.height, metrics.xmin, metrics.ymin, metrics.advance_width); - } - - // Check if any glyph was substituted - let has_substitution = combined_infos.iter().zip(individual_glyphs.iter()) - .any(|(combined, &individual)| combined.glyph_id != individual); - println!(" Has substitution: {}", has_substitution); - } - - // Also test what Kitty does - check glyph names - println!("\n=== Checking glyph names via ttf-parser ==="); - let ttf_face = ttf_parser::Face::parse(&font_data, 0).unwrap(); - - // Shape "->" and get glyph names - let mut buffer = UnicodeBuffer::new(); - buffer.push_str("->"); - let combined = rustybuzz::shape(&face, &features, buffer); - for info in combined.glyph_infos() { - let glyph_id = ttf_parser::GlyphId(info.glyph_id as u16); - if let Some(name) = ttf_face.glyph_name(glyph_id) { - println!(" Glyph {} name: {}", info.glyph_id, name); - } else { - println!(" Glyph {} has no name", info.glyph_id); - } - } -} diff --git a/src/config.rs b/src/config.rs index c9a869d..17a8a79 100644 --- a/src/config.rs +++ b/src/config.rs @@ -367,6 +367,9 @@ pub struct Config { pub inactive_pane_fade_ms: u64, /// Dim factor for inactive panes (0.0 = fully dimmed/black, 1.0 = no dimming). pub inactive_pane_dim: f32, + /// Intensity of the edge glow effect when pane navigation fails (0.0 = disabled, 1.0 = full intensity). + /// The edge glow provides visual feedback when you try to navigate to a pane that doesn't exist. + pub edge_glow_intensity: f32, /// Process names that should receive pane navigation keys instead of zterm handling them. /// When the foreground process matches one of these names, Alt+Arrow keys are passed /// to the application (e.g., for Neovim buffer navigation) instead of switching panes. @@ -385,6 +388,7 @@ impl Default for Config { scrollback_lines: 50_000, inactive_pane_fade_ms: 150, inactive_pane_dim: 0.6, + edge_glow_intensity: 1.0, pass_keys_to_programs: vec!["nvim".to_string(), "vim".to_string()], keybindings: Keybindings::default(), } diff --git a/src/glyph_shader.wgsl b/src/glyph_shader.wgsl index 095fe5c..789361e 100644 --- a/src/glyph_shader.wgsl +++ b/src/glyph_shader.wgsl @@ -1,5 +1,63 @@ // Glyph rendering shader for terminal emulator // Supports both legacy quad-based rendering and new instanced cell rendering +// Uses Kitty-style "legacy" gamma-incorrect text blending for crisp rendering + +// ═══════════════════════════════════════════════════════════════════════════════ +// GAMMA CONVERSION FUNCTIONS (for legacy text rendering) +// ═══════════════════════════════════════════════════════════════════════════════ + +// Luminance weights for perceived brightness (ITU-R BT.709) +const Y: vec3 = vec3(0.2126, 0.7152, 0.0722); + +// Convert linear RGB to sRGB +fn linear2srgb(x: f32) -> f32 { + if x <= 0.0031308 { + return 12.92 * x; + } else { + return 1.055 * pow(x, 1.0 / 2.4) - 0.055; + } +} + +// Convert sRGB to linear RGB +fn srgb2linear(x: f32) -> f32 { + if x <= 0.04045 { + return x / 12.92; + } else { + return pow((x + 0.055) / 1.055, 2.4); + } +} + +// Kitty's legacy gamma-incorrect text blending +// This simulates how text was blended before gamma-correct rendering became standard. +// It makes dark text on light backgrounds appear thicker and light text on dark +// backgrounds appear thinner, which many users prefer for readability. +// +// The input colors are in sRGB space. We convert to linear for the luminance +// calculation, then simulate gamma-incorrect blending. +fn foreground_contrast_legacy(over_srgb: vec3, over_alpha: f32, under_srgb: vec3) -> f32 { + // Convert sRGB colors to linear for luminance calculation + let over_linear = vec3(srgb2linear(over_srgb.r), srgb2linear(over_srgb.g), srgb2linear(over_srgb.b)); + let under_linear = vec3(srgb2linear(under_srgb.r), srgb2linear(under_srgb.g), srgb2linear(under_srgb.b)); + + let under_luminance = dot(under_linear, Y); + let over_luminance = dot(over_linear, Y); + + // Avoid division by zero when luminances are equal + let luminance_diff = over_luminance - under_luminance; + if abs(luminance_diff) < 0.001 { + return over_alpha; + } + + // Kitty's formula: simulate gamma-incorrect blending + // This is the solution to: + // linear2srgb(over * alpha2 + under * (1 - alpha2)) = linear2srgb(over) * alpha + linear2srgb(under) * (1 - alpha) + // ^ gamma correct blending with new alpha ^ gamma incorrect blending with old alpha + let blended_srgb = linear2srgb(over_luminance) * over_alpha + linear2srgb(under_luminance) * (1.0 - over_alpha); + let blended_linear = srgb2linear(blended_srgb); + let new_alpha = (blended_linear - under_luminance) / luminance_diff; + + return clamp(new_alpha, 0.0, 1.0); +} // ═══════════════════════════════════════════════════════════════════════════════ // LEGACY QUAD-BASED RENDERING (for backwards compatibility) @@ -47,9 +105,11 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { // Sample the glyph alpha from the atlas let glyph_alpha = textureSample(atlas_texture, atlas_sampler, in.uv).r; - // Output foreground color with glyph alpha for blending - // The background was already rendered, so we just blend the glyph on top - return vec4(in.color.rgb, in.color.a * glyph_alpha); + // Apply legacy gamma-incorrect blending for crisp text + let adjusted_alpha = foreground_contrast_legacy(in.color.rgb, glyph_alpha, in.bg_color.rgb); + + // Output foreground color with adjusted alpha for blending + return vec4(in.color.rgb, in.color.a * adjusted_alpha); } // ═══════════════════════════════════════════════════════════════════════════════ @@ -232,9 +292,7 @@ fn vs_cell_bg( bg = tmp; } - // Convert to linear for sRGB surface - fg = vec4(srgb_to_linear(fg.r), srgb_to_linear(fg.g), srgb_to_linear(fg.b), fg.a); - bg = vec4(srgb_to_linear(bg.r), srgb_to_linear(bg.g), srgb_to_linear(bg.b), bg.a); + // Keep colors in sRGB space for legacy blending var out: CellVertexOutput; out.clip_position = vec4(ndc_pos, 0.0, 1.0); @@ -324,14 +382,13 @@ fn vs_cell_glyph( bg = tmp; } - // Convert to linear - fg = vec4(srgb_to_linear(fg.r), srgb_to_linear(fg.g), srgb_to_linear(fg.b), fg.a); + // Keep colors in sRGB space for legacy blending (conversion happens in fragment shader) var out: CellVertexOutput; out.clip_position = vec4(ndc_pos, 0.0, 1.0); out.uv = uvs[vertex_index]; out.fg_color = fg; - out.bg_color = vec4(0.0, 0.0, 0.0, 0.0); + out.bg_color = bg; // Pass background for legacy gamma blending out.is_background = 0u; out.is_colored_glyph = select(0u, 1u, is_colored); @@ -356,6 +413,9 @@ fn fs_cell(in: CellVertexOutput) -> @location(0) vec4 { return vec4(in.fg_color.rgb, glyph_alpha); } - // Normal glyph - tint with foreground color - return vec4(in.fg_color.rgb, in.fg_color.a * glyph_alpha); + // Apply legacy gamma-incorrect blending for crisp text + let adjusted_alpha = foreground_contrast_legacy(in.fg_color.rgb, glyph_alpha, in.bg_color.rgb); + + // Normal glyph - tint with foreground color using adjusted alpha + return vec4(in.fg_color.rgb, in.fg_color.a * adjusted_alpha); } diff --git a/src/graphics.rs b/src/graphics.rs new file mode 100644 index 0000000..c8ec4a2 --- /dev/null +++ b/src/graphics.rs @@ -0,0 +1,1826 @@ +//! Kitty Graphics Protocol implementation. +//! +//! This module handles parsing and processing of Kitty graphics protocol commands +//! which allow terminals to display images inline. +//! +//! Protocol reference: https://sw.kovidgoyal.net/kitty/graphics-protocol/ + +use std::collections::HashMap; +use std::io::{Cursor, Read}; +use std::time::Instant; + +use flate2::read::ZlibDecoder; +use image::{codecs::gif::GifDecoder, AnimationDecoder, ImageFormat}; + +/// Action to perform with the graphics command. +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum Action { + /// Transmit image data (store but don't display). + #[default] + Transmit, + /// Transmit and display image. + TransmitAndDisplay, + /// Display a previously transmitted image. + Put, + /// Delete images. + Delete, + /// Transmit animation frame. + AnimationFrame, + /// Control animation. + AnimationControl, + /// Query terminal for support. + Query, +} + +/// Image data format. +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum Format { + /// 24-bit RGB (3 bytes per pixel). + Rgb, + /// 32-bit RGBA (4 bytes per pixel). + #[default] + Rgba, + /// PNG encoded data. + Png, + /// GIF encoded data (for animations). + Gif, +} + +/// How image data is transmitted. +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum Transmission { + /// Direct data in the escape sequence. + #[default] + Direct, + /// Read from a file path. + File, + /// Read from a temporary file (deleted after read). + TempFile, + /// Read from shared memory. + SharedMemory, +} + +/// Compression method for the payload. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Compression { + /// Zlib compression. + Zlib, +} + +/// Delete target specification. +#[derive(Clone, Debug, PartialEq, Default)] +pub enum DeleteTarget { + /// Delete all images. + #[default] + All, + /// Delete by image ID. + ById(u32), + /// Delete by image number (for virtual placements). + ByNumber(u32), + /// Delete at cursor position. + AtCursor, + /// Delete animation frames. + AnimationFrames(u32), + /// Delete by cell range. + CellRange { + x: Option, + y: Option, + z: Option, + }, +} + +/// Parsed Kitty graphics command. +#[derive(Clone, Debug, Default)] +pub struct GraphicsCommand { + /// Action to perform. + pub action: Action, + /// Image data format. + pub format: Format, + /// Transmission medium. + pub transmission: Transmission, + /// Image ID (for referencing stored images). + pub image_id: Option, + /// Placement ID (for multiple placements of same image). + pub placement_id: Option, + /// Image width in pixels (required for raw data). + pub width: Option, + /// Image height in pixels (required for raw data). + pub height: Option, + /// Source rectangle X offset. + pub src_x: u32, + /// Source rectangle Y offset. + pub src_y: u32, + /// Source rectangle width (0 = full width). + pub src_width: u32, + /// Source rectangle height (0 = full height). + pub src_height: u32, + /// Display columns (0 = auto). + pub cols: u32, + /// Display rows (0 = auto). + pub rows: u32, + /// X offset within cell (pixels). + pub x_offset: u32, + /// Y offset within cell (pixels). + pub y_offset: u32, + /// Z-index for layering. + pub z_index: i32, + /// Compression method. + pub compression: Option, + /// More chunks coming (chunked transfer). + pub more_chunks: bool, + /// Quiet mode (0=normal, 1=suppress OK, 2=suppress all). + pub quiet: u8, + /// Cursor movement after display (0=move, 1=don't move). + pub cursor_movement: u8, + /// Delete target (for delete action). + pub delete_target: DeleteTarget, + /// Unicode placeholder (virtual placement). + pub unicode_placeholder: bool, + /// Parent image ID (for animation frames). + pub parent_id: Option, + /// Parent placement ID (for animation frames). + pub parent_placement_id: Option, + /// Frame number (for animation). + pub frame_number: Option, + /// Frame gap in milliseconds (z key for animation frames). + pub frame_gap: Option, + /// Base/source frame number for compositing (c key for animation, 1-indexed). + pub base_frame: Option, + /// Frame number being edited (r key for animation, 1-indexed). + pub edit_frame: Option, + /// Animation state (s key for animation control: 1=stop, 2=loading, 3=run). + pub animation_state: Option, + /// Loop count (v key for animation: 0=infinite, n=n loops). + pub loop_count: Option, + /// Composition mode (X key for animation: 0=alpha blend, 1=replace). + pub composition_mode: u8, + /// Background color for animation frames (Y key, 32-bit RGBA). + pub background_color: Option, + /// Raw payload data (base64 decoded). + pub payload: Vec, +} + +impl GraphicsCommand { + /// Parse a graphics command from APC data. + /// + /// The data format is: `G=,=,...;` + pub fn parse(data: &[u8]) -> Option { + // Must start with 'G' + if data.first() != Some(&b'G') { + return None; + } + + let data = &data[1..]; // Skip 'G' + + // Find the semicolon separating control data from payload + let (control_part, payload_part) = + match data.iter().position(|&b| b == b';') { + Some(pos) => (&data[..pos], &data[pos + 1..]), + None => (data, &[][..]), + }; + + let mut cmd = GraphicsCommand::default(); + + // Parse control key=value pairs + // First pass: get action to know how to interpret overloaded keys + let control_str = std::str::from_utf8(control_part).ok()?; + log::debug!("Graphics control string: {}", control_str); + + // Collect key-value pairs + let mut pairs: Vec<(&str, &str)> = Vec::new(); + for pair in control_str.split(',') { + if pair.is_empty() { + continue; + } + let mut parts = pair.splitn(2, '='); + if let Some(key) = parts.next() { + let value = parts.next().unwrap_or(""); + pairs.push((key, value)); + // Get action early so we know how to interpret other keys + if key == "a" { + cmd.action = match value { + "t" => Action::Transmit, + "T" => Action::TransmitAndDisplay, + "p" => Action::Put, + "d" => Action::Delete, + "f" => Action::AnimationFrame, + "a" => Action::AnimationControl, + "q" => Action::Query, + _ => Action::Transmit, + }; + } + } + } + + let is_animation = + matches!(cmd.action, Action::AnimationFrame | Action::AnimationControl); + + // Second pass: parse all keys with correct interpretation + for (key, value) in pairs { + match key { + "a" => {} // Already parsed above + "f" => { + cmd.format = match value { + "24" => Format::Rgb, + "32" => Format::Rgba, + "100" => Format::Png, + _ => Format::Rgba, + }; + } + "t" => { + cmd.transmission = match value { + "d" => Transmission::Direct, + "f" => Transmission::File, + "t" => Transmission::TempFile, + "s" => Transmission::SharedMemory, + _ => Transmission::Direct, + }; + } + "i" => cmd.image_id = value.parse().ok(), + "I" => cmd.image_id = value.parse().ok(), // Alternate form + "p" => cmd.placement_id = value.parse().ok(), + "s" => { + // s = width for images, animation_state for animation control + if matches!(cmd.action, Action::AnimationControl) { + cmd.animation_state = value.parse().ok(); + } else { + cmd.width = value.parse().ok(); + } + } + "v" => { + // v = height for images, loop_count for animation control + if matches!(cmd.action, Action::AnimationControl) { + cmd.loop_count = value.parse().ok(); + } else { + cmd.height = value.parse().ok(); + } + } + "x" => cmd.src_x = value.parse().unwrap_or(0), + "y" => cmd.src_y = value.parse().unwrap_or(0), + "w" => cmd.src_width = value.parse().unwrap_or(0), + "h" => cmd.src_height = value.parse().unwrap_or(0), + "c" => { + // c = cols for images, base_frame for animation + if is_animation { + cmd.base_frame = value.parse().ok(); + } else { + cmd.cols = value.parse().unwrap_or(0); + } + } + "r" => { + // r = rows for images, edit_frame for animation + if is_animation { + cmd.edit_frame = value.parse().ok(); + } else { + cmd.rows = value.parse().unwrap_or(0); + } + } + "X" => { + // X = x_offset for images, composition_mode for animation + if is_animation { + cmd.composition_mode = value.parse().unwrap_or(0); + } else { + cmd.x_offset = value.parse().unwrap_or(0); + log::debug!( + "Parsed X={} as x_offset={}", + value, + cmd.x_offset + ); + } + } + "Y" => { + // Y = y_offset for images, background_color for animation + if is_animation { + cmd.background_color = value.parse().ok(); + } else { + cmd.y_offset = value.parse().unwrap_or(0); + } + } + "z" => { + // z = z_index for images, frame_gap for animation frames + if matches!(cmd.action, Action::AnimationFrame) { + cmd.frame_gap = value.parse().ok(); + } else { + cmd.z_index = value.parse().unwrap_or(0); + } + } + "o" => { + if value == "z" { + cmd.compression = Some(Compression::Zlib); + } + } + "m" => cmd.more_chunks = value == "1", + "q" => cmd.quiet = value.parse().unwrap_or(0), + "C" => cmd.cursor_movement = value.parse().unwrap_or(0), + "U" => cmd.unicode_placeholder = value == "1", + "d" => { + // Delete target + cmd.delete_target = match value { + "a" | "A" => DeleteTarget::All, + "i" | "I" => DeleteTarget::ById(0), // ID set separately + "n" | "N" => DeleteTarget::ByNumber(0), + "c" | "C" => DeleteTarget::AtCursor, + "f" | "F" => DeleteTarget::AnimationFrames(0), + "p" | "P" | "q" | "Q" | "x" | "X" | "y" | "Y" | "z" + | "Z" => DeleteTarget::CellRange { + x: None, + y: None, + z: None, + }, + _ => DeleteTarget::All, + }; + } + _ => {} // Ignore unknown keys + } + } + + // Decode base64 payload + if !payload_part.is_empty() { + if let Ok(payload_str) = std::str::from_utf8(payload_part) { + if let Ok(decoded) = base64_decode(payload_str) { + cmd.payload = decoded; + } + } + } + + Some(cmd) + } + + /// Decompress payload if compressed. + pub fn decompress_payload(&mut self) -> Result<(), GraphicsError> { + if let Some(Compression::Zlib) = self.compression { + let mut decoder = ZlibDecoder::new(&self.payload[..]); + let mut decompressed = Vec::new(); + decoder + .read_to_end(&mut decompressed) + .map_err(|_| GraphicsError::DecompressionFailed)?; + self.payload = decompressed; + self.compression = None; + } + Ok(()) + } + + /// Decode PNG payload to RGBA pixels. + pub fn decode_png(&self) -> Result<(u32, u32, Vec), GraphicsError> { + let img = image::load_from_memory_with_format( + &self.payload, + ImageFormat::Png, + ) + .map_err(|_| GraphicsError::PngDecodeFailed)?; + let rgba = img.to_rgba8(); + let (width, height) = rgba.dimensions(); + Ok((width, height, rgba.into_raw())) + } + + /// Convert RGB payload to RGBA. + pub fn rgb_to_rgba(&self) -> Vec { + let mut rgba = Vec::with_capacity(self.payload.len() * 4 / 3); + for chunk in self.payload.chunks(3) { + if chunk.len() == 3 { + rgba.push(chunk[0]); + rgba.push(chunk[1]); + rgba.push(chunk[2]); + rgba.push(255); + } + } + rgba + } +} + +/// Decode a GIF image, returning dimensions and animation data. +/// Returns (width, height, first_frame_data, animation_data). +pub fn decode_gif( + data: &[u8], +) -> Result<(u32, u32, Vec, Option), GraphicsError> { + let cursor = Cursor::new(data); + let decoder = GifDecoder::new(cursor).map_err(|e| { + log::error!("GIF decode error: {}", e); + GraphicsError::GifDecodeFailed + })?; + + let frames_iter = decoder.into_frames(); + let mut frames = Vec::new(); + let mut width = 0u32; + let mut height = 0u32; + let mut total_duration_ms = 0u64; + + for frame_result in frames_iter { + let frame = frame_result.map_err(|e| { + log::error!("GIF frame decode error: {}", e); + GraphicsError::GifDecodeFailed + })?; + + let buffer = frame.buffer(); + let (w, h) = buffer.dimensions(); + width = w; + height = h; + + // Get frame delay (in milliseconds) + let delay = frame.delay(); + let (numer, denom) = delay.numer_denom_ms(); + let duration_ms = if denom > 0 { numer / denom } else { 100 }; + // GIF standard: delay of 0 means use default (100ms) + let duration_ms = if duration_ms == 0 { 100 } else { duration_ms }; + + total_duration_ms += duration_ms as u64; + + frames.push(AnimationFrame { + data: buffer.as_raw().clone(), + duration_ms, + }); + } + + if frames.is_empty() { + return Err(GraphicsError::GifDecodeFailed); + } + + log::debug!("Decoded GIF: {}x{}, {} frames, {}ms total duration", + width, height, frames.len(), total_duration_ms); + + let first_frame = frames[0].data.clone(); + + // If only one frame, treat as static image + let animation = if frames.len() > 1 { + Some(AnimationData { + frames, + current_frame: 0, + frame_start: None, + looping: true, + total_duration_ms, + state: AnimationState::Running, + loops_remaining: None, + }) + } else { + None + }; + + Ok((width, height, first_frame, animation)) +} + +/// Decode a WebM video file, returning dimensions and animation data. +/// Only decodes video stream, audio is ignored. +#[cfg(feature = "webm")] +pub fn decode_webm( + path: &str, +) -> Result<(u32, u32, Vec, Option), GraphicsError> { + use ffmpeg::format::{input, Pixel}; + use ffmpeg::media::Type; + use ffmpeg::software::scaling::{ + context::Context as ScalingContext, flag::Flags, + }; + use ffmpeg::util::frame::video::Video; + use ffmpeg_next as ffmpeg; + + // Initialize FFmpeg (safe to call multiple times) + ffmpeg::init().map_err(|e| { + log::error!("FFmpeg init error: {}", e); + GraphicsError::VideoDecodeFailed + })?; + + // Open the file + let mut input_ctx = input(&path).map_err(|e| { + log::error!("Failed to open video file {}: {}", path, e); + GraphicsError::FileReadFailed + })?; + + // Find the video stream + let video_stream = + input_ctx.streams().best(Type::Video).ok_or_else(|| { + log::error!("No video stream found in {}", path); + GraphicsError::VideoDecodeFailed + })?; + + let video_stream_index = video_stream.index(); + let time_base = video_stream.time_base(); + + // Get decoder for this stream + let context_decoder = ffmpeg::codec::context::Context::from_parameters( + video_stream.parameters(), + ) + .map_err(|e| { + log::error!("Failed to create decoder context: {}", e); + GraphicsError::VideoDecodeFailed + })?; + + let mut decoder = context_decoder.decoder().video().map_err(|e| { + log::error!("Failed to get video decoder: {}", e); + GraphicsError::VideoDecodeFailed + })?; + + let width = decoder.width(); + let height = decoder.height(); + + // Create scaler to convert to RGBA + let mut scaler = ScalingContext::get( + decoder.format(), + width, + height, + Pixel::RGBA, + width, + height, + Flags::BILINEAR, + ) + .map_err(|e| { + log::error!("Failed to create scaler: {}", e); + GraphicsError::VideoDecodeFailed + })?; + + let mut frames = Vec::new(); + let mut total_duration_ms = 0u64; + let mut last_pts: Option = None; + + // Process packets + for (stream, packet) in input_ctx.packets() { + if stream.index() != video_stream_index { + continue; + } + + decoder.send_packet(&packet).ok(); + + let mut decoded = Video::empty(); + while decoder.receive_frame(&mut decoded).is_ok() { + // Scale to RGBA + let mut rgba_frame = Video::empty(); + if scaler.run(&decoded, &mut rgba_frame).is_err() { + continue; + } + + // Get RGBA pixel data + let data = rgba_frame.data(0); + let stride = rgba_frame.stride(0); + + // Copy data, handling stride if needed + let mut rgba_data = + Vec::with_capacity((width * height * 4) as usize); + if stride == (width * 4) as usize { + rgba_data + .extend_from_slice(&data[..(width * height * 4) as usize]); + } else { + // Handle stride padding + for row in 0..height as usize { + let start = row * stride; + let end = start + (width * 4) as usize; + rgba_data.extend_from_slice(&data[start..end]); + } + } + + // Calculate frame duration from PTS + let pts = decoded.pts().unwrap_or(0); + let duration_ms = if let Some(last) = last_pts { + let pts_diff = pts - last; + ((pts_diff as f64) * f64::from(time_base) * 1000.0) as u32 + } else { + // First frame - estimate from frame rate or use default + 33 // ~30fps default + }; + last_pts = Some(pts); + + total_duration_ms += duration_ms as u64; + + frames.push(AnimationFrame { + data: rgba_data, + duration_ms: duration_ms.max(1), // Ensure at least 1ms + }); + } + } + + // Flush decoder + decoder.send_eof().ok(); + let mut decoded = Video::empty(); + while decoder.receive_frame(&mut decoded).is_ok() { + let mut rgba_frame = Video::empty(); + if scaler.run(&decoded, &mut rgba_frame).is_ok() { + let data = rgba_frame.data(0); + let stride = rgba_frame.stride(0); + let mut rgba_data = + Vec::with_capacity((width * height * 4) as usize); + if stride == (width * 4) as usize { + rgba_data + .extend_from_slice(&data[..(width * height * 4) as usize]); + } else { + for row in 0..height as usize { + let start = row * stride; + let end = start + (width * 4) as usize; + rgba_data.extend_from_slice(&data[start..end]); + } + } + frames.push(AnimationFrame { + data: rgba_data, + duration_ms: 33, + }); + total_duration_ms += 33; + } + } + + if frames.is_empty() { + return Err(GraphicsError::VideoDecodeFailed); + } + + let first_frame = frames[0].data.clone(); + + // Videos always have animation data (even if just one frame) + let animation = if frames.len() > 1 { + Some(AnimationData { + frames, + current_frame: 0, + frame_start: None, + looping: true, + total_duration_ms, + state: AnimationState::Running, + loops_remaining: None, + }) + } else { + None + }; + + log::debug!( + "Decoded WebM: {}x{}, {} frames, {}ms total", + width, + height, + animation.as_ref().map(|a| a.frames.len()).unwrap_or(1), + total_duration_ms + ); + + Ok((width, height, first_frame, animation)) +} + +/// Errors that can occur during graphics processing. +#[derive(Clone, Debug, PartialEq)] +pub enum GraphicsError { + /// Base64 decoding failed. + Base64DecodeFailed, + /// Zlib decompression failed. + DecompressionFailed, + /// PNG decoding failed. + PngDecodeFailed, + /// GIF decoding failed. + GifDecodeFailed, + /// WebM/video decoding failed. + VideoDecodeFailed, + /// Missing required dimensions. + MissingDimensions, + /// Invalid image data. + InvalidData, + /// Image not found. + ImageNotFound, + /// Missing image ID. + MissingId, + /// File read failed. + FileReadFailed, + /// Unsupported transmission or format. + UnsupportedFormat, +} + +/// Stored image data. +#[derive(Clone, Debug)] +pub struct ImageData { + /// Unique image ID. + pub id: u32, + /// Image width in pixels. + pub width: u32, + /// Image height in pixels. + pub height: u32, + /// RGBA pixel data (current frame for animated images). + pub data: Vec, + /// Animation data if this is an animated image. + pub animation: Option, +} + +/// Animation state for playback control. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub enum AnimationState { + /// Animation is stopped (s=1). + Stopped, + /// Animation is loading frames (s=2). + Loading, + /// Animation is running normally (s=3). + #[default] + Running, +} + +/// Animation data for GIF/WebM images. +#[derive(Clone, Debug)] +pub struct AnimationData { + /// All frames of the animation. + pub frames: Vec, + /// Current frame index. + pub current_frame: usize, + /// When the current frame started displaying. + pub frame_start: Option, + /// Whether the animation should loop. + pub looping: bool, + /// Total duration of one loop in milliseconds. + pub total_duration_ms: u64, + /// Playback state. + pub state: AnimationState, + /// Number of loops remaining (None = infinite). + pub loops_remaining: Option, +} + +/// A single frame in an animation. +#[derive(Clone, Debug)] +pub struct AnimationFrame { + /// RGBA pixel data for this frame. + pub data: Vec, + /// Duration to display this frame in milliseconds. + pub duration_ms: u32, +} + +/// Result of placing an image, used for cursor movement. +#[derive(Clone, Debug, Default)] +pub struct PlacementResult { + /// Number of columns the image spans. + pub cols: usize, + /// Number of rows the image spans. + pub rows: usize, + /// Whether cursor movement should be suppressed (C=1). + pub suppress_cursor_move: bool, + /// Whether this is a virtual/Unicode placeholder placement (U=1). + pub virtual_placement: bool, +} + +/// A placement of an image on the terminal grid. +#[derive(Clone, Debug)] +pub struct ImagePlacement { + /// Image ID this placement refers to. + pub image_id: u32, + /// Unique placement ID. + pub placement_id: u32, + /// Column position. + pub col: usize, + /// Row position (in scrollback-aware coordinates). + pub row: usize, + /// Display width in columns. + pub cols: usize, + /// Display height in rows. + pub rows: usize, + /// Z-index for layering. + pub z_index: i32, + /// Source rectangle X offset. + pub src_x: u32, + /// Source rectangle Y offset. + pub src_y: u32, + /// Source rectangle width. + pub src_width: u32, + /// Source rectangle height. + pub src_height: u32, + /// X offset within the first cell. + pub x_offset: u32, + /// Y offset within the first cell. + pub y_offset: u32, +} + +/// Storage for images and their placements. +#[derive(Default)] +pub struct ImageStorage { + /// Stored images by ID. + images: HashMap, + /// Active placements. + placements: Vec, + /// Buffer for chunked transmissions (image_id -> accumulated data). + chunk_buffer: HashMap, + /// Next auto-generated image ID. + next_id: u32, + /// Flag indicating images have changed and need re-upload to GPU. + pub dirty: bool, +} + +/// Buffer for accumulating chunked image data. +#[derive(Default)] +struct ChunkBuffer { + command: Option, + data: Vec, +} + +impl ImageStorage { + /// Create a new empty image storage. + pub fn new() -> Self { + Self { + images: HashMap::new(), + placements: Vec::new(), + chunk_buffer: HashMap::new(), + next_id: 1, + dirty: false, + } + } + + /// Process a graphics command and return an optional response and placement result. + /// The placement result contains dimensions for cursor movement after image display. + pub fn process_command( + &mut self, + mut cmd: GraphicsCommand, + cursor_col: usize, + cursor_row: usize, + cell_width: f32, + cell_height: f32, + ) -> (Option, Option) { + // Handle chunked transfer + if cmd.more_chunks { + let id = cmd.image_id.unwrap_or(0); + let buffer = self.chunk_buffer.entry(id).or_default(); + buffer.data.extend_from_slice(&cmd.payload); + if buffer.command.is_none() { + buffer.command = Some(cmd); + } + return (None, None); + } + + // Check if this completes a chunked transfer + let id = cmd.image_id.unwrap_or(0); + 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 { + buffered_cmd.payload = buffer.data; + cmd = buffered_cmd; + } + } + + match cmd.action { + Action::Query => (self.handle_query(&cmd), None), + Action::Transmit => (self.handle_transmit(cmd), None), + Action::TransmitAndDisplay => self.handle_transmit_and_display( + cmd, + cursor_col, + cursor_row, + cell_width, + cell_height, + ), + Action::Put => self.handle_put( + &cmd, + cursor_col, + cursor_row, + cell_width, + cell_height, + ), + Action::Delete => { + self.handle_delete(&cmd); + (None, None) + } + Action::AnimationFrame => { + let response = self.handle_animation_frame(cmd); + (response, None) + } + Action::AnimationControl => { + let response = self.handle_animation_control(&cmd); + (response, None) + } + } + } + + /// Handle a query command. + fn handle_query(&self, cmd: &GraphicsCommand) -> Option { + let id = cmd.image_id.unwrap_or(0); + // Respond with OK to indicate we support the protocol + Some(format!("\x1b_Gi={};OK\x1b\\", id)) + } + + /// Handle a transmit command (store image without displaying). + fn handle_transmit(&mut self, mut cmd: GraphicsCommand) -> Option { + let result = self.store_image(&mut cmd); + self.format_response(&cmd, result) + } + + /// Handle transmit and display. + fn handle_transmit_and_display( + &mut self, + mut cmd: GraphicsCommand, + cursor_col: usize, + cursor_row: usize, + cell_width: f32, + cell_height: f32, + ) -> (Option, Option) { + log::debug!( + "handle_transmit_and_display: transmission={:?}, payload_len={}", + cmd.transmission, + cmd.payload.len() + ); + + let suppress_cursor = cmd.cursor_movement == 1; + let virtual_placement = cmd.unicode_placeholder; + + let result = self.store_image(&mut cmd); + log::debug!("store_image result: {:?}", result); + + let placement_result = if let Ok(id) = result { + // Update cmd.image_id with the assigned ID (needed if it was None) + cmd.image_id = Some(id); + let (cols, rows) = self.place_image( + &cmd, + cursor_col, + cursor_row, + 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()); + Some(PlacementResult { + cols, + rows, + suppress_cursor_move: suppress_cursor, + virtual_placement, + }) + } else { + None + }; + (self.format_response(&cmd, result), placement_result) + } + + /// Handle a put command (display previously stored image). + fn handle_put( + &mut self, + cmd: &GraphicsCommand, + cursor_col: usize, + cursor_row: usize, + cell_width: f32, + cell_height: f32, + ) -> (Option, Option) { + let id = match cmd.image_id { + Some(id) => id, + None => return (None, None), + }; + + let suppress_cursor = cmd.cursor_movement == 1; + let virtual_placement = cmd.unicode_placeholder; + + if self.images.contains_key(&id) { + let (cols, rows) = self.place_image( + cmd, + cursor_col, + cursor_row, + cell_width, + cell_height, + ); + let placement_result = PlacementResult { + cols, + rows, + suppress_cursor_move: suppress_cursor, + virtual_placement, + }; + (self.format_response(cmd, Ok(id)), Some(placement_result)) + } else { + ( + self.format_response(cmd, Err(GraphicsError::ImageNotFound)), + None, + ) + } + } + + /// Handle a delete command. + fn handle_delete(&mut self, cmd: &GraphicsCommand) { + match &cmd.delete_target { + DeleteTarget::All => { + self.images.clear(); + self.placements.clear(); + self.dirty = true; + } + DeleteTarget::ById(id) => { + let id = cmd.image_id.unwrap_or(*id); + self.images.remove(&id); + self.placements.retain(|p| p.image_id != id); + self.dirty = true; + } + DeleteTarget::AtCursor => { + // Would need cursor position - simplified for now + self.placements.clear(); + self.dirty = true; + } + _ => { + // Other delete modes not yet implemented + } + } + } + + /// 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 { + let id = match cmd.image_id { + Some(id) => id, + None => { + log::warn!("AnimationFrame without image_id"); + return self.format_response(&cmd, Err(GraphicsError::MissingId)); + } + }; + + log::debug!( + "AnimationFrame: id={}, base_frame={:?}, edit_frame={:?}, frame_gap={:?}, size={}x{:?}, transmission={:?}, payload_len={}", + id, + cmd.base_frame, + cmd.edit_frame, + cmd.frame_gap, + cmd.width.unwrap_or(0), + cmd.height, + cmd.transmission, + cmd.payload.len() + ); + + // Handle file-based transmission (load actual data from file/shm) + match cmd.transmission { + Transmission::File | Transmission::TempFile => { + let path = match std::str::from_utf8(&cmd.payload) { + Ok(p) => p.trim().to_string(), + Err(_) => { + log::warn!("Invalid file path in animation frame"); + 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)); + } + } + // Delete temp file after reading + if cmd.transmission == Transmission::TempFile { + let _ = std::fs::remove_file(&path); + } + } + Transmission::SharedMemory => { + 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)); + } + }; + let shm_path = format!("/dev/shm/{}", shm_name); + 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()); + cmd.payload = data; + } + Err(e) => { + 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 + let _ = std::fs::remove_file(&shm_path); + } + Transmission::Direct => { + // Payload already contains the data + } + } + + // Decompress payload if needed + if let Err(e) = cmd.decompress_payload() { + log::warn!("Failed to decompress animation frame payload: {:?}", e); + return self.format_response(&cmd, Err(e)); + } + + // Get or decode the frame data + let frame_data = match cmd.format { + Format::Png => match cmd.decode_png() { + Ok((w, h, data)) => { + // Update dimensions from decoded PNG + cmd.width = Some(w); + cmd.height = Some(h); + data + } + Err(e) => { + log::warn!("Failed to decode animation frame PNG: {:?}", e); + return self.format_response(&cmd, Err(e)); + } + }, + Format::Rgba => cmd.payload.clone(), + Format::Rgb => cmd.rgb_to_rgba(), + Format::Gif => { + // Unlikely, but handle it + log::warn!("GIF format in animation frame - not supported"); + return self.format_response(&cmd, Err(GraphicsError::UnsupportedFormat)); + } + }; + + // Get the image and add the frame + let image = match self.images.get_mut(&id) { + Some(img) => img, + None => { + log::warn!("AnimationFrame for non-existent image {}", id); + 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 total_pixels = image.data.len() / 4; + log::debug!( + "Creating animation base frame: {}/{} pixels have alpha < 255, data len = {}", + transparent_count, + total_pixels, + image.data.len() + ); + + let base_frame = AnimationFrame { + data: image.data.clone(), + duration_ms: 100, // Default for base frame + }; + image.animation = Some(AnimationData { + frames: vec![base_frame], + current_frame: 0, + frame_start: None, + looping: true, + total_duration_ms: 100, + state: AnimationState::Loading, + loops_remaining: None, + }); + } + + // 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 + let final_frame_data = if let Some(base_frame_num) = cmd.base_frame { + // base_frame is 1-indexed (1 = root frame, 2 = second frame, etc.) + // We need to get the base frame data and composite the new data on top + let anim = image.animation.as_ref().unwrap(); // Safe: we just created it above + let base_idx = if base_frame_num == 0 { + 0 + } 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 { + // 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]; + composited[i + 1] = frame_data[i + 1]; + composited[i + 2] = frame_data[i + 2]; + composited[i + 3] = 255; + } else if src_a > 0 { + if cmd.composition_mode == 1 { + // Overwrite mode - replace pixel + composited[i] = frame_data[i]; + composited[i + 1] = frame_data[i + 1]; + composited[i + 2] = frame_data[i + 2]; + composited[i + 3] = frame_data[i + 3]; + } else { + // Alpha blend mode (default) + let src_r = frame_data[i] as u32; + 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; + } + } + // 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 total_pixels = composited.len() / 4; + if transparent_count > 0 { + log::debug!( + "After compositing: {}/{} pixels have alpha < 255", + transparent_count, + total_pixels + ); + } + + composited + } 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 {}", + frame_data.len(), + expected_size, + base_frame_num + ); + base_data.clone() + } else { + // Pad/truncate to expected size + let mut data = frame_data; + data.resize(expected_size, 0); + data + } + } 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); + let mut data = frame_data; + data.resize(expected_size, 0); + data + } + } else if frame_data.len() != expected_size { + // No base frame specified, ensure correct size + log::debug!( + "Frame data size {} != expected {}, resizing", + frame_data.len(), + expected_size + ); + let mut data = frame_data; + data.resize(expected_size, 0); + data + } else { + frame_data + }; + + // Frame gap - use provided value or default to 100ms + // Negative values mean "gapless" (use previous frame's timing) + let duration_ms = cmd.frame_gap.unwrap_or(100).max(0) as u32; + let duration_ms = if duration_ms == 0 { 100 } else { duration_ms }; + + // Create the frame + let frame = AnimationFrame { + data: final_frame_data, + duration_ms, + }; + + // 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; + } else { + // Append new frame + anim.total_duration_ms += duration_ms as u64; + anim.frames.push(frame); + } + + log::debug!( + "Added animation frame to image {}: now {} frames, {}ms total", + id, + anim.frames.len(), + anim.total_duration_ms + ); + } + + self.dirty = true; + + // Return OK response (quiet mode respected) + if cmd.quiet >= 1 { + None + } else { + Some(format!("\x1b_Gi={};OK\x1b\\", id)) + } + } + + /// Handle an animation control command (a=a). + /// This controls playback of an animated image. + fn handle_animation_control(&mut self, cmd: &GraphicsCommand) -> Option { + let id = match cmd.image_id { + Some(id) => id, + None => { + log::warn!("AnimationControl without image_id"); + return self.format_response(cmd, Err(GraphicsError::MissingId)); + } + }; + + log::debug!( + "AnimationControl: id={}, state={:?}, base_frame={:?}, loop_count={:?}", + id, + cmd.animation_state, + cmd.base_frame, + cmd.loop_count + ); + + let image = match self.images.get_mut(&id) { + Some(img) => img, + None => { + log::warn!("AnimationControl for non-existent image {}", id); + return self.format_response(cmd, Err(GraphicsError::ImageNotFound)); + } + }; + + if let Some(ref mut anim) = image.animation { + // Handle animation state (s key) + if let Some(state) = cmd.animation_state { + anim.state = match state { + 1 => { + log::debug!("Animation {} stopped", id); + AnimationState::Stopped + } + 2 => { + log::debug!("Animation {} loading", id); + AnimationState::Loading + } + 3 => { + log::debug!("Animation {} running ({} frames)", id, anim.frames.len()); + // Reset frame start when starting animation + anim.frame_start = None; + AnimationState::Running + } + _ => anim.state.clone(), + }; + } + + // Handle current frame (c key in control context) + if let Some(frame_num) = cmd.base_frame { + if frame_num > 0 && (frame_num as usize) <= anim.frames.len() { + anim.current_frame = frame_num as usize - 1; // 1-indexed to 0-indexed + // Update image data to show this frame + image.data = anim.frames[anim.current_frame].data.clone(); + anim.frame_start = None; // Reset timing + log::debug!("Animation {} jumped to frame {}", id, frame_num); + } + } + + // Handle loop count (v key) + if let Some(loop_count) = cmd.loop_count { + if loop_count == 0 { + anim.looping = true; + anim.loops_remaining = None; // Infinite + } else { + anim.looping = true; + anim.loops_remaining = Some(loop_count); + } + log::debug!("Animation {} loop count set to {:?}", id, anim.loops_remaining); + } + + self.dirty = true; + } else { + log::warn!("AnimationControl for non-animated image {}", id); + } + + // AnimationControl commands don't need OK responses by default + // Only errors are sent. This matches Kitty behavior - kitten icat + // doesn't set q= for a=a commands, but expects no OK response. + None + } + + /// Store an image from command data. + fn store_image( + &mut self, + cmd: &mut GraphicsCommand, + ) -> Result { + // Track if we loaded from a file (for WebM which needs path) + let mut file_path: Option = None; + + // Handle file-based transmission + match cmd.transmission { + Transmission::File | Transmission::TempFile => { + // Payload contains a file path + let path = std::str::from_utf8(&cmd.payload) + .map_err(|_| GraphicsError::FileReadFailed)?; + let path = path.trim().to_string(); // Clone to avoid borrow issues + + log::debug!("Reading image from file: {}", path); + + // Detect format from file extension + let path_lower = path.to_lowercase(); + if path_lower.ends_with(".gif") { + cmd.format = Format::Gif; + } else if path_lower.ends_with(".webm") + || path_lower.ends_with(".mp4") + || path_lower.ends_with(".mkv") + || path_lower.ends_with(".avi") + || path_lower.ends_with(".mov") + { + // For video files, we'll handle them specially + #[cfg(feature = "webm")] + { + file_path = Some(path.clone()); + } + #[cfg(not(feature = "webm"))] + { + log::warn!("WebM support not enabled, treating as PNG"); + } + } else if path_lower.ends_with(".png") { + cmd.format = Format::Png; + } + + // Read the file (unless it's a video file that needs the path) + if file_path.is_none() { + let file_data = std::fs::read(&path) + .map_err(|_| GraphicsError::FileReadFailed)?; + cmd.payload = file_data; + } + + // Delete temp file after reading + if cmd.transmission == Transmission::TempFile && file_path.is_none() { + let _ = std::fs::remove_file(&path); + } + } + Transmission::SharedMemory => { + // Payload contains a shared memory object name + let shm_name = std::str::from_utf8(&cmd.payload) + .map_err(|_| GraphicsError::FileReadFailed)?; + let shm_name = shm_name.trim(); + + log::debug!("Reading image from shared memory: {}", shm_name); + + // On Linux, shared memory objects are in /dev/shm/ + let shm_path = format!("/dev/shm/{}", shm_name); + + // Read the shared memory file + let file_data = std::fs::read(&shm_path).map_err(|e| { + log::error!( + "Failed to read shared memory {}: {}", + shm_path, + e + ); + GraphicsError::FileReadFailed + })?; + + // Remove the shared memory object after reading + let _ = std::fs::remove_file(&shm_path); + + cmd.payload = file_data; + } + Transmission::Direct => { + // 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" { + cmd.format = Format::Gif; + } + } + } + } + + // Decompress if needed (but not for video files) + if file_path.is_none() { + cmd.decompress_payload()?; + } + + // Decode image data - some formats may include animation data + let (width, height, data, animation) = if let Some(path) = file_path { + // Handle video files via FFmpeg + #[cfg(feature = "webm")] + { + decode_webm(&path)? + } + #[cfg(not(feature = "webm"))] + { + let _ = path; + return Err(GraphicsError::UnsupportedFormat); + } + } else { + match cmd.format { + Format::Png => { + let (w, h, d) = cmd.decode_png()?; + (w, h, d, None) + } + Format::Rgba => { + let w = cmd.width.ok_or(GraphicsError::MissingDimensions)?; + let h = cmd.height.ok_or(GraphicsError::MissingDimensions)?; + (w, h, cmd.payload.clone(), None) + } + Format::Rgb => { + let w = cmd.width.ok_or(GraphicsError::MissingDimensions)?; + let h = cmd.height.ok_or(GraphicsError::MissingDimensions)?; + (w, h, cmd.rgb_to_rgba(), None) + } + Format::Gif => decode_gif(&cmd.payload)?, + } + }; + + // Assign ID if not provided + let id = cmd.image_id.unwrap_or_else(|| { + let id = self.next_id; + self.next_id += 1; + id + }); + + self.images.insert( + id, + ImageData { + id, + width, + height, + data, + animation, + }, + ); + self.dirty = true; + + Ok(id) + } + + /// Place an image at the cursor position. + /// Returns (cols, rows) dimensions of the placement. + fn place_image( + &mut self, + cmd: &GraphicsCommand, + cursor_col: usize, + cursor_row: usize, + cell_width: f32, + cell_height: f32, + ) -> (usize, usize) { + let id = match cmd.image_id { + Some(id) => id, + None => return (0, 0), + }; + + let image = match self.images.get(&id) { + Some(img) => img, + None => return (0, 0), + }; + + // Calculate display size + let src_width = if cmd.src_width == 0 { + image.width + } else { + cmd.src_width + }; + let src_height = if cmd.src_height == 0 { + image.height + } else { + cmd.src_height + }; + + // Calculate columns and rows if not specified + let cols = if cmd.cols == 0 { + ((src_width as f32) / cell_width).ceil() as usize + } else { + cmd.cols as usize + }; + let rows = if cmd.rows == 0 { + ((src_height as f32) / cell_height).ceil() as usize + } else { + cmd.rows as usize + }; + + // Don't create actual placement for virtual placements (U=1) + // Virtual placements are referenced by Unicode placeholders + if cmd.unicode_placeholder { + log::debug!( + "Virtual placement for image id={}, cols={} rows={}", + id, + cols, + rows + ); + return (cols, rows); + } + + let placement = ImagePlacement { + image_id: id, + placement_id: cmd.placement_id.unwrap_or(0), + col: cursor_col, + row: cursor_row, + cols, + rows, + z_index: cmd.z_index, + src_x: cmd.src_x, + src_y: cmd.src_y, + src_width, + src_height, + x_offset: cmd.x_offset, + 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 + }); + } + + self.placements.push(placement); + self.dirty = true; + + (cols, rows) + } + + /// Format a response for the application. + fn format_response( + &self, + cmd: &GraphicsCommand, + result: Result, + ) -> Option { + // Quiet mode 2 suppresses all responses + if cmd.quiet >= 2 { + return None; + } + + match result { + Ok(id) => { + // Quiet mode 1 suppresses OK responses + if cmd.quiet >= 1 { + None + } else { + Some(format!("\x1b_Gi={};OK\x1b\\", id)) + } + } + Err(e) => { + let msg = match e { + GraphicsError::Base64DecodeFailed => { + "ENODATA:base64 decode failed" + } + GraphicsError::DecompressionFailed => { + "ENODATA:decompression failed" + } + GraphicsError::PngDecodeFailed => { + "ENODATA:PNG decode failed" + } + GraphicsError::GifDecodeFailed => { + "ENODATA:GIF decode failed" + } + GraphicsError::VideoDecodeFailed => { + "ENODATA:video decode failed" + } + GraphicsError::MissingDimensions => { + "EINVAL:missing dimensions" + } + GraphicsError::InvalidData => "ENODATA:invalid data", + GraphicsError::ImageNotFound => "ENOENT:image not found", + GraphicsError::MissingId => "EINVAL:missing image id", + GraphicsError::FileReadFailed => "ENOENT:file read failed", + GraphicsError::UnsupportedFormat => { + "EINVAL:unsupported format" + } + }; + let id = cmd.image_id.unwrap_or(0); + Some(format!("\x1b_Gi={};{}\x1b\\", id, msg)) + } + } + } + + /// Get all images. + pub fn images(&self) -> &HashMap { + &self.images + } + + /// Get all active placements. + pub fn placements(&self) -> &[ImagePlacement] { + &self.placements + } + + /// Get an image by ID. + pub fn get_image(&self, id: u32) -> Option<&ImageData> { + self.images.get(&id) + } + + /// Clear the dirty flag. + pub fn clear_dirty(&mut self) { + self.dirty = false; + } + + /// Update animations and return list of image IDs that changed frames. + /// This should be called every frame to advance animations. + pub fn update_animations(&mut self) -> Vec { + let now = Instant::now(); + let mut changed = Vec::new(); + + for (id, image) in self.images.iter_mut() { + if let Some(ref mut anim) = image.animation { + // Only advance if animation is running + if anim.state != AnimationState::Running { + continue; + } + + // 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); + } + + 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; + + if elapsed >= current_frame_duration { + // Advance to next frame + let old_frame = anim.current_frame; + let next_frame = anim.current_frame + 1; + if next_frame >= anim.frames.len() { + if anim.looping { + // Check loop count + if let Some(ref mut loops) = anim.loops_remaining { + if *loops > 0 { + *loops -= 1; + anim.current_frame = 0; + } else { + // No more loops, stop + anim.state = AnimationState::Stopped; + continue; + } + } else { + // Infinite looping + anim.current_frame = 0; + } + } + // 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); + + // Update the image data with the new frame + image.data = anim.frames[anim.current_frame].data.clone(); + anim.frame_start = Some(now); + changed.push(*id); + } + } + } + + if !changed.is_empty() { + self.dirty = true; + } + + changed + } + + /// Check if any images have running animations. + pub fn has_animations(&self) -> bool { + self.images.values().any(|img| { + img.animation + .as_ref() + .map(|a| a.state == AnimationState::Running && a.frames.len() > 1) + .unwrap_or(false) + }) + } + + /// Get mutable access to an image by ID. + pub fn get_image_mut(&mut self, id: u32) -> Option<&mut ImageData> { + self.images.get_mut(&id) + } +} + +/// Simple base64 decoder. +fn base64_decode(input: &str) -> Result, GraphicsError> { + const DECODE_TABLE: [i8; 256] = { + let mut table = [-1i8; 256]; + let chars = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut i = 0; + while i < 64 { + table[chars[i] as usize] = i as i8; + i += 1; + } + table + }; + + let input = input.as_bytes(); + let mut output = Vec::with_capacity(input.len() * 3 / 4); + let mut buffer = 0u32; + let mut bits = 0; + + for &byte in input { + if byte == b'=' || byte == b'\n' || byte == b'\r' || byte == b' ' { + continue; + } + let value = DECODE_TABLE[byte as usize]; + if value < 0 { + return Err(GraphicsError::Base64DecodeFailed); + } + buffer = (buffer << 6) | (value as u32); + bits += 6; + if bits >= 8 { + bits -= 8; + output.push((buffer >> bits) as u8); + buffer &= (1 << bits) - 1; + } + } + + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_basic_command() { + let data = b"Ga=T,f=100,i=1;iVBORw0KGgo="; + let cmd = GraphicsCommand::parse(data).unwrap(); + assert_eq!(cmd.action, Action::TransmitAndDisplay); + assert_eq!(cmd.format, Format::Png); + assert_eq!(cmd.image_id, Some(1)); + } + + #[test] + fn test_parse_query() { + let data = b"Ga=q,i=31;"; + let cmd = GraphicsCommand::parse(data).unwrap(); + assert_eq!(cmd.action, Action::Query); + assert_eq!(cmd.image_id, Some(31)); + } + + #[test] + fn test_base64_decode() { + let decoded = base64_decode("SGVsbG8=").unwrap(); + assert_eq!(decoded, b"Hello"); + } +} diff --git a/src/image_shader.wgsl b/src/image_shader.wgsl new file mode 100644 index 0000000..9cae00b --- /dev/null +++ b/src/image_shader.wgsl @@ -0,0 +1,92 @@ +// Image rendering shader for Kitty graphics protocol +// Renders RGBA images with proper alpha blending + +struct ImageUniforms { + // Screen dimensions in pixels + screen_width: f32, + screen_height: f32, + // Image position in pixels (top-left corner) + pos_x: f32, + pos_y: f32, + // Image display size in pixels + display_width: f32, + display_height: f32, + // Source rectangle in normalized coordinates (0-1) + src_x: f32, + src_y: f32, + src_width: f32, + src_height: f32, + // Padding for alignment + _padding1: f32, + _padding2: f32, +} + +@group(0) @binding(0) +var uniforms: ImageUniforms; + +@group(0) @binding(1) +var image_texture: texture_2d; + +@group(0) @binding(2) +var image_sampler: sampler; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) uv: vec2, +} + +// Convert pixel coordinate to NDC +fn pixel_to_ndc(pixel: vec2, screen: vec2) -> vec2 { + return vec2( + (pixel.x / screen.x) * 2.0 - 1.0, + 1.0 - (pixel.y / screen.y) * 2.0 + ); +} + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + + // Calculate quad corners in pixel space + let x0 = uniforms.pos_x; + let y0 = uniforms.pos_y; + let x1 = uniforms.pos_x + uniforms.display_width; + let y1 = uniforms.pos_y + uniforms.display_height; + + // Quad vertex positions (0=top-left, 1=top-right, 2=bottom-left, 3=bottom-right) + // Using triangle strip order + var positions: array, 4>; + positions[0] = vec2(x0, y0); + positions[1] = vec2(x1, y0); + positions[2] = vec2(x0, y1); + positions[3] = vec2(x1, y1); + + // UV coordinates mapping to source rectangle + var uvs: array, 4>; + let u0 = uniforms.src_x; + let v0 = uniforms.src_y; + let u1 = uniforms.src_x + uniforms.src_width; + let v1 = uniforms.src_y + uniforms.src_height; + + uvs[0] = vec2(u0, v0); + uvs[1] = vec2(u1, v0); + uvs[2] = vec2(u0, v1); + uvs[3] = vec2(u1, v1); + + let screen_size = vec2(uniforms.screen_width, uniforms.screen_height); + let ndc_pos = pixel_to_ndc(positions[vertex_index], screen_size); + + out.clip_position = vec4(ndc_pos, 0.0, 1.0); + out.uv = uvs[vertex_index]; + + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + // Sample the image texture + let color = textureSample(image_texture, image_sampler, in.uv); + + // Return with premultiplied alpha for proper blending + return vec4(color.rgb * color.a, color.a); +} diff --git a/src/lib.rs b/src/lib.rs index c4af061..95882f0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ //! Single-process architecture: one process owns PTY, terminal state, and rendering. pub mod config; +pub mod graphics; pub mod keyboard; pub mod pty; pub mod renderer; diff --git a/src/main.rs b/src/main.rs index 119a8ea..834cd79 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use polling::{Event, Events, Poller}; use winit::application::ApplicationHandler; use winit::dpi::{PhysicalPosition, PhysicalSize}; @@ -54,9 +55,18 @@ struct DoubleBuffer { impl SharedPtyBuffer { fn new() -> Self { + // Use with_capacity to avoid zeroing memory - we only need the allocation + let mut buf1 = Vec::with_capacity(PTY_BUF_SIZE); + let mut buf2 = Vec::with_capacity(PTY_BUF_SIZE); + // SAFETY: We're setting length to capacity. The data is uninitialized but + // we only read from portions that have been written to (tracked by write_len). + unsafe { + buf1.set_len(PTY_BUF_SIZE); + buf2.set_len(PTY_BUF_SIZE); + } Self { inner: Mutex::new(DoubleBuffer { - bufs: [vec![0u8; PTY_BUF_SIZE], vec![0u8; PTY_BUF_SIZE]], + bufs: [buf1, buf2], write_idx: 0, write_len: 0, }), @@ -158,8 +168,15 @@ impl Pane { let terminal = Terminal::new(cols, rows, scrollback_lines); let pty = Pty::spawn(None).map_err(|e| format!("Failed to spawn PTY: {}", e))?; - // Set terminal size - if let Err(e) = pty.resize(cols as u16, rows as u16) { + // Set terminal size (use default cell size estimate for initial pixel dimensions) + let default_cell_width = 10u16; + let default_cell_height = 20u16; + if let Err(e) = pty.resize( + cols as u16, + rows as u16, + cols as u16 * default_cell_width, + rows as u16 * default_cell_height, + ) { log::warn!("Failed to set initial PTY size: {}", e); } @@ -180,9 +197,9 @@ impl Pane { } /// Resize the terminal and PTY. - fn resize(&mut self, cols: usize, rows: usize) { + fn resize(&mut self, cols: usize, rows: usize, width_px: u16, height_px: u16) { self.terminal.resize(cols, rows); - if let Err(e) = self.pty.resize(cols as u16, rows as u16) { + if let Err(e) = self.pty.resize(cols as u16, rows as u16, width_px, height_px) { log::warn!("Failed to resize PTY: {}", e); } } @@ -588,7 +605,11 @@ impl Tab { for (pane_id, geom) in geometries { if let Some(pane) = self.panes.get_mut(&pane_id) { - pane.resize(geom.cols, geom.rows); + // Report pixel dimensions as exact cell grid size (cols * cell_width, rows * cell_height) + // This ensures applications like kitten icat calculate image placement correctly + let pixel_width = (geom.cols as f32 * cell_width) as u16; + let pixel_height = (geom.rows as f32 * cell_height) as u16; + pane.resize(geom.cols, geom.rows, pixel_width, pixel_height); } } } @@ -784,6 +805,8 @@ enum UserEvent { ShowWindow, /// PTY has data available for a specific pane. PtyReadable(PaneId), + /// Config file was modified and should be reloaded. + ConfigReloaded, } /// Main application state. @@ -816,8 +839,8 @@ struct App { last_frame_log: std::time::Instant, /// Whether window should be created on next opportunity. should_create_window: bool, - /// Edge glow animation state (for when navigation fails). - edge_glow: Option, + /// Edge glow animations (for when navigation fails). Multiple can be active simultaneously. + edge_glows: Vec, } const PTY_KEY: usize = 1; @@ -848,7 +871,7 @@ impl App { frame_count: 0, last_frame_log: std::time::Instant::now(), should_create_window: false, - edge_glow: None, + edge_glows: Vec::new(), } } @@ -856,6 +879,50 @@ impl App { self.event_loop_proxy = Some(proxy); } + /// Reload configuration from disk and apply changes. + fn reload_config(&mut self) { + log::info!("Reloading configuration..."); + let new_config = Config::load(); + + // Check what changed and apply updates + let font_size_changed = (new_config.font_size - self.config.font_size).abs() > 0.01; + let opacity_changed = (new_config.background_opacity - self.config.background_opacity).abs() > 0.01; + let tab_bar_changed = new_config.tab_bar_position != self.config.tab_bar_position; + + // Update the config + self.config = new_config; + + // Rebuild action map for keybindings + self.action_map = self.config.keybindings.build_action_map(); + + // Apply renderer changes if we have a renderer + if let Some(renderer) = &mut self.renderer { + if opacity_changed { + renderer.set_background_opacity(self.config.background_opacity); + log::info!("Updated background opacity to {}", self.config.background_opacity); + } + + if tab_bar_changed { + renderer.set_tab_bar_position(self.config.tab_bar_position); + log::info!("Updated tab bar position to {:?}", self.config.tab_bar_position); + } + + if font_size_changed { + renderer.set_font_size(self.config.font_size); + log::info!("Updated font size to {}", self.config.font_size); + // Font size change requires resize to recalculate cell dimensions + self.resize_all_panes(); + } + } + + // Request redraw to apply visual changes + if let Some(window) = &self.window { + window.request_redraw(); + } + + log::info!("Configuration reloaded successfully"); + } + /// Create a new tab and start its I/O thread. /// Returns the index of the new tab. fn create_tab(&mut self, cols: usize, rows: usize) -> Option { @@ -1046,6 +1113,11 @@ impl App { for tab in &mut self.tabs { tab.resize(width, height, cell_width, cell_height, border_width); + + // Update cell size on all terminals (needed for Kitty graphics protocol) + for pane in tab.panes.values_mut() { + pane.terminal.set_cell_size(cell_width, cell_height); + } } } @@ -1368,6 +1440,13 @@ impl App { } fn focus_pane(&mut self, direction: Direction) { + // Get current active pane geometry before attempting navigation + let active_pane_geom = if let Some(tab) = self.tabs.get(self.active_tab) { + tab.split_root.find_geometry(tab.active_pane) + } else { + None + }; + let navigated = if let Some(tab) = self.tabs.get_mut(self.active_tab) { let old_pane = tab.active_pane; tab.focus_neighbor(direction); @@ -1378,7 +1457,16 @@ impl App { if !navigated { // No neighbor in that direction - trigger edge glow animation - self.edge_glow = Some(EdgeGlow::new(direction)); + // Add to existing glows (don't replace) so multiple can be visible + if let Some(geom) = active_pane_geom { + self.edge_glows.push(EdgeGlow::new( + direction, + geom.x, + geom.y, + geom.width, + geom.height, + )); + } } if let Some(window) = &self.window { @@ -1605,9 +1693,11 @@ impl App { impl ApplicationHandler for App { fn resumed(&mut self, event_loop: &ActiveEventLoop) { + let start = std::time::Instant::now(); if self.window.is_none() { self.create_window(event_loop); } + log::info!("App resumed (window creation): {:?}", start.elapsed()); } fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { @@ -1633,6 +1723,9 @@ impl ApplicationHandler for App { log::info!("PTY process took {:?}", process_time); } } + UserEvent::ConfigReloaded => { + self.reload_config(); + } } } @@ -1851,13 +1944,16 @@ impl ApplicationHandler for App { let geometries = tab.collect_pane_geometries(); let active_pane_id = tab.active_pane; - // First pass: calculate dim factors (needs mutable access) + // First pass: sync images and calculate dim factors (needs mutable access) let mut dim_factors: Vec<(PaneId, f32)> = Vec::new(); for (pane_id, _) in &geometries { if let Some(pane) = tab.panes.get_mut(pane_id) { let is_active = *pane_id == active_pane_id; let dim_factor = pane.calculate_dim_factor(is_active, fade_duration_ms, inactive_dim); dim_factors.push((*pane_id, dim_factor)); + + // Sync terminal images to GPU (Kitty graphics protocol) + renderer.sync_images(&mut pane.terminal.image_storage); } } @@ -1914,11 +2010,15 @@ impl ApplicationHandler for App { } } - // Handle edge glow animation - let edge_glow_ref = self.edge_glow.as_ref(); - let glow_in_progress = edge_glow_ref.map(|g| !g.is_finished()).unwrap_or(false); + // Handle edge glow animations + let glow_in_progress = !self.edge_glows.is_empty(); - match renderer.render_panes(&pane_render_data, num_tabs, active_tab_idx, edge_glow_ref) { + // Check if any pane has animated images + let image_animation_in_progress = tab.panes.values().any(|pane| { + pane.terminal.image_storage.has_animations() + }); + + match renderer.render_panes(&pane_render_data, num_tabs, active_tab_idx, &self.edge_glows, self.config.edge_glow_intensity) { Ok(_) => {} Err(wgpu::SurfaceError::Lost) => { renderer.resize(renderer.width, renderer.height); @@ -1932,8 +2032,8 @@ impl ApplicationHandler for App { } } - // Request redraw if edge glow is animating - if glow_in_progress { + // Request redraw if edge glow or image animation is in progress + if glow_in_progress || image_animation_in_progress { if let Some(window) = &self.window { window.request_redraw(); } @@ -1941,10 +2041,8 @@ impl ApplicationHandler for App { } } - // Clean up finished edge glow animation - if self.edge_glow.as_ref().map(|g| g.is_finished()).unwrap_or(false) { - self.edge_glow = None; - } + // Clean up finished edge glow animations + self.edge_glows.retain(|g| !g.is_finished()); let render_time = render_start.elapsed(); let frame_time = frame_start.elapsed(); @@ -2000,6 +2098,69 @@ impl Drop for App { } } +/// Set up a file watcher to monitor the config file for changes. +/// Returns the watcher (must be kept alive for watching to continue). +fn setup_config_watcher(proxy: EventLoopProxy) -> Option { + let config_path = match Config::config_path() { + Some(path) => path, + None => { + log::warn!("Could not determine config path, config hot-reload disabled"); + return None; + } + }; + + // Watch the parent directory since the file might be replaced atomically + let watch_path = match config_path.parent() { + Some(parent) => parent.to_path_buf(), + None => { + log::warn!("Could not determine config directory, config hot-reload disabled"); + return None; + } + }; + + let config_filename = config_path.file_name().map(|s| s.to_os_string()); + + let mut watcher = match notify::recommended_watcher(move |res: Result| { + match res { + Ok(event) => { + // Only trigger on modify/create events for the config file + use notify::EventKind; + match event.kind { + EventKind::Modify(_) | EventKind::Create(_) => { + // Check if the event is for our config file + let is_config_file = event.paths.iter().any(|p| { + p.file_name().map(|s| s.to_os_string()) == config_filename + }); + + if is_config_file { + log::debug!("Config file changed, triggering reload"); + let _ = proxy.send_event(UserEvent::ConfigReloaded); + } + } + _ => {} + } + } + Err(e) => { + log::warn!("Config watcher error: {:?}", e); + } + } + }) { + Ok(w) => w, + Err(e) => { + log::warn!("Failed to create config watcher: {:?}", e); + return None; + } + }; + + if let Err(e) = watcher.watch(&watch_path, RecursiveMode::NonRecursive) { + log::warn!("Failed to watch config directory {:?}: {:?}", watch_path, e); + return None; + } + + log::info!("Config hot-reload enabled, watching {:?}", watch_path); + Some(watcher) +} + fn main() { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); @@ -2035,9 +2196,12 @@ fn main() { // Store proxy for signal handler (uses the global static defined below) unsafe { - EVENT_PROXY = Some(proxy); + EVENT_PROXY = Some(proxy.clone()); } + // Set up config file watcher for hot-reloading + let _config_watcher = setup_config_watcher(proxy); + event_loop.run_app(&mut app).expect("Event loop error"); } diff --git a/src/pty.rs b/src/pty.rs index ff1585f..2445487 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -150,12 +150,12 @@ impl Pty { } /// Resizes the PTY window. - pub fn resize(&self, cols: u16, rows: 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, - ws_xpixel: 0, - ws_ypixel: 0, + ws_xpixel: xpixel, + ws_ypixel: ypixel, }; let fd = std::os::fd::AsRawFd::as_raw_fd(&self.master); diff --git a/src/renderer.rs b/src/renderer.rs index 09c46d0..138d82b 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -2,10 +2,12 @@ //! Uses rustybuzz (HarfBuzz port) for text shaping to support font features. use crate::config::TabBarPosition; +use crate::graphics::{ImageData, ImagePlacement, ImageStorage}; use crate::terminal::{Color, ColorPalette, CursorShape, Direction, Terminal}; -use fontdue::Font as FontdueFont; +use ab_glyph::{Font, FontRef, GlyphId, ScaleFont}; use rustybuzz::UnicodeBuffer; use ttf_parser::Tag; +use std::cell::OnceCell; use std::collections::{HashMap, HashSet}; use std::ffi::CStr; use std::path::PathBuf; @@ -47,27 +49,39 @@ pub struct EdgeGlow { pub direction: Direction, /// When the animation started. pub start_time: std::time::Instant, + /// Pane bounds - left edge in pixels. + pub pane_x: f32, + /// Pane bounds - top edge in pixels. + pub pane_y: f32, + /// Pane bounds - width in pixels. + pub pane_width: f32, + /// Pane bounds - height in pixels. + pub pane_height: f32, } impl EdgeGlow { /// Duration of the glow animation in milliseconds. pub const DURATION_MS: u64 = 500; - - /// Create a new edge glow animation. - pub fn new(direction: Direction) -> Self { + + /// Create a new edge glow animation constrained to a pane's bounds. + pub fn new(direction: Direction, pane_x: f32, pane_y: f32, pane_width: f32, pane_height: f32) -> Self { Self { direction, start_time: std::time::Instant::now(), + pane_x, + pane_y, + pane_width, + pane_height, } } - + /// Get the current animation progress (0.0 to 1.0). pub fn progress(&self) -> f32 { let elapsed = self.start_time.elapsed().as_millis() as f32; let duration = Self::DURATION_MS as f32; (elapsed / duration).min(1.0) } - + /// Check if the animation has completed. pub fn is_finished(&self) -> bool { self.progress() >= 1.0 @@ -99,11 +113,20 @@ struct ShapingContext { /// Result of shaping a text sequence. #[derive(Clone, Debug)] struct ShapedGlyphs { - /// Glyph IDs, advances, and cluster indices. - /// Each tuple is (glyph_id, advance, cluster). - glyphs: Vec<(u16, f32, u32)>, - /// Whether this represents a ligature (one visual glyph for multiple characters). - is_ligature: bool, + /// Glyph IDs, advances, offsets, and cluster indices. + /// Each tuple is (glyph_id, x_advance, x_offset, y_offset, cluster). + /// x_offset/y_offset are for texture healing - they shift the glyph without affecting advance. + glyphs: Vec<(u16, f32, f32, f32, u32)>, +} + +/// Cached cell sprites for a text run. +/// When we render a text run using Kitty's canvas approach, we get one sprite per cell. +/// This caches those sprites so we don't re-render the same text runs. +#[derive(Clone, Debug)] +struct TextRunSprites { + /// UV coordinates and sizes for each cell in the run. + /// Each entry is (uv_x, uv_y, uv_w, uv_h) in the atlas, plus cell_width and cell_height. + cells: Vec<[f32; 4]>, } /// Vertex for rendering textured quads. @@ -133,6 +156,29 @@ impl GlyphVertex { } } +/// Maximum number of simultaneous edge glows. +const MAX_EDGE_GLOWS: usize = 16; + +/// Per-glow instance data (48 bytes, aligned to 16 bytes). +/// Must match GlowInstance in shader.wgsl exactly. +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +struct GlowInstance { + direction: u32, + progress: f32, + color_r: f32, + color_g: f32, + color_b: f32, + // Pane bounds in pixels + pane_x: f32, + pane_y: f32, + pane_width: f32, + pane_height: f32, + _padding1: f32, + _padding2: f32, + _padding3: f32, +} + /// GPU-compatible edge glow uniform data. /// Must match the layout in shader.wgsl exactly. #[repr(C)] @@ -141,15 +187,40 @@ struct EdgeGlowUniforms { screen_width: f32, screen_height: f32, terminal_y_offset: f32, - direction: u32, - progress: f32, - color_r: f32, - color_g: f32, - color_b: f32, - enabled: u32, - _padding1: u32, - _padding2: u32, - _padding3: u32, + glow_intensity: f32, + glow_count: u32, + _padding: [u32; 3], // Pad to 16-byte alignment before array + glows: [GlowInstance; MAX_EDGE_GLOWS], +} + +/// GPU-compatible image uniform data. +/// Must match the layout in image_shader.wgsl exactly. +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +struct ImageUniforms { + screen_width: f32, + screen_height: f32, + pos_x: f32, + pos_y: f32, + display_width: f32, + display_height: f32, + src_x: f32, + src_y: f32, + src_width: f32, + src_height: f32, + _padding1: f32, + _padding2: f32, +} + +/// Cached GPU texture for an image. +#[allow(dead_code)] +struct GpuImage { + texture: wgpu::Texture, + view: wgpu::TextureView, + uniform_buffer: wgpu::Buffer, + bind_group: wgpu::BindGroup, + width: u32, + height: u32, } /// The terminal renderer. @@ -162,30 +233,47 @@ pub struct Renderer { // Glyph rendering pipeline glyph_pipeline: wgpu::RenderPipeline, glyph_bind_group: wgpu::BindGroup, - + // Edge glow rendering pipeline edge_glow_pipeline: wgpu::RenderPipeline, edge_glow_bind_group: wgpu::BindGroup, edge_glow_uniform_buffer: wgpu::Buffer, + // Image rendering pipeline (Kitty graphics protocol) + image_pipeline: wgpu::RenderPipeline, + image_bind_group_layout: wgpu::BindGroupLayout, + image_sampler: wgpu::Sampler, + /// Cached GPU textures for images, keyed by image ID. + image_textures: HashMap, + // Atlas texture atlas_texture: wgpu::Texture, atlas_data: Vec, atlas_dirty: bool, // Font and shaping - #[allow(dead_code)] // Kept alive for rustybuzz::Face which borrows it + #[allow(dead_code)] // Kept alive for rustybuzz::Face and FontRef which borrow it font_data: Box<[u8]>, - fontdue_font: FontdueFont, - fallback_fonts: Vec, - /// Fontconfig handle for dynamic font discovery - fontconfig: Option, + /// Primary font for rasterization (borrows font_data) + primary_font: FontRef<'static>, + /// Fallback fonts with their owned data + fallback_fonts: Vec<(Box<[u8]>, FontRef<'static>)>, + /// Fontconfig handle for dynamic font discovery (lazy initialized) + fontconfig: OnceCell>, /// Set of font paths we've already tried (to avoid reloading) tried_font_paths: HashSet, shaping_ctx: ShapingContext, char_cache: HashMap, // cache char -> rendered glyph ligature_cache: HashMap, // cache multi-char -> shaped glyphs glyph_cache: HashMap<(usize, u16), GlyphInfo>, // keyed by (font_index, glyph ID) + /// Cache for text run sprites (Kitty-style texture healing). + /// Keyed by the text string of the run. Value contains UV coords for each cell. + text_run_cache: HashMap, + /// Reusable canvas buffer for rendering text runs (Kitty-style texture healing). + /// This is a temporary buffer we render multiple glyphs into, then slice into cells. + canvas_buffer: Vec, + /// Current canvas dimensions (width, height) in pixels. + canvas_size: (u32, u32), atlas_cursor_x: u32, atlas_cursor_y: u32, atlas_row_height: u32, @@ -205,6 +293,9 @@ pub struct Renderer { dpi: f64, /// Effective font size in pixels (base_font_size * scale_factor). pub font_size: f32, + /// Scale factor to convert font units to pixels. + /// This is font_size / height_unscaled, matching ab_glyph's calculation. + font_units_to_px: f32, /// Cell dimensions in pixels. pub cell_width: f32, pub cell_height: f32, @@ -217,7 +308,7 @@ pub struct Renderer { tab_bar_position: TabBarPosition, /// Background opacity (0.0 = transparent, 1.0 = opaque). background_opacity: f32, - + // Reusable vertex/index buffers to avoid per-frame allocations bg_vertices: Vec, bg_indices: Vec, @@ -225,7 +316,7 @@ pub struct Renderer { glyph_indices: Vec, overlay_vertices: Vec, overlay_indices: Vec, - + /// Current selection range for rendering (start_col, start_row, end_col, end_row). /// If set, cells within this range will be rendered with inverted colors. selection: Option<(usize, usize, usize, usize)>, @@ -240,36 +331,36 @@ pub struct Renderer { fn find_font_for_char(_fc: &Fontconfig, c: char) -> Option { use fontconfig_sys as fcsys; use fcsys::*; - + unsafe { // Create a pattern let pat = FcPatternCreate(); if pat.is_null() { return None; } - + // Create a charset with the target character let charset = FcCharSetCreate(); if charset.is_null() { FcPatternDestroy(pat); return None; } - + // Add the character to the charset FcCharSetAddChar(charset, c as u32); - + // Add the charset to the pattern let fc_charset_cstr = CStr::from_bytes_with_nul(b"charset\0").unwrap(); FcPatternAddCharSet(pat, fc_charset_cstr.as_ptr(), charset); - + // Run substitutions FcConfigSubstitute(std::ptr::null_mut(), pat, FcMatchPattern); FcDefaultSubstitute(pat); - + // Find matching font let mut result = FcResultNoMatch; let matched = FcFontMatch(std::ptr::null_mut(), pat, &mut result); - + let font_path = if !matched.is_null() && result == FcResultMatch { // Get the file path from the matched pattern let mut file_ptr: *mut FcChar8 = std::ptr::null_mut(); @@ -284,14 +375,14 @@ fn find_font_for_char(_fc: &Fontconfig, c: char) -> Option { } else { None }; - + // Cleanup if !matched.is_null() { FcPatternDestroy(matched); } FcCharSetDestroy(charset); FcPatternDestroy(pat); - + font_path } } @@ -321,7 +412,7 @@ struct SupersampledCanvas { impl SupersampledCanvas { const FACTOR: usize = 4; - + fn new(width: usize, height: usize) -> Self { let ss_width = width * Self::FACTOR; let ss_height = height * Self::FACTOR; @@ -333,7 +424,7 @@ impl SupersampledCanvas { ss_height, } } - + /// Blend a pixel with alpha compositing #[inline] fn blend_pixel(&mut self, x: usize, y: usize, alpha: f64) { @@ -343,7 +434,7 @@ impl SupersampledCanvas { self.bitmap[y * self.ss_width + x] = (new_alpha * 255.0) as u8; } } - + /// Draw a thick line along x-axis with y computed by a function fn thick_line_h(&mut self, x1: usize, x2: usize, y_at_x: impl Fn(usize) -> f64, thickness: usize) { let delta = thickness / 2; @@ -357,7 +448,7 @@ impl SupersampledCanvas { } } } - + /// Draw a thick point (for curve rendering) fn thick_point(&mut self, x: f64, y: f64, thickness: f64) { let half = thickness / 2.0; @@ -371,7 +462,7 @@ impl SupersampledCanvas { } } } - + /// Fill a corner triangle. Corner specifies which corner of the cell the right angle is in. /// inverted=false fills the triangle itself, inverted=true fills everything except the triangle. fn fill_corner_triangle(&mut self, corner: Corner, inverted: bool) { @@ -380,35 +471,35 @@ impl SupersampledCanvas { // Use (ss_size - 1) as max coordinate, matching Kitty's approach let max_x = (w - 1) as f64; let max_y = (h - 1) as f64; - + for py in 0..h { let y = py as f64; for px in 0..w { let x = px as f64; - + // Calculate edge y for this x based on corner // The diagonal goes from one corner to the opposite corner let (edge_y, fill_below) = match corner { // BottomLeft: diagonal from (0, max_y) to (max_x, 0), fill below the line Corner::BottomLeft => (max_y - (max_y / max_x) * x, true), - // TopLeft: diagonal from (0, 0) to (max_x, max_y), fill above the line + // TopLeft: diagonal from (0, 0) to (max_x, max_y), fill above the line Corner::TopLeft => ((max_y / max_x) * x, false), // BottomRight: diagonal from (0, 0) to (max_x, max_y), fill below the line Corner::BottomRight => ((max_y / max_x) * x, true), // TopRight: diagonal from (0, max_y) to (max_x, 0), fill above the line Corner::TopRight => (max_y - (max_y / max_x) * x, false), }; - + let in_triangle = if fill_below { y >= edge_y } else { y <= edge_y }; let should_fill = if inverted { !in_triangle } else { in_triangle }; - + if should_fill { self.bitmap[py * w + px] = 255; } } } } - + /// Fill a powerline arrow triangle pointing left or right. /// Uses Kitty's approach: define line equations and fill based on y_limits. fn fill_powerline_arrow(&mut self, left: bool, inverted: bool) { @@ -418,12 +509,12 @@ impl SupersampledCanvas { let max_x = (w - 1) as f64; let max_y = (h - 1) as f64; let mid_y = max_y / 2.0; - + for py in 0..h { let y = py as f64; for px in 0..w { let x = px as f64; - + let (upper_y, lower_y) = if left { // Left-pointing: tip at (0, mid), base from (max_x, 0) to (max_x, max_y) // Upper line: from (max_x, 0) to (0, mid_y) -> y = mid_y/max_x * (max_x - x) @@ -439,17 +530,17 @@ impl SupersampledCanvas { let lower = max_y - (mid_y / max_x) * x; (upper, lower) }; - + let in_shape = y >= upper_y && y <= lower_y; let should_fill = if inverted { !in_shape } else { in_shape }; - + if should_fill { self.bitmap[py * w + px] = 255; } } } } - + /// Draw powerline arrow outline (chevron shape - two diagonal lines meeting at a point) fn stroke_powerline_arrow(&mut self, left: bool, thickness: usize) { let w = self.ss_width; @@ -458,7 +549,7 @@ impl SupersampledCanvas { let max_x = (w - 1) as f64; let max_y = (h - 1) as f64; let mid_y = max_y / 2.0; - + if left { // Left-pointing chevron <: lines meeting at (0, mid_y) self.thick_line_h(0, w, |x| (mid_y / max_x) * (max_x - x as f64), thickness); @@ -469,7 +560,7 @@ impl SupersampledCanvas { self.thick_line_h(0, w, |x| max_y - (mid_y / max_x) * x as f64, thickness); } } - + /// Fill region using a Bezier curve (for "D" shaped powerline semicircles). /// The curve goes from top-left to bottom-left, bulging to the right. /// Bezier: P0=(0,0), P1=(cx,0), P2=(cx,h), P3=(0,h) @@ -480,25 +571,25 @@ impl SupersampledCanvas { // Use (ss_size - 1) as max coordinate, matching Kitty's approach let max_x = (w - 1) as f64; let max_y = (h - 1) as f64; - + // Control point X: determines how far the curve bulges // At t=0.5, bezier_x = 0.75 * cx, so cx = max_x / 0.75 to reach max_x let cx = max_x / 0.75; - + for py in 0..h { let target_y = py as f64; - + // Find t where y(t) = target_y // y(t) = max_y * t^2 * (3 - 2t) let t = Self::find_t_for_bezier_y(max_y, target_y); - + // Calculate x at this t let u = 1.0 - t; let bx = 3.0 * cx * t * u; - + // Clamp to cell width let x_extent = (bx.round() as usize).min(w - 1); - + if left { // Left semicircle: fill from (w - 1 - x_extent) to (w - 1) let start_x = (w - 1).saturating_sub(x_extent); @@ -513,27 +604,27 @@ impl SupersampledCanvas { } } } - + /// Binary search for t where bezier_y(t) ≈ target_y /// y(t) = h * t^2 * (3 - 2t), monotonically increasing from 0 to h fn find_t_for_bezier_y(h: f64, target_y: f64) -> f64 { let mut t_low = 0.0; let mut t_high = 1.0; - + for _ in 0..20 { let t_mid = (t_low + t_high) / 2.0; let y = h * t_mid * t_mid * (3.0 - 2.0 * t_mid); - + if y < target_y { t_low = t_mid; } else { t_high = t_mid; } } - + (t_low + t_high) / 2.0 } - + /// Draw Bezier curve outline (for outline powerline semicircles) fn stroke_bezier_d(&mut self, left: bool, thickness: f64) { let w = self.ss_width; @@ -542,28 +633,28 @@ impl SupersampledCanvas { let max_x = (w - 1) as f64; let max_y = (h - 1) as f64; let cx = max_x / 0.75; - + let steps = (h * 2) as usize; for i in 0..=steps { let t = i as f64 / steps as f64; let u = 1.0 - t; let bx = 3.0 * cx * t * u; let by = max_y * t * t * (3.0 - 2.0 * t); - + // Clamp bx to cell width let bx_clamped = bx.min(max_x); let x = if left { max_x - bx_clamped } else { bx_clamped }; self.thick_point(x, by, thickness); } } - + /// Fill a circle centered in the cell fn fill_circle(&mut self, radius_factor: f64) { let cx = self.ss_width as f64 / 2.0; let cy = self.ss_height as f64 / 2.0; let radius = (cx.min(cy) - 0.5) * radius_factor; let limit = radius * radius; - + for py in 0..self.ss_height { for px in 0..self.ss_width { let dx = px as f64 - cx; @@ -574,13 +665,13 @@ impl SupersampledCanvas { } } } - + /// Fill a circle with a specific radius fn fill_circle_radius(&mut self, radius: f64) { let cx = self.ss_width as f64 / 2.0; let cy = self.ss_height as f64 / 2.0; let limit = radius * radius; - + for py in 0..self.ss_height { for px in 0..self.ss_width { let dx = px as f64 - cx; @@ -591,57 +682,57 @@ impl SupersampledCanvas { } } } - + /// Stroke a circle outline with anti-aliasing fn stroke_circle(&mut self, radius: f64, line_width: f64) { let cx = self.ss_width as f64 / 2.0; let cy = self.ss_height as f64 / 2.0; let half_thickness = line_width / 2.0; - + for py in 0..self.ss_height { for px in 0..self.ss_width { let pixel_x = px as f64 + 0.5; let pixel_y = py as f64 + 0.5; - + let dx = pixel_x - cx; let dy = pixel_y - cy; let dist_to_center = (dx * dx + dy * dy).sqrt(); let distance = (dist_to_center - radius).abs(); - + let alpha = (half_thickness - distance + 0.5).clamp(0.0, 1.0); self.blend_pixel(px, py, alpha); } } } - + /// Stroke an arc (partial circle) with anti-aliasing fn stroke_arc(&mut self, radius: f64, line_width: f64, start_angle: f64, end_angle: f64) { let cx = self.ss_width as f64 / 2.0; let cy = self.ss_height as f64 / 2.0; let half_thickness = line_width / 2.0; - + // Sample points along the arc let num_samples = (self.ss_width.max(self.ss_height) * 2) as usize; let angle_range = end_angle - start_angle; - + for i in 0..=num_samples { let t = i as f64 / num_samples as f64; let angle = start_angle + angle_range * t; let arc_x = cx + radius * angle.cos(); let arc_y = cy + radius * angle.sin(); - + // Draw anti-aliased point at this position self.stroke_point_aa(arc_x, arc_y, half_thickness); } } - + /// Draw an anti-aliased point fn stroke_point_aa(&mut self, x: f64, y: f64, half_thickness: f64) { let x_start = ((x - half_thickness - 1.0).max(0.0)) as usize; let x_end = ((x + half_thickness + 2.0) as usize).min(self.ss_width); let y_start = ((y - half_thickness - 1.0).max(0.0)) as usize; let y_end = ((y + half_thickness + 2.0) as usize).min(self.ss_height); - + for py in y_start..y_end { for px in x_start..x_end { let pixel_x = px as f64 + 0.5; @@ -649,13 +740,13 @@ impl SupersampledCanvas { let dx = pixel_x - x; let dy = pixel_y - y; let distance = (dx * dx + dy * dy).sqrt(); - + let alpha = (half_thickness - distance + 0.5).clamp(0.0, 1.0); self.blend_pixel(px, py, alpha); } } } - + /// Downsample to final resolution fn downsample(&self, output: &mut [u8]) { for y in 0..self.height { @@ -681,7 +772,7 @@ impl Renderer { pub async fn new(window: Arc, config: &Config) -> Self { let size = window.inner_size(); let scale_factor = window.scale_factor(); - + // Calculate DPI from scale factor // Standard assumption: scale_factor 1.0 = 96 DPI (Windows/Linux default) // macOS uses 72 as base DPI, but winit normalizes this @@ -765,20 +856,18 @@ impl Renderer { .expect("Failed to load any monospace font") .into_boxed_slice(); - let fontdue_font = FontdueFont::from_bytes( - &font_data[..], - fontdue::FontSettings::default(), - ) - .expect("Failed to parse font with fontdue"); + // Parse font with ab_glyph for rasterization + // SAFETY: We transmute to 'static because font_data lives as long as Renderer. + let primary_font: FontRef<'static> = { + let font = FontRef::try_from_slice(&font_data) + .expect("Failed to parse font with ab_glyph"); + unsafe { std::mem::transmute(font) } + }; + + // Fontconfig will be initialized lazily on first fallback font lookup - // Initialize fontconfig for dynamic font fallback - let fontconfig = Fontconfig::new(); - if fontconfig.is_none() { - log::warn!("Failed to initialize fontconfig - Unicode fallback may not work"); - } - // Start with empty fallback fonts - will be loaded on-demand via fontconfig - let fallback_fonts: Vec = Vec::new(); + let fallback_fonts: Vec<(Box<[u8]>, FontRef<'static>)> = Vec::new(); let tried_font_paths: HashSet = HashSet::new(); // Create rustybuzz Face for text shaping (ligatures). @@ -790,7 +879,7 @@ impl Renderer { .expect("Failed to parse font for shaping"); unsafe { std::mem::transmute(face) } }; - + // Enable OpenType features for ligatures and contextual alternates // These are the standard features used by coding fonts like Fira Code, JetBrains Mono, etc. let features = vec![ @@ -801,27 +890,65 @@ impl Renderer { // Discretionary ligatures (optional ligatures) rustybuzz::Feature::new(Tag::from_bytes(b"dlig"), 1, ..), ]; - let shaping_ctx = ShapingContext { face, features }; + let shaping_ctx = ShapingContext { face: face.clone(), features }; - // Calculate cell dimensions from font metrics - // Scale font size by the display scale factor for crisp rendering + // Calculate cell dimensions from font metrics using ab_glyph + // + // The config font_size is in pixels. Scale by display scale factor for HiDPI. + // Round to integer for pixel-perfect glyph rendering. let base_font_size = config.font_size; - let font_size = base_font_size * scale_factor as f32; - let metrics = fontdue_font.metrics('M', font_size); - let cell_width = metrics.advance_width.ceil(); + let font_size = (base_font_size * scale_factor as f32).round(); - // Use actual font line metrics for cell height (matching Kitty's approach) - // Kitty uses the font's "height" metric which is: ascent - descent + line_gap - // In fontdue, this is provided as "new_line_size" - let cell_height = if let Some(line_metrics) = fontdue_font.horizontal_line_metrics(font_size) { - line_metrics.new_line_size - } else { - // Fallback if no line metrics available - font_size * 1.2 - }; + let scaled_font = primary_font.as_scaled(font_size); + + // Get advance width for 'M' (em width) + let m_glyph_id = primary_font.glyph_id('M'); + let cell_width = scaled_font.h_advance(m_glyph_id).round(); - log::info!("Scale factor: {}, font size: {}pt -> {}px, cell: {}x{}", - scale_factor, base_font_size, font_size, cell_width, cell_height); + // Use font line metrics for cell height + // ab_glyph's height() = ascent - descent (where descent is negative) + let cell_height = scaled_font.height().round(); + + // DEBUG: Compare ab_glyph and rustybuzz advance calculations + { + let units_per_em = face.units_per_em(); + let height_unscaled = primary_font.height_unscaled(); + + // ab_glyph uses: scale / height_unscaled as the scale factor for h_advance + let ab_scale_factor = font_size / height_unscaled; + // We were using: scale / units_per_em (WRONG!) + let our_wrong_scale = font_size / units_per_em as f32; + + // Get 'M' glyph advance from rustybuzz's underlying ttf_parser + let _m_glyph_rb = face.glyph_index('M').map(|g| g.0).unwrap_or(0); + // Let's shape a single 'M' and see what advance we get + let mut buffer = rustybuzz::UnicodeBuffer::new(); + buffer.push_str("M"); + let glyph_buffer = rustybuzz::shape(&face, &[], buffer); + let positions = glyph_buffer.glyph_positions(); + if !positions.is_empty() { + let rb_advance_font_units = positions[0].x_advance; + let rb_advance_px_wrong = rb_advance_font_units as f32 * our_wrong_scale; + let rb_advance_px_correct = rb_advance_font_units as f32 * ab_scale_factor; + let ab_advance_raw = scaled_font.h_advance(m_glyph_id); + eprintln!("DEBUG font metrics:"); + eprintln!(" font_size = {} px", font_size); + eprintln!(" units_per_em = {}", units_per_em); + eprintln!(" height_unscaled = {} (ascent - descent)", height_unscaled); + eprintln!(" ab_glyph scale factor = font_size / height_unscaled = {}", ab_scale_factor); + eprintln!(" our WRONG scale factor = font_size / units_per_em = {}", our_wrong_scale); + eprintln!(" ab_glyph 'M' h_advance (raw) = {}", ab_advance_raw); + eprintln!(" ab_glyph 'M' h_advance (rounded) = {}", cell_width); + eprintln!(" rustybuzz 'M' x_advance (font units) = {}", rb_advance_font_units); + eprintln!(" rustybuzz 'M' x_advance (WRONG pixels) = {}", rb_advance_px_wrong); + eprintln!(" rustybuzz 'M' x_advance (CORRECT pixels) = {}", rb_advance_px_correct); + } + } + + // Calculate the correct scale factor for converting font units to pixels. + // This matches ab_glyph's calculation: scale / height_unscaled + // where height_unscaled = ascent - descent (the font's natural line height). + let font_units_to_px = font_size / primary_font.height_unscaled(); // Create atlas texture let atlas_texture = device.create_texture(&wgpu::TextureDescriptor { @@ -937,13 +1064,13 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════════════════════ // EDGE GLOW PIPELINE SETUP // ═══════════════════════════════════════════════════════════════════════════════ - + // Create edge glow shader let edge_glow_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Edge Glow Shader"), source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()), }); - + // Create uniform buffer for edge glow parameters let edge_glow_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Edge Glow Uniform Buffer"), @@ -951,7 +1078,7 @@ impl Renderer { usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - + // Create bind group layout for edge glow let edge_glow_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("Edge Glow Bind Group Layout"), @@ -968,7 +1095,7 @@ impl Renderer { }, ], }); - + // Create bind group for edge glow let edge_glow_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("Edge Glow Bind Group"), @@ -980,14 +1107,14 @@ impl Renderer { }, ], }); - + // Create pipeline layout for edge glow let edge_glow_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("Edge Glow Pipeline Layout"), bind_group_layouts: &[&edge_glow_bind_group_layout], push_constant_ranges: &[], }); - + // Create edge glow render pipeline let edge_glow_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("Edge Glow Pipeline"), @@ -1024,6 +1151,115 @@ impl Renderer { cache: None, }); + // ═══════════════════════════════════════════════════════════════════════════════ + // IMAGE PIPELINE SETUP (Kitty Graphics Protocol) + // ═══════════════════════════════════════════════════════════════════════════════ + + // Create image shader + let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Image Shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("image_shader.wgsl").into()), + }); + + // Create sampler for images (linear filtering for smooth scaling) + let image_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Image Sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + // Create bind group layout for images + let image_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Image Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + // Create pipeline layout for images + let image_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Image Pipeline Layout"), + bind_group_layouts: &[&image_bind_group_layout], + push_constant_ranges: &[], + }); + + // Create image render pipeline + let image_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Image Pipeline"), + layout: Some(&image_pipeline_layout), + vertex: wgpu::VertexState { + module: &image_shader, + entry_point: Some("vs_main"), + buffers: &[], // Quad generated in shader + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &image_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: surface_config.format, + // Premultiplied alpha blending (shader outputs premultiplied) + blend: Some(wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + }), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleStrip, + 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: None, + cache: None, + }); + // Create initial buffers with some capacity let initial_vertex_capacity = 4096; let initial_index_capacity = 6144; @@ -1052,18 +1288,26 @@ impl Renderer { edge_glow_pipeline, edge_glow_bind_group, edge_glow_uniform_buffer, + image_pipeline, + image_bind_group_layout, + image_sampler, + image_textures: HashMap::new(), atlas_texture, atlas_data: vec![0u8; (ATLAS_SIZE * ATLAS_SIZE) as usize], atlas_dirty: false, font_data, - fontdue_font, + primary_font, fallback_fonts, - fontconfig, + fontconfig: OnceCell::new(), tried_font_paths, shaping_ctx, char_cache: HashMap::new(), ligature_cache: HashMap::new(), glyph_cache: HashMap::new(), + text_run_cache: HashMap::new(), + // Initial canvas size - will be resized as needed + canvas_buffer: vec![0u8; (cell_width as usize * 16) * cell_height as usize], + canvas_size: (cell_width as u32 * 16, cell_height as u32), atlas_cursor_x: 0, atlas_cursor_y: 0, atlas_row_height: 0, @@ -1075,6 +1319,7 @@ impl Renderer { scale_factor, dpi, font_size, + font_units_to_px, cell_width, cell_height, width: size.width, @@ -1108,7 +1353,7 @@ impl Renderer { _ => 0.0, } } - + /// Sets the current selection range for highlighting. /// Pass None to clear the selection. /// The selection is specified as (start_col, start_row, end_col, end_row) in normalized order. @@ -1134,14 +1379,14 @@ impl Renderer { let rows = (available_height / self.cell_height).floor() as usize; (cols.max(1), rows.max(1)) } - + /// Converts a pixel position to a terminal cell position. /// Returns None if the position is outside the terminal area (e.g., in the tab bar). pub fn pixel_to_cell(&self, x: f64, y: f64) -> Option<(usize, usize)> { let terminal_y_offset = self.terminal_y_offset(); let tab_bar_height = self.tab_bar_height(); let height = self.height as f32; - + // Check if position is in the tab bar area (which could be at top or bottom) match self.tab_bar_position { TabBarPosition::Top => { @@ -1156,21 +1401,21 @@ impl Renderer { } TabBarPosition::Hidden => {} } - + // Adjust y to be relative to terminal area let terminal_y = y as f32 - terminal_y_offset; - + // Calculate cell position let col = (x as f32 / self.cell_width).floor() as usize; let row = (terminal_y / self.cell_height).floor() as usize; - + // Get terminal dimensions to clamp let (max_cols, max_rows) = self.terminal_size(); - + // Clamp to valid range let col = col.min(max_cols.saturating_sub(1)); let row = row.min(max_rows.saturating_sub(1)); - + Some((col, row)) } @@ -1185,19 +1430,22 @@ impl Renderer { let old_cell_height = self.cell_height; self.scale_factor = new_scale; - self.font_size = self.base_font_size * new_scale as f32; + self.dpi = 96.0 * new_scale; + + // Font size in pixels, rounded for pixel-perfect rendering + self.font_size = (self.base_font_size * new_scale as f32).round(); - // Recalculate cell dimensions - let metrics = self.fontdue_font.metrics('M', self.font_size); - self.cell_width = metrics.advance_width.ceil(); - self.cell_height = if let Some(line_metrics) = self.fontdue_font.horizontal_line_metrics(self.font_size) { - line_metrics.new_line_size - } else { - self.font_size * 1.2 - }; + // Recalculate cell dimensions using ab_glyph + let scaled_font = self.primary_font.as_scaled(self.font_size); + let m_glyph_id = self.primary_font.glyph_id('M'); + self.cell_width = scaled_font.h_advance(m_glyph_id).round(); + self.cell_height = scaled_font.height().round(); + + // Update the font units to pixels scale factor + self.font_units_to_px = self.font_size / self.primary_font.height_unscaled(); log::info!( - "Scale factor changed to {}: font {}pt -> {}px, cell: {}x{}", + "Scale factor changed to {}: font {}px -> {}px, cell: {}x{}", new_scale, self.base_font_size, self.font_size, self.cell_width, self.cell_height ); @@ -1205,6 +1453,64 @@ impl Renderer { self.char_cache.clear(); self.ligature_cache.clear(); self.glyph_cache.clear(); + self.text_run_cache.clear(); + + // Reset atlas + self.atlas_cursor_x = 0; + self.atlas_cursor_y = 0; + self.atlas_row_height = 0; + self.atlas_data.fill(0); + self.atlas_dirty = true; + + // Return true if cell dimensions changed + (self.cell_width - old_cell_width).abs() > 0.01 + || (self.cell_height - old_cell_height).abs() > 0.01 + } + + /// Set the background opacity for transparent terminal rendering. + pub fn set_background_opacity(&mut self, opacity: f32) { + self.background_opacity = opacity.clamp(0.0, 1.0); + } + + /// Set the tab bar position. + pub fn set_tab_bar_position(&mut self, position: TabBarPosition) { + self.tab_bar_position = position; + } + + /// Set the base font size and recalculate cell dimensions. + /// Returns true if the cell dimensions changed (terminal needs resize). + pub fn set_font_size(&mut self, size: f32) -> bool { + if (self.base_font_size - size).abs() < 0.01 { + return false; + } + + let old_cell_width = self.cell_width; + let old_cell_height = self.cell_height; + + self.base_font_size = size; + + // Font size in pixels, rounded for pixel-perfect rendering + self.font_size = (size * self.scale_factor as f32).round(); + + // Recalculate cell dimensions using ab_glyph + let scaled_font = self.primary_font.as_scaled(self.font_size); + let m_glyph_id = self.primary_font.glyph_id('M'); + self.cell_width = scaled_font.h_advance(m_glyph_id).round(); + self.cell_height = scaled_font.height().round(); + + // Update the font units to pixels scale factor + self.font_units_to_px = self.font_size / self.primary_font.height_unscaled(); + + log::info!( + "Font size changed to {}px -> {}px, cell: {}x{}", + size, self.font_size, self.cell_width, self.cell_height + ); + + // Clear all glyph caches - they were rendered at the old size + self.char_cache.clear(); + self.ligature_cache.clear(); + self.glyph_cache.clear(); + self.text_run_cache.clear(); // Reset atlas self.atlas_cursor_x = 0; @@ -1221,7 +1527,7 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════════════════ // BOX DRAWING HELPER FUNCTIONS // ═══════════════════════════════════════════════════════════════════════════ - + /// Calculate line thickness based on DPI and scale, similar to Kitty's thickness_as_float. /// Level 0 = hairline, 1 = light, 2 = medium, 3 = heavy fn box_thickness(&self, level: usize) -> f64 { @@ -1240,7 +1546,7 @@ impl Renderer { // Geometric Shapes (subset): U+25A0-U+25FF (circles, arcs, triangles) // Braille Patterns: U+2800-U+28FF // Powerline Symbols: U+E0B0-U+E0BF - (0x2500..=0x257F).contains(&cp) + (0x2500..=0x257F).contains(&cp) || (0x2580..=0x259F).contains(&cp) || (0x25A0..=0x25FF).contains(&cp) || (0x2800..=0x28FF).contains(&cp) @@ -1254,12 +1560,12 @@ impl Renderer { let h = self.cell_height.ceil() as usize; let mut bitmap = vec![0u8; w * h]; let mut supersampled = false; - + let mid_x = w / 2; let mid_y = h / 2; let light = 2.max((self.font_size / 8.0).round() as usize); // 2px minimum, scales with font let heavy = light * 2; // 4px minimum - + // For double lines let double_gap = light + 2; let double_off = double_gap / 2; @@ -1299,11 +1605,11 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════ // LIGHT BOX DRAWING (single lines) // ═══════════════════════════════════════════════════════════════ - + // Horizontal and vertical lines '─' => hline(&mut bitmap, 0, w, mid_y, light), '│' => vline(&mut bitmap, 0, h, mid_x, light), - + // Light corners '┌' => { hline(&mut bitmap, mid_x, w, mid_y, light); @@ -1321,7 +1627,7 @@ impl Renderer { hline(&mut bitmap, 0, mid_x + 1, mid_y, light); vline(&mut bitmap, 0, mid_y + 1, mid_x, light); } - + // Light T-junctions '├' => { vline(&mut bitmap, 0, h, mid_x, light); @@ -1339,7 +1645,7 @@ impl Renderer { hline(&mut bitmap, 0, w, mid_y, light); vline(&mut bitmap, 0, mid_y + 1, mid_x, light); } - + // Light cross '┼' => { hline(&mut bitmap, 0, w, mid_y, light); @@ -1349,10 +1655,10 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════ // HEAVY BOX DRAWING (bold lines) // ═══════════════════════════════════════════════════════════════ - + '━' => hline(&mut bitmap, 0, w, mid_y, heavy), '┃' => vline(&mut bitmap, 0, h, mid_x, heavy), - + // Heavy corners '┏' => { hline(&mut bitmap, mid_x, w, mid_y, heavy); @@ -1370,7 +1676,7 @@ impl Renderer { hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy); vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); } - + // Heavy T-junctions '┣' => { vline(&mut bitmap, 0, h, mid_x, heavy); @@ -1388,7 +1694,7 @@ impl Renderer { hline(&mut bitmap, 0, w, mid_y, heavy); vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); } - + // Heavy cross '╋' => { hline(&mut bitmap, 0, w, mid_y, heavy); @@ -1398,7 +1704,7 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════ // MIXED LIGHT/HEAVY // ═══════════════════════════════════════════════════════════════ - + // Light horizontal, heavy vertical corners '┎' => { hline(&mut bitmap, mid_x, w, mid_y, light); @@ -1416,7 +1722,7 @@ impl Renderer { hline(&mut bitmap, 0, mid_x + 1, mid_y, light); vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy); } - + // Heavy horizontal, light vertical corners '┍' => { hline(&mut bitmap, mid_x, w, mid_y, heavy); @@ -1616,7 +1922,7 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════ // DOUBLE LINES // ═══════════════════════════════════════════════════════════════ - + '═' => { hline(&mut bitmap, 0, w, mid_y.saturating_sub(double_off), light); hline(&mut bitmap, 0, w, mid_y + double_off, light); @@ -1625,7 +1931,7 @@ impl Renderer { vline(&mut bitmap, 0, h, mid_x.saturating_sub(double_off), light); vline(&mut bitmap, 0, h, mid_x + double_off, light); } - + // Double corners '╔' => { hline(&mut bitmap, mid_x, w, mid_y.saturating_sub(double_off), light); @@ -1651,7 +1957,7 @@ impl Renderer { vline(&mut bitmap, 0, mid_y + 1, mid_x + double_off, light); vline(&mut bitmap, 0, mid_y + double_off + 1, mid_x.saturating_sub(double_off), light); } - + // Double T-junctions '╠' => { vline(&mut bitmap, 0, h, mid_x.saturating_sub(double_off), light); @@ -1681,7 +1987,7 @@ impl Renderer { vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x.saturating_sub(double_off), light); vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x + double_off, light); } - + // Double cross '╬' => { vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x.saturating_sub(double_off), light); @@ -1697,7 +2003,7 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════ // SINGLE/DOUBLE MIXED // ═══════════════════════════════════════════════════════════════ - + // Single horizontal, double vertical corners '╒' => { hline(&mut bitmap, mid_x + double_off, w, mid_y, light); @@ -1735,7 +2041,7 @@ impl Renderer { hline(&mut bitmap, 0, mid_x + 1, mid_y, light); vline(&mut bitmap, 0, mid_y + 1, mid_x, light); } - + // Mixed T-junctions '╞' => { vline(&mut bitmap, 0, h, mid_x.saturating_sub(double_off), light); @@ -1777,7 +2083,7 @@ impl Renderer { vline(&mut bitmap, 0, mid_y + 1, mid_x.saturating_sub(double_off), light); vline(&mut bitmap, 0, mid_y + 1, mid_x + double_off, light); } - + // Mixed crosses '╪' => { hline(&mut bitmap, 0, w, mid_y.saturating_sub(double_off), light); @@ -1795,39 +2101,39 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════ // ROUNDED CORNERS (using SDF like Kitty, with anti-aliasing) // ═══════════════════════════════════════════════════════════════ - + '╭' | '╮' | '╯' | '╰' => { // Kitty-style rounded corner using signed distance field // Translated directly from kitty/decorations.c rounded_corner() - + // hline_limits: for a horizontal line at y with thickness t, // returns range [y - t/2, y - t/2 + t] let hori_line_start = mid_y.saturating_sub(light / 2); let hori_line_end = (hori_line_start + light).min(h); let hori_line_height = hori_line_end - hori_line_start; - + // vline_limits: for a vertical line at x with thickness t, // returns range [x - t/2, x - t/2 + t] let vert_line_start = mid_x.saturating_sub(light / 2); let vert_line_end = (vert_line_start + light).min(w); let vert_line_width = vert_line_end - vert_line_start; - + // adjusted_Hx/Hy: center of the line in each direction let adjusted_hx = vert_line_start as f64 + vert_line_width as f64 / 2.0; let adjusted_hy = hori_line_start as f64 + hori_line_height as f64 / 2.0; - + let stroke = (hori_line_height.max(vert_line_width)) as f64; let corner_radius = adjusted_hx.min(adjusted_hy); let bx = adjusted_hx - corner_radius; let by = adjusted_hy - corner_radius; - + let aa_corner = 0.5; // anti-aliasing amount (kitty uses supersample_factor * 0.5) let half_stroke = 0.5 * stroke; - + // Determine shifts based on corner type (matching Kitty's Edge flags) // RIGHT_EDGE = 4, TOP_EDGE = 2 // ╭ = TOP_LEFT (top-left corner, line goes right and down) - // ╮ = TOP_RIGHT (top-right corner, line goes left and down) + // ╮ = TOP_RIGHT (top-right corner, line goes left and down) // ╰ = BOTTOM_LEFT (bottom-left corner, line goes right and up) // ╯ = BOTTOM_RIGHT (bottom-right corner, line goes left and up) let (is_right, is_top) = match c { @@ -1837,10 +2143,10 @@ impl Renderer { '╯' => (true, false), // BOTTOM_RIGHT _ => unreachable!(), }; - + let x_shift = if is_right { adjusted_hx } else { -adjusted_hx }; let y_shift = if is_top { -adjusted_hy } else { adjusted_hy }; - + // Smoothstep for anti-aliasing let smoothstep = |edge0: f64, edge1: f64, x: f64| -> f64 { if edge0 == edge1 { @@ -1849,26 +2155,26 @@ impl Renderer { let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0); t * t * (3.0 - 2.0 * t) }; - + for py in 0..h { let sample_y = py as f64 + y_shift + 0.5; let pos_y = sample_y - adjusted_hy; - + for px in 0..w { let sample_x = px as f64 + x_shift + 0.5; let pos_x = sample_x - adjusted_hx; - + let qx = pos_x.abs() - bx; let qy = pos_y.abs() - by; let dx = if qx > 0.0 { qx } else { 0.0 }; let dy = if qy > 0.0 { qy } else { 0.0 }; let dist = (dx * dx + dy * dy).sqrt() + qx.max(qy).min(0.0) - corner_radius; - + let aa = if qx > 1e-7 && qy > 1e-7 { aa_corner } else { 0.0 }; let outer = half_stroke - dist; let inner = -half_stroke - dist; let alpha = smoothstep(-aa, aa, outer) - smoothstep(-aa, aa, inner); - + if alpha <= 0.0 { continue; } @@ -1884,7 +2190,7 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════ // DASHED/DOTTED LINES // ═══════════════════════════════════════════════════════════════ - + '┄' => { let seg = w / 8; for i in 0..4 { @@ -1949,7 +2255,7 @@ impl Renderer { vline(&mut bitmap, y1, y2, mid_x, heavy); } } - + // Double dashed '╌' => { let seg = w / 4; @@ -1975,7 +2281,7 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════ // HALF LINES (line to edge) // ═══════════════════════════════════════════════════════════════ - + '╴' => hline(&mut bitmap, 0, mid_x + 1, mid_y, light), '╵' => vline(&mut bitmap, 0, mid_y + 1, mid_x, light), '╶' => hline(&mut bitmap, mid_x, w, mid_y, light), @@ -1984,7 +2290,7 @@ impl Renderer { '╹' => vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy), '╺' => hline(&mut bitmap, mid_x, w, mid_y, heavy), '╻' => vline(&mut bitmap, mid_y, h, mid_x, heavy), - + // Mixed half lines '╼' => { hline(&mut bitmap, 0, mid_x + 1, mid_y, light); @@ -2006,7 +2312,7 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════ // DIAGONAL LINES // ═══════════════════════════════════════════════════════════════ - + '╱' => { for i in 0..w.max(h) { let x = w.saturating_sub(1).saturating_sub(i * w / h.max(1)); @@ -2047,7 +2353,7 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════ // BLOCK ELEMENTS (U+2580-U+259F) // ═══════════════════════════════════════════════════════════════ - + '▀' => fill_rect(&mut bitmap, 0, 0, w, h / 2), '▁' => fill_rect(&mut bitmap, 0, h * 7 / 8, w, h), '▂' => fill_rect(&mut bitmap, 0, h * 3 / 4, w, h), @@ -2065,7 +2371,7 @@ impl Renderer { '▎' => fill_rect(&mut bitmap, 0, 0, w / 4, h), '▏' => fill_rect(&mut bitmap, 0, 0, w / 8, h), '▐' => fill_rect(&mut bitmap, w / 2, 0, w, h), - + // Shades '░' => { for y in 0..h { @@ -2088,11 +2394,11 @@ impl Renderer { } } } - + // Right half blocks and upper eighth '▕' => fill_rect(&mut bitmap, w * 7 / 8, 0, w, h), '▔' => fill_rect(&mut bitmap, 0, 0, w, h / 8), // Upper one eighth block - + // Quadrants '▖' => fill_rect(&mut bitmap, 0, h / 2, w / 2, h), '▗' => fill_rect(&mut bitmap, w / 2, h / 2, w, h), @@ -2127,7 +2433,7 @@ impl Renderer { // BRAILLE PATTERNS (U+2800-U+28FF) // Uses Kitty's distribute_dots algorithm for proper spacing // ═══════════════════════════════════════════════════════════════ - + c if (0x2800..=0x28FF).contains(&(c as u32)) => { let which = (c as u32 - 0x2800) as u8; if which != 0 { @@ -2136,7 +2442,7 @@ impl Renderer { // For vertical: 4 dots down height let num_x_dots = 2usize; let num_y_dots = 4usize; - + // distribute_dots for x (2 dots) let dot_width = 1.max(w / (2 * num_x_dots)); let mut x_gaps = [dot_width; 2]; @@ -2149,7 +2455,7 @@ impl Renderer { } x_gaps[0] /= 2; let x_summed: [usize; 2] = [x_gaps[0], x_gaps[0] + x_gaps[1]]; - + // distribute_dots for y (4 dots) let dot_height = 1.max(h / (2 * num_y_dots)); let mut y_gaps = [dot_height; 4]; @@ -2167,7 +2473,7 @@ impl Renderer { y_gaps[0] + y_gaps[1] + y_gaps[2], y_gaps[0] + y_gaps[1] + y_gaps[2] + y_gaps[3], ]; - + // Draw braille dots as rectangles (matching Kitty) // Bit mapping: 0=dot1, 1=dot2, 2=dot3, 3=dot4, 4=dot5, 5=dot6, 6=dot7, 7=dot8 // Layout: col 0 col 1 @@ -2188,10 +2494,10 @@ impl Renderer { 3 | 6 => 2, _ => 3, }; - + let x_start = x_summed[col] + col * dot_width; let y_start = y_summed[row] + row * dot_height; - + if y_start < h && x_start < w { let x_end = (x_start + dot_width).min(w); let y_end = (y_start + dot_height).min(h); @@ -2217,7 +2523,7 @@ impl Renderer { canvas.fill_powerline_arrow(false, false); canvas.downsample(&mut bitmap); supersampled = true; } - + // E0B1: Right-pointing chevron (outline) '\u{E0B1}' => { let mut canvas = SupersampledCanvas::new(w, h); @@ -2225,14 +2531,14 @@ impl Renderer { canvas.stroke_powerline_arrow(false, thickness); canvas.downsample(&mut bitmap); supersampled = true; } - + // E0B2: Left-pointing solid triangle '\u{E0B2}' => { let mut canvas = SupersampledCanvas::new(w, h); canvas.fill_powerline_arrow(true, false); canvas.downsample(&mut bitmap); supersampled = true; } - + // E0B3: Left-pointing chevron (outline) '\u{E0B3}' => { let mut canvas = SupersampledCanvas::new(w, h); @@ -2240,14 +2546,14 @@ impl Renderer { canvas.stroke_powerline_arrow(true, thickness); canvas.downsample(&mut bitmap); supersampled = true; } - + // E0B4: Right semicircle (filled) '\u{E0B4}' => { let mut canvas = SupersampledCanvas::new(w, h); canvas.fill_bezier_d(false); canvas.downsample(&mut bitmap); supersampled = true; } - + // E0B5: Right semicircle (outline) '\u{E0B5}' => { let mut canvas = SupersampledCanvas::new(w, h); @@ -2255,14 +2561,14 @@ impl Renderer { canvas.stroke_bezier_d(false, thickness); canvas.downsample(&mut bitmap); supersampled = true; } - + // E0B6: Left semicircle (filled) '\u{E0B6}' => { let mut canvas = SupersampledCanvas::new(w, h); canvas.fill_bezier_d(true); canvas.downsample(&mut bitmap); supersampled = true; } - + // E0B7: Left semicircle (outline) '\u{E0B7}' => { let mut canvas = SupersampledCanvas::new(w, h); @@ -2270,7 +2576,7 @@ impl Renderer { canvas.stroke_bezier_d(true, thickness); canvas.downsample(&mut bitmap); supersampled = true; } - + // E0B8-E0BF: Corner triangles '\u{E0B8}' => { let mut canvas = SupersampledCanvas::new(w, h); @@ -2316,14 +2622,14 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════ // GEOMETRIC SHAPES - Circles, Arcs, and Triangles (U+25A0-U+25FF) // ═══════════════════════════════════════════════════════════════ - + // ● U+25CF: Black circle (filled) '●' => { let mut canvas = SupersampledCanvas::new(w, h); canvas.fill_circle(1.0); canvas.downsample(&mut bitmap); supersampled = true; } - + // ○ U+25CB: White circle (outline) '○' => { let mut canvas = SupersampledCanvas::new(w, h); @@ -2335,7 +2641,7 @@ impl Renderer { canvas.stroke_circle(radius, line_width); canvas.downsample(&mut bitmap); supersampled = true; } - + // ◉ U+25C9: Fisheye (filled center + circle outline) '◉' => { let mut canvas = SupersampledCanvas::new(w, h); @@ -2343,18 +2649,18 @@ impl Renderer { let cy = canvas.ss_height as f64 / 2.0; let radius = cx.min(cy); let central_radius = (2.0 / 3.0) * radius; - + // Fill central circle canvas.fill_circle_radius(central_radius); - + // Draw outer ring let line_width = (SupersampledCanvas::FACTOR as f64).max((radius - central_radius) / 2.5); let outer_radius = 0.0_f64.max(cx.min(cy) - line_width / 2.0); canvas.stroke_circle(outer_radius, line_width); - + canvas.downsample(&mut bitmap); supersampled = true; } - + // ◜ U+25DC: Upper left quadrant circular arc (180° to 270°) '◜' => { let mut canvas = SupersampledCanvas::new(w, h); @@ -2366,7 +2672,7 @@ impl Renderer { canvas.stroke_arc(radius, line_width, std::f64::consts::PI, 3.0 * std::f64::consts::PI / 2.0); canvas.downsample(&mut bitmap); supersampled = true; } - + // ◝ U+25DD: Upper right quadrant circular arc (270° to 360°) '◝' => { let mut canvas = SupersampledCanvas::new(w, h); @@ -2378,7 +2684,7 @@ impl Renderer { canvas.stroke_arc(radius, line_width, 3.0 * std::f64::consts::PI / 2.0, 2.0 * std::f64::consts::PI); canvas.downsample(&mut bitmap); supersampled = true; } - + // ◞ U+25DE: Lower right quadrant circular arc (0° to 90°) '◞' => { let mut canvas = SupersampledCanvas::new(w, h); @@ -2390,7 +2696,7 @@ impl Renderer { canvas.stroke_arc(radius, line_width, 0.0, std::f64::consts::PI / 2.0); canvas.downsample(&mut bitmap); supersampled = true; } - + // ◟ U+25DF: Lower left quadrant circular arc (90° to 180°) '◟' => { let mut canvas = SupersampledCanvas::new(w, h); @@ -2402,7 +2708,7 @@ impl Renderer { canvas.stroke_arc(radius, line_width, std::f64::consts::PI / 2.0, std::f64::consts::PI); canvas.downsample(&mut bitmap); supersampled = true; } - + // ◠ U+25E0: Upper half arc (180° to 360°) '◠' => { let mut canvas = SupersampledCanvas::new(w, h); @@ -2414,7 +2720,7 @@ impl Renderer { canvas.stroke_arc(radius, line_width, std::f64::consts::PI, 2.0 * std::f64::consts::PI); canvas.downsample(&mut bitmap); supersampled = true; } - + // ◡ U+25E1: Lower half arc (0° to 180°) '◡' => { let mut canvas = SupersampledCanvas::new(w, h); @@ -2426,7 +2732,7 @@ impl Renderer { canvas.stroke_arc(radius, line_width, 0.0, std::f64::consts::PI); canvas.downsample(&mut bitmap); supersampled = true; } - + // Fall through for unimplemented characters _ => return None, } @@ -2508,60 +2814,77 @@ impl Renderer { } } - // Try primary font first, then fallbacks - let (metrics, bitmap) = { - // Check if primary font has this glyph - let glyph_idx = self.fontdue_font.lookup_glyph_index(c); - if glyph_idx != 0 { - self.fontdue_font.rasterize(c, self.font_size) - } else { - // Try already-loaded fallback fonts first - let mut result = None; - for fallback in &self.fallback_fonts { - let fb_glyph_idx = fallback.lookup_glyph_index(c); - if fb_glyph_idx != 0 { - result = Some(fallback.rasterize(c, self.font_size)); - break; - } + // Try primary font first, then fallbacks using ab_glyph + let glyph_id = self.primary_font.glyph_id(c); + + // Rasterize glyph data: (width, height, bitmap, offset_x, offset_y) + let raster_result: Option<(u32, u32, Vec, f32, f32)> = if glyph_id.0 != 0 { + // Primary font has this glyph + self.rasterize_glyph_ab(&self.primary_font.clone(), glyph_id) + } else { + // Try already-loaded fallback fonts first + let mut result = None; + for (_, fallback_font) in &self.fallback_fonts { + let fb_glyph_id = fallback_font.glyph_id(c); + if fb_glyph_id.0 != 0 { + result = self.rasterize_glyph_ab(&fallback_font.clone(), fb_glyph_id); + break; } - - // If no cached fallback has the glyph, use fontconfig to find one - if result.is_none() { - if let Some(ref fc) = self.fontconfig { - // Query fontconfig for a font that has this character - if let Some(path) = find_font_for_char(fc, c) { - // Only load if we haven't tried this path before - if !self.tried_font_paths.contains(&path) { - self.tried_font_paths.insert(path.clone()); - - if let Ok(data) = std::fs::read(&path) { - if let Ok(font) = FontdueFont::from_bytes( - data.as_slice(), - fontdue::FontSettings::default(), - ) { - log::debug!("Loaded fallback font via fontconfig: {}", path.display()); - - // Check if this font actually has the glyph - let fb_glyph_idx = font.lookup_glyph_index(c); - if fb_glyph_idx != 0 { - result = Some(font.rasterize(c, self.font_size)); - } - - // Cache the font for future use - self.fallback_fonts.push(font); + } + + // If no cached fallback has the glyph, use fontconfig to find one + if result.is_none() { + // Lazy-initialize fontconfig on first use + let fc = self.fontconfig.get_or_init(|| { + log::debug!("Initializing fontconfig for fallback font lookup"); + Fontconfig::new() + }); + if let Some(fc) = fc { + // Query fontconfig for a font that has this character + if let Some(path) = find_font_for_char(fc, c) { + // Only load if we haven't tried this path before + if !self.tried_font_paths.contains(&path) { + self.tried_font_paths.insert(path.clone()); + + if let Ok(data) = std::fs::read(&path) { + let data: Box<[u8]> = data.into_boxed_slice(); + if let Ok(font) = FontRef::try_from_slice(&data) { + log::debug!("Loaded fallback font via fontconfig: {}", path.display()); + + // Check if this font actually has the glyph + let fb_glyph_id = font.glyph_id(c); + if fb_glyph_id.0 != 0 { + result = self.rasterize_glyph_ab(&font, fb_glyph_id); } + + // Cache the font for future use + // SAFETY: We're storing data alongside the FontRef that borrows it + let font_static: FontRef<'static> = unsafe { std::mem::transmute(font) }; + self.fallback_fonts.push((data, font_static)); } } } } } - - // Use primary font's .notdef if no fallback has the glyph - result.unwrap_or_else(|| self.fontdue_font.rasterize(c, self.font_size)) } + + // Use primary font's .notdef if no fallback has the glyph + result.or_else(|| self.rasterize_glyph_ab(&self.primary_font.clone(), glyph_id)) }; - if bitmap.is_empty() || metrics.width == 0 || metrics.height == 0 { + // Handle rasterization result + let Some((glyph_width, glyph_height, bitmap, offset_x, offset_y)) = raster_result else { + // Empty glyph (e.g., space) + let info = GlyphInfo { + uv: [0.0, 0.0, 0.0, 0.0], + offset: [0.0, 0.0], + size: [0.0, 0.0], + }; + self.char_cache.insert(c, info); + return info; + }; + + if bitmap.is_empty() || glyph_width == 0 || glyph_height == 0 { // Empty glyph (e.g., space) let info = GlyphInfo { uv: [0.0, 0.0, 0.0, 0.0], @@ -2572,9 +2895,6 @@ impl Renderer { return info; } - let glyph_width = metrics.width as u32; - let glyph_height = metrics.height as u32; - // Check if we need to move to next row if self.atlas_cursor_x + glyph_width > ATLAS_SIZE { self.atlas_cursor_x = 0; @@ -2595,9 +2915,9 @@ impl Renderer { } // Copy bitmap to atlas - for y in 0..metrics.height { - for x in 0..metrics.width { - let src_idx = y * metrics.width + x; + for y in 0..glyph_height as usize { + for x in 0..glyph_width as usize { + let src_idx = y * glyph_width as usize + x; let dst_x = self.atlas_cursor_x + x as u32; let dst_y = self.atlas_cursor_y + y as u32; let dst_idx = (dst_y * ATLAS_SIZE + dst_x) as usize; @@ -2614,7 +2934,7 @@ impl Renderer { let info = GlyphInfo { uv: [uv_x, uv_y, uv_w, uv_h], - offset: [metrics.xmin as f32, metrics.ymin as f32], + offset: [offset_x, offset_y], size: [glyph_width as f32, glyph_height as f32], }; @@ -2625,6 +2945,68 @@ impl Renderer { self.char_cache.insert(c, info); info } + + /// Rasterize a glyph using ab_glyph with pixel-perfect alignment. + /// Returns (width, height, bitmap, offset_x, offset_y) or None if glyph has no outline. + /// offset_x is the left bearing (horizontal offset from cursor), snapped to integer pixels + /// offset_y is compatible with fontdue's ymin (distance from baseline to glyph bottom, negative for descenders) + fn rasterize_glyph_ab(&self, font: &FontRef<'_>, glyph_id: GlyphId) -> Option<(u32, u32, Vec, f32, f32)> { + // First, get the unpositioned glyph bounds to determine pixel-aligned position + let unpositioned = glyph_id.with_scale_and_position(self.font_size, ab_glyph::point(0.0, 0.0)); + let outlined_check = font.outline_glyph(unpositioned)?; + let raw_bounds = outlined_check.px_bounds(); + + // Snap to integer pixel boundaries for crisp rendering. + // Floor the min coordinates to ensure the glyph bitmap starts at an integer pixel. + // This prevents antialiasing artifacts where horizontal/vertical lines appear + // to have uneven thickness due to fractional pixel positioning. + let snapped_min_x = raw_bounds.min.x.floor(); + let snapped_min_y = raw_bounds.min.y.floor(); + + // Position the glyph so its bounds start at integer pixels. + // We offset by the fractional part to align to pixel grid. + let offset_to_snap_x = snapped_min_x - raw_bounds.min.x; + let offset_to_snap_y = snapped_min_y - raw_bounds.min.y; + let snapped_glyph = glyph_id.with_scale_and_position( + self.font_size, + ab_glyph::point(offset_to_snap_x, offset_to_snap_y), + ); + + let outlined = font.outline_glyph(snapped_glyph)?; + let bounds = outlined.px_bounds(); + + // Now bounds.min.x and bounds.min.y should be very close to integers + let width = bounds.width().ceil() as u32; + let height = bounds.height().ceil() as u32; + + if width == 0 || height == 0 { + return None; + } + + let mut bitmap = vec![0u8; (width * height) as usize]; + + outlined.draw(|x, y, coverage| { + let x = x as u32; + let y = y as u32; + if x < width && y < height { + let idx = (y * width + x) as usize; + bitmap[idx] = (coverage * 255.0) as u8; + } + }); + + // Use the snapped (integer) offsets for positioning. + // offset_x = left bearing, snapped to integer pixels + // offset_y = distance from baseline to glyph BOTTOM (fontdue's ymin convention) + // + // ab_glyph's bounds.min.y is the TOP of the glyph (negative = above baseline) + // ab_glyph's bounds.max.y is the BOTTOM of the glyph (positive = below baseline) + // + // We use the snapped bounds which are now at integer pixel positions. + let offset_x = snapped_min_x; + let offset_y = -(raw_bounds.max.y + offset_to_snap_y); // Snap the bottom too + + Some((width, height, bitmap, offset_x, offset_y)) + } /// Get or rasterize a glyph by its glyph ID from the primary font. /// Used for ligatures where we have the glyph ID from rustybuzz. @@ -2634,10 +3016,22 @@ impl Renderer { return *info; } - // Rasterize the glyph by ID from primary font - let (metrics, bitmap) = self.fontdue_font.rasterize_indexed(glyph_id, self.font_size); + // Rasterize the glyph by ID from primary font using ab_glyph + let ab_glyph_id = GlyphId(glyph_id); + let raster_result = self.rasterize_glyph_ab(&self.primary_font.clone(), ab_glyph_id); - if bitmap.is_empty() || metrics.width == 0 || metrics.height == 0 { + let Some((glyph_width, glyph_height, bitmap, offset_x, offset_y)) = raster_result else { + // Empty glyph (e.g., space) + let info = GlyphInfo { + uv: [0.0, 0.0, 0.0, 0.0], + offset: [0.0, 0.0], + size: [0.0, 0.0], + }; + self.glyph_cache.insert(cache_key, info); + return info; + }; + + if bitmap.is_empty() || glyph_width == 0 || glyph_height == 0 { // Empty glyph (e.g., space) let info = GlyphInfo { uv: [0.0, 0.0, 0.0, 0.0], @@ -2648,9 +3042,6 @@ impl Renderer { return info; } - let glyph_width = metrics.width as u32; - let glyph_height = metrics.height as u32; - // Check if we need to move to next row if self.atlas_cursor_x + glyph_width > ATLAS_SIZE { self.atlas_cursor_x = 0; @@ -2671,9 +3062,9 @@ impl Renderer { } // Copy bitmap to atlas - for y in 0..metrics.height { - for x in 0..metrics.width { - let src_idx = y * metrics.width + x; + for y in 0..glyph_height as usize { + for x in 0..glyph_width as usize { + let src_idx = y * glyph_width as usize + x; let dst_x = self.atlas_cursor_x + x as u32; let dst_y = self.atlas_cursor_y + y as u32; let dst_idx = (dst_y * ATLAS_SIZE + dst_x) as usize; @@ -2690,7 +3081,7 @@ impl Renderer { let info = GlyphInfo { uv: [uv_x, uv_y, uv_w, uv_h], - offset: [metrics.xmin as f32, metrics.ymin as f32], + offset: [offset_x, offset_y], size: [glyph_width as f32, glyph_height as f32], }; @@ -2702,32 +3093,16 @@ impl Renderer { info } - /// Shape a single character to get its default glyph ID. - /// Used for ligature detection by comparing combined vs individual shaping. - fn shape_single_char(&self, c: char) -> u16 { - let mut buffer = UnicodeBuffer::new(); - buffer.push_str(&c.to_string()); - let glyph_buffer = rustybuzz::shape(&self.shaping_ctx.face, &[], buffer); - let infos = glyph_buffer.glyph_infos(); - if infos.is_empty() { - 0 - } else { - infos[0].glyph_id as u16 - } - } - - /// Shape a multi-character text string (for ligatures). - /// Detects if the font produces a ligature by comparing glyph IDs - /// when shaped together vs shaped individually. + /// Shape a text string using HarfBuzz/rustybuzz. + /// Returns glyph IDs with advances and offsets for texture healing. fn shape_text(&mut self, text: &str) -> ShapedGlyphs { // Check cache first if let Some(cached) = self.ligature_cache.get(text) { return cached.clone(); } - let chars: Vec = text.chars().collect(); - let char_count = chars.len(); - + let _chars: Vec = text.chars().collect(); + let mut buffer = UnicodeBuffer::new(); buffer.push_str(text); @@ -2736,56 +3111,233 @@ impl Renderer { let glyph_infos = glyph_buffer.glyph_infos(); let glyph_positions = glyph_buffer.glyph_positions(); - let glyphs: Vec<(u16, f32, u32)> = glyph_infos + let glyphs: Vec<(u16, f32, f32, f32, u32)> = glyph_infos .iter() .zip(glyph_positions.iter()) .map(|(info, pos)| { let glyph_id = info.glyph_id as u16; // Ensure glyph is rasterized self.get_glyph_by_id(glyph_id); - // Convert advance from font units to pixels - let advance = pos.x_advance as f32 * self.font_size / self.shaping_ctx.face.units_per_em() as f32; - (glyph_id, advance, info.cluster) + // Convert from font units to pixels using the correct scale factor. + // This matches ab_glyph's calculation: font_size / height_unscaled + let x_advance = pos.x_advance as f32 * self.font_units_to_px; + let x_offset = pos.x_offset as f32 * self.font_units_to_px; + let y_offset = pos.y_offset as f32 * self.font_units_to_px; + (glyph_id, x_advance, x_offset, y_offset, info.cluster) }) .collect(); - // Get individual glyph IDs for comparison - let individual_glyphs: Vec = chars.iter().map(|&c| self.shape_single_char(c)).collect(); - - // Detect ligature by comparing combined vs individual shaping - // A ligature occurred if: - // 1. Fewer glyphs than input characters, OR - // 2. Any glyph ID differs from the individual character's glyph ID - let fewer_glyphs = glyphs.len() < char_count; - - let has_substitution = if glyphs.len() == char_count { - glyphs.iter().zip(individual_glyphs.iter()) - .any(|((combined_id, _, _), &individual_id)| *combined_id != individual_id) - } else { - true // If glyph count differs, it's definitely a substitution - }; - - let is_ligature = fewer_glyphs || has_substitution; - - // Debug: log shaping results for ligature patterns - log::debug!( - "shape_text: '{}' ({} chars) -> {} glyphs, is_ligature={}, combined={:?}, individual={:?}", - text, - char_count, - glyphs.len(), - is_ligature, - glyphs.iter().map(|(id, _, _)| *id).collect::>(), - individual_glyphs - ); - - let shaped = ShapedGlyphs { - glyphs, - is_ligature, + let shaped = ShapedGlyphs { + glyphs, }; self.ligature_cache.insert(text.to_string(), shaped.clone()); shaped } + // ═══════════════════════════════════════════════════════════════════════════ + // KITTY-STYLE TEXTURE HEALING: CANVAS-BASED TEXT RUN RENDERING + // ═══════════════════════════════════════════════════════════════════════════ + // + // This implements Kitty's approach to texture healing: + // 1. Render all glyphs in a text run into a single multi-cell canvas + // 2. Use HarfBuzz x_offset and x_advance for glyph positioning + // 3. Extract individual cell-sized sprites from the canvas + // 4. Upload each cell sprite to the atlas + // + // This allows narrow characters (like 'i') to leave space for adjacent + // wider characters (like 'm'), creating more balanced text appearance. + + /// Ensure the canvas buffer is large enough for the given number of cells. + fn ensure_canvas_size(&mut self, num_cells: usize) { + let required_width = (self.cell_width as usize) * num_cells; + let required_height = self.cell_height as usize; + let required_size = required_width * required_height; + + if self.canvas_buffer.len() < required_size { + self.canvas_buffer.resize(required_size, 0); + } + self.canvas_size = (required_width as u32, required_height as u32); + } + + /// Clear the canvas buffer to transparent. + fn clear_canvas(&mut self, num_cells: usize) { + let canvas_width = (self.cell_width as usize) * num_cells; + let canvas_height = self.cell_height as usize; + let size = canvas_width * canvas_height; + // Only clear the portion we're using + self.canvas_buffer[..size].fill(0); + } + + /// Render shaped glyphs into the canvas buffer using HarfBuzz positions. + /// This is the core of Kitty's texture healing approach. + /// + /// Arguments: + /// - `shaped`: The shaped glyph data from HarfBuzz + /// - `num_cells`: Number of cells this text run spans + /// - `baseline_offset`: Offset from top of cell to baseline (typically cell_height * 0.8) + fn render_glyphs_to_canvas(&mut self, shaped: &ShapedGlyphs, num_cells: usize, baseline_offset: f32) { + let canvas_width = (self.cell_width as usize) * num_cells; + let canvas_height = self.cell_height as usize; + + // Track cursor position in canvas coordinates + let mut cursor_x: f32 = 0.0; + + for &(glyph_id, x_advance, x_offset, y_offset, _cluster) in &shaped.glyphs { + // Get the rasterized glyph bitmap + let glyph = self.get_glyph_by_id(glyph_id); + + if glyph.size[0] <= 0.0 || glyph.size[1] <= 0.0 { + // Empty glyph (e.g., space) - just advance cursor + cursor_x += x_advance; + continue; + } + + // Calculate glyph position in canvas: + // - cursor_x: accumulated from x_advance + // - x_offset: HarfBuzz adjustment for texture healing + // - glyph.offset[0]: left bearing from the font + let glyph_x = cursor_x + x_offset + glyph.offset[0]; + + // Y position: baseline_offset is from top to baseline + // glyph.offset[1] is distance from baseline to glyph bottom (negative = below baseline) + // For canvas coords (y=0 at top), we need: baseline_offset - glyph_top + // glyph_top = glyph.offset[1] + glyph.size[1] (since offset is to bottom) + let glyph_y = baseline_offset + y_offset - glyph.offset[1] - glyph.size[1]; + + // Round to integers for pixel placement + let dest_x = glyph_x.round() as i32; + let dest_y = glyph_y.round() as i32; + + // Get glyph bitmap from atlas + // The glyph.uv gives us the location in the atlas + let atlas_x = (glyph.uv[0] * ATLAS_SIZE as f32) as u32; + let atlas_y = (glyph.uv[1] * ATLAS_SIZE as f32) as u32; + let glyph_w = glyph.size[0] as u32; + let glyph_h = glyph.size[1] as u32; + + // Copy glyph bitmap from atlas to canvas, with bounds checking + for gy in 0..glyph_h { + let canvas_y = dest_y + gy as i32; + if canvas_y < 0 || canvas_y >= canvas_height as i32 { + continue; + } + + for gx in 0..glyph_w { + let canvas_x = dest_x + gx as i32; + if canvas_x < 0 || canvas_x >= canvas_width as i32 { + continue; + } + + // Source in atlas + let src_idx = ((atlas_y + gy) * ATLAS_SIZE + (atlas_x + gx)) as usize; + // Destination in canvas + let dst_idx = (canvas_y as usize) * canvas_width + (canvas_x as usize); + + // Blend: use max for overlapping glyphs (simple compositing) + let src_alpha = self.atlas_data[src_idx]; + let dst_alpha = self.canvas_buffer[dst_idx]; + self.canvas_buffer[dst_idx] = src_alpha.max(dst_alpha); + } + } + + // Advance cursor by glyph's advance width. + // Round the advance to ensure it aligns with cell boundaries for monospace fonts. + // This matches Kitty's approach: x += roundf(x_advance) + cursor_x += x_advance.round(); + } + } + + /// Extract a single cell from the canvas and upload it to the atlas. + /// Returns the GlyphInfo for this cell's sprite. + fn extract_cell_from_canvas(&mut self, cell_index: usize, num_cells: usize) -> [f32; 4] { + let cell_width = self.cell_width as u32; + let cell_height = self.cell_height as u32; + let canvas_width = cell_width * num_cells as u32; + + // Check if we need to move to next row in atlas + if self.atlas_cursor_x + cell_width > ATLAS_SIZE { + self.atlas_cursor_x = 0; + self.atlas_cursor_y += self.atlas_row_height + 1; + self.atlas_row_height = 0; + } + + // Check if atlas is full + if self.atlas_cursor_y + cell_height > ATLAS_SIZE { + log::warn!("Glyph atlas is full during text run rendering!"); + return [0.0, 0.0, 0.0, 0.0]; + } + + // Copy cell region from canvas to atlas + let src_x_start = cell_index as u32 * cell_width; + for y in 0..cell_height { + for x in 0..cell_width { + let src_idx = (y * canvas_width + src_x_start + x) as usize; + let dst_x = self.atlas_cursor_x + x; + let dst_y = self.atlas_cursor_y + y; + let dst_idx = (dst_y * ATLAS_SIZE + dst_x) as usize; + self.atlas_data[dst_idx] = self.canvas_buffer[src_idx]; + } + } + self.atlas_dirty = true; + + // Calculate UV coordinates + let uv_x = self.atlas_cursor_x as f32 / ATLAS_SIZE as f32; + let uv_y = self.atlas_cursor_y as f32 / ATLAS_SIZE as f32; + let uv_w = cell_width as f32 / ATLAS_SIZE as f32; + let uv_h = cell_height as f32 / ATLAS_SIZE as f32; + + // Update atlas cursor + self.atlas_cursor_x += cell_width + 1; + self.atlas_row_height = self.atlas_row_height.max(cell_height); + + [uv_x, uv_y, uv_w, uv_h] + } + + /// Render a text run using Kitty's canvas-based approach for texture healing. + /// Returns TextRunSprites containing UV coordinates for each cell. + fn render_text_run(&mut self, text: &str, num_cells: usize) -> TextRunSprites { + // Check cache first + if let Some(cached) = self.text_run_cache.get(text) { + return cached.clone(); + } + + // Shape the text + let shaped = self.shape_text(text); + + // DEBUG: Log shaping results + if num_cells >= 2 { + let total_advance: f32 = shaped.glyphs.iter().map(|g| g.1).sum(); + let expected_width = self.cell_width * num_cells as f32; + eprintln!("DEBUG shape_text: text='{}' num_cells={} num_glyphs={} total_advance={:.2} expected_width={:.2} cell_width={:.2}", + text, num_cells, shaped.glyphs.len(), total_advance, expected_width, self.cell_width); + for (i, &(glyph_id, x_advance, x_offset, _y_offset, cluster)) in shaped.glyphs.iter().enumerate() { + eprintln!(" glyph[{}]: id={} x_advance={:.2} x_offset={:.2} cluster={}", + i, glyph_id, x_advance, x_offset, cluster); + } + } + + // Ensure canvas is big enough + self.ensure_canvas_size(num_cells); + self.clear_canvas(num_cells); + + // Calculate baseline offset (typically ~80% down from top of cell) + let baseline_offset = self.cell_height * 0.8; + + // Render all glyphs into the canvas + self.render_glyphs_to_canvas(&shaped, num_cells, baseline_offset); + + // Extract each cell from the canvas + let mut cells = Vec::with_capacity(num_cells); + for i in 0..num_cells { + let uv = self.extract_cell_from_canvas(i, num_cells); + cells.push(uv); + } + + let sprites = TextRunSprites { cells }; + self.text_run_cache.insert(text.to_string(), sprites.clone()); + sprites + } + /// Convert sRGB component (0.0-1.0) to linear RGB. /// This is needed because we're rendering to an sRGB surface. #[inline] @@ -2810,14 +3362,14 @@ impl Renderer { let snapped = pixel.round(); 1.0 - (snapped / screen_height) * 2.0 } - + /// Render a single pane's terminal content at a given position. /// This is a helper method for multi-pane rendering. - /// + /// /// Arguments: /// - `terminal`: The terminal state for this pane /// - `pane_x`: Left edge of pane in pixels - /// - `pane_y`: Top edge of pane in pixels + /// - `pane_y`: Top edge of pane in pixels /// - `pane_width`: Width of pane in pixels /// - `pane_height`: Height of pane in pixels /// - `is_active`: Whether this is the active pane (for cursor rendering) @@ -2836,15 +3388,15 @@ impl Renderer { ) { let width = self.width as f32; let height = self.height as f32; - + // Calculate pane's terminal dimensions let cols = (pane_width / self.cell_width).floor() as usize; let rows = (pane_height / self.cell_height).floor() as usize; - + // Cache palette values let palette_default_fg = self.palette.default_fg; let palette_colors = self.palette.colors; - + // Helper to convert Color to linear RGBA let color_to_rgba = |color: &Color, is_foreground: bool| -> [f32; 4] { match color { @@ -2878,7 +3430,7 @@ impl Renderer { } } }; - + // Helper to check if a cell is selected let is_cell_selected = |col: usize, row: usize| -> bool { let Some((start_col, start_row, end_col, end_row)) = selection else { @@ -2898,173 +3450,281 @@ impl Renderer { return true; } }; - + // Get visible rows (accounts for scroll offset) let visible_rows = terminal.visible_rows(); - - // Common programming ligatures to check (longest first for greedy matching) - const LIGATURE_PATTERNS: &[&str] = &[ - // 3-char - "===", "!==", ">>>", "<<<", "||=", "&&=", "??=", "...", "-->", "<--", "<->", - "www", - // 2-char - "=>", "->", "<-", ">=", "<=", "==", "!=", "::", "&&", "||", "??", "..", "++", - "--", "<<", ">>", "|>", "<|", "/*", "*/", "//", "##", ":=", "~=", "<>", - ]; - + // Render each row for (row_idx, row) in visible_rows.iter().enumerate() { if row_idx >= rows { break; } - + // Find the last non-empty cell for selection clipping + // Note: U+10EEEE is Kitty graphics placeholder, treat as empty + const KITTY_PLACEHOLDER_CHAR: char = '\u{10EEEE}'; let last_content_col = row.iter() .enumerate() .rev() - .find(|(_, cell)| cell.character != ' ' && cell.character != '\0') + .find(|(_, cell)| cell.character != ' ' && cell.character != '\0' && cell.character != KITTY_PLACEHOLDER_CHAR) .map(|(idx, _)| idx) .unwrap_or(0); + + // ═══════════════════════════════════════════════════════════════════════════ + // TEXT RUN SHAPING FOR TEXTURE HEALING + // ═══════════════════════════════════════════════════════════════════════════ + // Instead of rendering character-by-character, we group consecutive cells + // with the same styling into "text runs" and shape them together. This allows + // the font's calt (contextual alternates) feature to apply texture healing, + // which adjusts character positions based on their neighbors. + + let cell_y = pane_y + row_idx as f32 * self.cell_height; let mut col_idx = 0; while col_idx < row.len() && col_idx < cols { let cell = &row[col_idx]; - let cell_x = pane_x + col_idx as f32 * self.cell_width; - let cell_y = pane_y + row_idx as f32 * self.cell_height; - let mut fg_color = color_to_rgba(&cell.fg_color, true); - let mut bg_color = color_to_rgba(&cell.bg_color, false); + // Determine colors for this cell (with selection override) + let (base_fg, base_bg) = if is_cell_selected(col_idx, row_idx) && col_idx <= last_content_col { + ([0.0f32, 0.0, 0.0, 1.0], [1.0f32, 1.0, 1.0, 1.0]) + } else { + (color_to_rgba(&cell.fg_color, true), color_to_rgba(&cell.bg_color, false)) + }; - // Handle selection - if is_cell_selected(col_idx, row_idx) && col_idx <= last_content_col { - fg_color = [0.0, 0.0, 0.0, 1.0]; - bg_color = [1.0, 1.0, 1.0, 1.0]; - } + // Collect a run of cells with the same fg/bg colors + let mut run_text = String::new(); + let mut run_cells: Vec<(usize, char, bool)> = Vec::new(); // (col, char, is_box_drawing) - // Check for ligatures by looking ahead - let mut ligature_len = 0; - let mut ligature_shaped: Option = None; - - for pattern in LIGATURE_PATTERNS { - let pat_len = pattern.len(); - if col_idx + pat_len <= row.len() { - // Build the candidate string from consecutive cells - let candidate: String = row[col_idx..col_idx + pat_len] - .iter() - .map(|c| c.character) - .collect(); - - if candidate == *pattern { - // Only form ligatures from cells with matching foreground colors. - // This prevents ghost/completion text (which typically has a - // different color) from being combined with typed text. - let first_fg = &row[col_idx].fg_color; - let all_same_color = row[col_idx..col_idx + pat_len] - .iter() - .all(|c| &c.fg_color == first_fg); - - if !all_same_color { - continue; - } - - // Check if font actually produces a ligature - let shaped = self.shape_text(&candidate); - // Use our improved ligature detection - if shaped.is_ligature { - ligature_shaped = Some(shaped); - ligature_len = pat_len; - break; - } - } - } - } - - if let Some(shaped) = ligature_shaped { - // Render ligature spanning multiple cells - // Add background for all cells in the ligature - for i in 0..ligature_len { - let bg_cell_x = pane_x + (col_idx + i) as f32 * self.cell_width; - let cell_left = Self::pixel_to_ndc_x(bg_cell_x, width); - let cell_right = Self::pixel_to_ndc_x(bg_cell_x + self.cell_width, width); - let cell_top = Self::pixel_to_ndc_y(cell_y, height); - let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); - - let base_idx = self.bg_vertices.len() as u32; - self.bg_vertices.push(GlyphVertex { - position: [cell_left, cell_top], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - self.bg_vertices.push(GlyphVertex { - position: [cell_right, cell_top], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - self.bg_vertices.push(GlyphVertex { - position: [cell_right, cell_bottom], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - self.bg_vertices.push(GlyphVertex { - position: [cell_left, cell_bottom], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - self.bg_indices.extend_from_slice(&[ - base_idx, base_idx + 1, base_idx + 2, - base_idx, base_idx + 2, base_idx + 3, - ]); - } - - // Render all glyphs from the shaped output with their proper advances. - // For ligatures like "->", the font produces: - // - Glyph 0: spacer (0x0 invisible), advance = cell_width - // - Glyph 1: ligature glyph with negative xmin to extend back into cell 0 - let baseline_y = (cell_y + self.cell_height * 0.8).round(); - let mut cursor_x = cell_x; + while col_idx < row.len() && col_idx < cols { + let run_cell = &row[col_idx]; - for &(glyph_id, advance, _cluster) in &shaped.glyphs { - let glyph = self.get_glyph_by_id(glyph_id); - - // Only render if glyph has content (spacers are 0x0) + // Check if this cell has the same colors + let (cell_fg, cell_bg) = if is_cell_selected(col_idx, row_idx) && col_idx <= last_content_col { + ([0.0f32, 0.0, 0.0, 1.0], [1.0f32, 1.0, 1.0, 1.0]) + } else { + (color_to_rgba(&run_cell.fg_color, true), color_to_rgba(&run_cell.bg_color, false)) + }; + + if cell_fg != base_fg || cell_bg != base_bg { + break; // Different colors, end this run + } + + let c = run_cell.character; + const KITTY_PLACEHOLDER: char = '\u{10EEEE}'; + let is_renderable = c != ' ' && c != '\0' && c != KITTY_PLACEHOLDER; + let is_box = Self::is_box_drawing(c); + + run_cells.push((col_idx, c, is_box)); + if is_renderable && !is_box { + run_text.push(c); + } else { + // Use a placeholder that won't affect shaping + run_text.push(' '); + } + + col_idx += 1; + } + + // Render backgrounds for all cells in the run + for &(cell_col, _, _) in &run_cells { + let cell_x = pane_x + cell_col as f32 * self.cell_width; + let cell_left = Self::pixel_to_ndc_x(cell_x, width); + let cell_right = Self::pixel_to_ndc_x(cell_x + self.cell_width, width); + let cell_top = Self::pixel_to_ndc_y(cell_y, height); + let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); + + let base_idx = self.bg_vertices.len() as u32; + self.bg_vertices.push(GlyphVertex { + position: [cell_left, cell_top], + uv: [0.0, 0.0], + color: base_fg, + bg_color: base_bg, + }); + self.bg_vertices.push(GlyphVertex { + position: [cell_right, cell_top], + uv: [0.0, 0.0], + color: base_fg, + bg_color: base_bg, + }); + self.bg_vertices.push(GlyphVertex { + position: [cell_right, cell_bottom], + uv: [0.0, 0.0], + color: base_fg, + bg_color: base_bg, + }); + self.bg_vertices.push(GlyphVertex { + position: [cell_left, cell_bottom], + uv: [0.0, 0.0], + color: base_fg, + bg_color: base_bg, + }); + self.bg_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + + // ═══════════════════════════════════════════════════════════════ + // RENDER TEXT RUN USING KITTY'S CANVAS-BASED APPROACH + // ═══════════════════════════════════════════════════════════════ + // Use canvas-based rendering for texture healing: + // 1. Render all glyphs into a multi-cell canvas using HarfBuzz positions + // 2. Extract each cell's portion as a sprite + // 3. Render sprites at cell positions + + // First, collect renderable (non-box-drawing) characters for shaping + let mut shape_text = String::new(); + let mut shape_indices: Vec = Vec::new(); // Maps shape_text position to run_cells index + + for (i, &(_, c, is_box)) in run_cells.iter().enumerate() { + const KITTY_PLACEHOLDER: char = '\u{10EEEE}'; + if c != ' ' && c != '\0' && c != KITTY_PLACEHOLDER && !is_box { + shape_text.push(c); + shape_indices.push(i); + } + } + + // Render the text run using canvas approach if there's text to shape + let sprites = if !shape_text.is_empty() && shape_indices.len() == run_cells.len() { + // All cells are shapeable text - use canvas rendering + // DEBUG: Log what we're shaping + if run_cells.len() >= 2 { + eprintln!("DEBUG render_text_run: text='{}' num_cells={} shape_text='{}'", + run_text, run_cells.len(), shape_text); + } + Some(self.render_text_run(&run_text, run_cells.len())) + } else { + None + }; + + // Render each cell + for (run_idx, &(cell_col, c, is_box)) in run_cells.iter().enumerate() { + let cell_x = pane_x + cell_col as f32 * self.cell_width; + + const KITTY_PLACEHOLDER: char = '\u{10EEEE}'; + if c == ' ' || c == '\0' || c == KITTY_PLACEHOLDER { + // Empty cell - nothing to render + continue; + } + + if is_box { + // Box drawing - render without shaping/canvas + let glyph = self.rasterize_char(c); if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { - // Use the glyph's horizontal offset (xmin) - this allows - // ligature glyphs with negative xmin to extend backwards - let glyph_x = (cursor_x + glyph.offset[0]).round(); - let glyph_y = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); - + let glyph_x = cell_x; + let glyph_y = cell_y; + let left = Self::pixel_to_ndc_x(glyph_x, width); let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); let top = Self::pixel_to_ndc_y(glyph_y, height); let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); - + let base_idx = self.glyph_vertices.len() as u32; self.glyph_vertices.push(GlyphVertex { position: [left, top], uv: [glyph.uv[0], glyph.uv[1]], - color: fg_color, + color: base_fg, bg_color: [0.0, 0.0, 0.0, 0.0], }); self.glyph_vertices.push(GlyphVertex { position: [right, top], uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], - color: fg_color, + color: base_fg, bg_color: [0.0, 0.0, 0.0, 0.0], }); self.glyph_vertices.push(GlyphVertex { position: [right, bottom], uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], - color: fg_color, + color: base_fg, bg_color: [0.0, 0.0, 0.0, 0.0], }); self.glyph_vertices.push(GlyphVertex { position: [left, bottom], uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], - color: fg_color, + color: base_fg, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + self.glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + } else if let Some(ref run_sprites) = sprites { + // Use pre-rendered sprite from canvas + if run_idx < run_sprites.cells.len() { + let uv = run_sprites.cells[run_idx]; + if uv[2] > 0.0 && uv[3] > 0.0 { + let left = Self::pixel_to_ndc_x(cell_x, width); + let right = Self::pixel_to_ndc_x(cell_x + self.cell_width, width); + let top = Self::pixel_to_ndc_y(cell_y, height); + let bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); + + let base_idx = self.glyph_vertices.len() as u32; + self.glyph_vertices.push(GlyphVertex { + position: [left, top], + uv: [uv[0], uv[1]], + color: base_fg, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + self.glyph_vertices.push(GlyphVertex { + position: [right, top], + uv: [uv[0] + uv[2], uv[1]], + color: base_fg, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + self.glyph_vertices.push(GlyphVertex { + position: [right, bottom], + uv: [uv[0] + uv[2], uv[1] + uv[3]], + color: base_fg, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + self.glyph_vertices.push(GlyphVertex { + position: [left, bottom], + uv: [uv[0], uv[1] + uv[3]], + color: base_fg, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + self.glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + } + } else { + // Fallback: render character individually + let glyph = self.rasterize_char(c); + if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { + let glyph_x = (cell_x + glyph.offset[0]).round(); + let glyph_y = (cell_y + self.cell_height * 0.8 - glyph.offset[1] - glyph.size[1]).round(); + + let left = Self::pixel_to_ndc_x(glyph_x, width); + let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); + let top = Self::pixel_to_ndc_y(glyph_y, height); + let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); + + let base_idx = self.glyph_vertices.len() as u32; + self.glyph_vertices.push(GlyphVertex { + position: [left, top], + uv: [glyph.uv[0], glyph.uv[1]], + color: base_fg, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + self.glyph_vertices.push(GlyphVertex { + position: [right, top], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], + color: base_fg, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + self.glyph_vertices.push(GlyphVertex { + position: [right, bottom], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], + color: base_fg, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + self.glyph_vertices.push(GlyphVertex { + position: [left, bottom], + uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], + color: base_fg, bg_color: [0.0, 0.0, 0.0, 0.0], }); self.glyph_indices.extend_from_slice(&[ @@ -3072,110 +3732,11 @@ impl Renderer { base_idx, base_idx + 2, base_idx + 3, ]); } - - // Advance cursor for next glyph - cursor_x += advance; - } - - // Skip the cells consumed by the ligature - col_idx += ligature_len; - continue; - } - - // No ligature - render single cell - - // Cell bounds - let cell_left = Self::pixel_to_ndc_x(cell_x, width); - let cell_right = Self::pixel_to_ndc_x(cell_x + self.cell_width, width); - let cell_top = Self::pixel_to_ndc_y(cell_y, height); - let cell_bottom = Self::pixel_to_ndc_y(cell_y + self.cell_height, height); - - // Add background quad - let base_idx = self.bg_vertices.len() as u32; - self.bg_vertices.push(GlyphVertex { - position: [cell_left, cell_top], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - self.bg_vertices.push(GlyphVertex { - position: [cell_right, cell_top], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - self.bg_vertices.push(GlyphVertex { - position: [cell_right, cell_bottom], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - self.bg_vertices.push(GlyphVertex { - position: [cell_left, cell_bottom], - uv: [0.0, 0.0], - color: fg_color, - bg_color, - }); - self.bg_indices.extend_from_slice(&[ - base_idx, base_idx + 1, base_idx + 2, - base_idx, base_idx + 2, base_idx + 3, - ]); - - // Add glyph if it has content - let c = cell.character; - if c != ' ' && c != '\0' { - let glyph = self.rasterize_char(c); - if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { - let (glyph_x, glyph_y) = if Self::is_box_drawing(c) { - (cell_x, cell_y) - } else { - let baseline_y = (cell_y + self.cell_height * 0.8).round(); - let gx = (cell_x + glyph.offset[0]).round(); - let gy = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); - (gx, gy) - }; - - let left = Self::pixel_to_ndc_x(glyph_x, width); - let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); - let top = Self::pixel_to_ndc_y(glyph_y, height); - let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); - - let base_idx = self.glyph_vertices.len() as u32; - self.glyph_vertices.push(GlyphVertex { - position: [left, top], - uv: [glyph.uv[0], glyph.uv[1]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - self.glyph_vertices.push(GlyphVertex { - position: [right, top], - uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - self.glyph_vertices.push(GlyphVertex { - position: [right, bottom], - uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - self.glyph_vertices.push(GlyphVertex { - position: [left, bottom], - uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], - color: fg_color, - bg_color: [0.0, 0.0, 0.0, 0.0], - }); - self.glyph_indices.extend_from_slice(&[ - base_idx, base_idx + 1, base_idx + 2, - base_idx, base_idx + 2, base_idx + 3, - ]); } } - - col_idx += 1; } } - + // Render cursor only for active pane if is_active && terminal.cursor_visible && terminal.scroll_offset == 0 && terminal.cursor_row < rows && terminal.cursor_col < cols { @@ -3183,10 +3744,10 @@ impl Renderer { let cursor_row = terminal.cursor_row; let cursor_x = pane_x + cursor_col as f32 * self.cell_width; let cursor_y = pane_y + cursor_row as f32 * self.cell_height; - + // Get cell under cursor let cursor_cell = visible_rows.get(cursor_row).and_then(|row| row.get(cursor_col)); - + let (cell_fg, cell_bg, cell_char) = if let Some(cell) = cursor_cell { let fg = color_to_rgba(&cell.fg_color, true); let bg = color_to_rgba(&cell.bg_color, false); @@ -3203,9 +3764,11 @@ impl Renderer { }; (fg, [0.0, 0.0, 0.0, 0.0], ' ') }; - - let has_character = cell_char != ' ' && cell_char != '\0'; - + + // Kitty graphics Unicode placeholder should be treated as empty + const KITTY_PLACEHOLDER: char = '\u{10EEEE}'; + let has_character = cell_char != ' ' && cell_char != '\0' && cell_char != KITTY_PLACEHOLDER; + let cursor_bg_color = if has_character { [cell_fg[0], cell_fg[1], cell_fg[2], 1.0] } else { @@ -3216,13 +3779,13 @@ impl Renderer { [1.0 - cell_bg[0], 1.0 - cell_bg[1], 1.0 - cell_bg[2], 1.0] } }; - + let cursor_style = match terminal.cursor_shape { CursorShape::BlinkingBlock | CursorShape::SteadyBlock => 0, CursorShape::BlinkingUnderline | CursorShape::SteadyUnderline => 1, CursorShape::BlinkingBar | CursorShape::SteadyBar => 2, }; - + let (left, right, top, bottom) = match cursor_style { 0 => ( cursor_x, @@ -3249,12 +3812,12 @@ impl Renderer { ) } }; - + let cursor_left = Self::pixel_to_ndc_x(left, width); let cursor_right = Self::pixel_to_ndc_x(right, width); let cursor_top = Self::pixel_to_ndc_y(top, height); let cursor_bottom = Self::pixel_to_ndc_y(bottom, height); - + let base_idx = self.glyph_vertices.len() as u32; self.glyph_vertices.push(GlyphVertex { position: [cursor_left, cursor_top], @@ -3284,7 +3847,7 @@ impl Renderer { base_idx, base_idx + 1, base_idx + 2, base_idx, base_idx + 2, base_idx + 3, ]); - + // If block cursor with character, render it inverted if cursor_style == 0 && has_character { let char_color = if cell_bg[3] < 0.01 { @@ -3292,7 +3855,7 @@ impl Renderer { } else { [cell_bg[0], cell_bg[1], cell_bg[2], 1.0] }; - + let glyph = self.rasterize_char(cell_char); if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { let (glyph_x, glyph_y) = if Self::is_box_drawing(cell_char) { @@ -3303,12 +3866,12 @@ impl Renderer { let gy = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); (gx, gy) }; - + let g_left = Self::pixel_to_ndc_x(glyph_x, width); let g_right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); let g_top = Self::pixel_to_ndc_y(glyph_y, height); let g_bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); - + let base_idx = self.glyph_vertices.len() as u32; self.glyph_vertices.push(GlyphVertex { position: [g_left, g_top], @@ -3342,17 +3905,17 @@ impl Renderer { } } } - + /// Draw a filled rectangle. fn render_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) { let width = self.width as f32; let height = self.height as f32; - + let left = Self::pixel_to_ndc_x(x, width); let right = Self::pixel_to_ndc_x(x + w, width); let top = Self::pixel_to_ndc_y(y, height); let bottom = Self::pixel_to_ndc_y(y + h, height); - + let base_idx = self.bg_vertices.len() as u32; self.bg_vertices.push(GlyphVertex { position: [left, top], @@ -3383,17 +3946,17 @@ impl Renderer { base_idx, base_idx + 2, base_idx + 3, ]); } - + /// Draw a filled rectangle to the overlay layer (rendered on top of everything). fn render_overlay_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) { let width = self.width as f32; let height = self.height as f32; - + let left = Self::pixel_to_ndc_x(x, width); let right = Self::pixel_to_ndc_x(x + w, width); let top = Self::pixel_to_ndc_y(y, height); let bottom = Self::pixel_to_ndc_y(y + h, height); - + let base_idx = self.overlay_vertices.len() as u32; self.overlay_vertices.push(GlyphVertex { position: [left, top], @@ -3424,63 +3987,95 @@ impl Renderer { base_idx, base_idx + 2, base_idx + 3, ]); } - + /// Prepare edge glow uniform data for shader-based rendering. /// Returns the uniform data to be uploaded to the GPU. - fn prepare_edge_glow_uniforms(&self, glow: &EdgeGlow, terminal_y_offset: f32) -> EdgeGlowUniforms { + /// Prepare combined edge glow uniform data for all active glows. + fn prepare_edge_glow_uniforms(&self, glows: &[EdgeGlow], terminal_y_offset: f32, intensity: f32) -> EdgeGlowUniforms { // Use the same color as the active pane border (palette color 4 - typically blue) let [r, g, b] = self.palette.colors[4]; let color_r = Self::srgb_to_linear(r as f32 / 255.0); let color_g = Self::srgb_to_linear(g as f32 / 255.0); let color_b = Self::srgb_to_linear(b as f32 / 255.0); - - let direction = match glow.direction { - Direction::Up => 0, - Direction::Down => 1, - Direction::Left => 2, - Direction::Right => 3, - }; - + + let mut glow_instances = [GlowInstance { + direction: 0, + progress: 0.0, + color_r: 0.0, + color_g: 0.0, + color_b: 0.0, + pane_x: 0.0, + pane_y: 0.0, + pane_width: 0.0, + pane_height: 0.0, + _padding1: 0.0, + _padding2: 0.0, + _padding3: 0.0, + }; MAX_EDGE_GLOWS]; + + let glow_count = glows.len().min(MAX_EDGE_GLOWS); + + for (i, glow) in glows.iter().take(MAX_EDGE_GLOWS).enumerate() { + let direction = match glow.direction { + Direction::Up => 0, + Direction::Down => 1, + Direction::Left => 2, + Direction::Right => 3, + }; + + glow_instances[i] = GlowInstance { + direction, + progress: glow.progress(), + color_r, + color_g, + color_b, + pane_x: glow.pane_x, + pane_y: glow.pane_y, + pane_width: glow.pane_width, + pane_height: glow.pane_height, + _padding1: 0.0, + _padding2: 0.0, + _padding3: 0.0, + }; + } + EdgeGlowUniforms { screen_width: self.width as f32, screen_height: self.height as f32, terminal_y_offset, - direction, - progress: glow.progress(), - color_r, - color_g, - color_b, - enabled: 1, - _padding1: 0, - _padding2: 0, - _padding3: 0, + glow_intensity: intensity, + glow_count: glow_count as u32, + _padding: [0; 3], + glows: glow_instances, } } - + /// Render multiple panes with borders. - /// + /// /// Arguments: /// - `panes`: List of (terminal, pane_info, selection) tuples /// - `num_tabs`: Number of tabs for the tab bar /// - `active_tab`: Index of the active tab - /// - `edge_glow`: Optional edge glow animation for visual feedback + /// - `edge_glows`: Active edge glow animations for visual feedback + /// - `edge_glow_intensity`: Intensity of edge glow effect (0.0 = disabled, 1.0 = full) pub fn render_panes( &mut self, panes: &[(&Terminal, PaneRenderInfo, Option<(usize, usize, usize, usize)>)], num_tabs: usize, active_tab: usize, - edge_glow: Option<&EdgeGlow>, + edge_glows: &[EdgeGlow], + edge_glow_intensity: f32, ) -> Result<(), wgpu::SurfaceError> { // Sync palette from first terminal if let Some((terminal, _, _)) = panes.first() { self.palette = terminal.palette.clone(); } - + let output = self.surface.get_current_texture()?; let view = output .texture .create_view(&wgpu::TextureViewDescriptor::default()); - + // Clear buffers self.bg_vertices.clear(); self.bg_indices.clear(); @@ -3488,12 +4083,12 @@ impl Renderer { self.glyph_indices.clear(); self.overlay_vertices.clear(); self.overlay_indices.clear(); - + let width = self.width as f32; let height = self.height as f32; let tab_bar_height = self.tab_bar_height(); let terminal_y_offset = self.terminal_y_offset(); - + // ═══════════════════════════════════════════════════════════════════ // RENDER TAB BAR (same as render_from_terminal) // ═══════════════════════════════════════════════════════════════════ @@ -3503,7 +4098,7 @@ impl Renderer { TabBarPosition::Bottom => height - tab_bar_height, TabBarPosition::Hidden => unreachable!(), }; - + let tab_bar_bg = { let [r, g, b] = self.palette.default_bg; let factor = 0.85_f32; @@ -3514,21 +4109,21 @@ impl Renderer { 1.0, ] }; - + // Draw tab bar background self.render_rect(0.0, tab_bar_y, width, tab_bar_height, tab_bar_bg); - + // Render each tab let mut tab_x = 4.0_f32; let tab_padding = 8.0_f32; let min_tab_width = self.cell_width * 8.0; - + for idx in 0..num_tabs { let is_active = idx == active_tab; let title = format!(" {} ", idx + 1); let title_width = title.chars().count() as f32 * self.cell_width; let tab_width = title_width.max(min_tab_width); - + let tab_bg = if is_active { let [r, g, b] = self.palette.default_bg; [ @@ -3540,7 +4135,7 @@ impl Renderer { } else { tab_bar_bg }; - + let tab_fg = { let [r, g, b] = self.palette.default_fg; let alpha = if is_active { 1.0 } else { 0.6 }; @@ -3551,14 +4146,14 @@ impl Renderer { alpha, ] }; - + // Draw tab background self.render_rect(tab_x, tab_bar_y + 2.0, tab_width, tab_bar_height - 4.0, tab_bg); - + // Render tab title text let text_y = tab_bar_y + (tab_bar_height - self.cell_height) / 2.0; let text_x = tab_x + (tab_width - title_width) / 2.0; - + for (char_idx, c) in title.chars().enumerate() { if c == ' ' { continue; @@ -3569,12 +4164,12 @@ impl Renderer { let baseline_y = (text_y + self.cell_height * 0.8).round(); let glyph_x = (char_x + glyph.offset[0]).round(); let glyph_y = (baseline_y - glyph.offset[1] - glyph.size[1]).round(); - + let left = Self::pixel_to_ndc_x(glyph_x, width); let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width); let top = Self::pixel_to_ndc_y(glyph_y, height); let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], height); - + let base_idx = self.glyph_vertices.len() as u32; self.glyph_vertices.push(GlyphVertex { position: [left, top], @@ -3606,11 +4201,11 @@ impl Renderer { ]); } } - + tab_x += tab_width + tab_padding; } } - + // ═══════════════════════════════════════════════════════════════════ // RENDER PANE BORDERS (only between adjacent panes) // ═══════════════════════════════════════════════════════════════════ @@ -3636,37 +4231,37 @@ impl Renderer { 1.0, ] }; - + // Only draw borders if there's more than one pane // The layout leaves a gap between panes, so we look for gaps and draw borders there if panes.len() > 1 { // Maximum gap size to consider as "adjacent" (layout uses border_width gap) let max_gap = 20.0; - + // Check each pair of panes to find adjacent ones with gaps for i in 0..panes.len() { for j in (i + 1)..panes.len() { let (_, info_a, _) = &panes[i]; let (_, info_b, _) = &panes[j]; - + // Use active border color if either pane is active let border_color = if info_a.is_active || info_b.is_active { active_border_color } else { inactive_border_color }; - + // Calculate absolute positions (with terminal_y_offset) let a_x = info_a.x; let a_y = terminal_y_offset + info_a.y; let a_right = a_x + info_a.width; let a_bottom = a_y + info_a.height; - + let b_x = info_b.x; let b_y = terminal_y_offset + info_b.y; let b_right = b_x + info_b.width; let b_bottom = b_y + info_b.height; - + // Check for vertical adjacency (horizontal gap between panes) // Pane A is to the left of pane B let h_gap_ab = b_x - a_right; @@ -3690,7 +4285,7 @@ impl Renderer { self.render_rect(border_x, top, border_thickness, bottom - top, border_color); } } - + // Check for horizontal adjacency (vertical gap between panes) // Pane A is above pane B let v_gap_ab = b_y - a_bottom; @@ -3717,7 +4312,7 @@ impl Renderer { } } } - + // ═══════════════════════════════════════════════════════════════════ // RENDER EACH PANE'S CONTENT // ═══════════════════════════════════════════════════════════════════ @@ -3727,7 +4322,7 @@ impl Renderer { let pane_y = terminal_y_offset + info.y; let pane_width = info.width; let pane_height = info.height; - + self.render_pane_content( terminal, pane_x, @@ -3738,7 +4333,7 @@ impl Renderer { *selection, info.dim_factor, ); - + // Draw dimming overlay for inactive panes // dim_factor of 1.0 = no dimming, dim_factor of 0.6 = 40% dark overlay if info.dim_factor < 1.0 { @@ -3747,16 +4342,39 @@ impl Renderer { self.render_overlay_rect(pane_x, pane_y, pane_width, pane_height, overlay_color); } } - + // ═══════════════════════════════════════════════════════════════════ - // PREPARE EDGE GLOW UNIFORMS (if navigation failed) + // PREPARE IMAGE RENDERS (Kitty Graphics Protocol) // ═══════════════════════════════════════════════════════════════════ - let edge_glow_uniforms = if let Some(glow) = edge_glow { - Some(self.prepare_edge_glow_uniforms(glow, terminal_y_offset)) + let mut image_renders: Vec<(u32, ImageUniforms)> = Vec::new(); + for (terminal, info, _) in panes { + let pane_x = info.x; + let pane_y = terminal_y_offset + info.y; + + let renders = self.prepare_image_renders( + terminal.image_storage.placements(), + pane_x, + pane_y, + self.cell_width, + self.cell_height, + width, + height, + terminal.scrollback.len(), + terminal.scroll_offset, + info.rows, + ); + image_renders.extend(renders); + } + + // ═══════════════════════════════════════════════════════════════════ + // PREPARE EDGE GLOW UNIFORMS (combined for all active glows) + // ═══════════════════════════════════════════════════════════════════ + let edge_glow_uniforms = if !edge_glows.is_empty() && edge_glow_intensity > 0.0 { + Some(self.prepare_edge_glow_uniforms(edge_glows, terminal_y_offset, edge_glow_intensity)) } else { None }; - + // ═══════════════════════════════════════════════════════════════════ // SUBMIT TO GPU // ═══════════════════════════════════════════════════════════════════ @@ -3765,7 +4383,7 @@ impl Renderer { let overlay_vertex_count = self.overlay_vertices.len(); let total_vertex_count = bg_vertex_count + glyph_vertex_count + overlay_vertex_count; let total_index_count = self.bg_indices.len() + self.glyph_indices.len() + self.overlay_indices.len(); - + // Resize buffers if needed if total_vertex_count > self.vertex_capacity { self.vertex_capacity = total_vertex_count * 2; @@ -3776,7 +4394,7 @@ impl Renderer { mapped_at_creation: false, }); } - + if total_index_count > self.index_capacity { self.index_capacity = total_index_count * 2; self.index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { @@ -3856,12 +4474,12 @@ impl Renderer { ); self.atlas_dirty = false; } - + // Create command encoder and render let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("Render Encoder"), }); - + { let [bg_r, bg_g, bg_b] = self.palette.default_bg; let bg_r_linear = Self::srgb_to_linear(bg_r as f32 / 255.0) as f64; @@ -3887,26 +4505,63 @@ impl Renderer { occlusion_query_set: None, timestamp_writes: None, }); - + render_pass.set_pipeline(&self.glyph_pipeline); render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32); render_pass.draw_indexed(0..total_index_count as u32, 0, 0..1); } - + + // ═══════════════════════════════════════════════════════════════════ + // IMAGE PASS (Kitty Graphics Protocol images, after glyph rendering) + // Each image is rendered with its own draw call using separate bind groups + // ═══════════════════════════════════════════════════════════════════ + for (image_id, uniforms) in &image_renders { + // Check if we have the GPU texture for this image + if let Some(gpu_image) = self.image_textures.get(image_id) { + // Upload uniforms to this image's dedicated uniform buffer + self.queue.write_buffer( + &gpu_image.uniform_buffer, + 0, + bytemuck::cast_slice(&[*uniforms]), + ); + + // Create a render pass for this image (load existing content) + let mut image_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Image Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, // Preserve existing content + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + }); + + image_pass.set_pipeline(&self.image_pipeline); + image_pass.set_bind_group(0, &gpu_image.bind_group, &[]); + image_pass.draw(0..4, 0..1); // Triangle strip quad + } + } + // ═══════════════════════════════════════════════════════════════════ // EDGE GLOW PASS (shader-based, after main rendering) + // All active glows are rendered in a single pass via uniform array // ═══════════════════════════════════════════════════════════════════ - if let Some(uniforms) = edge_glow_uniforms { + if let Some(uniforms) = &edge_glow_uniforms { // Upload uniforms self.queue.write_buffer( &self.edge_glow_uniform_buffer, 0, - bytemuck::cast_slice(&[uniforms]), + bytemuck::cast_slice(&[*uniforms]), ); - - // Second render pass for edge glow (load existing content) + + // Render pass for this edge glow (load existing content) let mut glow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Edge Glow Pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -3921,16 +4576,261 @@ impl Renderer { occlusion_query_set: None, timestamp_writes: None, }); - + glow_pass.set_pipeline(&self.edge_glow_pipeline); glow_pass.set_bind_group(0, &self.edge_glow_bind_group, &[]); glow_pass.draw(0..3, 0..1); // Fullscreen triangle } - + self.queue.submit(std::iter::once(encoder.finish())); output.present(); - + Ok(()) } + // ═══════════════════════════════════════════════════════════════════════════════ + // IMAGE RENDERING (Kitty Graphics Protocol) + // ═══════════════════════════════════════════════════════════════════════════════ + + /// Upload an image to the GPU, creating or updating its texture. + pub fn upload_image(&mut self, image: &ImageData) { + // Check if we already have this image + if let Some(existing) = self.image_textures.get(&image.id) { + if existing.width == image.width && existing.height == image.height { + // Same dimensions, just update the data + self.queue.write_texture( + wgpu::ImageCopyTexture { + texture: &existing.texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &image.data, + wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(image.width * 4), + rows_per_image: Some(image.height), + }, + wgpu::Extent3d { + width: image.width, + height: image.height, + depth_or_array_layers: 1, + }, + ); + return; + } + // Different dimensions, need to recreate + } + + // Create new texture + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some(&format!("Image {}", image.id)), + size: wgpu::Extent3d { + width: image.width, + height: image.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + // Upload the data + self.queue.write_texture( + wgpu::ImageCopyTexture { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &image.data, + wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(image.width * 4), + rows_per_image: Some(image.height), + }, + wgpu::Extent3d { + width: image.width, + height: image.height, + depth_or_array_layers: 1, + }, + ); + + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + // Create per-image uniform buffer + let uniform_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some(&format!("Image {} Uniform Buffer", image.id)), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + // Create bind group for this image with its own uniform buffer + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(&format!("Image {} Bind Group", image.id)), + layout: &self.image_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.image_sampler), + }, + ], + }); + + self.image_textures.insert(image.id, GpuImage { + texture, + view, + uniform_buffer, + bind_group, + width: image.width, + height: image.height, + }); + + log::debug!( + "Uploaded image {} ({}x{}) to GPU", + image.id, + image.width, + image.height + ); + } + + /// Remove an image from the GPU. + pub fn remove_image(&mut self, image_id: u32) { + if self.image_textures.remove(&image_id).is_some() { + log::debug!("Removed image {} from GPU", image_id); + } + } + + /// Sync images from terminal's image storage to GPU. + /// Uploads new/changed images and removes deleted ones. + /// Also updates animation frames. + pub fn sync_images(&mut self, storage: &mut ImageStorage) { + // Update animations and get list of changed image IDs + let changed_ids = storage.update_animations(); + + // Re-upload frames that changed due to animation + for id in &changed_ids { + if let Some(image) = storage.get_image(*id) { + self.upload_image(image); + } + } + + if !storage.dirty && changed_ids.is_empty() { + return; + } + + // Upload all images (upload_image handles deduplication) + for image in storage.images().values() { + self.upload_image(image); + } + + // Remove textures for deleted images + let current_ids: std::collections::HashSet = storage.images().keys().copied().collect(); + let gpu_ids: Vec = self.image_textures.keys().copied().collect(); + for id in gpu_ids { + if !current_ids.contains(&id) { + self.remove_image(id); + } + } + + storage.clear_dirty(); + } + + /// Render images for a pane. Called from render_pane_content. + /// Returns a Vec of (image_id, uniforms) for deferred rendering. + fn prepare_image_renders( + &self, + placements: &[ImagePlacement], + pane_x: f32, + pane_y: f32, + cell_width: f32, + cell_height: f32, + screen_width: f32, + screen_height: f32, + scrollback_len: usize, + scroll_offset: usize, + visible_rows: usize, + ) -> Vec<(u32, ImageUniforms)> { + let mut renders = Vec::new(); + + for placement in placements { + // Check if we have the GPU texture for this image + let gpu_image = match self.image_textures.get(&placement.image_id) { + Some(img) => img, + None => continue, // Skip if not uploaded yet + }; + + // Convert absolute row to visible screen row + // placement.row is absolute (scrollback_len_at_placement + cursor_row) + // visible_row = absolute_row - scrollback_len + scroll_offset + let absolute_row = placement.row as isize; + let visible_row = absolute_row - scrollback_len as isize + scroll_offset as isize; + + // Check if image is visible on screen + // Image spans from visible_row to visible_row + placement.rows + let image_bottom = visible_row + placement.rows as isize; + if image_bottom < 0 || visible_row >= visible_rows as isize { + continue; // Image is completely off-screen + } + + // Calculate display position in pixels + let pos_x = pane_x + (placement.col as f32 * cell_width) + placement.x_offset as f32; + let pos_y = pane_y + (visible_row as f32 * cell_height) + placement.y_offset as f32; + + log::debug!( + "Image render: pane_x={} col={} cell_width={} x_offset={} => pos_x={}", + pane_x, placement.col, cell_width, placement.x_offset, pos_x + ); + + // Calculate display size in pixels + let display_width = placement.cols as f32 * cell_width; + let display_height = placement.rows as f32 * cell_height; + + // Calculate source rectangle in normalized coordinates + let src_x = placement.src_x as f32 / gpu_image.width as f32; + let src_y = placement.src_y as f32 / gpu_image.height as f32; + let src_width = if placement.src_width == 0 { + 1.0 - src_x + } else { + placement.src_width as f32 / gpu_image.width as f32 + }; + let src_height = if placement.src_height == 0 { + 1.0 - src_y + } else { + placement.src_height as f32 / gpu_image.height as f32 + }; + + let uniforms = ImageUniforms { + screen_width, + screen_height, + pos_x, + pos_y, + display_width, + display_height, + src_x, + src_y, + src_width, + src_height, + _padding1: 0.0, + _padding2: 0.0, + }; + + renders.push((placement.image_id, uniforms)); + } + + renders + } + } diff --git a/src/shader.wgsl b/src/shader.wgsl index 991850c..121ba10 100644 --- a/src/shader.wgsl +++ b/src/shader.wgsl @@ -1,28 +1,49 @@ // Edge Glow Shader -// Renders a soft glow effect at terminal edges for failed pane navigation feedback. -// The glow appears as a light node at center that splits into two and travels to corners. +// Renders natural-looking light effects at terminal edges for failed pane navigation feedback. +// Supports multiple simultaneous lights that blend together. +// Features: bright hot center, colored mid-range, soft outer halo with bloom. -// Uniform buffer with glow parameters +// Maximum number of simultaneous glows +const MAX_GLOWS: u32 = 16u; + +// Per-glow parameters (48 bytes each, aligned to 16 bytes) +struct GlowInstance { + // Direction: 0=Up, 1=Down, 2=Left, 3=Right + direction: u32, + // Animation progress (0.0 to 1.0) + progress: f32, + // Glow color (linear RGB) + color_r: f32, + color_g: f32, + color_b: f32, + // Pane bounds in pixels + pane_x: f32, + pane_y: f32, + pane_width: f32, + pane_height: f32, + // Padding to align to 16 bytes + _padding1: f32, + _padding2: f32, + _padding3: f32, +} + +// Global parameters + array of glow instances struct EdgeGlowParams { // Screen dimensions in pixels screen_width: f32, screen_height: f32, // Terminal area offset (for tab bar) terminal_y_offset: f32, - // Direction: 0=Up, 1=Down, 2=Left, 3=Right - direction: u32, - // Animation progress (0.0 to 1.0) - progress: f32, - // Glow color (linear RGB) - stored as separate floats to avoid vec3 alignment issues - color_r: f32, - color_g: f32, - color_b: f32, - // Whether glow is enabled (1 = yes, 0 = no) - enabled: u32, - // Padding to align to 16 bytes + // Glow intensity multiplier (0.0 = disabled, 1.0 = full) + glow_intensity: f32, + // Number of active glows + glow_count: u32, + // Padding to align to 16 bytes before array _padding1: u32, _padding2: u32, _padding3: u32, + // Array of glow instances + glows: array, } @group(0) @binding(0) @@ -34,16 +55,10 @@ struct VertexOutput { } // Fullscreen triangle vertex shader -// Uses vertex_index 0,1,2 to create a triangle that covers the screen @vertex fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { var out: VertexOutput; - // Generate fullscreen triangle vertices - // This creates a triangle that covers [-1,1] in clip space - let x = f32(i32(vertex_index) - 1); - let y = f32(i32(vertex_index & 1u) * 2 - 1); - // Positions for a fullscreen triangle var pos: vec2; switch vertex_index { @@ -54,7 +69,6 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { } out.clip_position = vec4(pos, 0.0, 1.0); - // Convert to 0-1 UV (flip Y since clip space Y is up, pixel Y is down) out.uv = vec2((pos.x + 1.0) * 0.5, (1.0 - pos.y) * 0.5); return out; @@ -63,19 +77,8 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { // Constants const PI: f32 = 3.14159265359; const PHASE1_END: f32 = 0.15; // Phase 1 ends at 15% progress -const GLOW_RADIUS: f32 = 90.0; // Base radius of glow -const GLOW_ASPECT: f32 = 2.0; // Stretch factor along edge (ellipse) - -// Smooth gaussian-like falloff -fn glow_falloff(dist: f32, radius: f32) -> f32 { - let normalized = dist / radius; - if normalized > 1.0 { - return 0.0; - } - // Smooth falloff: (1 - x^2)^3 gives nice soft edges - let t = 1.0 - normalized * normalized; - return t * t * t; -} +const GLOW_RADIUS: f32 = 80.0; // Core radius of the light +const GLOW_ASPECT: f32 = 2.5; // Stretch factor along edge (ellipse) // Ease-out cubic fn ease_out_cubic(t: f32) -> f32 { @@ -83,8 +86,9 @@ fn ease_out_cubic(t: f32) -> f32 { return 1.0 - t1 * t1 * t1; } -// Calculate distance from point to glow center, accounting for ellipse shape -fn ellipse_distance(point: vec2, center: vec2, radius_along: f32, radius_perp: f32, is_horizontal: bool) -> f32 { +// Calculate normalized distance from point to glow center (elliptical) +// Returns 0 at center, 1 at edge of core, >1 outside +fn ellipse_dist_normalized(point: vec2, center: vec2, radius_along: f32, radius_perp: f32, is_horizontal: bool) -> f32 { let delta = point - center; var normalized: vec2; if is_horizontal { @@ -92,45 +96,76 @@ fn ellipse_distance(point: vec2, center: vec2, radius_along: f32, radi } else { normalized = vec2(delta.x / radius_perp, delta.y / radius_along); } - return length(normalized) * min(radius_along, radius_perp); + return length(normalized); } -@fragment -fn fs_main(in: VertexOutput) -> @location(0) vec4 { - // Early out if not enabled - if params.enabled == 0u { - return vec4(0.0, 0.0, 0.0, 0.0); +// Natural light intensity falloff +// Creates a bright core with soft extended halo +fn light_intensity(dist: f32) -> f32 { + // Multi-layer falloff for natural light appearance: + // 1. Bright core (inverse square-ish, clamped) + // 2. Soft halo that extends further + + if dist < 0.001 { + return 1.0; } - let progress = params.progress; + // Core intensity - bright center that falls off quickly + // Using smoothed inverse for the hot center + let core = 1.0 / (1.0 + dist * dist * 4.0); - // Convert UV to pixel coordinates - let pixel = vec2( - in.uv.x * params.screen_width, - in.uv.y * params.screen_height - ); + // Soft halo - gaussian-like falloff that extends further + let halo = exp(-dist * dist * 1.5); - let terminal_height = params.screen_height - params.terminal_y_offset; - let is_horizontal = params.direction == 0u || params.direction == 1u; + // Combine: core dominates near center, halo extends the glow + return core * 0.7 + halo * 0.5; +} + +// Calculate the "hotness" - how white/bright the center should be +// Returns 0-1 where 1 = pure white (hottest), 0 = base color +fn light_hotness(dist: f32) -> f32 { + // Very bright white core that quickly transitions to color + let hot = 1.0 / (1.0 + dist * dist * 12.0); + return hot * hot; // Square it for sharper transition +} + +// Calculate contribution from a single glow at the given pixel +// Returns (intensity, hotness, 1.0) packed in vec3 +fn calculate_glow(pixel: vec2, glow: GlowInstance) -> vec3 { + // Get pane bounds from the glow instance + let pane_x = glow.pane_x; + let pane_y = glow.pane_y; + let pane_width = glow.pane_width; + let pane_height = glow.pane_height; + + // Mask: if pixel is outside pane bounds, return zero contribution + if pixel.x < pane_x || pixel.x > pane_x + pane_width || + pixel.y < pane_y || pixel.y > pane_y + pane_height { + return vec3(0.0, 0.0, 0.0); + } + + let progress = glow.progress; + let is_horizontal = glow.direction == 0u || glow.direction == 1u; // Calculate glow parameters based on animation phase - var alpha: f32; + var intensity_mult: f32; var size_factor: f32; var split: f32; if progress < PHASE1_END { - // Phase 1: Fade in, grow + // Phase 1: Appear and grow let t = progress / PHASE1_END; let ease = ease_out_cubic(t); - alpha = ease * 0.8; - size_factor = 0.3 + 0.7 * ease; + intensity_mult = ease; + size_factor = 0.4 + 0.6 * ease; split = 0.0; } else { // Phase 2: Split and fade out let t = (progress - PHASE1_END) / (1.0 - PHASE1_END); let fade = 1.0 - t; - alpha = fade * fade * 0.8; - size_factor = 1.0 - 0.3 * t; + // Slower fade for more visible effect + intensity_mult = fade * fade * fade; + size_factor = 1.0 - 0.2 * t; split = ease_out_cubic(t); } @@ -139,29 +174,30 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { let radius_perp = base_radius; // Calculate edge center and travel distance based on direction + // Now using pane bounds instead of screen bounds var edge_center: vec2; var travel: vec2; - switch params.direction { - // Up - top edge + switch glow.direction { + // Up - top edge of pane case 0u: { - edge_center = vec2(params.screen_width / 2.0, params.terminal_y_offset); - travel = vec2(params.screen_width / 2.0, 0.0); + edge_center = vec2(pane_x + pane_width / 2.0, pane_y); + travel = vec2(pane_width / 2.0, 0.0); } - // Down - bottom edge + // Down - bottom edge of pane case 1u: { - edge_center = vec2(params.screen_width / 2.0, params.screen_height); - travel = vec2(params.screen_width / 2.0, 0.0); + edge_center = vec2(pane_x + pane_width / 2.0, pane_y + pane_height); + travel = vec2(pane_width / 2.0, 0.0); } - // Left - left edge + // Left - left edge of pane case 2u: { - edge_center = vec2(0.0, params.terminal_y_offset + terminal_height / 2.0); - travel = vec2(0.0, terminal_height / 2.0); + edge_center = vec2(pane_x, pane_y + pane_height / 2.0); + travel = vec2(0.0, pane_height / 2.0); } - // Right - right edge + // Right - right edge of pane case 3u: { - edge_center = vec2(params.screen_width, params.terminal_y_offset + terminal_height / 2.0); - travel = vec2(0.0, terminal_height / 2.0); + edge_center = vec2(pane_x + pane_width, pane_y + pane_height / 2.0); + travel = vec2(0.0, pane_height / 2.0); } default: { edge_center = vec2(0.0, 0.0); @@ -169,34 +205,97 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { } } - var glow_intensity: f32 = 0.0; + // Accumulate light from one or two sources + var total_intensity: f32 = 0.0; + var total_hotness: f32 = 0.0; if split < 0.01 { - // Single glow at center - let dist = ellipse_distance(pixel, edge_center, radius_along, radius_perp, is_horizontal); - glow_intensity = glow_falloff(dist, base_radius); + // Single light at center + let dist = ellipse_dist_normalized(pixel, edge_center, radius_along, radius_perp, is_horizontal); + total_intensity = light_intensity(dist); + total_hotness = light_hotness(dist); } else { - // Two glows splitting apart - let split_radius = base_radius * (1.0 - 0.2 * split); - let split_radius_along = radius_along * (1.0 - 0.2 * split); - let split_radius_perp = radius_perp * (1.0 - 0.2 * split); + // Two lights splitting apart + let split_factor = 1.0 - 0.15 * split; + let r_along = radius_along * split_factor; + let r_perp = radius_perp * split_factor; let center1 = edge_center - travel * split; let center2 = edge_center + travel * split; - let dist1 = ellipse_distance(pixel, center1, split_radius_along, split_radius_perp, is_horizontal); - let dist2 = ellipse_distance(pixel, center2, split_radius_along, split_radius_perp, is_horizontal); + let dist1 = ellipse_dist_normalized(pixel, center1, r_along, r_perp, is_horizontal); + let dist2 = ellipse_dist_normalized(pixel, center2, r_along, r_perp, is_horizontal); - // Combine both glows (additive but capped) - let glow1 = glow_falloff(dist1, split_radius); - let glow2 = glow_falloff(dist2, split_radius); - glow_intensity = min(glow1 + glow2, 1.0); + let intensity1 = light_intensity(dist1); + let intensity2 = light_intensity(dist2); + let hotness1 = light_hotness(dist1); + let hotness2 = light_hotness(dist2); + + // Additive blending for overlapping lights (capped) + total_intensity = min(intensity1 + intensity2, 1.5); + total_hotness = max(hotness1, hotness2); } - // Apply alpha - let final_alpha = glow_intensity * alpha; + // Apply animation intensity multiplier + total_intensity *= intensity_mult; + total_hotness *= intensity_mult; + + // Return intensity, hotness, and a flag that this glow contributed + return vec3(total_intensity, total_hotness, 1.0); +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + // Early out if no glows + if params.glow_count == 0u { + return vec4(0.0, 0.0, 0.0, 0.0); + } + + // Convert UV to pixel coordinates + let pixel = vec2( + in.uv.x * params.screen_width, + in.uv.y * params.screen_height + ); + + // Accumulate contributions from all active glows + var total_intensity: f32 = 0.0; + var total_hotness: f32 = 0.0; + var accum_color = vec3(0.0, 0.0, 0.0); + var color_weight: f32 = 0.0; + + for (var i: u32 = 0u; i < params.glow_count && i < MAX_GLOWS; i++) { + let glow = params.glows[i]; + let result = calculate_glow(pixel, glow); + let intensity = result.x; + let hotness = result.y; + + // Accumulate intensity and hotness additively + total_intensity += intensity; + total_hotness = max(total_hotness, hotness); + + // Weight color contribution by intensity + let base_color = vec3(glow.color_r, glow.color_g, glow.color_b); + accum_color += base_color * intensity; + color_weight += intensity; + } + + // Cap intensity for overlapping glows + total_intensity = min(total_intensity, 1.5); + + // Calculate final base color (weighted average) + var base_color = vec3(0.0, 0.0, 0.0); + if color_weight > 0.001 { + base_color = accum_color / color_weight; + } + + // Mix between base color and white based on hotness + // Hot center = white, outer regions = base color + let white = vec3(1.0, 1.0, 1.0); + let final_color = mix(base_color, white, total_hotness * 0.8); + + // Final alpha based on intensity, scaled by global glow_intensity setting + let final_alpha = clamp(total_intensity * 0.9 * params.glow_intensity, 0.0, 1.0); // Output with premultiplied alpha for proper blending - let color = vec3(params.color_r, params.color_g, params.color_b); - return vec4(color * final_alpha, final_alpha); + return vec4(final_color * final_alpha, final_alpha); } diff --git a/src/terminal.rs b/src/terminal.rs index 5181328..c86d8bf 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -1,5 +1,6 @@ //! Terminal state management and escape sequence handling. +use crate::graphics::{GraphicsCommand, ImageStorage}; use crate::keyboard::{query_response, KeyboardState}; use crate::vt_parser::{CsiParams, Handler, Parser}; @@ -320,15 +321,12 @@ pub struct ScrollbackBuffer { } impl ScrollbackBuffer { - /// Creates a new scrollback buffer with the given capacity and column width. - /// All lines are pre-allocated to avoid any allocation during scrolling. - pub fn new(capacity: usize, cols: usize) -> Self { - // Pre-allocate all lines upfront - let lines = if capacity > 0 { - (0..capacity).map(|_| vec![Cell::default(); cols]).collect() - } else { - Vec::new() - }; + /// Creates a new scrollback buffer with the given capacity. + /// Lines are allocated lazily as needed to avoid slow startup. + pub fn new(capacity: usize) -> Self { + // Don't pre-allocate lines - allocate them lazily as content is added + // This avoids allocating and zeroing potentially 20MB+ of memory at startup + let lines = Vec::with_capacity(capacity.min(1024)); // Start with reasonable capacity Self { lines, @@ -361,9 +359,9 @@ impl ScrollbackBuffer { /// If the buffer is full, the oldest line is overwritten and its slot is returned /// for reuse (the caller can swap content into it). /// - /// This is the key operation - it's O(1) with just modulo arithmetic, no allocation. + /// Lines are allocated lazily on first use to avoid slow startup. #[inline] - pub fn push(&mut self) -> &mut Vec { + pub fn push(&mut self, cols: usize) -> &mut Vec { if self.capacity == 0 { // Shouldn't happen in normal use, but handle gracefully panic!("Cannot push to zero-capacity scrollback buffer"); @@ -379,7 +377,11 @@ impl ScrollbackBuffer { self.start = (self.start + 1) % self.capacity; // count stays the same } else { - // Buffer not full yet - just increment count + // Buffer not full yet - allocate new line if needed + if idx >= self.lines.len() { + // Grow the lines vector and allocate the new line + self.lines.push(vec![Cell::default(); cols]); + } self.count += 1; } @@ -488,6 +490,12 @@ pub struct Terminal { /// Command queue for terminal-to-application communication. /// Commands are added by OSC handlers and consumed by the application. command_queue: Vec, + /// Image storage for Kitty graphics protocol. + pub image_storage: ImageStorage, + /// Cell width in pixels (for image sizing). + pub cell_width: f32, + /// Cell height in pixels (for image sizing). + pub cell_height: f32, } impl Terminal { @@ -529,7 +537,7 @@ impl Terminal { keyboard: KeyboardState::new(), response_queue: Vec::new(), palette: ColorPalette::default(), - scrollback: ScrollbackBuffer::new(scrollback_limit, cols), + scrollback: ScrollbackBuffer::new(scrollback_limit), scroll_offset: 0, mouse_tracking: MouseTrackingMode::default(), mouse_encoding: MouseEncoding::default(), @@ -545,6 +553,9 @@ impl Terminal { parser: Some(Parser::new()), stats: ProcessingStats::default(), command_queue: Vec::new(), + image_storage: ImageStorage::new(), + cell_width: 10.0, // Default, will be set by renderer + cell_height: 20.0, // Default, will be set by renderer } } @@ -808,7 +819,8 @@ impl Terminal { if self.scroll_top == 0 && !self.using_alternate_screen && self.scrollback.capacity > 0 { // 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) - let dest = self.scrollback.push(); + let cols = self.cols; + let dest = self.scrollback.push(cols); // Swap grid row content into scrollback slot // The scrollback slot's old content (if any) moves to the grid row std::mem::swap(&mut self.grid[recycled_grid_row], dest); @@ -1184,7 +1196,8 @@ 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 - let dest = self.scrollback.push(); + let cols = self.cols; + let dest = self.scrollback.push(cols); std::mem::swap(&mut self.grid[grid_row], dest); } } @@ -1403,6 +1416,12 @@ impl Handler for Terminal { } } + /// Handle an APC (Application Program Command) sequence. + /// Used for Kitty graphics protocol. + fn apc(&mut self, data: &[u8]) { + self.handle_apc(data); + } + /// Handle a complete CSI sequence. fn csi(&mut self, params: &CsiParams) { let action = params.final_char as char; @@ -1619,10 +1638,32 @@ impl Handler for Terminal { self.cursor_row = 0; self.cursor_col = 0; } - // Window manipulation (CSI Ps t) + // Window manipulation (CSI Ps t) - XTWINOPS 't' => { let ps = params.get(0, 0); match ps { + 14 => { + // Report text area size in pixels: CSI 4 ; height ; width t + let pixel_height = (self.rows as f32 * self.cell_height) as u32; + let pixel_width = (self.cols as f32 * self.cell_width) as u32; + let response = format!("\x1b[4;{};{}t", pixel_height, pixel_width); + self.response_queue.extend_from_slice(response.as_bytes()); + log::debug!("XTWINOPS 14: Reported text area size {}x{} pixels", pixel_width, pixel_height); + } + 16 => { + // Report cell size in pixels: CSI 6 ; height ; width t + let cell_h = self.cell_height as u32; + let cell_w = self.cell_width as u32; + let response = format!("\x1b[6;{};{}t", cell_h, cell_w); + self.response_queue.extend_from_slice(response.as_bytes()); + log::debug!("XTWINOPS 16: Reported cell size {}x{} pixels", cell_w, cell_h); + } + 18 => { + // Report text area size in characters: CSI 8 ; rows ; cols t + let response = format!("\x1b[8;{};{}t", self.rows, self.cols); + self.response_queue.extend_from_slice(response.as_bytes()); + log::debug!("XTWINOPS 18: Reported text area size {}x{} chars", self.cols, self.rows); + } 22 | 23 => { // Save/restore window title - ignore } @@ -2079,4 +2120,73 @@ impl Terminal { } } } + + /// Set cell dimensions (called by renderer after font metrics are calculated). + pub fn set_cell_size(&mut self, width: f32, height: f32) { + self.cell_width = width; + self.cell_height = height; + } + + /// Handle an APC (Application Program Command) sequence. + /// This is used for the Kitty graphics protocol. + fn handle_apc(&mut self, data: &[u8]) { + // Kitty graphics protocol: APC starts with 'G' + if let Some(cmd) = GraphicsCommand::parse(data) { + log::debug!( + "Graphics command: action={:?} format={:?} id={:?} size={}x{:?} C={} U={}", + cmd.action, + cmd.format, + cmd.image_id, + cmd.width.unwrap_or(0), + cmd.height, + cmd.cursor_movement, + cmd.unicode_placeholder + ); + + // Convert cursor_row to absolute row (accounting for scrollback) + // This allows images to scroll with terminal content + let absolute_row = self.scrollback.len() + self.cursor_row; + + // Process the command + let (response, placement_result) = self.image_storage.process_command( + cmd, + self.cursor_col, + absolute_row, + self.cell_width, + self.cell_height, + ); + + // Queue the response to send back to the application + if let Some(resp) = response { + 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 { + if !placement.suppress_cursor_move && !placement.virtual_placement { + // Move cursor down by (rows - 1) since we're already on the first row + // Then set cursor to the column after the image + let new_row = self.cursor_row + placement.rows.saturating_sub(1); + 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; + } + // Move cursor to after the image (or stay at column 0 of next line) + // Per protocol, cursor ends at the last row of the image + log::debug!( + "Cursor moved after image placement: row={} (moved {} rows)", + self.cursor_row, placement.rows.saturating_sub(1) + ); + } + } + } + } } diff --git a/src/vt_parser.rs b/src/vt_parser.rs index 63e5077..c2a84ad 100644 --- a/src/vt_parser.rs +++ b/src/vt_parser.rs @@ -558,7 +558,7 @@ impl Parser { self.csi.primary = ch; self.csi.state = CsiState::Body; } - b' ' | b'\'' | b'"' | b'!' | b'$' => { + b' ' | b'\'' | b'"' | b'!' | b'$' | b'#' | b'*' => { self.csi.secondary = ch; self.csi.state = CsiState::PostSecondary; } diff --git a/zterm.terminfo b/zterm.terminfo index b35c562..a228237 100644 --- a/zterm.terminfo +++ b/zterm.terminfo @@ -68,3 +68,7 @@ zterm|ZTerm - GPU-accelerated terminal emulator, XM=\E[?1006;1000%?%p1%{1}%=%th%el%;, Ss=\E[%p1%d q, Se=\E[2 q, + Sync=\E[?2026%?%p1%{1}%=%th%el%;, + Smulx=\E[4:%p1%dm, + setrgbf=\E[38:2:%p1%d:%p2%d:%p3%dm, + setrgbb=\E[48:2:%p1%d:%p2%d:%p3%dm,