// Glyph rendering shader for terminal emulator // Supports both legacy quad-based rendering and new instanced cell rendering // Uses Kitty-style "legacy" gamma-incorrect text blending for crisp rendering // ═══════════════════════════════════════════════════════════════════════════════ // GAMMA CONVERSION FUNCTIONS (for legacy text rendering) // ═══════════════════════════════════════════════════════════════════════════════ // Luminance weights for perceived brightness (ITU-R BT.709) const Y: vec3 = vec3(0.2126, 0.7152, 0.0722); // Convert linear RGB to sRGB fn linear2srgb(x: f32) -> f32 { if x <= 0.0031308 { return 12.92 * x; } else { return 1.055 * pow(x, 1.0 / 2.4) - 0.055; } } // Convert sRGB to linear RGB fn srgb2linear(x: f32) -> f32 { if x <= 0.04045 { return x / 12.92; } else { return pow((x + 0.055) / 1.055, 2.4); } } // Kitty's legacy gamma-incorrect text blending // This simulates how text was blended before gamma-correct rendering became standard. // It makes dark text on light backgrounds appear thicker and light text on dark // backgrounds appear thinner, which many users prefer for readability. // // The input colors are in sRGB space. We convert to linear for the luminance // calculation, then simulate gamma-incorrect blending. fn foreground_contrast_legacy(over_srgb: vec3, over_alpha: f32, under_srgb: vec3) -> f32 { // Convert sRGB colors to linear for luminance calculation let over_linear = vec3(srgb2linear(over_srgb.r), srgb2linear(over_srgb.g), srgb2linear(over_srgb.b)); let under_linear = vec3(srgb2linear(under_srgb.r), srgb2linear(under_srgb.g), srgb2linear(under_srgb.b)); let under_luminance = dot(under_linear, Y); let over_luminance = dot(over_linear, Y); // Avoid division by zero when luminances are equal let luminance_diff = over_luminance - under_luminance; if abs(luminance_diff) < 0.001 { return over_alpha; } // Kitty's formula: simulate gamma-incorrect blending // This is the solution to: // linear2srgb(over * alpha2 + under * (1 - alpha2)) = linear2srgb(over) * alpha + linear2srgb(under) * (1 - alpha) // ^ gamma correct blending with new alpha ^ gamma incorrect blending with old alpha let blended_srgb = linear2srgb(over_luminance) * over_alpha + linear2srgb(under_luminance) * (1.0 - over_alpha); let blended_linear = srgb2linear(blended_srgb); let new_alpha = (blended_linear - under_luminance) / luminance_diff; return clamp(new_alpha, 0.0, 1.0); } // ═══════════════════════════════════════════════════════════════════════════════ // LEGACY QUAD-BASED RENDERING (for backwards compatibility) // ═══════════════════════════════════════════════════════════════════════════════ struct VertexInput { @location(0) position: vec2, @location(1) uv: vec2, @location(2) color: vec4, @location(3) bg_color: vec4, } struct VertexOutput { @builtin(position) clip_position: vec4, @location(0) uv: vec2, @location(1) color: vec4, @location(2) bg_color: vec4, } @vertex fn vs_main(in: VertexInput) -> VertexOutput { var out: VertexOutput; out.clip_position = vec4(in.position, 0.0, 1.0); out.uv = in.uv; out.color = in.color; out.bg_color = in.bg_color; return out; } @group(0) @binding(0) var atlas_textures: binding_array>; @group(0) @binding(1) var atlas_sampler: sampler; @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { // If UV is at origin (0,0), this is a background-only quad let is_background_only = in.uv.x == 0.0 && in.uv.y == 0.0; if is_background_only { // Just render the background color (fully opaque) return in.bg_color; } // Sample from RGBA atlas (layer 0 for legacy rendering) let glyph_sample = textureSample(atlas_textures[0], atlas_sampler, in.uv); // Detect color glyphs: regular glyphs are stored as white (1,1,1) with alpha // Color glyphs have actual RGB colors. Check if any RGB channel is not white. let is_color_glyph = glyph_sample.r < 0.99 || glyph_sample.g < 0.99 || glyph_sample.b < 0.99; if is_color_glyph { // Color glyph (emoji) - use atlas color directly return glyph_sample; } // Regular glyph - use alpha with foreground color let glyph_alpha = glyph_sample.a; // Apply legacy gamma-incorrect blending for crisp text let adjusted_alpha = foreground_contrast_legacy(in.color.rgb, glyph_alpha, in.bg_color.rgb); // Output foreground color with adjusted alpha for blending return vec4(in.color.rgb, in.color.a * adjusted_alpha); } // ═══════════════════════════════════════════════════════════════════════════════ // KITTY-STYLE INSTANCED CELL RENDERING // ═══════════════════════════════════════════════════════════════════════════════ // Color table uniform containing 256 indexed colors + default fg/bg struct ColorTable { // 256 indexed colors + default_fg (256) + default_bg (257) colors: array, 258>, } // Grid parameters uniform // Uses Kitty-style NDC positioning: the viewport is set per-pane, so the shader // works in pure NDC space (-1 to +1) without needing pixel offsets. // Cell dimensions are integers like Kitty for pixel-perfect rendering. struct GridParams { // Grid dimensions in cells cols: u32, rows: u32, // Cell dimensions in pixels (integers like Kitty) cell_width: u32, cell_height: u32, // Cursor position (-1 if hidden) cursor_col: i32, cursor_row: i32, // Cursor style: 0=block, 1=underline, 2=bar cursor_style: u32, // Background opacity for transparency (0.0 = transparent, 1.0 = opaque) 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, selection_row_max_col: array, } // GPUCell instance data (matches Rust GPUCell struct) struct GPUCell { fg: u32, bg: u32, decoration_fg: u32, sprite_idx: u32, attrs: u32, } // Sprite info for glyph positioning // In Kitty's model, sprites are always cell-sized and glyphs are pre-positioned // within the sprite at the correct baseline. No offset math needed. struct SpriteInfo { // UV coordinates in atlas (x, y, width, height) - normalized 0-1 uv: vec4, // Atlas layer index (z-coordinate for texture array) layer: f32, // Padding for alignment _padding: f32, // Size in pixels (width, height) - always matches cell dimensions size: vec2, } // Uniforms and storage buffers for instanced rendering @group(1) @binding(0) var color_table: ColorTable; @group(1) @binding(1) var grid_params: GridParams; @group(1) @binding(2) var cells: array; @group(1) @binding(3) var sprites: array; // Constants for packed color decoding const COLOR_TYPE_DEFAULT: u32 = 0u; const COLOR_TYPE_INDEXED: u32 = 1u; const COLOR_TYPE_RGB: u32 = 2u; // Constants for cell attributes const ATTR_DECORATION_MASK: u32 = 0x7u; const ATTR_BOLD_BIT: u32 = 0x8u; const ATTR_ITALIC_BIT: u32 = 0x10u; const ATTR_REVERSE_BIT: u32 = 0x20u; const ATTR_STRIKE_BIT: u32 = 0x40u; const ATTR_DIM_BIT: u32 = 0x80u; const ATTR_SELECTED_BIT: u32 = 0x100u; // Colored glyph flag const COLORED_GLYPH_FLAG: u32 = 0x80000000u; // Cursor shape constants (matches terminal cursor_style values) const CURSOR_BLOCK: u32 = 0u; const CURSOR_UNDERLINE: u32 = 1u; const CURSOR_BAR: u32 = 2u; // Pre-rendered cursor sprite indices (must match Rust CURSOR_SPRITE_* constants) // These sprites are created at fixed indices in the sprite array. const CURSOR_SPRITE_BEAM: u32 = 1u; // Bar/beam cursor (vertical line on left) const CURSOR_SPRITE_UNDERLINE: u32 = 2u; // Underline cursor (horizontal line at bottom) const CURSOR_SPRITE_HOLLOW: u32 = 3u; // Hollow/unfocused cursor (outline rectangle) // Pre-rendered decoration sprite indices (must match Rust DECORATION_SPRITE_* constants) const DECORATION_SPRITE_STRIKETHROUGH: u32 = 4u; // Strikethrough line const DECORATION_SPRITE_UNDERLINE: u32 = 5u; // Single underline const DECORATION_SPRITE_DOUBLE_UNDERLINE: u32 = 6u; // Double underline const DECORATION_SPRITE_UNDERCURL: u32 = 7u; // Wavy/curly underline const DECORATION_SPRITE_DOTTED: u32 = 8u; // Dotted underline const DECORATION_SPRITE_DASHED: u32 = 9u; // Dashed underline // Decoration type values from ATTR_DECORATION_MASK (lower 3 bits of attrs) const DECORATION_NONE: u32 = 0u; const DECORATION_SINGLE: u32 = 1u; const DECORATION_DOUBLE: u32 = 2u; const DECORATION_CURLY: u32 = 3u; const DECORATION_DOTTED: u32 = 4u; const DECORATION_DASHED: u32 = 5u; // Map cursor style to cursor sprite index fn cursor_style_to_sprite(style: u32) -> u32 { if style == CURSOR_BAR { return CURSOR_SPRITE_BEAM; } else if style == CURSOR_UNDERLINE { return CURSOR_SPRITE_UNDERLINE; } else { // CURSOR_BLOCK uses solid fill, not a sprite return 0u; } } // Map decoration type (from attrs lower 3 bits) to underline sprite index // Returns 0 if no underline decoration fn decoration_type_to_sprite(decoration_type: u32) -> u32 { if decoration_type == DECORATION_SINGLE { return DECORATION_SPRITE_UNDERLINE; } else if decoration_type == DECORATION_DOUBLE { return DECORATION_SPRITE_DOUBLE_UNDERLINE; } else if decoration_type == DECORATION_CURLY { return DECORATION_SPRITE_UNDERCURL; } else if decoration_type == DECORATION_DOTTED { return DECORATION_SPRITE_DOTTED; } else if decoration_type == DECORATION_DASHED { return DECORATION_SPRITE_DASHED; } else { return 0u; // DECORATION_NONE } } // Check if a cell is within the selection range // Selection is specified as (start_col, start_row) to (end_col, end_row), normalized // so start <= end in reading order fn is_cell_selected(col: u32, row: u32) -> bool { // Check if selection is active (-1 values mean no selection) if grid_params.selection_start_col < 0 || grid_params.selection_start_row < 0 { return false; } // Only highlight cells that have content in them or to their right on this row if grid_params.selection_row_max_col[row] < 0 || col > u32(grid_params.selection_row_max_col[row]) { return false; } let sel_start_col = u32(grid_params.selection_start_col); let sel_start_row = u32(grid_params.selection_start_row); let sel_end_col = u32(grid_params.selection_end_col); let sel_end_row = u32(grid_params.selection_end_row); // Check if cell is within row range if row < sel_start_row || row > sel_end_row { return false; } // Single row selection if sel_start_row == sel_end_row { return col >= sel_start_col && col <= sel_end_col; } // Multi-row selection if row == sel_start_row { // First row: from start_col to end of line return col >= sel_start_col; } else if row == sel_end_row { // Last row: from start of line to end_col return col <= sel_end_col; } else { // Middle rows: entire row is selected return true; } } // Vertex output for instanced cell rendering struct CellVertexOutput { @builtin(position) clip_position: vec4, @location(0) uv: vec2, @location(1) fg_color: vec4, @location(2) bg_color: vec4, @location(3) @interpolate(flat) is_background: u32, @location(4) @interpolate(flat) is_colored_glyph: u32, @location(5) @interpolate(flat) is_cursor: u32, @location(6) @interpolate(flat) cursor_shape: u32, @location(7) cursor_color: vec4, @location(8) cursor_uv: vec2, // UV coordinates for cursor sprite (interpolated) @location(9) @interpolate(flat) cell_size: vec2, // Cell width/height in pixels @location(10) underline_uv: vec2, // UV for underline sprite @location(11) strike_uv: vec2, // UV for strikethrough sprite @location(12) @interpolate(flat) decoration_fg: vec4, // Decoration color @location(13) @interpolate(flat) has_underline: u32, // Underline sprite index (0 = none) @location(14) @interpolate(flat) has_strikethrough: u32, // 1 if strikethrough, 0 otherwise // Atlas layer indices for texture array sampling @location(15) @interpolate(flat) glyph_layer: i32, // Layer for main glyph sprite @location(16) @interpolate(flat) cursor_layer: i32, // Layer for cursor sprite @location(17) @interpolate(flat) underline_layer: i32, // Layer for underline decoration @location(18) @interpolate(flat) strike_layer: i32, // Layer for strikethrough decoration } // Resolve a packed color to RGBA (in linear space for GPU rendering) fn resolve_color(packed: u32, is_foreground: bool) -> vec4 { let color_type = packed & 0xFFu; if color_type == COLOR_TYPE_DEFAULT { // Default color - use color table entry 256 (fg) or 257 (bg) // Color table is already in linear space if is_foreground { return color_table.colors[256]; } else { return color_table.colors[257]; } } else if color_type == COLOR_TYPE_INDEXED { // Indexed color - look up in color table // Color table is already in linear space let index = (packed >> 8u) & 0xFFu; return color_table.colors[index]; } else { // RGB color - extract components and convert sRGB to linear let r = f32((packed >> 8u) & 0xFFu) / 255.0; let g = f32((packed >> 16u) & 0xFFu) / 255.0; let b = f32((packed >> 24u) & 0xFFu) / 255.0; return vec4(srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b), 1.0); } } // Convert sRGB to linear (for GPU rendering to sRGB surface) fn srgb_to_linear(c: f32) -> f32 { if c <= 0.04045 { return c / 12.92; } else { return pow((c + 0.055) / 1.055, 2.4); } } // Background vertex shader (renders cell backgrounds) // vertex_index: 0-3 for quad corners // instance_index: cell index in row-major order // // Uses Kitty-style NDC positioning: the viewport is set per-pane, so we work // directly in NDC space (-1 to +1). This avoids floating-point precision issues // that cause wobbly/misaligned text when cell dimensions aren't integer pixels. @vertex fn vs_cell_bg( @builtin(vertex_index) vertex_index: u32, @builtin(instance_index) instance_index: u32 ) -> CellVertexOutput { let col = instance_index % grid_params.cols; let row = instance_index / grid_params.cols; // Skip if out of bounds - place vertex outside clip volume (z=2 is beyond far plane) if row >= grid_params.rows { var out: CellVertexOutput; out.clip_position = vec4(0.0, 0.0, 2.0, 1.0); out.uv = vec2(0.0, 0.0); out.fg_color = vec4(0.0, 0.0, 0.0, 0.0); out.bg_color = vec4(0.0, 0.0, 0.0, 0.0); out.is_background = 1u; out.is_colored_glyph = 0u; out.is_cursor = 0u; out.cursor_shape = 0u; out.cursor_color = vec4(0.0, 0.0, 0.0, 0.0); out.cursor_uv = vec2(0.0, 0.0); out.cell_size = vec2(0.0, 0.0); out.underline_uv = vec2(0.0, 0.0); out.strike_uv = vec2(0.0, 0.0); out.decoration_fg = vec4(0.0, 0.0, 0.0, 0.0); out.has_underline = 0u; out.has_strikethrough = 0u; out.glyph_layer = 0; out.cursor_layer = 0; out.underline_layer = 0; out.strike_layer = 0; return out; } // Get cell data let cell = cells[instance_index]; // Kitty-style NDC positioning: calculate cell size in NDC space // NDC ranges from -1 to +1, so total range is 2.0 let dx = 2.0 / f32(grid_params.cols); let dy = 2.0 / f32(grid_params.rows); // Calculate cell position in NDC (origin at top-left: -1, +1) let left = -1.0 + f32(col) * dx; let top = 1.0 - f32(row) * dy; // Quad vertex positions for TriangleStrip (0=top-left, 1=top-right, 2=bottom-left, 3=bottom-right) // TriangleStrip produces triangles: (0,1,2) and (1,2,3) var ndc_positions: array, 4>; ndc_positions[0] = vec2(left, top); // top-left ndc_positions[1] = vec2(left + dx, top); // top-right ndc_positions[2] = vec2(left, top - dy); // bottom-left ndc_positions[3] = vec2(left + dx, top - dy); // bottom-right let ndc_pos = ndc_positions[vertex_index]; // Convert integer cell dimensions to float for calculations let cell_width_f = f32(grid_params.cell_width); let cell_height_f = f32(grid_params.cell_height); // Resolve colors let attrs = cell.attrs; let is_reverse = (attrs & ATTR_REVERSE_BIT) != 0u; var fg = resolve_color(cell.fg, true); var bg = resolve_color(cell.bg, false); // Handle reverse video if is_reverse { let tmp = fg; fg = bg; bg = tmp; } // Check if this cell is selected using GridParams selection range let is_selected = is_cell_selected(col, row); if is_selected { fg = vec4(0.0, 0.0, 0.0, 1.0); // Black foreground bg = vec4(1.0, 1.0, 1.0, 1.0); // White background } // Check if this cell is the cursor let is_cursor_cell = (i32(col) == grid_params.cursor_col) && (i32(row) == grid_params.cursor_row); // For default background (type 0), use fully transparent so the window's // clear color (which has background_opacity applied) shows through. // UNLESS the grid params specify an opaque background (e.g. alternate screen). // Only non-default backgrounds should be opaque. // But NOT if the cell is selected (selection always has white bg) let bg_type = cell.bg & 0xFFu; if bg_type == COLOR_TYPE_DEFAULT && !is_reverse && !is_selected { if grid_params.background_opacity < 1.0 { bg.a = 0.0; } else { bg.a = 1.0; } } // Calculate cursor color // If the cell is empty (no glyph), use default foreground color for cursor // Otherwise use the cell's foreground color var cursor_color: vec4; let sprite_idx = cell.sprite_idx & ~COLORED_GLYPH_FLAG; if sprite_idx == 0u { // Empty cell - use default foreground color for cursor cursor_color = color_table.colors[256]; // default_fg } else { // Cell has a glyph - use its foreground color cursor_color = fg; } cursor_color.a = 1.0; // Calculate cursor sprite UV coordinates (for non-block cursors) // Look up the pre-rendered cursor sprite based on cursor style var cursor_uv = vec2(0.0, 0.0); var cursor_layer: i32 = 0; if is_cursor_cell && grid_params.cursor_style != CURSOR_BLOCK { let cursor_sprite_idx = cursor_style_to_sprite(grid_params.cursor_style); if cursor_sprite_idx > 0u { let cursor_sprite = sprites[cursor_sprite_idx]; cursor_layer = i32(cursor_sprite.layer); // Calculate UV for this vertex (matching quad corners) // vertex_index: 0=top-left, 1=top-right, 2=bottom-left, 3=bottom-right var cursor_uvs: array, 4>; cursor_uvs[0] = vec2(cursor_sprite.uv.x, cursor_sprite.uv.y); cursor_uvs[1] = vec2(cursor_sprite.uv.x + cursor_sprite.uv.z, cursor_sprite.uv.y); cursor_uvs[2] = vec2(cursor_sprite.uv.x, cursor_sprite.uv.y + cursor_sprite.uv.w); cursor_uvs[3] = vec2(cursor_sprite.uv.x + cursor_sprite.uv.z, cursor_sprite.uv.y + cursor_sprite.uv.w); cursor_uv = cursor_uvs[vertex_index]; } } var out: CellVertexOutput; out.clip_position = vec4(ndc_pos, 0.0, 1.0); out.uv = vec2(0.0, 0.0); // Not used for background out.fg_color = fg; out.bg_color = bg; out.is_background = 1u; out.is_colored_glyph = 0u; out.is_cursor = select(0u, 1u, is_cursor_cell); out.cursor_shape = grid_params.cursor_style; out.cursor_color = cursor_color; out.cursor_uv = cursor_uv; out.cell_size = vec2(cell_width_f, cell_height_f); out.underline_uv = vec2(0.0, 0.0); // Not used for background out.strike_uv = vec2(0.0, 0.0); // Not used for background out.decoration_fg = vec4(0.0, 0.0, 0.0, 0.0); // Not used for background out.has_underline = 0u; out.has_strikethrough = 0u; // Layer indices for atlas sampling out.glyph_layer = 0; // Not used for background out.cursor_layer = cursor_layer; out.underline_layer = 0; // Not used for background out.strike_layer = 0; // Not used for background return out; } // Glyph vertex shader (renders cell glyphs) // Uses Kitty-style NDC positioning for alignment, viewport handles pane offset. @vertex fn vs_cell_glyph( @builtin(vertex_index) vertex_index: u32, @builtin(instance_index) instance_index: u32 ) -> CellVertexOutput { let col = instance_index % grid_params.cols; let row = instance_index / grid_params.cols; // Skip if out of bounds - use off-screen position with valid W to avoid undefined behavior if row >= grid_params.rows { var out: CellVertexOutput; out.clip_position = vec4(0.0, 0.0, 2.0, 1.0); out.uv = vec2(0.0, 0.0); out.fg_color = vec4(0.0, 0.0, 0.0, 0.0); out.bg_color = vec4(0.0, 0.0, 0.0, 0.0); out.is_background = 0u; out.is_colored_glyph = 0u; out.is_cursor = 0u; out.cursor_shape = 0u; out.cursor_color = vec4(0.0, 0.0, 0.0, 0.0); out.cursor_uv = vec2(0.0, 0.0); out.cell_size = vec2(0.0, 0.0); out.underline_uv = vec2(0.0, 0.0); out.strike_uv = vec2(0.0, 0.0); out.decoration_fg = vec4(0.0, 0.0, 0.0, 0.0); out.has_underline = 0u; out.has_strikethrough = 0u; out.glyph_layer = 0; out.cursor_layer = 0; out.underline_layer = 0; out.strike_layer = 0; return out; } // Get cell data let cell = cells[instance_index]; let sprite_idx = cell.sprite_idx & ~COLORED_GLYPH_FLAG; let is_colored = (cell.sprite_idx & COLORED_GLYPH_FLAG) != 0u; let attrs = cell.attrs; // Check for decorations (like Kitty: decorations render even for empty cells) let decoration_type = attrs & ATTR_DECORATION_MASK; let has_strike = (attrs & ATTR_STRIKE_BIT) != 0u; let underline_sprite_idx = decoration_type_to_sprite(decoration_type); let has_decorations = underline_sprite_idx > 0u || has_strike; // Skip if no glyph AND no decorations - use off-screen position with valid W if sprite_idx == 0u && !has_decorations { var out: CellVertexOutput; out.clip_position = vec4(0.0, 0.0, 2.0, 1.0); out.uv = vec2(0.0, 0.0); out.fg_color = vec4(0.0, 0.0, 0.0, 0.0); out.bg_color = vec4(0.0, 0.0, 0.0, 0.0); out.is_background = 0u; out.is_colored_glyph = 0u; out.is_cursor = 0u; out.cursor_shape = 0u; out.cursor_color = vec4(0.0, 0.0, 0.0, 0.0); out.cursor_uv = vec2(0.0, 0.0); out.cell_size = vec2(0.0, 0.0); out.underline_uv = vec2(0.0, 0.0); out.strike_uv = vec2(0.0, 0.0); out.decoration_fg = vec4(0.0, 0.0, 0.0, 0.0); out.has_underline = 0u; out.has_strikethrough = 0u; out.glyph_layer = 0; out.cursor_layer = 0; out.underline_layer = 0; out.strike_layer = 0; return out; } // Kitty-style NDC positioning: calculate cell size in NDC space // NDC ranges from -1 to +1, so total range is 2.0 let dx = 2.0 / f32(grid_params.cols); let dy = 2.0 / f32(grid_params.rows); // Convert integer cell dimensions to float for calculations let cell_width_f = f32(grid_params.cell_width); let cell_height_f = f32(grid_params.cell_height); // Calculate cell position in NDC (origin at top-left: -1, +1) let left = -1.0 + f32(col) * dx; let top = 1.0 - f32(row) * dy; // Determine sprite dimensions - for decoration-only cells, use cell size var sprite_dx = dx; var sprite_dy = dy; var glyph_uv = vec2(0.0, 0.0); // Default: no glyph var glyph_layer: i32 = 0; // If we have a glyph, use sprite dimensions and compute glyph UV if sprite_idx > 0u { let sprite = sprites[sprite_idx]; glyph_layer = i32(sprite.layer); if sprite.size.x > 0.0 && sprite.size.y > 0.0 { // Scale NDC size proportionally for wide chars, emoji, etc. sprite_dx = dx * (sprite.size.x / cell_width_f); sprite_dy = dy * (sprite.size.y / cell_height_f); } } // Quad vertex positions for TriangleStrip (0=top-left, 1=top-right, 2=bottom-left, 3=bottom-right) var ndc_positions: array, 4>; ndc_positions[0] = vec2(left, top); // top-left ndc_positions[1] = vec2(left + sprite_dx, top); // top-right ndc_positions[2] = vec2(left, top - sprite_dy); // bottom-left ndc_positions[3] = vec2(left + sprite_dx, top - sprite_dy); // bottom-right let ndc_pos = ndc_positions[vertex_index]; // UV coordinates for glyph (only if we have a glyph) var uvs: array, 4>; if sprite_idx > 0u { let sprite = sprites[sprite_idx]; uvs[0] = vec2(sprite.uv.x, sprite.uv.y); // top-left uvs[1] = vec2(sprite.uv.x + sprite.uv.z, sprite.uv.y); // top-right uvs[2] = vec2(sprite.uv.x, sprite.uv.y + sprite.uv.w); // bottom-left uvs[3] = vec2(sprite.uv.x + sprite.uv.z, sprite.uv.y + sprite.uv.w); // bottom-right glyph_uv = uvs[vertex_index]; } // Resolve colors let is_reverse = (attrs & ATTR_REVERSE_BIT) != 0u; var fg = resolve_color(cell.fg, true); var bg = resolve_color(cell.bg, false); if is_reverse { let tmp = fg; fg = bg; bg = tmp; } // Check if this cell is selected using GridParams selection range let is_selected = is_cell_selected(col, row); if is_selected { fg = vec4(0.0, 0.0, 0.0, 1.0); // Black foreground bg = vec4(1.0, 1.0, 1.0, 1.0); // White background } // Check if this cell is the cursor let is_cursor_cell = (i32(col) == grid_params.cursor_col) && (i32(row) == grid_params.cursor_row); // For block cursor, invert text color (use bg as fg) var cursor_text_color = bg; cursor_text_color.a = 1.0; if is_cursor_cell && grid_params.cursor_style == CURSOR_BLOCK { fg = cursor_text_color; } // Calculate underline UV if decoration is present // (underline_sprite_idx, has_strike were computed earlier) var underline_uv = vec2(0.0, 0.0); var underline_layer: i32 = 0; if underline_sprite_idx > 0u { let underline_sprite = sprites[underline_sprite_idx]; underline_layer = i32(underline_sprite.layer); var underline_uvs: array, 4>; underline_uvs[0] = vec2(underline_sprite.uv.x, underline_sprite.uv.y); underline_uvs[1] = vec2(underline_sprite.uv.x + underline_sprite.uv.z, underline_sprite.uv.y); underline_uvs[2] = vec2(underline_sprite.uv.x, underline_sprite.uv.y + underline_sprite.uv.w); underline_uvs[3] = vec2(underline_sprite.uv.x + underline_sprite.uv.z, underline_sprite.uv.y + underline_sprite.uv.w); underline_uv = underline_uvs[vertex_index]; } // Calculate strikethrough UV if present var strike_uv = vec2(0.0, 0.0); var strike_layer: i32 = 0; if has_strike { let strike_sprite = sprites[DECORATION_SPRITE_STRIKETHROUGH]; strike_layer = i32(strike_sprite.layer); var strike_uvs: array, 4>; strike_uvs[0] = vec2(strike_sprite.uv.x, strike_sprite.uv.y); strike_uvs[1] = vec2(strike_sprite.uv.x + strike_sprite.uv.z, strike_sprite.uv.y); strike_uvs[2] = vec2(strike_sprite.uv.x, strike_sprite.uv.y + strike_sprite.uv.w); strike_uvs[3] = vec2(strike_sprite.uv.x + strike_sprite.uv.z, strike_sprite.uv.y + strike_sprite.uv.w); strike_uv = strike_uvs[vertex_index]; } // Resolve decoration color (use decoration_fg if set, otherwise foreground color) var decoration_fg_color = resolve_color(cell.decoration_fg, true); // If decoration_fg is default (type 0), use foreground color instead let deco_fg_type = cell.decoration_fg & 0xFFu; if deco_fg_type == COLOR_TYPE_DEFAULT { decoration_fg_color = fg; } var out: CellVertexOutput; out.clip_position = vec4(ndc_pos, 0.0, 1.0); out.uv = glyph_uv; out.fg_color = fg; out.bg_color = bg; // Pass background for legacy gamma blending out.is_background = 0u; out.is_colored_glyph = select(0u, 1u, is_colored); out.is_cursor = select(0u, 1u, is_cursor_cell); out.cursor_shape = grid_params.cursor_style; out.cursor_color = cursor_text_color; out.cursor_uv = vec2(0.0, 0.0); // Not used for glyph pass (cursor rendered in bg pass) out.cell_size = vec2(cell_width_f, cell_height_f); out.underline_uv = underline_uv; out.strike_uv = strike_uv; out.decoration_fg = decoration_fg_color; out.has_underline = underline_sprite_idx; out.has_strikethrough = select(0u, 1u, has_strike); // Layer indices for atlas sampling out.glyph_layer = glyph_layer; out.cursor_layer = 0; // Not used for glyph pass out.underline_layer = underline_layer; out.strike_layer = strike_layer; return out; } // Fragment shader for cell rendering (both background and glyph) @fragment fn fs_cell(in: CellVertexOutput) -> @location(0) vec4 { if in.is_background == 1u { // Check if this is a cursor cell if in.is_cursor == 1u { if in.cursor_shape == CURSOR_BLOCK { // Block cursor - fill entire cell with cursor color return in.cursor_color; } else { // Non-block cursors (bar, underline) - sample from pre-rendered cursor sprite // The cursor_uv was calculated in the vertex shader let cursor_sample = textureSample(atlas_textures[in.cursor_layer], atlas_sampler, in.cursor_uv); let cursor_alpha = cursor_sample.a; if cursor_alpha > 0.0 { // Blend cursor color with background based on sprite alpha return vec4(in.cursor_color.rgb, cursor_alpha); } } } // Normal background - just output the bg color return in.bg_color; } // Glyph pass - sample from RGBA atlas // Start with glyph color (or transparent if decoration-only cell) var result_rgb: vec3; var result_alpha: f32; // Check if this is a decoration-only cell (no glyph, UV at origin) let has_glyph = in.uv.x != 0.0 || in.uv.y != 0.0; if has_glyph { let glyph_sample = textureSample(atlas_textures[in.glyph_layer], atlas_sampler, in.uv); if in.is_colored_glyph == 1u { // Colored glyph (emoji) - use atlas color directly result_rgb = glyph_sample.rgb; result_alpha = glyph_sample.a; } else { // Regular glyph - atlas stores white (1,1,1) with alpha in A channel let glyph_alpha = glyph_sample.a; // Apply legacy gamma-incorrect blending for crisp text let adjusted_alpha = foreground_contrast_legacy(in.fg_color.rgb, glyph_alpha, in.bg_color.rgb); result_rgb = in.fg_color.rgb; result_alpha = in.fg_color.a * adjusted_alpha; } } else { // Decoration-only cell - start with transparent result_rgb = vec3(0.0, 0.0, 0.0); result_alpha = 0.0; } // Sample and blend underline decoration if present if in.has_underline > 0u { let underline_sample = textureSample(atlas_textures[in.underline_layer], atlas_sampler, in.underline_uv); let underline_alpha = underline_sample.a; if underline_alpha > 0.0 { // Alpha-blend underline on top of glyph // Use decoration_fg color with the sprite's alpha let deco_alpha = underline_alpha * in.decoration_fg.a; result_rgb = mix(result_rgb, in.decoration_fg.rgb, deco_alpha * (1.0 - result_alpha) + deco_alpha * result_alpha); result_alpha = result_alpha + deco_alpha * (1.0 - result_alpha); } } // Sample and blend strikethrough decoration if present if in.has_strikethrough > 0u { let strike_sample = textureSample(atlas_textures[in.strike_layer], atlas_sampler, in.strike_uv); let strike_alpha = strike_sample.a; if strike_alpha > 0.0 { // Alpha-blend strikethrough on top (it should cover the glyph) let deco_alpha = strike_alpha * in.decoration_fg.a; result_rgb = mix(result_rgb, in.decoration_fg.rgb, deco_alpha); result_alpha = max(result_alpha, deco_alpha); } } return vec4(result_rgb, result_alpha); }