Files
zterm/src/renderer.rs
T
Zacharias-Brohn d47ee0d1ef experimental
2025-12-18 18:25:28 +01:00

7853 lines
349 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! GPU-accelerated terminal rendering using wgpu with a glyph atlas.
//! 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 ab_glyph::{Font, FontRef, GlyphId, ScaleFont};
use rustybuzz::UnicodeBuffer;
use ttf_parser::Tag;
use std::cell::{OnceCell, RefCell};
use std::collections::{HashMap, HashSet};
use std::ffi::CStr;
use std::path::PathBuf;
use std::sync::Arc;
// Fontconfig for dynamic font fallback
use fontconfig::Fontconfig;
// FreeType + Cairo for color emoji rendering
use freetype::Library as FtLibrary;
use cairo::{Format, ImageSurface};
/// Pane geometry for multi-pane rendering.
/// Describes where to render a pane within the window.
#[derive(Debug, Clone, Copy)]
pub struct PaneRenderInfo {
/// Unique identifier for this pane (used to track GPU resources).
/// Like Kitty's vao_idx, this maps to per-pane GPU buffers and bind groups.
pub pane_id: u64,
/// Left edge in pixels.
pub x: f32,
/// Top edge in pixels.
pub y: f32,
/// Width in pixels.
pub width: f32,
/// Height in pixels.
pub height: f32,
/// Number of columns.
pub cols: usize,
/// Number of rows.
pub rows: usize,
/// Whether this is the active pane.
pub is_active: bool,
/// Dim factor for this pane (0.0 = fully dimmed, 1.0 = fully bright).
/// Used for smooth fade animations when switching pane focus.
pub dim_factor: f32,
}
// ═══════════════════════════════════════════════════════════════════════════════
// PER-PANE GPU RESOURCES (Like Kitty's VAO per window)
// ═══════════════════════════════════════════════════════════════════════════════
/// GPU resources for a single pane.
/// Like Kitty's VAO, each pane gets its own buffers and bind group.
/// This allows uploading each pane's cell data independently before rendering.
pub struct PaneGpuResources {
/// Cell storage buffer - contains GPUCell array for this pane's visible cells.
pub cell_buffer: wgpu::Buffer,
/// Grid parameters uniform buffer for this pane.
pub grid_params_buffer: wgpu::Buffer,
/// Bind group for instanced rendering (@group(1)) - references this pane's buffers.
pub bind_group: wgpu::BindGroup,
/// Buffer capacity (max cells) - used to detect when buffer needs resizing.
pub capacity: usize,
}
// ═══════════════════════════════════════════════════════════════════════════════
// STATUSLINE COMPONENTS
// ═══════════════════════════════════════════════════════════════════════════════
/// Color specification for statusline components.
/// Uses the terminal's indexed color palette (0-255), RGB, or default fg.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatuslineColor {
/// Use the default foreground color.
Default,
/// Use an indexed color from the 256-color palette (0-15 for ANSI colors).
Indexed(u8),
/// Use an RGB color.
Rgb(u8, u8, u8),
}
impl Default for StatuslineColor {
fn default() -> Self {
StatuslineColor::Default
}
}
/// A single component/segment of the statusline.
/// Components are rendered left-to-right with optional separators.
#[derive(Debug, Clone)]
pub struct StatuslineComponent {
/// The text content of this component.
pub text: String,
/// Foreground color for this component.
pub fg: StatuslineColor,
/// Whether this text should be bold.
pub bold: bool,
}
impl StatuslineComponent {
/// Create a new statusline component with default styling.
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
fg: StatuslineColor::Default,
bold: false,
}
}
/// Set the foreground color using an indexed palette color.
pub fn fg(mut self, color_index: u8) -> Self {
self.fg = StatuslineColor::Indexed(color_index);
self
}
/// Set the foreground color using RGB values.
pub fn rgb_fg(mut self, r: u8, g: u8, b: u8) -> Self {
self.fg = StatuslineColor::Rgb(r, g, b);
self
}
/// Set bold styling.
pub fn bold(mut self) -> Self {
self.bold = true;
self
}
/// Create a separator component (e.g., "/", " > ", etc.).
pub fn separator(text: impl Into<String>) -> Self {
Self {
text: text.into(),
fg: StatuslineColor::Indexed(8), // Dim gray by default
bold: false,
}
}
}
/// A section of the statusline with its own background color.
/// Sections are rendered left-to-right and end with a powerline transition arrow.
#[derive(Debug, Clone)]
pub struct StatuslineSection {
/// The components within this section.
pub components: Vec<StatuslineComponent>,
/// Background color for this section.
pub bg: StatuslineColor,
}
impl StatuslineSection {
/// Create a new section with the given indexed background color.
pub fn new(bg_color: u8) -> Self {
Self {
components: Vec::new(),
bg: StatuslineColor::Indexed(bg_color),
}
}
/// Create a new section with an RGB background color.
pub fn with_rgb_bg(r: u8, g: u8, b: u8) -> Self {
Self {
components: Vec::new(),
bg: StatuslineColor::Rgb(r, g, b),
}
}
/// Create a new section with the default (transparent) background.
pub fn transparent() -> Self {
Self {
components: Vec::new(),
bg: StatuslineColor::Default,
}
}
/// Add a component to this section.
pub fn push(mut self, component: StatuslineComponent) -> Self {
self.components.push(component);
self
}
/// Add multiple components to this section.
pub fn with_components(mut self, components: Vec<StatuslineComponent>) -> Self {
self.components = components;
self
}
}
/// Content to display in the statusline.
/// Either structured sections (for ZTerm's default CWD/git display) or raw ANSI
/// content (from neovim or other programs that provide their own statusline).
#[derive(Debug, Clone)]
pub enum StatuslineContent {
/// Structured sections with powerline-style transitions.
Sections(Vec<StatuslineSection>),
/// Raw ANSI-formatted string (rendered as-is without section styling).
Raw(String),
}
impl Default for StatuslineContent {
fn default() -> Self {
StatuslineContent::Sections(Vec::new())
}
}
/// Edge glow animation state for visual feedback when navigation fails.
/// Creates an organic glow effect: a single light node appears at center,
/// then splits into two that travel outward to the corners while fading.
/// Animation logic is handled in the shader (shader.wgsl).
#[derive(Debug, Clone, Copy)]
pub struct EdgeGlow {
/// Which edge to glow (based on the direction the user tried to navigate).
pub direction: Direction,
/// When the animation started.
pub start_time: std::time::Instant,
/// Pane bounds - left edge in pixels.
pub pane_x: f32,
/// Pane bounds - top edge in pixels.
pub pane_y: f32,
/// Pane bounds - width in pixels.
pub pane_width: f32,
/// Pane bounds - height in pixels.
pub pane_height: f32,
}
impl EdgeGlow {
/// Duration of the glow animation in milliseconds.
pub const DURATION_MS: u64 = 500;
/// Create a new edge glow animation constrained to a pane's bounds.
pub fn new(direction: Direction, pane_x: f32, pane_y: f32, pane_width: f32, pane_height: f32) -> Self {
Self {
direction,
start_time: std::time::Instant::now(),
pane_x,
pane_y,
pane_width,
pane_height,
}
}
/// Get the current animation progress (0.0 to 1.0).
pub fn progress(&self) -> f32 {
let elapsed = self.start_time.elapsed().as_millis() as f32;
let duration = Self::DURATION_MS as f32;
(elapsed / duration).min(1.0)
}
/// Check if the animation has completed.
pub fn is_finished(&self) -> bool {
self.progress() >= 1.0
}
}
/// Size of the glyph atlas texture.
const ATLAS_SIZE: u32 = 1024;
/// Bytes per pixel in the RGBA atlas (4 for RGBA8).
const ATLAS_BPP: u32 = 4;
/// Cached glyph information.
/// In Kitty's model, all glyphs are stored as cell-sized sprites with the glyph
/// pre-positioned at the correct baseline within the sprite.
#[derive(Clone, Copy, Debug)]
struct GlyphInfo {
/// UV coordinates in the atlas (left, top, width, height) normalized 0-1.
uv: [f32; 4],
/// Size of the sprite in pixels (always cell_width x cell_height).
size: [f32; 2],
/// Whether this is a colored glyph (emoji).
is_colored: bool,
}
/// Wrapper to hold the rustybuzz Face with a 'static lifetime.
/// This is safe because we keep font_data alive for the lifetime of the Renderer.
struct ShapingContext {
face: rustybuzz::Face<'static>,
/// OpenType features to enable during shaping (liga, calt, etc.)
/// Note: This field is kept for potential future use when we need to modify
/// features per-context. Currently shaping_features on Renderer is used instead.
#[allow(dead_code)]
features: Vec<rustybuzz::Feature>,
}
/// Font style variant indices.
/// These map to the indices in font_variants array.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(usize)]
pub enum FontStyle {
Regular = 0,
Bold = 1,
Italic = 2,
BoldItalic = 3,
}
impl FontStyle {
/// Get the font style from bold and italic flags.
pub fn from_flags(bold: bool, italic: bool) -> Self {
match (bold, italic) {
(false, false) => FontStyle::Regular,
(true, false) => FontStyle::Bold,
(false, true) => FontStyle::Italic,
(true, true) => FontStyle::BoldItalic,
}
}
}
/// A font variant with its data and parsed references.
struct FontVariant {
/// Owned font data (kept alive for the lifetime of the font references).
#[allow(dead_code)]
data: Box<[u8]>,
/// ab_glyph font reference for rasterization.
font: FontRef<'static>,
/// rustybuzz face for text shaping.
face: rustybuzz::Face<'static>,
}
/// Result of shaping a text sequence.
#[derive(Clone, Debug)]
struct ShapedGlyphs {
/// 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)>,
}
/// Vertex for rendering textured quads.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct GlyphVertex {
position: [f32; 2],
uv: [f32; 2],
color: [f32; 4],
bg_color: [f32; 4],
}
impl GlyphVertex {
const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
0 => Float32x2, // position
1 => Float32x2, // uv
2 => Float32x4, // color (fg)
3 => Float32x4, // bg_color
];
fn desc() -> wgpu::VertexBufferLayout<'static> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<GlyphVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &Self::ATTRIBS,
}
}
}
/// 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)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct EdgeGlowUniforms {
screen_width: f32,
screen_height: f32,
terminal_y_offset: f32,
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,
}
// ═══════════════════════════════════════════════════════════════════════════════
// KITTY-STYLE INSTANCED CELL RENDERING STRUCTURES
// ═══════════════════════════════════════════════════════════════════════════════
/// GPU cell data for instanced rendering.
/// Matches GPUCell in glyph_shader.wgsl exactly.
///
/// Like Kitty, we store a sprite_idx that references pre-rendered glyphs in the atlas.
/// This allows us to update GPU buffers with a simple memcpy when content changes,
/// rather than rebuilding vertex buffers every frame.
#[repr(C)]
#[derive(Copy, Clone, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
pub struct GPUCell {
/// Foreground color (packed: type in low byte, then RGB or index)
pub fg: u32,
/// Background color (packed: type in low byte, then RGB or index)
pub bg: u32,
/// Decoration foreground color (for underlines, etc.)
pub decoration_fg: u32,
/// Sprite index in the sprite info array. High bit set = colored glyph.
/// 0 = no glyph (space or empty)
pub sprite_idx: u32,
/// Cell attributes (bold, italic, reverse, etc.)
pub attrs: u32,
}
/// Color type constants for packed color encoding.
pub const COLOR_TYPE_DEFAULT: u32 = 0;
pub const COLOR_TYPE_INDEXED: u32 = 1;
pub const COLOR_TYPE_RGB: u32 = 2;
/// Attribute bit flags.
pub const ATTR_BOLD: u32 = 0x8;
pub const ATTR_ITALIC: u32 = 0x10;
pub const ATTR_REVERSE: u32 = 0x20;
pub const ATTR_STRIKE: u32 = 0x40;
pub const ATTR_DIM: u32 = 0x80;
pub const ATTR_UNDERLINE: u32 = 0x1; // Part of decoration mask
pub const ATTR_SELECTED: u32 = 0x100; // Cell is selected (for selection highlighting)
/// Flag for colored glyphs (emoji).
pub const COLORED_GLYPH_FLAG: u32 = 0x80000000;
/// Sprite info for glyph positioning.
/// Matches SpriteInfo in glyph_shader.wgsl exactly.
///
/// In Kitty's model, sprites are always cell-sized and glyphs are pre-positioned
/// within the sprite at the correct baseline. The shader just maps the sprite
/// to the cell 1:1, with no offset math needed.
#[repr(C)]
#[derive(Copy, Clone, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SpriteInfo {
/// UV coordinates in atlas (x, y, width, height) - normalized 0-1
pub uv: [f32; 4],
/// Padding to maintain alignment (previously offset, now unused)
pub _padding: [f32; 2],
/// Size in pixels (width, height) - always matches cell dimensions
pub size: [f32; 2],
}
/// Grid parameters uniform for instanced rendering.
/// Matches GridParams in glyph_shader.wgsl exactly.
#[repr(C)]
#[derive(Copy, Clone, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
struct GridParams {
cols: u32,
rows: u32,
cell_width: f32,
cell_height: f32,
screen_width: f32,
screen_height: f32,
x_offset: f32,
y_offset: f32,
cursor_col: i32,
cursor_row: i32,
cursor_style: u32,
background_opacity: f32,
// Selection range (-1 values mean no selection)
selection_start_col: i32,
selection_start_row: i32,
selection_end_col: i32,
selection_end_row: i32,
}
/// GPU quad instance for instanced rectangle rendering.
/// Matches Quad in glyph_shader.wgsl exactly.
#[repr(C)]
#[derive(Copy, Clone, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Quad {
/// X position in pixels
pub x: f32,
/// Y position in pixels
pub y: f32,
/// Width in pixels
pub width: f32,
/// Height in pixels
pub height: f32,
/// Color (linear RGBA)
pub color: [f32; 4],
}
/// Parameters for quad rendering.
/// Matches QuadParams in glyph_shader.wgsl exactly.
#[repr(C)]
#[derive(Copy, Clone, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
struct QuadParams {
screen_width: f32,
screen_height: f32,
_padding: [f32; 2],
}
/// Parameters for statusline rendering.
/// Matches StatuslineParams in statusline_shader.wgsl exactly.
#[repr(C)]
#[derive(Copy, Clone, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
struct StatuslineParams {
/// Number of characters in statusline
char_count: u32,
/// Cell width in pixels
cell_width: f32,
/// Cell height in pixels
cell_height: f32,
/// Screen width in pixels
screen_width: f32,
/// Screen height in pixels
screen_height: f32,
/// Y offset from top of screen in pixels
y_offset: f32,
/// Padding for alignment (to match shader struct layout)
_padding: [f32; 2],
}
/// Color table uniform containing 256 indexed colors + default fg/bg.
/// Matches ColorTable in glyph_shader.wgsl.
/// Note: We don't use this directly - colors are resolved per-cell on CPU side.
/// This struct is kept for documentation/future use.
#[allow(dead_code)]
struct ColorTable {
/// 256 indexed colors + default_fg (256) + default_bg (257)
colors: [[f32; 4]; 258],
}
/// Key for looking up sprites in the sprite map.
/// A sprite is uniquely identified by the glyph content and style.
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
struct SpriteKey {
/// The character or ligature string
text: String,
/// Font style (regular, bold, italic, bold-italic)
style: FontStyle,
/// Whether this is a colored glyph (emoji)
colored: bool,
}
/// Target sprite buffer for glyph allocation.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SpriteTarget {
/// Terminal pane sprites (main sprite buffer)
Terminal,
/// Statusline sprites (separate buffer)
Statusline,
}
/// The terminal renderer.
pub struct Renderer {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
surface_config: wgpu::SurfaceConfiguration,
// 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<u32, GpuImage>,
// Atlas texture
atlas_texture: wgpu::Texture,
atlas_data: Vec<u8>,
atlas_dirty: bool,
// Font and shaping
#[allow(dead_code)] // Kept alive for rustybuzz::Face and FontRef which borrow it
font_data: Box<[u8]>,
/// Primary font for rasterization (borrows font_data)
primary_font: FontRef<'static>,
/// Font style variants: [Regular, Bold, Italic, BoldItalic]
/// Each entry is Option because some variants may not be available.
/// Index 0 (Regular) is always Some (same as primary_font's data).
font_variants: [Option<FontVariant>; 4],
/// Fallback fonts with their owned data
fallback_fonts: Vec<(Box<[u8]>, FontRef<'static>)>,
/// Fontconfig handle for dynamic font discovery (lazy initialized)
fontconfig: OnceCell<Option<Fontconfig>>,
/// Set of font paths we've already tried (to avoid reloading)
tried_font_paths: HashSet<PathBuf>,
/// Color font renderer (FreeType + Cairo) for emoji - lazy initialized
/// Using RefCell because ColorFontRenderer needs mutable access to cache font faces
color_font_renderer: RefCell<Option<ColorFontRenderer>>,
/// Cache mapping characters to their color font path (if any)
color_font_cache: HashMap<char, Option<PathBuf>>,
shaping_ctx: ShapingContext,
/// OpenType features for shaping (shared across all font variants)
shaping_features: Vec<rustybuzz::Feature>,
char_cache: HashMap<char, GlyphInfo>, // cache char -> rendered glyph
ligature_cache: HashMap<String, ShapedGlyphs>, // cache multi-char -> shaped glyphs
/// Glyph cache keyed by (font_style, font_index, glyph_id)
/// font_style is FontStyle as usize, font_index is 0 for primary, 1+ for fallbacks
glyph_cache: HashMap<(usize, usize, u16), GlyphInfo>,
atlas_cursor_x: u32,
atlas_cursor_y: u32,
atlas_row_height: u32,
// Dynamic vertex/index buffers
vertex_buffer: wgpu::Buffer,
index_buffer: wgpu::Buffer,
vertex_capacity: usize,
index_capacity: usize,
/// Base font size in points (from config).
base_font_size: f32,
/// Current scale factor.
pub scale_factor: f64,
/// Screen DPI (dots per inch), used for scaling box drawing characters.
/// Default is 96.0 if not available from the system.
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,
/// Baseline offset from top of cell in pixels.
/// Glyphs are positioned so their baseline sits at this Y position within the cell.
baseline: f32,
/// Window dimensions.
pub width: u32,
pub height: u32,
/// Color palette for rendering.
palette: ColorPalette,
/// Tab bar position.
tab_bar_position: TabBarPosition,
/// Background opacity (0.0 = transparent, 1.0 = opaque).
background_opacity: f32,
/// Actual used grid dimensions (set by pane layout, used for centering).
/// When there are splits, this is the total size of all panes + borders.
grid_used_width: f32,
grid_used_height: f32,
// Reusable vertex/index buffers to avoid per-frame allocations
bg_vertices: Vec<GlyphVertex>,
bg_indices: Vec<u32>,
glyph_vertices: Vec<GlyphVertex>,
glyph_indices: Vec<u32>,
// ═══════════════════════════════════════════════════════════════════════════════
// KITTY-STYLE INSTANCED RENDERING STATE
// ═══════════════════════════════════════════════════════════════════════════════
/// Sprite map: maps glyph content + style to sprite index.
/// The sprite index is used in GPUCell.sprite_idx to reference the glyph in the atlas.
sprite_map: HashMap<SpriteKey, u32>,
/// Sprite info array: UV coordinates and offsets for each sprite.
/// Index 0 is reserved for "no glyph" (space).
sprite_info: Vec<SpriteInfo>,
/// Next sprite index to allocate.
next_sprite_idx: u32,
/// GPU cell buffer for all visible cells (flattened row-major).
/// Updated only when terminal content changes.
gpu_cells: Vec<GPUCell>,
/// Whether the GPU cell buffer needs to be re-uploaded.
cells_dirty: bool,
/// Last rendered grid dimensions (cols, rows) to detect resizes.
last_grid_size: (usize, usize),
// GPU buffers for instanced rendering
/// Cell storage buffer - contains GPUCell array for all visible cells.
cell_buffer: wgpu::Buffer,
/// Sprite storage buffer - contains SpriteInfo array for all sprites.
sprite_buffer: wgpu::Buffer,
/// Current capacity of sprite buffer (number of sprites it can hold).
sprite_buffer_capacity: usize,
/// Grid parameters uniform buffer.
grid_params_buffer: wgpu::Buffer,
/// Color table uniform buffer (258 colors: 256 indexed + default fg/bg).
color_table_buffer: wgpu::Buffer,
/// Bind group for instanced rendering (@group(1)).
instanced_bind_group: wgpu::BindGroup,
/// Background pipeline for instanced cell rendering.
cell_bg_pipeline: wgpu::RenderPipeline,
/// Glyph pipeline for instanced cell rendering.
cell_glyph_pipeline: wgpu::RenderPipeline,
/// 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)>,
// ═══════════════════════════════════════════════════════════════════════════════
// PER-PANE GPU RESOURCES (Like Kitty's VAO per window)
// ═══════════════════════════════════════════════════════════════════════════════
/// Bind group layout for instanced rendering - needed to create per-pane bind groups.
instanced_bind_group_layout: wgpu::BindGroupLayout,
/// Per-pane GPU resources, keyed by pane_id.
/// Like Kitty's VAO array, each pane gets its own cell buffer, grid params buffer, and bind group.
pane_resources: HashMap<u64, PaneGpuResources>,
// ═══════════════════════════════════════════════════════════════════════════════
// STATUSLINE RENDERING (dedicated shader and pipeline)
// ═══════════════════════════════════════════════════════════════════════════════
/// GPU cells for the statusline (single row).
statusline_gpu_cells: Vec<GPUCell>,
/// GPU buffer for statusline cells.
statusline_cell_buffer: wgpu::Buffer,
/// Maximum columns for statusline (to size buffer appropriately).
statusline_max_cols: usize,
/// Statusline params uniform buffer.
statusline_params_buffer: wgpu::Buffer,
/// Bind group layout for statusline rendering.
statusline_bind_group_layout: wgpu::BindGroupLayout,
/// Bind group for statusline rendering.
statusline_bind_group: wgpu::BindGroup,
/// Pipeline for statusline background rendering.
statusline_bg_pipeline: wgpu::RenderPipeline,
/// Pipeline for statusline glyph rendering.
statusline_glyph_pipeline: wgpu::RenderPipeline,
/// Separate sprite map for statusline (isolated from terminal sprites).
statusline_sprite_map: HashMap<SpriteKey, u32>,
/// Sprite info array for statusline.
statusline_sprite_info: Vec<SpriteInfo>,
/// Next sprite index for statusline.
statusline_next_sprite_idx: u32,
/// GPU buffer for statusline sprites.
statusline_sprite_buffer: wgpu::Buffer,
/// Capacity of the statusline sprite buffer.
statusline_sprite_buffer_capacity: usize,
// ═══════════════════════════════════════════════════════════════════════════════
// INSTANCED QUAD RENDERING (for rectangles, borders, overlays, tab bar)
// ═══════════════════════════════════════════════════════════════════════════════
/// GPU quads for rectangle rendering.
quads: Vec<Quad>,
/// GPU buffer for quad instances.
quad_buffer: wgpu::Buffer,
/// Maximum number of quads (to size buffer appropriately).
max_quads: usize,
/// Quad params uniform buffer.
quad_params_buffer: wgpu::Buffer,
/// Pipeline for instanced quad rendering.
quad_pipeline: wgpu::RenderPipeline,
/// Bind group for quad rendering.
quad_bind_group: wgpu::BindGroup,
/// GPU quads for overlay rendering (rendered on top of everything).
overlay_quads: Vec<Quad>,
}
// ═══════════════════════════════════════════════════════════════════════════════
// FONTCONFIG HELPER FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════════
/// Find a font that contains the given character using fontconfig.
/// Returns the path to the font file and whether it's a color font.
///
/// For emoji characters (detected via the `emojis` crate), this function
/// explicitly requests a color font from fontconfig, similar to how Kitty
/// handles emoji presentation: FC_FAMILY = "emoji" and FC_COLOR = true.
fn find_font_for_char(_fc: &Fontconfig, c: char) -> Option<(PathBuf, bool)> {
use fontconfig_sys as fcsys;
use fcsys::*;
use fcsys::constants::FC_COLOR;
// Check if this character is an emoji using the emojis crate (O(1) lookup)
let char_str = c.to_string();
let is_emoji = emojis::get(&char_str).is_some();
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);
// For emoji characters, explicitly request a color font from the "emoji" family
// This matches Kitty's approach in fontconfig.c:create_fallback_face()
if is_emoji {
let fc_family_cstr = CStr::from_bytes_with_nul(b"family\0").unwrap();
let emoji_family = CStr::from_bytes_with_nul(b"emoji\0").unwrap();
FcPatternAddString(pat, fc_family_cstr.as_ptr(), emoji_family.as_ptr() as *const u8);
FcPatternAddBool(pat, FC_COLOR.as_ptr() as *const i8, 1); // Request color font
}
// Run substitutions
FcConfigSubstitute(std::ptr::null_mut(), pat, FcMatchPattern);
FcDefaultSubstitute(pat);
// Find matching font
let mut result = FcResultNoMatch;
let matched = FcFontMatch(std::ptr::null_mut(), pat, &mut result);
let font_result = if !matched.is_null() && result == FcResultMatch {
// Get the file path from the matched pattern
let mut file_ptr: *mut FcChar8 = std::ptr::null_mut();
let fc_file_cstr = CStr::from_bytes_with_nul(b"file\0").unwrap();
if FcPatternGetString(matched, fc_file_cstr.as_ptr(), 0, &mut file_ptr) == FcResultMatch
{
let path_cstr = CStr::from_ptr(file_ptr as *const i8);
let path = PathBuf::from(path_cstr.to_string_lossy().into_owned());
// Check if the font is a color font (FC_COLOR property)
let mut is_color: i32 = 0;
let has_color = FcPatternGetBool(matched, FC_COLOR.as_ptr() as *const i8, 0, &mut is_color) == FcResultMatch && is_color != 0;
log::debug!("find_font_for_char: found font for U+{:04X} '{}': {:?} (color={}, requested_emoji={})",
c as u32, c, path, has_color, is_emoji);
Some((path, has_color))
} else {
None
}
} else {
None
};
// Cleanup
if !matched.is_null() {
FcPatternDestroy(matched);
}
FcCharSetDestroy(charset);
FcPatternDestroy(pat);
font_result
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// COLOR EMOJI RENDERING (FreeType + Cairo)
// ═══════════════════════════════════════════════════════════════════════════════
/// Find a color font (emoji font) that contains the given character using fontconfig.
/// Returns the path to the font file if found.
fn find_color_font_for_char(c: char) -> Option<PathBuf> {
use fontconfig_sys as fcsys;
use fcsys::*;
use fcsys::constants::{FC_CHARSET, FC_COLOR, FC_FILE};
log::debug!("find_color_font_for_char: looking for color font for U+{:04X} '{}'", c as u32, c);
unsafe {
// Create a pattern
let pat = FcPatternCreate();
if pat.is_null() {
log::debug!("find_color_font_for_char: FcPatternCreate failed");
return None;
}
// Create a charset with the target character
let charset = FcCharSetCreate();
if charset.is_null() {
FcPatternDestroy(pat);
log::debug!("find_color_font_for_char: FcCharSetCreate failed");
return None;
}
// Add the character to the charset
FcCharSetAddChar(charset, c as u32);
// Add the charset to the pattern
FcPatternAddCharSet(pat, FC_CHARSET.as_ptr() as *const i8, charset);
// Request a color font
FcPatternAddBool(pat, FC_COLOR.as_ptr() as *const i8, 1); // FcTrue = 1
// Run substitutions
FcConfigSubstitute(std::ptr::null_mut(), pat, FcMatchPattern);
FcDefaultSubstitute(pat);
// Find matching font
let mut result = FcResultNoMatch;
let matched = FcFontMatch(std::ptr::null_mut(), pat, &mut result);
let font_path = if !matched.is_null() && result == FcResultMatch {
// Check if the matched font is actually a color font
let mut is_color: i32 = 0;
let has_color = FcPatternGetBool(matched, FC_COLOR.as_ptr() as *const i8, 0, &mut is_color) == FcResultMatch && is_color != 0;
log::debug!("find_color_font_for_char: matched font, is_color={}", has_color);
if has_color {
// Get the file path from the matched pattern
let mut file_ptr: *mut u8 = std::ptr::null_mut();
if FcPatternGetString(matched, FC_FILE.as_ptr() as *const i8, 0, &mut file_ptr) == FcResultMatch {
let path_cstr = CStr::from_ptr(file_ptr as *const i8);
let path = PathBuf::from(path_cstr.to_string_lossy().into_owned());
log::debug!("find_color_font_for_char: found color font {:?}", path);
Some(path)
} else {
log::debug!("find_color_font_for_char: couldn't get file path");
None
}
} else {
log::debug!("find_color_font_for_char: matched font is not a color font");
None
}
} else {
log::debug!("find_color_font_for_char: no match found (result={:?})", result);
None
};
// Cleanup
if !matched.is_null() {
FcPatternDestroy(matched);
}
FcCharSetDestroy(charset);
FcPatternDestroy(pat);
font_path
}
}
/// Lazy-initialized color font renderer using FreeType + Cairo.
/// Only created when a color emoji is first encountered.
/// Cairo is required for proper color font rendering (COLR, CBDT, sbix formats).
struct ColorFontRenderer {
/// FreeType library instance
ft_library: FtLibrary,
/// Loaded FreeType faces and their Cairo font faces, keyed by font path
faces: HashMap<PathBuf, (freetype::Face, cairo::FontFace)>,
/// Reusable Cairo surface for rendering
surface: Option<ImageSurface>,
/// Current surface dimensions
surface_size: (i32, i32),
}
impl ColorFontRenderer {
fn new() -> Result<Self, freetype::Error> {
let ft_library = FtLibrary::init()?;
Ok(Self {
ft_library,
faces: HashMap::new(),
surface: None,
surface_size: (0, 0),
})
}
/// Ensure faces are loaded and return font size to set
fn ensure_faces_loaded(&mut self, path: &PathBuf) -> bool {
if !self.faces.contains_key(path) {
match self.ft_library.new_face(path, 0) {
Ok(ft_face) => {
// Create Cairo font face from FreeType face
match cairo::FontFace::create_from_ft(&ft_face) {
Ok(cairo_face) => {
self.faces.insert(path.clone(), (ft_face, cairo_face));
true
}
Err(e) => {
log::warn!("Failed to create Cairo font face for {:?}: {:?}", path, e);
false
}
}
}
Err(e) => {
log::warn!("Failed to load color font {:?}: {:?}", path, e);
false
}
}
} else {
true
}
}
/// Render a color glyph using FreeType + Cairo.
/// Returns (width, height, RGBA bitmap, offset_x, offset_y) or None if rendering fails.
fn render_color_glyph(
&mut self,
font_path: &PathBuf,
c: char,
font_size_px: f32,
cell_width: u32,
cell_height: u32,
) -> Option<(u32, u32, Vec<u8>, f32, f32)> {
log::debug!("render_color_glyph: U+{:04X} '{}' font={:?}", c as u32, c, font_path);
// Ensure faces are loaded
if !self.ensure_faces_loaded(font_path) {
log::debug!("render_color_glyph: failed to load faces");
return None;
}
log::debug!("render_color_glyph: faces loaded successfully, faces count={}", self.faces.len());
// Get glyph index from FreeType face
// Note: We do NOT call set_pixel_sizes here because CBDT (bitmap) fonts have fixed sizes
// and will fail. Cairo handles font sizing internally.
let glyph_index = {
let face_entry = self.faces.get(font_path);
if face_entry.is_none() {
log::debug!("render_color_glyph: face not found in hashmap after ensure_faces_loaded!");
return None;
}
let (ft_face, _) = face_entry?;
log::debug!("render_color_glyph: got ft_face, getting char index for U+{:04X}", c as u32);
let idx = ft_face.get_char_index(c as usize);
log::debug!("render_color_glyph: FreeType glyph index for U+{:04X} = {:?}", c as u32, idx);
if idx.is_none() {
log::debug!("render_color_glyph: glyph index is None - char not in font!");
return None;
}
idx?
};
// Clone the Cairo font face (it's reference-counted)
let cairo_face = {
let (_, cairo_face) = self.faces.get(font_path)?;
cairo_face.clone()
};
// For emoji, we typically render at 2x cell width (double-width character)
let render_width = (cell_width * 2).max(cell_height) as i32;
let render_height = cell_height as i32;
log::debug!("render_color_glyph: render size {}x{}", render_width, render_height);
// Ensure we have a large enough surface
let surface_width = render_width.max(256);
let surface_height = render_height.max(256);
if self.surface.is_none() || self.surface_size.0 < surface_width || self.surface_size.1 < surface_height {
let new_width = surface_width.max(self.surface_size.0);
let new_height = surface_height.max(self.surface_size.1);
match ImageSurface::create(Format::ARgb32, new_width, new_height) {
Ok(surface) => {
log::debug!("render_color_glyph: created Cairo surface {}x{}", new_width, new_height);
self.surface = Some(surface);
self.surface_size = (new_width, new_height);
}
Err(e) => {
log::warn!("Failed to create Cairo surface: {:?}", e);
return None;
}
}
}
let surface = self.surface.as_mut()?;
// Create Cairo context
let cr = match cairo::Context::new(surface) {
Ok(cr) => cr,
Err(e) => {
log::warn!("Failed to create Cairo context: {:?}", e);
return None;
}
};
// Clear the surface
cr.set_operator(cairo::Operator::Clear);
cr.paint().ok()?;
cr.set_operator(cairo::Operator::Over);
// Set the font face and initial size
cr.set_font_face(&cairo_face);
// Target dimensions for the glyph (2 cells wide, 1 cell tall for emoji)
let target_width = render_width as f64;
let target_height = render_height as f64;
// Start with the requested font size and reduce until glyph fits
// This matches Kitty's fit_cairo_glyph() approach
let mut current_size = font_size_px as f64;
let min_size = 2.0;
cr.set_font_size(current_size);
let mut glyph = cairo::Glyph::new(glyph_index as u64, 0.0, 0.0);
let mut text_extents = cr.glyph_extents(&[glyph]).ok()?;
while current_size > min_size && (text_extents.width() > target_width || text_extents.height() > target_height) {
let ratio = (target_width / text_extents.width()).min(target_height / text_extents.height());
let new_size = (ratio * current_size).max(min_size);
if new_size >= current_size {
current_size -= 2.0;
} else {
current_size = new_size;
}
cr.set_font_size(current_size);
text_extents = cr.glyph_extents(&[glyph]).ok()?;
}
log::debug!("render_color_glyph: fitted font size {:.1} (from {:.1}), glyph extents {:.1}x{:.1}",
current_size, font_size_px, text_extents.width(), text_extents.height());
// Get font metrics for positioning with the final size
let font_extents = cr.font_extents().ok()?;
log::debug!("render_color_glyph: font extents - ascent={:.1}, descent={:.1}, height={:.1}",
font_extents.ascent(), font_extents.descent(), font_extents.height());
// Create glyph with positioning at baseline
// y position should be at baseline (ascent from top)
glyph = cairo::Glyph::new(glyph_index as u64, 0.0, font_extents.ascent());
// Get final glyph extents for sizing
text_extents = cr.glyph_extents(&[glyph]).ok()?;
log::debug!("render_color_glyph: text extents - width={:.1}, height={:.1}, x_bearing={:.1}, y_bearing={:.1}, x_advance={:.1}",
text_extents.width(), text_extents.height(),
text_extents.x_bearing(), text_extents.y_bearing(),
text_extents.x_advance());
// Set source color to white - the atlas stores colors directly for emoji
cr.set_source_rgba(1.0, 1.0, 1.0, 1.0);
// Render the glyph
if let Err(e) = cr.show_glyphs(&[glyph]) {
log::warn!("render_color_glyph: show_glyphs failed: {:?}", e);
return None;
}
log::debug!("render_color_glyph: cairo show_glyphs succeeded");
// Flush and get surface reference again
drop(cr); // Drop the context before accessing surface data
let surface = self.surface.as_mut()?;
surface.flush();
// Calculate actual glyph bounds
let glyph_width = text_extents.width().ceil() as u32;
let glyph_height = text_extents.height().ceil() as u32;
log::debug!("render_color_glyph: glyph size {}x{}", glyph_width, glyph_height);
if glyph_width == 0 || glyph_height == 0 {
log::debug!("render_color_glyph: zero size glyph, returning None");
return None;
}
// The actual rendered area - use the text extents to determine position
let x_offset = text_extents.x_bearing();
let y_offset = text_extents.y_bearing();
// Calculate source rectangle in the surface
let src_x = x_offset.max(0.0) as i32;
let src_y = (font_extents.ascent() + y_offset).max(0.0) as i32;
log::debug!("render_color_glyph: source rect starts at ({}, {})", src_x, src_y);
// Get surface data
let stride = surface.stride() as usize;
let surface_data = surface.data().ok()?;
// Extract the glyph region and convert ARGB -> RGBA
let out_width = glyph_width.min(render_width as u32);
let out_height = glyph_height.min(render_height as u32);
let mut rgba = vec![0u8; (out_width * out_height * 4) as usize];
let mut non_zero_pixels = 0u32;
let mut has_color = false;
for y in 0..out_height as i32 {
for x in 0..out_width as i32 {
let src_pixel_x = src_x + x;
let src_pixel_y = src_y + y;
if src_pixel_x >= 0 && src_pixel_x < self.surface_size.0
&& src_pixel_y >= 0 && src_pixel_y < self.surface_size.1 {
let src_idx = (src_pixel_y as usize) * stride + (src_pixel_x as usize) * 4;
let dst_idx = (y as usize * out_width as usize + x as usize) * 4;
if src_idx + 3 < surface_data.len() {
// Cairo uses ARGB in native byte order (on little-endian: BGRA in memory)
// We need to convert to RGBA
let b = surface_data[src_idx];
let g = surface_data[src_idx + 1];
let r = surface_data[src_idx + 2];
let a = surface_data[src_idx + 3];
if a > 0 {
non_zero_pixels += 1;
// Check if this is actual color (not just white/gray)
if r != g || g != b {
has_color = true;
}
}
// Un-premultiply alpha if needed (Cairo uses premultiplied alpha)
if a > 0 && a < 255 {
let inv_alpha = 255.0 / a as f32;
rgba[dst_idx] = (r as f32 * inv_alpha).min(255.0) as u8;
rgba[dst_idx + 1] = (g as f32 * inv_alpha).min(255.0) as u8;
rgba[dst_idx + 2] = (b as f32 * inv_alpha).min(255.0) as u8;
rgba[dst_idx + 3] = a;
} else {
rgba[dst_idx] = r;
rgba[dst_idx + 1] = g;
rgba[dst_idx + 2] = b;
rgba[dst_idx + 3] = a;
}
}
}
}
}
log::debug!("render_color_glyph: extracted {}x{} pixels, {} non-zero, has_color={}",
out_width, out_height, non_zero_pixels, has_color);
// Check if we actually got any non-transparent pixels
let has_content = rgba.chunks(4).any(|p| p[3] > 0);
if !has_content {
log::debug!("render_color_glyph: no visible content, returning None");
return None;
}
// Kitty convention: bitmap_top = -y_bearing (distance from baseline to glyph top)
let offset_x = text_extents.x_bearing() as f32;
let offset_y = -text_extents.y_bearing() as f32;
log::debug!("render_color_glyph: SUCCESS - returning {}x{} glyph, offset=({:.1}, {:.1})",
out_width, out_height, offset_x, offset_y);
Some((out_width, out_height, rgba, offset_x, offset_y))
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// FONT LOADING HELPER FUNCTIONS
// ═══════════════════════════════════════════════════════════════════════════════
/// Try to load a font file and create both ab_glyph and rustybuzz handles.
/// Returns None if the file doesn't exist or can't be parsed.
fn load_font_variant(path: &std::path::Path) -> Option<FontVariant> {
let data = std::fs::read(path).ok()?.into_boxed_slice();
// Parse with ab_glyph
let font: FontRef<'static> = {
let font = FontRef::try_from_slice(&data).ok()?;
// SAFETY: We keep data alive in the FontVariant struct
unsafe { std::mem::transmute(font) }
};
// Parse with rustybuzz
let face: rustybuzz::Face<'static> = {
let face = rustybuzz::Face::from_slice(&data, 0)?;
// SAFETY: We keep data alive in the FontVariant struct
unsafe { std::mem::transmute(face) }
};
Some(FontVariant { data, font, face })
}
/// Find font files for a font family using fontconfig.
/// Returns paths for (regular, bold, italic, bold_italic).
/// Any variant that can't be found will be None.
fn find_font_family_variants(family: &str) -> [Option<PathBuf>; 4] {
use fontconfig_sys as fcsys;
use fcsys::*;
use fcsys::constants::{FC_FAMILY, FC_WEIGHT, FC_SLANT, FC_FILE};
use std::ffi::CString;
let mut results: [Option<PathBuf>; 4] = [None, None, None, None];
// Style queries: (weight, slant) pairs for each variant
// FC_WEIGHT_REGULAR = 80, FC_WEIGHT_BOLD = 200
// FC_SLANT_ROMAN = 0, FC_SLANT_ITALIC = 100
let styles: [(i32, i32); 4] = [
(80, 0), // Regular
(200, 0), // Bold
(80, 100), // Italic
(200, 100), // BoldItalic
];
unsafe {
let family_cstr = match CString::new(family) {
Ok(s) => s,
Err(_) => return results,
};
for (idx, (weight, slant)) in styles.iter().enumerate() {
let pat = FcPatternCreate();
if pat.is_null() {
continue;
}
// Set family name
FcPatternAddString(pat, FC_FAMILY.as_ptr() as *const i8, family_cstr.as_ptr() as *const u8);
// Set weight
FcPatternAddInteger(pat, FC_WEIGHT.as_ptr() as *const i8, *weight);
// Set slant
FcPatternAddInteger(pat, FC_SLANT.as_ptr() as *const i8, *slant);
FcConfigSubstitute(std::ptr::null_mut(), pat, FcMatchPattern);
FcDefaultSubstitute(pat);
let mut result: FcResult = FcResultMatch;
let matched = FcFontMatch(std::ptr::null_mut(), pat, &mut result);
if result == FcResultMatch && !matched.is_null() {
let mut file_ptr: *mut u8 = std::ptr::null_mut();
if FcPatternGetString(matched, FC_FILE.as_ptr() as *const i8, 0, &mut file_ptr) == FcResultMatch {
if !file_ptr.is_null() {
let path_cstr = std::ffi::CStr::from_ptr(file_ptr as *const i8);
if let Ok(path_str) = path_cstr.to_str() {
results[idx] = Some(PathBuf::from(path_str));
}
}
}
FcPatternDestroy(matched);
}
FcPatternDestroy(pat);
}
}
results
}
/// Load font variants for a font family.
/// Returns array of font variants, with index 0 being the regular font.
/// Falls back to hardcoded paths if fontconfig fails.
fn load_font_family(font_family: Option<&str>) -> (Box<[u8]>, FontRef<'static>, [Option<FontVariant>; 4]) {
// Try to use fontconfig to find the font family
if let Some(family) = font_family {
let paths = find_font_family_variants(family);
log::info!("Font family '{}' resolved to:", family);
for (i, path) in paths.iter().enumerate() {
let style = match i {
0 => "Regular",
1 => "Bold",
2 => "Italic",
3 => "BoldItalic",
_ => "Unknown",
};
if let Some(p) = path {
log::info!(" {}: {:?}", style, p);
}
}
// Load the regular font (required)
if let Some(regular_path) = &paths[0] {
if let Some(regular) = load_font_variant(regular_path) {
let primary_font = regular.font.clone();
let font_data = regular.data.clone();
// Load other variants
let variants: [Option<FontVariant>; 4] = [
Some(regular),
paths[1].as_ref().and_then(|p| load_font_variant(p)),
paths[2].as_ref().and_then(|p| load_font_variant(p)),
paths[3].as_ref().and_then(|p| load_font_variant(p)),
];
return (font_data, primary_font, variants);
}
}
log::warn!("Failed to load font family '{}', falling back to defaults", family);
}
// Fallback: try hardcoded paths
let fallback_fonts = [
("/usr/share/fonts/TTF/0xProtoNerdFont-Regular.ttf",
"/usr/share/fonts/TTF/0xProtoNerdFont-Bold.ttf",
"/usr/share/fonts/TTF/0xProtoNerdFont-Italic.ttf",
"/usr/share/fonts/TTF/0xProtoNerdFont-BoldItalic.ttf"),
("/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Regular.ttf",
"/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Bold.ttf",
"/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Italic.ttf",
"/usr/share/fonts/TTF/JetBrainsMonoNerdFont-BoldItalic.ttf"),
("/usr/share/fonts/TTF/JetBrainsMono-Regular.ttf",
"/usr/share/fonts/TTF/JetBrainsMono-Bold.ttf",
"/usr/share/fonts/TTF/JetBrainsMono-Italic.ttf",
"/usr/share/fonts/TTF/JetBrainsMono-BoldItalic.ttf"),
];
for (regular, bold, italic, bold_italic) in fallback_fonts {
let regular_path = std::path::Path::new(regular);
if let Some(regular_variant) = load_font_variant(regular_path) {
let primary_font = regular_variant.font.clone();
let font_data = regular_variant.data.clone();
let variants: [Option<FontVariant>; 4] = [
Some(regular_variant),
load_font_variant(std::path::Path::new(bold)),
load_font_variant(std::path::Path::new(italic)),
load_font_variant(std::path::Path::new(bold_italic)),
];
log::info!("Loaded font from fallback paths:");
log::info!(" Regular: {}", regular);
if variants[1].is_some() { log::info!(" Bold: {}", bold); }
if variants[2].is_some() { log::info!(" Italic: {}", italic); }
if variants[3].is_some() { log::info!(" BoldItalic: {}", bold_italic); }
return (font_data, primary_font, variants);
}
}
// Last resort: try NotoSansMono
let noto_regular = std::path::Path::new("/usr/share/fonts/noto/NotoSansMono-Regular.ttf");
if let Some(regular_variant) = load_font_variant(noto_regular) {
let primary_font = regular_variant.font.clone();
let font_data = regular_variant.data.clone();
let variants: [Option<FontVariant>; 4] = [Some(regular_variant), None, None, None];
log::info!("Loaded NotoSansMono as fallback");
return (font_data, primary_font, variants);
}
panic!("Failed to load any monospace font");
}
// ═══════════════════════════════════════════════════════════════════════════════
// BOX DRAWING HELPER TYPES
// ═══════════════════════════════════════════════════════════════════════════════
/// Which corner of a cell for corner triangle rendering
#[derive(Clone, Copy)]
enum Corner {
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
/// Supersampled canvas for anti-aliased rendering of box drawing characters.
/// Renders at 4x resolution then downsamples for smooth edges.
struct SupersampledCanvas {
bitmap: Vec<u8>,
width: usize,
height: usize,
ss_width: usize,
ss_height: usize,
}
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;
Self {
bitmap: vec![0u8; ss_width * ss_height],
width,
height,
ss_width,
ss_height,
}
}
/// Blend a pixel with alpha compositing
#[inline]
fn blend_pixel(&mut self, x: usize, y: usize, alpha: f64) {
if x < self.ss_width && y < self.ss_height && alpha > 0.0 {
let old_alpha = self.bitmap[y * self.ss_width + x] as f64 / 255.0;
let new_alpha = alpha + (1.0 - alpha) * old_alpha;
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;
let extra = thickness % 2;
for x in x1..x2.min(self.ss_width) {
let y_center = y_at_x(x) as i32;
let y_start = (y_center - delta as i32).max(0) as usize;
let y_end = ((y_center + delta as i32 + extra as i32) as usize).min(self.ss_height);
for y in y_start..y_end {
self.bitmap[y * self.ss_width + x] = 255;
}
}
}
/// Draw a thick point (for curve rendering)
fn thick_point(&mut self, x: f64, y: f64, thickness: f64) {
let half = thickness / 2.0;
let x_start = (x - half).max(0.0) as usize;
let x_end = ((x + half).ceil() as usize).min(self.ss_width);
let y_start = (y - half).max(0.0) as usize;
let y_end = ((y + half).ceil() as usize).min(self.ss_height);
for py in y_start..y_end {
for px in x_start..x_end {
self.bitmap[py * self.ss_width + px] = 255;
}
}
}
/// 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) {
let w = self.ss_width;
let h = self.ss_height;
// 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
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) {
let w = self.ss_width;
let h = self.ss_height;
// 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;
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)
// Lower line: from (max_x, max_y) to (0, mid_y) -> y = max_y - mid_y/max_x * (max_x - x)
let upper = (mid_y / max_x) * (max_x - x);
let lower = max_y - (mid_y / max_x) * (max_x - x);
(upper, lower)
} else {
// Right-pointing: tip at (max_x, mid), base from (0, 0) to (0, max_y)
// Upper line: from (0, 0) to (max_x, mid_y) -> y = mid_y/max_x * x
// Lower line: from (0, max_y) to (max_x, mid_y) -> y = max_y - mid_y/max_x * x
let upper = (mid_y / max_x) * x;
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;
let h = self.ss_height;
// 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;
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);
self.thick_line_h(0, w, |x| max_y - (mid_y / max_x) * (max_x - x as f64), thickness);
} else {
// Right-pointing chevron >: lines meeting at (max_x, mid_y)
self.thick_line_h(0, w, |x| (mid_y / max_x) * x as f64, thickness);
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)
/// This creates a "D" shape that bulges to the right.
fn fill_bezier_d(&mut self, left: bool) {
let w = self.ss_width;
let h = self.ss_height;
// 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);
for px in start_x..w {
self.bitmap[py * w + px] = 255;
}
} else {
// Right semicircle: fill from 0 to x_extent
for px in 0..=x_extent {
self.bitmap[py * w + px] = 255;
}
}
}
}
/// 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;
let h = self.ss_height;
// 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;
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;
let dy = py as f64 - cy;
if dx * dx + dy * dy <= limit {
self.bitmap[py * self.ss_width + px] = 255;
}
}
}
}
/// 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;
let dy = py as f64 - cy;
if dx * dx + dy * dy <= limit {
self.bitmap[py * self.ss_width + px] = 255;
}
}
}
}
/// 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;
let pixel_y = py as f64 + 0.5;
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 {
for x in 0..self.width {
let src_x = x * Self::FACTOR;
let src_y = y * Self::FACTOR;
let mut total: u32 = 0;
for sy in src_y..src_y + Self::FACTOR {
for sx in src_x..src_x + Self::FACTOR {
total += self.bitmap[sy * self.ss_width + sx] as u32;
}
}
output[y * self.width + x] = (total / (Self::FACTOR * Self::FACTOR) as u32) as u8;
}
}
}
}
use crate::config::Config;
impl Renderer {
/// Creates a new renderer for the given window.
pub async fn new(window: Arc<winit::window::Window>, 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
let dpi = 96.0 * scale_factor;
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
..Default::default()
});
let surface = instance.create_surface(window).unwrap();
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
.expect("Failed to find a suitable GPU adapter");
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("Terminal Device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
memory_hints: wgpu::MemoryHints::Performance,
},
None,
)
.await
.expect("Failed to create device");
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.find(|f| f.is_srgb())
.copied()
.unwrap_or(surface_caps.formats[0]);
// Select alpha mode for transparency support
// Prefer PreMultiplied for proper transparency blending, fall back to others
let alpha_mode = if config.background_opacity < 1.0 {
if surface_caps.alpha_modes.contains(&wgpu::CompositeAlphaMode::PreMultiplied) {
wgpu::CompositeAlphaMode::PreMultiplied
} else if surface_caps.alpha_modes.contains(&wgpu::CompositeAlphaMode::PostMultiplied) {
wgpu::CompositeAlphaMode::PostMultiplied
} else {
log::warn!("Transparency requested but compositor doesn't support alpha blending");
surface_caps.alpha_modes[0]
}
} else {
surface_caps.alpha_modes[0]
};
let surface_config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
width: size.width.max(1),
height: size.height.max(1),
// Use Immediate for lowest latency (no vsync wait)
// Fall back to Mailbox if Immediate not supported
present_mode: if surface_caps.present_modes.contains(&wgpu::PresentMode::Immediate) {
wgpu::PresentMode::Immediate
} else {
wgpu::PresentMode::Mailbox
},
alpha_mode,
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &surface_config);
// Load primary font and font variants (regular, bold, italic, bold-italic)
let (font_data, primary_font, font_variants) = load_font_family(config.font_family.as_deref());
// Fontconfig will be initialized lazily on first fallback font lookup
// Start with empty fallback fonts - will be loaded on-demand via fontconfig
let fallback_fonts: Vec<(Box<[u8]>, FontRef<'static>)> = Vec::new();
let tried_font_paths: HashSet<PathBuf> = HashSet::new();
// Enable OpenType features for ligatures and contextual alternates
// These are the standard features used by coding fonts like Fira Code, JetBrains Mono, etc.
let shaping_features = vec![
// Standard ligatures (fi, fl, etc.)
rustybuzz::Feature::new(Tag::from_bytes(b"liga"), 1, ..),
// Contextual alternates (programming ligatures like ->, =>, etc.)
rustybuzz::Feature::new(Tag::from_bytes(b"calt"), 1, ..),
// Discretionary ligatures (optional ligatures)
rustybuzz::Feature::new(Tag::from_bytes(b"dlig"), 1, ..),
];
// Create shaping context using the regular font variant's face
// The face is borrowed from font_variants[0], which is always Some
let shaping_ctx = {
let regular_variant = font_variants[0].as_ref()
.expect("Regular font variant should always be present");
ShapingContext {
face: regular_variant.face.clone(),
features: shaping_features.clone(),
}
};
// Calculate cell dimensions from font metrics using ab_glyph
//
// The config font_size is in pixels. Scale by display scale factor for HiDPI.
// Round to integer for pixel-perfect glyph rendering.
let base_font_size = config.font_size;
let font_size = (base_font_size * scale_factor as f32).round();
let scaled_font = primary_font.as_scaled(font_size);
// Get advance width for 'M' (em width)
let m_glyph_id = primary_font.glyph_id('M');
let cell_width = scaled_font.h_advance(m_glyph_id).round();
// Use font line metrics for cell height
// ab_glyph's height() = ascent - descent (where descent is negative)
let cell_height = scaled_font.height().round();
// Calculate baseline offset from top of cell.
// The baseline is where the bottom of uppercase letters sit.
// ascent is the distance from baseline to top of tallest glyph.
let baseline = scaled_font.ascent().round();
// 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 {
label: Some("Glyph Atlas"),
size: wgpu::Extent3d {
width: ATLAS_SIZE,
height: ATLAS_SIZE,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor::default());
let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
// Create bind group layout
let glyph_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Glyph Bind Group Layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
count: None,
},
],
});
let glyph_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Glyph Bind Group"),
layout: &glyph_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&atlas_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&atlas_sampler),
},
],
});
// Create shader
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Glyph Shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("glyph_shader.wgsl").into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Glyph Pipeline Layout"),
bind_group_layouts: &[&glyph_bind_group_layout],
push_constant_ranges: &[],
});
let glyph_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Glyph Pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[GlyphVertex::desc()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: surface_config.format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
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,
});
// ═══════════════════════════════════════════════════════════════════════════════
// 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"),
size: std::mem::size_of::<EdgeGlowUniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Create bind group layout for edge glow
let edge_glow_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Edge Glow Bind Group Layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
// Create bind group for edge glow
let edge_glow_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Edge Glow Bind Group"),
layout: &edge_glow_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: edge_glow_uniform_buffer.as_entire_binding(),
},
],
});
// Create pipeline layout for edge glow
let edge_glow_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Edge Glow Pipeline Layout"),
bind_group_layouts: &[&edge_glow_bind_group_layout],
push_constant_ranges: &[],
});
// Create edge glow render pipeline
let edge_glow_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Edge Glow Pipeline"),
layout: Some(&edge_glow_pipeline_layout),
vertex: wgpu::VertexState {
module: &edge_glow_shader,
entry_point: Some("vs_main"),
buffers: &[], // Fullscreen triangle, no vertex buffer needed
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &edge_glow_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: surface_config.format,
// Premultiplied alpha blending for proper glow compositing
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
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,
});
// ═══════════════════════════════════════════════════════════════════════════════
// 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;
let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Glyph Vertex Buffer"),
size: (initial_vertex_capacity * std::mem::size_of::<GlyphVertex>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Glyph Index Buffer"),
size: (initial_index_capacity * std::mem::size_of::<u32>()) as u64,
usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// ═══════════════════════════════════════════════════════════════════════════════
// KITTY-STYLE INSTANCED RENDERING SETUP
// ═══════════════════════════════════════════════════════════════════════════════
// Initial capacity: 200x50 grid = 10000 cells, 4096 sprites
let initial_cells = 10000;
let initial_sprites = 4096;
// Cell storage buffer - holds GPUCell array
let cell_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Cell Storage Buffer"),
size: (initial_cells * std::mem::size_of::<GPUCell>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Statusline cell buffer - single row, max 500 columns
let statusline_max_cols = 500;
let statusline_cell_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Statusline Cell Buffer"),
size: (statusline_max_cols * std::mem::size_of::<GPUCell>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Sprite storage buffer - holds SpriteInfo array
let sprite_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Sprite Storage Buffer"),
size: (initial_sprites * std::mem::size_of::<SpriteInfo>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Grid parameters uniform buffer
let grid_params_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Grid Params Buffer"),
size: std::mem::size_of::<GridParams>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Color table uniform buffer - 258 colors * 16 bytes (vec4<f32>)
let color_table_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Color Table Buffer"),
size: (258 * 16) as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Create bind group layout for instanced rendering (@group(1))
let instanced_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Instanced Bind Group Layout"),
entries: &[
// @binding(0): color_table (uniform)
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// @binding(1): grid_params (uniform)
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// @binding(2): cells (storage, read-only)
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// @binding(3): sprites (storage, read-only)
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
// Create bind group for instanced rendering
let instanced_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Instanced Bind Group"),
layout: &instanced_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: color_table_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: grid_params_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: cell_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: sprite_buffer.as_entire_binding(),
},
],
});
// ═══════════════════════════════════════════════════════════════════════════════
// STATUSLINE RENDERING SETUP (dedicated shader and pipeline)
// ═══════════════════════════════════════════════════════════════════════════════
let statusline_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Statusline Shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("statusline_shader.wgsl").into()),
});
// Statusline params uniform buffer
let statusline_params_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Statusline Params Buffer"),
size: std::mem::size_of::<StatuslineParams>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Statusline sprite buffer (separate from terminal sprites)
let statusline_sprite_buffer_capacity = 256; // Smaller than terminal - statusline has fewer glyphs
let statusline_sprite_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Statusline Sprite Buffer"),
size: (statusline_sprite_buffer_capacity * std::mem::size_of::<SpriteInfo>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Create bind group layout for statusline rendering (@group(1))
// Same bindings as instanced_bind_group_layout but with StatuslineParams instead of GridParams
let statusline_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Statusline Bind Group Layout"),
entries: &[
// @binding(0): color_table (uniform)
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// @binding(1): statusline_params (uniform)
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// @binding(2): cells (storage, read-only)
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
// @binding(3): sprites (storage, read-only)
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
// Create bind group for statusline rendering
let statusline_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Statusline Bind Group"),
layout: &statusline_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: color_table_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: statusline_params_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: statusline_cell_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: statusline_sprite_buffer.as_entire_binding(),
},
],
});
// Create pipeline layout for statusline rendering
let statusline_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Statusline Pipeline Layout"),
bind_group_layouts: &[&glyph_bind_group_layout, &statusline_bind_group_layout],
push_constant_ranges: &[],
});
// Statusline background pipeline
let statusline_bg_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Statusline Background Pipeline"),
layout: Some(&statusline_pipeline_layout),
vertex: wgpu::VertexState {
module: &statusline_shader,
entry_point: Some("vs_statusline_bg"),
buffers: &[],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &statusline_shader,
entry_point: Some("fs_statusline"),
targets: &[Some(wgpu::ColorTargetState {
format: surface_config.format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
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,
});
// Statusline glyph pipeline
let statusline_glyph_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Statusline Glyph Pipeline"),
layout: Some(&statusline_pipeline_layout),
vertex: wgpu::VertexState {
module: &statusline_shader,
entry_point: Some("vs_statusline_glyph"),
buffers: &[],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &statusline_shader,
entry_point: Some("fs_statusline"),
targets: &[Some(wgpu::ColorTargetState {
format: surface_config.format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
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 pipeline layout for instanced cell rendering
// Uses @group(0) for atlas texture/sampler and @group(1) for cell data
let instanced_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Instanced Pipeline Layout"),
bind_group_layouts: &[&glyph_bind_group_layout, &instanced_bind_group_layout],
push_constant_ranges: &[],
});
// Background pipeline - uses vs_cell_bg and fs_cell
let cell_bg_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Cell Background Pipeline"),
layout: Some(&instanced_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_cell_bg"),
buffers: &[], // No vertex buffers - uses instancing
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_cell"),
targets: &[Some(wgpu::ColorTargetState {
format: surface_config.format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
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,
});
// Glyph pipeline - uses vs_cell_glyph and fs_cell
let cell_glyph_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Cell Glyph Pipeline"),
layout: Some(&instanced_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_cell_glyph"),
buffers: &[], // No vertex buffers - uses instancing
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_cell"),
targets: &[Some(wgpu::ColorTargetState {
format: surface_config.format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
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,
});
// ═══════════════════════════════════════════════════════════════════════════════
// INSTANCED QUAD RENDERING SETUP
// For rectangles, borders, overlays, and tab bar backgrounds
// ═══════════════════════════════════════════════════════════════════════════════
let quad_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Quad Shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("quad_shader.wgsl").into()),
});
// Maximum number of quads we can render in one batch
let max_quads: usize = 256;
// Quad buffer for instance data
let quad_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Quad Buffer"),
size: (max_quads * std::mem::size_of::<Quad>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Quad params uniform buffer
let quad_params_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Quad Params Buffer"),
size: std::mem::size_of::<QuadParams>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Bind group layout for quad rendering
let quad_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Quad Bind Group Layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
// Bind group for quad rendering
let quad_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Quad Bind Group"),
layout: &quad_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: quad_params_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: quad_buffer.as_entire_binding(),
},
],
});
// Pipeline layout for quad rendering
let quad_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Quad Pipeline Layout"),
bind_group_layouts: &[&quad_bind_group_layout],
push_constant_ranges: &[],
});
// Quad pipeline
let quad_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Quad Pipeline"),
layout: Some(&quad_pipeline_layout),
vertex: wgpu::VertexState {
module: &quad_shader,
entry_point: Some("vs_quad"),
buffers: &[], // No vertex buffers - uses instancing
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &quad_shader,
entry_point: Some("fs_quad"),
targets: &[Some(wgpu::ColorTargetState {
format: surface_config.format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
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,
});
Self {
surface,
device,
queue,
surface_config,
glyph_pipeline,
glyph_bind_group,
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 * ATLAS_BPP) as usize],
atlas_dirty: false,
font_data,
primary_font,
font_variants,
fallback_fonts,
fontconfig: OnceCell::new(),
tried_font_paths,
color_font_renderer: RefCell::new(None),
color_font_cache: HashMap::new(),
shaping_ctx,
shaping_features,
char_cache: HashMap::new(),
ligature_cache: HashMap::new(),
glyph_cache: HashMap::new(),
atlas_cursor_x: 0,
atlas_cursor_y: 0,
atlas_row_height: 0,
vertex_buffer,
index_buffer,
vertex_capacity: initial_vertex_capacity,
index_capacity: initial_index_capacity,
base_font_size,
scale_factor,
dpi,
font_size,
font_units_to_px,
cell_width,
cell_height,
baseline,
width: size.width,
height: size.height,
palette: ColorPalette::default(),
tab_bar_position: config.tab_bar_position,
background_opacity: config.background_opacity.clamp(0.0, 1.0),
// Initialize with single-pane dimensions (will be updated by layout)
grid_used_width: 0.0,
grid_used_height: 0.0,
// Pre-allocate reusable buffers for rendering
bg_vertices: Vec::with_capacity(4096),
bg_indices: Vec::with_capacity(6144),
glyph_vertices: Vec::with_capacity(4096),
glyph_indices: Vec::with_capacity(6144),
// Kitty-style instanced rendering state
sprite_map: HashMap::new(),
// Index 0 is reserved for "no glyph" (space/empty)
sprite_info: vec![SpriteInfo::default()],
next_sprite_idx: 1,
gpu_cells: Vec::new(),
cells_dirty: true,
last_grid_size: (0, 0),
// GPU buffers for instanced rendering
cell_buffer,
sprite_buffer,
sprite_buffer_capacity: initial_sprites,
grid_params_buffer,
color_table_buffer,
instanced_bind_group,
cell_bg_pipeline,
cell_glyph_pipeline,
selection: None,
// Per-pane GPU resources (like Kitty's VAO per window)
instanced_bind_group_layout,
pane_resources: HashMap::new(),
// Statusline rendering (dedicated shader and pipeline)
statusline_gpu_cells: Vec::with_capacity(statusline_max_cols),
statusline_cell_buffer,
statusline_max_cols,
statusline_params_buffer,
statusline_bind_group_layout,
statusline_bind_group,
statusline_bg_pipeline,
statusline_glyph_pipeline,
statusline_sprite_map: HashMap::new(),
statusline_sprite_info: vec![SpriteInfo::default()], // Index 0 reserved for "no glyph"
statusline_next_sprite_idx: 1,
statusline_sprite_buffer,
statusline_sprite_buffer_capacity,
// Instanced quad rendering
quads: Vec::with_capacity(max_quads),
quad_buffer,
max_quads,
quad_params_buffer,
quad_pipeline,
quad_bind_group,
overlay_quads: Vec::with_capacity(32),
}
}
/// Returns the height of the tab bar in pixels (one cell height, or 0 if hidden).
pub fn tab_bar_height(&self) -> f32 {
match self.tab_bar_position {
TabBarPosition::Hidden => 0.0,
_ => self.cell_height,
}
}
/// Returns the height of the statusline in pixels (one cell height).
pub fn statusline_height(&self) -> f32 {
self.cell_height
}
/// Returns the Y position where the statusline starts.
/// The statusline is rendered below the tab bar (if top) or above it (if bottom).
pub fn statusline_y(&self) -> f32 {
match self.tab_bar_position {
TabBarPosition::Top => self.tab_bar_height(),
TabBarPosition::Bottom => self.height as f32 - self.tab_bar_height() - self.statusline_height(),
TabBarPosition::Hidden => 0.0,
}
}
/// Returns the Y offset where the terminal content starts.
/// Accounts for both the tab bar and the statusline.
pub fn terminal_y_offset(&self) -> f32 {
match self.tab_bar_position {
TabBarPosition::Top => self.tab_bar_height() + self.statusline_height(),
TabBarPosition::Hidden => self.statusline_height(),
_ => 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.
pub fn set_selection(&mut self, selection: Option<(usize, usize, usize, usize)>) {
self.selection = selection;
}
/// Resizes the rendering surface.
pub fn resize(&mut self, new_width: u32, new_height: u32) {
if new_width > 0 && new_height > 0 {
self.width = new_width;
self.height = new_height;
self.surface_config.width = new_width;
self.surface_config.height = new_height;
self.surface.configure(&self.device, &self.surface_config);
}
}
/// Calculates terminal dimensions in cells, accounting for tab bar and statusline.
pub fn terminal_size(&self) -> (usize, usize) {
let available_height = self.height as f32 - self.tab_bar_height() - self.statusline_height();
let cols = (self.width as f32 / self.cell_width).floor() as usize;
let rows = (available_height / self.cell_height).floor() as usize;
(cols.max(1), rows.max(1))
}
/// Returns the raw available pixel dimensions for the terminal grid area.
/// This is the space available for panes before any cell alignment.
pub fn available_grid_space(&self) -> (f32, f32) {
let available_width = self.width as f32;
let available_height = self.height as f32 - self.tab_bar_height() - self.statusline_height();
(available_width, available_height)
}
/// Sets the actual used grid dimensions (from pane layout).
/// This is called after layout to ensure centering accounts for splits and borders.
pub fn set_grid_used_dimensions(&mut self, width: f32, height: f32) {
self.grid_used_width = width;
self.grid_used_height = height;
}
/// Returns the horizontal offset needed to center the cell grid in the window.
/// Uses the actual used width from pane layout if set, otherwise calculates from terminal_size.
pub fn grid_x_offset(&self) -> f32 {
let used_width = if self.grid_used_width > 0.0 {
self.grid_used_width
} else {
let (cols, _) = self.terminal_size();
cols as f32 * self.cell_width
};
(self.width as f32 - used_width) / 2.0
}
/// Returns the vertical offset needed to center the cell grid in the terminal area.
/// Uses the actual used height from pane layout if set, otherwise calculates from terminal_size.
pub fn grid_y_offset(&self) -> f32 {
let used_height = if self.grid_used_height > 0.0 {
self.grid_used_height
} else {
let (_, rows) = self.terminal_size();
rows as f32 * self.cell_height
};
let available_height = self.height as f32 - self.tab_bar_height() - self.statusline_height();
(available_height - used_height) / 2.0
}
/// 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 or statusline).
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 statusline_height = self.statusline_height();
let grid_x_offset = self.grid_x_offset();
let grid_y_offset = self.grid_y_offset();
let height = self.height as f32;
// Check if position is in the tab bar or statusline area
match self.tab_bar_position {
TabBarPosition::Top => {
// Tab bar at top, statusline below it
if (y as f32) < tab_bar_height + statusline_height {
return None;
}
}
TabBarPosition::Bottom => {
// Statusline above tab bar, both at bottom
let statusline_y = height - tab_bar_height - statusline_height;
if (y as f32) >= statusline_y {
return None;
}
}
TabBarPosition::Hidden => {
// Just statusline at top
if (y as f32) < statusline_height {
return None;
}
}
}
// Adjust position to be relative to the centered grid
let grid_x = x as f32 - grid_x_offset;
let grid_y = y as f32 - terminal_y_offset - grid_y_offset;
// Check if position is in the padding area (outside the centered grid)
if grid_x < 0.0 || grid_y < 0.0 {
return None;
}
// Calculate cell position
let col = (grid_x / self.cell_width).floor() as usize;
let row = (grid_y / self.cell_height).floor() as usize;
// Get terminal dimensions to check bounds
let (max_cols, max_rows) = self.terminal_size();
// Return None if outside the grid bounds
if col >= max_cols || row >= max_rows {
return None;
}
Some((col, row))
}
/// Updates the scale factor and recalculates font/cell dimensions.
/// Returns true if the cell dimensions changed (terminal needs resize).
pub fn set_scale_factor(&mut self, new_scale: f64) -> bool {
if (self.scale_factor - new_scale).abs() < 0.001 {
return false;
}
let old_cell_width = self.cell_width;
let old_cell_height = self.cell_height;
self.scale_factor = new_scale;
self.dpi = 96.0 * new_scale;
// Font size in pixels, rounded for pixel-perfect rendering
self.font_size = (self.base_font_size * new_scale as f32).round();
// 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 {}px -> {}px, cell: {}x{}",
new_scale, self.base_font_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();
// Reset atlas and sprite tracking
self.atlas_cursor_x = 0;
self.atlas_cursor_y = 0;
self.atlas_row_height = 0;
self.atlas_data.fill(0);
self.atlas_dirty = true;
// Clear sprite maps since sprite indices are now invalid
self.sprite_map.clear();
self.sprite_info.clear();
self.sprite_info.push(SpriteInfo::default());
self.next_sprite_idx = 1;
self.cells_dirty = true;
self.statusline_sprite_map.clear();
self.statusline_sprite_info.clear();
self.statusline_sprite_info.push(SpriteInfo::default());
self.statusline_next_sprite_idx = 1;
// 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();
// Reset atlas and sprite tracking
self.atlas_cursor_x = 0;
self.atlas_cursor_y = 0;
self.atlas_row_height = 0;
self.atlas_data.fill(0);
self.atlas_dirty = true;
// Clear sprite maps since sprite indices are now invalid
self.sprite_map.clear();
self.sprite_info.clear();
self.sprite_info.push(SpriteInfo::default());
self.next_sprite_idx = 1;
self.cells_dirty = true;
self.statusline_sprite_map.clear();
self.statusline_sprite_info.clear();
self.statusline_sprite_info.push(SpriteInfo::default());
self.statusline_next_sprite_idx = 1;
// Return true if cell dimensions changed
(self.cell_width - old_cell_width).abs() > 0.01
|| (self.cell_height - old_cell_height).abs() > 0.01
}
/// Reset the glyph atlas when it becomes full.
/// This clears all cached glyphs and resets the atlas cursor.
fn reset_atlas(&mut self) {
log::info!("Resetting glyph atlas (was full)");
// Clear all glyph caches - they need to be re-rasterized
self.char_cache.clear();
self.ligature_cache.clear();
self.glyph_cache.clear();
// Also clear sprite map since sprite indices are now invalid
self.sprite_map.clear();
self.sprite_info.clear();
self.sprite_info.push(SpriteInfo::default()); // Index 0 = no glyph
self.next_sprite_idx = 1;
self.cells_dirty = true; // Force re-upload of cell data
// Also clear statusline sprite tracking - they share the same atlas
self.statusline_sprite_map.clear();
self.statusline_sprite_info.clear();
self.statusline_sprite_info.push(SpriteInfo::default()); // Index 0 = no glyph
self.statusline_next_sprite_idx = 1;
// Reset atlas cursor and data
self.atlas_cursor_x = 0;
self.atlas_cursor_y = 0;
self.atlas_row_height = 0;
self.atlas_data.fill(0);
self.atlas_dirty = true;
}
// ═══════════════════════════════════════════════════════════════════════════════
// KITTY-STYLE SPRITE AND CELL HELPERS
// ═══════════════════════════════════════════════════════════════════════════════
/// Pack a terminal Color into u32 format for GPU.
/// Format: type in low byte, then color data in higher bytes.
#[inline]
fn pack_color(color: &Color) -> u32 {
match color {
Color::Default => COLOR_TYPE_DEFAULT,
Color::Indexed(idx) => COLOR_TYPE_INDEXED | ((*idx as u32) << 8),
Color::Rgb(r, g, b) => {
COLOR_TYPE_RGB | ((*r as u32) << 8) | ((*g as u32) << 16) | ((*b as u32) << 24)
}
}
}
/// Pack cell attributes into u32 format for GPU.
#[inline]
fn pack_attrs(bold: bool, italic: bool, underline: bool) -> u32 {
let mut attrs = 0u32;
if bold { attrs |= ATTR_BOLD; }
if italic { attrs |= ATTR_ITALIC; }
if underline { attrs |= ATTR_UNDERLINE; }
attrs
}
/// Pack a StatuslineColor into u32 format for GPU.
#[inline]
fn pack_statusline_color(color: StatuslineColor) -> u32 {
match color {
StatuslineColor::Default => COLOR_TYPE_DEFAULT,
StatuslineColor::Indexed(idx) => COLOR_TYPE_INDEXED | ((idx as u32) << 8),
StatuslineColor::Rgb(r, g, b) => {
COLOR_TYPE_RGB | ((r as u32) << 8) | ((g as u32) << 16) | ((b as u32) << 24)
}
}
}
/// Get or create a sprite index for a character.
/// Returns (sprite_idx, is_colored).
///
/// This uses the same approach as Kitty: shape the text with HarfBuzz using
/// the appropriate font variant (regular, bold, italic, bold-italic), then
/// rasterize the resulting glyph ID with the styled font.
///
/// The `target` parameter specifies which sprite buffer to use:
/// - `SpriteTarget::Terminal` uses the main terminal sprite buffer
/// - `SpriteTarget::Statusline` uses the separate statusline sprite buffer
fn get_or_create_sprite_for(&mut self, c: char, style: FontStyle, target: SpriteTarget) -> (u32, bool) {
// Skip spaces and null characters - they use sprite index 0
if c == ' ' || c == '\0' {
return (0, false);
}
// Select the appropriate sprite tracking based on target
let (sprite_map, _sprite_info, _next_sprite_idx) = match target {
SpriteTarget::Terminal => (
&mut self.sprite_map,
&mut self.sprite_info,
&mut self.next_sprite_idx,
),
SpriteTarget::Statusline => (
&mut self.statusline_sprite_map,
&mut self.statusline_sprite_info,
&mut self.statusline_next_sprite_idx,
),
};
// Check if we already have this sprite
let key = SpriteKey {
text: c.to_string(),
style,
colored: false, // Will check for emoji below
};
if let Some(&idx) = sprite_map.get(&key) {
// Check if it's a colored glyph
let is_colored = (idx & COLORED_GLYPH_FLAG) != 0;
return (idx, is_colored);
}
// Check for emoji with color key
let color_key = SpriteKey {
text: c.to_string(),
style,
colored: true,
};
if let Some(&idx) = sprite_map.get(&color_key) {
return (idx, true);
}
// Need to rasterize this glyph
// First, check if it's an emoji
let char_str = c.to_string();
let is_emoji = emojis::get(&char_str).is_some();
// For emoji, box-drawing, and multi-cell symbols (PUA/dingbats), use rasterize_char
// which has scaling logic for oversized glyphs. Regular text uses HarfBuzz shaping.
let glyph = if is_emoji || Self::is_box_drawing(c) || Self::is_multicell_symbol(c) {
// These don't need style variants or use rasterize_char for scaling
self.rasterize_char(c)
} else {
// Shape the single character with HarfBuzz using the styled font
// This gets us the correct glyph ID for the styled font variant
let shaped = self.shape_text_with_style(&char_str, style);
if shaped.glyphs.is_empty() {
// Fallback to regular rasterization if shaping fails
self.rasterize_char(c)
} else {
// Get the glyph ID from shaping
let (glyph_id, _x_advance, _x_offset, _y_offset, _cluster) = shaped.glyphs[0];
// If glyph_id is 0, the font doesn't have this character (.notdef)
// Fall back to rasterize_char which has full font fallback support
if glyph_id == 0 {
self.rasterize_char(c)
} else {
// Rasterize with the styled font
self.get_glyph_by_id_with_style(glyph_id, style)
}
}
};
// If glyph has no size, return 0
if glyph.size[0] <= 0.0 || glyph.size[1] <= 0.0 {
return (0, false);
}
// Create sprite info from glyph info
// In Kitty's model, glyphs are pre-positioned in cell-sized sprites,
// so no offset is needed - the shader just maps sprite to cell 1:1
let sprite = SpriteInfo {
uv: glyph.uv,
_padding: [0.0, 0.0],
size: glyph.size,
};
// Re-borrow the sprite tracking for the target (needed after self borrows above)
let (sprite_map, sprite_info, next_sprite_idx) = match target {
SpriteTarget::Terminal => (
&mut self.sprite_map,
&mut self.sprite_info,
&mut self.next_sprite_idx,
),
SpriteTarget::Statusline => (
&mut self.statusline_sprite_map,
&mut self.statusline_sprite_info,
&mut self.statusline_next_sprite_idx,
),
};
// Allocate new sprite index
let sprite_idx = *next_sprite_idx;
*next_sprite_idx += 1;
// Add to sprite info array (ensure we have enough capacity)
while sprite_info.len() <= sprite_idx as usize {
sprite_info.push(SpriteInfo::default());
}
sprite_info[sprite_idx as usize] = sprite;
// Mark as colored if it's an emoji
let final_idx = if is_emoji || glyph.is_colored {
sprite_idx | COLORED_GLYPH_FLAG
} else {
sprite_idx
};
// Cache the mapping
let cache_key = SpriteKey {
text: c.to_string(),
style,
colored: is_emoji || glyph.is_colored,
};
sprite_map.insert(cache_key, final_idx);
(final_idx, is_emoji || glyph.is_colored)
}
/// Get or create a sprite index for a character in the terminal sprite buffer.
/// Returns (sprite_idx, is_colored).
///
/// This is a convenience wrapper around `get_or_create_sprite_for` that uses
/// the terminal sprite buffer.
fn get_or_create_sprite(&mut self, c: char, style: FontStyle) -> (u32, bool) {
self.get_or_create_sprite_for(c, style, SpriteTarget::Terminal)
}
/// Convert terminal cells to GPU cells for a visible row.
/// This is called when terminal content changes to update the GPU buffer.
///
/// Note: This method cannot take &mut self because it's called from update_gpu_cells
/// which needs to borrow both self (for sprite lookups) and self.gpu_cells (for output).
/// Instead, we pass in the necessary state explicitly.
fn cells_to_gpu_row_static(
row: &[crate::terminal::Cell],
gpu_row: &mut [GPUCell],
cols: usize,
sprite_map: &HashMap<SpriteKey, u32>,
) {
let mut col = 0;
while col < cols.min(row.len()) {
let cell = &row[col];
// Skip wide character continuations - they share the sprite of the previous cell
if cell.wide_continuation {
gpu_row[col] = GPUCell {
fg: Self::pack_color(&cell.fg_color),
bg: Self::pack_color(&cell.bg_color),
decoration_fg: 0,
sprite_idx: 0, // No glyph for continuation
attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline),
};
col += 1;
continue;
}
// Get font style
let style = FontStyle::from_flags(cell.bold, cell.italic);
let c = cell.character;
// Check for symbol+empty multi-cell pattern
// Like Kitty, look for symbol character followed by empty cells
if c != ' ' && c != '\0' && Self::is_multicell_symbol(c) && !Self::is_box_drawing(c) {
// Count trailing empty cells to determine if this is a multi-cell group
let mut num_empty = 0;
const MAX_EXTRA_CELLS: usize = 4;
while col + num_empty + 1 < row.len() && num_empty < MAX_EXTRA_CELLS {
let next_char = row[col + num_empty + 1].character;
// Check for space, en-space, or empty/null cell
if next_char == ' ' || next_char == '\u{2002}' || next_char == '\0' {
num_empty += 1;
} else {
break;
}
}
if num_empty > 0 {
let total_cells = 1 + num_empty;
// Try to find multi-cell sprites - check colored first, then non-colored
// This avoids expensive emoji detection in the hot path
let first_key_colored = SpriteKey {
text: format!("{}_0", c),
style,
colored: true,
};
let first_key_normal = SpriteKey {
text: format!("{}_0", c),
style,
colored: false,
};
let (first_sprite, is_colored) = if let Some(&sprite) = sprite_map.get(&first_key_colored) {
(Some(sprite), true)
} else if let Some(&sprite) = sprite_map.get(&first_key_normal) {
(Some(sprite), false)
} else {
(None, false)
};
if let Some(first_sprite) = first_sprite {
// Use multi-cell sprites for each cell in the group
for cell_idx in 0..total_cells {
if col + cell_idx >= cols {
break;
}
let sprite_idx = if cell_idx == 0 {
first_sprite
} else {
let key = SpriteKey {
text: format!("{}_{}", c, cell_idx),
style,
colored: is_colored,
};
sprite_map.get(&key).copied().unwrap_or(0)
};
// For colored glyphs (emoji), set the COLORED_GLYPH_FLAG so the shader
// knows to use the atlas color directly instead of applying fg color
let final_sprite_idx = if is_colored {
sprite_idx | COLORED_GLYPH_FLAG
} else {
sprite_idx
};
// Use the symbol cell's foreground color for all cells in the group
let current_cell = &row[col + cell_idx];
gpu_row[col + cell_idx] = GPUCell {
fg: Self::pack_color(&cell.fg_color),
bg: Self::pack_color(&current_cell.bg_color),
decoration_fg: 0,
sprite_idx: final_sprite_idx,
attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline),
};
}
col += total_cells;
continue;
}
}
}
// Check for emoji multi-cell pattern (colored glyphs followed by empty cells)
// This is separate from PUA because emoji detection happens via sprite lookup
if c != ' ' && c != '\0' {
let mut num_empty = 0;
const MAX_EXTRA_CELLS: usize = 1; // Emoji are 2 cells wide
while col + num_empty + 1 < row.len() && num_empty < MAX_EXTRA_CELLS {
let next_char = row[col + num_empty + 1].character;
if next_char == ' ' || next_char == '\u{2002}' || next_char == '\0' {
num_empty += 1;
} else {
break;
}
}
if num_empty > 0 {
// Check if we have colored multi-cell sprites for this character
let first_key = SpriteKey {
text: format!("{}_0", c),
style,
colored: true,
};
if let Some(&first_sprite) = sprite_map.get(&first_key) {
let total_cells = 1 + num_empty;
for cell_idx in 0..total_cells {
if col + cell_idx >= cols {
break;
}
let sprite_idx = if cell_idx == 0 {
first_sprite
} else {
let key = SpriteKey {
text: format!("{}_{}", c, cell_idx),
style,
colored: true,
};
sprite_map.get(&key).copied().unwrap_or(0)
};
let current_cell = &row[col + cell_idx];
gpu_row[col + cell_idx] = GPUCell {
fg: Self::pack_color(&cell.fg_color),
bg: Self::pack_color(&current_cell.bg_color),
decoration_fg: 0,
sprite_idx: sprite_idx | COLORED_GLYPH_FLAG,
attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline),
};
}
col += total_cells;
continue;
}
}
}
// Regular character lookup
let sprite_idx = if c == ' ' || c == '\0' {
0
} else {
// Check cache - first try non-colored, then colored
let key = SpriteKey {
text: c.to_string(),
style,
colored: false,
};
if let Some(&idx) = sprite_map.get(&key) {
idx
} else {
let color_key = SpriteKey {
text: c.to_string(),
style,
colored: true,
};
sprite_map.get(&color_key).copied().unwrap_or(0)
}
};
gpu_row[col] = GPUCell {
fg: Self::pack_color(&cell.fg_color),
bg: Self::pack_color(&cell.bg_color),
decoration_fg: 0,
sprite_idx,
attrs: Self::pack_attrs(cell.bold, cell.italic, cell.underline),
};
col += 1;
}
// Fill remaining columns with empty cells
for col_idx in row.len()..cols {
gpu_row[col_idx] = GPUCell::default();
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// PER-PANE GPU RESOURCE MANAGEMENT (Like Kitty's VAO per window)
// ═══════════════════════════════════════════════════════════════════════════════
/// Get or create GPU resources for a pane.
/// Like Kitty's create_cell_vao(), this allocates per-pane buffers and bind group.
///
/// Following Kitty's approach: we check if size matches exactly and reallocate if needed.
/// This is simpler than tracking capacity with headroom.
fn get_or_create_pane_resources(&mut self, pane_id: u64, required_cells: usize) -> &PaneGpuResources {
// Check if we need to create or resize (like Kitty's alloc_buffer size check)
let needs_create = match self.pane_resources.get(&pane_id) {
None => true,
Some(res) => res.capacity != required_cells, // Reallocate if size changed (Kitty's approach)
};
if needs_create {
// Create new buffers with exact size needed (like Kitty - no headroom)
let capacity = required_cells;
let cell_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some(&format!("Pane {} Cell Buffer", pane_id)),
size: (capacity * std::mem::size_of::<GPUCell>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let grid_params_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some(&format!("Pane {} Grid Params Buffer", pane_id)),
size: std::mem::size_of::<GridParams>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Create bind group referencing this pane's buffers + shared resources
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(&format!("Pane {} Bind Group", pane_id)),
layout: &self.instanced_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.color_table_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: grid_params_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: cell_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: self.sprite_buffer.as_entire_binding(),
},
],
});
self.pane_resources.insert(pane_id, PaneGpuResources {
cell_buffer,
grid_params_buffer,
bind_group,
capacity,
});
}
self.pane_resources.get(&pane_id).unwrap()
}
/// Remove GPU resources for panes that no longer exist.
/// Like Kitty's remove_vao(), this frees GPU resources when panes are destroyed.
///
/// Call this after rendering with a set of active pane IDs.
pub fn cleanup_unused_pane_resources(&mut self, active_pane_ids: &std::collections::HashSet<u64>) {
self.pane_resources.retain(|id, _| active_pane_ids.contains(id));
}
/// Update GPU cell buffer from terminal content.
/// Like Kitty, this only processes dirty lines to minimize work.
///
/// Returns true if any cells were updated (buffer needs upload to GPU).
pub fn update_gpu_cells(&mut self, terminal: &Terminal) -> bool {
let cols = terminal.cols;
let rows = terminal.rows;
let total_cells = cols * rows;
// Check if grid size changed - need full rebuild
let size_changed = self.last_grid_size != (cols, rows);
if size_changed {
self.gpu_cells.resize(total_cells, GPUCell::default());
self.last_grid_size = (cols, rows);
self.cells_dirty = true;
}
// Get visible rows (accounts for scroll offset)
let visible_rows = terminal.visible_rows();
// First pass: ensure all characters have sprites
// This needs mutable access to self for sprite creation
// Like Kitty's render_line(), detect PUA+space patterns for multi-cell rendering
for row in visible_rows.iter() {
let mut col = 0;
while col < row.len() {
let cell = &row[col];
if cell.character == ' ' || cell.character == '\0' || cell.wide_continuation {
col += 1;
continue;
}
let c = cell.character;
let style = FontStyle::from_flags(cell.bold, cell.italic);
// Check if this is a symbol that might need multi-cell rendering
// Like Kitty's render_line() at fonts.c:1873-1912
// This includes PUA characters and dingbats
if Self::is_multicell_symbol(c) && !Self::is_box_drawing(c) {
// Get the glyph's natural width to determine desired cells
let glyph_width = self.get_glyph_width(c);
let desired_cells = (glyph_width / self.cell_width).ceil() as usize;
log::debug!("Symbol check U+{:04X}: glyph_width={:.1}, cell_width={:.1}, desired_cells={}",
c as u32, glyph_width, self.cell_width, desired_cells);
if desired_cells > 1 {
// Count trailing empty cells (spaces or null characters)
// Like Kitty's loop at fonts.c:1888-1903, but also including empty cells
let mut num_empty = 0;
const MAX_EXTRA_CELLS: usize = 4; // Like Kitty's MAX_NUM_EXTRA_GLYPHS_PUA
while col + num_empty + 1 < row.len()
&& num_empty + 1 < desired_cells
&& num_empty < MAX_EXTRA_CELLS
{
let next_char = row[col + num_empty + 1].character;
log::debug!(" next char at col {}: U+{:04X} '{}'",
col + num_empty + 1, next_char as u32, next_char);
// Check for space, en-space, or empty/null cell
if next_char == ' ' || next_char == '\u{2002}' || next_char == '\0' {
num_empty += 1;
} else {
break;
}
}
log::debug!(" found {} trailing empty cells", num_empty);
if num_empty > 0 {
// We have symbol + empty cells - render as multi-cell
let total_cells = 1 + num_empty;
// Check if we already have sprites for this multi-cell group
// PUA symbols are not colored
let first_key = SpriteKey {
text: format!("{}_0_{}", c, total_cells),
style,
colored: false,
};
if self.sprite_map.get(&first_key).is_none() {
// Need to rasterize
let cell_sprites = self.rasterize_pua_multicell(c, total_cells);
// Store each cell's sprite with a unique key
for (cell_idx, glyph) in cell_sprites.into_iter().enumerate() {
if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 {
let key = SpriteKey {
text: format!("{}_{}", c, cell_idx),
style,
colored: false,
};
// Create sprite info from glyph info
let sprite = SpriteInfo {
uv: glyph.uv,
_padding: [0.0, 0.0],
size: glyph.size,
};
// Use next_sprite_idx like get_or_create_sprite does
let sprite_idx = self.next_sprite_idx;
self.next_sprite_idx += 1;
// Ensure sprite_info array is large enough
while self.sprite_info.len() <= sprite_idx as usize {
self.sprite_info.push(SpriteInfo::default());
}
self.sprite_info[sprite_idx as usize] = sprite;
self.sprite_map.insert(key, sprite_idx);
}
}
}
// Skip the spaces we consumed
col += total_cells;
continue;
}
}
}
// Regular character - create sprite as normal
// This also handles emoji detection (via emojis::get, but cached per character)
let (sprite_idx, is_colored) = self.get_or_create_sprite(c, style);
// If this is a colored glyph (emoji) followed by empty cells, create multi-cell sprites
if is_colored && sprite_idx != 0 {
// Count trailing empty cells for potential multi-cell emoji
let mut num_empty = 0;
const MAX_EXTRA_CELLS: usize = 1; // Emoji are typically 2 cells wide
while col + num_empty + 1 < row.len() && num_empty < MAX_EXTRA_CELLS {
let next_char = row[col + num_empty + 1].character;
if next_char == ' ' || next_char == '\u{2002}' || next_char == '\0' {
num_empty += 1;
} else {
break;
}
}
if num_empty > 0 {
let total_cells = 1 + num_empty;
// Check if we already have multi-cell sprites for this emoji
let first_key = SpriteKey {
text: format!("{}_0", c),
style,
colored: true,
};
if self.sprite_map.get(&first_key).is_none() {
// Need to create multi-cell emoji sprites
let cell_sprites = self.rasterize_emoji_multicell(c, total_cells);
for (cell_idx, glyph) in cell_sprites.into_iter().enumerate() {
if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 {
let key = SpriteKey {
text: format!("{}_{}", c, cell_idx),
style,
colored: true,
};
let sprite = SpriteInfo {
uv: glyph.uv,
_padding: [0.0, 0.0],
size: glyph.size,
};
// Use next_sprite_idx like get_or_create_sprite does
let idx = self.next_sprite_idx;
self.next_sprite_idx += 1;
// Ensure sprite_info array is large enough
while self.sprite_info.len() <= idx as usize {
self.sprite_info.push(SpriteInfo::default());
}
self.sprite_info[idx as usize] = sprite;
self.sprite_map.insert(key, idx);
}
}
}
col += total_cells;
continue;
}
}
col += 1;
}
}
// Re-get visible rows (the reference was invalidated by get_or_create_sprite)
let visible_rows = terminal.visible_rows();
// Check dirty lines and update only those
let dirty_bitmap = terminal.get_dirty_lines();
let mut any_updated = false;
// If we did a full reset or size changed, update all lines
if self.cells_dirty {
for (row_idx, row) in visible_rows.iter().enumerate() {
if row_idx >= rows {
break;
}
let start = row_idx * cols;
let end = start + cols;
Self::cells_to_gpu_row_static(row, &mut self.gpu_cells[start..end], cols, &self.sprite_map);
}
self.cells_dirty = false;
any_updated = true;
} else {
// Only update dirty lines
for row_idx in 0..rows.min(64) {
let bit = 1u64 << row_idx;
if (dirty_bitmap & bit) != 0 {
if row_idx < visible_rows.len() {
let start = row_idx * cols;
let end = start + cols;
Self::cells_to_gpu_row_static(visible_rows[row_idx], &mut self.gpu_cells[start..end], cols, &self.sprite_map);
any_updated = true;
}
}
}
// For terminals with more than 64 rows, check additional dirty_lines words
if rows > 64 && dirty_bitmap != 0 {
for row_idx in 64..rows.min(visible_rows.len()) {
let start = row_idx * cols;
let end = start + cols;
Self::cells_to_gpu_row_static(visible_rows[row_idx], &mut self.gpu_cells[start..end], cols, &self.sprite_map);
any_updated = true;
}
}
}
any_updated
}
/// Parse ANSI escape sequences from raw statusline content.
/// Returns a vector of (char, fg_color, bg_color, bold) tuples.
fn parse_ansi_statusline(content: &str) -> Vec<(char, StatuslineColor, StatuslineColor, bool)> {
let mut result = Vec::new();
let chars: Vec<char> = content.chars().collect();
let mut i = 0;
// Current styling state
let mut fg = StatuslineColor::Default;
let mut bg = StatuslineColor::Rgb(0x1a, 0x1a, 0x1a); // Default statusline background
let mut bold = false;
while i < chars.len() {
let c = chars[i];
// Check for escape sequence (ESC = 0x1B)
if c == '\x1b' && i + 1 < chars.len() && chars[i + 1] == '[' {
// Parse CSI sequence: ESC [ params m
i += 2; // Skip ESC [
// Collect parameters
let mut params: Vec<u16> = Vec::new();
let mut current_param: u16 = 0;
let mut has_digit = false;
while i < chars.len() {
let pc = chars[i];
if pc.is_ascii_digit() {
current_param = current_param * 10 + (pc as u16 - '0' as u16);
has_digit = true;
i += 1;
} else if pc == ';' || pc == ':' {
params.push(if has_digit { current_param } else { 0 });
current_param = 0;
has_digit = false;
i += 1;
} else if pc == 'm' {
// SGR sequence complete
params.push(if has_digit { current_param } else { 0 });
i += 1;
// Process SGR parameters
let mut pi = 0;
while pi < params.len() {
let code = params[pi];
match code {
0 => {
fg = StatuslineColor::Default;
bg = StatuslineColor::Rgb(0x1a, 0x1a, 0x1a);
bold = false;
}
1 => bold = true,
22 => bold = false,
30..=37 => fg = StatuslineColor::Indexed((code - 30) as u8),
38 => {
// Extended foreground color
if pi + 1 < params.len() {
let mode = params[pi + 1];
if mode == 5 && pi + 2 < params.len() {
fg = StatuslineColor::Indexed(params[pi + 2] as u8);
pi += 2;
} else if mode == 2 && pi + 4 < params.len() {
fg = StatuslineColor::Rgb(
params[pi + 2] as u8,
params[pi + 3] as u8,
params[pi + 4] as u8,
);
pi += 4;
}
}
}
39 => fg = StatuslineColor::Default,
40..=47 => bg = StatuslineColor::Indexed((code - 40) as u8),
48 => {
// Extended background color
if pi + 1 < params.len() {
let mode = params[pi + 1];
if mode == 5 && pi + 2 < params.len() {
bg = StatuslineColor::Indexed(params[pi + 2] as u8);
pi += 2;
} else if mode == 2 && pi + 4 < params.len() {
bg = StatuslineColor::Rgb(
params[pi + 2] as u8,
params[pi + 3] as u8,
params[pi + 4] as u8,
);
pi += 4;
}
}
}
49 => bg = StatuslineColor::Rgb(0x1a, 0x1a, 0x1a), // Reset to default statusline bg
90..=97 => fg = StatuslineColor::Indexed((code - 90 + 8) as u8),
100..=107 => bg = StatuslineColor::Indexed((code - 100 + 8) as u8),
_ => {}
}
pi += 1;
}
break;
} else {
// Unknown sequence terminator, skip it
i += 1;
break;
}
}
} else if c >= ' ' {
// Printable character - add to result with current styling
result.push((c, fg, bg, bold));
i += 1;
} else {
// Skip other control characters
i += 1;
}
}
result
}
/// Update statusline GPU cells from StatuslineContent.
/// This converts the statusline sections/components into GPUCell format for instanced rendering.
///
/// `target_width` is the desired width in pixels - for Raw content (like neovim statuslines),
/// this is used to expand the middle gap to fill the full window width.
///
/// Returns the number of columns used.
fn update_statusline_cells(&mut self, content: &StatuslineContent, target_width: f32) -> usize {
self.statusline_gpu_cells.clear();
// Calculate target columns based on window width
let target_cols = if self.cell_width > 0.0 {
(target_width / self.cell_width).floor() as usize
} else {
self.statusline_max_cols
};
// Default background color for statusline (dark gray)
let default_bg = Self::pack_statusline_color(StatuslineColor::Rgb(0x1a, 0x1a, 0x1a));
let _ = default_bg; // Silence unused warning - used by Sections path
match content {
StatuslineContent::Raw(ansi_content) => {
// Parse ANSI escape sequences to extract colors and text
let parsed = Self::parse_ansi_statusline(ansi_content);
// Find the middle gap (largest consecutive run of spaces)
// and expand it to fill the target width
let current_len = parsed.len();
if current_len < target_cols && current_len > 0 {
// Find the largest gap of consecutive spaces
let mut best_gap_start = 0;
let mut best_gap_len = 0;
let mut current_gap_start = 0;
let mut current_gap_len = 0;
let mut in_gap = false;
for (i, (c, _, _, _)) in parsed.iter().enumerate() {
if *c == ' ' {
if !in_gap {
current_gap_start = i;
current_gap_len = 0;
in_gap = true;
}
current_gap_len += 1;
} else {
if in_gap && current_gap_len > best_gap_len {
// Prefer gaps in the middle (not at start or end)
let is_middle = current_gap_start > 0 && (current_gap_start + current_gap_len) < current_len;
if is_middle || best_gap_len == 0 {
best_gap_start = current_gap_start;
best_gap_len = current_gap_len;
}
}
in_gap = false;
}
}
// Check final gap
if in_gap && current_gap_len > best_gap_len {
let is_middle = current_gap_start > 0;
if is_middle || best_gap_len == 0 {
best_gap_start = current_gap_start;
best_gap_len = current_gap_len;
}
}
// Calculate how many extra spaces we need
let extra_spaces = target_cols.saturating_sub(current_len);
// Get the background color for padding (from the gap area)
let gap_bg = if best_gap_len > 0 && best_gap_start < parsed.len() {
parsed[best_gap_start].2
} else {
StatuslineColor::Rgb(0x1a, 0x1a, 0x1a)
};
// The position right before right-hand content starts (end of gap)
let gap_end = best_gap_start + best_gap_len;
// Render with expanded gap - insert extra padding at the END of the gap
for (i, (c, fg_color, bg_color, bold)) in parsed.iter().enumerate() {
// Insert extra padding right before the right-hand content
if i == gap_end && extra_spaces > 0 && best_gap_len > 0 {
let padding_bg = Self::pack_statusline_color(gap_bg);
for _ in 0..extra_spaces {
if self.statusline_gpu_cells.len() >= self.statusline_max_cols {
break;
}
self.statusline_gpu_cells.push(GPUCell {
fg: 0,
bg: padding_bg,
decoration_fg: 0,
sprite_idx: 0,
attrs: 0,
});
}
}
if self.statusline_gpu_cells.len() >= self.statusline_max_cols {
break;
}
let fg = Self::pack_statusline_color(*fg_color);
let bg = Self::pack_statusline_color(*bg_color);
let style = if *bold { FontStyle::Bold } else { FontStyle::Regular };
let attrs = Self::pack_attrs(*bold, false, false);
let (sprite_idx, is_colored) = if *c == ' ' || *c == '\0' {
(0, false)
} else {
self.get_or_create_sprite_for(*c, style, SpriteTarget::Statusline)
};
let final_sprite_idx = if is_colored {
sprite_idx | COLORED_GLYPH_FLAG
} else {
sprite_idx
};
self.statusline_gpu_cells.push(GPUCell {
fg,
bg,
decoration_fg: 0,
sprite_idx: final_sprite_idx,
attrs,
});
}
// If gap is at the very end (right content is empty), add padding after everything
if gap_end == parsed.len() && extra_spaces > 0 && best_gap_len > 0 {
let padding_bg = Self::pack_statusline_color(gap_bg);
for _ in 0..extra_spaces {
if self.statusline_gpu_cells.len() >= self.statusline_max_cols {
break;
}
self.statusline_gpu_cells.push(GPUCell {
fg: 0,
bg: padding_bg,
decoration_fg: 0,
sprite_idx: 0,
attrs: 0,
});
}
}
} else {
// No expansion needed, render as-is
for (c, fg_color, bg_color, bold) in parsed {
if self.statusline_gpu_cells.len() >= self.statusline_max_cols {
break;
}
let fg = Self::pack_statusline_color(fg_color);
let bg = Self::pack_statusline_color(bg_color);
let style = if bold { FontStyle::Bold } else { FontStyle::Regular };
let attrs = Self::pack_attrs(bold, false, false);
let (sprite_idx, is_colored) = if c == ' ' || c == '\0' {
(0, false)
} else {
self.get_or_create_sprite_for(c, style, SpriteTarget::Statusline)
};
let final_sprite_idx = if is_colored {
sprite_idx | COLORED_GLYPH_FLAG
} else {
sprite_idx
};
self.statusline_gpu_cells.push(GPUCell {
fg,
bg,
decoration_fg: 0,
sprite_idx: final_sprite_idx,
attrs,
});
}
}
}
StatuslineContent::Sections(sections) => {
for section in sections.iter() {
let section_bg = Self::pack_statusline_color(section.bg);
for component in section.components.iter() {
let component_fg = Self::pack_statusline_color(component.fg);
let style = if component.bold { FontStyle::Bold } else { FontStyle::Regular };
let attrs = Self::pack_attrs(component.bold, false, false);
// Process characters with lookahead for multi-cell symbols
let chars: Vec<char> = component.text.chars().collect();
let mut char_idx = 0;
while char_idx < chars.len() {
if self.statusline_gpu_cells.len() >= self.statusline_max_cols {
break;
}
let c = chars[char_idx];
// Check for multi-cell symbol pattern
let is_powerline_char = ('\u{E0B0}'..='\u{E0BF}').contains(&c);
let is_multicell_with_space = !is_powerline_char
&& Self::is_multicell_symbol(c)
&& !Self::is_box_drawing(c)
&& char_idx + 1 < chars.len()
&& chars[char_idx + 1] == ' ';
if is_multicell_with_space {
// Render as 2-cell symbol
let multi_style = FontStyle::Regular;
// Check if we already have multi-cell sprites
let first_key = SpriteKey {
text: format!("{}_0", c),
style: multi_style,
colored: false,
};
if self.statusline_sprite_map.get(&first_key).is_none() {
// Need to rasterize multi-cell sprites
let cell_sprites = self.rasterize_pua_multicell(c, 2);
for (cell_i, glyph) in cell_sprites.into_iter().enumerate() {
if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 {
let key = SpriteKey {
text: format!("{}_{}", c, cell_i),
style: multi_style,
colored: false,
};
let sprite = SpriteInfo {
uv: glyph.uv,
_padding: [0.0, 0.0],
size: glyph.size,
};
// Use statusline sprite tracking
let sprite_idx = self.statusline_next_sprite_idx;
self.statusline_next_sprite_idx += 1;
// Ensure sprite_info array is large enough
while self.statusline_sprite_info.len() <= sprite_idx as usize {
self.statusline_sprite_info.push(SpriteInfo::default());
}
self.statusline_sprite_info[sprite_idx as usize] = sprite;
self.statusline_sprite_map.insert(key, sprite_idx);
}
}
}
// Add GPUCells for both parts
for cell_i in 0..2 {
if self.statusline_gpu_cells.len() >= self.statusline_max_cols {
break;
}
let key = SpriteKey {
text: format!("{}_{}", c, cell_i),
style: multi_style,
colored: false,
};
let sprite_idx = self.statusline_sprite_map.get(&key).copied().unwrap_or(0);
self.statusline_gpu_cells.push(GPUCell {
fg: component_fg,
bg: section_bg,
decoration_fg: 0,
sprite_idx,
attrs,
});
}
// Skip symbol and space
char_idx += 2;
continue;
}
// Regular character
let (sprite_idx, is_colored) = if c == ' ' || c == '\0' {
(0, false)
} else {
self.get_or_create_sprite_for(c, style, SpriteTarget::Statusline)
};
log::debug!(" char '{}' (U+{:04X}) -> sprite_idx={}, is_colored={}",
c, c as u32, sprite_idx, is_colored);
let final_sprite_idx = if is_colored {
sprite_idx | COLORED_GLYPH_FLAG
} else {
sprite_idx
};
self.statusline_gpu_cells.push(GPUCell {
fg: component_fg,
bg: section_bg,
decoration_fg: 0,
sprite_idx: final_sprite_idx,
attrs,
});
char_idx += 1;
}
}
// Add powerline arrow at end of section if it has a background
let has_bg = matches!(section.bg, StatuslineColor::Indexed(_) | StatuslineColor::Rgb(_, _, _));
if has_bg && self.statusline_gpu_cells.len() < self.statusline_max_cols {
// The powerline arrow character
let arrow_char = '\u{E0B0}';
let (sprite_idx, _) = self.get_or_create_sprite_for(arrow_char, FontStyle::Regular, SpriteTarget::Statusline);
// Arrow foreground is section background, arrow background is next section bg or default
self.statusline_gpu_cells.push(GPUCell {
fg: section_bg, // Arrow takes section bg color as its foreground
bg: default_bg, // Will be overwritten if there's a next section
decoration_fg: 0,
sprite_idx,
attrs: 0,
});
}
}
}
}
self.statusline_gpu_cells.len()
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 {
// Kitty's box_drawing_scale defaults: [0.001, 1.0, 1.5, 2.0] in points
const BOX_DRAWING_SCALE: [f64; 4] = [0.001, 1.0, 1.5, 2.0];
let pts = BOX_DRAWING_SCALE[level.min(3)];
// thickness = scale * pts * dpi / 72.0
(pts * self.dpi / 72.0).max(1.0)
}
/// Check if a character is a box-drawing character that should be rendered procedurally.
fn is_box_drawing(c: char) -> bool {
let cp = c as u32;
// Box Drawing: U+2500-U+257F
// Block Elements: U+2580-U+259F
// 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)
|| (0x2580..=0x259F).contains(&cp)
|| (0x25A0..=0x25FF).contains(&cp)
|| (0x2800..=0x28FF).contains(&cp)
|| (0xE0B0..=0xE0BF).contains(&cp)
}
/// Check if a character is in the Unicode Private Use Area (PUA).
/// Nerd Fonts and other symbol fonts use PUA codepoints.
/// Returns true for:
/// - BMP Private Use Area: U+E000-U+F8FF
/// - Supplementary Private Use Area-A: U+F0000-U+FFFFD
/// - Supplementary Private Use Area-B: U+100000-U+10FFFD
fn is_private_use(c: char) -> bool {
let cp = c as u32;
(0xE000..=0xF8FF).contains(&cp)
|| (0xF0000..=0xFFFFD).contains(&cp)
|| (0x100000..=0x10FFFD).contains(&cp)
}
/// Check if a character is a symbol that may need multi-cell rendering.
/// This includes PUA characters and dingbats.
/// Emoji are handled separately via the colored sprite path.
/// Used to detect symbols that might be wider than a single cell.
fn is_multicell_symbol(c: char) -> bool {
let cp = c as u32;
// Private Use Areas
if Self::is_private_use(c) {
return true;
}
// Dingbats: U+2700-U+27BF (like Kitty's is_non_emoji_dingbat)
// This includes arrows like ➜ (U+279C)
if (0x2700..=0x27BF).contains(&cp) {
return true;
}
// Miscellaneous Symbols: U+2600-U+26FF
if (0x2600..=0x26FF).contains(&cp) {
return true;
}
false
}
/// Get the rendered width of a glyph in pixels.
/// Used to determine if a PUA glyph needs multiple cells.
/// Like Kitty's get_glyph_width() in freetype.c, this returns the actual
/// bitmap/bounding box width, not the advance width.
fn get_glyph_width(&self, c: char) -> f32 {
use ab_glyph::Font;
// Try primary font first
let glyph_id = self.primary_font.glyph_id(c);
if glyph_id.0 != 0 {
let scaled = self.primary_font.as_scaled(self.font_size);
// Create a Glyph from the GlyphId
let glyph = glyph_id.with_scale(self.font_size);
// Use pixel bounds width (like Kitty's B.width)
// This is the actual rendered glyph width, not the advance width
if let Some(outlined) = scaled.outline_glyph(glyph) {
let bounds = outlined.px_bounds();
let width = bounds.max.x - bounds.min.x;
if width > 0.0 {
return width;
}
}
// Fall back to h_advance if no outline
return scaled.h_advance(glyph_id);
}
// Try fallback fonts
for (_, fallback_font) in &self.fallback_fonts {
let fb_glyph_id = fallback_font.glyph_id(c);
if fb_glyph_id.0 != 0 {
let scaled = fallback_font.as_scaled(self.font_size);
// Create a Glyph from the GlyphId
let glyph = fb_glyph_id.with_scale(self.font_size);
// Use pixel bounds width (like Kitty's B.width)
if let Some(outlined) = scaled.outline_glyph(glyph) {
let bounds = outlined.px_bounds();
let width = bounds.max.x - bounds.min.x;
if width > 0.0 {
return width;
}
}
// Fall back to h_advance if no outline
return scaled.h_advance(fb_glyph_id);
}
}
// Default to one cell width if glyph not found
self.cell_width
}
/// Render a box-drawing character procedurally to a bitmap.
/// Returns (bitmap, supersampled) where supersampled indicates if anti-aliasing was used.
fn render_box_char(&self, c: char) -> Option<(Vec<u8>, bool)> {
let w = self.cell_width.ceil() as usize;
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;
// Helper: draw horizontal line
let hline = |buf: &mut [u8], x1: usize, x2: usize, y: usize, t: usize| {
let y_start = y.saturating_sub(t / 2);
let y_end = (y_start + t).min(h);
for py in y_start..y_end {
for px in x1..x2.min(w) {
buf[py * w + px] = 255;
}
}
};
// Helper: draw vertical line
let vline = |buf: &mut [u8], y1: usize, y2: usize, x: usize, t: usize| {
let x_start = x.saturating_sub(t / 2);
let x_end = (x_start + t).min(w);
for py in y1..y2.min(h) {
for px in x_start..x_end {
buf[py * w + px] = 255;
}
}
};
// Helper: fill rectangle
let fill_rect = |buf: &mut [u8], x1: usize, y1: usize, x2: usize, y2: usize| {
for py in y1..y2.min(h) {
for px in x1..x2.min(w) {
buf[py * w + px] = 255;
}
}
};
match c {
// ═══════════════════════════════════════════════════════════════
// 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);
vline(&mut bitmap, mid_y, h, mid_x, light);
}
'┐' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, light);
vline(&mut bitmap, mid_y, h, mid_x, light);
}
'└' => {
hline(&mut bitmap, mid_x, w, mid_y, light);
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
}
'┘' => {
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);
hline(&mut bitmap, mid_x, w, mid_y, light);
}
'┤' => {
vline(&mut bitmap, 0, h, mid_x, light);
hline(&mut bitmap, 0, mid_x + 1, mid_y, light);
}
'┬' => {
hline(&mut bitmap, 0, w, mid_y, light);
vline(&mut bitmap, mid_y, h, mid_x, light);
}
'┴' => {
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);
vline(&mut bitmap, 0, h, mid_x, light);
}
// ═══════════════════════════════════════════════════════════════
// 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);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
}
'┓' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
}
'┗' => {
hline(&mut bitmap, mid_x, w, mid_y, heavy);
vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy);
}
'┛' => {
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);
hline(&mut bitmap, mid_x, w, mid_y, heavy);
}
'┫' => {
vline(&mut bitmap, 0, h, mid_x, heavy);
hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy);
}
'┳' => {
hline(&mut bitmap, 0, w, mid_y, heavy);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
}
'┻' => {
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);
vline(&mut bitmap, 0, h, mid_x, heavy);
}
// ═══════════════════════════════════════════════════════════════
// MIXED LIGHT/HEAVY
// ═══════════════════════════════════════════════════════════════
// Light horizontal, heavy vertical corners
'┎' => {
hline(&mut bitmap, mid_x, w, mid_y, light);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
}
'┒' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, light);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
}
'┖' => {
hline(&mut bitmap, mid_x, w, mid_y, light);
vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy);
}
'┚' => {
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);
vline(&mut bitmap, mid_y, h, mid_x, light);
}
'┑' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy);
vline(&mut bitmap, mid_y, h, mid_x, light);
}
'┕' => {
hline(&mut bitmap, mid_x, w, mid_y, heavy);
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
}
'┙' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy);
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
}
// Mixed T-junctions (vertical heavy, horizontal light)
'┠' => {
vline(&mut bitmap, 0, h, mid_x, heavy);
hline(&mut bitmap, mid_x, w, mid_y, light);
}
'┨' => {
vline(&mut bitmap, 0, h, mid_x, heavy);
hline(&mut bitmap, 0, mid_x + 1, mid_y, light);
}
'┰' => {
hline(&mut bitmap, 0, w, mid_y, light);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
}
'┸' => {
hline(&mut bitmap, 0, w, mid_y, light);
vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy);
}
// Mixed T-junctions (vertical light, horizontal heavy)
'┝' => {
vline(&mut bitmap, 0, h, mid_x, light);
hline(&mut bitmap, mid_x, w, mid_y, heavy);
}
'┥' => {
vline(&mut bitmap, 0, h, mid_x, light);
hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy);
}
'┯' => {
hline(&mut bitmap, 0, w, mid_y, heavy);
vline(&mut bitmap, mid_y, h, mid_x, light);
}
'┷' => {
hline(&mut bitmap, 0, w, mid_y, heavy);
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
}
// More mixed T-junctions
'┞' => {
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
hline(&mut bitmap, mid_x, w, mid_y, light);
}
'┟' => {
vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy);
vline(&mut bitmap, mid_y, h, mid_x, light);
hline(&mut bitmap, mid_x, w, mid_y, light);
}
'┡' => {
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
hline(&mut bitmap, mid_x, w, mid_y, heavy);
}
'┢' => {
vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy);
vline(&mut bitmap, mid_y, h, mid_x, light);
hline(&mut bitmap, mid_x, w, mid_y, heavy);
}
'┦' => {
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
hline(&mut bitmap, 0, mid_x + 1, mid_y, light);
}
'┧' => {
vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy);
vline(&mut bitmap, mid_y, h, mid_x, light);
hline(&mut bitmap, 0, mid_x + 1, mid_y, light);
}
'┩' => {
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy);
}
'┪' => {
vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy);
vline(&mut bitmap, mid_y, h, mid_x, light);
hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy);
}
'┭' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, light);
hline(&mut bitmap, mid_x, w, mid_y, heavy);
vline(&mut bitmap, mid_y, h, mid_x, light);
}
'┮' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy);
hline(&mut bitmap, mid_x, w, mid_y, light);
vline(&mut bitmap, mid_y, h, mid_x, light);
}
'┱' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, light);
hline(&mut bitmap, mid_x, w, mid_y, heavy);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
}
'┲' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy);
hline(&mut bitmap, mid_x, w, mid_y, light);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
}
'┵' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, light);
hline(&mut bitmap, mid_x, w, mid_y, heavy);
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
}
'┶' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy);
hline(&mut bitmap, mid_x, w, mid_y, light);
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
}
'┹' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, light);
hline(&mut bitmap, mid_x, w, mid_y, heavy);
vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy);
}
'┺' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy);
hline(&mut bitmap, mid_x, w, mid_y, light);
vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy);
}
// Mixed crosses
'╀' => {
hline(&mut bitmap, 0, w, mid_y, light);
vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy);
vline(&mut bitmap, mid_y, h, mid_x, light);
}
'╁' => {
hline(&mut bitmap, 0, w, mid_y, light);
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
}
'╂' => {
hline(&mut bitmap, 0, w, mid_y, light);
vline(&mut bitmap, 0, h, mid_x, heavy);
}
'╃' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy);
hline(&mut bitmap, mid_x, w, mid_y, light);
vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy);
vline(&mut bitmap, mid_y, h, mid_x, light);
}
'╄' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, light);
hline(&mut bitmap, mid_x, w, mid_y, heavy);
vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy);
vline(&mut bitmap, mid_y, h, mid_x, light);
}
'╅' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy);
hline(&mut bitmap, mid_x, w, mid_y, light);
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
}
'╆' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, light);
hline(&mut bitmap, mid_x, w, mid_y, heavy);
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
}
'╇' => {
hline(&mut bitmap, 0, w, mid_y, heavy);
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
}
'╈' => {
hline(&mut bitmap, 0, w, mid_y, heavy);
vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy);
vline(&mut bitmap, mid_y, h, mid_x, light);
}
'╉' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, light);
hline(&mut bitmap, mid_x, w, mid_y, heavy);
vline(&mut bitmap, 0, h, mid_x, heavy);
}
'╊' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy);
hline(&mut bitmap, mid_x, w, mid_y, light);
vline(&mut bitmap, 0, h, mid_x, heavy);
}
// ═══════════════════════════════════════════════════════════════
// DOUBLE LINES
// ═══════════════════════════════════════════════════════════════
'═' => {
hline(&mut bitmap, 0, w, mid_y.saturating_sub(double_off), light);
hline(&mut bitmap, 0, w, mid_y + double_off, light);
}
'║' => {
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);
hline(&mut bitmap, mid_x + double_off, w, mid_y + double_off, light);
vline(&mut bitmap, mid_y, h, mid_x.saturating_sub(double_off), light);
vline(&mut bitmap, mid_y.saturating_sub(double_off), h, mid_x + double_off, light);
}
'╗' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y.saturating_sub(double_off), light);
hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light);
vline(&mut bitmap, mid_y, h, mid_x + double_off, light);
vline(&mut bitmap, mid_y.saturating_sub(double_off), h, mid_x.saturating_sub(double_off), light);
}
'╚' => {
hline(&mut bitmap, mid_x, w, mid_y + double_off, light);
hline(&mut bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light);
vline(&mut bitmap, 0, mid_y + 1, mid_x.saturating_sub(double_off), light);
vline(&mut bitmap, 0, mid_y + double_off + 1, mid_x + double_off, light);
}
'╝' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y + double_off, light);
hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light);
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);
vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x + double_off, light);
vline(&mut bitmap, mid_y + double_off, h, mid_x + double_off, light);
hline(&mut bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light);
hline(&mut bitmap, mid_x + double_off, w, mid_y + double_off, light);
}
'╣' => {
vline(&mut bitmap, 0, h, mid_x + double_off, light);
vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x.saturating_sub(double_off), light);
vline(&mut bitmap, mid_y + double_off, h, mid_x.saturating_sub(double_off), light);
hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light);
hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light);
}
'╦' => {
hline(&mut bitmap, 0, w, mid_y.saturating_sub(double_off), light);
hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light);
hline(&mut bitmap, mid_x + double_off, w, mid_y + double_off, light);
vline(&mut bitmap, mid_y + double_off, h, mid_x.saturating_sub(double_off), light);
vline(&mut bitmap, mid_y + double_off, h, mid_x + double_off, light);
}
'╩' => {
hline(&mut bitmap, 0, w, mid_y + double_off, light);
hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light);
hline(&mut bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light);
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);
vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x + double_off, light);
vline(&mut bitmap, mid_y + double_off, h, mid_x.saturating_sub(double_off), light);
vline(&mut bitmap, mid_y + double_off, h, mid_x + double_off, light);
hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light);
hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light);
hline(&mut bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light);
hline(&mut bitmap, mid_x + double_off, w, mid_y + double_off, light);
}
// ═══════════════════════════════════════════════════════════════
// SINGLE/DOUBLE MIXED
// ═══════════════════════════════════════════════════════════════
// Single horizontal, double vertical corners
'╒' => {
hline(&mut bitmap, mid_x + double_off, w, mid_y, light);
vline(&mut bitmap, mid_y, h, mid_x.saturating_sub(double_off), light);
vline(&mut bitmap, mid_y, h, mid_x + double_off, light);
}
'╓' => {
hline(&mut bitmap, mid_x, w, mid_y, light);
vline(&mut bitmap, mid_y, h, mid_x, light);
}
'╕' => {
hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light);
vline(&mut bitmap, mid_y, h, mid_x.saturating_sub(double_off), light);
vline(&mut bitmap, mid_y, h, mid_x + double_off, light);
}
'╖' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, light);
vline(&mut bitmap, mid_y, h, mid_x, light);
}
'╘' => {
hline(&mut bitmap, mid_x + double_off, w, mid_y, light);
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);
}
'╙' => {
hline(&mut bitmap, mid_x, w, mid_y, light);
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
}
'╛' => {
hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light);
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);
}
'╜' => {
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);
vline(&mut bitmap, 0, h, mid_x + double_off, light);
hline(&mut bitmap, mid_x + double_off, w, mid_y, light);
}
'╟' => {
vline(&mut bitmap, 0, h, mid_x, light);
hline(&mut bitmap, mid_x, w, mid_y.saturating_sub(double_off), light);
hline(&mut bitmap, mid_x, w, mid_y + double_off, light);
}
'╡' => {
vline(&mut bitmap, 0, h, mid_x.saturating_sub(double_off), light);
vline(&mut bitmap, 0, h, mid_x + double_off, light);
hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light);
}
'╢' => {
vline(&mut bitmap, 0, h, mid_x, light);
hline(&mut bitmap, 0, mid_x + 1, mid_y.saturating_sub(double_off), light);
hline(&mut bitmap, 0, mid_x + 1, mid_y + double_off, light);
}
'╤' => {
hline(&mut bitmap, 0, w, mid_y.saturating_sub(double_off), light);
hline(&mut bitmap, 0, w, mid_y + double_off, light);
vline(&mut bitmap, mid_y + double_off, h, mid_x, light);
}
'╥' => {
hline(&mut bitmap, 0, w, mid_y, light);
vline(&mut bitmap, mid_y, h, mid_x.saturating_sub(double_off), light);
vline(&mut bitmap, mid_y, h, mid_x + double_off, light);
}
'╧' => {
hline(&mut bitmap, 0, w, mid_y.saturating_sub(double_off), light);
hline(&mut bitmap, 0, w, mid_y + double_off, light);
vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x, light);
}
'╨' => {
hline(&mut bitmap, 0, w, mid_y, light);
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);
hline(&mut bitmap, 0, w, mid_y + double_off, light);
vline(&mut bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x, light);
vline(&mut bitmap, mid_y + double_off, h, mid_x, light);
}
'╫' => {
hline(&mut bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light);
hline(&mut bitmap, mid_x + double_off, w, mid_y, light);
vline(&mut bitmap, 0, h, mid_x.saturating_sub(double_off), light);
vline(&mut bitmap, 0, h, mid_x + double_off, light);
}
// ═══════════════════════════════════════════════════════════════
// 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)
// ╰ = 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 {
'╭' => (false, true), // TOP_LEFT
'╮' => (true, true), // TOP_RIGHT
'╰' => (false, false), // BOTTOM_LEFT
'╯' => (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 {
return if x < edge0 { 0.0 } else { 1.0 };
}
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;
}
let value = (alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
let idx = py * w + px;
if value > bitmap[idx] {
bitmap[idx] = value;
}
}
}
}
// ═══════════════════════════════════════════════════════════════
// DASHED/DOTTED LINES
// ═══════════════════════════════════════════════════════════════
'┄' => {
let seg = w / 8;
for i in 0..4 {
let x1 = i * 2 * seg;
let x2 = (x1 + seg).min(w);
hline(&mut bitmap, x1, x2, mid_y, light);
}
}
'┅' => {
let seg = w / 8;
for i in 0..4 {
let x1 = i * 2 * seg;
let x2 = (x1 + seg).min(w);
hline(&mut bitmap, x1, x2, mid_y, heavy);
}
}
'┆' => {
let seg = h / 8;
for i in 0..4 {
let y1 = i * 2 * seg;
let y2 = (y1 + seg).min(h);
vline(&mut bitmap, y1, y2, mid_x, light);
}
}
'┇' => {
let seg = h / 8;
for i in 0..4 {
let y1 = i * 2 * seg;
let y2 = (y1 + seg).min(h);
vline(&mut bitmap, y1, y2, mid_x, heavy);
}
}
'┈' => {
let seg = w / 12;
for i in 0..6 {
let x1 = i * 2 * seg;
let x2 = (x1 + seg).min(w);
hline(&mut bitmap, x1, x2, mid_y, light);
}
}
'┉' => {
let seg = w / 12;
for i in 0..6 {
let x1 = i * 2 * seg;
let x2 = (x1 + seg).min(w);
hline(&mut bitmap, x1, x2, mid_y, heavy);
}
}
'┊' => {
let seg = h / 12;
for i in 0..6 {
let y1 = i * 2 * seg;
let y2 = (y1 + seg).min(h);
vline(&mut bitmap, y1, y2, mid_x, light);
}
}
'┋' => {
let seg = h / 12;
for i in 0..6 {
let y1 = i * 2 * seg;
let y2 = (y1 + seg).min(h);
vline(&mut bitmap, y1, y2, mid_x, heavy);
}
}
// Double dashed
'╌' => {
let seg = w / 4;
hline(&mut bitmap, 0, seg, mid_y, light);
hline(&mut bitmap, seg * 2, seg * 3, mid_y, light);
}
'╍' => {
let seg = w / 4;
hline(&mut bitmap, 0, seg, mid_y, heavy);
hline(&mut bitmap, seg * 2, seg * 3, mid_y, heavy);
}
'╎' => {
let seg = h / 4;
vline(&mut bitmap, 0, seg, mid_x, light);
vline(&mut bitmap, seg * 2, seg * 3, mid_x, light);
}
'╏' => {
let seg = h / 4;
vline(&mut bitmap, 0, seg, mid_x, heavy);
vline(&mut bitmap, seg * 2, seg * 3, mid_x, heavy);
}
// ═══════════════════════════════════════════════════════════════
// 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),
'╷' => vline(&mut bitmap, mid_y, h, mid_x, light),
'╸' => hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy),
'╹' => 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);
hline(&mut bitmap, mid_x, w, mid_y, heavy);
}
'╽' => {
vline(&mut bitmap, 0, mid_y + 1, mid_x, light);
vline(&mut bitmap, mid_y, h, mid_x, heavy);
}
'╾' => {
hline(&mut bitmap, 0, mid_x + 1, mid_y, heavy);
hline(&mut bitmap, mid_x, w, mid_y, light);
}
'╿' => {
vline(&mut bitmap, 0, mid_y + 1, mid_x, heavy);
vline(&mut bitmap, mid_y, h, mid_x, light);
}
// ═══════════════════════════════════════════════════════════════
// DIAGONAL LINES
// ═══════════════════════════════════════════════════════════════
'' => {
for i in 0..w.max(h) {
let x = w.saturating_sub(1).saturating_sub(i * w / h.max(1));
let y = i * h / w.max(1);
if x < w && y < h {
for t in 0..light {
if x + t < w { bitmap[y * w + x + t] = 255; }
}
}
}
}
'╲' => {
for i in 0..w.max(h) {
let x = i * w / h.max(1);
let y = i * h / w.max(1);
if x < w && y < h {
for t in 0..light {
if x + t < w { bitmap[y * w + x + t] = 255; }
}
}
}
}
'' => {
// Draw both diagonals
for i in 0..w.max(h) {
let x1 = w.saturating_sub(1).saturating_sub(i * w / h.max(1));
let x2 = i * w / h.max(1);
let y = i * h / w.max(1);
if y < h {
for t in 0..light {
if x1 + t < w { bitmap[y * w + x1 + t] = 255; }
if x2 + t < w { bitmap[y * w + x2 + t] = 255; }
}
}
}
}
// ═══════════════════════════════════════════════════════════════
// 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),
'▃' => fill_rect(&mut bitmap, 0, h * 5 / 8, w, h),
'▄' => fill_rect(&mut bitmap, 0, h / 2, w, h),
'▅' => fill_rect(&mut bitmap, 0, h * 3 / 8, w, h),
'▆' => fill_rect(&mut bitmap, 0, h / 4, w, h),
'▇' => fill_rect(&mut bitmap, 0, h / 8, w, h),
'█' => fill_rect(&mut bitmap, 0, 0, w, h),
'▉' => fill_rect(&mut bitmap, 0, 0, w * 7 / 8, h),
'▊' => fill_rect(&mut bitmap, 0, 0, w * 3 / 4, h),
'▋' => fill_rect(&mut bitmap, 0, 0, w * 5 / 8, h),
'▌' => fill_rect(&mut bitmap, 0, 0, w / 2, h),
'▍' => fill_rect(&mut bitmap, 0, 0, w * 3 / 8, h),
'▎' => 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 {
for x in 0..w {
if (x + y) % 4 == 0 { bitmap[y * w + x] = 255; }
}
}
}
'▒' => {
for y in 0..h {
for x in 0..w {
if (x + y) % 2 == 0 { bitmap[y * w + x] = 255; }
}
}
}
'▓' => {
for y in 0..h {
for x in 0..w {
if (x + y) % 4 != 0 { bitmap[y * w + x] = 255; }
}
}
}
// 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),
'▘' => fill_rect(&mut bitmap, 0, 0, w / 2, h / 2),
'▙' => {
fill_rect(&mut bitmap, 0, 0, w / 2, h);
fill_rect(&mut bitmap, w / 2, h / 2, w, h);
}
'▚' => {
fill_rect(&mut bitmap, 0, 0, w / 2, h / 2);
fill_rect(&mut bitmap, w / 2, h / 2, w, h);
}
'▛' => {
fill_rect(&mut bitmap, 0, 0, w, h / 2);
fill_rect(&mut bitmap, 0, h / 2, w / 2, h);
}
'▜' => {
fill_rect(&mut bitmap, 0, 0, w, h / 2);
fill_rect(&mut bitmap, w / 2, h / 2, w, h);
}
'▝' => fill_rect(&mut bitmap, w / 2, 0, w, h / 2),
'▞' => {
fill_rect(&mut bitmap, w / 2, 0, w, h / 2);
fill_rect(&mut bitmap, 0, h / 2, w / 2, h);
}
'▟' => {
fill_rect(&mut bitmap, w / 2, 0, w, h);
fill_rect(&mut bitmap, 0, h / 2, w / 2, h);
}
// ═══════════════════════════════════════════════════════════════
// 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 {
// Kitty's distribute_dots algorithm
// For horizontal: 2 dots across width
// 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];
let mut extra = w.saturating_sub(2 * num_x_dots * dot_width);
let mut idx = 0;
while extra > 0 {
x_gaps[idx] += 1;
idx = (idx + 1) % num_x_dots;
extra -= 1;
}
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];
let mut extra = h.saturating_sub(2 * num_y_dots * dot_height);
let mut idx = 0;
while extra > 0 {
y_gaps[idx] += 1;
idx = (idx + 1) % num_y_dots;
extra -= 1;
}
y_gaps[0] /= 2;
let y_summed: [usize; 4] = [
y_gaps[0],
y_gaps[0] + y_gaps[1],
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
// row 0: dot1 dot4
// row 1: dot2 dot5
// row 2: dot3 dot6
// row 3: dot7 dot8
for bit in 0u8..8 {
if which & (1 << bit) != 0 {
let q = bit + 1;
let col = match q {
1 | 2 | 3 | 7 => 0,
_ => 1,
};
let row = match q {
1 | 4 => 0,
2 | 5 => 1,
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);
for py in y_start..y_end {
for px in x_start..x_end {
bitmap[py * w + px] = 255;
}
}
}
}
}
}
}
// ═══════════════════════════════════════════════════════════════
// POWERLINE SYMBOLS (U+E0B0-U+E0BF)
// Ported from Kitty's decorations.c with proper DPI scaling
// ═══════════════════════════════════════════════════════════════
// E0B0: Right-pointing solid triangle
'\u{E0B0}' => {
let mut canvas = SupersampledCanvas::new(w, h);
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);
let thickness = (self.box_thickness(1) * SupersampledCanvas::FACTOR as f64).round() as usize;
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);
let thickness = (self.box_thickness(1) * SupersampledCanvas::FACTOR as f64).round() as usize;
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);
let thickness = self.box_thickness(1) * SupersampledCanvas::FACTOR as f64;
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);
let thickness = self.box_thickness(1) * SupersampledCanvas::FACTOR as f64;
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);
canvas.fill_corner_triangle(Corner::BottomLeft, false);
canvas.downsample(&mut bitmap); supersampled = true;
}
'\u{E0B9}' => {
let mut canvas = SupersampledCanvas::new(w, h);
canvas.fill_corner_triangle(Corner::BottomLeft, true);
canvas.downsample(&mut bitmap); supersampled = true;
}
'\u{E0BA}' => {
let mut canvas = SupersampledCanvas::new(w, h);
canvas.fill_corner_triangle(Corner::TopLeft, false);
canvas.downsample(&mut bitmap); supersampled = true;
}
'\u{E0BB}' => {
let mut canvas = SupersampledCanvas::new(w, h);
canvas.fill_corner_triangle(Corner::TopLeft, true);
canvas.downsample(&mut bitmap); supersampled = true;
}
'\u{E0BC}' => {
let mut canvas = SupersampledCanvas::new(w, h);
canvas.fill_corner_triangle(Corner::BottomRight, false);
canvas.downsample(&mut bitmap); supersampled = true;
}
'\u{E0BD}' => {
let mut canvas = SupersampledCanvas::new(w, h);
canvas.fill_corner_triangle(Corner::BottomRight, true);
canvas.downsample(&mut bitmap); supersampled = true;
}
'\u{E0BE}' => {
let mut canvas = SupersampledCanvas::new(w, h);
canvas.fill_corner_triangle(Corner::TopRight, false);
canvas.downsample(&mut bitmap); supersampled = true;
}
'\u{E0BF}' => {
let mut canvas = SupersampledCanvas::new(w, h);
canvas.fill_corner_triangle(Corner::TopRight, true);
canvas.downsample(&mut bitmap); supersampled = true;
}
// ═══════════════════════════════════════════════════════════════
// 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);
let line_width = self.box_thickness(1) * SupersampledCanvas::FACTOR as f64;
let half_line = line_width / 2.0;
let cx = canvas.ss_width as f64 / 2.0;
let cy = canvas.ss_height as f64 / 2.0;
let radius = 0.0_f64.max(cx.min(cy) - half_line);
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);
let cx = canvas.ss_width as f64 / 2.0;
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);
let line_width = self.box_thickness(1) * SupersampledCanvas::FACTOR as f64;
let half_line = 0.5_f64.max(line_width / 2.0);
let cx = canvas.ss_width as f64 / 2.0;
let cy = canvas.ss_height as f64 / 2.0;
let radius = 0.0_f64.max(cx.min(cy) - half_line);
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);
let line_width = self.box_thickness(1) * SupersampledCanvas::FACTOR as f64;
let half_line = 0.5_f64.max(line_width / 2.0);
let cx = canvas.ss_width as f64 / 2.0;
let cy = canvas.ss_height as f64 / 2.0;
let radius = 0.0_f64.max(cx.min(cy) - half_line);
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);
let line_width = self.box_thickness(1) * SupersampledCanvas::FACTOR as f64;
let half_line = 0.5_f64.max(line_width / 2.0);
let cx = canvas.ss_width as f64 / 2.0;
let cy = canvas.ss_height as f64 / 2.0;
let radius = 0.0_f64.max(cx.min(cy) - half_line);
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);
let line_width = self.box_thickness(1) * SupersampledCanvas::FACTOR as f64;
let half_line = 0.5_f64.max(line_width / 2.0);
let cx = canvas.ss_width as f64 / 2.0;
let cy = canvas.ss_height as f64 / 2.0;
let radius = 0.0_f64.max(cx.min(cy) - half_line);
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);
let line_width = self.box_thickness(1) * SupersampledCanvas::FACTOR as f64;
let half_line = 0.5_f64.max(line_width / 2.0);
let cx = canvas.ss_width as f64 / 2.0;
let cy = canvas.ss_height as f64 / 2.0;
let radius = 0.0_f64.max(cx.min(cy) - half_line);
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);
let line_width = self.box_thickness(1) * SupersampledCanvas::FACTOR as f64;
let half_line = 0.5_f64.max(line_width / 2.0);
let cx = canvas.ss_width as f64 / 2.0;
let cy = canvas.ss_height as f64 / 2.0;
let radius = 0.0_f64.max(cx.min(cy) - half_line);
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,
}
Some((bitmap, supersampled))
}
/// Get or rasterize a glyph by character, with font fallback.
/// Returns the GlyphInfo for the character.
fn rasterize_char(&mut self, c: char) -> GlyphInfo {
// Check cache first
if let Some(info) = self.char_cache.get(&c) {
// Log cache hits for emoji to debug first-emoji issue
if info.is_colored {
log::debug!("CACHE HIT for color glyph U+{:04X} '{}'", c as u32, c);
}
return *info;
}
log::debug!("CACHE MISS for U+{:04X} '{}' - will rasterize", c as u32, c);
// Check if this is a box-drawing character - render procedurally
// Box-drawing characters are already cell-sized, positioned at (0,0)
if Self::is_box_drawing(c) {
if let Some((bitmap, _supersampled)) = self.render_box_char(c) {
// Box-drawing bitmaps are already cell-sized and fill from top-left.
// Use upload_cell_canvas_to_atlas directly since no repositioning needed.
let info = self.upload_cell_canvas_to_atlas(&bitmap, false);
self.char_cache.insert(c, info);
return info;
}
}
// Check if this is an emoji BEFORE checking primary font.
// Like Kitty, we skip the primary font for emoji since it may report a glyph
// (tofu/fallback) that isn't a proper color emoji. Go straight to fontconfig.
let char_str = c.to_string();
let is_emoji = emojis::get(&char_str).is_some();
// Track whether we found the glyph in a regular font
let mut found_in_regular_font = false;
// Rasterize glyph data: (width, height, bitmap, offset_x, offset_y)
let raster_result: Option<(u32, u32, Vec<u8>, f32, f32)> = if is_emoji {
// Emoji: skip primary font, will be handled by fontconfig color font path below
log::debug!("Character U+{:04X} is emoji, skipping primary font check", c as u32);
None
} else if { let glyph_id = self.primary_font.glyph_id(c); glyph_id.0 != 0 } {
// Primary font has this glyph (non-emoji)
let glyph_id = self.primary_font.glyph_id(c);
found_in_regular_font = true;
self.rasterize_glyph_ab(&self.primary_font.clone(), glyph_id)
} else {
// Try already-loaded fallback fonts first (but NOT for emoji)
let mut result = None;
if !is_emoji {
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);
found_in_regular_font = true;
break;
}
}
}
// If no cached fallback has the glyph (or it's emoji), 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
// This now also tells us if the font is a color font
if let Some((path, is_color_font)) = find_font_for_char(fc, c) {
// If fontconfig returns a COLOR font, use Cairo to render it
// (ab_glyph can't render color glyphs from COLR/CBDT/sbix fonts)
if is_color_font {
log::debug!("Fontconfig found color font for U+{:04X}, using Cairo renderer", c as u32);
// Render color glyph in a separate scope to release borrow before atlas ops
let color_glyph_data: Option<(u32, u32, Vec<u8>, f32, f32)> = {
let mut renderer_cell = self.color_font_renderer.borrow_mut();
if renderer_cell.is_none() {
*renderer_cell = ColorFontRenderer::new().ok();
if renderer_cell.is_some() {
log::debug!("Initialized color font renderer for emoji support");
} else {
log::warn!("Failed to initialize color font renderer");
}
}
if let Some(ref mut renderer) = *renderer_cell {
log::debug!("Attempting to render color glyph for U+{:04X} with font_size={}, cell={}x{}",
c as u32, self.font_size, self.cell_width as u32, self.cell_height as u32);
renderer.render_color_glyph(
&path, c, self.font_size, self.cell_width as u32, self.cell_height as u32
)
} else {
None
}
}; // renderer_cell borrow ends here
if let Some((w, h, rgba, ox, oy)) = color_glyph_data {
log::debug!("Successfully rendered color glyph U+{:04X}: {}x{} pixels, offset=({}, {})",
c as u32, w, h, ox, oy);
// Place the color glyph in a cell-sized canvas at baseline
let canvas = self.place_color_glyph_in_cell_canvas(
&rgba, w, h, ox, oy
);
let info = self.upload_cell_canvas_to_atlas(&canvas, true);
self.char_cache.insert(c, info);
return info;
}
// If color rendering failed, fall through to try ab_glyph
log::debug!("Color rendering failed for U+{:04X}, trying ab_glyph fallback", c as u32);
}
// Non-color font or color rendering failed: use ab_glyph
// 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);
found_in_regular_font = true;
}
// Cache the font for future use
// SAFETY: We're storing data alongside the FontRef that borrows it
let font_static: FontRef<'static> = unsafe { std::mem::transmute(font) };
self.fallback_fonts.push((data, font_static));
}
}
}
}
}
}
// Don't fall back to .notdef yet - we may still try color fonts below
result
};
// If no regular font has this glyph, try color fonts (emoji) as last resort
// This handles cases where no font at all was found via normal fontconfig
if !found_in_regular_font {
log::debug!("Character U+{:04X} '{}' not found in regular fonts, trying dedicated color font query", c as u32, c);
// Check color font cache or query fontconfig for color font explicitly
let color_path = self.color_font_cache.entry(c).or_insert_with(|| {
let path = find_color_font_for_char(c);
log::debug!("Fontconfig color font query for U+{:04X}: {:?}", c as u32, path);
path
}).clone();
if let Some(ref path) = color_path {
log::debug!("Found color font for U+{:04X}: {:?}", c as u32, path);
// Render color glyph in a separate scope to release borrow before atlas ops
let color_glyph_data: Option<(u32, u32, Vec<u8>, f32, f32)> = {
let mut renderer_cell = self.color_font_renderer.borrow_mut();
if renderer_cell.is_none() {
*renderer_cell = ColorFontRenderer::new().ok();
if renderer_cell.is_some() {
log::debug!("Initialized color font renderer for emoji support");
} else {
log::warn!("Failed to initialize color font renderer");
}
}
if let Some(ref mut renderer) = *renderer_cell {
log::debug!("Attempting to render color glyph for U+{:04X} with font_size={}, cell={}x{}",
c as u32, self.font_size, self.cell_width as u32, self.cell_height as u32);
renderer.render_color_glyph(
path, c, self.font_size, self.cell_width as u32, self.cell_height as u32
)
} else {
None
}
}; // renderer_cell borrow ends here
if let Some((w, h, rgba, ox, oy)) = color_glyph_data {
log::debug!("Successfully rendered color glyph U+{:04X}: {}x{} pixels, offset=({}, {})",
c as u32, w, h, ox, oy);
// Place the color glyph in a cell-sized canvas at baseline
let canvas = self.place_color_glyph_in_cell_canvas(
&rgba, w, h, ox, oy
);
let info = self.upload_cell_canvas_to_atlas(&canvas, true);
self.char_cache.insert(c, info);
return info;
}
}
}
// Fall back to .notdef from primary font if we still have no glyph
let raster_result = raster_result.or_else(|| {
let notdef_glyph_id = self.primary_font.glyph_id(c);
self.rasterize_glyph_ab(&self.primary_font.clone(), notdef_glyph_id)
});
// 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],
size: [0.0, 0.0],
is_colored: false,
};
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],
size: [0.0, 0.0],
is_colored: false,
};
self.char_cache.insert(c, info);
return info;
}
// Check if this is an oversized symbol glyph that needs rescaling.
// PUA glyphs (Nerd Fonts), dingbats, and other symbols that are wider than
// one cell should be rescaled to fit when rendered standalone (not part of
// a multi-cell group).
let (final_bitmap, final_width, final_height, final_offset_x, final_offset_y) =
if Self::is_multicell_symbol(c) {
let cell_w = self.cell_width;
// Use just the glyph bitmap width for comparison, not offset_x + width
// offset_x is the left bearing which can be negative
let glyph_w = glyph_width as f32;
log::debug!("Scaling check for U+{:04X}: glyph_width={}, cell_width={:.1}, offset_x={:.1}",
c as u32, glyph_width, cell_w, offset_x);
if glyph_w > cell_w {
// Glyph is wider than cell - rescale to fit
// Calculate scale factor to fit within cell width with small margin
let target_width = cell_w * 0.95; // Leave 5% margin
let scale_factor = target_width / glyph_w;
log::debug!("Scaling U+{:04X} by factor {:.2} (glyph_w={:.1} > cell_w={:.1})",
c as u32, scale_factor, glyph_w, cell_w);
// Rescale bitmap using simple nearest-neighbor (good enough for icons)
let new_width = (glyph_width as f32 * scale_factor).ceil() as u32;
let new_height = (glyph_height as f32 * scale_factor).ceil() as u32;
if new_width > 0 && new_height > 0 {
let mut scaled_bitmap = vec![0u8; (new_width * new_height) as usize];
for y in 0..new_height {
for x in 0..new_width {
// Map to source coordinates
let src_x = ((x as f32 / scale_factor) as u32).min(glyph_width - 1);
let src_y = ((y as f32 / scale_factor) as u32).min(glyph_height - 1);
let src_idx = (src_y * glyph_width + src_x) as usize;
let dst_idx = (y * new_width + x) as usize;
scaled_bitmap[dst_idx] = bitmap[src_idx];
}
}
// Adjust offset to center the scaled glyph
let new_offset_x = (cell_w - new_width as f32) / 2.0;
let new_offset_y = offset_y * scale_factor;
(scaled_bitmap, new_width, new_height, new_offset_x, new_offset_y)
} else {
(bitmap, glyph_width, glyph_height, offset_x, offset_y)
}
} else {
(bitmap, glyph_width, glyph_height, offset_x, offset_y)
}
} else {
(bitmap, glyph_width, glyph_height, offset_x, offset_y)
};
// Place the glyph in a cell-sized canvas at the correct baseline position
let canvas = self.place_glyph_in_cell_canvas(
&final_bitmap, final_width, final_height, final_offset_x, final_offset_y
);
let info = self.upload_cell_canvas_to_atlas(&canvas, false);
self.char_cache.insert(c, info);
info
}
/// Rasterize a PUA character into a multi-cell canvas and return GlyphInfo for each cell.
/// This is used when a PUA glyph is followed by space(s) - the glyph spans multiple cells.
///
/// Like Kitty's approach:
/// 1. Render the glyph to a canvas sized for `num_cells` cells
/// 2. Center the glyph horizontally within the canvas
/// 3. Extract each cell's portion as a separate sprite
///
/// Returns a Vec of GlyphInfo, one for each cell.
fn rasterize_pua_multicell(&mut self, c: char, num_cells: usize) -> Vec<GlyphInfo> {
let cell_w = self.cell_width.ceil() as usize;
let cell_h = self.cell_height.ceil() as usize;
let canvas_width = cell_w * num_cells;
// First, rasterize the glyph at full size
let raster_result: Option<(u32, u32, Vec<u8>, f32, f32)> = {
let glyph_id = self.primary_font.glyph_id(c);
if glyph_id.0 != 0 {
self.rasterize_glyph_ab(&self.primary_font.clone(), glyph_id)
} else {
// Try fallback fonts
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;
}
}
result
}
};
let Some((glyph_width, glyph_height, bitmap, _offset_x, offset_y)) = raster_result else {
// Empty glyph - return empty sprites for each cell
return vec![GlyphInfo {
uv: [0.0, 0.0, 0.0, 0.0],
size: [0.0, 0.0],
is_colored: false,
}; num_cells];
};
if bitmap.is_empty() || glyph_width == 0 || glyph_height == 0 {
return vec![GlyphInfo {
uv: [0.0, 0.0, 0.0, 0.0],
size: [0.0, 0.0],
is_colored: false,
}; num_cells];
}
// Create a multi-cell canvas
let mut canvas = vec![0u8; canvas_width * cell_h];
// Position glyph at x=0 (left-aligned), like Kitty's model where
// glyphs are positioned at origin without offset adjustments
let dest_x = 0i32;
// Calculate vertical position using baseline, same as single-cell rendering
// dest_y = baseline - glyph_height - offset_y
let dest_y = (self.baseline - glyph_height as f32 - offset_y).round() as i32;
// Copy glyph bitmap to the multi-cell canvas
for gy in 0..glyph_height as i32 {
let cy = dest_y + gy;
if cy < 0 || cy >= cell_h as i32 {
continue;
}
for gx in 0..glyph_width as i32 {
let cx = dest_x + gx;
if cx < 0 || cx >= canvas_width as i32 {
continue;
}
let src_idx = (gy as u32 * glyph_width + gx as u32) as usize;
let dst_idx = cy as usize * canvas_width + cx as usize;
canvas[dst_idx] = canvas[dst_idx].max(bitmap[src_idx]);
}
}
// Extract each cell's portion as a separate sprite
let mut sprites = Vec::with_capacity(num_cells);
for cell_idx in 0..num_cells {
// Extract this cell's portion from the canvas
let mut cell_canvas = vec![0u8; cell_w * cell_h];
let cell_start_x = cell_idx * cell_w;
for y in 0..cell_h {
for x in 0..cell_w {
let src_idx = y * canvas_width + cell_start_x + x;
let dst_idx = y * cell_w + x;
cell_canvas[dst_idx] = canvas[src_idx];
}
}
// Upload this cell's sprite to the atlas
let info = self.upload_cell_canvas_to_atlas(&cell_canvas, false);
sprites.push(info);
}
sprites
}
/// Rasterize an emoji into a multi-cell canvas and return GlyphInfo for each cell.
/// This uses the Cairo color font renderer since emoji are color glyphs.
///
/// Returns a Vec of GlyphInfo, one for each cell.
fn rasterize_emoji_multicell(&mut self, c: char, num_cells: usize) -> Vec<GlyphInfo> {
let cell_w = self.cell_width.ceil() as usize;
let cell_h = self.cell_height.ceil() as usize;
let canvas_width = cell_w * num_cells;
// Find a color font for this emoji (find_color_font_for_char handles fontconfig internally)
let Some(font_path) = find_color_font_for_char(c) else {
log::debug!("No color font found for emoji U+{:04X}", c as u32);
return vec![GlyphInfo {
uv: [0.0, 0.0, 0.0, 0.0],
size: [0.0, 0.0],
is_colored: true,
}; num_cells];
};
// Render the emoji using Cairo at full multi-cell size
let color_glyph_data: Option<(u32, u32, Vec<u8>, f32, f32)> = {
let mut renderer_cell = self.color_font_renderer.borrow_mut();
if renderer_cell.is_none() {
*renderer_cell = ColorFontRenderer::new().ok();
}
if let Some(ref mut renderer) = *renderer_cell {
// Render at multi-cell width
renderer.render_color_glyph(
&font_path, c, self.font_size,
(cell_w * num_cells) as u32, cell_h as u32
)
} else {
None
}
};
let Some((glyph_width, glyph_height, rgba, offset_x, offset_y)) = color_glyph_data else {
log::debug!("Failed to render emoji U+{:04X}", c as u32);
return vec![GlyphInfo {
uv: [0.0, 0.0, 0.0, 0.0],
size: [0.0, 0.0],
is_colored: true,
}; num_cells];
};
if rgba.is_empty() || glyph_width == 0 || glyph_height == 0 {
return vec![GlyphInfo {
uv: [0.0, 0.0, 0.0, 0.0],
size: [0.0, 0.0],
is_colored: true,
}; num_cells];
}
// Create a multi-cell RGBA canvas
let mut canvas = vec![0u8; canvas_width * cell_h * 4];
// Position the glyph - for color glyphs, offset_y is ascent (distance from baseline to TOP)
let dest_x = offset_x.round() as i32;
let dest_y = (self.baseline - offset_y).round() as i32;
// Copy the RGBA bitmap to the multi-cell canvas
for gy in 0..glyph_height as i32 {
let cy = dest_y + gy;
if cy < 0 || cy >= cell_h as i32 {
continue;
}
for gx in 0..glyph_width as i32 {
let cx = dest_x + gx;
if cx < 0 || cx >= canvas_width as i32 {
continue;
}
let src_idx = (gy as u32 * glyph_width + gx as u32) as usize * 4;
let dst_idx = (cy as usize * canvas_width + cx as usize) * 4;
if src_idx + 3 < rgba.len() && dst_idx + 3 < canvas.len() {
canvas[dst_idx] = rgba[src_idx];
canvas[dst_idx + 1] = rgba[src_idx + 1];
canvas[dst_idx + 2] = rgba[src_idx + 2];
canvas[dst_idx + 3] = rgba[src_idx + 3];
}
}
}
// Extract each cell's portion as a separate sprite
let mut sprites = Vec::with_capacity(num_cells);
for cell_idx in 0..num_cells {
// Extract this cell's RGBA portion from the canvas
let mut cell_canvas = vec![0u8; cell_w * cell_h * 4];
let cell_start_x = cell_idx * cell_w;
for y in 0..cell_h {
for x in 0..cell_w {
let src_idx = (y * canvas_width + cell_start_x + x) * 4;
let dst_idx = (y * cell_w + x) * 4;
if src_idx + 3 < canvas.len() && dst_idx + 3 < cell_canvas.len() {
cell_canvas[dst_idx] = canvas[src_idx];
cell_canvas[dst_idx + 1] = canvas[src_idx + 1];
cell_canvas[dst_idx + 2] = canvas[src_idx + 2];
cell_canvas[dst_idx + 3] = canvas[src_idx + 3];
}
}
}
// Upload this cell's sprite to the atlas (colored = true for RGBA)
let info = self.upload_cell_canvas_to_atlas(&cell_canvas, true);
sprites.push(info);
}
sprites
}
/// Rasterize a glyph using ab_glyph with pixel-perfect alignment.
/// Returns (width, height, bitmap, offset_x, offset_y) or None if glyph has no outline.
/// offset_x is the left bearing (horizontal offset from cursor), snapped to integer pixels
/// offset_y is compatible with fontdue's ymin (distance from baseline to glyph bottom, negative for descenders)
fn rasterize_glyph_ab(&self, font: &FontRef<'_>, glyph_id: GlyphId) -> Option<(u32, u32, Vec<u8>, 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))
}
/// Place a glyph bitmap into a cell-sized canvas at the correct baseline position.
/// This follows Kitty's model where sprites are always cell-sized.
///
/// Parameters:
/// - bitmap: The rasterized glyph bitmap (grayscale)
/// - glyph_width, glyph_height: Dimensions of the bitmap
/// - offset_x: Left bearing (horizontal offset from cell origin)
/// - offset_y: Distance from baseline to glyph bottom (negative = below baseline)
///
/// Returns: Cell-sized canvas with the glyph positioned at baseline
fn place_glyph_in_cell_canvas(
&self,
bitmap: &[u8],
glyph_width: u32,
glyph_height: u32,
offset_x: f32,
offset_y: f32,
) -> Vec<u8> {
let cell_w = self.cell_width.ceil() as usize;
let cell_h = self.cell_height.ceil() as usize;
let mut canvas = vec![0u8; cell_w * cell_h];
// Calculate destination position in the cell canvas.
// baseline is the Y position where the baseline sits (from top of cell).
// offset_y is the distance from baseline to glyph bottom.
// glyph_top = baseline - (glyph_height + offset_y)
// = baseline - glyph_height - offset_y
// Since offset_y can be negative (for descenders), this works correctly.
let dest_x = offset_x.round() as i32;
let dest_y = (self.baseline - glyph_height as f32 - offset_y).round() as i32;
// Copy the glyph bitmap to the canvas, clipping to cell bounds
for gy in 0..glyph_height as i32 {
let cy = dest_y + gy;
if cy < 0 || cy >= cell_h as i32 {
continue;
}
for gx in 0..glyph_width as i32 {
let cx = dest_x + gx;
if cx < 0 || cx >= cell_w as i32 {
continue;
}
let src_idx = (gy as u32 * glyph_width + gx as u32) as usize;
let dst_idx = cy as usize * cell_w + cx as usize;
// Use max to handle overlapping glyphs (shouldn't happen for single chars)
canvas[dst_idx] = canvas[dst_idx].max(bitmap[src_idx]);
}
}
canvas
}
/// Place a colored (RGBA) glyph bitmap into a cell-sized RGBA canvas.
/// Used for emoji and other color glyphs.
fn place_color_glyph_in_cell_canvas(
&self,
bitmap: &[u8],
glyph_width: u32,
glyph_height: u32,
offset_x: f32,
offset_y: f32,
) -> Vec<u8> {
let cell_w = self.cell_width.ceil() as usize;
let cell_h = self.cell_height.ceil() as usize;
let mut canvas = vec![0u8; cell_w * cell_h * 4]; // RGBA
// For color glyphs, offset_y is the ascent (distance from baseline to TOP of glyph)
// So dest_y = baseline - offset_y positions the top of the glyph correctly
let dest_x = offset_x.round() as i32;
let dest_y = (self.baseline - offset_y).round() as i32;
// Copy the RGBA bitmap to the canvas
for gy in 0..glyph_height as i32 {
let cy = dest_y + gy;
if cy < 0 || cy >= cell_h as i32 {
continue;
}
for gx in 0..glyph_width as i32 {
let cx = dest_x + gx;
if cx < 0 || cx >= cell_w as i32 {
continue;
}
let src_idx = (gy as u32 * glyph_width + gx as u32) as usize * 4;
let dst_idx = (cy as usize * cell_w + cx as usize) * 4;
// For color glyphs, just copy the RGBA values
// (could do alpha blending if needed, but single glyph per cell)
if src_idx + 3 < bitmap.len() && dst_idx + 3 < canvas.len() {
canvas[dst_idx] = bitmap[src_idx];
canvas[dst_idx + 1] = bitmap[src_idx + 1];
canvas[dst_idx + 2] = bitmap[src_idx + 2];
canvas[dst_idx + 3] = bitmap[src_idx + 3];
}
}
}
canvas
}
/// Upload a cell-sized grayscale canvas to the atlas.
/// Returns GlyphInfo with UV coordinates pointing to the uploaded sprite.
fn upload_cell_canvas_to_atlas(&mut self, canvas: &[u8], is_colored: bool) -> GlyphInfo {
let cell_w = self.cell_width.ceil() as u32;
let cell_h = self.cell_height.ceil() as u32;
// Check if we need to move to next row
if self.atlas_cursor_x + cell_w > ATLAS_SIZE {
self.atlas_cursor_x = 0;
self.atlas_cursor_y += self.atlas_row_height + 1;
self.atlas_row_height = 0;
}
// Check if atlas is full - reset and retry
if self.atlas_cursor_y + cell_h > ATLAS_SIZE {
self.reset_atlas();
if self.atlas_cursor_x + cell_w > ATLAS_SIZE {
self.atlas_cursor_x = 0;
self.atlas_cursor_y += self.atlas_row_height + 1;
self.atlas_row_height = 0;
}
}
// Copy canvas to atlas
if is_colored {
// RGBA canvas - copy directly
for y in 0..cell_h as usize {
for x in 0..cell_w as usize {
let src_idx = (y * cell_w as usize + x) * 4;
let dst_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) * ATLAS_BPP) as usize;
if src_idx + 3 < canvas.len() && dst_idx + 3 < self.atlas_data.len() {
self.atlas_data[dst_idx] = canvas[src_idx];
self.atlas_data[dst_idx + 1] = canvas[src_idx + 1];
self.atlas_data[dst_idx + 2] = canvas[src_idx + 2];
self.atlas_data[dst_idx + 3] = canvas[src_idx + 3];
}
}
}
} else {
// Grayscale canvas - convert to RGBA (white with alpha)
for y in 0..cell_h as usize {
for x in 0..cell_w as usize {
let src_idx = y * cell_w as usize + x;
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) * ATLAS_BPP) as usize;
if src_idx < canvas.len() && dst_idx + 3 < self.atlas_data.len() {
self.atlas_data[dst_idx] = 255; // R
self.atlas_data[dst_idx + 1] = 255; // G
self.atlas_data[dst_idx + 2] = 255; // B
self.atlas_data[dst_idx + 3] = canvas[src_idx]; // A
}
}
}
}
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_w as f32 / ATLAS_SIZE as f32;
let uv_h = cell_h as f32 / ATLAS_SIZE as f32;
// Update atlas cursor
self.atlas_cursor_x += cell_w + 1;
self.atlas_row_height = self.atlas_row_height.max(cell_h);
GlyphInfo {
uv: [uv_x, uv_y, uv_w, uv_h],
size: [cell_w as f32, cell_h as f32],
is_colored,
}
}
/// Get or rasterize a glyph by its glyph ID from the primary font.
/// Used for ligatures where we have the glyph ID from rustybuzz.
/// Note: Kept for potential fallback use. Use get_glyph_by_id_with_style for styled text.
#[allow(dead_code)]
fn get_glyph_by_id(&mut self, glyph_id: u16) -> GlyphInfo {
// Cache key: (font_style, font_index, glyph_id)
// For now, we use Regular style (0) and primary font index (0)
let cache_key = (FontStyle::Regular as usize, 0usize, glyph_id);
if let Some(info) = self.glyph_cache.get(&cache_key) {
return *info;
}
// 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);
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],
size: [0.0, 0.0],
is_colored: false,
};
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],
size: [0.0, 0.0],
is_colored: false,
};
self.glyph_cache.insert(cache_key, info);
return info;
}
// Place the glyph in a cell-sized canvas at the correct baseline position
let canvas = self.place_glyph_in_cell_canvas(
&bitmap, glyph_width, glyph_height, offset_x, offset_y
);
let info = self.upload_cell_canvas_to_atlas(&canvas, false);
self.glyph_cache.insert(cache_key, info);
info
}
/// Get or rasterize a glyph by its glyph ID from a specific font variant.
/// Uses bold/italic font if available, otherwise falls back to regular.
fn get_glyph_by_id_with_style(&mut self, glyph_id: u16, style: FontStyle) -> GlyphInfo {
// Cache key: (font_style, font_index, glyph_id)
// font_index 0 = primary/regular font
let cache_key = (style as usize, 0usize, glyph_id);
if let Some(info) = self.glyph_cache.get(&cache_key) {
return *info;
}
// Get the font for the requested style
let font = if style == FontStyle::Regular {
self.primary_font.clone()
} else if let Some(ref variant) = self.font_variants[style as usize] {
variant.font.clone()
} else {
// Fall back to regular font if variant not available
self.primary_font.clone()
};
// Rasterize the glyph by ID using ab_glyph
let ab_glyph_id = GlyphId(glyph_id);
let raster_result = self.rasterize_glyph_ab(&font, ab_glyph_id);
let Some((glyph_width, glyph_height, bitmap, offset_x, offset_y)) = raster_result else {
// Empty glyph (e.g., space)
let info = GlyphInfo {
uv: [0.0, 0.0, 0.0, 0.0],
size: [0.0, 0.0],
is_colored: false,
};
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],
size: [0.0, 0.0],
is_colored: false,
};
self.glyph_cache.insert(cache_key, info);
return info;
}
// Place the glyph in a cell-sized canvas at the correct baseline position
let canvas = self.place_glyph_in_cell_canvas(
&bitmap, glyph_width, glyph_height, offset_x, offset_y
);
let info = self.upload_cell_canvas_to_atlas(&canvas, false);
self.glyph_cache.insert(cache_key, info);
info
}
/// Shape a text string using HarfBuzz/rustybuzz.
/// Returns glyph IDs with advances and offsets for texture healing.
/// Note: Kept for potential fallback use. Use shape_text_with_style for styled text.
#[allow(dead_code)]
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<char> = text.chars().collect();
let mut buffer = UnicodeBuffer::new();
buffer.push_str(text);
// Shape with OpenType features enabled (liga, calt, dlig)
let glyph_buffer = rustybuzz::shape(&self.shaping_ctx.face, &self.shaping_ctx.features, buffer);
let glyph_infos = glyph_buffer.glyph_infos();
let glyph_positions = glyph_buffer.glyph_positions();
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 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();
let shaped = ShapedGlyphs {
glyphs,
};
self.ligature_cache.insert(text.to_string(), shaped.clone());
shaped
}
/// Shape a text string using HarfBuzz/rustybuzz with a specific font style.
/// Uses the bold/italic font variant if available, otherwise falls back to regular.
fn shape_text_with_style(&mut self, text: &str, style: FontStyle) -> ShapedGlyphs {
// For now, we'll create a cache key that includes style
// TODO: Could optimize by having separate caches per style
let cache_key = format!("{}\x00{}", style as usize, text);
if let Some(cached) = self.ligature_cache.get(&cache_key) {
return cached.clone();
}
let mut buffer = UnicodeBuffer::new();
buffer.push_str(text);
// Get the face for the requested style, falling back to regular if not available
let face = if style == FontStyle::Regular {
&self.shaping_ctx.face
} else if let Some(ref variant) = self.font_variants[style as usize] {
&variant.face
} else {
// Fall back to regular font
&self.shaping_ctx.face
};
// Shape with OpenType features enabled (liga, calt, dlig)
let glyph_buffer = rustybuzz::shape(face, &self.shaping_features, buffer);
let glyph_infos = glyph_buffer.glyph_infos();
let glyph_positions = glyph_buffer.glyph_positions();
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;
// Note: We don't pre-rasterize here; that happens in render_glyphs_to_canvas_with_style
// Convert from font units to pixels using the correct scale factor.
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();
let shaped = ShapedGlyphs { glyphs };
self.ligature_cache.insert(cache_key, shaped.clone());
shaped
}
/// Convert sRGB component (0.0-1.0) to linear RGB.
/// This is needed because we're rendering to an sRGB surface.
#[inline]
fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
/// Convert pixel X coordinate to NDC, snapped to pixel boundaries.
#[inline]
fn pixel_to_ndc_x(pixel: f32, screen_width: f32) -> f32 {
let snapped = pixel.round();
(snapped / screen_width) * 2.0 - 1.0
}
/// Convert pixel Y coordinate to NDC (inverted), snapped to pixel boundaries.
#[inline]
fn pixel_to_ndc_y(pixel: f32, screen_height: f32) -> f32 {
let snapped = pixel.round();
1.0 - (snapped / screen_height) * 2.0
}
/// Draw a filled rectangle.
fn render_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: [f32; 4]) {
// Add quad to the batch for instanced rendering
if self.quads.len() < self.max_quads {
self.quads.push(Quad {
x,
y,
width: w,
height: h,
color,
});
}
}
/// 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]) {
// Add quad to the overlay batch for instanced rendering (rendered last)
self.overlay_quads.push(Quad {
x,
y,
width: w,
height: h,
color,
});
}
/// Prepare edge glow uniform data for shader-based rendering.
/// Returns the uniform data to be uploaded to the GPU.
/// Prepare combined edge glow uniform data for all active glows.
fn prepare_edge_glow_uniforms(&self, glows: &[EdgeGlow], terminal_y_offset: f32, intensity: f32) -> EdgeGlowUniforms {
// 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 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,
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_glows`: Active edge glow animations for visual feedback
/// - `edge_glow_intensity`: Intensity of edge glow effect (0.0 = disabled, 1.0 = full)
/// - `statusline_content`: Content to render in the statusline
pub fn render_panes(
&mut self,
panes: &[(&Terminal, PaneRenderInfo, Option<(usize, usize, usize, usize)>)],
num_tabs: usize,
active_tab: usize,
edge_glows: &[EdgeGlow],
edge_glow_intensity: f32,
statusline_content: &StatuslineContent,
) -> 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();
self.glyph_vertices.clear();
self.glyph_indices.clear();
self.quads.clear();
self.overlay_quads.clear();
// Check if atlas is getting full and reset proactively
// This prevents mid-render failures and ensures all glyphs can be rendered
let atlas_usage = self.atlas_cursor_y as f32 / ATLAS_SIZE as f32;
if atlas_usage > 0.9 {
self.reset_atlas();
}
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();
// Grid centering offsets - center the cell grid in the window
let grid_x_offset = self.grid_x_offset();
let grid_y_offset = self.grid_y_offset();
// ═══════════════════════════════════════════════════════════════════
// RENDER TAB BAR (same as render_from_terminal)
// ═══════════════════════════════════════════════════════════════════
if self.tab_bar_position != TabBarPosition::Hidden && num_tabs > 0 {
let tab_bar_y = match self.tab_bar_position {
TabBarPosition::Top => 0.0,
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;
[
Self::srgb_to_linear((r as f32 / 255.0) * factor),
Self::srgb_to_linear((g as f32 / 255.0) * factor),
Self::srgb_to_linear((b as f32 / 255.0) * factor),
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;
[
Self::srgb_to_linear(r as f32 / 255.0),
Self::srgb_to_linear(g as f32 / 255.0),
Self::srgb_to_linear(b as f32 / 255.0),
1.0,
]
} 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 };
[
Self::srgb_to_linear(r as f32 / 255.0),
Self::srgb_to_linear(g as f32 / 255.0),
Self::srgb_to_linear(b as f32 / 255.0),
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;
}
let glyph = self.rasterize_char(c);
if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 {
// In Kitty's model, glyphs are cell-sized and positioned at (0,0)
let char_x = text_x + char_idx as f32 * self.cell_width;
let glyph_x = char_x.round();
let glyph_y = text_y.round();
let left = Self::pixel_to_ndc_x(glyph_x, width);
let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], width);
let 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: tab_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: tab_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: tab_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: tab_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,
]);
}
}
tab_x += tab_width + tab_padding;
}
}
// ═══════════════════════════════════════════════════════════════════
// RENDER PANE BORDERS (only between adjacent panes)
// ═══════════════════════════════════════════════════════════════════
let border_thickness = 2.0;
let active_border_color = {
// Use a bright accent color for active pane
let [r, g, b] = self.palette.colors[4]; // Blue from palette
[
Self::srgb_to_linear(r as f32 / 255.0),
Self::srgb_to_linear(g as f32 / 255.0),
Self::srgb_to_linear(b as f32 / 255.0),
1.0,
]
};
let inactive_border_color = {
// Use a dimmer color for inactive panes
let [r, g, b] = self.palette.default_bg;
let factor = 1.5_f32.min(2.0);
[
Self::srgb_to_linear((r as f32 / 255.0) * factor),
Self::srgb_to_linear((g as f32 / 255.0) * factor),
Self::srgb_to_linear((b as f32 / 255.0) * factor),
1.0,
]
};
// Only draw borders if there's more than one pane
// Panes are now flush against each other, so we draw borders at shared edges
// Borders are rendered as overlays so they appear on top of pane content
if panes.len() > 1 {
// Tolerance for detecting adjacent panes (should be touching or very close)
let adjacency_tolerance = 1.0;
// Check each pair of panes to find adjacent ones
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 and grid centering)
let a_x = grid_x_offset + info_a.x;
let a_y = terminal_y_offset + grid_y_offset + info_a.y;
let a_right = a_x + info_a.width;
let a_bottom = a_y + info_a.height;
let b_x = grid_x_offset + info_b.x;
let b_y = terminal_y_offset + grid_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 (panes side by side)
// Pane A is to the left of pane B (A's right edge touches B's left edge)
if (a_right - b_x).abs() < adjacency_tolerance {
// Check if they overlap vertically
let top = a_y.max(b_y);
let bottom = a_bottom.min(b_bottom);
if bottom > top {
// Draw vertical border centered on their shared edge
let border_x = a_right - border_thickness / 2.0;
self.render_overlay_rect(border_x, top, border_thickness, bottom - top, border_color);
}
}
// Pane B is to the left of pane A
if (b_right - a_x).abs() < adjacency_tolerance {
let top = a_y.max(b_y);
let bottom = a_bottom.min(b_bottom);
if bottom > top {
let border_x = b_right - border_thickness / 2.0;
self.render_overlay_rect(border_x, top, border_thickness, bottom - top, border_color);
}
}
// Check for horizontal adjacency (panes stacked)
// Pane A is above pane B (A's bottom edge touches B's top edge)
if (a_bottom - b_y).abs() < adjacency_tolerance {
// Check if they overlap horizontally
let left = a_x.max(b_x);
let right = a_right.min(b_right);
if right > left {
// Draw horizontal border centered on their shared edge
let border_y = a_bottom - border_thickness / 2.0;
self.render_overlay_rect(left, border_y, right - left, border_thickness, border_color);
}
}
// Pane B is above pane A
if (b_bottom - a_y).abs() < adjacency_tolerance {
let left = a_x.max(b_x);
let right = a_right.min(b_right);
if right > left {
let border_y = b_bottom - border_thickness / 2.0;
self.render_overlay_rect(left, border_y, right - left, border_thickness, border_color);
}
}
}
}
}
// ═══════════════════════════════════════════════════════════════════
// RENDER EACH PANE'S CONTENT (Like Kitty's per-window VAO approach)
// ═══════════════════════════════════════════════════════════════════
// Each pane gets its own GPU buffers and bind group.
// We upload all pane data BEFORE starting the render pass,
// then use each pane's bind group during rendering.
struct PaneRenderData {
pane_id: u64,
cols: u32,
rows: u32,
dim_overlay: Option<(f32, f32, f32, f32, [f32; 4])>, // (x, y, w, h, color)
}
let mut pane_render_list: Vec<PaneRenderData> = Vec::new();
// First pass: collect pane data, ensure GPU resources exist, and upload data
for (terminal, info, selection) in panes {
// Apply grid centering offsets to pane position
let pane_x = grid_x_offset + info.x;
let pane_y = terminal_y_offset + grid_y_offset + info.y;
let pane_width = info.width;
let pane_height = info.height;
// Update GPU cells for this terminal (populates self.gpu_cells)
self.update_gpu_cells(terminal);
// Calculate pane dimensions in cells
let cols = (pane_width / self.cell_width).floor() as u32;
let rows = (pane_height / self.cell_height).floor() as u32;
// Use the actual gpu_cells size for buffer allocation (terminal.cols * terminal.rows)
// This may differ from pane pixel dimensions due to rounding
let actual_cells = self.gpu_cells.len();
// Ensure this pane has GPU resources (like Kitty's create_cell_vao)
// This creates or resizes buffers as needed
let _pane_res = self.get_or_create_pane_resources(info.pane_id, actual_cells);
// Build grid params for this pane
let (sel_start_col, sel_start_row, sel_end_col, sel_end_row) = match selection {
Some((sc, sr, ec, er)) => (*sc as i32, *sr as i32, *ec as i32, *er as i32),
None => (-1, -1, -1, -1),
};
let grid_params = GridParams {
cols,
rows,
cell_width: self.cell_width,
cell_height: self.cell_height,
screen_width: self.width as f32,
screen_height: self.height as f32,
x_offset: pane_x,
y_offset: pane_y,
cursor_col: if terminal.cursor_visible { terminal.cursor_col as i32 } else { -1 },
cursor_row: if terminal.cursor_visible { terminal.cursor_row as i32 } else { -1 },
cursor_style: match terminal.cursor_shape {
CursorShape::BlinkingBlock | CursorShape::SteadyBlock => 0,
CursorShape::BlinkingUnderline | CursorShape::SteadyUnderline => 1,
CursorShape::BlinkingBar | CursorShape::SteadyBar => 2,
},
background_opacity: self.background_opacity,
selection_start_col: sel_start_col,
selection_start_row: sel_start_row,
selection_end_col: sel_end_col,
selection_end_row: sel_end_row,
};
// Upload this pane's cell data to its own buffer (like Kitty's send_cell_data_to_gpu)
// This happens BEFORE the render pass, so each pane has its own data
if let Some(pane_res) = self.pane_resources.get(&info.pane_id) {
// Safety check: verify buffer can hold the data
let data_size = self.gpu_cells.len() * std::mem::size_of::<GPUCell>();
let buffer_size = pane_res.capacity * std::mem::size_of::<GPUCell>();
if data_size > buffer_size {
// This shouldn't happen if get_or_create_pane_resources worked correctly
eprintln!(
"BUG: Buffer size mismatch for pane {}: data={} bytes, buffer={} bytes, gpu_cells.len()={}, capacity={}",
info.pane_id, data_size, buffer_size, self.gpu_cells.len(), pane_res.capacity
);
// Skip this pane to avoid crash - will be fixed next frame
continue;
}
self.queue.write_buffer(
&pane_res.cell_buffer,
0,
bytemuck::cast_slice(&self.gpu_cells),
);
self.queue.write_buffer(
&pane_res.grid_params_buffer,
0,
bytemuck::bytes_of(&grid_params),
);
}
// Build dim overlay if needed
let dim_overlay = if info.dim_factor < 1.0 {
let overlay_alpha = 1.0 - info.dim_factor;
let overlay_color = [0.0, 0.0, 0.0, overlay_alpha];
Some((pane_x, pane_y, pane_width, pane_height, overlay_color))
} else {
None
};
pane_render_list.push(PaneRenderData {
pane_id: info.pane_id,
cols,
rows,
dim_overlay,
});
}
// Clean up resources for panes that no longer exist (like Kitty's remove_vao)
let active_pane_ids: std::collections::HashSet<u64> = pane_render_list.iter().map(|p| p.pane_id).collect();
self.cleanup_unused_pane_resources(&active_pane_ids);
// ═══════════════════════════════════════════════════════════════════
// UPLOAD SHARED DATA (color table)
// ═══════════════════════════════════════════════════════════════════
{
let mut color_table_data = [[0.0f32; 4]; 258];
for i in 0..256 {
let [r, g, b] = self.palette.colors[i];
color_table_data[i] = [
Self::srgb_to_linear(r as f32 / 255.0),
Self::srgb_to_linear(g as f32 / 255.0),
Self::srgb_to_linear(b as f32 / 255.0),
1.0,
];
}
let [fg_r, fg_g, fg_b] = self.palette.default_fg;
color_table_data[256] = [
Self::srgb_to_linear(fg_r as f32 / 255.0),
Self::srgb_to_linear(fg_g as f32 / 255.0),
Self::srgb_to_linear(fg_b as f32 / 255.0),
1.0,
];
let [bg_r, bg_g, bg_b] = self.palette.default_bg;
color_table_data[257] = [
Self::srgb_to_linear(bg_r as f32 / 255.0),
Self::srgb_to_linear(bg_g as f32 / 255.0),
Self::srgb_to_linear(bg_b as f32 / 255.0),
1.0,
];
self.queue.write_buffer(&self.color_table_buffer, 0, bytemuck::cast_slice(&color_table_data));
}
// ═══════════════════════════════════════════════════════════════════
// PREPARE STATUSLINE FOR RENDERING (dedicated shader)
// Must happen AFTER pane content rendering so sprite indices are correct
// ═══════════════════════════════════════════════════════════════════
let statusline_cols = {
let statusline_y = self.statusline_y();
// Update statusline GPU cells from content, passing window width for gap expansion
let cols = self.update_statusline_cells(statusline_content, width);
if cols > 0 {
// Upload statusline cells to GPU
self.queue.write_buffer(
&self.statusline_cell_buffer,
0,
bytemuck::cast_slice(&self.statusline_gpu_cells),
);
// Create params for statusline shader
let statusline_params = StatuslineParams {
char_count: cols as u32,
cell_width: self.cell_width,
cell_height: self.cell_height,
screen_width: width,
screen_height: height,
y_offset: statusline_y,
_padding: [0.0, 0.0],
};
// Upload statusline params
self.queue.write_buffer(
&self.statusline_params_buffer,
0,
bytemuck::cast_slice(&[statusline_params]),
);
}
cols
};
// Upload terminal sprites (shared between all panes)
// Must happen after all sprites have been created
// Resize sprite buffer if needed
if !self.sprite_info.is_empty() {
let required_sprites = self.sprite_info.len();
if required_sprites > self.sprite_buffer_capacity {
// Need to resize - create a new larger buffer
let new_capacity = (required_sprites * 3 / 2).max(self.sprite_buffer_capacity * 2);
self.sprite_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Sprite Storage Buffer"),
size: (new_capacity * std::mem::size_of::<SpriteInfo>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.sprite_buffer_capacity = new_capacity;
// Recreate all per-pane bind groups since they reference the sprite buffer
let pane_ids: Vec<u64> = self.pane_resources.keys().cloned().collect();
for pane_id in pane_ids {
if let Some(pane_res) = self.pane_resources.get(&pane_id) {
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(&format!("Pane {} Bind Group", pane_id)),
layout: &self.instanced_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.color_table_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: pane_res.grid_params_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: pane_res.cell_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: self.sprite_buffer.as_entire_binding(),
},
],
});
// Update the bind group in pane_resources
if let Some(pane_res_mut) = self.pane_resources.get_mut(&pane_id) {
pane_res_mut.bind_group = bind_group;
}
}
}
}
self.queue.write_buffer(&self.sprite_buffer, 0, bytemuck::cast_slice(&self.sprite_info));
}
// Upload statusline sprites (separate buffer from terminal)
if !self.statusline_sprite_info.is_empty() {
let required_sprites = self.statusline_sprite_info.len();
if required_sprites > self.statusline_sprite_buffer_capacity {
// Need to resize - create a new larger buffer
let new_capacity = (required_sprites * 3 / 2).max(self.statusline_sprite_buffer_capacity * 2);
self.statusline_sprite_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Statusline Sprite Buffer"),
size: (new_capacity * std::mem::size_of::<SpriteInfo>()) as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.statusline_sprite_buffer_capacity = new_capacity;
// Recreate statusline bind group since it references the sprite buffer
self.statusline_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Statusline Bind Group"),
layout: &self.statusline_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.color_table_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.statusline_params_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.statusline_cell_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: self.statusline_sprite_buffer.as_entire_binding(),
},
],
});
}
self.queue.write_buffer(&self.statusline_sprite_buffer, 0, bytemuck::cast_slice(&self.statusline_sprite_info));
}
// ═══════════════════════════════════════════════════════════════════
// PREPARE IMAGE RENDERS (Kitty Graphics Protocol)
// ═══════════════════════════════════════════════════════════════════
let mut image_renders: Vec<(u32, ImageUniforms)> = Vec::new();
for (terminal, info, _) in panes {
// Apply grid centering offsets to pane position
let pane_x = grid_x_offset + info.x;
let pane_y = terminal_y_offset + grid_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
// ═══════════════════════════════════════════════════════════════════
let bg_vertex_count = self.bg_vertices.len();
let glyph_vertex_count = self.glyph_vertices.len();
let total_vertex_count = bg_vertex_count + glyph_vertex_count;
let total_index_count = self.bg_indices.len() + self.glyph_indices.len();
// Resize buffers if needed
if total_vertex_count > self.vertex_capacity {
self.vertex_capacity = total_vertex_count * 2;
self.vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Glyph Vertex Buffer"),
size: (self.vertex_capacity * std::mem::size_of::<GlyphVertex>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
}
if total_index_count > self.index_capacity {
self.index_capacity = total_index_count * 2;
self.index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Glyph Index Buffer"),
size: (self.index_capacity * std::mem::size_of::<u32>()) as u64,
usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
}
// Upload vertices: bg, then glyph
self.queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&self.bg_vertices));
self.queue.write_buffer(
&self.vertex_buffer,
(bg_vertex_count * std::mem::size_of::<GlyphVertex>()) as u64,
bytemuck::cast_slice(&self.glyph_vertices),
);
// Upload indices: bg, then glyph (adjusted)
self.queue.write_buffer(&self.index_buffer, 0, bytemuck::cast_slice(&self.bg_indices));
let glyph_vertex_offset = bg_vertex_count as u32;
let bg_index_bytes = self.bg_indices.len() * std::mem::size_of::<u32>();
if !self.glyph_indices.is_empty() {
let adjusted_indices: Vec<u32> = self.glyph_indices.iter()
.map(|i| i + glyph_vertex_offset)
.collect();
self.queue.write_buffer(
&self.index_buffer,
bg_index_bytes as u64,
bytemuck::cast_slice(&adjusted_indices),
);
}
// Upload quad params and instances for instanced quad rendering
let quad_params = QuadParams {
screen_width: width,
screen_height: height,
_padding: [0.0, 0.0],
};
self.queue.write_buffer(&self.quad_params_buffer, 0, bytemuck::cast_slice(&[quad_params]));
// Upload quads if we have any
if !self.quads.is_empty() {
self.queue.write_buffer(&self.quad_buffer, 0, bytemuck::cast_slice(&self.quads));
}
// Upload overlay quads if we have any (will be rendered after main quads)
// We reuse the same buffer, uploading overlay quads when needed during rendering
if self.atlas_dirty {
self.queue.write_texture(
wgpu::ImageCopyTexture {
texture: &self.atlas_texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&self.atlas_data,
wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: Some(ATLAS_SIZE * ATLAS_BPP),
rows_per_image: Some(ATLAS_SIZE),
},
wgpu::Extent3d {
width: ATLAS_SIZE,
height: ATLAS_SIZE,
depth_or_array_layers: 1,
},
);
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;
let bg_g_linear = Self::srgb_to_linear(bg_g as f32 / 255.0) as f64;
let bg_b_linear = Self::srgb_to_linear(bg_b as f32 / 255.0) as f64;
let bg_alpha = self.background_opacity as f64;
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Render Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: bg_r_linear,
g: bg_g_linear,
b: bg_b_linear,
a: bg_alpha,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
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);
// Draw bg + glyph indices (tab bar text uses legacy vertex rendering)
render_pass.draw_indexed(0..total_index_count as u32, 0, 0..1);
// ═══════════════════════════════════════════════════════════════════
// INSTANCED QUAD RENDERING (tab bar backgrounds, borders, etc.)
// Rendered before cell content so backgrounds appear behind cells
// ═══════════════════════════════════════════════════════════════════
if !self.quads.is_empty() {
render_pass.set_pipeline(&self.quad_pipeline);
render_pass.set_bind_group(0, &self.quad_bind_group, &[]);
render_pass.draw(0..4, 0..self.quads.len() as u32);
}
// ═══════════════════════════════════════════════════════════════════
// INSTANCED CELL RENDERING (Like Kitty's per-window VAO approach)
// Each pane has its own bind group with its own buffers.
// Data was already uploaded before the render pass started.
// ═══════════════════════════════════════════════════════════════════
for pane_data in &pane_render_list {
let instance_count = pane_data.cols * pane_data.rows;
// Get this pane's bind group (data already uploaded)
if let Some(pane_res) = self.pane_resources.get(&pane_data.pane_id) {
// Draw cell backgrounds
render_pass.set_pipeline(&self.cell_bg_pipeline);
render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); // Atlas (shared)
render_pass.set_bind_group(1, &pane_res.bind_group, &[]); // This pane's data
render_pass.draw(0..4, 0..instance_count); // 4 vertices per quad, N instances
// Draw cell glyphs
render_pass.set_pipeline(&self.cell_glyph_pipeline);
render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); // Atlas (shared)
render_pass.set_bind_group(1, &pane_res.bind_group, &[]); // This pane's data
render_pass.draw(0..4, 0..instance_count); // 4 vertices per quad, N instances
}
}
// ═══════════════════════════════════════════════════════════════════
// STATUSLINE RENDERING (dedicated shader)
// Render the statusline using its own pipelines
// ═══════════════════════════════════════════════════════════════════
if statusline_cols > 0 {
let instance_count = statusline_cols as u32;
// Draw statusline backgrounds
render_pass.set_pipeline(&self.statusline_bg_pipeline);
render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); // Atlas
render_pass.set_bind_group(1, &self.statusline_bind_group, &[]); // Statusline data
render_pass.draw(0..4, 0..instance_count);
// Draw statusline glyphs
render_pass.set_pipeline(&self.statusline_glyph_pipeline);
render_pass.set_bind_group(0, &self.glyph_bind_group, &[]); // Atlas
render_pass.set_bind_group(1, &self.statusline_bind_group, &[]); // Statusline data
render_pass.draw(0..4, 0..instance_count);
}
// ═══════════════════════════════════════════════════════════════════
// ADD DIM OVERLAYS FOR INACTIVE PANES
// ═══════════════════════════════════════════════════════════════════
for pane_data in &pane_render_list {
if let Some((x, y, w, h, color)) = pane_data.dim_overlay {
self.overlay_quads.push(Quad { x, y, width: w, height: h, color });
}
}
// ═══════════════════════════════════════════════════════════════════
// INSTANCED OVERLAY QUAD RENDERING (dimming overlays, borders)
// Rendered last so overlays appear on top of everything
// ═══════════════════════════════════════════════════════════════════
if !self.overlay_quads.is_empty() {
// Upload overlay quads to the buffer (reusing the same buffer)
self.queue.write_buffer(&self.quad_buffer, 0, bytemuck::cast_slice(&self.overlay_quads));
render_pass.set_pipeline(&self.quad_pipeline);
render_pass.set_bind_group(0, &self.quad_bind_group, &[]);
render_pass.draw(0..4, 0..self.overlay_quads.len() as u32);
}
}
// ═══════════════════════════════════════════════════════════════════
// 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 {
// Upload uniforms
self.queue.write_buffer(
&self.edge_glow_uniform_buffer,
0,
bytemuck::cast_slice(&[*uniforms]),
);
// Render pass for this edge glow (load existing content)
let mut glow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Edge Glow Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load, // Preserve existing content
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
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) {
// Get current frame data (handles animation frames automatically)
let data = image.current_frame_data();
// Check if we already have this image
if let Some(existing) = self.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,
},
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,
},
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::<ImageUniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Create bind group for this image with its own uniform buffer
let bind_group = 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<u32> = storage.images().keys().copied().collect();
let gpu_ids: Vec<u32> = 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
}
}