From 73b52ab341fd1ac22fed396baa692f6bc8201507 Mon Sep 17 00:00:00 2001 From: Zacharias-Brohn Date: Mon, 22 Dec 2025 00:22:55 +0100 Subject: [PATCH] tick system, AVX2 UTF-8 decoder, uh faster in general --- ATLAS_REFACTOR.md | 359 +++ CONFIG_REORG.md | 248 ++ Cargo.toml | 13 +- MAIN_REORG.md | 346 +++ MISC_REORG.md | 220 ++ PKGBUILD | 2 +- REDUNDANCY_AUDIT.md | 705 ++++++ RENDERER_REORG.md | 314 +++ TERMINAL_REORG.md | 356 +++ VT_PARSER_REORG.md | 295 +++ src/bin/bench_process.rs | 167 +- src/box_drawing.rs | 1610 +++++++++++++ src/color.rs | 57 + src/color_font.rs | 398 ++++ src/config.rs | 227 +- src/edge_glow.rs | 55 + src/font_loader.rs | 301 +++ src/glyph_shader.wgsl | 12 +- src/gpu_types.rs | 291 +++ src/image_renderer.rs | 347 +++ src/lib.rs | 11 + src/main.rs | 1088 ++++----- src/pane_resources.rs | 23 + src/pipeline.rs | 79 + src/renderer.rs | 4482 ++++-------------------------------- src/simd_utf8.rs | 1476 ++++++++++++ src/statusline.rs | 137 ++ src/statusline_shader.wgsl | 6 +- src/terminal.rs | 641 +++--- src/vt_parser.rs | 1175 ++++++++-- 30 files changed, 10231 insertions(+), 5210 deletions(-) create mode 100644 ATLAS_REFACTOR.md create mode 100644 CONFIG_REORG.md create mode 100644 MAIN_REORG.md create mode 100644 MISC_REORG.md create mode 100644 REDUNDANCY_AUDIT.md create mode 100644 RENDERER_REORG.md create mode 100644 TERMINAL_REORG.md create mode 100644 VT_PARSER_REORG.md create mode 100644 src/box_drawing.rs create mode 100644 src/color.rs create mode 100644 src/color_font.rs create mode 100644 src/edge_glow.rs create mode 100644 src/font_loader.rs create mode 100644 src/gpu_types.rs create mode 100644 src/image_renderer.rs create mode 100644 src/pane_resources.rs create mode 100644 src/pipeline.rs create mode 100644 src/simd_utf8.rs create mode 100644 src/statusline.rs diff --git a/ATLAS_REFACTOR.md b/ATLAS_REFACTOR.md new file mode 100644 index 0000000..ad86ef1 --- /dev/null +++ b/ATLAS_REFACTOR.md @@ -0,0 +1,359 @@ +# Atlas Texture Refactor: Array of Textures + +## Problem + +When adding a new layer to the glyph atlas, the current implementation creates a new texture array with N+1 layers and copies all N existing layers to it. This causes performance issues that scale with the number of layers: + +- Layer 1→2: Copy 256MB (8192×8192×4 bytes) +- Layer 2→3: Copy 512MB +- Layer 3→4: Copy 768MB +- etc. + +Observed frame times when adding layers: +- Layer 1 added: 14.4ms +- Layer 2 added: 21.9ms +- Layer 3 added: 34.2ms + +## Solution + +Instead of using a single `texture_2d_array` that must be reallocated and copied when growing, use a **`Vec` of separate 2D textures**. When a new layer is needed, simply create a new texture and add it to the vector. No copying of existing texture data is required. + +The bind group must be recreated to include the new texture, but this is a cheap CPU-side operation (just creating metadata/pointers). + +## Current Implementation + +### Rust (renderer.rs) + +**Struct fields:** +```rust +atlas_texture: wgpu::Texture, // Single texture array +atlas_view: wgpu::TextureView, // Single view +atlas_num_layers: u32, // Number of layers in the array +atlas_current_layer: u32, // Current layer being written to +``` + +**Bind group layout (binding 0):** +```rust +ty: wgpu::BindingType::Texture { + view_dimension: wgpu::TextureViewDimension::D2Array, + // ... +}, +count: None, // Single texture +``` + +**Bind group entry:** +```rust +wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&atlas_view), +} +``` + +### WGSL (glyph_shader.wgsl) + +**Texture declaration:** +```wgsl +@group(0) @binding(0) +var atlas_texture: texture_2d_array; +``` + +**Sampling:** +```wgsl +let sample = textureSample(atlas_texture, atlas_sampler, uv, layer_index); +``` + +## New Implementation + +### Rust (renderer.rs) + +**Struct fields:** +```rust +atlas_textures: Vec, // Vector of separate textures +atlas_views: Vec, // Vector of views (one per texture) +atlas_current_layer: u32, // Current layer being written to +// atlas_num_layers removed - use atlas_textures.len() instead +``` + +**Bind group layout (binding 0):** +```rust +ty: wgpu::BindingType::Texture { + view_dimension: wgpu::TextureViewDimension::D2, // Changed from D2Array + // ... +}, +count: Some(NonZeroU32::new(MAX_ATLAS_LAYERS).unwrap()), // Array of textures +``` + +**Bind group entry:** +```rust +wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureViewArray(&atlas_view_refs), +} +``` + +Where `atlas_view_refs` is a `Vec<&wgpu::TextureView>` containing references to all views. + +**Note:** wgpu requires the bind group to have exactly `count` textures. We'll need to either: +1. Pre-create dummy textures to fill unused slots, OR +2. Recreate the bind group layout when adding textures (more complex) + +Option 1 is simpler: create small 1x1 dummy textures for unused slots up to MAX_ATLAS_LAYERS. + +### WGSL (glyph_shader.wgsl) + +**Texture declaration:** +```wgsl +@group(0) @binding(0) +var atlas_textures: binding_array>; +``` + +**Sampling:** +```wgsl +let sample = textureSample(atlas_textures[layer_index], atlas_sampler, uv); +``` + +**Note:** `binding_array` requires the `binding_array` feature in wgpu, which should be enabled by default on most backends. + +## Implementation Steps + +### Step 1: Update Struct Fields + +In `renderer.rs`, change: +```rust +// Old +atlas_texture: wgpu::Texture, +atlas_view: wgpu::TextureView, +atlas_num_layers: u32, + +// New +atlas_textures: Vec, +atlas_views: Vec, +``` + +### Step 2: Create Helper for New Atlas Layer + +```rust +fn create_atlas_layer(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Glyph Atlas Layer"), + 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 view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) +} +``` + +### Step 3: Update Bind Group Layout + +```rust +use std::num::NonZeroU32; + +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: Some(NonZeroU32::new(MAX_ATLAS_LAYERS).unwrap()), + }, + // ... sampler entry unchanged + ], +}); +``` + +### Step 4: Initialize with Dummy Textures + +At initialization, create one real texture and fill the rest with 1x1 dummy textures: + +```rust +fn create_dummy_texture(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Dummy Atlas Texture"), + size: wgpu::Extent3d { + width: 1, + height: 1, + 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, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) +} + +// In new(): +let mut atlas_textures = Vec::with_capacity(MAX_ATLAS_LAYERS as usize); +let mut atlas_views = Vec::with_capacity(MAX_ATLAS_LAYERS as usize); + +// First texture is real +let (tex, view) = create_atlas_layer(&device); +atlas_textures.push(tex); +atlas_views.push(view); + +// Fill rest with dummies +for _ in 1..MAX_ATLAS_LAYERS { + let (tex, view) = create_dummy_texture(&device); + atlas_textures.push(tex); + atlas_views.push(view); +} +``` + +### Step 5: Update Bind Group Creation + +```rust +fn create_atlas_bind_group(&self) -> wgpu::BindGroup { + let view_refs: Vec<&wgpu::TextureView> = self.atlas_views.iter().collect(); + + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Glyph Bind Group"), + layout: &self.glyph_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureViewArray(&view_refs), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.atlas_sampler), + }, + ], + }) +} +``` + +### Step 6: Update add_atlas_layer / ensure_atlas_layer_capacity + +```rust +fn ensure_atlas_layer_capacity(&mut self, target_layer: u32) { + // Count real layers (non-dummy) + let current_real_layers = self.atlas_current_layer + 1; + + if target_layer < current_real_layers { + return; // Already have this layer + } + + if target_layer >= MAX_ATLAS_LAYERS { + log::error!("Atlas layer limit reached"); + return; + } + + log::info!("Adding atlas layer {}", target_layer); + + // Create new real texture + let (texture, view) = Self::create_atlas_layer(&self.device); + + // Replace dummy at this index with real texture + self.atlas_textures[target_layer as usize] = texture; + self.atlas_views[target_layer as usize] = view; + + // Recreate bind group with updated view + self.glyph_bind_group = self.create_atlas_bind_group(); +} +``` + +### Step 7: Update upload_cell_canvas_to_atlas + +Change texture reference from `&self.atlas_texture` to `&self.atlas_textures[layer as usize]`: + +```rust +self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &self.atlas_textures[layer as usize], // Changed + mip_level: 0, + origin: wgpu::Origin3d { + x: self.atlas_cursor_x, + y: self.atlas_cursor_y, + z: 0, // Always 0 now, layer is selected by texture index + }, + aspect: wgpu::TextureAspect::All, + }, + // ... rest unchanged +); +``` + +### Step 8: Update Shader + +In `glyph_shader.wgsl`: + +```wgsl +// Old +@group(0) @binding(0) +var atlas_texture: texture_2d_array; + +// New +@group(0) @binding(0) +var atlas_textures: binding_array>; +``` + +Update all `textureSample` calls: + +```wgsl +// Old +let sample = textureSample(atlas_texture, atlas_sampler, uv, layer_index); + +// New +let sample = textureSample(atlas_textures[layer_index], atlas_sampler, uv); +``` + +**Locations to update in glyph_shader.wgsl:** +- Line 91: Declaration +- Line 106: `fs_main` sampling +- Line 700: Cursor sprite sampling in `fs_cell` +- Line 723: Glyph sampling in `fs_cell` +- Line 747: Underline sampling in `fs_cell` +- Line 761: Strikethrough sampling in `fs_cell` + +### Step 9: Update statusline_shader.wgsl + +The statusline shader also uses the atlas. Check and update similarly. + +### Step 10: Update Other References + +Search for all uses of: +- `atlas_texture` +- `atlas_view` +- `atlas_num_layers` +- `D2Array` + +And update accordingly. + +## Testing + +1. Build and run: `cargo build && cargo run` +2. Verify glyphs render correctly +3. Use terminal heavily to trigger layer additions +4. Check logs for "Adding atlas layer" messages +5. Verify no slow frame warnings during layer addition +6. Test with emoji (color glyphs) to ensure they still work + +## Rollback Plan + +If issues arise, the changes can be reverted by: +1. Restoring `texture_2d_array` in shaders +2. Restoring single `atlas_texture`/`atlas_view` in Rust +3. Restoring the copy-based layer addition + +## References + +- wgpu binding arrays: https://docs.rs/wgpu/latest/wgpu/enum.BindingResource.html#variant.TextureViewArray +- WGSL binding_array: https://www.w3.org/TR/WGSL/#binding-array +- Kitty's approach: `/tmp/kitty/kitty/shaders.c` (uses `GL_TEXTURE_2D_ARRAY` with `glCopyImageSubData`) diff --git a/CONFIG_REORG.md b/CONFIG_REORG.md new file mode 100644 index 0000000..c0027a6 --- /dev/null +++ b/CONFIG_REORG.md @@ -0,0 +1,248 @@ +# Config.rs Reorganization Analysis + +This document analyzes `/src/config.rs` (438 lines) and identifies sections that could be extracted into separate files to improve code organization. + +## Current Structure Overview + +The file contains: +- `TabBarPosition` enum (lines 10-21) +- `Keybind` struct + parsing logic (lines 23-166) +- `Action` enum (lines 168-206) +- `Keybindings` struct + default impl (lines 208-321) +- `Config` struct + load/save logic (lines 323-437) + +--- + +## Recommended Extractions + +### 1. Keybind Parsing Module + +**Proposed file:** `src/keybind.rs` + +**Lines:** 23-166 (144 lines) + +**Types involved:** +- `struct Keybind` +- `impl Keybind` (including `parse()` and `normalize_key_name()`) + +**Current code:** +```rust +pub struct Keybind(pub String); + +impl Keybind { + pub fn parse(&self) -> Option<(bool, bool, bool, bool, String)> { ... } + fn normalize_key_name(name: &str) -> Option<&'static str> { ... } +} +``` + +**Dependencies needed:** +- `serde::{Deserialize, Serialize}` (for derive macros) + +**Why extract:** +- Self-contained parsing logic with no dependencies on other config types +- The `normalize_key_name` function is a substantial lookup table (70+ lines) +- Could be tested independently with unit tests for key parsing edge cases +- Reusable if keybinding logic is needed elsewhere (e.g., a config editor UI) + +**Challenges:** +- None significant. This is a clean extraction. + +**After extraction, config.rs would:** +```rust +pub use keybind::Keybind; +// or +mod keybind; +pub use keybind::Keybind; +``` + +--- + +### 2. Actions Module + +**Proposed file:** `src/action.rs` + +**Lines:** 168-206 (39 lines) + +**Types involved:** +- `enum Action` + +**Current code:** +```rust +pub enum Action { + NewTab, + NextTab, + PrevTab, + Tab1, Tab2, ... Tab9, + SplitHorizontal, + SplitVertical, + ClosePane, + FocusPaneUp, FocusPaneDown, FocusPaneLeft, FocusPaneRight, + Copy, + Paste, +} +``` + +**Dependencies needed:** +- `serde::{Deserialize, Serialize}` + +**Why extract:** +- `Action` is a distinct concept used throughout the codebase (keyboard.rs likely references it) +- Decouples the "what can be done" from "how it's configured" +- Makes it easy to add new actions without touching config logic +- Could be extended with action metadata (description, default keybind, etc.) + +**Challenges:** +- Small file on its own (39 lines). Could be bundled with `keybind.rs` as a combined `src/keybindings.rs` module. + +**Alternative:** Combine with Keybindings into a single module (see option 3). + +--- + +### 3. Combined Keybindings Module (Recommended) + +**Proposed file:** `src/keybindings.rs` + +**Lines:** 23-321 (299 lines) + +**Types involved:** +- `struct Keybind` + impl +- `enum Action` +- `struct Keybindings` + impl (including `Default` and `build_action_map`) + +**Dependencies needed:** +- `serde::{Deserialize, Serialize}` +- `std::collections::HashMap` + +**Why extract:** +- These three types form a cohesive "keybindings subsystem" +- `Keybindings::build_action_map()` ties together `Keybind`, `Action`, and `Keybindings` +- Reduces config.rs from 438 lines to ~140 lines +- Clear separation: config.rs handles general settings, keybindings.rs handles input mapping + +**What stays in config.rs:** +- `TabBarPosition` enum +- `Config` struct with `keybindings: Keybindings` field +- `Config::load()`, `Config::save()`, `Config::config_path()` + +**After extraction, config.rs would:** +```rust +mod keybindings; +pub use keybindings::{Action, Keybind, Keybindings}; +``` + +**Challenges:** +- Need to ensure `Keybindings` is re-exported for external use +- The `Keybindings` struct is embedded in `Config`, so it must remain `pub` + +--- + +### 4. TabBarPosition Enum + +**Proposed file:** Could stay in `config.rs` or move to `src/ui.rs` / `src/types.rs` + +**Lines:** 10-21 (12 lines) + +**Types involved:** +- `enum TabBarPosition` + +**Why extract:** +- Very small (12 lines) - extraction may be overkill +- Could be grouped with other UI-related enums if more are added in the future + +**Recommendation:** Keep in `config.rs` for now. Only extract if you add more UI-related configuration enums (e.g., `CursorStyle`, `ScrollbarPosition`, etc.). + +--- + +## Recommended Module Structure + +``` +src/ + config.rs # Config struct, load/save, TabBarPosition (~140 lines) + keybindings.rs # Keybind, Action, Keybindings (~300 lines) + # or alternatively: + keybindings/ + mod.rs # Re-exports + keybind.rs # Keybind struct + parsing + action.rs # Action enum + bindings.rs # Keybindings struct +``` + +For a codebase of this size, the single `keybindings.rs` file is recommended over a subdirectory. + +--- + +## Implementation Steps + +### Step 1: Create `src/keybindings.rs` + +1. Create new file `src/keybindings.rs` +2. Move lines 23-321 from config.rs +3. Add module header: + ```rust + //! Keybinding types and parsing for ZTerm. + + use serde::{Deserialize, Serialize}; + use std::collections::HashMap; + ``` +4. Ensure all types are `pub` + +### Step 2: Update `src/config.rs` + +1. Remove lines 23-321 +2. Add at the top (after the module doc comment): + ```rust + mod keybindings; + pub use keybindings::{Action, Keybind, Keybindings}; + ``` +3. Keep existing imports that `Config` needs + +### Step 3: Update `src/lib.rs` or `src/main.rs` + +If `keybindings` types are used directly elsewhere, update the module declarations: +```rust +// In lib.rs, if keybindings needs to be public: +pub mod keybindings; +// Or re-export from config: +pub use config::{Action, Keybind, Keybindings}; +``` + +### Step 4: Verify compilation + +```bash +cargo check +cargo test +``` + +--- + +## Summary Table + +| Section | Lines | New File | Priority | Effort | +|---------|-------|----------|----------|--------| +| Keybind + Action + Keybindings | 23-321 (299 lines) | `keybindings.rs` | **High** | Low | +| TabBarPosition | 10-21 (12 lines) | Keep in config.rs | Low | N/A | +| Config struct + impls | 323-437 (115 lines) | Keep in config.rs | N/A | N/A | + +**Recommended action:** Extract the keybindings module as a single file. This provides the best balance of organization improvement vs. complexity. + +--- + +## Benefits After Reorganization + +1. **config.rs** becomes focused on: + - Core configuration values (font, opacity, scrollback, etc.) + - File I/O (load/save) + - ~140 lines instead of 438 + +2. **keybindings.rs** becomes a focused module for: + - Input parsing and normalization + - Action definitions + - Keybinding-to-action mapping + - ~300 lines, highly cohesive + +3. **Testing:** Keybinding parsing can be unit tested in isolation + +4. **Future extensibility:** + - Adding new actions: edit `keybindings.rs` + - Adding new config options: edit `config.rs` + - Clear separation of concerns diff --git a/Cargo.toml b/Cargo.toml index 54a4be8..06ee01b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" [dependencies] # Window and rendering winit = { version = "0.30", features = ["wayland", "x11"] } -wgpu = "23" +wgpu = "28" pollster = "0.4" # PTY handling @@ -27,6 +27,10 @@ polling = "3" thiserror = "2" # Logging +# No release_max_level set by default - use features to control: +# - Default: all logs enabled (for development) +# - --features production: disables all logs in release builds +# - --features render_timing: enables timing instrumentation with info-level logging log = "0.4" env_logger = "0.11" @@ -65,6 +69,9 @@ memmap2 = "0.9" # Fast byte searching memchr = "2" +# Fast HashMap (FxHash - what rustc uses) +rustc-hash = "2" + # Base64 decoding for OSC statusline base64 = "0.22" @@ -79,3 +86,7 @@ ffmpeg-next = { version = "8.0", optional = true } [features] default = ["webm"] webm = ["ffmpeg-next"] +# Enable timing instrumentation for performance debugging +render_timing = [] +# Production build: disable all logging for zero overhead +production = ["log/release_max_level_off"] diff --git a/MAIN_REORG.md b/MAIN_REORG.md new file mode 100644 index 0000000..4d4c890 --- /dev/null +++ b/MAIN_REORG.md @@ -0,0 +1,346 @@ +# Main.rs Reorganization Plan + +This document identifies sections of `src/main.rs` (2793 lines) that could be extracted into separate modules to improve code organization, maintainability, and testability. + +## Summary of Proposed Extractions + +| Module Name | Lines | Primary Contents | +|-------------|-------|------------------| +| `pty_buffer.rs` | ~180 | `SharedPtyBuffer`, `BufferState` | +| `pane.rs` | ~400 | `PaneId`, `Pane`, `PaneGeometry`, `SplitNode` | +| `tab.rs` | ~150 | `TabId`, `Tab` | +| `selection.rs` | ~45 | `CellPosition`, `Selection` | +| `statusline.rs` | ~220 | `GitStatus`, `build_cwd_section()`, `build_git_section()`, `get_git_status()` | +| `instance.rs` | ~45 | PID file management, `signal_existing_instance()` | + +--- + +## 1. PTY Buffer Module (`pty_buffer.rs`) + +**Lines: 30-227 (~197 lines)** + +### Contents +- `PTY_BUF_SIZE` constant +- `SharedPtyBuffer` struct and impl +- `BufferState` struct +- `unsafe impl Sync/Send for SharedPtyBuffer` + +### Description +This is a self-contained, zero-copy PTY I/O buffer implementation inspired by Kitty. It has no dependencies on other application-specific types and is purely concerned with efficient I/O buffering between an I/O thread and the main thread. + +### Types/Functions to Extract +```rust +const PTY_BUF_SIZE: usize +struct BufferState +struct SharedPtyBuffer +impl SharedPtyBuffer +impl Drop for SharedPtyBuffer +unsafe impl Sync for SharedPtyBuffer +unsafe impl Send for SharedPtyBuffer +``` + +### Dependencies +- `std::cell::UnsafeCell` +- `std::sync::Mutex` +- `libc` (for `eventfd`, `read`, `write`, `close`) + +### Challenges +- **Unsafe code**: Contains `UnsafeCell` and raw pointer manipulation. Must carefully preserve safety invariants. +- **libc dependency**: Uses Linux-specific `eventfd` syscalls. + +### Recommendation +**High priority extraction.** This is a well-documented, self-contained component with clear boundaries. Would benefit from its own unit tests for buffer operations. + +--- + +## 2. Pane Module (`pane.rs`) + +**Lines: 229-691 (~462 lines)** + +### Contents +- `PaneId` - unique identifier with atomic generation +- `Pane` - terminal + PTY + selection state +- `PaneGeometry` - pixel layout information +- `SplitNode` - tree structure for split pane layouts + +### Types/Functions to Extract +```rust +struct PaneId +impl PaneId + +struct Pane +impl Pane + - new() + - resize() + - write_to_pty() + - child_exited() + - foreground_matches() + - calculate_dim_factor() + +struct PaneGeometry + +enum SplitNode +impl SplitNode + - leaf() + - split() + - layout() + - find_geometry() + - collect_geometries() + - find_neighbor() + - overlaps_horizontally() + - overlaps_vertically() + - remove_pane() + - contains_pane() + - split_pane() +``` + +### Dependencies +- `zterm::terminal::{Direction, Terminal, TerminalCommand, MouseTrackingMode}` +- `zterm::pty::Pty` +- `std::sync::Arc` +- `std::os::fd::AsRawFd` +- `SharedPtyBuffer` (from pty_buffer module) +- `Selection` (from selection module) + +### Challenges +- **Cross-module dependencies**: `Pane` references `SharedPtyBuffer` and `Selection`, so those would need to be extracted first or extracted together. +- **`SplitNode` complexity**: The recursive tree structure has complex layout logic. Consider keeping `SplitNode` with `Pane` since they're tightly coupled. + +### Recommendation +**High priority extraction.** The pane management is a distinct concern from the main application loop. This would make the split tree logic easier to test in isolation. + +--- + +## 3. Tab Module (`tab.rs`) + +**Lines: 693-872 (~180 lines)** + +### Contents +- `TabId` - unique identifier with atomic generation +- `Tab` - collection of panes with a split tree + +### Types/Functions to Extract +```rust +struct TabId +impl TabId + +struct Tab +impl Tab + - new() + - active_pane() / active_pane_mut() + - resize() + - write_to_pty() + - check_exited_panes() + - split() + - remove_pane() + - close_active_pane() + - focus_neighbor() + - get_pane() / get_pane_mut() + - collect_pane_geometries() + - child_exited() +``` + +### Dependencies +- `std::collections::HashMap` +- `PaneId`, `Pane`, `PaneGeometry`, `SplitNode` (from pane module) +- `zterm::terminal::Direction` + +### Challenges +- **Tight coupling with Pane module**: Tab is essentially a container for panes. Consider combining into a single `pane.rs` module or using `pane/mod.rs` with submodules. + +### Recommendation +**Medium priority.** Could be combined with the pane module under `pane/mod.rs` with `pane/tab.rs` as a submodule, or kept as a separate `tab.rs`. + +--- + +## 4. Selection Module (`selection.rs`) + +**Lines: 1218-1259 (~42 lines)** + +### Contents +- `CellPosition` - column/row position +- `Selection` - start/end positions for text selection + +### Types/Functions to Extract +```rust +struct CellPosition +struct Selection +impl Selection + - normalized() + - to_screen_coords() +``` + +### Dependencies +- None (completely self-contained) + +### Challenges +- **Very small**: Only 42 lines. May be too small to justify its own file. + +### Recommendation +**Low priority as standalone.** Consider bundling with the pane module since selection is per-pane state, or creating a `types.rs` for small shared types. + +--- + +## 5. Statusline Module (`statusline.rs`) + +**Lines: 917-1216 (~300 lines)** + +### Contents +- `build_cwd_section()` - creates CWD statusline section +- `GitStatus` struct - git repository state +- `get_git_status()` - queries git for repo status +- `build_git_section()` - creates git statusline section + +### Types/Functions to Extract +```rust +fn build_cwd_section(cwd: &str) -> StatuslineSection +struct GitStatus +fn get_git_status(cwd: &str) -> Option +fn build_git_section(cwd: &str) -> Option +``` + +### Dependencies +- `zterm::renderer::{StatuslineComponent, StatuslineSection}` +- `std::process::Command` (for git commands) +- `std::env` (for HOME variable) + +### Challenges +- **External process calls**: Uses `git` commands. Consider whether this should be async or cached. +- **Powerline icons**: Uses hardcoded Unicode codepoints (Nerd Font icons). + +### Recommendation +**High priority extraction.** This is completely independent of the main application state. Would benefit from: +- Caching git status (it's currently queried every frame) +- Unit tests for path transformation logic +- Potential async git queries + +--- + +## 6. Instance Management Module (`instance.rs`) + +**Lines: 874-915, 2780-2792 (~55 lines total)** + +### Contents +- PID file path management +- Single-instance detection and signaling +- Signal handler for SIGUSR1 + +### Types/Functions to Extract +```rust +fn pid_file_path() -> PathBuf +fn signal_existing_instance() -> bool +fn write_pid_file() -> std::io::Result<()> +fn remove_pid_file() + +// From end of file: +static mut EVENT_PROXY: Option> +extern "C" fn handle_sigusr1(_: i32) +``` + +### Dependencies +- `std::fs` +- `std::path::PathBuf` +- `libc` (for `kill` syscall) +- `winit::event_loop::EventLoopProxy` +- `UserEvent` enum + +### Challenges +- **Global static**: The `EVENT_PROXY` static is `unsafe` and tightly coupled to the signal handler. +- **Split location**: The signal handler is at the end of the file, separate from PID functions. + +### Recommendation +**Medium priority.** Small but distinct concern. The global static handling could be cleaner in a dedicated module. + +--- + +## 7. Additional Observations + +### UserEvent Enum (Line 1262-1270) +```rust +enum UserEvent { + ShowWindow, + PtyReadable(PaneId), + ConfigReloaded, +} +``` +This is a small enum but is referenced throughout. Consider placing in a `types.rs` or `events.rs` module. + +### Config Watcher (Lines 2674-2735) +The `setup_config_watcher()` function is self-contained and could go in the instance module or a dedicated `config_watcher.rs`. + +### App Struct (Lines 1272-1334) +The `App` struct and its impl are the core of the application and should remain in `main.rs`. However, some of its methods could potentially be split: +- I/O thread management (lines 1418-1553) +- Keyboard/keybinding handling (lines 1773-2221) +- Mouse handling (scattered through `window_event`) + +### Keyboard Handling +Lines 1773-2221 contain significant keyboard handling logic. This could potentially be extracted, but it's tightly integrated with the `App` state. + +--- + +## Suggested Module Structure + +``` +src/ + main.rs (~1200 lines - App, ApplicationHandler, main()) + lib.rs (existing) + pty_buffer.rs (new - ~200 lines) + pane.rs (new - ~500 lines, includes SplitNode) + tab.rs (new - ~180 lines) + selection.rs (new - ~45 lines, or merge with pane.rs) + statusline.rs (new - ~300 lines) + instance.rs (new - ~60 lines) + config.rs (existing) + ... +``` + +Alternative with submodules: +``` +src/ + main.rs + lib.rs + pane/ + mod.rs (re-exports) + pane.rs (Pane, PaneId) + split.rs (SplitNode, PaneGeometry) + selection.rs (Selection, CellPosition) + tab.rs + pty_buffer.rs + statusline.rs + instance.rs + ... +``` + +--- + +## Implementation Order + +1. **`pty_buffer.rs`** - No internal dependencies, completely self-contained +2. **`selection.rs`** - No dependencies, simple extraction +3. **`statusline.rs`** - No internal dependencies, high value for testability +4. **`instance.rs`** - Small, isolated concern +5. **`pane.rs`** - Depends on pty_buffer and selection +6. **`tab.rs`** - Depends on pane + +--- + +## Testing Opportunities + +After extraction, these modules would benefit from unit tests: + +| Module | Testable Functionality | +|--------|----------------------| +| `pty_buffer` | Buffer overflow handling, space checking, wakeup signaling | +| `selection` | `normalized()` ordering, `to_screen_coords()` boundary conditions | +| `statusline` | Path normalization (~/ replacement), git status parsing | +| `pane` / `SplitNode` | Layout calculations, neighbor finding, tree operations | +| `instance` | PID file creation/cleanup (integration test) | + +--- + +## Notes on Maintaining Backward Compatibility + +All extracted types should be re-exported from `lib.rs` or a prelude if they're used externally. The current architecture appears to be internal to the binary, so this is likely not a concern. + +The `Pane` and `Tab` types are not part of the public API (defined in `main.rs`), so extraction won't affect external consumers. diff --git a/MISC_REORG.md b/MISC_REORG.md new file mode 100644 index 0000000..91526e8 --- /dev/null +++ b/MISC_REORG.md @@ -0,0 +1,220 @@ +# Code Reorganization Analysis + +This document identifies sections of code that could be moved into separate files to improve code organization. + +## Summary + +| File | Lines | Assessment | Recommended Extractions | +|------|-------|------------|------------------------| +| `pty.rs` | 260 | Well-organized | None needed | +| `keyboard.rs` | 558 | Could benefit from split | 1 extraction recommended | +| `graphics.rs` | 1846 | Needs refactoring | 3-4 extractions recommended | + +--- + +## pty.rs (260 lines) + +### Assessment: Well-Organized - No Changes Needed + +The file is focused and cohesive: +- `PtyError` enum (lines 10-28) - Error types for PTY operations +- `Pty` struct and impl (lines 31-250) - Core PTY functionality +- All code directly relates to PTY management + +**Rationale for keeping as-is:** +- Single responsibility: pseudo-terminal handling +- Manageable size (260 lines) +- All components are tightly coupled +- No reusable utilities that other modules would need + +--- + +## keyboard.rs (558 lines) + +### Assessment: Could Benefit from Minor Refactoring + +The file contains the Kitty keyboard protocol implementation with several logical sections. + +### Recommended Extraction: None (Optional Minor Refactoring) + +While the file has distinct sections, they are all tightly coupled around the keyboard protocol: + +1. **Flags/Types** (lines 8-95): `KeyboardFlags`, `KeyEventType`, `Modifiers` +2. **FunctionalKey enum** (lines 96-195): Key code definitions +3. **KeyboardState** (lines 197-292): Protocol state management +4. **KeyEncoder** (lines 294-548): Key encoding logic + +**Rationale for keeping as-is:** +- All components implement a single protocol specification +- `KeyEncoder` depends on `KeyboardState`, `Modifiers`, `FunctionalKey` +- The file is under 600 lines, which is manageable +- Splitting would require importing everything back together in practice + +**Optional Consideration:** +If the codebase grows to support multiple keyboard protocols, consider: +- `keyboard/mod.rs` - Public API +- `keyboard/kitty.rs` - Kitty protocol implementation +- `keyboard/legacy.rs` - Legacy encoding (currently in `encode_legacy_*` methods) + +--- + +## graphics.rs (1846 lines) + +### Assessment: Needs Refactoring - Multiple Extractions Recommended + +This file is too large and contains several distinct logical modules that could be separated. + +### Extraction 1: Animation Module + +**Location:** Lines 391-746 (animation-related code) + +**Components to extract:** +- `AnimationState` enum (lines 707-717) +- `AnimationData` struct (lines 719-736) +- `AnimationFrame` struct (lines 738-745) +- `decode_gif()` function (lines 393-459) +- `decode_webm()` function (lines 461-646, when feature enabled) + +**Suggested file:** `src/graphics/animation.rs` + +**Dependencies:** +```rust +use std::io::{Cursor, Read}; +use std::time::Instant; +use image::{codecs::gif::GifDecoder, AnimationDecoder}; +use super::GraphicsError; +``` + +**Challenges:** +- `decode_webm` is feature-gated (`#[cfg(feature = "webm")]`) +- Need to re-export types from `graphics/mod.rs` + +--- + +### Extraction 2: Graphics Protocol Types + +**Location:** Lines 16-162, 648-789 + +**Components to extract:** +- `Action` enum (lines 17-34) +- `Format` enum (lines 36-48) +- `Transmission` enum (lines 50-62) +- `Compression` enum (lines 64-69) +- `DeleteTarget` enum (lines 71-92) +- `GraphicsCommand` struct (lines 94-162) +- `GraphicsError` enum (lines 648-673) +- `ImageData` struct (lines 675-705) +- `PlacementResult` struct (lines 747-758) +- `ImagePlacement` struct (lines 760-789) + +**Suggested file:** `src/graphics/types.rs` + +**Dependencies:** +```rust +use std::time::Instant; +use super::animation::{AnimationData, AnimationState}; +``` + +**Challenges:** +- `GraphicsCommand` has methods that depend on decoding logic +- Consider keeping `GraphicsCommand::parse()` and `decode_*` methods in types.rs or a separate `parsing.rs` + +--- + +### Extraction 3: Image Storage + +**Location:** Lines 791-1807 + +**Components to extract:** +- `ImageStorage` struct and impl (lines 791-1807) +- `ChunkBuffer` struct (lines 808-813) + +**Suggested file:** `src/graphics/storage.rs` + +**Dependencies:** +```rust +use std::collections::HashMap; +use std::time::Instant; +use super::types::*; +use super::animation::*; +``` + +**Challenges:** +- This is the largest section (~1000 lines) +- Contains many handler methods (`handle_transmit`, `handle_put`, etc.) +- Could be further split into: + - `storage.rs` - Core storage and simple operations + - `handlers.rs` - Command handlers + - `animation_handlers.rs` - Animation-specific handlers (lines 1026-1399) + +--- + +### Extraction 4: Base64 Utility + +**Location:** Lines 1809-1817 + +**Components to extract:** +- `base64_decode()` function + +**Suggested file:** Could go in a general `src/utils.rs` or stay in graphics + +**Dependencies:** +```rust +use base64::Engine; +use super::GraphicsError; +``` + +**Challenges:** Minimal - this is a simple utility function + +--- + +### Recommended Graphics Module Structure + +``` +src/ + graphics/ + mod.rs # Re-exports, module declarations + types.rs # Enums, structs, GraphicsCommand parsing + animation.rs # AnimationData, AnimationFrame, GIF/WebM decoding + storage.rs # ImageStorage, placement logic + handlers.rs # Command handlers (optional further split) +``` + +**mod.rs example:** +```rust +mod animation; +mod handlers; +mod storage; +mod types; + +pub use animation::{AnimationData, AnimationFrame, AnimationState, decode_gif}; +pub use storage::ImageStorage; +pub use types::*; + +#[cfg(feature = "webm")] +pub use animation::decode_webm; +``` + +--- + +## Implementation Priority + +1. **High Priority:** Split `graphics.rs` - it's nearly 1900 lines and hard to navigate +2. **Low Priority:** `keyboard.rs` is fine as-is but could be modularized if protocol support expands +3. **No Action:** `pty.rs` is well-organized + +--- + +## Migration Notes + +When splitting `graphics.rs`: + +1. Start by creating `src/graphics/` directory +2. Move `graphics.rs` to `src/graphics/mod.rs` temporarily +3. Extract types first (fewest dependencies) +4. Extract animation module +5. Extract storage module +6. Update imports in `renderer.rs`, `terminal.rs`, and any other consumers +7. Run tests after each extraction to catch breakages + +The tests at the bottom of `graphics.rs` (lines 1819-1845) should remain in `mod.rs` or be split into module-specific test files. diff --git a/PKGBUILD b/PKGBUILD index 15184f2..9e39f1c 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -18,7 +18,7 @@ source=() build() { cd "$startdir" - cargo build --release --locked + cargo build --release --features production } package() { diff --git a/REDUNDANCY_AUDIT.md b/REDUNDANCY_AUDIT.md new file mode 100644 index 0000000..878b177 --- /dev/null +++ b/REDUNDANCY_AUDIT.md @@ -0,0 +1,705 @@ +# ZTerm Redundancy Audit + +Generated: 2025-12-20 + +## Summary + +| File | Current Lines | Est. Savings | % Reduction | +|------|---------------|--------------|-------------| +| renderer.rs | 8426 | ~1000-1400 | 12-17% | +| terminal.rs | 2366 | ~150-180 | ~7% | +| main.rs | 2787 | ~150-180 | ~6% | +| vt_parser.rs | 1044 | ~60-90 | 6-9% | +| Other files | ~3000 | ~100 | ~3% | +| **Total** | **~17600** | **~1500-2000** | **~10%** | + +--- + +## High Priority + +### 1. renderer.rs: Pipeline Creation Boilerplate +**Lines:** 2200-2960 (~760 lines) +**Estimated Savings:** 400-500 lines + +**Problem:** Nearly identical pipeline creation code with slight variations in entry points, blend modes, and bind group layouts. + +**Suggested Fix:** Create a pipeline builder: +```rust +struct PipelineBuilder<'a> { + device: &'a wgpu::Device, + shader: &'a wgpu::ShaderModule, + layout: &'a wgpu::PipelineLayout, + format: wgpu::TextureFormat, +} + +impl PipelineBuilder<'_> { + fn build(self, vs_entry: &str, fs_entry: &str, blend: Option) -> wgpu::RenderPipeline +} +``` + +--- + +### 2. renderer.rs: Box Drawing Character Rendering +**Lines:** 4800-5943 (~1150 lines) +**Estimated Savings:** 400-600 lines + +**Problem:** Massive match statement with repeated `hline()`/`vline()`/`fill_rect()` patterns. Many similar light/heavy/double line variants. + +**Suggested Fix:** +- Use lookup tables for line positions/thicknesses +- Create a `BoxDrawingSpec` struct with line positions +- Consolidate repeated drawing patterns into parameterized helpers + +--- + +### 3. main.rs: Duplicate NamedKey-to-String Matching +**Lines:** 1778-1812, 2146-2181 +**Estimated Savings:** ~70 lines + +**Problem:** `check_keybinding()` and `handle_keyboard_input()` both have nearly identical `NamedKey` to string/FunctionalKey matching logic for F1-F12, arrow keys, Home/End/PageUp/PageDown, etc. + +**Suggested Fix:** +```rust +fn named_key_to_str(named: &NamedKey) -> Option<&'static str> { ... } +fn named_key_to_functional(named: &NamedKey) -> Option { ... } +``` + +--- + +### 4. terminal.rs: Duplicated SGR Extended Color Parsing +**Lines:** 2046-2106 +**Estimated Savings:** ~30 lines + +**Problem:** SGR 38 (foreground) and SGR 48 (background) extended color parsing logic is nearly identical - duplicated twice each (once for sub-params, once for regular params). + +**Suggested Fix:** +```rust +fn parse_extended_color(&self, params: &CsiParams, i: &mut usize) -> Option { + let mode = params.get(*i + 1, 0); + if mode == 5 && *i + 2 < params.num_params { + *i += 2; + Some(Color::Indexed(params.params[*i] as u8)) + } else if mode == 2 && *i + 4 < params.num_params { + let color = Color::Rgb( + params.params[*i + 2] as u8, + params.params[*i + 3] as u8, + params.params[*i + 4] as u8, + ); + *i += 4; + Some(color) + } else { + None + } +} +``` + +--- + +### 5. terminal.rs: Cursor-Row-With-Scroll Pattern +**Lines:** 1239-1246, 1262-1270, 1319-1324, 1836-1842, 1844-1851, 1916-1922, 1934-1940 +**Estimated Savings:** ~25 lines + +**Problem:** The pattern "increment cursor_row, check against scroll_bottom, scroll_up(1) if needed" is repeated 7 times. + +**Suggested Fix:** +```rust +#[inline] +fn advance_row(&mut self) { + self.cursor_row += 1; + if self.cursor_row > self.scroll_bottom { + self.scroll_up(1); + self.cursor_row = self.scroll_bottom; + } +} +``` + +--- + +### 6. terminal.rs: Cell Construction with Current Attributes +**Lines:** 1278-1287, 1963-1972, 1985-1994 +**Estimated Savings:** ~20 lines + +**Problem:** `Cell` construction using current attributes is repeated 3 times with nearly identical code. + +**Suggested Fix:** +```rust +#[inline] +fn current_cell(&self, character: char, wide_continuation: bool) -> Cell { + Cell { + character, + fg_color: self.current_fg, + bg_color: self.current_bg, + bold: self.current_bold, + italic: self.current_italic, + underline_style: self.current_underline_style, + strikethrough: self.current_strikethrough, + wide_continuation, + } +} +``` + +--- + +### 7. config.rs: normalize_key_name Allocates String for Static Values +**Lines:** 89-160 +**Estimated Savings:** Eliminates 50+ string allocations + +**Problem:** Every match arm allocates a new String even though most are static: +```rust +"left" | "arrowleft" | "arrow_left" => "left".to_string(), +``` + +**Suggested Fix:** +```rust +fn normalize_key_name(name: &str) -> Cow<'static, str> { + match name { + "left" | "arrowleft" | "arrow_left" => Cow::Borrowed("left"), + // ... + _ => Cow::Owned(name.to_string()), + } +} +``` + +--- + +### 8. config.rs: Repeated Parse-and-Insert Blocks in build_action_map +**Lines:** 281-349 +**Estimated Savings:** ~40 lines + +**Problem:** 20+ repeated blocks: +```rust +if let Some(parsed) = self.new_tab.parse() { + map.insert(parsed, Action::NewTab); +} +``` + +**Suggested Fix:** +```rust +let bindings: &[(&Keybind, Action)] = &[ + (&self.new_tab, Action::NewTab), + (&self.next_tab, Action::NextTab), + // ... +]; +for (keybind, action) in bindings { + if let Some(parsed) = keybind.parse() { + map.insert(parsed, *action); + } +} +``` + +--- + +### 9. vt_parser.rs: Duplicate OSC/String Command Terminator Handling +**Lines:** 683-755, 773-843 +**Estimated Savings:** ~30 lines + +**Problem:** `consume_osc` and `consume_string_command` have nearly identical structure for finding and handling terminators (ESC, BEL, C1 ST). + +**Suggested Fix:** Extract a common helper: +```rust +fn consume_st_terminated( + &mut self, + bytes: &[u8], + pos: usize, + buffer: &mut Vec, + include_bel: bool, + on_complete: F, +) -> usize +where + F: FnOnce(&mut Self, &[u8]) +``` + +--- + +### 10. vt_parser.rs: Duplicate Control Char Handling in CSI States +**Lines:** 547-551, 593-596, 650-653 +**Estimated Savings:** ~15 lines + +**Problem:** Identical control character handling appears in all three `CsiState` match arms: +```rust +0x00..=0x1F => { + if ch != 0x1B { + handler.control(ch); + } +} +``` + +**Suggested Fix:** Move control char handling before the `match self.csi.state` block: +```rust +if ch <= 0x1F && ch != 0x1B { + handler.control(ch); + consumed += 1; + continue; +} +``` + +--- + +### 11. main.rs: Repeated active_tab().and_then(active_pane()) Pattern +**Lines:** Various (10+ occurrences) +**Estimated Savings:** ~30 lines + +**Problem:** This nested Option chain appears throughout the code. + +**Suggested Fix:** +```rust +fn active_pane(&self) -> Option<&Pane> { + self.active_tab().and_then(|t| t.active_pane()) +} + +fn active_pane_mut(&mut self) -> Option<&mut Pane> { + self.active_tab_mut().and_then(|t| t.active_pane_mut()) +} +``` + +--- + +## Medium Priority + +### 12. renderer.rs: set_scale_factor and set_font_size Duplicate Cell Metric Recalc +**Lines:** 3306-3413 +**Estimated Savings:** ~50 lines + +**Problem:** ~100 lines of nearly identical cell metric recalculation logic. + +**Suggested Fix:** Extract to a shared `recalculate_cell_metrics(&mut self)` method. + +--- + +### 13. renderer.rs: find_font_for_char and find_color_font_for_char Similar +**Lines:** 939-1081 +**Estimated Savings:** ~60-80 lines + +**Problem:** ~140 lines of similar fontconfig query patterns. + +**Suggested Fix:** Extract common fontconfig query helper, parameterize the charset/color requirements. + +--- + +### 14. renderer.rs: place_glyph_in_cell_canvas vs Color Variant +**Lines:** 6468-6554 +**Estimated Savings:** ~40-50 lines + +**Problem:** ~90 lines of nearly identical logic, differing only in bytes-per-pixel (1 vs 4). + +**Suggested Fix:** +```rust +fn place_glyph_in_cell_canvas_impl(&self, bitmap: &[u8], ..., bytes_per_pixel: usize) -> Vec +``` + +--- + +### 15. renderer.rs: render_rect vs render_overlay_rect Near-Identical +**Lines:** 7192-7215 +**Estimated Savings:** ~10 lines + +**Problem:** Near-identical functions pushing to different Vec. + +**Suggested Fix:** +```rust +fn render_quad(&mut self, x: f32, y: f32, w: f32, h: f32, color: [f32; 4], overlay: bool) +``` + +--- + +### 16. renderer.rs: Pane Border Adjacency Checks Repeated +**Lines:** 7471-7587 +**Estimated Savings:** ~60-80 lines + +**Problem:** ~120 lines of repetitive adjacency detection for 4 directions. + +**Suggested Fix:** +```rust +fn check_pane_adjacency(&self, a: &PaneInfo, b: &PaneInfo) -> Vec +``` + +--- + +### 17. terminal.rs: to_rgba and to_rgba_bg Nearly Identical +**Lines:** 212-239 +**Estimated Savings:** ~15 lines + +**Problem:** These two methods differ only in the `Color::Default` case. + +**Suggested Fix:** +```rust +pub fn to_rgba(&self, color: &Color, is_bg: bool) -> [f32; 4] { + let [r, g, b] = match color { + Color::Default => if is_bg { self.default_bg } else { self.default_fg }, + Color::Rgb(r, g, b) => [*r, *g, *b], + Color::Indexed(idx) => self.colors[*idx as usize], + }; + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] +} +``` + +--- + +### 18. terminal.rs: insert_lines/delete_lines Share Dirty Marking +**Lines:** 1080-1134 +**Estimated Savings:** ~6 lines + +**Problem:** Both use identical dirty marking loop that could use existing `mark_region_dirty`. + +**Suggested Fix:** Replace: +```rust +for line in self.cursor_row..=self.scroll_bottom { + self.mark_line_dirty(line); +} +``` +with: +```rust +self.mark_region_dirty(self.cursor_row, self.scroll_bottom); +``` + +--- + +### 19. terminal.rs: handle_dec_private_mode_set/reset Are Mirror Images +**Lines:** 2148-2295 +**Estimated Savings:** ~50 lines + +**Problem:** ~70 lines each, almost mirror images with `true` vs `false`. + +**Suggested Fix:** +```rust +fn handle_dec_private_mode(&mut self, params: &CsiParams, set: bool) { + for i in 0..params.num_params { + match params.params[i] { + 1 => self.application_cursor_keys = set, + 7 => self.auto_wrap = set, + 25 => self.cursor_visible = set, + // ... + } + } +} +``` + +--- + +### 20. main.rs: Duplicate Git Status String Building +**Lines:** 1095-1118 +**Estimated Savings:** ~15 lines + +**Problem:** Identical logic for building `working_string` and `staging_string` with `~N`, `+N`, `-N` format. + +**Suggested Fix:** +```rust +fn format_git_changes(modified: usize, added: usize, deleted: usize) -> String { + let mut parts = Vec::new(); + if modified > 0 { parts.push(format!("~{}", modified)); } + if added > 0 { parts.push(format!("+{}", added)); } + if deleted > 0 { parts.push(format!("-{}", deleted)); } + parts.join(" ") +} +``` + +--- + +### 21. main.rs: Repeated StatuslineComponent RGB Color Application +**Lines:** 1169-1212 +**Estimated Savings:** ~10 lines + +**Problem:** Multiple `StatuslineComponent::new(...).rgb_fg(fg_color.0, fg_color.1, fg_color.2)` calls with exact same color. + +**Suggested Fix:** +```rust +let with_fg = |text: &str| StatuslineComponent::new(text).rgb_fg(fg_color.0, fg_color.1, fg_color.2); +components.push(with_fg(" ")); +components.push(with_fg(&head_text)); +``` + +--- + +### 22. main.rs: Tab1-Tab9 as Separate Match Arms +**Lines:** 1862-1870 +**Estimated Savings:** ~8 lines + +**Problem:** Nine separate match arms each calling `self.switch_to_tab(N)`. + +**Suggested Fix:** +```rust +impl Action { + fn tab_index(&self) -> Option { + match self { + Action::Tab1 => Some(0), + Action::Tab2 => Some(1), + // ... + } + } +} +// Then in match: +action if action.tab_index().is_some() => { + self.switch_to_tab(action.tab_index().unwrap()); +} +``` + +--- + +### 23. keyboard.rs: encode_arrow and encode_f1_f4 Are Identical +**Lines:** 462-485 +**Estimated Savings:** ~15 lines + +**Problem:** These two methods have identical implementations. + +**Suggested Fix:** Remove `encode_f1_f4` and use `encode_arrow` for both, or rename to `encode_ss3_key`. + +--- + +### 24. keyboard.rs: Repeated to_string().as_bytes() Allocations +**Lines:** 356, 365, 372, 378, 466, 479, 490, 493, 554 +**Estimated Savings:** Reduced allocations + +**Problem:** Multiple places call `.to_string().as_bytes()` on integers. + +**Suggested Fix:** +```rust +fn write_u32_to_vec(n: u32, buf: &mut Vec) { + use std::io::Write; + write!(buf, "{}", n).unwrap(); +} +``` +Or use `itoa` crate for zero-allocation integer formatting. + +--- + +### 25. graphics.rs: Duplicate AnimationData Construction +**Lines:** 444-456, 623-635 +**Estimated Savings:** ~10 lines + +**Problem:** Both `decode_gif` and `decode_webm` create identical `AnimationData` structs. + +**Suggested Fix:** +```rust +impl AnimationData { + pub fn new(frames: Vec, total_duration_ms: u64) -> Self { + Self { + frames, + current_frame: 0, + frame_start: None, + looping: true, + total_duration_ms, + state: AnimationState::Running, + loops_remaining: None, + } + } +} +``` + +--- + +### 26. graphics.rs: Duplicate RGBA Stride Handling +**Lines:** 554-566, 596-607 +**Estimated Savings:** ~15 lines + +**Problem:** Code for handling RGBA stride appears twice (nearly identical). + +**Suggested Fix:** +```rust +fn copy_with_stride(data: &[u8], width: u32, height: u32, stride: usize) -> Vec { + let row_bytes = (width * 4) as usize; + if stride == row_bytes { + data[..(width * height * 4) as usize].to_vec() + } else { + let mut result = Vec::with_capacity((width * height * 4) as usize); + for row in 0..height as usize { + let start = row * stride; + result.extend_from_slice(&data[start..start + row_bytes]); + } + result + } +} +``` + +--- + +### 27. graphics.rs: Duplicate File/TempFile/SharedMemory Reading Logic +**Lines:** 1051-1098, 1410-1489 +**Estimated Savings:** ~30 lines + +**Problem:** Both `handle_animation_frame` and `store_image` have similar file reading logic. + +**Suggested Fix:** +```rust +fn load_transmission_data(&mut self, cmd: &mut GraphicsCommand) -> Result, GraphicsError> +``` + +--- + +### 28. pty.rs: Duplicate Winsize/ioctl Pattern +**Lines:** 57-67, 170-185 +**Estimated Savings:** ~8 lines + +**Problem:** Same `libc::winsize` struct creation and `TIOCSWINSZ` ioctl pattern duplicated. + +**Suggested Fix:** +```rust +fn set_winsize(fd: RawFd, cols: u16, rows: u16, xpixel: u16, ypixel: u16) -> Result<(), PtyError> { + let winsize = libc::winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: xpixel, + ws_ypixel: ypixel, + }; + let result = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &winsize) }; + if result == -1 { + Err(PtyError::Io(std::io::Error::last_os_error())) + } else { + Ok(()) + } +} +``` + +--- + +### 29. vt_parser.rs: Repeated Max Length Check Pattern +**Lines:** 537-541, 693-697, 745-749, 785-789, 831-835 +**Estimated Savings:** ~15 lines + +**Problem:** This pattern appears 5 times: +```rust +if self.escape_len + X > MAX_ESCAPE_LEN { + log::debug!("... sequence too long, aborting"); + self.state = State::Normal; + return consumed; +} +``` + +**Suggested Fix:** +```rust +#[inline] +fn check_max_len(&mut self, additional: usize) -> bool { + if self.escape_len + additional > MAX_ESCAPE_LEN { + log::debug!("Escape sequence too long, aborting"); + self.state = State::Normal; + true + } else { + false + } +} +``` + +--- + +## Low Priority + +### 30. main.rs: PaneId/TabId Have Identical Implementations +**Lines:** 230-239, 694-703 +**Estimated Savings:** ~15 lines + +**Problem:** Both use identical `new()` implementations with static atomics. + +**Suggested Fix:** +```rust +macro_rules! define_id { + ($name:ident) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct $name(u64); + + impl $name { + pub fn new() -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT_ID: AtomicU64 = AtomicU64::new(1); + Self(NEXT_ID.fetch_add(1, Ordering::Relaxed)) + } + } + }; +} +define_id!(PaneId); +define_id!(TabId); +``` + +--- + +### 31. main.rs: Duplicate "All Tabs Closed" Exit Checks +**Lines:** 2622-2653 +**Estimated Savings:** ~5 lines + +**Problem:** Two separate checks with same log message in `about_to_wait()`. + +**Suggested Fix:** Restructure to have a single exit point after cleanup logic. + +--- + +### 32. terminal.rs: scroll_viewport_up/down Similarity +**Lines:** 885-915 +**Estimated Savings:** ~8 lines + +**Problem:** Similar structure - both check for alternate screen, calculate offset, set dirty. + +**Suggested Fix:** Merge into single method with signed delta parameter. + +--- + +### 33. terminal.rs: screen_alignment Cell Construction Verbose +**Lines:** 1881-1890 +**Estimated Savings:** ~6 lines + +**Problem:** Constructs Cell with all default values explicitly. + +**Suggested Fix:** +```rust +*cell = Cell { character: 'E', ..Cell::default() }; +``` + +--- + +### 34. keyboard.rs: Repeated UTF-8 Char Encoding Pattern +**Lines:** 503-505, 529-532, 539-541 +**Estimated Savings:** ~8 lines + +**Problem:** Pattern appears 3 times: +```rust +let mut buf = [0u8; 4]; +let s = c.encode_utf8(&mut buf); +return s.as_bytes().to_vec(); +``` + +**Suggested Fix:** +```rust +fn char_to_vec(c: char) -> Vec { + let mut buf = [0u8; 4]; + c.encode_utf8(&mut buf).as_bytes().to_vec() +} +``` + +--- + +### 35. config.rs: Tab1-Tab9 as Separate Enum Variants/Fields +**Lines:** 174-182, 214-230 +**Estimated Savings:** Structural improvement + +**Problem:** Having separate `Tab1`, `Tab2`, ... `Tab9` variants and corresponding struct fields is verbose. + +**Suggested Fix:** Use `Tab(u8)` variant and `tab_keys: [Keybind; 9]` array. Note: This changes the JSON config format. + +--- + +### 36. pty.rs: Inconsistent AsRawFd Usage +**Lines:** 63, 177 +**Estimated Savings:** Cleanup only + +**Problem:** Uses fully-qualified `std::os::fd::AsRawFd::as_raw_fd` despite importing the trait. + +**Suggested Fix:** +```rust +// Change from: +let fd = std::os::fd::AsRawFd::as_raw_fd(&master); +// To: +let fd = master.as_raw_fd(); +``` + +--- + +### 37. pty.rs: Repeated /proc Path Pattern +**Lines:** 222-243 +**Estimated Savings:** ~4 lines + +**Problem:** Both `foreground_process_name` and `foreground_cwd` build `/proc/{pgid}/...` paths similarly. + +**Suggested Fix:** +```rust +fn proc_path(&self, file: &str) -> Option { + let pgid = self.foreground_pgid()?; + Some(std::path::PathBuf::from(format!("/proc/{}/{}", pgid, file))) +} +``` diff --git a/RENDERER_REORG.md b/RENDERER_REORG.md new file mode 100644 index 0000000..d906894 --- /dev/null +++ b/RENDERER_REORG.md @@ -0,0 +1,314 @@ +# Renderer.rs Reorganization Analysis + +This document analyzes `/src/renderer.rs` (8189 lines) and identifies sections that could be extracted into separate modules for better code organization and maintainability. + +## Overview + +The `renderer.rs` file is the largest file in the codebase and handles: +- GPU pipeline setup and rendering +- Font loading and glyph caching +- Color/emoji font rendering via FreeType/Cairo +- Box drawing character generation +- Statusline rendering +- Image/Kitty graphics protocol support +- Edge glow effects + +--- + +## Extraction Opportunities + +### 1. Color Utilities / Linear Palette +**Lines:** 23-77 +**Suggested file:** `src/color.rs` or `src/palette.rs` + +**Contents:** +- `LinearPalette` struct +- `srgb_to_linear()` function +- `make_linear_palette()` function + +**Dependencies:** +- `crate::config::ColorScheme` + +**Complexity:** Low - self-contained utility code with minimal dependencies. + +```rust +pub struct LinearPalette { + pub foreground: [f32; 4], + pub background: [f32; 4], + pub cursor: [f32; 4], + pub colors: [[f32; 4]; 256], +} +``` + +--- + +### 2. Statusline Types +**Lines:** 123-258 +**Suggested file:** `src/statusline.rs` + +**Contents:** +- `StatuslineColor` enum +- `StatuslineComponent` struct +- `StatuslineSection` struct +- `StatuslineContent` struct and its `impl` block + +**Dependencies:** +- Standard library only + +**Complexity:** Low - pure data structures with simple logic. + +**Note:** The `parse_ansi_statusline` method (lines 4029-4144) should also move here. + +--- + +### 3. Edge Glow Animation +**Lines:** 260-307 +**Suggested file:** `src/effects.rs` or `src/edge_glow.rs` + +**Contents:** +- `EdgeGlowSide` enum +- `EdgeGlow` struct +- Animation logic (`update`, `is_active`, `intensity` methods) + +**Dependencies:** +- `std::time::Instant` + +**Complexity:** Low - isolated animation state machine. + +--- + +### 4. GPU Data Structures +**Lines:** 399-710 +**Suggested file:** `src/gpu_types.rs` + +**Contents:** +- `GlyphVertex` struct +- `GlowInstance` struct +- `EdgeGlowUniforms` struct +- `ImageUniforms` struct +- `GPUCell` struct +- `SpriteInfo` struct +- `GridParams` struct +- `Quad` struct +- `QuadParams` struct +- `StatuslineParams` struct +- Various constants (`GLYPH_ATLAS_SIZE`, `CELL_INSTANCE_SIZE`, etc.) + +**Dependencies:** +- `bytemuck::{Pod, Zeroable}` + +**Complexity:** Low - pure data structures with `#[repr(C)]` layouts for GPU compatibility. + +--- + +### 5. Font Loading Helpers +**Lines:** 929-996, 1002-1081, 1382-1565 +**Suggested file:** `src/font_loader.rs` + +**Contents:** +- `find_font_for_char()` - finds a font file that can render a given character +- `find_color_font_for_char()` - finds color emoji fonts +- `load_font_variant()` - loads a specific font variant (bold, italic, etc.) +- `find_font_family_variants()` - discovers all variants of a font family +- `load_font_family()` - loads an entire font family + +**Dependencies:** +- `fontconfig` crate +- `fontdue` crate +- `std::fs` +- `std::collections::HashMap` + +**Complexity:** Medium - these are standalone functions but have some interdependencies. Would need to pass fontconfig patterns and settings as parameters. + +--- + +### 6. Color Font Renderer (Emoji) +**Lines:** 1083-1380 +**Suggested file:** `src/color_font.rs` or `src/emoji_renderer.rs` + +**Contents:** +- `ColorFontRenderer` struct +- FreeType library/face management +- Cairo surface rendering +- Emoji glyph rasterization + +**Dependencies:** +- `freetype` crate +- `cairo` crate +- `std::collections::HashMap` +- `std::ptr` + +**Complexity:** Medium - self-contained but uses unsafe code and external C libraries. The struct is instantiated inside `Renderer::new()`. + +```rust +struct ColorFontRenderer { + library: freetype::Library, + faces: HashMap, +} +``` + +--- + +### 7. Box Drawing / Supersampled Canvas +**Lines:** 1567-1943, 4500-5706 +**Suggested file:** `src/box_drawing.rs` + +**Contents:** +- `Corner` enum +- `SupersampledCanvas` struct +- Canvas rendering methods (lines, arcs, shading, etc.) +- `render_box_char()` method (1200+ lines of box drawing logic) + +**Dependencies:** +- Standard library only + +**Complexity:** High - the `render_box_char` method is massive (1200+ lines) and handles all Unicode box drawing, block elements, and legacy graphics characters. However, it's functionally isolated. + +**Recommendation:** This is the highest-value extraction target. The box drawing code is: +1. Completely self-contained +2. Very large (adds ~2400 lines) +3. Rarely needs modification +4. Easy to test in isolation + +--- + +### 8. Pipeline Builder +**Lines:** 1947-2019 +**Suggested file:** `src/pipeline.rs` + +**Contents:** +- `PipelineBuilder` struct +- Builder pattern for wgpu render pipelines + +**Dependencies:** +- `wgpu` crate + +**Complexity:** Low - clean builder pattern, easily extractable. + +```rust +struct PipelineBuilder<'a> { + device: &'a wgpu::Device, + label: &'a str, + // ... other fields +} +``` + +--- + +### 9. Pane GPU Resources +**Lines:** 105-121, 3710-3785 +**Suggested file:** `src/pane_resources.rs` + +**Contents:** +- `PaneGpuResources` struct +- Buffer management for per-pane GPU state +- Methods: `new()`, `ensure_grid_capacity()`, `ensure_glyph_capacity()` + +**Dependencies:** +- `wgpu` crate + +**Complexity:** Medium - the struct is simple but tightly coupled to the `Renderer` for buffer creation. Would need to pass `device` as parameter. + +--- + +### 10. Image Rendering / Kitty Graphics +**Lines:** 7940-8186 (+ `GpuImage` at 483-491) +**Suggested file:** `src/image_renderer.rs` + +**Contents:** +- `GpuImage` struct +- `upload_image()` method +- `remove_image()` method +- `sync_images()` method +- `prepare_image_renders()` method + +**Dependencies:** +- `wgpu` crate +- `crate::terminal::ImageData` + +**Complexity:** Medium - these methods operate on `Renderer` state but could be extracted into a helper struct that holds image-specific GPU resources. + +--- + +## Recommended Extraction Order + +Based on complexity and value, here's a suggested order: + +| Priority | Module | Lines Saved | Complexity | Value | +|----------|--------|-------------|------------|-------| +| 1 | `box_drawing.rs` | ~2400 | High | Very High | +| 2 | `gpu_types.rs` | ~310 | Low | High | +| 3 | `color_font.rs` | ~300 | Medium | High | +| 4 | `font_loader.rs` | ~330 | Medium | Medium | +| 5 | `statusline.rs` | ~250 | Low | Medium | +| 6 | `pipeline.rs` | ~75 | Low | Medium | +| 7 | `color.rs` | ~55 | Low | Low | +| 8 | `edge_glow.rs` | ~50 | Low | Low | +| 9 | `pane_resources.rs` | ~80 | Medium | Medium | +| 10 | `image_renderer.rs` | ~250 | Medium | Medium | + +--- + +## Implementation Notes + +### The Renderer Struct (Lines 712-927) + +The main `Renderer` struct ties everything together and would remain in `renderer.rs`. After extraction, it would: + +1. Import types from the new modules +2. Potentially hold instances of extracted helper structs (e.g., `ColorFontRenderer`, `ImageRenderer`) +3. Still contain the core rendering logic (`render()`, `prepare_pane_data()`, etc.) + +### Module Structure + +After refactoring, the structure might look like: + +``` +src/ +├── renderer/ +│ ├── mod.rs # Main Renderer struct and render logic +│ ├── box_drawing.rs # SupersampledCanvas + render_box_char +│ ├── color_font.rs # ColorFontRenderer for emoji +│ ├── font_loader.rs # Font discovery and loading +│ ├── gpu_types.rs # GPU data structures +│ ├── image.rs # Kitty graphics support +│ ├── pipeline.rs # PipelineBuilder +│ ├── statusline.rs # Statusline types and parsing +│ └── effects.rs # EdgeGlow and other effects +├── color.rs # LinearPalette (or keep in renderer/) +└── ... +``` + +### Challenges + +1. **Circular dependencies:** The `Renderer` struct is used throughout. Extracted modules should receive what they need via parameters, not by importing `Renderer`. + +2. **GPU resources:** Many extracted components need `&wgpu::Device` and `&wgpu::Queue`. These should be passed as parameters rather than stored. + +3. **Method extraction:** Some methods like `render_box_char` are currently `impl Renderer` methods. They'd need to become standalone functions or methods on the extracted structs. + +4. **Testing:** Extracted modules will be easier to unit test, which is a significant benefit. + +--- + +## Quick Wins + +These can be extracted with minimal refactoring: + +1. **`gpu_types.rs`** - Just move the structs and constants, add `pub use` in renderer +2. **`color.rs`** - Move `LinearPalette` and helper functions +3. **`pipeline.rs`** - Move `PipelineBuilder` as-is +4. **`edge_glow.rs`** - Move `EdgeGlow` and related types + +--- + +## Conclusion + +The `renderer.rs` file is doing too much. Extracting the identified modules would: + +- Reduce `renderer.rs` from ~8200 lines to ~4000-4500 lines +- Improve code organization and discoverability +- Enable better unit testing of isolated components +- Make the codebase more approachable for new contributors + +The highest-impact extraction is `box_drawing.rs`, which alone would remove ~2400 lines of self-contained code. diff --git a/TERMINAL_REORG.md b/TERMINAL_REORG.md new file mode 100644 index 0000000..47d229a --- /dev/null +++ b/TERMINAL_REORG.md @@ -0,0 +1,356 @@ +# Terminal.rs Reorganization Plan + +This document identifies sections of `src/terminal.rs` (2336 lines) that could be extracted into separate files to improve code organization, maintainability, and testability. + +--- + +## Summary of Proposed Extractions + +| New File | Lines | Primary Contents | +|----------|-------|------------------| +| `src/cell.rs` | ~90 | `Cell`, `Color`, `CursorShape` | +| `src/color.rs` | ~115 | `ColorPalette` and color parsing | +| `src/mouse.rs` | ~110 | `MouseTrackingMode`, `MouseEncoding`, `encode_mouse()` | +| `src/scrollback.rs` | ~100 | `ScrollbackBuffer` ring buffer | +| `src/terminal_commands.rs` | ~30 | `TerminalCommand`, `Direction` | +| `src/stats.rs` | ~45 | `ProcessingStats` | + +**Total extractable: ~490 lines** (reducing terminal.rs by ~21%) + +--- + +## 1. Cell Types and Color Enum + +**Lines:** 30-87 (Cell, Color, CursorShape) + +**Contents:** +- `Cell` struct (lines 31-45) - terminal grid cell with character and attributes +- `impl Default for Cell` (lines 47-60) +- `Color` enum (lines 63-69) - `Default`, `Rgb(u8, u8, u8)`, `Indexed(u8)` +- `CursorShape` enum (lines 72-87) - cursor style variants + +**Proposed file:** `src/cell.rs` + +**Dependencies:** +```rust +// No external dependencies - these are self-contained types +``` + +**Exports needed by terminal.rs:** +```rust +pub use cell::{Cell, Color, CursorShape}; +``` + +**Challenges:** +- None. These are simple data types with no logic dependencies. + +**Benefits:** +- `Cell` and `Color` are referenced by renderer and could be imported directly +- Makes the core data structures discoverable +- Easy to test color conversion independently + +--- + +## 2. Color Palette + +**Lines:** 119-240 + +**Contents:** +- `ColorPalette` struct (lines 120-128) - 256-color palette storage +- `impl Default for ColorPalette` (lines 130-177) - ANSI + 216 color cube + grayscale +- `ColorPalette::parse_color_spec()` (lines 181-209) - parse `#RRGGBB` and `rgb:RR/GG/BB` +- `ColorPalette::to_rgba()` (lines 212-224) - foreground color conversion +- `ColorPalette::to_rgba_bg()` (lines 227-239) - background color conversion + +**Proposed file:** `src/color.rs` + +**Dependencies:** +```rust +use crate::cell::Color; // For Color enum +``` + +**Challenges:** +- Depends on `Color` enum (extract cell.rs first) +- `to_rgba()` and `to_rgba_bg()` are called by the renderer + +**Benefits:** +- Color parsing logic is self-contained and testable +- Palette initialization is complex (color cube math) and benefits from isolation +- Could add color scheme loading from config files in the future + +--- + +## 3. Mouse Tracking and Encoding + +**Lines:** 89-117, 962-1059 + +**Contents:** +- `MouseTrackingMode` enum (lines 90-103) +- `MouseEncoding` enum (lines 106-117) +- `Terminal::encode_mouse()` method (lines 964-1059) - encode mouse events for PTY + +**Proposed file:** `src/mouse.rs` + +**Dependencies:** +```rust +// MouseTrackingMode and MouseEncoding are self-contained enums +// encode_mouse() would need to be a standalone function or trait +``` + +**Refactoring approach:** +```rust +// In mouse.rs +pub fn encode_mouse_event( + tracking: MouseTrackingMode, + encoding: MouseEncoding, + button: u8, + col: u16, + row: u16, + pressed: bool, + is_motion: bool, + modifiers: u8, +) -> Vec +``` + +**Challenges:** +- `encode_mouse()` is currently a method on `Terminal` but only reads `mouse_tracking` and `mouse_encoding` +- Need to change call sites to pass mode/encoding explicitly, OR keep as Terminal method but move enums + +**Benefits:** +- Mouse protocol logic is self-contained and well-documented +- Could add unit tests for X10, SGR, URXVT encoding without instantiating Terminal + +--- + +## 4. Scrollback Buffer + +**Lines:** 314-425 + +**Contents:** +- `ScrollbackBuffer` struct (lines 326-335) - Kitty-style ring buffer +- `ScrollbackBuffer::new()` (lines 340-351) - lazy allocation +- `ScrollbackBuffer::len()`, `is_empty()`, `is_full()` (lines 354-369) +- `ScrollbackBuffer::push()` (lines 377-403) - O(1) ring buffer insertion +- `ScrollbackBuffer::get()` (lines 407-415) - logical index access +- `ScrollbackBuffer::clear()` (lines 419-424) + +**Proposed file:** `src/scrollback.rs` + +**Dependencies:** +```rust +use crate::cell::Cell; // For Vec line storage +``` + +**Challenges:** +- Depends on `Cell` type (extract cell.rs first) +- Otherwise completely self-contained with great documentation + +**Benefits:** +- Ring buffer is a reusable data structure +- Excellent candidate for unit testing (push, wrap-around, get by index) +- Performance-critical code that benefits from isolation for profiling + +--- + +## 5. Terminal Commands + +**Lines:** 8-28 + +**Contents:** +- `TerminalCommand` enum (lines 10-19) - commands sent from terminal to application +- `Direction` enum (lines 22-28) - navigation direction + +**Proposed file:** `src/terminal_commands.rs` + +**Dependencies:** +```rust +// No dependencies - self-contained enums +``` + +**Challenges:** +- Very small extraction, but cleanly separates protocol from implementation + +**Benefits:** +- Defines the terminal-to-application interface +- Could grow as more custom OSC commands are added +- Clear documentation of what commands exist + +--- + +## 6. Processing Stats + +**Lines:** 268-312 + +**Contents:** +- `ProcessingStats` struct (lines 269-288) - timing/debugging statistics +- `ProcessingStats::reset()` (lines 291-293) +- `ProcessingStats::log_if_slow()` (lines 295-311) - conditional performance logging + +**Proposed file:** `src/stats.rs` + +**Dependencies:** +```rust +// No dependencies - uses only log crate +``` + +**Challenges:** +- Only used for debugging/profiling +- Could be feature-gated behind a `profiling` feature flag + +**Benefits:** +- Separates debug instrumentation from core logic +- Could be conditionally compiled out for release builds + +--- + +## 7. Saved Cursor and Alternate Screen (Keep in terminal.rs) + +**Lines:** 243-265 + +**Contents:** +- `SavedCursor` struct (lines 243-253) +- `AlternateScreen` struct (lines 256-265) + +**Recommendation:** Keep in terminal.rs + +**Rationale:** +- These are private implementation details of cursor save/restore and alternate screen +- Tightly coupled to Terminal's internal state +- No benefit from extraction + +--- + +## 8. Handler Implementation (Keep in terminal.rs) + +**Lines:** 1236-1904, 1906-2335 + +**Contents:** +- `impl Handler for Terminal` - VT parser callback implementations +- CSI handling, SGR parsing, DEC private modes +- Keyboard protocol, OSC handling, graphics protocol + +**Recommendation:** Keep in terminal.rs (or consider splitting if file grows further) + +**Rationale:** +- These are the core terminal emulation callbacks +- Heavily intertwined with Terminal's internal state +- Could potentially split into `terminal_csi.rs`, `terminal_sgr.rs`, etc. but adds complexity + +--- + +## Recommended Extraction Order + +1. **`src/cell.rs`** - No dependencies, foundational types +2. **`src/terminal_commands.rs`** - No dependencies, simple enums +3. **`src/stats.rs`** - No dependencies, debugging utility +4. **`src/color.rs`** - Depends on `cell.rs`, self-contained logic +5. **`src/scrollback.rs`** - Depends on `cell.rs`, self-contained data structure +6. **`src/mouse.rs`** - Self-contained enums, may need refactoring for encode function + +--- + +## Example: cell.rs Implementation + +```rust +//! Terminal cell and color types. + +/// A single cell in the terminal grid. +#[derive(Clone, Copy, Debug)] +pub struct Cell { + pub character: char, + pub fg_color: Color, + pub bg_color: Color, + pub bold: bool, + pub italic: bool, + /// Underline style: 0=none, 1=single, 2=double, 3=curly, 4=dotted, 5=dashed + pub underline_style: u8, + /// Strikethrough decoration + pub strikethrough: bool, + /// If true, this cell is the continuation of a wide (double-width) character. + pub wide_continuation: bool, +} + +impl Default for Cell { + fn default() -> Self { + Self { + character: ' ', + fg_color: Color::Default, + bg_color: Color::Default, + bold: false, + italic: false, + underline_style: 0, + strikethrough: false, + wide_continuation: false, + } + } +} + +/// Terminal colors. +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum Color { + #[default] + Default, + Rgb(u8, u8, u8), + Indexed(u8), +} + +/// Cursor shape styles (DECSCUSR). +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum CursorShape { + #[default] + BlinkingBlock, + SteadyBlock, + BlinkingUnderline, + SteadyUnderline, + BlinkingBar, + SteadyBar, +} +``` + +--- + +## Impact on lib.rs + +After extraction, `lib.rs` would need: + +```rust +pub mod cell; +pub mod color; +pub mod mouse; +pub mod scrollback; +pub mod stats; +pub mod terminal; +pub mod terminal_commands; +// ... existing modules ... +``` + +And `terminal.rs` would add: + +```rust +use crate::cell::{Cell, Color, CursorShape}; +use crate::color::ColorPalette; +use crate::mouse::{MouseEncoding, MouseTrackingMode}; +use crate::scrollback::ScrollbackBuffer; +use crate::stats::ProcessingStats; +use crate::terminal_commands::{Direction, TerminalCommand}; +``` + +--- + +## Testing Opportunities + +Extracting these modules enables focused unit tests: + +- **cell.rs**: Default cell values, wide_continuation handling +- **color.rs**: `parse_color_spec()` for various formats, palette indexing, RGBA conversion +- **mouse.rs**: Encoding tests for X10, SGR, URXVT formats, tracking mode filtering +- **scrollback.rs**: Ring buffer push/get/wrap, capacity limits, clear behavior + +--- + +## Notes + +- The `Terminal` struct itself (lines 428-512) should remain in `terminal.rs` as the central state container +- Private helper structs like `SavedCursor` and `AlternateScreen` should stay in `terminal.rs` +- The `Handler` trait implementation spans ~600 lines but is core terminal logic +- Consider feature-gating `ProcessingStats` behind `#[cfg(feature = "profiling")]` diff --git a/VT_PARSER_REORG.md b/VT_PARSER_REORG.md new file mode 100644 index 0000000..a1b6479 --- /dev/null +++ b/VT_PARSER_REORG.md @@ -0,0 +1,295 @@ +# VT Parser Reorganization Recommendations + +This document analyzes `src/vt_parser.rs` (1033 lines) and identifies sections that could be extracted into separate files to improve code organization, testability, and maintainability. + +## Current File Structure Overview + +| Lines | Section | Description | +|-------|---------|-------------| +| 1-49 | Constants & UTF-8 Tables | Parser limits, UTF-8 DFA decode table | +| 51-133 | UTF-8 Decoder | `Utf8Decoder` struct and implementation | +| 135-265 | State & CSI Types | `State` enum, `CsiState` enum, `CsiParams` struct | +| 267-832 | Parser Core | Main `Parser` struct with all parsing logic | +| 835-906 | Handler Trait | `Handler` trait definition | +| 908-1032 | Tests | Unit tests | + +--- + +## Recommended Extractions + +### 1. UTF-8 Decoder Module + +**File:** `src/utf8_decoder.rs` + +**Lines:** 27-133 + +**Components:** +- `UTF8_ACCEPT`, `UTF8_REJECT` constants (lines 28-29) +- `UTF8_DECODE_TABLE` static (lines 33-49) +- `decode_utf8()` function (lines 52-62) +- `Utf8Decoder` struct and impl (lines 66-133) +- `REPLACEMENT_CHAR` constant (line 25) + +**Dependencies:** +- None (completely self-contained) + +**Rationale:** +- This is a completely standalone UTF-8 DFA decoder based on Bjoern Hoehrmann's design +- Zero dependencies on the rest of the parser +- Could be reused in other parts of the codebase (keyboard input, file parsing) +- Independently testable +- ~100 lines, a good size for a focused module + +**Extraction Difficulty:** Easy + +**Example structure:** +```rust +// src/utf8_decoder.rs +pub const REPLACEMENT_CHAR: char = '\u{FFFD}'; + +const UTF8_ACCEPT: u8 = 0; +const UTF8_REJECT: u8 = 12; + +static UTF8_DECODE_TABLE: [u8; 364] = [ /* ... */ ]; + +#[inline] +fn decode_utf8(state: &mut u8, codep: &mut u32, byte: u8) -> u8 { /* ... */ } + +#[derive(Debug, Default)] +pub struct Utf8Decoder { /* ... */ } + +impl Utf8Decoder { + pub fn new() -> Self { /* ... */ } + pub fn reset(&mut self) { /* ... */ } + pub fn decode_to_esc(&mut self, src: &[u8], output: &mut Vec) -> (usize, bool) { /* ... */ } +} +``` + +--- + +### 2. CSI Parameters Module + +**File:** `src/csi_params.rs` + +**Lines:** 14-265 (constants and CSI-related types) + +**Components:** +- `MAX_CSI_PARAMS` constant (line 15) +- `CsiState` enum (lines 165-171) +- `CsiParams` struct and impl (lines 174-265) + +**Dependencies:** +- None (self-contained data structure) + +**Rationale:** +- `CsiParams` is a self-contained data structure for CSI parameter parsing +- Has its own sub-state machine (`CsiState`) +- The struct is 2KB+ in size due to the arrays - isolating it makes the size impact clearer +- Could be tested independently for parameter parsing edge cases +- The `get()`, `add_digit()`, `commit_param()` methods form a cohesive unit + +**Extraction Difficulty:** Easy + +**Note:** `CsiState` is currently private and only used within CSI parsing. It should remain private to the module. + +--- + +### 3. Handler Trait Module + +**File:** `src/vt_handler.rs` + +**Lines:** 835-906 + +**Components:** +- `Handler` trait (lines 840-906) +- `CsiParams` would need to be re-exported or the trait would depend on `csi_params` module + +**Dependencies:** +- `CsiParams` type (for `csi()` method signature) + +**Rationale:** +- Clear separation between the parser implementation and the callback interface +- Makes it easier for consumers to implement handlers without pulling in parser internals +- Trait documentation is substantial and benefits from its own file +- Allows different modules to implement handlers without circular dependencies + +**Extraction Difficulty:** Easy (after `CsiParams` is extracted) + +--- + +### 4. Parser Constants Module + +**File:** `src/vt_constants.rs` (or inline in a `mod.rs` approach) + +**Lines:** 14-25 + +**Components:** +- `MAX_CSI_PARAMS` (already mentioned above) +- `MAX_OSC_LEN` (line 19) +- `MAX_ESCAPE_LEN` (line 22) +- `REPLACEMENT_CHAR` (line 25, if not moved to utf8_decoder) + +**Dependencies:** +- None + +**Rationale:** +- Centralizes magic numbers +- Easy to find and adjust limits +- However, these are only 4 constants, so this extraction is optional + +**Extraction Difficulty:** Trivial + +**Recommendation:** Keep these in the main parser file or move to a `mod.rs` if using a directory structure. + +--- + +### 5. Parser State Enum + +**File:** Could remain in `vt_parser.rs` or move to `vt_handler.rs` + +**Lines:** 136-162 + +**Components:** +- `State` enum (lines 136-156) +- `Default` impl (lines 158-162) + +**Dependencies:** +- None + +**Rationale:** +- The `State` enum is public and part of the `Parser` struct +- It's tightly coupled with the parser's operation +- Small enough (~25 lines) to not warrant its own file + +**Recommendation:** Keep in main parser file or combine with handler trait. + +--- + +## Proposed Directory Structure + +### Option A: Flat Module Structure (Recommended) + +``` +src/ + vt_parser.rs # Main Parser struct, State enum, parsing logic (~700 lines) + utf8_decoder.rs # UTF-8 DFA decoder (~110 lines) + csi_params.rs # CsiParams struct and CsiState (~100 lines) + vt_handler.rs # Handler trait (~75 lines) +``` + +**lib.rs changes:** +```rust +mod utf8_decoder; +mod csi_params; +mod vt_handler; +mod vt_parser; + +pub use vt_parser::{Parser, State}; +pub use csi_params::{CsiParams, MAX_CSI_PARAMS}; +pub use vt_handler::Handler; +``` + +### Option B: Directory Module Structure + +``` +src/ + vt_parser/ + mod.rs # Re-exports and constants + parser.rs # Main Parser struct + utf8.rs # UTF-8 decoder + csi.rs # CSI params + handler.rs # Handler trait + tests.rs # Tests (optional, can stay inline) +``` + +--- + +## Extraction Priority + +| Priority | Module | Lines Saved | Benefit | +|----------|--------|-------------|---------| +| 1 | `utf8_decoder.rs` | ~110 | Completely independent, reusable | +| 2 | `csi_params.rs` | ~100 | Clear data structure boundary | +| 3 | `vt_handler.rs` | ~75 | Cleaner API surface | +| 4 | Constants | ~10 | Optional, low impact | + +--- + +## Challenges and Considerations + +### 1. Test Organization +- Lines 908-1032 contain tests that use private test helpers (`TestHandler`) +- If the `Handler` trait is extracted, `TestHandler` could move to a test module +- Consider using `#[cfg(test)]` modules in each file + +### 2. Circular Dependencies +- `Handler` trait references `CsiParams` - extract `CsiParams` first +- `Parser` uses both `Utf8Decoder` and `CsiParams` - both should be extracted before any handler extraction + +### 3. Public API Surface +- Currently public: `MAX_CSI_PARAMS`, `State`, `CsiParams`, `Parser`, `Handler`, `Utf8Decoder` +- After extraction, ensure re-exports maintain the same public API + +### 4. Performance Considerations +- The UTF-8 decoder uses `#[inline]` extensively - ensure this is preserved +- `CsiParams::reset()` is hot and optimized to avoid memset - document this + +--- + +## Migration Steps + +1. **Extract `utf8_decoder.rs`** + - Move lines 25-133 to new file + - Add `mod utf8_decoder;` to lib.rs + - Update `vt_parser.rs` to `use crate::utf8_decoder::Utf8Decoder;` + +2. **Extract `csi_params.rs`** + - Move lines 14-15 (MAX_CSI_PARAMS) and 164-265 to new file + - Make `CsiState` private to the module (`pub(crate)` at most) + - Add `mod csi_params;` to lib.rs + +3. **Extract `vt_handler.rs`** + - Move lines 835-906 to new file + - Add `use crate::csi_params::CsiParams;` + - Add `mod vt_handler;` to lib.rs + +4. **Update imports in `vt_parser.rs`** + ```rust + use crate::utf8_decoder::Utf8Decoder; + use crate::csi_params::{CsiParams, CsiState, MAX_CSI_PARAMS}; + use crate::vt_handler::Handler; + ``` + +5. **Verify public API unchanged** + - Ensure lib.rs re-exports all previously public items + - Run tests to verify nothing broke + +--- + +## Code That Should Stay in `vt_parser.rs` + +The following should remain in the main parser file: + +- `State` enum (lines 136-162) - tightly coupled to parser +- `Parser` struct (lines 268-299) - core type +- All `Parser` methods (lines 301-832) - core parsing logic +- Constants `MAX_OSC_LEN`, `MAX_ESCAPE_LEN` (lines 19, 22) - parser-specific limits + +After extraction, `vt_parser.rs` would be ~700 lines focused purely on the state machine and escape sequence parsing logic. + +--- + +## Summary + +The `vt_parser.rs` file has clear natural boundaries: + +1. **UTF-8 decoding** - completely standalone, based on external algorithm +2. **CSI parameter handling** - self-contained data structure with its own state +3. **Handler trait** - defines the callback interface +4. **Core parser** - the state machine and escape sequence processing + +Extracting the first three would reduce `vt_parser.rs` from 1033 lines to ~700 lines while improving: +- Code navigation +- Testability of individual components +- Reusability of the UTF-8 decoder +- API clarity (handler trait in its own file) diff --git a/src/bin/bench_process.rs b/src/bin/bench_process.rs index 5e73ce6..b977f96 100644 --- a/src/bin/bench_process.rs +++ b/src/bin/bench_process.rs @@ -1,36 +1,143 @@ use zterm::terminal::Terminal; +use zterm::vt_parser::Parser; use std::time::Instant; use std::io::Write; -fn main() { - // Generate seq 1 100000 output - let mut data = Vec::new(); - for i in 1..=100000 { - writeln!(&mut data, "{}", i).unwrap(); +const ASCII_PRINTABLE: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ `~!@#$%^&*()_+-=[]{}\\|;:'\",<.>/?"; +const CONTROL_CHARS: &[u8] = b"\n\t"; + +// Match Kitty's default repetitions +const REPETITIONS: usize = 100; + +fn random_string(len: usize, rng: &mut u64) -> Vec { + let alphabet_len = (ASCII_PRINTABLE.len() + CONTROL_CHARS.len()) as u64; + let mut result = Vec::with_capacity(len); + for _ in 0..len { + // Simple LCG random + *rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1); + let idx = ((*rng >> 33) % alphabet_len) as usize; + if idx < ASCII_PRINTABLE.len() { + result.push(ASCII_PRINTABLE[idx]); + } else { + result.push(CONTROL_CHARS[idx - ASCII_PRINTABLE.len()]); + } } - println!("Data size: {} bytes", data.len()); - - // Test with different terminal sizes to see scroll impact - for rows in [24, 100, 1000] { - let mut terminal = Terminal::new(80, rows, 10000); - let start = Instant::now(); - terminal.process(&data); - let elapsed = start.elapsed(); - println!("Terminal {}x{}: {:?} ({:.2} MB/s)", - 80, rows, - elapsed, - (data.len() as f64 / 1024.0 / 1024.0) / elapsed.as_secs_f64() - ); - } - - // Test with scrollback disabled - println!("\nWith scrollback disabled:"); - let mut terminal = Terminal::new(80, 24, 0); - let start = Instant::now(); - terminal.process(&data); - let elapsed = start.elapsed(); - println!("Terminal 80x24, no scrollback: {:?} ({:.2} MB/s)", - elapsed, - (data.len() as f64 / 1024.0 / 1024.0) / elapsed.as_secs_f64() - ); + result +} + +/// Run a benchmark with multiple repetitions like Kitty does +fn run_benchmark(name: &str, data: &[u8], repetitions: usize, mut setup: F) +where + F: FnMut() -> (Terminal, Parser), +{ + let data_size = data.len(); + let total_size = data_size * repetitions; + + // Warmup run + let (mut terminal, mut parser) = setup(); + parser.parse(data, &mut terminal); + + // Timed runs + let start = Instant::now(); + for _ in 0..repetitions { + let (mut terminal, mut parser) = setup(); + parser.parse(data, &mut terminal); + } + let elapsed = start.elapsed(); + + let mb = total_size as f64 / 1024.0 / 1024.0; + let rate = mb / elapsed.as_secs_f64(); + + println!(" {:<24} : {:>6.2}s @ {:.1} MB/s ({} reps, {:.2} MB each)", + name, elapsed.as_secs_f64(), rate, repetitions, data_size as f64 / 1024.0 / 1024.0); +} + +fn main() { + println!("=== ZTerm VT Parser Benchmark ==="); + println!("Matching Kitty's kitten __benchmark__ methodology\n"); + + // Benchmark 1: Only ASCII chars (matches Kitty's simple_ascii) + println!("--- Only ASCII chars ---"); + let target_sz = 1024 * 2048 + 13; + let mut rng: u64 = 12345; + let mut ascii_data = Vec::with_capacity(target_sz); + let alphabet = [ASCII_PRINTABLE, CONTROL_CHARS].concat(); + for _ in 0..target_sz { + rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1); + let idx = ((rng >> 33) % alphabet.len() as u64) as usize; + ascii_data.push(alphabet[idx]); + } + + run_benchmark("Only ASCII chars", &ascii_data, REPETITIONS, || { + (Terminal::new(80, 25, 20000), Parser::new()) + }); + + // Benchmark 2: CSI codes with few chars (matches Kitty's ascii_with_csi) + println!("\n--- CSI codes with few chars ---"); + let target_sz = 1024 * 1024 + 17; + let mut csi_data = Vec::with_capacity(target_sz + 100); + let mut rng: u64 = 12345; // Fixed seed for reproducibility + + while csi_data.len() < target_sz { + // Simple LCG random for chunk selection + rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1); + let q = ((rng >> 33) % 100) as u32; + + match q { + 0..=9 => { + // 10%: random ASCII text (1-72 chars) + rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1); + let len = ((rng >> 33) % 72 + 1) as usize; + csi_data.extend(random_string(len, &mut rng)); + } + 10..=29 => { + // 20%: cursor movement + csi_data.extend_from_slice(b"\x1b[m\x1b[?1h\x1b[H"); + } + 30..=39 => { + // 10%: basic SGR attributes + csi_data.extend_from_slice(b"\x1b[1;2;3;4:3;31m"); + } + 40..=49 => { + // 10%: SGR with 256-color + RGB (colon-separated subparams) + csi_data.extend_from_slice(b"\x1b[38:5:24;48:2:125:136:147m"); + } + 50..=59 => { + // 10%: SGR with underline color + csi_data.extend_from_slice(b"\x1b[58;5;44;2m"); + } + 60..=79 => { + // 20%: cursor movement + erase + csi_data.extend_from_slice(b"\x1b[m\x1b[10A\x1b[3E\x1b[2K"); + } + _ => { + // 20%: reset + cursor + repeat + mode + csi_data.extend_from_slice(b"\x1b[39m\x1b[10`a\x1b[100b\x1b[?1l"); + } + } + } + csi_data.extend_from_slice(b"\x1b[m"); + + run_benchmark("CSI codes with few chars", &csi_data, REPETITIONS, || { + (Terminal::new(80, 25, 20000), Parser::new()) + }); + + // Benchmark 3: Long escape codes (matches Kitty's long_escape_codes) + println!("\n--- Long escape codes ---"); + let mut long_esc_data = Vec::new(); + let long_content: String = (0..8024).map(|i| ASCII_PRINTABLE[i % ASCII_PRINTABLE.len()] as char).collect(); + for _ in 0..1024 { + // OSC 6 - document reporting, ignored after parsing + long_esc_data.extend_from_slice(b"\x1b]6;"); + long_esc_data.extend_from_slice(long_content.as_bytes()); + long_esc_data.push(0x07); // BEL terminator + } + + run_benchmark("Long escape codes", &long_esc_data, REPETITIONS, || { + (Terminal::new(80, 25, 20000), Parser::new()) + }); + + println!("\n=== Benchmark Complete ==="); + println!("\nNote: These benchmarks include terminal state updates but NOT GPU rendering."); + println!("Compare with: kitten __benchmark__ (without --render flag)"); } diff --git a/src/box_drawing.rs b/src/box_drawing.rs new file mode 100644 index 0000000..a89fc5d --- /dev/null +++ b/src/box_drawing.rs @@ -0,0 +1,1610 @@ +//! Box drawing character rendering. +//! +//! This module provides procedural rendering of Unicode box drawing characters, +//! block elements, Braille patterns, and Powerline symbols. + +// ═══════════════════════════════════════════════════════════════════════════════ +// CORNER ENUM +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Which corner of a cell for corner triangle rendering +#[derive(Clone, Copy)] +pub enum Corner { + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// SUPERSAMPLED CANVAS +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Supersampled canvas for anti-aliased rendering of box drawing characters. +/// Renders at 4x resolution then downsamples for smooth edges. +pub struct SupersampledCanvas { + bitmap: Vec, + width: usize, + height: usize, + pub ss_width: usize, + pub ss_height: usize, +} + +impl SupersampledCanvas { + pub const FACTOR: usize = 4; + + pub 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] + pub 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 + pub 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) + pub 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. + pub fn fill_corner_triangle(&mut self, corner: Corner, inverted: bool) { + let w = self.ss_width; + let h = self.ss_height; + 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; + + let (edge_y, fill_below) = match corner { + Corner::BottomLeft => (max_y - (max_y / max_x) * x, true), + Corner::TopLeft => ((max_y / max_x) * x, false), + Corner::BottomRight => ((max_y / max_x) * x, true), + 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. + pub fn fill_powerline_arrow(&mut self, left: bool, inverted: bool) { + let w = self.ss_width; + let h = self.ss_height; + 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 { + let upper = (mid_y / max_x) * (max_x - x); + let lower = max_y - (mid_y / max_x) * (max_x - x); + (upper, lower) + } else { + 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) + pub fn stroke_powerline_arrow(&mut self, left: bool, thickness: usize) { + let w = self.ss_width; + let h = self.ss_height; + let max_x = (w - 1) as f64; + let max_y = (h - 1) as f64; + let mid_y = max_y / 2.0; + + if left { + 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 { + 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). + pub fn fill_bezier_d(&mut self, left: bool) { + let w = self.ss_width; + let h = self.ss_height; + let max_x = (w - 1) as f64; + let max_y = (h - 1) as f64; + let cx = max_x / 0.75; + + for py in 0..h { + let target_y = py as f64; + let t = Self::find_t_for_bezier_y(max_y, target_y); + let u = 1.0 - t; + let bx = 3.0 * cx * t * u; + let x_extent = (bx.round() as usize).min(w - 1); + + if left { + let start_x = (w - 1).saturating_sub(x_extent); + for px in start_x..w { + self.bitmap[py * w + px] = 255; + } + } else { + for px in 0..=x_extent { + self.bitmap[py * w + px] = 255; + } + } + } + } + + /// Binary search for t where bezier_y(t) ≈ target_y + 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) + pub fn stroke_bezier_d(&mut self, left: bool, thickness: f64) { + let w = self.ss_width; + let h = self.ss_height; + 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); + + 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 + pub 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 + pub 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 + pub 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 + pub 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; + + 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(); + + self.stroke_point_aa(arc_x, arc_y, half_thickness); + } + } + + /// Draw an anti-aliased point + pub 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 + pub 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; + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// 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 +pub fn box_thickness(level: usize, dpi: f64) -> f64 { + const BOX_DRAWING_SCALE: [f64; 4] = [0.001, 1.0, 1.5, 2.0]; + let pts = BOX_DRAWING_SCALE[level.min(3)]; + (pts * dpi / 72.0).max(1.0) +} + +/// Check if a character is a box-drawing character that should be rendered procedurally. +pub fn is_box_drawing(c: char) -> bool { + let cp = c as u32; + (0x2500..=0x257F).contains(&cp) + || (0x2580..=0x259F).contains(&cp) + || (0x25A0..=0x25FF).contains(&cp) + || (0x2800..=0x28FF).contains(&cp) + || (0xE0B0..=0xE0BF).contains(&cp) +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// RENDER BOX CHAR +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Render a box-drawing character procedurally to a bitmap. +/// Returns (bitmap, supersampled) where supersampled indicates if anti-aliasing was used. +pub fn render_box_char( + c: char, + cell_width: usize, + cell_height: usize, + font_size: f32, + dpi: f64, +) -> Option<(Vec, bool)> { + let w = cell_width; + let h = cell_height; + 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((font_size / 8.0).round() as usize); + let heavy = light * 2; + + // 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; + } + } + }; + + // Box drawing arm thickness encoding: 0=none, 1=light, 2=heavy + let box_arms: Option<(u8, u8, u8, u8)> = match c { + // Light lines + '\u{2500}' => Some((1, 1, 0, 0)), // ─ + '\u{2502}' => Some((0, 0, 1, 1)), // │ + // Light corners + '\u{250C}' => Some((0, 1, 0, 1)), // ┌ + '\u{2510}' => Some((1, 0, 0, 1)), // ┐ + '\u{2514}' => Some((0, 1, 1, 0)), // └ + '\u{2518}' => Some((1, 0, 1, 0)), // ┘ + // Light T-junctions + '\u{251C}' => Some((0, 1, 1, 1)), // ├ + '\u{2524}' => Some((1, 0, 1, 1)), // ┤ + '\u{252C}' => Some((1, 1, 0, 1)), // ┬ + '\u{2534}' => Some((1, 1, 1, 0)), // ┴ + // Light cross + '\u{253C}' => Some((1, 1, 1, 1)), // ┼ + // Heavy lines + '\u{2501}' => Some((2, 2, 0, 0)), // ━ + '\u{2503}' => Some((0, 0, 2, 2)), // ┃ + // Heavy corners + '\u{250F}' => Some((0, 2, 0, 2)), // ┏ + '\u{2513}' => Some((2, 0, 0, 2)), // ┓ + '\u{2517}' => Some((0, 2, 2, 0)), // ┗ + '\u{251B}' => Some((2, 0, 2, 0)), // ┛ + // Heavy T-junctions + '\u{2523}' => Some((0, 2, 2, 2)), // ┣ + '\u{252B}' => Some((2, 0, 2, 2)), // ┫ + '\u{2533}' => Some((2, 2, 0, 2)), // ┳ + '\u{253B}' => Some((2, 2, 2, 0)), // ┻ + // Heavy cross + '\u{254B}' => Some((2, 2, 2, 2)), // ╋ + // Mixed light/heavy corners + '\u{250E}' => Some((0, 1, 0, 2)), // ┎ + '\u{2512}' => Some((1, 0, 0, 2)), // ┒ + '\u{2516}' => Some((0, 1, 2, 0)), // ┖ + '\u{251A}' => Some((1, 0, 2, 0)), // ┚ + '\u{250D}' => Some((0, 2, 0, 1)), // ┍ + '\u{2511}' => Some((2, 0, 0, 1)), // ┑ + '\u{2515}' => Some((0, 2, 1, 0)), // ┕ + '\u{2519}' => Some((2, 0, 1, 0)), // ┙ + // Mixed T-junctions (vertical heavy, horizontal light) + '\u{2520}' => Some((0, 1, 2, 2)), // ┠ + '\u{2528}' => Some((1, 0, 2, 2)), // ┨ + '\u{2530}' => Some((1, 1, 0, 2)), // ┰ + '\u{2538}' => Some((1, 1, 2, 0)), // ┸ + // Mixed T-junctions (vertical light, horizontal heavy) + '\u{251D}' => Some((0, 2, 1, 1)), // ┝ + '\u{2525}' => Some((2, 0, 1, 1)), // ┥ + '\u{252F}' => Some((2, 2, 0, 1)), // ┯ + '\u{2537}' => Some((2, 2, 1, 0)), // ┷ + // Mixed cross (heavy horizontal, light vertical) + '\u{2542}' => Some((1, 1, 2, 2)), // ╂ + _ => None, + }; + + // Handle simple box drawing via lookup table + if let Some((left, right, up, down)) = box_arms { + let thickness = |arm: u8| -> usize { + match arm { + 1 => light, + 2 => heavy, + _ => 0, + } + }; + if left > 0 { + hline(&mut bitmap, 0, mid_x + 1, mid_y, thickness(left)); + } + if right > 0 { + hline(&mut bitmap, mid_x, w, mid_y, thickness(right)); + } + if up > 0 { + vline(&mut bitmap, 0, mid_y + 1, mid_x, thickness(up)); + } + if down > 0 { + vline(&mut bitmap, mid_y, h, mid_x, thickness(down)); + } + return Some((bitmap, supersampled)); + } + + // Continue with match for remaining characters + render_box_char_extended( + c, &mut bitmap, &mut supersampled, + w, h, mid_x, mid_y, light, heavy, double_off, + dpi, hline, vline, fill_rect, + )?; + + Some((bitmap, supersampled)) +} + +// Part 2 of render_box_char - handles extended characters +fn render_box_char_extended( + c: char, + bitmap: &mut [u8], + supersampled: &mut bool, + w: usize, + h: usize, + mid_x: usize, + mid_y: usize, + light: usize, + heavy: usize, + double_off: usize, + dpi: f64, + hline: H, + vline: V, + fill_rect: F, +) -> Option<()> +where + H: Fn(&mut [u8], usize, usize, usize, usize), + V: Fn(&mut [u8], usize, usize, usize, usize), + F: Fn(&mut [u8], usize, usize, usize, usize), +{ + match c { + // Mixed T-junctions continued + '\u{251E}' => { + vline(bitmap, 0, mid_y + 1, mid_x, light); + vline(bitmap, mid_y, h, mid_x, heavy); + hline(bitmap, mid_x, w, mid_y, light); + } + '\u{251F}' => { + vline(bitmap, 0, mid_y + 1, mid_x, heavy); + vline(bitmap, mid_y, h, mid_x, light); + hline(bitmap, mid_x, w, mid_y, light); + } + '\u{2521}' => { + vline(bitmap, 0, mid_y + 1, mid_x, light); + vline(bitmap, mid_y, h, mid_x, heavy); + hline(bitmap, mid_x, w, mid_y, heavy); + } + '\u{2522}' => { + vline(bitmap, 0, mid_y + 1, mid_x, heavy); + vline(bitmap, mid_y, h, mid_x, light); + hline(bitmap, mid_x, w, mid_y, heavy); + } + '\u{2526}' => { + vline(bitmap, 0, mid_y + 1, mid_x, light); + vline(bitmap, mid_y, h, mid_x, heavy); + hline(bitmap, 0, mid_x + 1, mid_y, light); + } + '\u{2527}' => { + vline(bitmap, 0, mid_y + 1, mid_x, heavy); + vline(bitmap, mid_y, h, mid_x, light); + hline(bitmap, 0, mid_x + 1, mid_y, light); + } + '\u{2529}' => { + vline(bitmap, 0, mid_y + 1, mid_x, light); + vline(bitmap, mid_y, h, mid_x, heavy); + hline(bitmap, 0, mid_x + 1, mid_y, heavy); + } + '\u{252A}' => { + vline(bitmap, 0, mid_y + 1, mid_x, heavy); + vline(bitmap, mid_y, h, mid_x, light); + hline(bitmap, 0, mid_x + 1, mid_y, heavy); + } + '\u{252D}' => { + hline(bitmap, 0, mid_x + 1, mid_y, light); + hline(bitmap, mid_x, w, mid_y, heavy); + vline(bitmap, mid_y, h, mid_x, light); + } + '\u{252E}' => { + hline(bitmap, 0, mid_x + 1, mid_y, heavy); + hline(bitmap, mid_x, w, mid_y, light); + vline(bitmap, mid_y, h, mid_x, light); + } + '\u{2531}' => { + hline(bitmap, 0, mid_x + 1, mid_y, light); + hline(bitmap, mid_x, w, mid_y, heavy); + vline(bitmap, mid_y, h, mid_x, heavy); + } + '\u{2532}' => { + hline(bitmap, 0, mid_x + 1, mid_y, heavy); + hline(bitmap, mid_x, w, mid_y, light); + vline(bitmap, mid_y, h, mid_x, heavy); + } + '\u{2535}' => { + hline(bitmap, 0, mid_x + 1, mid_y, light); + hline(bitmap, mid_x, w, mid_y, heavy); + vline(bitmap, 0, mid_y + 1, mid_x, light); + } + '\u{2536}' => { + hline(bitmap, 0, mid_x + 1, mid_y, heavy); + hline(bitmap, mid_x, w, mid_y, light); + vline(bitmap, 0, mid_y + 1, mid_x, light); + } + '\u{2539}' => { + hline(bitmap, 0, mid_x + 1, mid_y, light); + hline(bitmap, mid_x, w, mid_y, heavy); + vline(bitmap, 0, mid_y + 1, mid_x, heavy); + } + '\u{253A}' => { + hline(bitmap, 0, mid_x + 1, mid_y, heavy); + hline(bitmap, mid_x, w, mid_y, light); + vline(bitmap, 0, mid_y + 1, mid_x, heavy); + } + // Mixed crosses + '\u{2540}' => { + hline(bitmap, 0, w, mid_y, light); + vline(bitmap, 0, mid_y + 1, mid_x, heavy); + vline(bitmap, mid_y, h, mid_x, light); + } + '\u{2541}' => { + hline(bitmap, 0, w, mid_y, light); + vline(bitmap, 0, mid_y + 1, mid_x, light); + vline(bitmap, mid_y, h, mid_x, heavy); + } + '\u{2543}' => { + hline(bitmap, 0, mid_x + 1, mid_y, heavy); + hline(bitmap, mid_x, w, mid_y, light); + vline(bitmap, 0, mid_y + 1, mid_x, heavy); + vline(bitmap, mid_y, h, mid_x, light); + } + '\u{2544}' => { + hline(bitmap, 0, mid_x + 1, mid_y, light); + hline(bitmap, mid_x, w, mid_y, heavy); + vline(bitmap, 0, mid_y + 1, mid_x, heavy); + vline(bitmap, mid_y, h, mid_x, light); + } + '\u{2545}' => { + hline(bitmap, 0, mid_x + 1, mid_y, heavy); + hline(bitmap, mid_x, w, mid_y, light); + vline(bitmap, 0, mid_y + 1, mid_x, light); + vline(bitmap, mid_y, h, mid_x, heavy); + } + '\u{2546}' => { + hline(bitmap, 0, mid_x + 1, mid_y, light); + hline(bitmap, mid_x, w, mid_y, heavy); + vline(bitmap, 0, mid_y + 1, mid_x, light); + vline(bitmap, mid_y, h, mid_x, heavy); + } + '\u{2547}' => { + hline(bitmap, 0, w, mid_y, heavy); + vline(bitmap, 0, mid_y + 1, mid_x, light); + vline(bitmap, mid_y, h, mid_x, heavy); + } + '\u{2548}' => { + hline(bitmap, 0, w, mid_y, heavy); + vline(bitmap, 0, mid_y + 1, mid_x, heavy); + vline(bitmap, mid_y, h, mid_x, light); + } + '\u{2549}' => { + hline(bitmap, 0, mid_x + 1, mid_y, light); + hline(bitmap, mid_x, w, mid_y, heavy); + vline(bitmap, 0, h, mid_x, heavy); + } + '\u{254A}' => { + hline(bitmap, 0, mid_x + 1, mid_y, heavy); + hline(bitmap, mid_x, w, mid_y, light); + vline(bitmap, 0, h, mid_x, heavy); + } + // Delegate to part 3 for double lines, blocks, etc. + _ => { + return render_box_char_part3( + c, bitmap, supersampled, + w, h, mid_x, mid_y, light, heavy, double_off, + dpi, hline, vline, fill_rect, + ); + } + } + Some(()) +} + +// Part 3 - double lines and block elements +fn render_box_char_part3( + c: char, + bitmap: &mut [u8], + supersampled: &mut bool, + w: usize, + h: usize, + mid_x: usize, + mid_y: usize, + light: usize, + _heavy: usize, + double_off: usize, + dpi: f64, + hline: H, + vline: V, + fill_rect: F, +) -> Option<()> +where + H: Fn(&mut [u8], usize, usize, usize, usize), + V: Fn(&mut [u8], usize, usize, usize, usize), + F: Fn(&mut [u8], usize, usize, usize, usize), +{ + match c { + // Double lines + '\u{2550}' => { + hline(bitmap, 0, w, mid_y.saturating_sub(double_off), light); + hline(bitmap, 0, w, mid_y + double_off, light); + } + '\u{2551}' => { + vline(bitmap, 0, h, mid_x.saturating_sub(double_off), light); + vline(bitmap, 0, h, mid_x + double_off, light); + } + // Double corners + '\u{2554}' => { + hline(bitmap, mid_x, w, mid_y.saturating_sub(double_off), light); + hline(bitmap, mid_x + double_off, w, mid_y + double_off, light); + vline(bitmap, mid_y, h, mid_x.saturating_sub(double_off), light); + vline(bitmap, mid_y.saturating_sub(double_off), h, mid_x + double_off, light); + } + '\u{2557}' => { + hline(bitmap, 0, mid_x + 1, mid_y.saturating_sub(double_off), light); + hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light); + vline(bitmap, mid_y, h, mid_x + double_off, light); + vline(bitmap, mid_y.saturating_sub(double_off), h, mid_x.saturating_sub(double_off), light); + } + '\u{255A}' => { + hline(bitmap, mid_x, w, mid_y + double_off, light); + hline(bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light); + vline(bitmap, 0, mid_y + 1, mid_x.saturating_sub(double_off), light); + vline(bitmap, 0, mid_y + double_off + 1, mid_x + double_off, light); + } + '\u{255D}' => { + hline(bitmap, 0, mid_x + 1, mid_y + double_off, light); + hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light); + vline(bitmap, 0, mid_y + 1, mid_x + double_off, light); + vline(bitmap, 0, mid_y + double_off + 1, mid_x.saturating_sub(double_off), light); + } + // Double T-junctions + '\u{2560}' => { + vline(bitmap, 0, h, mid_x.saturating_sub(double_off), light); + vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x + double_off, light); + vline(bitmap, mid_y + double_off, h, mid_x + double_off, light); + hline(bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light); + hline(bitmap, mid_x + double_off, w, mid_y + double_off, light); + } + '\u{2563}' => { + vline(bitmap, 0, h, mid_x + double_off, light); + vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x.saturating_sub(double_off), light); + vline(bitmap, mid_y + double_off, h, mid_x.saturating_sub(double_off), light); + hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light); + hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light); + } + '\u{2566}' => { + hline(bitmap, 0, w, mid_y.saturating_sub(double_off), light); + hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light); + hline(bitmap, mid_x + double_off, w, mid_y + double_off, light); + vline(bitmap, mid_y + double_off, h, mid_x.saturating_sub(double_off), light); + vline(bitmap, mid_y + double_off, h, mid_x + double_off, light); + } + '\u{2569}' => { + hline(bitmap, 0, w, mid_y + double_off, light); + hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light); + hline(bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light); + vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x.saturating_sub(double_off), light); + vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x + double_off, light); + } + // Double cross + '\u{256C}' => { + vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x.saturating_sub(double_off), light); + vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x + double_off, light); + vline(bitmap, mid_y + double_off, h, mid_x.saturating_sub(double_off), light); + vline(bitmap, mid_y + double_off, h, mid_x + double_off, light); + hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light); + hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light); + hline(bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light); + hline(bitmap, mid_x + double_off, w, mid_y + double_off, light); + } + // Delegate remaining to part 4 + _ => { + return render_box_char_part4( + c, bitmap, supersampled, + w, h, mid_x, mid_y, light, double_off, + dpi, hline, vline, fill_rect, + ); + } + } + Some(()) +} + +// Part 4 - single/double mixed corners and T-junctions +fn render_box_char_part4( + c: char, + bitmap: &mut [u8], + supersampled: &mut bool, + w: usize, + h: usize, + mid_x: usize, + mid_y: usize, + light: usize, + double_off: usize, + dpi: f64, + hline: H, + vline: V, + fill_rect: F, +) -> Option<()> +where + H: Fn(&mut [u8], usize, usize, usize, usize), + V: Fn(&mut [u8], usize, usize, usize, usize), + F: Fn(&mut [u8], usize, usize, usize, usize), +{ + match c { + // Single/double mixed corners + '\u{2552}' => { + hline(bitmap, mid_x + double_off, w, mid_y, light); + vline(bitmap, mid_y, h, mid_x.saturating_sub(double_off), light); + vline(bitmap, mid_y, h, mid_x + double_off, light); + } + '\u{2553}' => { + hline(bitmap, mid_x, w, mid_y, light); + vline(bitmap, mid_y, h, mid_x, light); + } + '\u{2555}' => { + hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light); + vline(bitmap, mid_y, h, mid_x.saturating_sub(double_off), light); + vline(bitmap, mid_y, h, mid_x + double_off, light); + } + '\u{2556}' => { + hline(bitmap, 0, mid_x + 1, mid_y, light); + vline(bitmap, mid_y, h, mid_x, light); + } + '\u{2558}' => { + hline(bitmap, mid_x + double_off, w, mid_y, light); + vline(bitmap, 0, mid_y + 1, mid_x.saturating_sub(double_off), light); + vline(bitmap, 0, mid_y + 1, mid_x + double_off, light); + } + '\u{2559}' => { + hline(bitmap, mid_x, w, mid_y, light); + vline(bitmap, 0, mid_y + 1, mid_x, light); + } + '\u{255B}' => { + hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light); + vline(bitmap, 0, mid_y + 1, mid_x.saturating_sub(double_off), light); + vline(bitmap, 0, mid_y + 1, mid_x + double_off, light); + } + '\u{255C}' => { + hline(bitmap, 0, mid_x + 1, mid_y, light); + vline(bitmap, 0, mid_y + 1, mid_x, light); + } + // Mixed T-junctions + '\u{255E}' => { + vline(bitmap, 0, h, mid_x.saturating_sub(double_off), light); + vline(bitmap, 0, h, mid_x + double_off, light); + hline(bitmap, mid_x + double_off, w, mid_y, light); + } + '\u{255F}' => { + vline(bitmap, 0, h, mid_x, light); + hline(bitmap, mid_x, w, mid_y.saturating_sub(double_off), light); + hline(bitmap, mid_x, w, mid_y + double_off, light); + } + '\u{2561}' => { + vline(bitmap, 0, h, mid_x.saturating_sub(double_off), light); + vline(bitmap, 0, h, mid_x + double_off, light); + hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light); + } + '\u{2562}' => { + vline(bitmap, 0, h, mid_x, light); + hline(bitmap, 0, mid_x + 1, mid_y.saturating_sub(double_off), light); + hline(bitmap, 0, mid_x + 1, mid_y + double_off, light); + } + '\u{2564}' => { + hline(bitmap, 0, w, mid_y.saturating_sub(double_off), light); + hline(bitmap, 0, w, mid_y + double_off, light); + vline(bitmap, mid_y + double_off, h, mid_x, light); + } + '\u{2565}' => { + hline(bitmap, 0, w, mid_y, light); + vline(bitmap, mid_y, h, mid_x.saturating_sub(double_off), light); + vline(bitmap, mid_y, h, mid_x + double_off, light); + } + '\u{2567}' => { + hline(bitmap, 0, w, mid_y.saturating_sub(double_off), light); + hline(bitmap, 0, w, mid_y + double_off, light); + vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x, light); + } + '\u{2568}' => { + hline(bitmap, 0, w, mid_y, light); + vline(bitmap, 0, mid_y + 1, mid_x.saturating_sub(double_off), light); + vline(bitmap, 0, mid_y + 1, mid_x + double_off, light); + } + // Mixed crosses + '\u{256A}' => { + hline(bitmap, 0, w, mid_y.saturating_sub(double_off), light); + hline(bitmap, 0, w, mid_y + double_off, light); + vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x, light); + vline(bitmap, mid_y + double_off, h, mid_x, light); + } + '\u{256B}' => { + hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light); + hline(bitmap, mid_x + double_off, w, mid_y, light); + vline(bitmap, 0, h, mid_x.saturating_sub(double_off), light); + vline(bitmap, 0, h, mid_x + double_off, light); + } + // Delegate to part 5 + _ => { + return render_box_char_part5( + c, bitmap, supersampled, + w, h, mid_x, mid_y, light, + dpi, hline, vline, fill_rect, + ); + } + } + Some(()) +} + +// Part 5 - rounded corners and dashed lines +fn render_box_char_part5( + c: char, + bitmap: &mut [u8], + supersampled: &mut bool, + w: usize, + h: usize, + mid_x: usize, + mid_y: usize, + light: usize, + dpi: f64, + hline: H, + vline: V, + fill_rect: F, +) -> Option<()> +where + H: Fn(&mut [u8], usize, usize, usize, usize), + V: Fn(&mut [u8], usize, usize, usize, usize), + F: Fn(&mut [u8], usize, usize, usize, usize), +{ + let heavy = light * 2; + + match c { + // Rounded corners + '\u{256D}' | '\u{256E}' | '\u{256F}' | '\u{2570}' => { + 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; + + 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; + + 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; + let half_stroke = 0.5 * stroke; + + let (is_right, is_top) = match c { + '\u{256D}' => (false, true), + '\u{256E}' => (true, true), + '\u{2570}' => (false, false), + '\u{256F}' => (true, false), + _ => unreachable!(), + }; + + let x_shift = if is_right { adjusted_hx } else { -adjusted_hx }; + let y_shift = if is_top { -adjusted_hy } else { adjusted_hy }; + + 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 lines (triple dash) + '\u{2504}' => { + let seg = w / 8; + for i in 0..4 { + let x1 = i * 2 * seg; + let x2 = (x1 + seg).min(w); + hline(bitmap, x1, x2, mid_y, light); + } + } + '\u{2505}' => { + let seg = w / 8; + for i in 0..4 { + let x1 = i * 2 * seg; + let x2 = (x1 + seg).min(w); + hline(bitmap, x1, x2, mid_y, heavy); + } + } + '\u{2506}' => { + let seg = h / 8; + for i in 0..4 { + let y1 = i * 2 * seg; + let y2 = (y1 + seg).min(h); + vline(bitmap, y1, y2, mid_x, light); + } + } + '\u{2507}' => { + let seg = h / 8; + for i in 0..4 { + let y1 = i * 2 * seg; + let y2 = (y1 + seg).min(h); + vline(bitmap, y1, y2, mid_x, heavy); + } + } + '\u{2508}' => { + let seg = w / 12; + for i in 0..6 { + let x1 = i * 2 * seg; + let x2 = (x1 + seg).min(w); + hline(bitmap, x1, x2, mid_y, light); + } + } + '\u{2509}' => { + let seg = w / 12; + for i in 0..6 { + let x1 = i * 2 * seg; + let x2 = (x1 + seg).min(w); + hline(bitmap, x1, x2, mid_y, heavy); + } + } + '\u{250A}' => { + let seg = h / 12; + for i in 0..6 { + let y1 = i * 2 * seg; + let y2 = (y1 + seg).min(h); + vline(bitmap, y1, y2, mid_x, light); + } + } + '\u{250B}' => { + let seg = h / 12; + for i in 0..6 { + let y1 = i * 2 * seg; + let y2 = (y1 + seg).min(h); + vline(bitmap, y1, y2, mid_x, heavy); + } + } + // Double dashed + '\u{254C}' => { + let seg = w / 4; + hline(bitmap, 0, seg, mid_y, light); + hline(bitmap, seg * 2, seg * 3, mid_y, light); + } + '\u{254D}' => { + let seg = w / 4; + hline(bitmap, 0, seg, mid_y, heavy); + hline(bitmap, seg * 2, seg * 3, mid_y, heavy); + } + '\u{254E}' => { + let seg = h / 4; + vline(bitmap, 0, seg, mid_x, light); + vline(bitmap, seg * 2, seg * 3, mid_x, light); + } + '\u{254F}' => { + let seg = h / 4; + vline(bitmap, 0, seg, mid_x, heavy); + vline(bitmap, seg * 2, seg * 3, mid_x, heavy); + } + // Delegate to part 6 + _ => { + return render_box_char_part6( + c, bitmap, supersampled, + w, h, mid_x, mid_y, light, heavy, + dpi, hline, vline, fill_rect, + ); + } + } + Some(()) +} + +// Part 6 - half lines, diagonals, and block elements +fn render_box_char_part6( + c: char, + bitmap: &mut [u8], + supersampled: &mut bool, + w: usize, + h: usize, + mid_x: usize, + mid_y: usize, + light: usize, + heavy: usize, + dpi: f64, + hline: H, + vline: V, + fill_rect: F, +) -> Option<()> +where + H: Fn(&mut [u8], usize, usize, usize, usize), + V: Fn(&mut [u8], usize, usize, usize, usize), + F: Fn(&mut [u8], usize, usize, usize, usize), +{ + match c { + // Half lines + '\u{2574}' => hline(bitmap, 0, mid_x + 1, mid_y, light), + '\u{2575}' => vline(bitmap, 0, mid_y + 1, mid_x, light), + '\u{2576}' => hline(bitmap, mid_x, w, mid_y, light), + '\u{2577}' => vline(bitmap, mid_y, h, mid_x, light), + '\u{2578}' => hline(bitmap, 0, mid_x + 1, mid_y, heavy), + '\u{2579}' => vline(bitmap, 0, mid_y + 1, mid_x, heavy), + '\u{257A}' => hline(bitmap, mid_x, w, mid_y, heavy), + '\u{257B}' => vline(bitmap, mid_y, h, mid_x, heavy), + // Mixed half lines + '\u{257C}' => { + hline(bitmap, 0, mid_x + 1, mid_y, light); + hline(bitmap, mid_x, w, mid_y, heavy); + } + '\u{257D}' => { + vline(bitmap, 0, mid_y + 1, mid_x, light); + vline(bitmap, mid_y, h, mid_x, heavy); + } + '\u{257E}' => { + hline(bitmap, 0, mid_x + 1, mid_y, heavy); + hline(bitmap, mid_x, w, mid_y, light); + } + '\u{257F}' => { + vline(bitmap, 0, mid_y + 1, mid_x, heavy); + vline(bitmap, mid_y, h, mid_x, light); + } + // Diagonal lines + '\u{2571}' => { + 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; } + } + } + } + } + '\u{2572}' => { + 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; } + } + } + } + } + '\u{2573}' => { + 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}' => fill_rect(bitmap, 0, 0, w, h / 2), + '\u{2581}' => fill_rect(bitmap, 0, h * 7 / 8, w, h), + '\u{2582}' => fill_rect(bitmap, 0, h * 3 / 4, w, h), + '\u{2583}' => fill_rect(bitmap, 0, h * 5 / 8, w, h), + '\u{2584}' => fill_rect(bitmap, 0, h / 2, w, h), + '\u{2585}' => fill_rect(bitmap, 0, h * 3 / 8, w, h), + '\u{2586}' => fill_rect(bitmap, 0, h / 4, w, h), + '\u{2587}' => fill_rect(bitmap, 0, h / 8, w, h), + '\u{2588}' => fill_rect(bitmap, 0, 0, w, h), + '\u{2589}' => fill_rect(bitmap, 0, 0, w * 7 / 8, h), + '\u{258A}' => fill_rect(bitmap, 0, 0, w * 3 / 4, h), + '\u{258B}' => fill_rect(bitmap, 0, 0, w * 5 / 8, h), + '\u{258C}' => fill_rect(bitmap, 0, 0, w / 2, h), + '\u{258D}' => fill_rect(bitmap, 0, 0, w * 3 / 8, h), + '\u{258E}' => fill_rect(bitmap, 0, 0, w / 4, h), + '\u{258F}' => fill_rect(bitmap, 0, 0, w / 8, h), + '\u{2590}' => fill_rect(bitmap, w / 2, 0, w, h), + // Shades + '\u{2591}' => { + for y in 0..h { + for x in 0..w { + if (x + y) % 4 == 0 { bitmap[y * w + x] = 255; } + } + } + } + '\u{2592}' => { + for y in 0..h { + for x in 0..w { + if (x + y) % 2 == 0 { bitmap[y * w + x] = 255; } + } + } + } + '\u{2593}' => { + for y in 0..h { + for x in 0..w { + if (x + y) % 4 != 0 { bitmap[y * w + x] = 255; } + } + } + } + // Right half and upper eighth + '\u{2595}' => fill_rect(bitmap, w * 7 / 8, 0, w, h), + '\u{2594}' => fill_rect(bitmap, 0, 0, w, h / 8), + // Quadrants + '\u{2596}' => fill_rect(bitmap, 0, h / 2, w / 2, h), + '\u{2597}' => fill_rect(bitmap, w / 2, h / 2, w, h), + '\u{2598}' => fill_rect(bitmap, 0, 0, w / 2, h / 2), + '\u{2599}' => { + fill_rect(bitmap, 0, 0, w / 2, h); + fill_rect(bitmap, w / 2, h / 2, w, h); + } + '\u{259A}' => { + fill_rect(bitmap, 0, 0, w / 2, h / 2); + fill_rect(bitmap, w / 2, h / 2, w, h); + } + '\u{259B}' => { + fill_rect(bitmap, 0, 0, w, h / 2); + fill_rect(bitmap, 0, h / 2, w / 2, h); + } + '\u{259C}' => { + fill_rect(bitmap, 0, 0, w, h / 2); + fill_rect(bitmap, w / 2, h / 2, w, h); + } + '\u{259D}' => fill_rect(bitmap, w / 2, 0, w, h / 2), + '\u{259E}' => { + fill_rect(bitmap, w / 2, 0, w, h / 2); + fill_rect(bitmap, 0, h / 2, w / 2, h); + } + '\u{259F}' => { + fill_rect(bitmap, w / 2, 0, w, h); + fill_rect(bitmap, 0, h / 2, w / 2, h); + } + // Delegate to part 7 + _ => { + return render_box_char_part7( + c, bitmap, supersampled, + w, h, dpi, + ); + } + } + Some(()) +} + +// Part 7 - Braille patterns +fn render_box_char_part7( + c: char, + bitmap: &mut [u8], + supersampled: &mut bool, + w: usize, + h: usize, + dpi: f64, +) -> Option<()> { + match c { + // Braille patterns (U+2800-U+28FF) + c if (0x2800..=0x28FF).contains(&(c as u32)) => { + let which = (c as u32 - 0x2800) as u8; + if which != 0 { + 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], + ]; + + 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; + } + } + } + } + } + } + } + // Delegate to part 8 for Powerline + _ => { + return render_box_char_part8(c, bitmap, supersampled, w, h, dpi); + } + } + Some(()) +} + +// Part 8 - Powerline symbols (U+E0B0-U+E0BF) +fn render_box_char_part8( + c: char, + bitmap: &mut [u8], + supersampled: &mut bool, + w: usize, + h: usize, + dpi: f64, +) -> Option<()> { + match c { + // E0B0: Right-pointing solid triangle + '\u{E0B0}' => { + let mut canvas = SupersampledCanvas::new(w, h); + canvas.fill_powerline_arrow(false, false); + canvas.downsample(bitmap); + *supersampled = true; + } + // E0B1: Right-pointing chevron (outline) + '\u{E0B1}' => { + let mut canvas = SupersampledCanvas::new(w, h); + let thickness = (box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64).round() as usize; + canvas.stroke_powerline_arrow(false, thickness); + canvas.downsample(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(bitmap); + *supersampled = true; + } + // E0B3: Left-pointing chevron (outline) + '\u{E0B3}' => { + let mut canvas = SupersampledCanvas::new(w, h); + let thickness = (box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64).round() as usize; + canvas.stroke_powerline_arrow(true, thickness); + canvas.downsample(bitmap); + *supersampled = true; + } + // E0B4: Right semicircle (filled) + '\u{E0B4}' => { + let mut canvas = SupersampledCanvas::new(w, h); + canvas.fill_bezier_d(false); + canvas.downsample(bitmap); + *supersampled = true; + } + // E0B5: Right semicircle (outline) + '\u{E0B5}' => { + let mut canvas = SupersampledCanvas::new(w, h); + let thickness = box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; + canvas.stroke_bezier_d(false, thickness); + canvas.downsample(bitmap); + *supersampled = true; + } + // E0B6: Left semicircle (filled) + '\u{E0B6}' => { + let mut canvas = SupersampledCanvas::new(w, h); + canvas.fill_bezier_d(true); + canvas.downsample(bitmap); + *supersampled = true; + } + // E0B7: Left semicircle (outline) + '\u{E0B7}' => { + let mut canvas = SupersampledCanvas::new(w, h); + let thickness = box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; + canvas.stroke_bezier_d(true, thickness); + canvas.downsample(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(bitmap); + *supersampled = true; + } + '\u{E0B9}' => { + let mut canvas = SupersampledCanvas::new(w, h); + canvas.fill_corner_triangle(Corner::BottomLeft, true); + canvas.downsample(bitmap); + *supersampled = true; + } + '\u{E0BA}' => { + let mut canvas = SupersampledCanvas::new(w, h); + canvas.fill_corner_triangle(Corner::TopLeft, false); + canvas.downsample(bitmap); + *supersampled = true; + } + '\u{E0BB}' => { + let mut canvas = SupersampledCanvas::new(w, h); + canvas.fill_corner_triangle(Corner::TopLeft, true); + canvas.downsample(bitmap); + *supersampled = true; + } + '\u{E0BC}' => { + let mut canvas = SupersampledCanvas::new(w, h); + canvas.fill_corner_triangle(Corner::BottomRight, false); + canvas.downsample(bitmap); + *supersampled = true; + } + '\u{E0BD}' => { + let mut canvas = SupersampledCanvas::new(w, h); + canvas.fill_corner_triangle(Corner::BottomRight, true); + canvas.downsample(bitmap); + *supersampled = true; + } + '\u{E0BE}' => { + let mut canvas = SupersampledCanvas::new(w, h); + canvas.fill_corner_triangle(Corner::TopRight, false); + canvas.downsample(bitmap); + *supersampled = true; + } + '\u{E0BF}' => { + let mut canvas = SupersampledCanvas::new(w, h); + canvas.fill_corner_triangle(Corner::TopRight, true); + canvas.downsample(bitmap); + *supersampled = true; + } + // Delegate to part 9 for geometric shapes + _ => { + return render_box_char_part9(c, bitmap, supersampled, w, h, dpi); + } + } + Some(()) +} + +// Part 9 - Geometric shapes (circles, arcs) +fn render_box_char_part9( + c: char, + bitmap: &mut [u8], + supersampled: &mut bool, + w: usize, + h: usize, + dpi: f64, +) -> Option<()> { + match c { + // Black circle (filled) + '\u{25CF}' => { + let mut canvas = SupersampledCanvas::new(w, h); + canvas.fill_circle(1.0); + canvas.downsample(bitmap); + *supersampled = true; + } + // White circle (outline) + '\u{25CB}' => { + let mut canvas = SupersampledCanvas::new(w, h); + let line_width = box_thickness(1, dpi) * 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(bitmap); + *supersampled = true; + } + // Fisheye + '\u{25C9}' => { + 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; + canvas.fill_circle_radius(central_radius); + 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(bitmap); + *supersampled = true; + } + // Quadrant arcs + '\u{25DC}' => { + let mut canvas = SupersampledCanvas::new(w, h); + let line_width = box_thickness(1, dpi) * 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(bitmap); + *supersampled = true; + } + '\u{25DD}' => { + let mut canvas = SupersampledCanvas::new(w, h); + let line_width = box_thickness(1, dpi) * 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(bitmap); + *supersampled = true; + } + '\u{25DE}' => { + let mut canvas = SupersampledCanvas::new(w, h); + let line_width = box_thickness(1, dpi) * 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(bitmap); + *supersampled = true; + } + '\u{25DF}' => { + let mut canvas = SupersampledCanvas::new(w, h); + let line_width = box_thickness(1, dpi) * 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(bitmap); + *supersampled = true; + } + // Half arcs + '\u{25E0}' => { + let mut canvas = SupersampledCanvas::new(w, h); + let line_width = box_thickness(1, dpi) * 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(bitmap); + *supersampled = true; + } + '\u{25E1}' => { + let mut canvas = SupersampledCanvas::new(w, h); + let line_width = box_thickness(1, dpi) * 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(bitmap); + *supersampled = true; + } + // Unimplemented character + _ => return None, + } + Some(()) +} diff --git a/src/color.rs b/src/color.rs new file mode 100644 index 0000000..a4bab48 --- /dev/null +++ b/src/color.rs @@ -0,0 +1,57 @@ +//! Linear color palette for GPU rendering. +//! +//! Provides pre-computed sRGB to linear RGB conversion for efficient GPU color handling. + +use crate::terminal::ColorPalette; + +/// Pre-computed linear RGB color palette. +/// Avoids repeated sRGB→linear conversions during rendering. +/// The color_table contains [258][4] floats: 256 indexed colors + default fg (256) + default bg (257). +#[derive(Clone)] +pub struct LinearPalette { + /// Pre-computed linear RGBA colors ready for GPU upload. + /// Index 0-255: palette colors, 256: default_fg, 257: default_bg + pub color_table: [[f32; 4]; 258], +} + +impl LinearPalette { + /// Convert sRGB component (0.0-1.0) to linear RGB. + #[inline] + pub fn srgb_to_linear(c: f32) -> f32 { + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + } + + /// Convert an sRGB [u8; 3] color to linear [f32; 4] with alpha=1.0. + #[inline] + pub fn rgb_to_linear(rgb: [u8; 3]) -> [f32; 4] { + [ + Self::srgb_to_linear(rgb[0] as f32 / 255.0), + Self::srgb_to_linear(rgb[1] as f32 / 255.0), + Self::srgb_to_linear(rgb[2] as f32 / 255.0), + 1.0, + ] + } + + /// Create a LinearPalette from a ColorPalette. + pub fn from_palette(palette: &ColorPalette) -> Self { + let mut color_table = [[0.0f32; 4]; 258]; + + for i in 0..256 { + color_table[i] = Self::rgb_to_linear(palette.colors[i]); + } + color_table[256] = Self::rgb_to_linear(palette.default_fg); + color_table[257] = Self::rgb_to_linear(palette.default_bg); + + Self { color_table } + } +} + +impl Default for LinearPalette { + fn default() -> Self { + Self::from_palette(&ColorPalette::default()) + } +} diff --git a/src/color_font.rs b/src/color_font.rs new file mode 100644 index 0000000..3e222c0 --- /dev/null +++ b/src/color_font.rs @@ -0,0 +1,398 @@ +//! Color font (emoji) rendering using FreeType + Cairo. +//! +//! This module provides color emoji rendering support by using FreeType to load +//! color fonts (COLR, CBDT, sbix formats) and Cairo to render them. + +use cairo::{Format, ImageSurface}; +use freetype::Library as FtLibrary; +use std::collections::HashMap; +use std::ffi::CStr; +use std::path::PathBuf; + +// ═══════════════════════════════════════════════════════════════════════════════ +// COLOR FONT LOOKUP +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Find a color font (emoji font) that contains the given character using fontconfig. +/// Returns the path to the font file if found. +pub fn find_color_font_for_char(c: char) -> Option { + 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 + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// COLOR FONT RENDERER +// ═══════════════════════════════════════════════════════════════════════════════ + +/// 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). +pub struct ColorFontRenderer { + /// FreeType library instance + ft_library: FtLibrary, + /// Loaded FreeType faces and their Cairo font faces, keyed by font path + faces: HashMap, + /// Reusable Cairo surface for rendering + surface: Option, + /// Current surface dimensions + surface_size: (i32, i32), +} + +impl ColorFontRenderer { + pub fn new() -> Result { + 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. + pub 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, 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)) + } +} diff --git a/src/config.rs b/src/config.rs index 3bb61ae..23fdbe1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -52,11 +52,15 @@ impl Keybind { let key_part = &lowercase[last_plus + 1..]; let mod_part = &lowercase[..last_plus]; // Normalize symbol names to actual characters - let key = Self::normalize_key_name(key_part); + let key = Self::normalize_key_name(key_part) + .map(|s| s.to_string()) + .unwrap_or_else(|| key_part.to_string()); (mod_part, key) } else { // No modifiers, just a key - let key = Self::normalize_key_name(&lowercase); + let key = Self::normalize_key_name(&lowercase) + .map(|s| s.to_string()) + .unwrap_or_else(|| lowercase.clone()); ("", key) }; @@ -86,77 +90,78 @@ impl Keybind { /// Normalizes key names to their canonical form. /// Supports both symbol names ("plus", "minus") and literal symbols ("+", "-"). - fn normalize_key_name(name: &str) -> String { - match name { + /// Returns a static str for known keys, None for unknown (caller uses input). + fn normalize_key_name(name: &str) -> Option<&'static str> { + Some(match name { // Arrow keys - "left" | "arrowleft" | "arrow_left" => "left".to_string(), - "right" | "arrowright" | "arrow_right" => "right".to_string(), - "up" | "arrowup" | "arrow_up" => "up".to_string(), - "down" | "arrowdown" | "arrow_down" => "down".to_string(), + "left" | "arrowleft" | "arrow_left" => "left", + "right" | "arrowright" | "arrow_right" => "right", + "up" | "arrowup" | "arrow_up" => "up", + "down" | "arrowdown" | "arrow_down" => "down", // Other special keys - "enter" | "return" => "enter".to_string(), - "tab" => "tab".to_string(), - "escape" | "esc" => "escape".to_string(), - "backspace" | "back" => "backspace".to_string(), - "delete" | "del" => "delete".to_string(), - "insert" | "ins" => "insert".to_string(), - "home" => "home".to_string(), - "end" => "end".to_string(), - "pageup" | "page_up" | "pgup" => "pageup".to_string(), - "pagedown" | "page_down" | "pgdn" => "pagedown".to_string(), + "enter" | "return" => "enter", + "tab" => "tab", + "escape" | "esc" => "escape", + "backspace" | "back" => "backspace", + "delete" | "del" => "delete", + "insert" | "ins" => "insert", + "home" => "home", + "end" => "end", + "pageup" | "page_up" | "pgup" => "pageup", + "pagedown" | "page_down" | "pgdn" => "pagedown", // Function keys - "f1" => "f1".to_string(), - "f2" => "f2".to_string(), - "f3" => "f3".to_string(), - "f4" => "f4".to_string(), - "f5" => "f5".to_string(), - "f6" => "f6".to_string(), - "f7" => "f7".to_string(), - "f8" => "f8".to_string(), - "f9" => "f9".to_string(), - "f10" => "f10".to_string(), - "f11" => "f11".to_string(), - "f12" => "f12".to_string(), + "f1" => "f1", + "f2" => "f2", + "f3" => "f3", + "f4" => "f4", + "f5" => "f5", + "f6" => "f6", + "f7" => "f7", + "f8" => "f8", + "f9" => "f9", + "f10" => "f10", + "f11" => "f11", + "f12" => "f12", // Symbol name aliases - "plus" => "+".to_string(), - "minus" => "-".to_string(), - "equal" | "equals" => "=".to_string(), - "bracket_left" | "bracketleft" | "lbracket" => "[".to_string(), - "bracket_right" | "bracketright" | "rbracket" => "]".to_string(), - "brace_left" | "braceleft" | "lbrace" => "{".to_string(), - "brace_right" | "braceright" | "rbrace" => "}".to_string(), - "semicolon" => ";".to_string(), - "colon" => ":".to_string(), - "apostrophe" | "quote" => "'".to_string(), - "quotedbl" | "doublequote" => "\"".to_string(), - "comma" => ",".to_string(), - "period" | "dot" => ".".to_string(), - "slash" => "/".to_string(), - "backslash" => "\\".to_string(), - "grave" | "backtick" => "`".to_string(), - "tilde" => "~".to_string(), - "at" => "@".to_string(), - "hash" | "pound" => "#".to_string(), - "dollar" => "$".to_string(), - "percent" => "%".to_string(), - "caret" => "^".to_string(), - "ampersand" => "&".to_string(), - "asterisk" | "star" => "*".to_string(), - "paren_left" | "parenleft" | "lparen" => "(".to_string(), - "paren_right" | "parenright" | "rparen" => ")".to_string(), - "underscore" => "_".to_string(), - "pipe" | "bar" => "|".to_string(), - "question" => "?".to_string(), - "exclam" | "exclamation" | "bang" => "!".to_string(), - "less" | "lessthan" => "<".to_string(), - "greater" | "greaterthan" => ">".to_string(), - "space" => " ".to_string(), - // Pass through everything else as-is - _ => name.to_string(), - } + "plus" => "+", + "minus" => "-", + "equal" | "equals" => "=", + "bracket_left" | "bracketleft" | "lbracket" => "[", + "bracket_right" | "bracketright" | "rbracket" => "]", + "brace_left" | "braceleft" | "lbrace" => "{", + "brace_right" | "braceright" | "rbrace" => "}", + "semicolon" => ";", + "colon" => ":", + "apostrophe" | "quote" => "'", + "quotedbl" | "doublequote" => "\"", + "comma" => ",", + "period" | "dot" => ".", + "slash" => "/", + "backslash" => "\\", + "grave" | "backtick" => "`", + "tilde" => "~", + "at" => "@", + "hash" | "pound" => "#", + "dollar" => "$", + "percent" => "%", + "caret" => "^", + "ampersand" => "&", + "asterisk" | "star" => "*", + "paren_left" | "parenleft" | "lparen" => "(", + "paren_right" | "parenright" | "rparen" => ")", + "underscore" => "_", + "pipe" | "bar" => "|", + "question" => "?", + "exclam" | "exclamation" | "bang" => "!", + "less" | "lessthan" => "<", + "greater" | "greaterthan" => ">", + "space" => " ", + // Unknown - caller handles passthrough + _ => return None, + }) } } @@ -281,68 +286,34 @@ impl Keybindings { pub fn build_action_map(&self) -> HashMap<(bool, bool, bool, bool, String), Action> { let mut map = HashMap::new(); - if let Some(parsed) = self.new_tab.parse() { - map.insert(parsed, Action::NewTab); - } - if let Some(parsed) = self.next_tab.parse() { - map.insert(parsed, Action::NextTab); - } - if let Some(parsed) = self.prev_tab.parse() { - map.insert(parsed, Action::PrevTab); - } - if let Some(parsed) = self.tab_1.parse() { - map.insert(parsed, Action::Tab1); - } - if let Some(parsed) = self.tab_2.parse() { - map.insert(parsed, Action::Tab2); - } - if let Some(parsed) = self.tab_3.parse() { - map.insert(parsed, Action::Tab3); - } - if let Some(parsed) = self.tab_4.parse() { - map.insert(parsed, Action::Tab4); - } - if let Some(parsed) = self.tab_5.parse() { - map.insert(parsed, Action::Tab5); - } - if let Some(parsed) = self.tab_6.parse() { - map.insert(parsed, Action::Tab6); - } - if let Some(parsed) = self.tab_7.parse() { - map.insert(parsed, Action::Tab7); - } - if let Some(parsed) = self.tab_8.parse() { - map.insert(parsed, Action::Tab8); - } - if let Some(parsed) = self.tab_9.parse() { - map.insert(parsed, Action::Tab9); - } - if let Some(parsed) = self.split_horizontal.parse() { - map.insert(parsed, Action::SplitHorizontal); - } - if let Some(parsed) = self.split_vertical.parse() { - map.insert(parsed, Action::SplitVertical); - } - if let Some(parsed) = self.close_pane.parse() { - map.insert(parsed, Action::ClosePane); - } - if let Some(parsed) = self.focus_pane_up.parse() { - map.insert(parsed, Action::FocusPaneUp); - } - if let Some(parsed) = self.focus_pane_down.parse() { - map.insert(parsed, Action::FocusPaneDown); - } - if let Some(parsed) = self.focus_pane_left.parse() { - map.insert(parsed, Action::FocusPaneLeft); - } - if let Some(parsed) = self.focus_pane_right.parse() { - map.insert(parsed, Action::FocusPaneRight); - } - if let Some(parsed) = self.copy.parse() { - map.insert(parsed, Action::Copy); - } - if let Some(parsed) = self.paste.parse() { - map.insert(parsed, Action::Paste); + let bindings: &[(&Keybind, Action)] = &[ + (&self.new_tab, Action::NewTab), + (&self.next_tab, Action::NextTab), + (&self.prev_tab, Action::PrevTab), + (&self.tab_1, Action::Tab1), + (&self.tab_2, Action::Tab2), + (&self.tab_3, Action::Tab3), + (&self.tab_4, Action::Tab4), + (&self.tab_5, Action::Tab5), + (&self.tab_6, Action::Tab6), + (&self.tab_7, Action::Tab7), + (&self.tab_8, Action::Tab8), + (&self.tab_9, Action::Tab9), + (&self.split_horizontal, Action::SplitHorizontal), + (&self.split_vertical, Action::SplitVertical), + (&self.close_pane, Action::ClosePane), + (&self.focus_pane_up, Action::FocusPaneUp), + (&self.focus_pane_down, Action::FocusPaneDown), + (&self.focus_pane_left, Action::FocusPaneLeft), + (&self.focus_pane_right, Action::FocusPaneRight), + (&self.copy, Action::Copy), + (&self.paste, Action::Paste), + ]; + + for (keybind, action) in bindings { + if let Some(parsed) = keybind.parse() { + map.insert(parsed, *action); + } } map diff --git a/src/edge_glow.rs b/src/edge_glow.rs new file mode 100644 index 0000000..349c0f6 --- /dev/null +++ b/src/edge_glow.rs @@ -0,0 +1,55 @@ +//! Edge glow animation for visual feedback. +//! +//! Creates an organic glow effect when navigation fails: a single light node appears at center, +//! then splits into two that travel outward to the corners while fading. + +use crate::terminal::Direction; + +/// 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 + } +} diff --git a/src/font_loader.rs b/src/font_loader.rs new file mode 100644 index 0000000..eddad86 --- /dev/null +++ b/src/font_loader.rs @@ -0,0 +1,301 @@ +//! Font loading and discovery using fontconfig. +//! +//! This module provides font loading utilities including: +//! - Finding fonts by family name with style variants (regular, bold, italic, bold-italic) +//! - Finding fonts that contain specific characters (for fallback) +//! - Loading font data for use with ab_glyph and rustybuzz + +use ab_glyph::FontRef; +use fontconfig::Fontconfig; +use std::ffi::CStr; +use std::path::PathBuf; + +// ═══════════════════════════════════════════════════════════════════════════════ +// FONT VARIANT +// ═══════════════════════════════════════════════════════════════════════════════ + +/// A font variant with its data and parsed references. +pub 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>, +} + +impl FontVariant { + /// Get a reference to the ab_glyph font. + pub fn font(&self) -> &FontRef<'static> { + &self.font + } + + /// Get a reference to the rustybuzz face. + pub fn face(&self) -> &rustybuzz::Face<'static> { + &self.face + } + + /// Clone the font reference (ab_glyph FontRef is Clone). + pub fn clone_font(&self) -> FontRef<'static> { + self.font.clone() + } + + /// Clone the font data. + pub fn clone_data(&self) -> Box<[u8]> { + self.data.clone() + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// FONT DISCOVERY +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Find a font that contains the given character using fontconfig. +/// Returns the path to the font file if found. +/// +/// Note: For emoji, use `find_color_font_for_char` from the color_font module instead, +/// which explicitly requests color fonts. +pub fn find_font_for_char(_fc: &Fontconfig, c: char) -> Option { + use fontconfig_sys as fcsys; + use fcsys::*; + + unsafe { + // Create a pattern + let pat = FcPatternCreate(); + if pat.is_null() { + return None; + } + + // Create a charset with the target character + let charset = FcCharSetCreate(); + if charset.is_null() { + FcPatternDestroy(pat); + return None; + } + + // Add the character to the charset + FcCharSetAddChar(charset, c as u32); + + // Add the charset to the pattern + let fc_charset_cstr = CStr::from_bytes_with_nul(b"charset\0").unwrap(); + FcPatternAddCharSet(pat, fc_charset_cstr.as_ptr(), charset); + + // Run substitutions + FcConfigSubstitute(std::ptr::null_mut(), pat, FcMatchPattern); + FcDefaultSubstitute(pat); + + // Find matching font + let mut result = FcResultNoMatch; + let matched = FcFontMatch(std::ptr::null_mut(), pat, &mut result); + + let font_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); + Some(PathBuf::from(path_cstr.to_string_lossy().into_owned())) + } else { + None + } + } else { + None + }; + + // Cleanup + if !matched.is_null() { + FcPatternDestroy(matched); + } + FcCharSetDestroy(charset); + FcPatternDestroy(pat); + + font_result + } +} + +/// 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. +pub fn find_font_family_variants(family: &str) -> [Option; 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; 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 +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// FONT LOADING +// ═══════════════════════════════════════════════════════════════════════════════ + +/// 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. +pub fn load_font_variant(path: &std::path::Path) -> Option { + let data = std::fs::read(path).ok()?.into_boxed_slice(); + + // Parse with ab_glyph + let font: FontRef<'static> = { + let font = FontRef::try_from_slice(&data).ok()?; + // SAFETY: We keep data alive in the FontVariant struct + unsafe { std::mem::transmute(font) } + }; + + // Parse with rustybuzz + let face: rustybuzz::Face<'static> = { + let face = rustybuzz::Face::from_slice(&data, 0)?; + // SAFETY: We keep data alive in the FontVariant struct + unsafe { std::mem::transmute(face) } + }; + + Some(FontVariant { data, font, face }) +} + +/// Load font variants for a font family. +/// Returns array of font variants, with index 0 being the regular font. +/// Falls back to hardcoded paths if fontconfig fails. +pub fn load_font_family(font_family: Option<&str>) -> (Box<[u8]>, FontRef<'static>, [Option; 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.clone_font(); + let font_data = regular.clone_data(); + + // Load other variants + let variants: [Option; 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.clone_font(); + let font_data = regular_variant.clone_data(); + + let variants: [Option; 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.clone_font(); + let font_data = regular_variant.clone_data(); + let variants: [Option; 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"); +} diff --git a/src/glyph_shader.wgsl b/src/glyph_shader.wgsl index 5d0b0d4..35d9e0c 100644 --- a/src/glyph_shader.wgsl +++ b/src/glyph_shader.wgsl @@ -88,7 +88,7 @@ fn vs_main(in: VertexInput) -> VertexOutput { } @group(0) @binding(0) -var atlas_texture: texture_2d_array; +var atlas_textures: binding_array>; @group(0) @binding(1) var atlas_sampler: sampler; @@ -103,7 +103,7 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { } // Sample from RGBA atlas (layer 0 for legacy rendering) - let glyph_sample = textureSample(atlas_texture, atlas_sampler, in.uv, 0); + 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. @@ -697,7 +697,7 @@ fn fs_cell(in: CellVertexOutput) -> @location(0) vec4 { } 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_texture, atlas_sampler, in.cursor_uv, in.cursor_layer); + 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 { @@ -720,7 +720,7 @@ fn fs_cell(in: CellVertexOutput) -> @location(0) vec4 { let has_glyph = in.uv.x != 0.0 || in.uv.y != 0.0; if has_glyph { - let glyph_sample = textureSample(atlas_texture, atlas_sampler, in.uv, in.glyph_layer); + 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 @@ -744,7 +744,7 @@ fn fs_cell(in: CellVertexOutput) -> @location(0) vec4 { // Sample and blend underline decoration if present if in.has_underline > 0u { - let underline_sample = textureSample(atlas_texture, atlas_sampler, in.underline_uv, in.underline_layer); + 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 { @@ -758,7 +758,7 @@ fn fs_cell(in: CellVertexOutput) -> @location(0) vec4 { // Sample and blend strikethrough decoration if present if in.has_strikethrough > 0u { - let strike_sample = textureSample(atlas_texture, atlas_sampler, in.strike_uv, in.strike_layer); + 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 { diff --git a/src/gpu_types.rs b/src/gpu_types.rs new file mode 100644 index 0000000..111a53d --- /dev/null +++ b/src/gpu_types.rs @@ -0,0 +1,291 @@ +//! GPU data structures for terminal rendering. +//! +//! Contains vertex formats, uniform structures, and constants for wgpu rendering. +//! All structures use `#[repr(C)]` and implement `bytemuck::Pod` for GPU compatibility. + +use bytemuck::{Pod, Zeroable}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// CONSTANTS +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Size of the glyph atlas texture (like Kitty's max_texture_size). +/// 8192x8192 provides massive capacity before needing additional layers. +pub const ATLAS_SIZE: u32 = 8192; + +/// Maximum number of atlas layers (like Kitty's max_array_len). +/// With 8192x8192 per layer, this provides virtually unlimited glyph storage. +pub const MAX_ATLAS_LAYERS: u32 = 64; + +/// Bytes per pixel in the RGBA atlas (4 for RGBA8). +pub const ATLAS_BPP: u32 = 4; + +/// Maximum number of simultaneous edge glows. +pub const MAX_EDGE_GLOWS: usize = 16; + +/// 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; + +/// Pre-rendered cursor sprite indices (like Kitty's cursor_shape_map). +/// These sprites are created at fixed indices in the sprite array after initialization. +/// Index 0 is reserved for "no glyph" (empty cell). +pub const CURSOR_SPRITE_BEAM: u32 = 1; // Bar/beam cursor (vertical line on left) +pub const CURSOR_SPRITE_UNDERLINE: u32 = 2; // Underline cursor (horizontal line at bottom) +pub const CURSOR_SPRITE_HOLLOW: u32 = 3; // Hollow/unfocused cursor (outline rectangle) + +/// Pre-rendered decoration sprite indices (like Kitty's decoration sprites). +/// These are created after cursor sprites and used for text decorations. +/// The shader uses these to render underlines, strikethrough, etc. +pub const DECORATION_SPRITE_STRIKETHROUGH: u32 = 4; // Strikethrough line +pub const DECORATION_SPRITE_UNDERLINE: u32 = 5; // Single underline +pub const DECORATION_SPRITE_DOUBLE_UNDERLINE: u32 = 6; // Double underline +pub const DECORATION_SPRITE_UNDERCURL: u32 = 7; // Wavy/curly underline +pub const DECORATION_SPRITE_DOTTED: u32 = 8; // Dotted underline +pub const DECORATION_SPRITE_DASHED: u32 = 9; // Dashed underline + +/// First available sprite index for regular glyphs (after reserved cursor and decoration sprites) +pub const FIRST_GLYPH_SPRITE: u32 = 10; + +// ═══════════════════════════════════════════════════════════════════════════════ +// VERTEX STRUCTURES +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Vertex for rendering textured quads. +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct GlyphVertex { + pub position: [f32; 2], + pub uv: [f32; 2], + pub color: [f32; 4], + pub bg_color: [f32; 4], +} + +impl GlyphVertex { + pub const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![ + 0 => Float32x2, // position + 1 => Float32x2, // uv + 2 => Float32x4, // color (fg) + 3 => Float32x4, // bg_color + ]; + + pub fn desc() -> wgpu::VertexBufferLayout<'static> { + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &Self::ATTRIBS, + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// EDGE GLOW STRUCTURES +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Per-glow instance data (48 bytes, aligned to 16 bytes). +/// Must match GlowInstance in shader.wgsl exactly. +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct GlowInstance { + pub direction: u32, + pub progress: f32, + pub color_r: f32, + pub color_g: f32, + pub color_b: f32, + // Pane bounds in pixels + pub pane_x: f32, + pub pane_y: f32, + pub pane_width: f32, + pub pane_height: f32, + pub _padding1: f32, + pub _padding2: f32, + pub _padding3: f32, +} + +/// GPU-compatible edge glow uniform data. +/// Must match the layout in shader.wgsl exactly. +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct EdgeGlowUniforms { + pub screen_width: f32, + pub screen_height: f32, + pub terminal_y_offset: f32, + pub glow_intensity: f32, + pub glow_count: u32, + pub _padding: [u32; 3], // Pad to 16-byte alignment before array + pub glows: [GlowInstance; MAX_EDGE_GLOWS], +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// IMAGE STRUCTURES +// ═══════════════════════════════════════════════════════════════════════════════ + +/// GPU-compatible image uniform data. +/// Must match the layout in image_shader.wgsl exactly. +#[repr(C)] +#[derive(Copy, Clone, Debug, Pod, Zeroable)] +pub struct ImageUniforms { + pub screen_width: f32, + pub screen_height: f32, + pub pos_x: f32, + pub pos_y: f32, + pub display_width: f32, + pub display_height: f32, + pub src_x: f32, + pub src_y: f32, + pub src_width: f32, + pub src_height: f32, + pub _padding1: f32, + pub _padding2: f32, +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// 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, Pod, 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, +} + +/// 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, Pod, Zeroable)] +pub struct SpriteInfo { + /// UV coordinates in atlas (x, y, width, height) - normalized 0-1 + pub uv: [f32; 4], + /// Atlas layer index (z-coordinate for texture array) and padding + /// layer is the first f32, second f32 is unused padding + pub layer: f32, + pub _padding: f32, + /// Size in pixels (width, height) - always matches cell dimensions + pub size: [f32; 2], +} + +/// Font cell metrics with integer dimensions (like Kitty's FontCellMetrics). +/// Using integers ensures pixel-perfect alignment and avoids floating-point precision issues. +#[derive(Copy, Clone, Debug)] +pub struct FontCellMetrics { + /// Cell width in pixels (computed using ceil from font advance). + pub cell_width: u32, + /// Cell height in pixels (computed using ceil from font height). + pub cell_height: u32, + /// Baseline offset from top of cell in pixels. + pub baseline: u32, + /// Y position for underline (from top of cell, in pixels). + /// Computed from font metrics: ascender - underline_position. + pub underline_position: u32, + /// Thickness of underline in pixels. + pub underline_thickness: u32, + /// Y position for strikethrough (from top of cell, in pixels). + /// Typically around 65% of baseline from top. + pub strikethrough_position: u32, + /// Thickness of strikethrough in pixels. + pub strikethrough_thickness: u32, +} + +/// Grid parameters uniform for instanced rendering. +/// Matches GridParams in glyph_shader.wgsl exactly. +/// Uses Kitty-style NDC positioning: viewport is set per-pane, so shader +/// works in pure NDC space without needing pixel offsets. +/// Cell dimensions are integers like Kitty for pixel-perfect rendering. +#[repr(C)] +#[derive(Copy, Clone, Debug, Default, Pod, Zeroable)] +pub struct GridParams { + pub cols: u32, + pub rows: u32, + pub cell_width: u32, + pub cell_height: u32, + pub cursor_col: i32, + pub cursor_row: i32, + pub cursor_style: u32, + pub background_opacity: f32, + // Selection range (-1 values mean no selection) + pub selection_start_col: i32, + pub selection_start_row: i32, + pub selection_end_col: i32, + pub 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, Pod, 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, Pod, Zeroable)] +pub struct QuadParams { + pub screen_width: f32, + pub screen_height: f32, + pub _padding: [f32; 2], +} + +/// Parameters for statusline rendering. +/// Matches StatuslineParams in statusline_shader.wgsl exactly. +#[repr(C)] +#[derive(Copy, Clone, Debug, Default, Pod, Zeroable)] +pub struct StatuslineParams { + /// Number of characters in statusline + pub char_count: u32, + /// Cell width in pixels + pub cell_width: f32, + /// Cell height in pixels + pub cell_height: f32, + /// Screen width in pixels + pub screen_width: f32, + /// Screen height in pixels + pub screen_height: f32, + /// Y offset from top of screen in pixels + pub y_offset: f32, + /// Padding for alignment (to match shader struct layout) + pub _padding: [f32; 2], +} diff --git a/src/image_renderer.rs b/src/image_renderer.rs new file mode 100644 index 0000000..af38a81 --- /dev/null +++ b/src/image_renderer.rs @@ -0,0 +1,347 @@ +//! Image rendering for the Kitty Graphics Protocol. +//! +//! This module handles GPU-accelerated rendering of images in the terminal, +//! supporting the Kitty Graphics Protocol for inline image display. + +use std::collections::HashMap; +use crate::gpu_types::ImageUniforms; +use crate::graphics::{ImageData, ImagePlacement, ImageStorage}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// GPU IMAGE +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Cached GPU texture for an image. +pub struct GpuImage { + pub texture: wgpu::Texture, + pub view: wgpu::TextureView, + pub uniform_buffer: wgpu::Buffer, + pub bind_group: wgpu::BindGroup, + pub width: u32, + pub height: u32, +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// IMAGE RENDERER +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Manages GPU resources for image rendering. +/// Handles uploading, caching, and preparing images for rendering. +pub struct ImageRenderer { + /// Bind group layout for image rendering. + bind_group_layout: wgpu::BindGroupLayout, + /// Sampler for image textures. + sampler: wgpu::Sampler, + /// Cached GPU textures for images, keyed by image ID. + textures: HashMap, +} + +impl ImageRenderer { + /// Create a new ImageRenderer with the necessary GPU resources. + pub fn new(device: &wgpu::Device) -> Self { + // Create sampler for images (linear filtering for smooth scaling) + let 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::MipmapFilterMode::Nearest, + ..Default::default() + }); + + // Create bind group layout for images + let 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, + }, + ], + }); + + Self { + bind_group_layout, + sampler, + textures: HashMap::new(), + } + } + + /// Get the bind group layout for creating the image pipeline. + pub fn bind_group_layout(&self) -> &wgpu::BindGroupLayout { + &self.bind_group_layout + } + + /// Get a GPU image by ID. + pub fn get(&self, image_id: &u32) -> Option<&GpuImage> { + self.textures.get(image_id) + } + + /// Upload an image to the GPU, creating or updating its texture. + pub fn upload_image(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, 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.textures.get(&image.id) { + if existing.width == image.width && existing.height == image.height { + // Same dimensions, just update the data + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &existing.texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + data, + wgpu::TexelCopyBufferLayout { + 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 = 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 + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + data, + wgpu::TexelCopyBufferLayout { + 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 = device.create_buffer(&wgpu::BufferDescriptor { + label: Some(&format!("Image {} Uniform Buffer", image.id)), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + // Create bind group for this image with its own uniform buffer + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(&format!("Image {} Bind Group", image.id)), + layout: &self.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.sampler), + }, + ], + }); + + self.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.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, device: &wgpu::Device, queue: &wgpu::Queue, 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(device, queue, 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(device, queue, image); + } + + // Remove textures for deleted images + let current_ids: std::collections::HashSet = storage.images().keys().copied().collect(); + let gpu_ids: Vec = self.textures.keys().copied().collect(); + for id in gpu_ids { + if !current_ids.contains(&id) { + self.remove_image(id); + } + } + + storage.clear_dirty(); + } + + /// Prepare image renders for a pane. + /// Returns a Vec of (image_id, uniforms) for deferred rendering. + pub 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.textures.get(&placement.image_id) { + Some(img) => img, + None => continue, // Skip if not uploaded yet + }; + + // Convert absolute row to visible screen row + // placement.row is absolute (scrollback_len_at_placement + cursor_row) + // visible_row = absolute_row - scrollback_len + scroll_offset + let absolute_row = placement.row as isize; + let visible_row = absolute_row - scrollback_len as isize + scroll_offset as isize; + + // Check if image is visible on screen + // Image spans from visible_row to visible_row + placement.rows + let image_bottom = visible_row + placement.rows as isize; + if image_bottom < 0 || visible_row >= visible_rows as isize { + continue; // Image is completely off-screen + } + + // Calculate display position in pixels + let pos_x = pane_x + (placement.col as f32 * cell_width) + placement.x_offset as f32; + let pos_y = pane_y + (visible_row as f32 * cell_height) + placement.y_offset as f32; + + log::debug!( + "Image render: pane_x={} col={} cell_width={} x_offset={} => pos_x={}", + pane_x, placement.col, cell_width, placement.x_offset, pos_x + ); + + // Calculate display size in pixels + let display_width = placement.cols as f32 * cell_width; + let display_height = placement.rows as f32 * cell_height; + + // Calculate source rectangle in normalized coordinates + let src_x = placement.src_x as f32 / gpu_image.width as f32; + let src_y = placement.src_y as f32 / gpu_image.height as f32; + let src_width = if placement.src_width == 0 { + 1.0 - src_x + } else { + placement.src_width as f32 / gpu_image.width as f32 + }; + let src_height = if placement.src_height == 0 { + 1.0 - src_y + } else { + placement.src_height as f32 / gpu_image.height as f32 + }; + + let uniforms = ImageUniforms { + screen_width, + screen_height, + pos_x, + pos_y, + display_width, + display_height, + src_x, + src_y, + src_width, + src_height, + _padding1: 0.0, + _padding2: 0.0, + }; + + renders.push((placement.image_id, uniforms)); + } + + renders + } +} diff --git a/src/lib.rs b/src/lib.rs index 95882f0..7ba9b50 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,10 +2,21 @@ //! //! Single-process architecture: one process owns PTY, terminal state, and rendering. +pub mod box_drawing; +pub mod color; +pub mod color_font; pub mod config; +pub mod font_loader; +pub mod edge_glow; +pub mod gpu_types; pub mod graphics; +pub mod image_renderer; pub mod keyboard; +pub mod pane_resources; +pub mod pipeline; pub mod pty; pub mod renderer; +pub mod statusline; pub mod terminal; +pub mod simd_utf8; pub mod vt_parser; diff --git a/src/main.rs b/src/main.rs index c93dac2..422b26d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,13 +8,14 @@ use zterm::keyboard::{FunctionalKey, KeyEncoder, KeyEventType, KeyboardState, Mo use zterm::pty::Pty; use zterm::renderer::{EdgeGlow, PaneRenderInfo, Renderer, StatuslineComponent, StatuslineContent, StatuslineSection}; use zterm::terminal::{Direction, Terminal, TerminalCommand, MouseTrackingMode}; +use zterm::vt_parser::SharedParser; use std::collections::HashMap; use std::io::Write; use std::os::fd::AsRawFd; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use std::time::Duration; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; @@ -27,205 +28,6 @@ use winit::keyboard::{Key, NamedKey}; use winit::platform::wayland::EventLoopBuilderExtWayland; use winit::window::{Window, WindowId}; -/// Kitty-style single-buffer for PTY I/O with zero-copy reads and writes. -/// -/// Uses a single buffer with separate read/write regions: -/// - I/O thread writes to `buf[write_offset..]` -/// - Main thread reads from `buf[0..read_len]` -/// - After main thread consumes data, buffer compacts via memmove -/// -/// When buffer is full, I/O thread waits on an eventfd. Main thread signals -/// the eventfd after consuming data to wake up the I/O thread. -/// -/// This gives us: -/// - Zero-copy writes (I/O reads directly into buffer) -/// - Zero-copy reads (main thread gets slice, no allocation) -/// - Single 1MB buffer (vs 8MB for double-buffering) -/// - No busy-waiting when buffer is full -const PTY_BUF_SIZE: usize = 1024 * 1024; // 1MB like Kitty - -struct SharedPtyBuffer { - /// The actual buffer. UnsafeCell because we need disjoint mutable access: - /// I/O thread writes to [write_pending..], main thread reads [0..read_available] - buf: std::cell::UnsafeCell>, - /// Metadata protected by mutex - offsets into the buffer - state: Mutex, - /// Eventfd to wake up I/O thread when space becomes available - wakeup_fd: i32, -} - -// SAFETY: We ensure disjoint access - I/O thread only writes past read_available, -// main thread only reads up to read_available. Mutex protects metadata updates. -unsafe impl Sync for SharedPtyBuffer {} -unsafe impl Send for SharedPtyBuffer {} - -struct BufferState { - /// Bytes available for main thread to read (I/O has written, main hasn't consumed) - read_available: usize, - /// Bytes written by I/O thread but not yet made available to main thread - write_pending: usize, - /// Whether the I/O thread is waiting for space - waiting_for_space: bool, -} - -impl SharedPtyBuffer { - fn new() -> Self { - // Create eventfd for wakeup signaling - let wakeup_fd = unsafe { libc::eventfd(0, libc::EFD_NONBLOCK | libc::EFD_CLOEXEC) }; - if wakeup_fd < 0 { - panic!("Failed to create eventfd: {}", std::io::Error::last_os_error()); - } - - Self { - buf: std::cell::UnsafeCell::new(Box::new([0u8; PTY_BUF_SIZE])), - state: Mutex::new(BufferState { - read_available: 0, - write_pending: 0, - waiting_for_space: false, - }), - wakeup_fd, - } - } - - /// Get the wakeup fd for the I/O thread to poll on. - fn wakeup_fd(&self) -> i32 { - self.wakeup_fd - } - - /// Check if there's space and mark as waiting if not. - /// Returns true if there's space, false if waiting. - fn check_space_or_wait(&self) -> bool { - let mut state = self.state.lock().unwrap(); - let has_space = state.read_available + state.write_pending < PTY_BUF_SIZE; - if !has_space { - state.waiting_for_space = true; - } - has_space - } - - /// Get a write buffer for the I/O thread to read PTY data into. - /// Returns (pointer, available_space). Caller must call commit_write() after. - /// - /// SAFETY: The returned pointer is valid until commit_write() is called. - /// Only one thread should call this at a time (the I/O thread). - fn create_write_buffer(&self) -> (*mut u8, usize) { - let state = self.state.lock().unwrap(); - let write_offset = state.read_available + state.write_pending; - let available = PTY_BUF_SIZE.saturating_sub(write_offset); - - if available == 0 { - return (std::ptr::null_mut(), 0); - } - - // SAFETY: We have exclusive write access to buf[write_offset..] because: - // - Main thread only reads [0..read_available] - // - We're the only writer past read_available + write_pending - let ptr = unsafe { (*self.buf.get()).as_mut_ptr().add(write_offset) }; - (ptr, available) - } - - /// Commit bytes written by the I/O thread. - fn commit_write(&self, len: usize) { - let mut state = self.state.lock().unwrap(); - state.write_pending += len; - } - - /// Read from PTY fd into the buffer. Called by I/O thread. - /// Returns number of bytes read, 0 if no space/would block, -1 on error. - fn read_from_fd(&self, fd: i32) -> isize { - let (ptr, available) = self.create_write_buffer(); - if available == 0 { - return 0; // Buffer full - } - - let result = unsafe { - libc::read(fd, ptr as *mut libc::c_void, available) - }; - - if result > 0 { - self.commit_write(result as usize); - } - result - } - - /// Drain the wakeup eventfd. Called by I/O thread after waking up. - fn drain_wakeup(&self) { - let mut buf = 0u64; - unsafe { - libc::read(self.wakeup_fd, &mut buf as *mut u64 as *mut libc::c_void, 8); - } - } - - /// Make pending writes available for reading, get slice to read. - /// Returns None if no data available. - /// - /// SAFETY: The returned slice is valid until consume() is called. - /// Only the main thread should call this. - fn get_read_slice(&self) -> Option<&[u8]> { - let mut state = self.state.lock().unwrap(); - - // Move pending writes to readable - state.read_available += state.write_pending; - state.write_pending = 0; - - if state.read_available == 0 { - return None; - } - - // SAFETY: We have exclusive read access to [0..read_available] because: - // - I/O thread only writes past read_available - // - We're the only reader - let slice = unsafe { - std::slice::from_raw_parts((*self.buf.get()).as_ptr(), state.read_available) - }; - Some(slice) - } - - /// Consume all read data, making space for new writes. - /// Called after parsing is complete. Wakes up I/O thread if it was waiting. - fn consume_all(&self) { - let should_wakeup; - { - let mut state = self.state.lock().unwrap(); - - // If there's pending write data, we need to move it to the front - if state.write_pending > 0 { - // SAFETY: Memmove handles overlapping regions - unsafe { - let buf = &mut *self.buf.get(); - std::ptr::copy( - buf.as_ptr().add(state.read_available), - buf.as_mut_ptr(), - state.write_pending, - ); - } - } - - state.read_available = 0; - // write_pending stays the same but is now at offset 0 - - should_wakeup = state.waiting_for_space; - state.waiting_for_space = false; - } - - // Wake up I/O thread if it was waiting for space - if should_wakeup { - let val = 1u64; - unsafe { - libc::write(self.wakeup_fd, &val as *const u64 as *const libc::c_void, 8); - } - } - } -} - -impl Drop for SharedPtyBuffer { - fn drop(&mut self) { - unsafe { - libc::close(self.wakeup_fd); - } - } -} - /// Unique identifier for a pane. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct PaneId(u64); @@ -248,8 +50,9 @@ struct Pane { pty: Pty, /// Raw file descriptor for the PTY (for polling). pty_fd: i32, - /// Shared buffer for this pane's PTY I/O. - pty_buffer: Arc, + /// Shared parser with integrated buffer (Kitty-style). + /// I/O thread writes directly to this, main thread parses in-place. + shared_parser: Arc, /// Selection state for this pane. selection: Option, /// Whether we're currently selecting in this pane. @@ -295,7 +98,7 @@ impl Pane { terminal, pty, pty_fd, - pty_buffer: Arc::new(SharedPtyBuffer::new()), + shared_parser: Arc::new(SharedParser::new()), selection: None, is_selecting: false, last_scrollback_len: 0, @@ -1263,8 +1066,9 @@ impl Selection { enum UserEvent { /// Signal received to show the window. ShowWindow, - /// PTY has data available for a specific pane. - PtyReadable(PaneId), + /// Tick event - signals main loop to process all pending PTY data and render. + /// This is like Kitty's glfwPostEmptyEvent() + process_global_state pattern. + Tick, /// Config file was modified and should be reloaded. ConfigReloaded, } @@ -1301,6 +1105,25 @@ struct App { should_create_window: bool, /// Edge glow animations (for when navigation fails). Multiple can be active simultaneously. edge_glows: Vec, + #[cfg(feature = "render_timing")] + /// Cumulative parse time for benchmarking (nanoseconds). + total_parse_ns: u64, + #[cfg(feature = "render_timing")] + /// Cumulative render time for benchmarking (nanoseconds). + total_render_ns: u64, + #[cfg(feature = "render_timing")] + /// Number of parse calls. + parse_count: u64, + #[cfg(feature = "render_timing")] + /// Number of render calls. + render_count: u64, + #[cfg(feature = "render_timing")] + /// Last time we logged cumulative stats. + last_stats_log: std::time::Instant, + /// Last time we rendered a frame (for repaint_delay throttling). + last_render_at: std::time::Instant, + /// Whether a fatal render error occurred (e.g., OutOfMemory). + render_fatal_error: bool, } impl App { @@ -1330,6 +1153,18 @@ impl App { last_frame_log: std::time::Instant::now(), should_create_window: false, edge_glows: Vec::new(), + #[cfg(feature = "render_timing")] + total_parse_ns: 0, + #[cfg(feature = "render_timing")] + total_render_ns: 0, + #[cfg(feature = "render_timing")] + parse_count: 0, + #[cfg(feature = "render_timing")] + render_count: 0, + #[cfg(feature = "render_timing")] + last_stats_log: std::time::Instant::now(), + last_render_at: std::time::Instant::now(), + render_fatal_error: false, } } @@ -1374,11 +1209,17 @@ impl App { } // Request redraw to apply visual changes + self.request_redraw(); + + log::info!("Configuration reloaded successfully"); + } + + /// Request a window redraw if window is available. + #[inline] + fn request_redraw(&self) { if let Some(window) = &self.window { window.request_redraw(); } - - log::info!("Configuration reloaded successfully"); } /// Create a new tab and start its I/O thread. @@ -1410,19 +1251,29 @@ impl App { /// Start background I/O thread for a pane's PTY. fn start_pane_io_thread(&self, pane: &Pane) { - self.start_pane_io_thread_with_info(pane.id, pane.pty_fd, pane.pty_buffer.clone()); + self.start_pane_io_thread_with_info(pane.id, pane.pty_fd, pane.shared_parser.clone()); } /// Start background I/O thread for a pane's PTY with explicit info. - fn start_pane_io_thread_with_info(&self, pane_id: PaneId, pty_fd: i32, pty_buffer: Arc) { + /// + /// Kitty-style design: + /// - I/O thread reads PTY data directly into SharedParser's buffer + /// - Commits bytes atomically (pending counter) + /// - Sends Tick to main thread after INPUT_DELAY + /// - When buffer is full, disables PTY polling and waits for wakeup + /// - Main thread wakes us after parsing (which frees space) + fn start_pane_io_thread_with_info(&self, pane_id: PaneId, pty_fd: i32, shared_parser: Arc) { let Some(proxy) = self.event_loop_proxy.clone() else { return }; let shutdown = self.shutdown.clone(); - let wakeup_fd = pty_buffer.wakeup_fd(); + let wakeup_fd = shared_parser.wakeup_fd(); std::thread::Builder::new() .name(format!("pty-io-{}", pane_id.0)) .spawn(move || { - const INPUT_DELAY: Duration = Duration::from_millis(3); + // Input delay: batch rapid input bursts to reduce overhead. + // Kitty uses 3ms, but we use 0 for minimal latency. + // The main thread's REPAINT_DELAY (10ms) already provides batching. + const INPUT_DELAY: Duration = Duration::from_millis(0); const PTY_KEY: usize = 0; const WAKEUP_KEY: usize = 1; @@ -1448,14 +1299,20 @@ impl App { } let mut events = Events::new(); - let mut last_wakeup_at = std::time::Instant::now(); - let mut has_pending_wakeup = false; + let mut last_tick_at = std::time::Instant::now(); + let mut has_pending_data = false; + + // Debug tracking + let mut total_bytes_read: u64 = 0; + let mut loop_count: u64 = 0; + let io_start = std::time::Instant::now(); while !shutdown.load(Ordering::Relaxed) { events.clear(); + loop_count += 1; // Check if we have space - if not, disable PTY polling until woken - let has_space = pty_buffer.check_space_or_wait(); + let has_space = shared_parser.has_space(); // Set up poll events: always listen on wakeup_fd, only listen on pty_fd if we have space unsafe { @@ -1463,15 +1320,21 @@ impl App { let _ = poller.modify(std::os::fd::BorrowedFd::borrow_raw(pty_fd), pty_event); } - let timeout = if has_pending_wakeup { - let elapsed = last_wakeup_at.elapsed(); + // Kitty-style timeout: if we have pending data OR buffer is full, use a timeout. + // When buffer is full, we need to periodically re-check if space became available + // (don't rely solely on wakeup - that can lead to deadlock). + // When we have space and no pending data, we can block indefinitely. + let timeout = if has_pending_data || !has_space { + let elapsed = last_tick_at.elapsed(); Some(INPUT_DELAY.saturating_sub(elapsed)) } else { - None // Block indefinitely until data or wakeup + None // Block indefinitely until data arrives }; + let wait_start = std::time::Instant::now(); match poller.wait(&mut events, timeout) { Ok(_) => { + let wait_time = wait_start.elapsed(); let mut got_wakeup = false; let mut got_pty_data = false; @@ -1484,9 +1347,20 @@ impl App { } } + // Log long waits (only with render_timing feature) + #[cfg(feature = "render_timing")] + if wait_time.as_millis() > 50 { + log::warn!("[IO-{}] Long wait: {:?} has_space={} has_pending={} got_wakeup={} got_pty={} timeout={:?}", + pane_id.0, wait_time, has_space, has_pending_data, got_wakeup, got_pty_data, timeout); + } + + #[cfg(not(feature = "render_timing"))] + let _ = wait_time; // silence unused warning + // Drain wakeup fd if signaled if got_wakeup { - pty_buffer.drain_wakeup(); + log::trace!("[IO-{}] Got wakeup from main thread", pane_id.0); + shared_parser.drain_wakeup(); // Re-arm wakeup fd unsafe { let _ = poller.modify( @@ -1496,10 +1370,18 @@ impl App { } } - // Read PTY data if available and we have space - if got_pty_data && has_space { + // Read PTY data if: + // 1. Poll said PTY is readable, OR + // 2. We just got woken up (space became available) - PTY might have data + // we couldn't read before because our buffer was full + // The PTY fd is non-blocking, so reading when empty just returns EAGAIN + let fresh_has_space = shared_parser.has_space(); + let should_try_read = (got_pty_data || got_wakeup) && fresh_has_space; + + if should_try_read { + let mut bytes_this_loop: i64 = 0; loop { - let result = pty_buffer.read_from_fd(pty_fd); + let result = shared_parser.read_from_fd(pty_fd); if result < 0 { let err = std::io::Error::last_os_error(); if err.kind() == std::io::ErrorKind::Interrupted { @@ -1511,24 +1393,35 @@ impl App { log::debug!("PTY read error: {}", err); break; } else if result == 0 { + log::debug!("[IO-{}] PTY EOF", pane_id.0); break; } else { - has_pending_wakeup = true; + bytes_this_loop += result as i64; + total_bytes_read += result as u64; + has_pending_data = true; // Check if buffer became full - if !pty_buffer.check_space_or_wait() { + if !shared_parser.has_space() { + log::trace!("[IO-{}] Buffer full after reading {} bytes", pane_id.0, bytes_this_loop); break; } } } + } else if got_wakeup && !fresh_has_space { + // Buffer is full but we got a wakeup - main is parsing and will + // free space soon. Mark pending so we send a tick after delay. + has_pending_data = true; + log::trace!("[IO-{}] Buffer full after wakeup, will tick", pane_id.0); } - // Send wakeup to main thread if we have pending data and enough time passed - if has_pending_wakeup { + // Send Tick to main thread if we have pending data and enough time passed + // Like Kitty: just send the wakeup, don't try to deduplicate + if has_pending_data { let now = std::time::Instant::now(); - if now.duration_since(last_wakeup_at) >= INPUT_DELAY { - let _ = proxy.send_event(UserEvent::PtyReadable(pane_id)); - last_wakeup_at = now; - has_pending_wakeup = false; + if now.duration_since(last_tick_at) >= INPUT_DELAY { + log::trace!("[IO-{}] Sending Tick", pane_id.0); + let _ = proxy.send_event(UserEvent::Tick); + last_tick_at = now; + has_pending_data = false; } } } @@ -1541,6 +1434,17 @@ impl App { } } + #[cfg(feature = "render_timing")] + { + let elapsed = io_start.elapsed(); + log::info!("[IO-{}] Thread exiting: loops={} total_bytes={} elapsed={:?} throughput={:.1} MB/s", + pane_id.0, loop_count, total_bytes_read, elapsed, + total_bytes_read as f64 / elapsed.as_secs_f64() / 1_000_000.0); + } + + #[cfg(not(feature = "render_timing"))] + let _ = (io_start, loop_count, total_bytes_read); // silence unused warnings + log::debug!("PTY I/O thread for pane {} exiting", pane_id.0); }) .expect("Failed to spawn PTY I/O thread"); @@ -1625,47 +1529,45 @@ impl App { } } - /// Process PTY data for a specific pane. - /// Returns true if any data was processed. - fn poll_pane(&mut self, pane_id: PaneId) -> bool { - // Find the pane across all tabs and process data - let mut processed = false; - let mut commands = Vec::new(); + /// Process PTY data for a specific pane using Kitty-style in-place parsing. + /// + /// Returns (processed_any, has_more_data, bytes_parsed) - processed_any is true if data was parsed, + /// has_more_data is true if there's still pending data after the time budget expired. + /// + /// Key differences from previous design: + /// 1. begin_parse_pass() compacts buffer and makes pending data visible + /// 2. Parsing happens in-place - no copying data out of buffer + /// 3. end_parse_pass() reports consumed bytes, wakes I/O only if buffer was full + /// 4. Loop continues parsing as long as time budget allows + fn poll_pane(&mut self, pane_id: PaneId) -> (bool, bool) { + let mut ever_processed = false; + let mut all_commands = Vec::new(); for tab in &mut self.tabs { if let Some(pane) = tab.get_pane_mut(pane_id) { - // Get slice of pending data - zero copy! - let Some(data) = pane.pty_buffer.get_read_slice() else { - return false; - }; - let len = data.len(); + // Use SharedParser's run_parse_pass - it releases lock during parsing + // so I/O thread can continue writing while we parse + ever_processed = pane.shared_parser.run_parse_pass(&mut pane.terminal); - let process_start = std::time::Instant::now(); - pane.terminal.process(data); - let process_time_ns = process_start.elapsed().as_nanos() as u64; - - // Consume the data now that we're done parsing - pane.pty_buffer.consume_all(); - - if process_time_ns > 5_000_000 { - log::info!("PTY: process={:.2}ms bytes={}", - process_time_ns as f64 / 1_000_000.0, - len); + if ever_processed { + pane.terminal.mark_dirty(); + // Collect any commands from the terminal + all_commands.extend(pane.terminal.take_commands()); } - // Collect any commands from the terminal - commands = pane.terminal.take_commands(); - processed = true; - break; + // Check for pending data - I/O thread may have written more + let has_more_data = pane.shared_parser.has_pending_data(); + + // Handle commands outside the borrow + for cmd in all_commands { + self.handle_terminal_command(pane_id, cmd); + } + + return (ever_processed, has_more_data); } } - // Handle commands outside the borrow - for cmd in commands { - self.handle_terminal_command(pane_id, cmd); - } - - processed + (false, false) } /// Handle a command from the terminal (triggered by OSC sequences). @@ -1688,6 +1590,178 @@ impl App { } } + /// Render a frame directly. Called from Tick handler for async rendering. + /// Returns true if animations are in progress and another render should be scheduled. + /// + /// This is the Kitty-style approach: parse all input, then render once, all in the + /// same event handler. This avoids the overhead of bouncing between UserEvent::Tick + /// and WindowEvent::RedrawRequested. + fn do_render(&mut self) -> bool { + #[cfg(feature = "render_timing")] + let render_start = std::time::Instant::now(); + let mut needs_another_frame = false; + + // Send any terminal responses back to PTY (for active pane) + if let Some(tab) = self.active_tab_mut() { + if let Some(pane) = tab.active_pane_mut() { + if let Some(response) = pane.terminal.take_response() { + pane.write_to_pty(&response); + } + + // Track scrollback changes for selection adjustment + let scrollback_len = pane.terminal.scrollback.len() as u32; + if scrollback_len != pane.last_scrollback_len { + let lines_added = scrollback_len.saturating_sub(pane.last_scrollback_len) as isize; + if let Some(ref mut selection) = pane.selection { + selection.start.row -= lines_added; + selection.end.row -= lines_added; + } + pane.last_scrollback_len = scrollback_len; + } + } + } + + // Render all panes + let num_tabs = self.tabs.len(); + let active_tab_idx = self.active_tab; + let fade_duration_ms = self.config.inactive_pane_fade_ms; + let inactive_dim = self.config.inactive_pane_dim; + + if let Some(renderer) = &mut self.renderer { + if let Some(tab) = self.tabs.get_mut(active_tab_idx) { + // Collect all pane geometries + let geometries = tab.collect_pane_geometries(); + let active_pane_id = tab.active_pane; + + // First pass: sync images and calculate dim factors (needs mutable access) + let mut dim_factors: Vec<(PaneId, f32)> = Vec::new(); + for (pane_id, _) in &geometries { + if let Some(pane) = tab.panes.get_mut(pane_id) { + let is_active = *pane_id == active_pane_id; + let dim_factor = pane.calculate_dim_factor(is_active, fade_duration_ms, inactive_dim); + dim_factors.push((*pane_id, dim_factor)); + + // Sync terminal images to GPU (Kitty graphics protocol) + renderer.sync_images(&mut pane.terminal.image_storage); + } + } + + // Clear custom statusline if the foreground process is no longer neovim/vim + if let Some(pane) = tab.panes.get_mut(&active_pane_id) { + if pane.custom_statusline.is_some() { + if let Some(proc_name) = pane.pty.foreground_process_name() { + let is_vim = proc_name == "nvim" || proc_name == "vim" || proc_name == "vi"; + if !is_vim { + pane.custom_statusline = None; + } + } + } + } + + // Build render info for all panes + let mut pane_render_data: Vec<(&Terminal, PaneRenderInfo, Option<(usize, usize, usize, usize)>)> = Vec::new(); + + for (pane_id, geom) in &geometries { + if let Some(pane) = tab.panes.get(pane_id) { + let is_active = *pane_id == active_pane_id; + let scroll_offset = pane.terminal.scroll_offset; + + // Get pre-calculated dim factor + let dim_factor = dim_factors.iter() + .find(|(id, _)| id == pane_id) + .map(|(_, f)| *f) + .unwrap_or(if is_active { 1.0 } else { inactive_dim }); + + // Convert selection to screen coords for this pane + let selection = if is_active { + pane.selection.as_ref() + .and_then(|sel| sel.to_screen_coords(scroll_offset, geom.rows)) + } else { + None + }; + + let render_info = PaneRenderInfo { + pane_id: pane_id.0, + x: geom.x, + y: geom.y, + width: geom.width, + height: geom.height, + cols: geom.cols, + rows: geom.rows, + is_active, + dim_factor, + }; + + pane_render_data.push((&pane.terminal, render_info, selection)); + } + } + + // Check if any animation is in progress + let animation_in_progress = dim_factors.iter().any(|(id, factor)| { + let is_active = *id == active_pane_id; + if is_active { + *factor < 1.0 + } else { + *factor > inactive_dim + } + }); + + let glow_in_progress = !self.edge_glows.is_empty(); + + let image_animation_in_progress = tab.panes.values().any(|pane| { + pane.terminal.image_storage.has_animations() + }); + + needs_another_frame = animation_in_progress || glow_in_progress || image_animation_in_progress; + + // Get the statusline content for the active pane + let statusline_content: StatuslineContent = tab.panes.get(&active_pane_id) + .map(|pane| { + if let Some(ref custom) = pane.custom_statusline { + StatuslineContent::Raw(custom.clone()) + } else if let Some(cwd) = pane.pty.foreground_cwd() { + let mut sections = vec![build_cwd_section(&cwd)]; + if let Some(git_section) = build_git_section(&cwd) { + sections.push(git_section); + } + StatuslineContent::Sections(sections) + } else { + StatuslineContent::Sections(Vec::new()) + } + }) + .unwrap_or_default(); + + match renderer.render_panes(&pane_render_data, num_tabs, active_tab_idx, &self.edge_glows, self.config.edge_glow_intensity, &statusline_content) { + Ok(_) => {} + Err(wgpu::SurfaceError::Lost) => { + renderer.resize(renderer.width, renderer.height); + } + Err(wgpu::SurfaceError::OutOfMemory) => { + log::error!("Out of GPU memory!"); + self.render_fatal_error = true; + } + Err(e) => { + log::error!("Render error: {:?}", e); + } + } + } + } + + // Clean up finished edge glow animations + self.edge_glows.retain(|g| !g.is_finished()); + + // Update stats + #[cfg(feature = "render_timing")] + { + let render_time = render_start.elapsed(); + self.total_render_ns += render_time.as_nanos() as u64; + self.render_count += 1; + } + self.last_render_at = std::time::Instant::now(); + + needs_another_frame + } + /// Send bytes to the active tab's PTY. fn write_to_pty(&mut self, data: &[u8]) { if let Some(tab) = self.tabs.get_mut(self.active_tab) { @@ -1705,6 +1779,16 @@ impl App { self.tabs.get_mut(self.active_tab) } + /// Get the active pane of the active tab, if any. + fn active_pane(&self) -> Option<&Pane> { + self.active_tab().and_then(|t| t.active_pane()) + } + + /// Get the active pane of the active tab mutably, if any. + fn active_pane_mut(&mut self) -> Option<&mut Pane> { + self.active_tab_mut().and_then(|t| t.active_pane_mut()) + } + fn resize(&mut self, new_size: PhysicalSize) { if new_size.width == 0 || new_size.height == 0 { return; @@ -1724,15 +1808,13 @@ impl App { } fn get_scroll_offset(&self) -> usize { - self.active_tab() - .and_then(|t| t.active_pane()) + self.active_pane() .map(|p| p.terminal.scroll_offset) .unwrap_or(0) } fn has_mouse_tracking(&self) -> bool { - self.active_tab() - .and_then(|t| t.active_pane()) + self.active_pane() .map(|p| p.terminal.mouse_tracking != MouseTrackingMode::None) .unwrap_or(false) } @@ -1748,8 +1830,7 @@ impl App { fn send_mouse_event(&mut self, button: u8, col: u16, row: u16, pressed: bool, is_motion: bool) { let seq = { - let Some(tab) = self.active_tab() else { return }; - let Some(pane) = tab.active_pane() else { return }; + let Some(pane) = self.active_pane() else { return }; pane.terminal.encode_mouse(button, col, row, pressed, is_motion, self.get_mouse_modifiers()) }; if !seq.is_empty() { @@ -1768,38 +1849,39 @@ impl App { let shift = mod_state.shift_key(); let super_key = mod_state.super_key(); - let key_name = match &event.logical_key { + let key_name: String = match &event.logical_key { Key::Named(named) => { - match named { - NamedKey::Tab => "tab".to_string(), - NamedKey::Enter => "enter".to_string(), - NamedKey::Escape => "escape".to_string(), - NamedKey::Backspace => "backspace".to_string(), - NamedKey::Delete => "delete".to_string(), - NamedKey::Insert => "insert".to_string(), - NamedKey::Home => "home".to_string(), - NamedKey::End => "end".to_string(), - NamedKey::PageUp => "pageup".to_string(), - NamedKey::PageDown => "pagedown".to_string(), - NamedKey::ArrowUp => "up".to_string(), - NamedKey::ArrowDown => "down".to_string(), - NamedKey::ArrowLeft => "left".to_string(), - NamedKey::ArrowRight => "right".to_string(), - NamedKey::Space => " ".to_string(), - NamedKey::F1 => "f1".to_string(), - NamedKey::F2 => "f2".to_string(), - NamedKey::F3 => "f3".to_string(), - NamedKey::F4 => "f4".to_string(), - NamedKey::F5 => "f5".to_string(), - NamedKey::F6 => "f6".to_string(), - NamedKey::F7 => "f7".to_string(), - NamedKey::F8 => "f8".to_string(), - NamedKey::F9 => "f9".to_string(), - NamedKey::F10 => "f10".to_string(), - NamedKey::F11 => "f11".to_string(), - NamedKey::F12 => "f12".to_string(), + let name: &'static str = match named { + NamedKey::Tab => "tab", + NamedKey::Enter => "enter", + NamedKey::Escape => "escape", + NamedKey::Backspace => "backspace", + NamedKey::Delete => "delete", + NamedKey::Insert => "insert", + NamedKey::Home => "home", + NamedKey::End => "end", + NamedKey::PageUp => "pageup", + NamedKey::PageDown => "pagedown", + NamedKey::ArrowUp => "up", + NamedKey::ArrowDown => "down", + NamedKey::ArrowLeft => "left", + NamedKey::ArrowRight => "right", + NamedKey::Space => " ", + NamedKey::F1 => "f1", + NamedKey::F2 => "f2", + NamedKey::F3 => "f3", + NamedKey::F4 => "f4", + NamedKey::F5 => "f5", + NamedKey::F6 => "f6", + NamedKey::F7 => "f7", + NamedKey::F8 => "f8", + NamedKey::F9 => "f9", + NamedKey::F10 => "f10", + NamedKey::F11 => "f11", + NamedKey::F12 => "f12", _ => return false, - } + }; + name.to_string() } Key::Character(c) => c.to_lowercase(), _ => return false, @@ -1831,9 +1913,7 @@ impl App { self.create_tab(cols, rows); // Resize the new tab to calculate pane geometries self.resize_all_panes(); - if let Some(window) = &self.window { - window.request_redraw(); - } + self.request_redraw(); } } Action::ClosePane => { @@ -1902,7 +1982,7 @@ impl App { Ok(new_pane_id) => { // Get the info we need to start the I/O thread tab.get_pane(new_pane_id).map(|pane| { - (pane.id, pane.pty_fd, pane.pty_buffer.clone()) + (pane.id, pane.pty_fd, pane.shared_parser.clone()) }) } Err(e) => { @@ -1915,13 +1995,11 @@ impl App { }; // Start I/O thread for the new pane (outside the tab borrow) - if let Some((pane_id, pty_fd, pty_buffer)) = new_pane_info { - self.start_pane_io_thread_with_info(pane_id, pty_fd, pty_buffer); + if let Some((pane_id, pty_fd, shared_parser)) = new_pane_info { + self.start_pane_io_thread_with_info(pane_id, pty_fd, shared_parser); // Recalculate layout self.resize_all_panes(); - if let Some(window) = &self.window { - window.request_redraw(); - } + self.request_redraw(); log::info!("Split pane (horizontal={}), new pane {}", horizontal, pane_id.0); } } @@ -1983,9 +2061,7 @@ impl App { } } - if let Some(window) = &self.window { - window.request_redraw(); - } + self.request_redraw(); } fn close_active_pane(&mut self) { @@ -2006,9 +2082,7 @@ impl App { self.resize_all_panes(); } - if let Some(window) = &self.window { - window.request_redraw(); - } + self.request_redraw(); } fn switch_to_tab(&mut self, idx: usize) { @@ -2016,9 +2090,7 @@ impl App { self.active_tab = idx; // Update grid dimensions for proper centering of the new active tab self.update_active_tab_grid_dimensions(); - if let Some(window) = &self.window { - window.request_redraw(); - } + self.request_redraw(); } } @@ -2116,6 +2188,8 @@ impl App { } fn handle_keyboard_input(&mut self, event: KeyEvent) { + log::debug!("KeyEvent: {:?} state={:?} repeat={}", event.logical_key, event.state, event.repeat); + if self.check_keybinding(&event) { return; } @@ -2128,6 +2202,7 @@ impl App { }; if event_type == KeyEventType::Release && !self.keyboard_state.report_events() { + log::debug!("Ignoring release event (not in enhanced mode)"); return; } @@ -2204,6 +2279,7 @@ impl App { }; if let Some(bytes) = bytes { + log::debug!("Sending {} bytes to PTY: {:?}", bytes.len(), bytes); // Reset scroll when typing if let Some(tab) = self.active_tab_mut() { if let Some(pane) = tab.active_pane_mut() { @@ -2219,10 +2295,12 @@ impl App { impl ApplicationHandler for App { fn resumed(&mut self, event_loop: &ActiveEventLoop) { + #[cfg(feature = "render_timing")] let start = std::time::Instant::now(); if self.window.is_none() { self.create_window(event_loop); } + #[cfg(feature = "render_timing")] log::info!("App resumed (window creation): {:?}", start.elapsed()); } @@ -2234,29 +2312,124 @@ impl ApplicationHandler for App { self.create_window(event_loop); } } - UserEvent::PtyReadable(pane_id) => { - // I/O thread has batched wakeups - read all available data now - let start = std::time::Instant::now(); - self.poll_pane(pane_id); - let process_time = start.elapsed(); + UserEvent::Tick => { + log::info!("[MAIN] Tick received"); + // Check for fatal render errors from previous frames + if self.render_fatal_error { + log::error!("Fatal render error occurred, exiting"); + event_loop.exit(); + return; + } - // Check if terminal is in synchronized output mode (DCS pending mode or CSI 2026) - // If so, skip the redraw - rendering will happen when sync mode ends - let synchronized = self.tabs.iter() - .flat_map(|tab| tab.panes.values()) - .find(|pane| pane.id == pane_id) - .map(|pane| pane.terminal.is_synchronized()) - .unwrap_or(false); + #[cfg(feature = "render_timing")] + let tick_received_at = std::time::Instant::now(); + #[cfg(feature = "render_timing")] + let _ = tick_received_at; // silence unused warning for now - // Request redraw to display the new content (unless in sync mode) - if !synchronized { - if let Some(window) = &self.window { - window.request_redraw(); + // Like Kitty's process_global_state: parse panes (with time budget), then render + #[cfg(feature = "render_timing")] + let tick_start = std::time::Instant::now(); + let mut any_input = false; + let mut any_has_more = false; + let mut any_not_synchronized = false; + + // Collect all pane IDs first to avoid borrow issues + let pane_ids: Vec = self.tabs.iter() + .flat_map(|tab| tab.panes.keys().copied()) + .collect(); + + // Poll each pane - parses data up to time budget, returns if more pending + for pane_id in &pane_ids { + let (processed, has_more) = self.poll_pane(*pane_id); + if processed { + any_input = true; + } + if has_more { + any_has_more = true; } } - if process_time.as_millis() > 5 { - log::info!("PTY process took {:?}", process_time); + // Log detailed parse stats if any tick was slow (only with render_timing feature) + #[cfg(feature = "render_timing")] + { + let parse_time = tick_start.elapsed(); + if parse_time.as_millis() > 5 { + for tab in &mut self.tabs { + for pane in tab.panes.values_mut() { + pane.terminal.stats.log_if_slow(0); // Log all stats when tick is slow + pane.terminal.stats.reset(); // Reset for next tick + } + } + } + } + + // CRITICAL: Send any terminal responses back to PTY immediately after parsing. + // This must happen regardless of rendering, or the application will hang + // waiting for responses to queries like DSR (Device Status Report). + for tab in &mut self.tabs { + for pane in tab.panes.values_mut() { + if let Some(response) = pane.terminal.take_response() { + log::debug!("[RESPONSE] Sending {} bytes to PTY", response.len()); + pane.write_to_pty(&response); + } + } + } + + // Check if any terminal is NOT in synchronized mode (needs render) + for tab in &self.tabs { + for pane in tab.panes.values() { + if !pane.terminal.is_synchronized() { + any_not_synchronized = true; + break; + } + } + if any_not_synchronized { break; } + } + + // Render directly here (Kitty-style), throttled by repaint_delay + // 6ms ≈ 166 FPS, ensures we hit 144 FPS on high refresh displays + const REPAINT_DELAY: Duration = Duration::from_millis(6); + let time_since_last_render = self.last_render_at.elapsed(); + + let should_render = any_input && any_not_synchronized && + time_since_last_render >= REPAINT_DELAY && + self.renderer.is_some(); + + #[cfg(feature = "render_timing")] + let render_start = std::time::Instant::now(); + + if should_render { + let needs_another_frame = self.do_render(); + + // If animations are in progress, schedule another render via redraw + if needs_another_frame { + self.request_redraw(); + } + } else if any_input && any_not_synchronized && self.renderer.is_some() { + // We had input but skipped render due to throttling. + // Request a redraw so we don't lose this frame. + self.request_redraw(); + } + + #[cfg(feature = "render_timing")] + let render_time = if should_render { render_start.elapsed() } else { Duration::ZERO }; + + // If there's more data pending, send another Tick immediately + // This ensures we keep processing without waiting for I/O thread wakeup + if any_has_more { + if let Some(proxy) = &self.event_loop_proxy { + let _ = proxy.send_event(UserEvent::Tick); + } + } + + // Log every tick during benchmark for analysis (only with render_timing feature) + #[cfg(feature = "render_timing")] + { + let tick_time = tick_start.elapsed(); + if tick_time.as_millis() > 5 { + log::info!("[TICK] render={:?} total={:?} has_more={} rendered={}", + render_time, tick_time, any_has_more, should_render); + } } } UserEvent::ConfigReloaded => { @@ -2287,9 +2460,7 @@ impl ApplicationHandler for App { if should_resize { self.resize_all_panes(); } - if let Some(window) = &self.window { - window.request_redraw(); - } + self.request_redraw(); } WindowEvent::ModifiersChanged(new_modifiers) => { @@ -2322,20 +2493,27 @@ impl ApplicationHandler for App { pane.terminal.scroll(lines); } } - if let Some(window) = &self.window { - window.request_redraw(); - } + self.request_redraw(); } } WindowEvent::CursorMoved { position, .. } => { self.cursor_position = position; - let is_selecting = self.active_tab() - .and_then(|t| t.active_pane()) + let is_selecting = self.active_pane() .map(|p| p.is_selecting) .unwrap_or(false); - if is_selecting && !self.has_mouse_tracking() { + + if is_selecting && self.has_mouse_tracking() { + // Send mouse drag/motion events to PTY for apps like Neovim + if let Some(renderer) = &self.renderer { + if let Some((col, row)) = renderer.pixel_to_cell(position.x, position.y) { + // Button 0 (left) with motion flag + self.send_mouse_event(0, col as u16, row as u16, true, true); + } + } + } else if is_selecting && !self.has_mouse_tracking() { + // Terminal-native selection if let Some(renderer) = &self.renderer { if let Some((col, screen_row)) = renderer.pixel_to_cell(position.x, position.y) { let scroll_offset = self.get_scroll_offset(); @@ -2345,9 +2523,7 @@ impl ApplicationHandler for App { if let Some(pane) = tab.active_pane_mut() { if let Some(ref mut selection) = pane.selection { selection.end = CellPosition { col, row: content_row }; - if let Some(window) = &self.window { - window.request_redraw(); - } + self.request_redraw(); } } } @@ -2408,8 +2584,7 @@ impl ApplicationHandler for App { } } ElementState::Released => { - let was_selecting = self.active_tab() - .and_then(|t| t.active_pane()) + let was_selecting = self.active_pane() .map(|p| p.is_selecting) .unwrap_or(false); if was_selecting { @@ -2423,19 +2598,25 @@ impl ApplicationHandler for App { } } } - if let Some(window) = &self.window { - window.request_redraw(); - } + self.request_redraw(); } WindowEvent::KeyboardInput { event, .. } => { self.handle_keyboard_input(event); - if let Some(window) = &self.window { - window.request_redraw(); - } + // Don't request_redraw here - let the Tick handler do parsing and rendering + // after PTY responds. Calling request_redraw here causes a render of stale + // state before the PTY response arrives. } WindowEvent::RedrawRequested => { + // Check for fatal render errors + if self.render_fatal_error { + log::error!("Fatal render error occurred, exiting"); + event_loop.exit(); + return; + } + + #[cfg(feature = "render_timing")] let frame_start = std::time::Instant::now(); self.frame_count += 1; @@ -2445,187 +2626,36 @@ impl ApplicationHandler for App { self.last_frame_log = std::time::Instant::now(); } - // Note: poll_pane() is called from UserEvent::PtyReadable, not here. - // This avoids double-processing and keeps rendering fast. + // Use shared render logic + let needs_another_frame = self.do_render(); - // Send any terminal responses back to PTY (for active pane) - if let Some(tab) = self.active_tab_mut() { - if let Some(pane) = tab.active_pane_mut() { - if let Some(response) = pane.terminal.take_response() { - pane.write_to_pty(&response); - } - - // Track scrollback changes for selection adjustment - let scrollback_len = pane.terminal.scrollback.len() as u32; - if scrollback_len != pane.last_scrollback_len { - let lines_added = scrollback_len.saturating_sub(pane.last_scrollback_len) as isize; - if let Some(ref mut selection) = pane.selection { - selection.start.row -= lines_added; - selection.end.row -= lines_added; - } - pane.last_scrollback_len = scrollback_len; - } - } + // If animations are in progress, schedule another render + if needs_another_frame { + self.request_redraw(); } - // Render all panes - let render_start = std::time::Instant::now(); - let num_tabs = self.tabs.len(); - let active_tab_idx = self.active_tab; - let fade_duration_ms = self.config.inactive_pane_fade_ms; - let inactive_dim = self.config.inactive_pane_dim; - - if let Some(renderer) = &mut self.renderer { - if let Some(tab) = self.tabs.get_mut(active_tab_idx) { - // Collect all pane geometries - let geometries = tab.collect_pane_geometries(); - let active_pane_id = tab.active_pane; - - // First pass: sync images and calculate dim factors (needs mutable access) - let mut dim_factors: Vec<(PaneId, f32)> = Vec::new(); - for (pane_id, _) in &geometries { - if let Some(pane) = tab.panes.get_mut(pane_id) { - let is_active = *pane_id == active_pane_id; - let dim_factor = pane.calculate_dim_factor(is_active, fade_duration_ms, inactive_dim); - dim_factors.push((*pane_id, dim_factor)); - - // Sync terminal images to GPU (Kitty graphics protocol) - renderer.sync_images(&mut pane.terminal.image_storage); - } - } - - // Clear custom statusline if the foreground process is no longer neovim/vim - // This handles the case where neovim exits but didn't send a clear command - if let Some(pane) = tab.panes.get_mut(&active_pane_id) { - if pane.custom_statusline.is_some() { - if let Some(proc_name) = pane.pty.foreground_process_name() { - let is_vim = proc_name == "nvim" || proc_name == "vim" || proc_name == "vi"; - if !is_vim { - pane.custom_statusline = None; - } - } - } - } - - // Build render info for all panes - let mut pane_render_data: Vec<(&Terminal, PaneRenderInfo, Option<(usize, usize, usize, usize)>)> = Vec::new(); - - for (pane_id, geom) in &geometries { - if let Some(pane) = tab.panes.get(pane_id) { - let is_active = *pane_id == active_pane_id; - let scroll_offset = pane.terminal.scroll_offset; - - // Get pre-calculated dim factor - let dim_factor = dim_factors.iter() - .find(|(id, _)| id == pane_id) - .map(|(_, f)| *f) - .unwrap_or(if is_active { 1.0 } else { inactive_dim }); - - // Convert selection to screen coords for this pane - let selection = if is_active { - let sel = pane.selection.as_ref() - .and_then(|sel| sel.to_screen_coords(scroll_offset, geom.rows)); - if pane.selection.is_some() { - log::debug!("Render: pane.selection={:?}, scroll_offset={}, rows={}, screen_coords={:?}", - pane.selection, scroll_offset, geom.rows, sel); - } - sel - } else { - None - }; - - let render_info = PaneRenderInfo { - pane_id: pane_id.0, - x: geom.x, - y: geom.y, - width: geom.width, - height: geom.height, - cols: geom.cols, - rows: geom.rows, - is_active, - dim_factor, - }; - - pane_render_data.push((&pane.terminal, render_info, selection)); - } - } - - // Request redraw if any animation is in progress - let animation_in_progress = dim_factors.iter().any(|(id, factor)| { - let is_active = *id == active_pane_id; - if is_active { - *factor < 1.0 - } else { - *factor > inactive_dim - } - }); - - if animation_in_progress { - if let Some(window) = &self.window { - window.request_redraw(); - } - } - - // Handle edge glow animations - let glow_in_progress = !self.edge_glows.is_empty(); - - // Check if any pane has animated images - let image_animation_in_progress = tab.panes.values().any(|pane| { - pane.terminal.image_storage.has_animations() - }); - - // Get the statusline content for the active pane - // If the pane has a custom statusline (from neovim), use raw ANSI content - let statusline_content: StatuslineContent = tab.panes.get(&active_pane_id) - .map(|pane| { - if let Some(ref custom) = pane.custom_statusline { - // Use raw ANSI content directly - no parsing into sections - StatuslineContent::Raw(custom.clone()) - } else if let Some(cwd) = pane.pty.foreground_cwd() { - // Default: CWD and git sections - let mut sections = vec![build_cwd_section(&cwd)]; - if let Some(git_section) = build_git_section(&cwd) { - sections.push(git_section); - } - StatuslineContent::Sections(sections) - } else { - StatuslineContent::Sections(Vec::new()) - } - }) - .unwrap_or_default(); - - match renderer.render_panes(&pane_render_data, num_tabs, active_tab_idx, &self.edge_glows, self.config.edge_glow_intensity, &statusline_content) { - Ok(_) => {} - Err(wgpu::SurfaceError::Lost) => { - renderer.resize(renderer.width, renderer.height); - } - Err(wgpu::SurfaceError::OutOfMemory) => { - log::error!("Out of GPU memory!"); - event_loop.exit(); - } - Err(e) => { - log::error!("Render error: {:?}", e); - } - } - - // Request redraw if edge glow or image animation is in progress - if glow_in_progress || image_animation_in_progress { - if let Some(window) = &self.window { - window.request_redraw(); - } - } - } + // Log cumulative stats every second (only with render_timing feature) + #[cfg(feature = "render_timing")] + if self.last_stats_log.elapsed() >= Duration::from_secs(1) { + let parse_ms = self.total_parse_ns as f64 / 1_000_000.0; + let render_ms = self.total_render_ns as f64 / 1_000_000.0; + log::info!("STATS: parse={:.1}ms/{} render={:.1}ms/{} ratio={:.2}", + parse_ms, self.parse_count, + render_ms, self.render_count, + if parse_ms > 0.0 { render_ms / parse_ms } else { 0.0 }); + self.total_parse_ns = 0; + self.total_render_ns = 0; + self.parse_count = 0; + self.render_count = 0; + self.last_stats_log = std::time::Instant::now(); } - // Clean up finished edge glow animations - self.edge_glows.retain(|g| !g.is_finished()); - - let render_time = render_start.elapsed(); - let frame_time = frame_start.elapsed(); - - if frame_time.as_millis() > 10 { - log::info!("Slow frame: total={:?} render={:?}", - frame_time, render_time); + #[cfg(feature = "render_timing")] + { + let frame_time = frame_start.elapsed(); + if frame_time.as_millis() > 16 { + log::info!("Slow frame: {:?}", frame_time); + } } } diff --git a/src/pane_resources.rs b/src/pane_resources.rs new file mode 100644 index 0000000..226db55 --- /dev/null +++ b/src/pane_resources.rs @@ -0,0 +1,23 @@ +//! Per-pane GPU resources for multi-pane terminal rendering. +//! +//! This module provides GPU resource management for individual terminal panes, +//! following Kitty's VAO-per-window approach where each pane gets its own +//! buffers and bind group for independent rendering. + +// ═══════════════════════════════════════════════════════════════════════════════ +// 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, +} diff --git a/src/pipeline.rs b/src/pipeline.rs new file mode 100644 index 0000000..0c28eca --- /dev/null +++ b/src/pipeline.rs @@ -0,0 +1,79 @@ +//! Render pipeline builder for wgpu. +//! +//! This module provides a builder pattern for creating wgpu render pipelines +//! with common settings, reducing boilerplate when creating multiple pipelines. + +// ═══════════════════════════════════════════════════════════════════════════════ +// PIPELINE BUILDER +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Builder for creating render pipelines with common settings. +/// Captures the device, shader, layout, and format that are shared across many pipelines. +pub struct PipelineBuilder<'a> { + device: &'a wgpu::Device, + shader: &'a wgpu::ShaderModule, + layout: &'a wgpu::PipelineLayout, + format: wgpu::TextureFormat, +} + +impl<'a> PipelineBuilder<'a> { + /// Create a new pipeline builder with shared settings. + pub fn new( + device: &'a wgpu::Device, + shader: &'a wgpu::ShaderModule, + layout: &'a wgpu::PipelineLayout, + format: wgpu::TextureFormat, + ) -> Self { + Self { device, shader, layout, format } + } + + /// Build a pipeline with TriangleStrip topology and no vertex buffers (most common case). + pub fn build(&self, label: &str, vs_entry: &str, fs_entry: &str, blend: wgpu::BlendState) -> wgpu::RenderPipeline { + self.build_full(label, vs_entry, fs_entry, blend, wgpu::PrimitiveTopology::TriangleStrip, &[]) + } + + /// Build a pipeline with custom topology and vertex buffers. + pub fn build_full( + &self, + label: &str, + vs_entry: &str, + fs_entry: &str, + blend: wgpu::BlendState, + topology: wgpu::PrimitiveTopology, + vertex_buffers: &[wgpu::VertexBufferLayout<'_>], + ) -> wgpu::RenderPipeline { + self.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(self.layout), + vertex: wgpu::VertexState { + module: self.shader, + entry_point: Some(vs_entry), + buffers: vertex_buffers, + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: self.shader, + entry_point: Some(fs_entry), + targets: &[Some(wgpu::ColorTargetState { + format: self.format, + blend: Some(blend), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }) + } +} diff --git a/src/renderer.rs b/src/renderer.rs index 595b4cb..9d06364 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,24 +1,45 @@ //! 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::box_drawing::{is_box_drawing, render_box_char}; +use crate::color::LinearPalette; +use crate::color_font::{find_color_font_for_char, ColorFontRenderer}; +use crate::config::{Config, TabBarPosition}; +use crate::font_loader::{find_font_for_char, load_font_family, FontVariant}; +use crate::pane_resources::PaneGpuResources; +use crate::pipeline::PipelineBuilder; +use crate::gpu_types::{ + GlowInstance, GlyphVertex, GridParams, ImageUniforms, + EdgeGlowUniforms, QuadParams, StatuslineParams, + ATLAS_SIZE, MAX_ATLAS_LAYERS, ATLAS_BPP, MAX_EDGE_GLOWS, + COLOR_TYPE_DEFAULT, COLOR_TYPE_INDEXED, COLOR_TYPE_RGB, + ATTR_BOLD, ATTR_ITALIC, ATTR_STRIKE, + COLORED_GLYPH_FLAG, + CURSOR_SPRITE_BEAM, CURSOR_SPRITE_UNDERLINE, CURSOR_SPRITE_HOLLOW, + DECORATION_SPRITE_STRIKETHROUGH, DECORATION_SPRITE_UNDERLINE, DECORATION_SPRITE_DOUBLE_UNDERLINE, + DECORATION_SPRITE_UNDERCURL, DECORATION_SPRITE_DOTTED, DECORATION_SPRITE_DASHED, + FIRST_GLYPH_SPRITE, +}; +use crate::graphics::ImageStorage; +use crate::image_renderer::ImageRenderer; 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::collections::HashSet; +use std::num::NonZeroU32; +use rustc_hash::FxHashMap; 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}; +// Re-export types for backwards compatibility +pub use crate::edge_glow::EdgeGlow; +pub use crate::statusline::{StatuslineColor, StatuslineComponent, StatuslineSection, StatuslineContent}; +pub use crate::gpu_types::{FontCellMetrics, GPUCell, Quad, SpriteInfo}; /// Pane geometry for multi-pane rendering. /// Describes where to render a pane within the window. @@ -46,221 +67,6 @@ pub struct PaneRenderInfo { 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) -> 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) -> 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, - /// 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) -> 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), - /// 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 (like Kitty's max_texture_size). -/// 8192x8192 provides massive capacity before needing additional layers. -const ATLAS_SIZE: u32 = 8192; - -/// Maximum number of atlas layers (like Kitty's max_array_len). -/// With 8192x8192 per layer, this provides virtually unlimited glyph storage. -const MAX_ATLAS_LAYERS: u32 = 64; - -/// 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. @@ -276,6 +82,16 @@ struct GlyphInfo { layer: f32, } +impl GlyphInfo { + /// Empty glyph info (e.g., for space characters or failed rasterization). + const EMPTY: Self = Self { + uv: [0.0, 0.0, 0.0, 0.0], + layer: 0.0, + size: [0.0, 0.0], + is_colored: false, + }; +} + /// 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 { @@ -310,17 +126,6 @@ impl FontStyle { } } -/// 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 { @@ -330,275 +135,18 @@ struct ShapedGlyphs { 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::() as wgpu::BufferAddress, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &Self::ATTRIBS, +impl From for SpriteInfo { + #[inline] + fn from(info: GlyphInfo) -> Self { + Self { + uv: info.uv, + layer: info.layer, + _padding: 0.0, + size: info.size, } } } -/// 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; - -/// Pre-rendered cursor sprite indices (like Kitty's cursor_shape_map). -/// These sprites are created at fixed indices in the sprite array after initialization. -/// Index 0 is reserved for "no glyph" (empty cell). -pub const CURSOR_SPRITE_BEAM: u32 = 1; // Bar/beam cursor (vertical line on left) -pub const CURSOR_SPRITE_UNDERLINE: u32 = 2; // Underline cursor (horizontal line at bottom) -pub const CURSOR_SPRITE_HOLLOW: u32 = 3; // Hollow/unfocused cursor (outline rectangle) - -/// Pre-rendered decoration sprite indices (like Kitty's decoration sprites). -/// These are created after cursor sprites and used for text decorations. -/// The shader uses these to render underlines, strikethrough, etc. -pub const DECORATION_SPRITE_STRIKETHROUGH: u32 = 4; // Strikethrough line -pub const DECORATION_SPRITE_UNDERLINE: u32 = 5; // Single underline -pub const DECORATION_SPRITE_DOUBLE_UNDERLINE: u32 = 6; // Double underline -pub const DECORATION_SPRITE_UNDERCURL: u32 = 7; // Wavy/curly underline -pub const DECORATION_SPRITE_DOTTED: u32 = 8; // Dotted underline -pub const DECORATION_SPRITE_DASHED: u32 = 9; // Dashed underline - -/// First available sprite index for regular glyphs (after reserved cursor and decoration sprites) -pub const FIRST_GLYPH_SPRITE: u32 = 10; - -/// 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], - /// Atlas layer index (z-coordinate for texture array) and padding - /// layer is the first f32, second f32 is unused padding - pub layer: f32, - pub _padding: f32, - /// Size in pixels (width, height) - always matches cell dimensions - pub size: [f32; 2], -} - -/// Font cell metrics with integer dimensions (like Kitty's FontCellMetrics). -/// Using integers ensures pixel-perfect alignment and avoids floating-point precision issues. -#[derive(Copy, Clone, Debug)] -pub struct FontCellMetrics { - /// Cell width in pixels (computed using ceil from font advance). - pub cell_width: u32, - /// Cell height in pixels (computed using ceil from font height). - pub cell_height: u32, - /// Baseline offset from top of cell in pixels. - pub baseline: u32, - /// Y position for underline (from top of cell, in pixels). - /// Computed from font metrics: ascender - underline_position. - pub underline_position: u32, - /// Thickness of underline in pixels. - pub underline_thickness: u32, - /// Y position for strikethrough (from top of cell, in pixels). - /// Typically around 65% of baseline from top. - pub strikethrough_position: u32, - /// Thickness of strikethrough in pixels. - pub strikethrough_thickness: u32, -} - -/// Grid parameters uniform for instanced rendering. -/// Matches GridParams in glyph_shader.wgsl exactly. -/// Uses Kitty-style NDC positioning: viewport is set per-pane, so shader -/// works in pure NDC space without needing pixel offsets. -/// Cell dimensions are integers like Kitty for pixel-perfect rendering. -#[repr(C)] -#[derive(Copy, Clone, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)] -struct GridParams { - cols: u32, - rows: u32, - cell_width: u32, - cell_height: u32, - 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. @@ -611,16 +159,34 @@ struct ColorTable { /// 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)] +/// Key for sprite lookup - optimized to avoid heap allocation. +/// Most sprites are single characters, multi-cell symbols use (char, cell_index). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] struct SpriteKey { - /// The character or ligature string - text: String, + /// The character (single char for most glyphs) + ch: char, + /// Cell index for multi-cell symbols (0 for single-cell) + cell_index: u8, /// Font style (regular, bold, italic, bold-italic) style: FontStyle, /// Whether this is a colored glyph (emoji) colored: bool, } +impl SpriteKey { + /// Create a key for a single-cell sprite (the common case) + #[inline] + fn single(ch: char, style: FontStyle, colored: bool) -> Self { + Self { ch, cell_index: 0, style, colored } + } + + /// Create a key for a multi-cell sprite + #[inline] + fn multi(ch: char, cell_index: u8, style: FontStyle, colored: bool) -> Self { + Self { ch, cell_index, style, colored } + } +} + /// Target sprite buffer for glyph allocation. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SpriteTarget { @@ -648,21 +214,19 @@ pub struct Renderer { // 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, + /// Image renderer for Kitty graphics protocol. + image_renderer: ImageRenderer, - // Atlas texture (2D array like Kitty for virtually unlimited glyph storage) - atlas_texture: wgpu::Texture, - atlas_view: wgpu::TextureView, + // Atlas textures - vector of separate 2D textures for O(1) layer addition + // Unlike a texture_2d_array, adding a new layer just means creating a new texture + // without copying existing data. + atlas_textures: Vec, + atlas_views: Vec, /// Atlas sampler - stored for use when recreating bind group after layer addition atlas_sampler: wgpu::Sampler, /// Bind group layout for glyph rendering - needed to recreate bind group after texture changes glyph_bind_group_layout: wgpu::BindGroupLayout, - /// Number of currently allocated layers in the GPU texture - atlas_num_layers: u32, - /// Current layer being written to (z-coordinate) + /// Current layer being written to (index into atlas_textures) atlas_current_layer: u32, // Font and shaping @@ -684,15 +248,15 @@ pub struct Renderer { /// Using RefCell because ColorFontRenderer needs mutable access to cache font faces color_font_renderer: RefCell>, /// Cache mapping characters to their color font path (if any) - color_font_cache: HashMap>, + color_font_cache: FxHashMap>, shaping_ctx: ShapingContext, /// OpenType features for shaping (shared across all font variants) shaping_features: Vec, - char_cache: HashMap, // cache char -> rendered glyph - ligature_cache: HashMap, // cache multi-char -> shaped glyphs + char_cache: FxHashMap, // cache char -> rendered glyph + ligature_cache: FxHashMap, // 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>, + glyph_cache: FxHashMap<(usize, usize, u16), GlyphInfo>, atlas_cursor_x: u32, atlas_cursor_y: u32, atlas_row_height: u32, @@ -721,8 +285,10 @@ pub struct Renderer { /// Window dimensions. pub width: u32, pub height: u32, - /// Color palette for rendering. + /// Color palette for rendering (sRGB). palette: ColorPalette, + /// Pre-computed linear palette for GPU (avoids repeated sRGB→linear conversions). + linear_palette: LinearPalette, /// Tab bar position. tab_bar_position: TabBarPosition, /// Background opacity (0.0 = transparent, 1.0 = opaque). @@ -744,7 +310,7 @@ pub struct Renderer { /// 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, + sprite_map: FxHashMap, /// Sprite info array: UV coordinates and offsets for each sprite. /// Index 0 is reserved for "no glyph" (space). sprite_info: Vec, @@ -788,7 +354,7 @@ pub struct Renderer { 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, + pane_resources: FxHashMap, // ═══════════════════════════════════════════════════════════════════════════════ // STATUSLINE RENDERING (dedicated shader and pipeline) @@ -811,7 +377,7 @@ pub struct Renderer { /// Pipeline for statusline glyph rendering. statusline_glyph_pipeline: wgpu::RenderPipeline, /// Separate sprite map for statusline (isolated from terminal sprites). - statusline_sprite_map: HashMap, + statusline_sprite_map: FxHashMap, /// Sprite info array for statusline. statusline_sprite_info: Vec, /// Next sprite index for statusline. @@ -846,1046 +412,6 @@ pub struct Renderer { overlay_quad_bind_group: wgpu::BindGroup, } -// ═══════════════════════════════════════════════════════════════════════════════ -// 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 { - 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, - /// Reusable Cairo surface for rendering - surface: Option, - /// Current surface dimensions - surface_size: (i32, i32), -} - -impl ColorFontRenderer { - fn new() -> Result { - 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, 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 { - 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; 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; 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; 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; 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; 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; 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, - 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, config: &Config) -> Self { @@ -1897,7 +423,7 @@ impl Renderer { // macOS uses 72 as base DPI, but winit normalizes this let dpi = 96.0 * scale_factor; - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { backends: wgpu::Backends::PRIMARY, ..Default::default() }); @@ -1914,15 +440,23 @@ impl Renderer { .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, + .request_device(&wgpu::DeviceDescriptor { + label: Some("Terminal Device"), + // TEXTURE_BINDING_ARRAY is required for our Vec atlas approach + // which uses binding_array> in shaders. + // SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING is required + // because we index the texture array with a non-uniform value (layer from vertex data). + required_features: wgpu::Features::TEXTURE_BINDING_ARRAY + | wgpu::Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING, + required_limits: wgpu::Limits { + // We need at least MAX_ATLAS_LAYERS (64) textures in our binding array + max_binding_array_elements_per_shader_stage: MAX_ATLAS_LAYERS, + ..wgpu::Limits::default() }, - None, - ) + memory_hints: wgpu::MemoryHints::Performance, + trace: wgpu::Trace::Off, + ..Default::default() + }) .await .expect("Failed to create device"); @@ -1992,7 +526,7 @@ impl Renderer { let regular_variant = font_variants[0].as_ref() .expect("Regular font variant should always be present"); ShapingContext { - face: regular_variant.face.clone(), + face: regular_variant.face().clone(), features: shaping_features.clone(), } }; @@ -2050,30 +584,65 @@ impl Renderer { // 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 as a 2D array (like Kitty) for virtually unlimited glyph storage. - // Start with 1 layer, grow dynamically as needed. - let initial_layers = 1u32; - let atlas_texture = device.create_texture(&wgpu::TextureDescriptor { - label: Some("Glyph Atlas Array"), - size: wgpu::Extent3d { - width: ATLAS_SIZE, - height: ATLAS_SIZE, - depth_or_array_layers: initial_layers, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_DST - | wgpu::TextureUsages::COPY_SRC, - view_formats: &[], - }); - - let atlas_view = atlas_texture.create_view(&wgpu::TextureViewDescriptor { - dimension: Some(wgpu::TextureViewDimension::D2Array), - ..Default::default() - }); + // Create atlas as a Vec of separate 2D textures for O(1) layer addition. + // Unlike a texture_2d_array, adding a new layer just means creating a new texture + // without copying existing data. wgpu requires bind group arrays to have exactly + // `count` textures, so we fill unused slots with 1x1 dummy textures. + let mut atlas_textures: Vec = Vec::with_capacity(MAX_ATLAS_LAYERS as usize); + let mut atlas_views: Vec = Vec::with_capacity(MAX_ATLAS_LAYERS as usize); + + // Helper to create a real atlas layer (8192x8192) + let create_atlas_layer = |device: &wgpu::Device| -> (wgpu::Texture, wgpu::TextureView) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Glyph Atlas Layer"), + 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 view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) + }; + + // Helper to create a dummy texture (1x1) for unused slots + let create_dummy_texture = |device: &wgpu::Device| -> (wgpu::Texture, wgpu::TextureView) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Dummy Atlas Texture"), + size: wgpu::Extent3d { + width: 1, + height: 1, + 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, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) + }; + + // First texture is real (layer 0) + let (tex, view) = create_atlas_layer(&device); + atlas_textures.push(tex); + atlas_views.push(view); + + // Fill remaining slots with dummy textures + for _ in 1..MAX_ATLAS_LAYERS { + let (tex, view) = create_dummy_texture(&device); + atlas_textures.push(tex); + atlas_views.push(view); + } + let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor { address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, @@ -2083,7 +652,7 @@ impl Renderer { ..Default::default() }); - // Create bind group layout - use D2Array for texture array + // Create bind group layout - use D2 with count for binding_array let glyph_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("Glyph Bind Group Layout"), @@ -2093,10 +662,10 @@ impl Renderer { visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2Array, + view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, - count: None, + count: Some(NonZeroU32::new(MAX_ATLAS_LAYERS).unwrap()), }, wgpu::BindGroupLayoutEntry { binding: 1, @@ -2107,13 +676,15 @@ impl Renderer { ], }); + // Create bind group with TextureViewArray + let atlas_view_refs: Vec<&wgpu::TextureView> = atlas_views.iter().collect(); 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), + resource: wgpu::BindingResource::TextureViewArray(&atlas_view_refs), }, wgpu::BindGroupEntry { binding: 1, @@ -2131,42 +702,18 @@ impl Renderer { let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("Glyph Pipeline Layout"), bind_group_layouts: &[&glyph_bind_group_layout], - push_constant_ranges: &[], + immediate_size: 0, }); - 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, - }); + let glyph_pipeline = PipelineBuilder::new(&device, &shader, &pipeline_layout, surface_config.format) + .build_full( + "Glyph Pipeline", + "vs_main", + "fs_main", + wgpu::BlendState::ALPHA_BLENDING, + wgpu::PrimitiveTopology::TriangleList, + &[GlyphVertex::desc()], + ); // ═══════════════════════════════════════════════════════════════════════════════ // EDGE GLOW PIPELINE SETUP @@ -2219,44 +766,19 @@ impl Renderer { 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: &[], + immediate_size: 0, }); // 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, - }); + let edge_glow_pipeline = PipelineBuilder::new(&device, &edge_glow_shader, &edge_glow_pipeline_layout, surface_config.format) + .build_full( + "Edge Glow Pipeline", + "vs_main", + "fs_main", + wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING, + wgpu::PrimitiveTopology::TriangleList, + &[], + ); // ═══════════════════════════════════════════════════════════════════════════════ // IMAGE PIPELINE SETUP (Kitty Graphics Protocol) @@ -2268,104 +790,32 @@ impl Renderer { 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 ImageRenderer (handles sampler and bind group layout) + let image_renderer = ImageRenderer::new(&device); // 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: &[], + bind_group_layouts: &[image_renderer.bind_group_layout()], + immediate_size: 0, }); // 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(), + // Premultiplied alpha blending (shader outputs premultiplied) + let image_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, }, - 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, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, }, - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview: None, - cache: None, - }); + }; + let image_pipeline = PipelineBuilder::new(&device, &image_shader, &image_pipeline_layout, surface_config.format) + .build("Image Pipeline", "vs_main", "fs_main", image_blend); // Create initial buffers with some capacity let initial_vertex_capacity = 4096; @@ -2615,156 +1065,46 @@ impl Renderer { 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: &[], + immediate_size: 0, }); - // 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, - }); + // Statusline pipelines share shader and layout + let statusline_builder = PipelineBuilder::new(&device, &statusline_shader, &statusline_pipeline_layout, surface_config.format); + let statusline_bg_pipeline = statusline_builder.build( + "Statusline Background Pipeline", + "vs_statusline_bg", + "fs_statusline", + wgpu::BlendState::ALPHA_BLENDING, + ); + let statusline_glyph_pipeline = statusline_builder.build( + "Statusline Glyph Pipeline", + "vs_statusline_glyph", + "fs_statusline", + wgpu::BlendState::ALPHA_BLENDING, + ); // 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: &[], + immediate_size: 0, }); - // 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, - }); + // Cell pipelines share shader and layout + let cell_builder = PipelineBuilder::new(&device, &shader, &instanced_pipeline_layout, surface_config.format); + let cell_bg_pipeline = cell_builder.build( + "Cell Background Pipeline", + "vs_cell_bg", + "fs_cell", + wgpu::BlendState::ALPHA_BLENDING, + ); + let cell_glyph_pipeline = cell_builder.build( + "Cell Glyph Pipeline", + "vs_cell_glyph", + "fs_cell", + wgpu::BlendState::ALPHA_BLENDING, + ); // ═══════════════════════════════════════════════════════════════════════════════ // INSTANCED QUAD RENDERING SETUP @@ -2866,43 +1206,12 @@ impl Renderer { 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: &[], + immediate_size: 0, }); // 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, - }); + let quad_pipeline = PipelineBuilder::new(&device, &quad_shader, &quad_pipeline_layout, surface_config.format) + .build("Quad Pipeline", "vs_quad", "fs_quad", wgpu::BlendState::ALPHA_BLENDING); let mut renderer = Self { surface, @@ -2915,12 +1224,11 @@ impl Renderer { edge_glow_bind_group, edge_glow_uniform_buffer, image_pipeline, - image_bind_group_layout, - image_sampler, - image_textures: HashMap::new(), - atlas_texture, - atlas_view, - atlas_num_layers: initial_layers, + image_renderer, + atlas_textures, + atlas_views, + atlas_sampler, + glyph_bind_group_layout, atlas_current_layer: 0, font_data, primary_font, @@ -2929,12 +1237,12 @@ impl Renderer { fontconfig: OnceCell::new(), tried_font_paths, color_font_renderer: RefCell::new(None), - color_font_cache: HashMap::new(), + color_font_cache: FxHashMap::default(), shaping_ctx, shaping_features, - char_cache: HashMap::new(), - ligature_cache: HashMap::new(), - glyph_cache: HashMap::new(), + char_cache: FxHashMap::default(), + ligature_cache: FxHashMap::default(), + glyph_cache: FxHashMap::default(), atlas_cursor_x: 0, atlas_cursor_y: 0, atlas_row_height: 0, @@ -2951,6 +1259,7 @@ impl Renderer { width: size.width, height: size.height, palette: ColorPalette::default(), + linear_palette: LinearPalette::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) @@ -2962,7 +1271,7 @@ impl Renderer { glyph_vertices: Vec::with_capacity(4096), glyph_indices: Vec::with_capacity(6144), // Kitty-style instanced rendering state - sprite_map: HashMap::new(), + sprite_map: FxHashMap::default(), // Index 0 is reserved for "no glyph" (space/empty) sprite_info: vec![SpriteInfo::default()], next_sprite_idx: 1, @@ -2981,7 +1290,7 @@ impl Renderer { selection: None, // Per-pane GPU resources (like Kitty's VAO per window) instanced_bind_group_layout, - pane_resources: HashMap::new(), + pane_resources: FxHashMap::default(), // Statusline rendering (dedicated shader and pipeline) statusline_gpu_cells: Vec::with_capacity(statusline_max_cols), statusline_cell_buffer, @@ -2991,7 +1300,7 @@ impl Renderer { statusline_bind_group, statusline_bg_pipeline, statusline_glyph_pipeline, - statusline_sprite_map: HashMap::new(), + statusline_sprite_map: FxHashMap::default(), statusline_sprite_info: vec![SpriteInfo::default()], // Index 0 reserved for "no glyph" statusline_next_sprite_idx: 1, statusline_sprite_buffer, @@ -3006,9 +1315,6 @@ impl Renderer { overlay_quads: Vec::with_capacity(32), overlay_quad_buffer, overlay_quad_bind_group, - // Store sampler and layout for atlas layer growth (bind group recreation) - atlas_sampler, - glyph_bind_group_layout, }; // Create pre-rendered cursor sprites at fixed indices (like Kitty's send_prerendered_sprites) @@ -3181,55 +1487,11 @@ impl Renderer { /// Calculates screen-space bounds for dim overlay given pane geometry. /// Takes pane coordinates in grid-relative space and transforms them to screen coordinates, /// extending to fill the terminal grid area (but not into tab bar or statusline). - /// This is identical to calculate_edge_glow_bounds - ensures dim overlays cover the full - /// pane area including centering margins, matching edge glow behavior. + /// This delegates to calculate_edge_glow_bounds as the logic is identical. /// Returns (screen_x, screen_y, width, height) for the overlay area. + #[inline] pub fn calculate_dim_overlay_bounds(&self, pane_x: f32, pane_y: f32, pane_width: f32, pane_height: f32) -> (f32, f32, f32, f32) { - let grid_x_offset = self.grid_x_offset(); - let grid_y_offset = self.grid_y_offset(); - let terminal_y_offset = self.terminal_y_offset(); - let (available_width, available_height) = self.available_grid_space(); - - // Calculate the terminal grid area boundaries in screen coordinates - // This is the area where content is rendered, excluding tab bar and statusline - let grid_top = terminal_y_offset; - let grid_bottom = terminal_y_offset + available_height; - let grid_left = 0.0_f32; - let grid_right = self.width as f32; - - // Transform pane coordinates to screen space (same as border rendering) - let mut screen_x = grid_x_offset + pane_x; - let mut screen_y = terminal_y_offset + grid_y_offset + pane_y; - let mut width = pane_width; - let mut height = pane_height; - - // Use a larger epsilon to account for cell-alignment gaps in split panes - // With cell-aligned splits, gaps can be up to one cell height - let epsilon = (self.cell_metrics.cell_height.max(self.cell_metrics.cell_width)) as f32; - - // Left edge at screen boundary - extend to screen left edge - if pane_x < epsilon { - width += screen_x - grid_left; - screen_x = grid_left; - } - - // Right edge at screen boundary - extend to screen right edge - if (pane_x + pane_width) >= available_width - epsilon { - width = grid_right - screen_x; - } - - // Top edge at grid boundary - extend to grid top (respects tab bar/statusline at top) - if pane_y < epsilon { - height += screen_y - grid_top; - screen_y = grid_top; - } - - // Bottom edge at grid boundary - extend to grid bottom (respects tab bar/statusline at bottom) - if (pane_y + pane_height) >= available_height - epsilon { - height = grid_bottom - screen_y; - } - - (screen_x, screen_y, width, height) + self.calculate_edge_glow_bounds(pane_x, pane_y, pane_width, pane_height) } /// Converts a pixel position to a terminal cell position. @@ -3506,11 +1768,7 @@ impl Renderer { }; // Check if we already have this sprite - let key = SpriteKey { - text: c.to_string(), - style, - colored: false, // Will check for emoji below - }; + let key = SpriteKey::single(c, style, false); if let Some(&idx) = sprite_map.get(&key) { // Check if it's a colored glyph @@ -3519,28 +1777,22 @@ impl Renderer { } // Check for emoji with color key - let color_key = SpriteKey { - text: c.to_string(), - style, - colored: true, - }; + let color_key = SpriteKey::single(c, style, 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 + // For box-drawing and multi-cell symbols (PUA/dingbats), use rasterize_char + // which has full font fallback and color font support. + // Regular text uses HarfBuzz shaping for proper glyph selection. + let glyph = if is_box_drawing(c) || Self::is_multicell_symbol(c) { + // These don't need style variants or use rasterize_char for scaling/color 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 char_str = c.to_string(); let shaped = self.shape_text_with_style(&char_str, style); if shaped.glyphs.is_empty() { @@ -3600,22 +1852,18 @@ impl Renderer { } sprite_info[sprite_idx as usize] = sprite; - // Mark as colored if it's an emoji - let final_idx = if is_emoji || glyph.is_colored { + // Mark as colored if glyph is colored (emoji rendered via color font) + let final_idx = if 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, - }; + let cache_key = SpriteKey::single(c, style, glyph.is_colored); sprite_map.insert(cache_key, final_idx); - (final_idx, is_emoji || glyph.is_colored) + (final_idx, glyph.is_colored) } /// Get or create a sprite index for a character in the terminal sprite buffer. @@ -3637,7 +1885,7 @@ impl Renderer { row: &[crate::terminal::Cell], gpu_row: &mut [GPUCell], cols: usize, - sprite_map: &HashMap, + sprite_map: &FxHashMap, ) { let mut col = 0; while col < cols.min(row.len()) { @@ -3662,7 +1910,7 @@ impl Renderer { // 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) { + if c != ' ' && c != '\0' && Self::is_multicell_symbol(c) && !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; @@ -3680,25 +1928,18 @@ impl Renderer { 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, - }; + // Try to find multi-cell sprites - check non-colored first (more common), then colored + let first_key_normal = SpriteKey::multi(c, 0, style, 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) { + let (first_sprite, is_colored) = if let Some(&sprite) = sprite_map.get(&first_key_normal) { (Some(sprite), false) } else { - (None, false) + let first_key_colored = SpriteKey::multi(c, 0, style, true); + if let Some(&sprite) = sprite_map.get(&first_key_colored) { + (Some(sprite), true) + } else { + (None, false) + } }; if let Some(first_sprite) = first_sprite { @@ -3711,11 +1952,7 @@ impl Renderer { let sprite_idx = if cell_idx == 0 { first_sprite } else { - let key = SpriteKey { - text: format!("{}_{}", c, cell_idx), - style, - colored: is_colored, - }; + let key = SpriteKey::multi(c, cell_idx as u8, style, is_colored); sprite_map.get(&key).copied().unwrap_or(0) }; @@ -3751,7 +1988,8 @@ impl Renderer { 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; + let next_cell = &row[col + num_empty + 1]; + let next_char = next_cell.character; if next_char == ' ' || next_char == '\u{2002}' || next_char == '\0' { num_empty += 1; } else { @@ -3761,11 +1999,7 @@ impl Renderer { 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, - }; + let first_key = SpriteKey::multi(c, 0, style, true); if let Some(&first_sprite) = sprite_map.get(&first_key) { let total_cells = 1 + num_empty; @@ -3778,11 +2012,7 @@ impl Renderer { let sprite_idx = if cell_idx == 0 { first_sprite } else { - let key = SpriteKey { - text: format!("{}_{}", c, cell_idx), - style, - colored: true, - }; + let key = SpriteKey::multi(c, cell_idx as u8, style, true); sprite_map.get(&key).copied().unwrap_or(0) }; @@ -3807,19 +2037,11 @@ impl Renderer { 0 } else { // Check cache - first try non-colored, then colored - let key = SpriteKey { - text: c.to_string(), - style, - colored: false, - }; + let key = SpriteKey::single(c, style, false); if let Some(&idx) = sprite_map.get(&key) { idx } else { - let color_key = SpriteKey { - text: c.to_string(), - style, - colored: true, - }; + let color_key = SpriteKey::single(c, style, true); sprite_map.get(&color_key).copied().unwrap_or(0) } }; @@ -3934,13 +2156,35 @@ impl Renderer { self.cells_dirty = true; } - // Get visible rows (accounts for scroll offset) - let visible_rows = terminal.visible_rows(); + // Get dirty lines bitmap BEFORE first pass - we only need to create sprites for dirty lines + // This is a key optimization: sprites are cached, so we only need to check lines that changed + let dirty_bitmap = terminal.get_dirty_lines(); // 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() { + // OPTIMIZATION: Only process dirty lines or when full rebuild is needed + // OPTIMIZATION: Use get_visible_row() to avoid Vec allocation + for row_idx in 0..rows { + // Skip clean lines (unless size changed, which sets cells_dirty) + if !self.cells_dirty { + if row_idx < 64 { + let bit = 1u64 << row_idx; + if (dirty_bitmap & bit) == 0 { + continue; + } + } + // For rows >= 64, we conservatively process them if any dirty bit is set + // (same as the second pass behavior) + else if dirty_bitmap == 0 { + continue; + } + } + + let Some(row) = terminal.get_visible_row(row_idx) else { + continue; + }; + let mut col = 0; while col < row.len() { let cell = &row[col]; @@ -3956,14 +2200,11 @@ impl Renderer { // 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) { + if Self::is_multicell_symbol(c) && !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_metrics.cell_width as f32).ceil() as usize; - log::debug!("Symbol check U+{:04X}: glyph_width={:.1}, cell_width={}, desired_cells={}", - c as u32, glyph_width, self.cell_metrics.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 @@ -3993,11 +2234,7 @@ impl Renderer { // 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, - }; + let first_key = SpriteKey::multi(c, 0, style, false); if self.sprite_map.get(&first_key).is_none() { // Need to rasterize @@ -4006,11 +2243,7 @@ impl Renderer { // 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, - }; + let key = SpriteKey::multi(c, cell_idx as u8, style, false); // Create sprite info from glyph info let sprite = SpriteInfo { @@ -4042,7 +2275,6 @@ impl Renderer { } // 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 @@ -4052,7 +2284,8 @@ impl Renderer { 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; + let next_cell = &row[col + num_empty + 1]; + let next_char = next_cell.character; if next_char == ' ' || next_char == '\u{2002}' || next_char == '\0' { num_empty += 1; } else { @@ -4064,11 +2297,7 @@ impl Renderer { 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, - }; + let first_key = SpriteKey::multi(c, 0, style, true); if self.sprite_map.get(&first_key).is_none() { // Need to create multi-cell emoji sprites @@ -4076,11 +2305,7 @@ impl Renderer { 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 key = SpriteKey::multi(c, cell_idx as u8, style, true); let sprite = SpriteInfo { uv: glyph.uv, @@ -4112,22 +2337,19 @@ impl Renderer { } } - // 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(); + // Second pass: convert cells to GPU format + // OPTIMIZATION: Use get_visible_row() to avoid Vec allocation + // dirty_bitmap already fetched above before first pass 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; + for row_idx in 0..rows { + if let Some(row) = terminal.get_visible_row(row_idx) { + 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); } - 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; @@ -4136,10 +2358,10 @@ impl Renderer { for row_idx in 0..rows.min(64) { let bit = 1u64 << row_idx; if (dirty_bitmap & bit) != 0 { - if row_idx < visible_rows.len() { + if let Some(row) = terminal.get_visible_row(row_idx) { 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); + Self::cells_to_gpu_row_static(row, &mut self.gpu_cells[start..end], cols, &self.sprite_map); any_updated = true; } } @@ -4147,11 +2369,13 @@ impl Renderer { // 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; + for row_idx in 64..rows { + if let Some(row) = terminal.get_visible_row(row_idx) { + 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); + any_updated = true; + } } } } @@ -4488,7 +2712,7 @@ impl Renderer { 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) + && !is_box_drawing(c) && char_idx + 1 < chars.len() && chars[char_idx + 1] == ' '; @@ -4497,11 +2721,7 @@ impl Renderer { 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, - }; + let first_key = SpriteKey::multi(c, 0, multi_style, false); if self.statusline_sprite_map.get(&first_key).is_none() { // Need to rasterize multi-cell sprites @@ -4509,11 +2729,7 @@ impl Renderer { 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 key = SpriteKey::multi(c, cell_i as u8, multi_style, false); let sprite = SpriteInfo { uv: glyph.uv, @@ -4543,11 +2759,7 @@ impl Renderer { break; } - let key = SpriteKey { - text: format!("{}_{}", c, cell_i), - style: multi_style, - colored: false, - }; + let key = SpriteKey::multi(c, cell_i as u8, multi_style, false); let sprite_idx = self.statusline_sprite_map.get(&key).copied().unwrap_or(0); @@ -4572,9 +2784,6 @@ impl Renderer { 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 { @@ -4629,35 +2838,6 @@ impl Renderer { 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: @@ -4693,6 +2873,7 @@ impl Renderer { false } + /// Get the rendered width of a glyph in pixels. /// 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 @@ -4704,10 +2885,7 @@ impl Renderer { 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; @@ -4715,7 +2893,6 @@ impl Renderer { return width; } } - // Fall back to h_advance if no outline return scaled.h_advance(glyph_id); } @@ -4724,9 +2901,7 @@ impl Renderer { 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; @@ -4734,7 +2909,6 @@ impl Renderer { return width; } } - // Fall back to h_advance if no outline return scaled.h_advance(fb_glyph_id); } } @@ -4743,1193 +2917,6 @@ impl Renderer { self.cell_metrics.cell_width as f32 } - /// 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, bool)> { - let w = self.cell_metrics.cell_width as usize; - let h = self.cell_metrics.cell_height 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 { @@ -5946,8 +2933,14 @@ impl Renderer { // 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) { + if is_box_drawing(c) { + if let Some((bitmap, _supersampled)) = render_box_char( + c, + self.cell_metrics.cell_width as usize, + self.cell_metrics.cell_height as usize, + self.font_size, + self.dpi, + ) { // 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); @@ -5998,55 +2991,8 @@ impl Renderer { }); 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, 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_metrics.cell_width, self.cell_metrics.cell_height); - - renderer.render_color_glyph( - &path, c, self.font_size, self.cell_metrics.cell_width, self.cell_metrics.cell_height - ) - } 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 + if let Some(path) = find_font_for_char(fc, c) { + // Load the font and rasterize with 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()); @@ -6142,26 +3088,14 @@ impl Renderer { // 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], - layer: 0.0, - size: [0.0, 0.0], - is_colored: false, - }; - self.char_cache.insert(c, info); - return info; + self.char_cache.insert(c, GlyphInfo::EMPTY); + return GlyphInfo::EMPTY; }; 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], - layer: 0.0, - size: [0.0, 0.0], - is_colored: false, - }; - self.char_cache.insert(c, info); - return info; + self.char_cache.insert(c, GlyphInfo::EMPTY); + return GlyphInfo::EMPTY; } // Check if this is an oversized symbol glyph that needs rescaling. @@ -6265,21 +3199,11 @@ impl Renderer { 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], - layer: 0.0, - size: [0.0, 0.0], - is_colored: false, - }; num_cells]; + return vec![GlyphInfo::EMPTY; num_cells]; }; if bitmap.is_empty() || glyph_width == 0 || glyph_height == 0 { - return vec![GlyphInfo { - uv: [0.0, 0.0, 0.0, 0.0], - layer: 0.0, - size: [0.0, 0.0], - is_colored: false, - }; num_cells]; + return vec![GlyphInfo::EMPTY; num_cells]; } // Create a multi-cell canvas @@ -6671,19 +3595,20 @@ impl Renderer { // Upload immediately to GPU - like Kitty's glTexSubImage3D call // This uploads only the cell-sized region, not the full 8192x8192 layer + // With Vec, we select the texture by layer index and always use z=0 self.queue.write_texture( - wgpu::ImageCopyTexture { - texture: &self.atlas_texture, + wgpu::TexelCopyTextureInfo { + texture: &self.atlas_textures[layer as usize], mip_level: 0, origin: wgpu::Origin3d { x: self.atlas_cursor_x, y: self.atlas_cursor_y, - z: layer, + z: 0, // Always 0 - layer is selected by texture index }, aspect: wgpu::TextureAspect::All, }, &sprite_data, - wgpu::ImageDataLayout { + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(cell_w * ATLAS_BPP), rows_per_image: Some(cell_h), @@ -6715,100 +3640,88 @@ impl Renderer { } /// Add a new layer to the atlas (like Kitty's realloc_sprite_texture). - /// This reallocates the GPU texture with an additional layer and uses GPU-to-GPU - /// copy (like Kitty's glCopyImageSubData) to preserve existing sprite data. + /// This switches to the next layer, creating the real texture if needed. fn add_atlas_layer(&mut self) { let new_layer = self.atlas_current_layer + 1; if new_layer >= MAX_ATLAS_LAYERS { log::error!("Atlas layer limit reached ({} layers), cannot add more", MAX_ATLAS_LAYERS); - // As a last resort, we could reset here, but this should never happen - // with 64 layers of 8192x8192 return; } - log::info!("Adding atlas layer {} (growing from {} layers)", new_layer, self.atlas_num_layers); + log::info!("Adding atlas layer {} (was on layer {})", new_layer, self.atlas_current_layer); + // Create real texture for the new layer (replacing the dummy) + self.ensure_atlas_layer_capacity(new_layer); + + // Now switch to the new layer self.atlas_current_layer = new_layer; - - // If we need more layers than currently allocated, reallocate the texture - if new_layer >= self.atlas_num_layers { - let new_num_layers = new_layer + 1; - - // Create new texture with more layers - // Must include COPY_SRC to allow GPU-to-GPU copy from old texture - let new_texture = self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("Glyph Atlas Array"), - size: wgpu::Extent3d { - width: ATLAS_SIZE, - height: ATLAS_SIZE, - depth_or_array_layers: new_num_layers, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_DST - | wgpu::TextureUsages::COPY_SRC, - view_formats: &[], - }); - - // GPU-to-GPU copy existing layers from old texture to new texture - // Like Kitty's glCopyImageSubData - fast GPU-side copy without CPU involvement - let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("Atlas Layer Copy Encoder"), - }); - - encoder.copy_texture_to_texture( - wgpu::ImageCopyTexture { - texture: &self.atlas_texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::ImageCopyTexture { - texture: &new_texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - wgpu::Extent3d { - width: ATLAS_SIZE, - height: ATLAS_SIZE, - depth_or_array_layers: self.atlas_num_layers, - }, - ); - - self.queue.submit(std::iter::once(encoder.finish())); - - // Create new view - let new_view = new_texture.create_view(&wgpu::TextureViewDescriptor { - dimension: Some(wgpu::TextureViewDimension::D2Array), - ..Default::default() - }); - - // Update bind group with new texture view - let new_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Glyph Bind Group"), - layout: &self.glyph_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(&new_view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(&self.atlas_sampler), - }, - ], - }); - - self.atlas_texture = new_texture; - self.atlas_view = new_view; - self.glyph_bind_group = new_bind_group; - self.atlas_num_layers = new_num_layers; + } + + /// Ensure the atlas has a real texture at the given layer index. + /// With our Vec approach, this just replaces the dummy texture at that index + /// with a real one. No copying of existing data is needed - O(1) operation. + /// + /// We track which layers are "real" vs "dummy" by checking atlas_current_layer. + /// Layers 0..=atlas_current_layer are real, layers above are dummies. + fn ensure_atlas_layer_capacity(&mut self, target_layer: u32) { + // Layer 0 is always real (created at init), and all layers up to + // atlas_current_layer are real. Only create if target is beyond current. + if target_layer <= self.atlas_current_layer { + return; } + + if target_layer >= MAX_ATLAS_LAYERS { + log::error!("Atlas layer limit reached: {} >= {}", target_layer, MAX_ATLAS_LAYERS); + return; + } + + log::info!("Adding atlas layer {} (replacing dummy texture)", target_layer); + + // Create new real texture (8192x8192) + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("Glyph Atlas Layer"), + 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 view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + // Replace dummy texture at this index with real texture + self.atlas_textures[target_layer as usize] = texture; + self.atlas_views[target_layer as usize] = view; + + // Recreate bind group with updated views (cheap - just metadata) + self.glyph_bind_group = self.create_atlas_bind_group(); + } + + /// Create the glyph bind group with all atlas texture views. + /// Called during initialization and when adding new atlas layers. + fn create_atlas_bind_group(&self) -> wgpu::BindGroup { + let view_refs: Vec<&wgpu::TextureView> = self.atlas_views.iter().collect(); + + self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Glyph Bind Group"), + layout: &self.glyph_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureViewArray(&view_refs), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.atlas_sampler), + }, + ], + }) } /// Create pre-rendered cursor sprites in the atlas (like Kitty's send_prerendered_sprites). @@ -6845,12 +3758,7 @@ impl Renderer { } } let beam_info = self.upload_cell_canvas_to_atlas(&canvas, false); - let beam_sprite = SpriteInfo { - uv: beam_info.uv, - layer: beam_info.layer, - _padding: 0.0, - size: beam_info.size, - }; + let beam_sprite = SpriteInfo::from(beam_info); // === Underline cursor (horizontal bar at bottom) === // Like Kitty's add_underline_cursor / horz() function @@ -6862,12 +3770,7 @@ impl Renderer { } } let underline_info = self.upload_cell_canvas_to_atlas(&canvas, false); - let underline_sprite = SpriteInfo { - uv: underline_info.uv, - layer: underline_info.layer, - _padding: 0.0, - size: underline_info.size, - }; + let underline_sprite = SpriteInfo::from(underline_info); // === Hollow cursor (rectangle outline) === // Like Kitty's add_hollow_cursor function @@ -6897,12 +3800,7 @@ impl Renderer { } } let hollow_info = self.upload_cell_canvas_to_atlas(&canvas, false); - let hollow_sprite = SpriteInfo { - uv: hollow_info.uv, - layer: hollow_info.layer, - _padding: 0.0, - size: hollow_info.size, - }; + let hollow_sprite = SpriteInfo::from(hollow_info); // Store sprites at their fixed indices // sprite_info[0] = no glyph (already set) @@ -6954,12 +3852,7 @@ impl Renderer { let strike_top = if strike_half > strike_pos { 0 } else { strike_pos - strike_half }; draw_hline(&mut canvas, strike_top, strike_thick); let strike_info = self.upload_cell_canvas_to_atlas(&canvas, false); - let strike_sprite = SpriteInfo { - uv: strike_info.uv, - layer: strike_info.layer, - _padding: 0.0, - size: strike_info.size, - }; + let strike_sprite = SpriteInfo::from(strike_info); // === Single Underline (like Kitty's add_straight_underline) === canvas.fill(0); @@ -6967,12 +3860,7 @@ impl Renderer { let under_top = if under_half > underline_pos { 0 } else { underline_pos - under_half }; draw_hline(&mut canvas, under_top, underline_thick); let underline_info = self.upload_cell_canvas_to_atlas(&canvas, false); - let underline_sprite = SpriteInfo { - uv: underline_info.uv, - layer: underline_info.layer, - _padding: 0.0, - size: underline_info.size, - }; + let underline_sprite = SpriteInfo::from(underline_info); // === Double Underline (like Kitty's add_double_underline) === canvas.fill(0); @@ -6996,12 +3884,7 @@ impl Renderer { for x in 0..cell_w { canvas[bottom * cell_w + x] = 255; } } let double_info = self.upload_cell_canvas_to_atlas(&canvas, false); - let double_sprite = SpriteInfo { - uv: double_info.uv, - layer: double_info.layer, - _padding: 0.0, - size: double_info.size, - }; + let double_sprite = SpriteInfo::from(double_info); // === Undercurl (like Kitty's add_curl_underline with Wu antialiasing) === // This follows Kitty's decorations.c add_curl_underline() exactly @@ -7074,12 +3957,7 @@ impl Renderer { } } let curl_info = self.upload_cell_canvas_to_atlas(&canvas, false); - let curl_sprite = SpriteInfo { - uv: curl_info.uv, - layer: curl_info.layer, - _padding: 0.0, - size: curl_info.size, - }; + let curl_sprite = SpriteInfo::from(curl_info); // === Dotted Underline (like Kitty's add_dotted_underline) === canvas.fill(0); @@ -7099,12 +3977,7 @@ impl Renderer { } } let dotted_info = self.upload_cell_canvas_to_atlas(&canvas, false); - let dotted_sprite = SpriteInfo { - uv: dotted_info.uv, - layer: dotted_info.layer, - _padding: 0.0, - size: dotted_info.size, - }; + let dotted_sprite = SpriteInfo::from(dotted_info); // === Dashed Underline (like Kitty's add_dashed_underline) === canvas.fill(0); @@ -7125,12 +3998,7 @@ impl Renderer { } } let dashed_info = self.upload_cell_canvas_to_atlas(&canvas, false); - let dashed_sprite = SpriteInfo { - uv: dashed_info.uv, - layer: dashed_info.layer, - _padding: 0.0, - size: dashed_info.size, - }; + let dashed_sprite = SpriteInfo::from(dashed_info); // Store sprites at their fixed indices // Ensure sprite_info has enough capacity @@ -7153,52 +4021,11 @@ impl Renderer { /// 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. + /// Delegates to get_glyph_by_id_with_style with Regular style. #[allow(dead_code)] + #[inline] 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], - layer: 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], - layer: 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 + self.get_glyph_by_id_with_style(glyph_id, FontStyle::Regular) } /// Get or rasterize a glyph by its glyph ID from a specific font variant. @@ -7215,7 +4042,7 @@ impl Renderer { 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() + variant.clone_font() } else { // Fall back to regular font if variant not available self.primary_font.clone() @@ -7227,26 +4054,14 @@ impl Renderer { 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], - layer: 0.0, - size: [0.0, 0.0], - is_colored: false, - }; - self.glyph_cache.insert(cache_key, info); - return info; + self.glyph_cache.insert(cache_key, GlyphInfo::EMPTY); + return GlyphInfo::EMPTY; }; 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], - layer: 0.0, - size: [0.0, 0.0], - is_colored: false, - }; - self.glyph_cache.insert(cache_key, info); - return info; + self.glyph_cache.insert(cache_key, GlyphInfo::EMPTY); + return GlyphInfo::EMPTY; } // Place the glyph in a cell-sized canvas at the correct baseline position @@ -7261,45 +4076,11 @@ impl Renderer { /// 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. + /// Delegates to shape_text_with_style with Regular style. #[allow(dead_code)] + #[inline] fn shape_text(&mut self, text: &str) -> ShapedGlyphs { - // Check cache first - if let Some(cached) = self.ligature_cache.get(text) { - return cached.clone(); - } - - let _chars: Vec = text.chars().collect(); - - let 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 + self.shape_text_with_style(text, FontStyle::Regular) } /// Shape a text string using HarfBuzz/rustybuzz with a specific font style. @@ -7319,7 +4100,7 @@ impl Renderer { let face = if style == FontStyle::Regular { &self.shaping_ctx.face } else if let Some(ref variant) = self.font_variants[style as usize] { - &variant.face + variant.face() } else { // Fall back to regular font &self.shaping_ctx.face @@ -7407,10 +4188,8 @@ impl Renderer { /// 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); + // Use pre-computed linear palette + let [color_r, color_g, color_b, _] = self.linear_palette.color_table[4]; let mut glow_instances = [GlowInstance { direction: 0, @@ -7483,9 +4262,13 @@ impl Renderer { edge_glow_intensity: f32, statusline_content: &StatuslineContent, ) -> Result<(), wgpu::SurfaceError> { - // Sync palette from first terminal + #[cfg(feature = "render_timing")] + let frame_start = std::time::Instant::now(); + + // Sync palette from first terminal (update both sRGB and linear versions) if let Some((terminal, _, _)) = panes.first() { self.palette = terminal.palette.clone(); + self.linear_palette = LinearPalette::from_palette(&self.palette); log::debug!("render_panes: synced palette from first terminal, default_bg={:?}, default_fg={:?}", self.palette.default_bg, self.palette.default_fg); } else { @@ -7528,13 +4311,9 @@ impl Renderer { }; // Use same color as statusline: 0x1a1a1a (26, 26, 26) in sRGB - // Linear RGB value: ~0.00972 - let tab_bar_bg = [ - Self::srgb_to_linear(26.0 / 255.0), - Self::srgb_to_linear(26.0 / 255.0), - Self::srgb_to_linear(26.0 / 255.0), - 1.0, - ]; + // Pre-computed linear RGB value for srgb_to_linear(26/255) ≈ 0.00972 + const TAB_BAR_BG_LINEAR: f32 = 0.00972; + let tab_bar_bg = [TAB_BAR_BG_LINEAR, TAB_BAR_BG_LINEAR, TAB_BAR_BG_LINEAR, 1.0]; // Draw tab bar background log::debug!("render_panes: drawing tab bar at y={}, height={}, num_tabs={}, quads_before={}", @@ -7576,14 +4355,9 @@ impl Renderer { }; let tab_fg = { - let [r, g, b] = self.palette.default_fg; + let [r, g, b, _] = self.linear_palette.color_table[256]; // 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, - ] + [r, g, b, alpha] }; // Draw tab background @@ -7649,16 +4423,8 @@ impl Renderer { // 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, - ] - }; + // Use pre-computed linear palette for active border (palette color 4 - typically blue) + let active_border_color = self.linear_palette.color_table[4]; let inactive_border_color = { // Use a dimmer color for inactive panes let [r, g, b] = self.palette.default_bg; @@ -7810,6 +4576,8 @@ impl Renderer { } let mut pane_render_list: Vec = Vec::new(); + #[cfg(feature = "render_timing")] + let pane_loop_start = std::time::Instant::now(); // 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 @@ -7822,7 +4590,16 @@ impl Renderer { info.pane_id, pane_x, pane_y, pane_width, pane_height, pane_y + pane_height); // Update GPU cells for this terminal (populates self.gpu_cells) + #[cfg(feature = "render_timing")] + let t0 = std::time::Instant::now(); self.update_gpu_cells(terminal); + #[cfg(feature = "render_timing")] + { + let update_time = t0.elapsed(); + if update_time.as_micros() > 500 { + log::info!("update_gpu_cells took {:?}", update_time); + } + } // Calculate pane dimensions in cells let cols = (pane_width / self.cell_metrics.cell_width as f32).floor() as u32; @@ -7918,41 +4695,22 @@ impl Renderer { dim_overlay, }); } + #[cfg(feature = "render_timing")] + { + let pane_loop_time = pane_loop_start.elapsed(); + if pane_loop_time.as_micros() > 500 { + log::info!("pane_loop took {:?}", pane_loop_time); + } + } // Clean up resources for panes that no longer exist (like Kitty's remove_vao) let active_pane_ids: std::collections::HashSet = pane_render_list.iter().map(|p| p.pane_id).collect(); self.cleanup_unused_pane_resources(&active_pane_ids); // ═══════════════════════════════════════════════════════════════════ - // UPLOAD SHARED DATA (color table) + // UPLOAD SHARED DATA (color table - uses pre-computed linear palette) // ═══════════════════════════════════════════════════════════════════ - { - 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)); - } + self.queue.write_buffer(&self.color_table_buffer, 0, bytemuck::cast_slice(&self.linear_palette.color_table)); // ═══════════════════════════════════════════════════════════════════ // PREPARE STATUSLINE FOR RENDERING (dedicated shader) @@ -8098,7 +4856,7 @@ impl Renderer { 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( + let renders = self.image_renderer.prepare_image_renders( terminal.image_storage.placements(), pane_x, pane_y, @@ -8220,10 +4978,12 @@ impl Renderer { }), store: wgpu::StoreOp::Store, }, + depth_slice: None, })], depth_stencil_attachment: None, occlusion_query_set: None, timestamp_writes: None, + multiview_mask: None, }); render_pass.set_pipeline(&self.glyph_pipeline); @@ -8332,7 +5092,7 @@ impl Renderer { // ═══════════════════════════════════════════════════════════════════ 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) { + if let Some(gpu_image) = self.image_renderer.get(image_id) { // Upload uniforms to this image's dedicated uniform buffer self.queue.write_buffer( &gpu_image.uniform_buffer, @@ -8350,10 +5110,12 @@ impl Renderer { load: wgpu::LoadOp::Load, // Preserve existing content store: wgpu::StoreOp::Store, }, + depth_slice: None, })], depth_stencil_attachment: None, occlusion_query_set: None, timestamp_writes: None, + multiview_mask: None, }); image_pass.set_pipeline(&self.image_pipeline); @@ -8384,10 +5146,12 @@ impl Renderer { load: wgpu::LoadOp::Load, // Preserve existing content store: wgpu::StoreOp::Store, }, + depth_slice: None, })], depth_stencil_attachment: None, occlusion_query_set: None, timestamp_writes: None, + multiview_mask: None, }); glow_pass.set_pipeline(&self.edge_glow_pipeline); @@ -8395,258 +5159,34 @@ impl Renderer { glow_pass.draw(0..3, 0..1); // Fullscreen triangle } + #[cfg(feature = "render_timing")] + let before_submit = frame_start.elapsed(); self.queue.submit(std::iter::once(encoder.finish())); + #[cfg(feature = "render_timing")] + let after_submit = frame_start.elapsed(); output.present(); + + // Log timing if frame took more than 1ms (only with render_timing feature) + #[cfg(feature = "render_timing")] + { + let after_present = frame_start.elapsed(); + if after_present.as_micros() > 1000 { + log::info!("render_panes: before_submit={:?} submit={:?} present={:?} total={:?}", + before_submit, + after_submit - before_submit, + after_present - after_submit, + after_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::() as u64, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - - // Create bind group for this image with its own uniform buffer - let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some(&format!("Image {} Bind Group", image.id)), - layout: &self.image_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: uniform_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView(&view), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: wgpu::BindingResource::Sampler(&self.image_sampler), - }, - ], - }); - - self.image_textures.insert(image.id, GpuImage { - texture, - view, - uniform_buffer, - bind_group, - width: image.width, - height: image.height, - }); - - log::debug!( - "Uploaded image {} ({}x{}) to GPU", - image.id, - image.width, - image.height - ); - } - - /// Remove an image from the GPU. - pub fn remove_image(&mut self, image_id: u32) { - if self.image_textures.remove(&image_id).is_some() { - log::debug!("Removed image {} from GPU", image_id); - } - } - /// Sync images from terminal's image storage to GPU. /// Uploads new/changed images and removes deleted ones. /// Also updates animation frames. pub fn sync_images(&mut self, storage: &mut ImageStorage) { - // Update animations and get list of changed image IDs - let changed_ids = storage.update_animations(); - - // Re-upload frames that changed due to animation - for id in &changed_ids { - if let Some(image) = storage.get_image(*id) { - self.upload_image(image); - } - } - - if !storage.dirty && changed_ids.is_empty() { - return; - } - - // Upload all images (upload_image handles deduplication) - for image in storage.images().values() { - self.upload_image(image); - } - - // Remove textures for deleted images - let current_ids: std::collections::HashSet = storage.images().keys().copied().collect(); - let gpu_ids: Vec = self.image_textures.keys().copied().collect(); - for id in gpu_ids { - if !current_ids.contains(&id) { - self.remove_image(id); - } - } - - storage.clear_dirty(); - } - - /// Render images for a pane. Called from render_pane_content. - /// Returns a Vec of (image_id, uniforms) for deferred rendering. - fn prepare_image_renders( - &self, - placements: &[ImagePlacement], - pane_x: f32, - pane_y: f32, - cell_width: f32, - cell_height: f32, - screen_width: f32, - screen_height: f32, - scrollback_len: usize, - scroll_offset: usize, - visible_rows: usize, - ) -> Vec<(u32, ImageUniforms)> { - let mut renders = Vec::new(); - - for placement in placements { - // Check if we have the GPU texture for this image - let gpu_image = match self.image_textures.get(&placement.image_id) { - Some(img) => img, - None => continue, // Skip if not uploaded yet - }; - - // Convert absolute row to visible screen row - // placement.row is absolute (scrollback_len_at_placement + cursor_row) - // visible_row = absolute_row - scrollback_len + scroll_offset - let absolute_row = placement.row as isize; - let visible_row = absolute_row - scrollback_len as isize + scroll_offset as isize; - - // Check if image is visible on screen - // Image spans from visible_row to visible_row + placement.rows - let image_bottom = visible_row + placement.rows as isize; - if image_bottom < 0 || visible_row >= visible_rows as isize { - continue; // Image is completely off-screen - } - - // Calculate display position in pixels - let pos_x = pane_x + (placement.col as f32 * cell_width) + placement.x_offset as f32; - let pos_y = pane_y + (visible_row as f32 * cell_height) + placement.y_offset as f32; - - log::debug!( - "Image render: pane_x={} col={} cell_width={} x_offset={} => pos_x={}", - pane_x, placement.col, cell_width, placement.x_offset, pos_x - ); - - // Calculate display size in pixels - let display_width = placement.cols as f32 * cell_width; - let display_height = placement.rows as f32 * cell_height; - - // Calculate source rectangle in normalized coordinates - let src_x = placement.src_x as f32 / gpu_image.width as f32; - let src_y = placement.src_y as f32 / gpu_image.height as f32; - let src_width = if placement.src_width == 0 { - 1.0 - src_x - } else { - placement.src_width as f32 / gpu_image.width as f32 - }; - let src_height = if placement.src_height == 0 { - 1.0 - src_y - } else { - placement.src_height as f32 / gpu_image.height as f32 - }; - - let uniforms = ImageUniforms { - screen_width, - screen_height, - pos_x, - pos_y, - display_width, - display_height, - src_x, - src_y, - src_width, - src_height, - _padding1: 0.0, - _padding2: 0.0, - }; - - renders.push((placement.image_id, uniforms)); - } - - renders + self.image_renderer.sync_images(&self.device, &self.queue, storage); } } diff --git a/src/simd_utf8.rs b/src/simd_utf8.rs new file mode 100644 index 0000000..38c01c9 --- /dev/null +++ b/src/simd_utf8.rs @@ -0,0 +1,1476 @@ +//! SIMD-optimized string operations based on Kitty's implementation. +//! +//! This module provides high-performance SIMD-accelerated operations: +//! - UTF-8 decoder (16-byte SSE or 32-byte AVX2 chunks) +//! - Byte search functions (find_either_of_two_bytes, find_c0_control) +//! - XOR masking for WebSocket frames +//! +//! The UTF-8 algorithm is based on the blog post: +//! https://woboq.com/blog/utf-8-processing-using-simd.html +//! and Kitty's implementation in simd-string-impl.h + +// Allow unsafe operations within unsafe functions without additional blocks. +// This code is ported from C and follows the same patterns as Kitty's implementation. +#![allow(unsafe_op_in_unsafe_fn)] + +#[cfg(target_arch = "x86_64")] +use std::arch::x86_64::*; + +#[cfg(target_arch = "x86")] +use std::arch::x86::*; + +// ============================================================================ +// SIMD Feature Detection and Dispatch +// ============================================================================ + +/// Cached SIMD capability flags for runtime dispatch. +#[derive(Clone, Copy)] +pub struct SimdCapabilities { + pub has_sse41: bool, + pub has_ssse3: bool, + pub has_avx2: bool, +} + +impl SimdCapabilities { + /// Detect SIMD capabilities at runtime. + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + pub fn detect() -> Self { + Self { + has_sse41: is_x86_feature_detected!("sse4.1"), + has_ssse3: is_x86_feature_detected!("ssse3"), + has_avx2: is_x86_feature_detected!("avx2"), + } + } + + #[cfg(not(any(target_arch = "x86_64", target_arch = "x86")))] + pub fn detect() -> Self { + Self { + has_sse41: false, + has_ssse3: false, + has_avx2: false, + } + } +} + +// Global cached capabilities (initialized on first use) +static SIMD_CAPS: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Get cached SIMD capabilities. +pub fn simd_caps() -> &'static SimdCapabilities { + SIMD_CAPS.get_or_init(SimdCapabilities::detect) +} + +// ============================================================================ +// Byte Search Functions (like Kitty's find_either_of_two_bytes) +// ============================================================================ + +/// Find the first occurrence of a single byte in the haystack. +/// Returns the index of the first match, or None if not found. +/// +/// This is a SIMD-accelerated alternative to `memchr::memchr`. +#[inline] +pub fn find_byte(haystack: &[u8], needle: u8) -> Option { + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + { + let caps = simd_caps(); + if caps.has_avx2 { + // SAFETY: We checked for AVX2 support + return unsafe { find_byte_avx2(haystack, needle) }; + } + if caps.has_sse41 { + // SAFETY: We checked for SSE4.1 support + return unsafe { find_byte_sse(haystack, needle) }; + } + } + haystack.iter().position(|&b| b == needle) +} + +/// SSE implementation of find_byte. +#[cfg(any(target_arch = "x86_64", target_arch = "x86"))] +#[target_feature(enable = "sse2", enable = "sse4.1")] +unsafe fn find_byte_sse(haystack: &[u8], needle: u8) -> Option { + let needle_vec = _mm_set1_epi8(needle as i8); + let mut offset = 0; + let len = haystack.len(); + let ptr = haystack.as_ptr(); + + while offset + 16 <= len { + let chunk = _mm_loadu_si128(ptr.add(offset) as *const __m128i); + let cmp = _mm_cmpeq_epi8(chunk, needle_vec); + let mask = _mm_movemask_epi8(cmp) as u32; + if mask != 0 { + return Some(offset + mask.trailing_zeros() as usize); + } + offset += 16; + } + + for i in offset..len { + if *ptr.add(i) == needle { + return Some(i); + } + } + None +} + +/// AVX2 implementation of find_byte. +#[cfg(any(target_arch = "x86_64", target_arch = "x86"))] +#[target_feature(enable = "avx2")] +unsafe fn find_byte_avx2(haystack: &[u8], needle: u8) -> Option { + let needle_vec = _mm256_set1_epi8(needle as i8); + let mut offset = 0; + let len = haystack.len(); + let ptr = haystack.as_ptr(); + + while offset + 32 <= len { + let chunk = _mm256_loadu_si256(ptr.add(offset) as *const __m256i); + let cmp = _mm256_cmpeq_epi8(chunk, needle_vec); + let mask = _mm256_movemask_epi8(cmp) as u32; + if mask != 0 { + return Some(offset + mask.trailing_zeros() as usize); + } + offset += 32; + } + + // Handle remainder with SSE + while offset + 16 <= len { + let chunk = _mm_loadu_si128(ptr.add(offset) as *const __m128i); + let needle_vec_128 = _mm_set1_epi8(needle as i8); + let cmp = _mm_cmpeq_epi8(chunk, needle_vec_128); + let mask = _mm_movemask_epi8(cmp) as u32; + if mask != 0 { + return Some(offset + mask.trailing_zeros() as usize); + } + offset += 16; + } + + for i in offset..len { + if *ptr.add(i) == needle { + return Some(i); + } + } + None +} + +/// Find the first occurrence of either byte `a` or byte `b` in the haystack. +/// Returns the index of the first match, or None if not found. +/// +/// This is equivalent to Kitty's `find_either_of_two_bytes` function. +#[inline] +pub fn find_either_of_two_bytes(haystack: &[u8], a: u8, b: u8) -> Option { + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + { + let caps = simd_caps(); + if caps.has_avx2 { + // SAFETY: We checked for AVX2 support + return unsafe { find_either_of_two_bytes_avx2(haystack, a, b) }; + } + if caps.has_sse41 { + // SAFETY: We checked for SSE4.1 support + return unsafe { find_either_of_two_bytes_sse(haystack, a, b) }; + } + } + find_either_of_two_bytes_scalar(haystack, a, b) +} + +/// Scalar fallback for find_either_of_two_bytes. +#[inline] +fn find_either_of_two_bytes_scalar(haystack: &[u8], a: u8, b: u8) -> Option { + haystack.iter().position(|&byte| byte == a || byte == b) +} + +/// SSE implementation of find_either_of_two_bytes. +#[cfg(any(target_arch = "x86_64", target_arch = "x86"))] +#[target_feature(enable = "sse2", enable = "sse4.1")] +unsafe fn find_either_of_two_bytes_sse(haystack: &[u8], a: u8, b: u8) -> Option { + let a_vec = _mm_set1_epi8(a as i8); + let b_vec = _mm_set1_epi8(b as i8); + + let mut offset = 0; + let len = haystack.len(); + let ptr = haystack.as_ptr(); + + // Process 16 bytes at a time + while offset + 16 <= len { + let chunk = _mm_loadu_si128(ptr.add(offset) as *const __m128i); + let cmp_a = _mm_cmpeq_epi8(chunk, a_vec); + let cmp_b = _mm_cmpeq_epi8(chunk, b_vec); + let combined = _mm_or_si128(cmp_a, cmp_b); + let mask = _mm_movemask_epi8(combined) as u32; + if mask != 0 { + return Some(offset + mask.trailing_zeros() as usize); + } + offset += 16; + } + + // Handle remainder with scalar + for i in offset..len { + let byte = *ptr.add(i); + if byte == a || byte == b { + return Some(i); + } + } + None +} + +/// AVX2 implementation of find_either_of_two_bytes. +#[cfg(any(target_arch = "x86_64", target_arch = "x86"))] +#[target_feature(enable = "avx2")] +unsafe fn find_either_of_two_bytes_avx2(haystack: &[u8], a: u8, b: u8) -> Option { + let a_vec = _mm256_set1_epi8(a as i8); + let b_vec = _mm256_set1_epi8(b as i8); + + let mut offset = 0; + let len = haystack.len(); + let ptr = haystack.as_ptr(); + + // Process 32 bytes at a time + while offset + 32 <= len { + let chunk = _mm256_loadu_si256(ptr.add(offset) as *const __m256i); + let cmp_a = _mm256_cmpeq_epi8(chunk, a_vec); + let cmp_b = _mm256_cmpeq_epi8(chunk, b_vec); + let combined = _mm256_or_si256(cmp_a, cmp_b); + let mask = _mm256_movemask_epi8(combined) as u32; + if mask != 0 { + return Some(offset + mask.trailing_zeros() as usize); + } + offset += 32; + } + + // Handle remainder with SSE (16 bytes) + while offset + 16 <= len { + let chunk = _mm_loadu_si128(ptr.add(offset) as *const __m128i); + let a_vec_128 = _mm_set1_epi8(a as i8); + let b_vec_128 = _mm_set1_epi8(b as i8); + let cmp_a = _mm_cmpeq_epi8(chunk, a_vec_128); + let cmp_b = _mm_cmpeq_epi8(chunk, b_vec_128); + let combined = _mm_or_si128(cmp_a, cmp_b); + let mask = _mm_movemask_epi8(combined) as u32; + if mask != 0 { + return Some(offset + mask.trailing_zeros() as usize); + } + offset += 16; + } + + // Handle remainder with scalar + for i in offset..len { + let byte = *ptr.add(i); + if byte == a || byte == b { + return Some(i); + } + } + None +} + +// ============================================================================ +// C0 Control Character Detection (like Kitty's IndexC0) +// ============================================================================ + +/// Find the first C0 control character (byte < 0x20 or byte == 0x7F). +/// Returns the index of the first match, or None if not found. +/// +/// This is equivalent to Kitty's `IndexC0` function in the simdstring package. +#[inline] +pub fn find_c0_control(haystack: &[u8]) -> Option { + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + { + let caps = simd_caps(); + if caps.has_avx2 { + // SAFETY: We checked for AVX2 support + return unsafe { find_c0_control_avx2(haystack) }; + } + if caps.has_sse41 { + // SAFETY: We checked for SSE4.1 support + return unsafe { find_c0_control_sse(haystack) }; + } + } + find_c0_control_scalar(haystack) +} + +/// Scalar fallback for find_c0_control. +#[inline] +fn find_c0_control_scalar(haystack: &[u8]) -> Option { + haystack.iter().position(|&byte| byte < 0x20 || byte == 0x7F) +} + +/// SSE implementation of find_c0_control. +#[cfg(any(target_arch = "x86_64", target_arch = "x86"))] +#[target_feature(enable = "sse2", enable = "sse4.1")] +unsafe fn find_c0_control_sse(haystack: &[u8]) -> Option { + // C0 control chars are: 0x00-0x1F and 0x7F + // Strategy: (byte < 0x20) || (byte == 0x7F) + // For the < 0x20 check, we use saturating subtraction: if byte < 0x20, then (byte - 0x20) saturates to 0 + // Or we can use: byte + 0x80 (wrapping) < 0x20 + 0x80 = 0xA0 in unsigned = -96 in signed + let threshold = _mm_set1_epi8(-96i8); // 0x20 - 0x80 = -96 in signed + let bias = _mm_set1_epi8(-128i8); // 0x80 as i8 + let del = _mm_set1_epi8(0x7F); + + let mut offset = 0; + let len = haystack.len(); + let ptr = haystack.as_ptr(); + + while offset + 16 <= len { + let chunk = _mm_loadu_si128(ptr.add(offset) as *const __m128i); + // Convert to signed range for comparison: chunk_signed = chunk + 0x80 (wrapping) + // This maps 0x00 -> 0x80 (-128), 0x7F -> 0xFF (-1), 0x80 -> 0x00, etc. + let chunk_signed = _mm_add_epi8(chunk, bias); + // Check chunk_signed < threshold (equivalent to chunk < 0x20) + let lt_20 = _mm_cmplt_epi8(chunk_signed, threshold); + // Check chunk == 0x7F + let eq_7f = _mm_cmpeq_epi8(chunk, del); + // Combine + let combined = _mm_or_si128(lt_20, eq_7f); + let mask = _mm_movemask_epi8(combined) as u32; + if mask != 0 { + return Some(offset + mask.trailing_zeros() as usize); + } + offset += 16; + } + + // Handle remainder + for i in offset..len { + let byte = *ptr.add(i); + if byte < 0x20 || byte == 0x7F { + return Some(i); + } + } + None +} + +/// AVX2 implementation of find_c0_control. +#[cfg(any(target_arch = "x86_64", target_arch = "x86"))] +#[target_feature(enable = "avx2")] +unsafe fn find_c0_control_avx2(haystack: &[u8]) -> Option { + let threshold = _mm256_set1_epi8(-96i8); // 0x20 - 0x80 = -96 in signed + let bias = _mm256_set1_epi8(-128i8); // 0x80 as i8 + let del = _mm256_set1_epi8(0x7F); + + let mut offset = 0; + let len = haystack.len(); + let ptr = haystack.as_ptr(); + + while offset + 32 <= len { + let chunk = _mm256_loadu_si256(ptr.add(offset) as *const __m256i); + let chunk_signed = _mm256_add_epi8(chunk, bias); + let lt_20 = _mm256_cmpgt_epi8(threshold, chunk_signed); + let eq_7f = _mm256_cmpeq_epi8(chunk, del); + let combined = _mm256_or_si256(lt_20, eq_7f); + let mask = _mm256_movemask_epi8(combined) as u32; + if mask != 0 { + return Some(offset + mask.trailing_zeros() as usize); + } + offset += 32; + } + + // Handle remainder with SSE path + while offset + 16 <= len { + let chunk = _mm_loadu_si128(ptr.add(offset) as *const __m128i); + let threshold_128 = _mm_set1_epi8(-96i8); + let bias_128 = _mm_set1_epi8(-128i8); + let del_128 = _mm_set1_epi8(0x7F); + let chunk_signed = _mm_add_epi8(chunk, bias_128); + let lt_20 = _mm_cmplt_epi8(chunk_signed, threshold_128); + let eq_7f = _mm_cmpeq_epi8(chunk, del_128); + let combined = _mm_or_si128(lt_20, eq_7f); + let mask = _mm_movemask_epi8(combined) as u32; + if mask != 0 { + return Some(offset + mask.trailing_zeros() as usize); + } + offset += 16; + } + + // Handle remainder + for i in offset..len { + let byte = *ptr.add(i); + if byte < 0x20 || byte == 0x7F { + return Some(i); + } + } + None +} + +// ============================================================================ +// XOR Data for WebSocket Masking (like Kitty's xor_data64) +// ============================================================================ + +/// XOR data with a 4-byte mask (WebSocket frame masking). +/// The mask is applied cyclically starting from the given offset. +/// Returns the new mask offset after processing. +/// +/// This is equivalent to Kitty's `xor_data64` function but optimized for +/// the standard 4-byte WebSocket mask. +#[inline] +pub fn xor_mask(data: &mut [u8], mask: [u8; 4], start_offset: usize) -> usize { + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + { + let caps = simd_caps(); + if caps.has_avx2 && data.len() >= 32 { + // SAFETY: We checked for AVX2 support + return unsafe { xor_mask_avx2(data, mask, start_offset) }; + } + if caps.has_sse41 && data.len() >= 16 { + // SAFETY: We checked for SSE4.1 support + return unsafe { xor_mask_sse(data, mask, start_offset) }; + } + } + xor_mask_scalar(data, mask, start_offset) +} + +/// Scalar fallback for xor_mask. +#[inline] +fn xor_mask_scalar(data: &mut [u8], mask: [u8; 4], start_offset: usize) -> usize { + let mut offset = start_offset; + for byte in data.iter_mut() { + *byte ^= mask[offset & 3]; + offset += 1; + } + offset & 3 +} + +/// SSE implementation of xor_mask. +#[cfg(any(target_arch = "x86_64", target_arch = "x86"))] +#[target_feature(enable = "sse2")] +unsafe fn xor_mask_sse(data: &mut [u8], mask: [u8; 4], start_offset: usize) -> usize { + let len = data.len(); + let ptr = data.as_mut_ptr(); + let mut pos = 0; + let mut offset = start_offset; + + // Handle unaligned prefix to get to mask-aligned position + while pos < len && (offset & 3) != 0 { + *ptr.add(pos) ^= mask[offset & 3]; + pos += 1; + offset += 1; + } + + // Create 16-byte mask vector (repeat 4-byte mask 4 times) + let mask_vec = _mm_set_epi8( + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + ); + + // Process 16 bytes at a time + while pos + 16 <= len { + let chunk = _mm_loadu_si128(ptr.add(pos) as *const __m128i); + let xored = _mm_xor_si128(chunk, mask_vec); + _mm_storeu_si128(ptr.add(pos) as *mut __m128i, xored); + pos += 16; + offset += 16; + } + + // Handle remainder + while pos < len { + *ptr.add(pos) ^= mask[offset & 3]; + pos += 1; + offset += 1; + } + + offset & 3 +} + +/// AVX2 implementation of xor_mask. +#[cfg(any(target_arch = "x86_64", target_arch = "x86"))] +#[target_feature(enable = "avx2")] +unsafe fn xor_mask_avx2(data: &mut [u8], mask: [u8; 4], start_offset: usize) -> usize { + let len = data.len(); + let ptr = data.as_mut_ptr(); + let mut pos = 0; + let mut offset = start_offset; + + // Handle unaligned prefix + while pos < len && (offset & 3) != 0 { + *ptr.add(pos) ^= mask[offset & 3]; + pos += 1; + offset += 1; + } + + // Create 32-byte mask vector (repeat 4-byte mask 8 times) + let mask_vec = _mm256_set_epi8( + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + ); + + // Process 32 bytes at a time + while pos + 32 <= len { + let chunk = _mm256_loadu_si256(ptr.add(pos) as *const __m256i); + let xored = _mm256_xor_si256(chunk, mask_vec); + _mm256_storeu_si256(ptr.add(pos) as *mut __m256i, xored); + pos += 32; + offset += 32; + } + + // Process 16 bytes if remaining + while pos + 16 <= len { + let mask_vec_128 = _mm_set_epi8( + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + mask[3] as i8, mask[2] as i8, mask[1] as i8, mask[0] as i8, + ); + let chunk = _mm_loadu_si128(ptr.add(pos) as *const __m128i); + let xored = _mm_xor_si128(chunk, mask_vec_128); + _mm_storeu_si128(ptr.add(pos) as *mut __m128i, xored); + pos += 16; + offset += 16; + } + + // Handle remainder + while pos < len { + *ptr.add(pos) ^= mask[offset & 3]; + pos += 1; + offset += 1; + } + + offset & 3 +} + +// ============================================================================ +// UTF-8 Decoder State and Tables +// ============================================================================ + +/// UTF-8 decoder state for handling sequences that span chunks. +#[derive(Debug, Default, Clone)] +pub struct Utf8State { + pub cur: u8, + pub prev: u8, + pub codep: u32, +} + +const UTF8_ACCEPT: u8 = 0; +const UTF8_REJECT: u8 = 12; + +/// UTF-8 state transition table (Bjoern Hoehrmann's DFA). +static UTF8_DECODE_TABLE: [u8; 364] = [ + // Character class lookup (0-255) + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, + 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, + 8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2, + 10,3,3,3,3,3,3,3,3,3,3,3,3,4,3,3, 11,6,6,6,5,8,8,8,8,8,8,8,8,8,8,8, + // State transition table + 0,12,24,36,60,96,84,12,12,12,48,72, 12,12,12,12,12,12,12,12,12,12,12,12, + 12, 0,12,12,12,12,12, 0,12, 0,12,12, 12,24,12,12,12,12,12,24,12,24,12,12, + 12,12,12,12,12,12,12,24,12,12,12,12, 12,24,12,12,12,12,12,12,12,24,12,12, + 12,12,12,12,12,12,12,36,12,36,12,12, 12,36,12,12,12,12,12,36,12,36,12,12, + 12,36,12,12,12,12,12,12,12,12,12,12, +]; + +/// Decode a single UTF-8 byte using DFA. +#[inline(always)] +fn decode_utf8_byte(state: &mut u8, codep: &mut u32, byte: u8) -> u8 { + let char_class = UTF8_DECODE_TABLE[byte as usize]; + *codep = if *state == UTF8_ACCEPT { + (0xFF >> char_class) as u32 & byte as u32 + } else { + (byte as u32 & 0x3F) | (*codep << 6) + }; + *state = UTF8_DECODE_TABLE[256 + *state as usize + char_class as usize]; + *state +} + +/// SIMD UTF-8 decoder. +/// +/// Processes input in 16-byte (SSE) or 32-byte (AVX2) chunks, using SIMD for: +/// - Fast ESC (0x1B) detection +/// - Pure ASCII fast path +/// - Parallel UTF-8 validation and decoding +#[derive(Debug, Default)] +pub struct SimdUtf8Decoder { + pub state: Utf8State, +} + +impl SimdUtf8Decoder { + pub fn new() -> Self { + Self::default() + } + + pub fn reset(&mut self) { + self.state = Utf8State::default(); + } + + /// Decode UTF-8 bytes until ESC is found. + /// Returns (bytes_consumed, found_esc). + /// + /// Output codepoints are written to the output buffer as u32 values. + /// Uses AVX2 (32 bytes at a time) if available, otherwise SSE (16 bytes). + #[inline] + pub fn decode_to_esc(&mut self, src: &[u8], output: &mut Vec) -> (usize, bool) { + output.clear(); + if src.is_empty() { + return (0, false); + } + output.reserve(src.len()); + + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + { + let caps = simd_caps(); + // TODO: AVX2 decoder would go here when implemented + // For now, AVX2 is used for the byte search functions + if caps.has_sse41 && caps.has_ssse3 { + // SAFETY: We checked for required SIMD support + return unsafe { self.decode_to_esc_simd(src, output) }; + } + } + + // Fallback to scalar + self.decode_to_esc_scalar(src, output) + } + + /// Scalar fallback decoder. + fn decode_to_esc_scalar(&mut self, src: &[u8], output: &mut Vec) -> (usize, bool) { + let mut pos = 0; + + while pos < src.len() { + let byte = src[pos]; + + if byte == 0x1B { + if self.state.cur != UTF8_ACCEPT { + output.push(0xFFFD); + self.state = Utf8State::default(); + } + return (pos + 1, true); + } + + pos += 1; + self.state.prev = self.state.cur; + + match decode_utf8_byte(&mut self.state.cur, &mut self.state.codep, byte) { + UTF8_ACCEPT => { + output.push(self.state.codep); + } + UTF8_REJECT => { + output.push(0xFFFD); + let was_accept = self.state.prev == UTF8_ACCEPT; + self.state = Utf8State::default(); + if !was_accept { + pos -= 1; + } + } + _ => {} + } + } + + (pos, false) + } + + /// SIMD decoder - processes 16 bytes at a time. + /// Based on Kitty's simd-string-impl.h + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + #[target_feature(enable = "sse2", enable = "ssse3", enable = "sse4.1")] + unsafe fn decode_to_esc_simd(&mut self, src: &[u8], output: &mut Vec) -> (usize, bool) { + let mut num_consumed: usize = 0; + + // Finish any trailing sequence from previous call + if self.state.cur != UTF8_ACCEPT { + num_consumed = self.scalar_decode_to_accept(src, output); + if num_consumed >= src.len() { + return (num_consumed, false); + } + } + + // SIMD constants + let esc_vec = _mm_set1_epi8(0x1Bu8 as i8); + let zero = _mm_setzero_si128(); + let one = _mm_set1_epi8(1); + let two = _mm_set1_epi8(2); + let three = _mm_set1_epi8(3); + let four = _mm_set1_epi8(4); + let numbered = _mm_set_epi8(15,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0); + + let limit = src.as_ptr().add(src.len()); + let mut p = src.as_ptr().add(num_consumed); + let mut sentinel_found = false; + + while p < limit && !sentinel_found { + let remaining = limit.offset_from(p) as usize; + let mut chunk_src_sz = remaining.min(16); + + // Load chunk (potentially partial) + let mut vec = if chunk_src_sz == 16 { + _mm_loadu_si128(p as *const __m128i) + } else { + // Partial load - zero-extend + let mut buf = [0u8; 16]; + std::ptr::copy_nonoverlapping(p, buf.as_mut_ptr(), chunk_src_sz); + _mm_loadu_si128(buf.as_ptr() as *const __m128i) + }; + + let start_of_current_chunk = p; + p = p.add(chunk_src_sz); + + // Check for ESC + let esc_cmp = _mm_cmpeq_epi8(vec, esc_vec); + let num_bytes_to_first_esc = Self::bytes_to_first_match(esc_cmp); + + if num_bytes_to_first_esc >= 0 && (num_bytes_to_first_esc as usize) < chunk_src_sz { + sentinel_found = true; + chunk_src_sz = num_bytes_to_first_esc as usize; + num_consumed += chunk_src_sz + 1; // +1 for ESC + if chunk_src_sz == 0 { + continue; + } + } else { + num_consumed += chunk_src_sz; + } + + // Zero out bytes past chunk_src_sz + if chunk_src_sz < 16 { + vec = Self::zero_last_n_bytes(vec, 16 - chunk_src_sz); + } + + // Check for trailing incomplete sequence + let mut num_trailing_bytes = 0usize; + let mut check_for_trailing = !sentinel_found; + + 'classification: loop { + // Check if pure ASCII (no high bits set) + let ascii_mask = _mm_movemask_epi8(vec); + if ascii_mask == 0 { + // Pure ASCII - fast output + Self::output_plain_ascii(vec, chunk_src_sz, output); + + // Handle trailing bytes + if num_trailing_bytes > 0 && p < limit { + p = p.sub(num_trailing_bytes); + } + break 'classification; + } + + // Classify bytes by whether they start 2, 3, or 4 byte sequences + let state_80 = _mm_set1_epi8(0x80u8 as i8); + let vec_signed = _mm_add_epi8(vec, state_80); + + // state now has 0x80 on all bytes + let mut state = state_80; + + // 2-byte sequence starters (0xC0-0xDF, but 0xC0-0xC1 invalid) + let c2_start = _mm_cmplt_epi8(_mm_set1_epi8((0xC0 - 1 - 0x80) as i8), vec_signed); + state = _mm_blendv_epi8(state, _mm_set1_epi8(0xC2u8 as i8), c2_start); + + // 3-byte sequence starters (0xE0-0xEF) + let e3_start = _mm_cmplt_epi8(_mm_set1_epi8((0xE0 - 1 - 0x80) as i8), vec_signed); + state = _mm_blendv_epi8(state, _mm_set1_epi8(0xE3u8 as i8), e3_start); + + // 4-byte sequence starters (0xF0-0xFF, but 0xF5+ invalid) + let f4_start = _mm_cmplt_epi8(_mm_set1_epi8((0xF0 - 1 - 0x80) as i8), vec_signed); + state = _mm_blendv_epi8(state, _mm_set1_epi8(0xF4u8 as i8), f4_start); + + // mask = upper 5 bits of state (indicates byte type) + let mask = _mm_and_si128(state, _mm_set1_epi8(0xF8u8 as i8)); + // count = lower 3 bits of state (sequence length) + let count = _mm_and_si128(state, _mm_set1_epi8(0x07)); + + // Propagate counts: count[i] = remaining bytes in sequence at position i + // count_subs1[i] = count[i] - 1, saturating + let count_subs1 = _mm_subs_epu8(count, one); + // counts[i] = count[i] + count_subs1[i-1] + let mut counts = _mm_add_epi8(count, _mm_srli_si128(count_subs1, 1)); + // counts[i] += counts_subs2[i-2] (for 3 and 4 byte sequences) + counts = _mm_add_epi8(counts, _mm_srli_si128(_mm_subs_epu8(counts, two), 2)); + + // Check for trailing incomplete sequence + if check_for_trailing { + let last_byte_idx = _mm_set1_epi8((chunk_src_sz - 1) as i8); + let at_last_byte = _mm_cmpeq_epi8(numbered, last_byte_idx); + let counts_at_last = _mm_and_si128(counts, at_last_byte); + let has_trailing = _mm_cmplt_epi8(one, counts_at_last); + + if _mm_testz_si128(has_trailing, has_trailing) == 0 { + // We have a trailing incomplete sequence + check_for_trailing = false; + + let last_byte = *start_of_current_chunk.add(chunk_src_sz - 1); + if last_byte >= 0xC0 { + num_trailing_bytes = 1; + } else if chunk_src_sz > 1 && *start_of_current_chunk.add(chunk_src_sz - 2) >= 0xE0 { + num_trailing_bytes = 2; + } else if chunk_src_sz > 2 && *start_of_current_chunk.add(chunk_src_sz - 3) >= 0xF0 { + num_trailing_bytes = 3; + } + + chunk_src_sz -= num_trailing_bytes; + num_consumed -= num_trailing_bytes; + + if chunk_src_sz == 0 { + // Fall back to scalar for trailing bytes + let slice = std::slice::from_raw_parts( + start_of_current_chunk, + num_trailing_bytes + ); + self.scalar_decode_all(slice, output); + num_consumed += num_trailing_bytes; + break 'classification; + } + + vec = Self::zero_last_n_bytes(vec, 16 - chunk_src_sz); + continue 'classification; + } + } + + // Validation: ASCII bytes should have counts[i] == 0 + let count_gt_zero = _mm_cmpgt_epi8(counts, zero); + let count_mask = _mm_movemask_epi8(count_gt_zero); + if ascii_mask != count_mask { + // Invalid UTF-8 - fall back to scalar + let slice = std::slice::from_raw_parts( + start_of_current_chunk, + chunk_src_sz + num_trailing_bytes + ); + self.scalar_decode_all(slice, output); + num_consumed += num_trailing_bytes; + break 'classification; + } + + // Build chunk_is_invalid vector + let mut chunk_invalid = zero; + + // Validate 2-byte starters: 0xC0, 0xC1 are invalid + chunk_invalid = _mm_or_si128(chunk_invalid, + _mm_and_si128(c2_start, _mm_cmplt_epi8(vec, _mm_set1_epi8(0xC2u8 as i8)))); + + // Validate 4-byte starters: 0xF5+ are invalid + chunk_invalid = _mm_or_si128(chunk_invalid, + _mm_and_si128(f4_start, _mm_cmpgt_epi8(vec, _mm_set1_epi8(0xF4u8 as i8)))); + + // Validate continuation bytes don't have starter bytes + let cont_has_starter = _mm_andnot_si128( + _mm_cmplt_epi8(vec, _mm_set1_epi8(0xC0u8 as i8)), + _mm_cmpgt_epi8(counts, count) + ); + chunk_invalid = _mm_or_si128(chunk_invalid, cont_has_starter); + + // Validate E0 second bytes (must be >= 0xA0) + let e0_starters = _mm_cmpeq_epi8(vec, _mm_set1_epi8(0xE0u8 as i8)); + let e0_followers = _mm_srli_si128(e0_starters, 1); + let e0_invalid = _mm_and_si128(e0_followers, + _mm_cmplt_epi8(_mm_and_si128(e0_followers, vec), _mm_set1_epi8(0xA0u8 as i8))); + chunk_invalid = _mm_or_si128(chunk_invalid, e0_invalid); + + // Validate ED second bytes (must be < 0xA0, i.e. <= 0x9F) + let ed_starters = _mm_cmpeq_epi8(vec, _mm_set1_epi8(0xEDu8 as i8)); + let ed_followers = _mm_srli_si128(ed_starters, 1); + let ed_invalid = _mm_and_si128(ed_followers, + _mm_cmpgt_epi8(_mm_and_si128(ed_followers, vec), _mm_set1_epi8(0x9Fu8 as i8))); + chunk_invalid = _mm_or_si128(chunk_invalid, ed_invalid); + + // Validate F0 second bytes (must be >= 0x90) + let f0_starters = _mm_cmpeq_epi8(vec, _mm_set1_epi8(0xF0u8 as i8)); + let f0_followers = _mm_srli_si128(f0_starters, 1); + let f0_invalid = _mm_and_si128(f0_followers, + _mm_cmplt_epi8(_mm_and_si128(f0_followers, vec), _mm_set1_epi8(0x90u8 as i8))); + chunk_invalid = _mm_or_si128(chunk_invalid, f0_invalid); + + // Validate F4 second bytes (must be < 0x90, i.e. <= 0x8F) + let f4_starters = _mm_cmpeq_epi8(vec, _mm_set1_epi8(0xF4u8 as i8)); + let f4_followers = _mm_srli_si128(f4_starters, 1); + let f4_invalid = _mm_and_si128(f4_followers, + _mm_cmpgt_epi8(_mm_and_si128(f4_followers, vec), _mm_set1_epi8(0x8Fu8 as i8))); + chunk_invalid = _mm_or_si128(chunk_invalid, f4_invalid); + + // If invalid, fall back to scalar + if _mm_testz_si128(chunk_invalid, chunk_invalid) == 0 { + let slice = std::slice::from_raw_parts( + start_of_current_chunk, + chunk_src_sz + num_trailing_bytes + ); + self.scalar_decode_all(slice, output); + num_consumed += num_trailing_bytes; + break 'classification; + } + + // Mask control bits to get payload only + vec = _mm_andnot_si128(mask, vec); + + // Build output vectors + let vec_non_ascii = _mm_andnot_si128(_mm_cmpeq_epi8(counts, zero), vec); + + // output1: lowest byte of each codepoint + // For count==1 positions: OR with shifted bits from count==2 position + let count1_locs = _mm_cmpeq_epi8(counts, one); + let shifted_6 = _mm_and_si128( + _mm_slli_epi16(_mm_srli_si128(vec_non_ascii, 1), 6), + _mm_set1_epi8(0xC0u8 as i8) + ); + let output1 = _mm_blendv_epi8(vec, _mm_or_si128(vec, shifted_6), count1_locs); + + // output2: middle byte (for 3 and 4 byte sequences) + let count2_locs = _mm_cmpeq_epi8(counts, two); + let count3_locs = _mm_cmpeq_epi8(counts, three); + let mut output2 = _mm_and_si128(vec, count2_locs); + output2 = _mm_srli_epi32(output2, 2); // bits 5,4,3,2 + let shifted_4 = _mm_and_si128( + _mm_set1_epi8(0xF0u8 as i8), + _mm_slli_epi16(_mm_srli_si128(_mm_and_si128(count3_locs, vec_non_ascii), 1), 4) + ); + output2 = _mm_or_si128(output2, shifted_4); + output2 = _mm_and_si128(output2, count2_locs); + output2 = _mm_srli_si128(output2, 1); + + // output3: highest byte (for 4 byte sequences) + let count4_locs = _mm_cmpeq_epi8(counts, four); + let mut output3 = _mm_and_si128(three, _mm_srli_epi32(vec, 4)); // bits 5,6 from count==3 + let shifted_2 = _mm_and_si128( + _mm_set1_epi8(0xFCu8 as i8), + _mm_slli_epi16(_mm_srli_si128(_mm_and_si128(count4_locs, vec_non_ascii), 1), 2) + ); + output3 = _mm_or_si128(output3, shifted_2); + output3 = _mm_and_si128(output3, count3_locs); + output3 = _mm_srli_si128(output3, 2); + + // Shuffle to remove continuation bytes + // shifts = number of bytes to skip for each position + let mut shifts = count_subs1; + // Propagate shifts: shifts[i] += shifts[i-1] + shifts[i-2] + ... + shifts = _mm_add_epi8(shifts, _mm_srli_si128(shifts, 1)); + shifts = _mm_add_epi8(shifts, _mm_srli_si128(shifts, 2)); + shifts = _mm_add_epi8(shifts, _mm_srli_si128(shifts, 4)); + shifts = _mm_add_epi8(shifts, _mm_srli_si128(shifts, 8)); + + // Zero shifts for discarded continuation bytes (where counts >= 2) + shifts = _mm_and_si128(shifts, _mm_cmplt_epi8(counts, two)); + + // Move shifts leftward based on bit patterns + // This is Kitty's move() macro + shifts = Self::move_shifts_by_1(shifts); + shifts = Self::move_shifts_by_2(shifts); + shifts = Self::move_shifts_by_4(shifts); + shifts = Self::move_shifts_by_8(shifts); + + // Add byte numbers to create shuffle mask + shifts = _mm_add_epi8(shifts, numbered); + + // Shuffle the output vectors + let output1 = _mm_shuffle_epi8(output1, shifts); + let output2 = _mm_shuffle_epi8(output2, shifts); + let output3 = _mm_shuffle_epi8(output3, shifts); + + // Count discarded bytes to get codepoint count + let num_discarded = Self::sum_bytes(count_subs1); + let num_codepoints = chunk_src_sz - num_discarded; + + // Output unicode codepoints + Self::output_unicode(output1, output2, output3, num_codepoints, output); + + // Handle trailing bytes + if num_trailing_bytes > 0 && p < limit { + p = p.sub(num_trailing_bytes); + } + + break 'classification; + } + } + + (num_consumed, sentinel_found) + } + + /// move() macro from Kitty: move shifts leftward based on bit pattern + /// move(shifts, one_byte, 1) + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + #[target_feature(enable = "sse2", enable = "ssse3", enable = "sse4.1")] + #[inline] + unsafe fn move_shifts_by_1(shifts: __m128i) -> __m128i { + // blendv_epi8(shifts, shift_left_by_one_byte(shifts), + // shift_left_by_one_byte(shift_left_by_bits16(shifts, 7))) + let selector = _mm_slli_si128(_mm_slli_epi16(shifts, 7), 1); + let shifted = _mm_slli_si128(shifts, 1); + _mm_blendv_epi8(shifts, shifted, selector) + } + + /// move(shifts, two_bytes, 2) + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + #[target_feature(enable = "sse2", enable = "ssse3", enable = "sse4.1")] + #[inline] + unsafe fn move_shifts_by_2(shifts: __m128i) -> __m128i { + let selector = _mm_slli_si128(_mm_slli_epi16(shifts, 6), 2); + let shifted = _mm_slli_si128(shifts, 2); + _mm_blendv_epi8(shifts, shifted, selector) + } + + /// move(shifts, four_bytes, 3) + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + #[target_feature(enable = "sse2", enable = "ssse3", enable = "sse4.1")] + #[inline] + unsafe fn move_shifts_by_4(shifts: __m128i) -> __m128i { + let selector = _mm_slli_si128(_mm_slli_epi16(shifts, 5), 4); + let shifted = _mm_slli_si128(shifts, 4); + _mm_blendv_epi8(shifts, shifted, selector) + } + + /// move(shifts, eight_bytes, 4) + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + #[target_feature(enable = "sse2", enable = "ssse3", enable = "sse4.1")] + #[inline] + unsafe fn move_shifts_by_8(shifts: __m128i) -> __m128i { + let selector = _mm_slli_si128(_mm_slli_epi16(shifts, 4), 8); + let shifted = _mm_slli_si128(shifts, 8); + _mm_blendv_epi8(shifts, shifted, selector) + } + + /// Find first matching byte position, returns -1 if none found + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + #[target_feature(enable = "sse2", enable = "sse4.1")] + #[inline] + unsafe fn bytes_to_first_match(cmp_result: __m128i) -> i32 { + if _mm_testz_si128(cmp_result, cmp_result) != 0 { + -1 + } else { + _mm_movemask_epi8(cmp_result).trailing_zeros() as i32 + } + } + + /// Zero the last n bytes of the vector. + /// E.g., zero_last_n_bytes(vec, 3) zeros bytes at indices 13, 14, 15. + /// This matches Kitty's implementation which uses shift_left_by_bytes (actually _mm_srli_si128). + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + #[target_feature(enable = "sse2")] + #[inline] + unsafe fn zero_last_n_bytes(vec: __m128i, n: usize) -> __m128i { + // Kitty's approach: shift all-ones "left" (toward lower indices) by n bytes + // This uses _mm_srli_si128 which shifts bytes toward index 0, zeros enter at high indices + // Result: mask with FF at indices 0..15-n and 00 at indices 16-n..15 + let all_ones = _mm_set1_epi8(-1); + let mask = match n { + 0 => all_ones, + 1 => _mm_srli_si128(all_ones, 1), + 2 => _mm_srli_si128(all_ones, 2), + 3 => _mm_srli_si128(all_ones, 3), + 4 => _mm_srli_si128(all_ones, 4), + 5 => _mm_srli_si128(all_ones, 5), + 6 => _mm_srli_si128(all_ones, 6), + 7 => _mm_srli_si128(all_ones, 7), + 8 => _mm_srli_si128(all_ones, 8), + 9 => _mm_srli_si128(all_ones, 9), + 10 => _mm_srli_si128(all_ones, 10), + 11 => _mm_srli_si128(all_ones, 11), + 12 => _mm_srli_si128(all_ones, 12), + 13 => _mm_srli_si128(all_ones, 13), + 14 => _mm_srli_si128(all_ones, 14), + 15 => _mm_srli_si128(all_ones, 15), + _ => _mm_setzero_si128(), + }; + _mm_and_si128(mask, vec) + } + + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + #[target_feature(enable = "sse2")] + #[inline] + unsafe fn sum_bytes(vec: __m128i) -> usize { + let sum = _mm_sad_epu8(vec, _mm_setzero_si128()); + let lower = _mm_cvtsi128_si32(sum) as usize; + let upper = _mm_cvtsi128_si32(_mm_srli_si128(sum, 8)) as usize; + lower + upper + } + + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + #[target_feature(enable = "sse2", enable = "sse4.1")] + #[inline] + unsafe fn output_plain_ascii(vec: __m128i, src_sz: usize, output: &mut Vec) { + output.reserve(src_sz); + + // Process 4 bytes at a time + let mut v = vec; + let mut remaining = src_sz; + + while remaining > 0 { + let unpacked = _mm_cvtepu8_epi32(v); + let to_write = remaining.min(4); + + let mut buf = [0u32; 4]; + _mm_storeu_si128(buf.as_mut_ptr() as *mut __m128i, unpacked); + output.extend_from_slice(&buf[..to_write]); + + remaining = remaining.saturating_sub(4); + v = _mm_srli_si128(v, 4); + } + } + + #[cfg(any(target_arch = "x86_64", target_arch = "x86"))] + #[target_feature(enable = "sse2", enable = "sse4.1")] + #[inline] + unsafe fn output_unicode( + output1: __m128i, + output2: __m128i, + output3: __m128i, + num_codepoints: usize, + output: &mut Vec + ) { + output.reserve(num_codepoints); + + let mut o1 = output1; + let mut o2 = output2; + let mut o3 = output3; + let mut remaining = num_codepoints; + + while remaining > 0 { + // Unpack lowest 4 bytes to 4 u32s + let unpacked1 = _mm_cvtepu8_epi32(o1); + // Shift right by 1 byte, then unpack - this puts bytes in position 1 of each u32 (bits 8-15) + let unpacked2 = _mm_cvtepu8_epi32(_mm_srli_si128(o2, 0)); + let unpacked2 = _mm_slli_epi32(unpacked2, 8); + // Shift right by 2 bytes for output3 - puts bytes in position 2 (bits 16-23) + let unpacked3 = _mm_cvtepu8_epi32(_mm_srli_si128(o3, 0)); + let unpacked3 = _mm_slli_epi32(unpacked3, 16); + + let unpacked = _mm_or_si128(_mm_or_si128(unpacked1, unpacked2), unpacked3); + + let to_write = remaining.min(4); + let mut buf = [0u32; 4]; + _mm_storeu_si128(buf.as_mut_ptr() as *mut __m128i, unpacked); + output.extend_from_slice(&buf[..to_write]); + + remaining = remaining.saturating_sub(4); + o1 = _mm_srli_si128(o1, 4); + o2 = _mm_srli_si128(o2, 4); + o3 = _mm_srli_si128(o3, 4); + } + } + + /// Scalar decode until state is ACCEPT. + fn scalar_decode_to_accept(&mut self, src: &[u8], output: &mut Vec) -> usize { + let mut pos = 0; + while pos < src.len() && self.state.cur != UTF8_ACCEPT { + let byte = src[pos]; + if byte == 0x1B { + output.push(0xFFFD); + self.state = Utf8State::default(); + return pos; + } + pos += 1; + self.state.prev = self.state.cur; + match decode_utf8_byte(&mut self.state.cur, &mut self.state.codep, byte) { + UTF8_ACCEPT => output.push(self.state.codep), + UTF8_REJECT => { + output.push(0xFFFD); + let was_accept = self.state.prev == UTF8_ACCEPT; + self.state = Utf8State::default(); + if !was_accept { + pos -= 1; + } + } + _ => {} + } + } + pos + } + + /// Scalar decode all bytes. + fn scalar_decode_all(&mut self, src: &[u8], output: &mut Vec) -> usize { + let mut pos = 0; + while pos < src.len() { + let byte = src[pos]; + if byte == 0x1B { + if self.state.cur != UTF8_ACCEPT { + output.push(0xFFFD); + self.state = Utf8State::default(); + } + return pos; + } + pos += 1; + self.state.prev = self.state.cur; + match decode_utf8_byte(&mut self.state.cur, &mut self.state.codep, byte) { + UTF8_ACCEPT => output.push(self.state.codep), + UTF8_REJECT => { + output.push(0xFFFD); + let was_accept = self.state.prev == UTF8_ACCEPT; + self.state = Utf8State::default(); + if !was_accept { + pos -= 1; + } + } + _ => {} + } + } + pos + } +} + +/// Convert u32 codepoints to chars. +/// SAFETY: Caller must ensure all codepoints are valid Unicode. +#[inline] +pub fn codepoints_to_chars(codepoints: &[u32], chars: &mut Vec) { + chars.clear(); + chars.reserve(codepoints.len()); + for &cp in codepoints { + // SAFETY: The SIMD decoder validates UTF-8, so codepoints are valid + if cp <= 0x10FFFF && !(0xD800..=0xDFFF).contains(&cp) { + chars.push(unsafe { char::from_u32_unchecked(cp) }); + } else { + chars.push('\u{FFFD}'); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ascii() { + let mut decoder = SimdUtf8Decoder::new(); + let mut output = Vec::new(); + let input = b"Hello, World!"; + let (consumed, found_esc) = decoder.decode_to_esc(input, &mut output); + assert_eq!(consumed, 13); + assert!(!found_esc); + let chars: Vec = output.iter().filter_map(|&cp| char::from_u32(cp)).collect(); + assert_eq!(chars.iter().collect::(), "Hello, World!"); + } + + #[test] + fn test_with_esc() { + let mut decoder = SimdUtf8Decoder::new(); + let mut output = Vec::new(); + let input = b"Hello\x1b[0m"; + let (consumed, found_esc) = decoder.decode_to_esc(input, &mut output); + assert_eq!(consumed, 6); // "Hello" + ESC + assert!(found_esc); + let chars: Vec = output.iter().filter_map(|&cp| char::from_u32(cp)).collect(); + assert_eq!(chars.iter().collect::(), "Hello"); + } + + #[test] + fn test_utf8_2byte() { + let mut decoder = SimdUtf8Decoder::new(); + let mut output = Vec::new(); + let input = "café".as_bytes(); + let (consumed, found_esc) = decoder.decode_to_esc(input, &mut output); + assert_eq!(consumed, 5); // c, a, f, é (2 bytes) + assert!(!found_esc); + let chars: Vec = output.iter().filter_map(|&cp| char::from_u32(cp)).collect(); + assert_eq!(chars.iter().collect::(), "café"); + } + + #[test] + fn test_utf8_3byte() { + let mut decoder = SimdUtf8Decoder::new(); + let mut output = Vec::new(); + let input = "日本語".as_bytes(); + let (consumed, found_esc) = decoder.decode_to_esc(input, &mut output); + assert_eq!(consumed, 9); // 3 chars * 3 bytes + assert!(!found_esc); + let chars: Vec = output.iter().filter_map(|&cp| char::from_u32(cp)).collect(); + assert_eq!(chars.iter().collect::(), "日本語"); + } + + #[test] + fn test_utf8_4byte() { + let mut decoder = SimdUtf8Decoder::new(); + let mut output = Vec::new(); + let input = "🎉🚀".as_bytes(); + let (consumed, found_esc) = decoder.decode_to_esc(input, &mut output); + assert_eq!(consumed, 8); // 2 chars * 4 bytes + assert!(!found_esc); + let chars: Vec = output.iter().filter_map(|&cp| char::from_u32(cp)).collect(); + assert_eq!(chars.iter().collect::(), "🎉🚀"); + } + + #[test] + fn test_invalid_utf8() { + let mut decoder = SimdUtf8Decoder::new(); + let mut output = Vec::new(); + let input = b"\xff\xfe"; + let (consumed, _) = decoder.decode_to_esc(input, &mut output); + assert_eq!(consumed, 2); + // Should have replacement characters + assert!(output.iter().any(|&cp| cp == 0xFFFD)); + } + + // ======================================================================== + // Tests for find_byte + // ======================================================================== + + #[test] + fn test_find_byte_first() { + let haystack = b"hello world"; + assert_eq!(find_byte(haystack, b'h'), Some(0)); + } + + #[test] + fn test_find_byte_middle() { + let haystack = b"hello world"; + assert_eq!(find_byte(haystack, b'w'), Some(6)); + } + + #[test] + fn test_find_byte_not_found() { + let haystack = b"hello world"; + assert_eq!(find_byte(haystack, b'x'), None); + } + + #[test] + fn test_find_byte_long() { + // Test with > 32 bytes to exercise AVX2 path + let mut haystack = vec![b'a'; 100]; + haystack[75] = b'Z'; + assert_eq!(find_byte(&haystack, b'Z'), Some(75)); + } + + // ======================================================================== + // Tests for find_either_of_two_bytes + // ======================================================================== + + #[test] + fn test_find_either_of_two_bytes_first() { + let haystack = b"hello world"; + assert_eq!(find_either_of_two_bytes(haystack, b'h', b'x'), Some(0)); + } + + #[test] + fn test_find_either_of_two_bytes_second() { + let haystack = b"hello world"; + assert_eq!(find_either_of_two_bytes(haystack, b'x', b'h'), Some(0)); + } + + #[test] + fn test_find_either_of_two_bytes_middle() { + let haystack = b"hello world"; + assert_eq!(find_either_of_two_bytes(haystack, b'w', b'o'), Some(4)); // first 'o' at index 4 + } + + #[test] + fn test_find_either_of_two_bytes_not_found() { + let haystack = b"hello world"; + assert_eq!(find_either_of_two_bytes(haystack, b'x', b'y'), None); + } + + #[test] + fn test_find_either_of_two_bytes_esc() { + let haystack = b"hello\x1bworld"; + assert_eq!(find_either_of_two_bytes(haystack, 0x1B, b'\n'), Some(5)); + } + + #[test] + fn test_find_either_of_two_bytes_long() { + // Test with > 32 bytes to exercise AVX2 path + let mut haystack = vec![b'a'; 100]; + haystack[50] = b'X'; + assert_eq!(find_either_of_two_bytes(&haystack, b'X', b'Y'), Some(50)); + } + + #[test] + fn test_find_either_of_two_bytes_empty() { + let haystack = b""; + assert_eq!(find_either_of_two_bytes(haystack, b'a', b'b'), None); + } + + // ======================================================================== + // Tests for find_c0_control + // ======================================================================== + + #[test] + fn test_find_c0_control_newline() { + let haystack = b"hello\nworld"; + assert_eq!(find_c0_control(haystack), Some(5)); + } + + #[test] + fn test_find_c0_control_tab() { + let haystack = b"hello\tworld"; + assert_eq!(find_c0_control(haystack), Some(5)); + } + + #[test] + fn test_find_c0_control_del() { + let haystack = b"hello\x7fworld"; + assert_eq!(find_c0_control(haystack), Some(5)); + } + + #[test] + fn test_find_c0_control_bell() { + let haystack = b"hello\x07world"; + assert_eq!(find_c0_control(haystack), Some(5)); + } + + #[test] + fn test_find_c0_control_esc() { + let haystack = b"hello\x1bworld"; + assert_eq!(find_c0_control(haystack), Some(5)); + } + + #[test] + fn test_find_c0_control_none() { + let haystack = b"hello world!"; + assert_eq!(find_c0_control(haystack), None); + } + + #[test] + fn test_find_c0_control_long() { + // Test with > 32 bytes to exercise AVX2 path + let mut haystack = vec![b'a'; 100]; + haystack[60] = b'\n'; + assert_eq!(find_c0_control(&haystack), Some(60)); + } + + #[test] + fn test_find_c0_control_at_start() { + let haystack = b"\x00hello"; + assert_eq!(find_c0_control(haystack), Some(0)); + } + + // ======================================================================== + // Tests for xor_mask + // ======================================================================== + + #[test] + fn test_xor_mask_basic() { + let mut data = vec![0u8; 8]; + let mask = [0x12, 0x34, 0x56, 0x78]; + xor_mask(&mut data, mask, 0); + assert_eq!(data, vec![0x12, 0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78]); + } + + #[test] + fn test_xor_mask_offset() { + let mut data = vec![0u8; 8]; + let mask = [0x12, 0x34, 0x56, 0x78]; + xor_mask(&mut data, mask, 1); + // Starting at offset 1: 0x34, 0x56, 0x78, 0x12, 0x34, ... + assert_eq!(data, vec![0x34, 0x56, 0x78, 0x12, 0x34, 0x56, 0x78, 0x12]); + } + + #[test] + fn test_xor_mask_roundtrip() { + let original = b"Hello, World!".to_vec(); + let mut data = original.clone(); + let mask = [0xAB, 0xCD, 0xEF, 0x01]; + + // XOR once + xor_mask(&mut data, mask, 0); + assert_ne!(data, original); + + // XOR again to get back original + xor_mask(&mut data, mask, 0); + assert_eq!(data, original); + } + + #[test] + fn test_xor_mask_long() { + // Test with > 32 bytes to exercise AVX2 path + let mut data = vec![0xFFu8; 100]; + let mask = [0x12, 0x34, 0x56, 0x78]; + xor_mask(&mut data, mask, 0); + + // Verify pattern + for (i, &byte) in data.iter().enumerate() { + assert_eq!(byte, 0xFF ^ mask[i % 4]); + } + } + + #[test] + fn test_xor_mask_empty() { + let mut data: Vec = vec![]; + let mask = [0x12, 0x34, 0x56, 0x78]; + let result = xor_mask(&mut data, mask, 0); + assert_eq!(result, 0); + } +} diff --git a/src/statusline.rs b/src/statusline.rs new file mode 100644 index 0000000..3c80ed4 --- /dev/null +++ b/src/statusline.rs @@ -0,0 +1,137 @@ +//! Statusline types and rendering. +//! +//! Provides data structures for building structured statusline content with +//! powerline-style sections and 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) -> 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) -> 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, + /// 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) -> 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), + /// Raw ANSI-formatted string (rendered as-is without section styling). + Raw(String), +} + +impl Default for StatuslineContent { + fn default() -> Self { + StatuslineContent::Sections(Vec::new()) + } +} diff --git a/src/statusline_shader.wgsl b/src/statusline_shader.wgsl index c035dcd..283dfd8 100644 --- a/src/statusline_shader.wgsl +++ b/src/statusline_shader.wgsl @@ -103,7 +103,7 @@ struct ColorTable { // ═══════════════════════════════════════════════════════════════════════════════ @group(0) @binding(0) -var atlas_texture: texture_2d_array; +var atlas_textures: binding_array>; @group(0) @binding(1) var atlas_sampler: sampler; @@ -312,8 +312,8 @@ fn fs_statusline(in: VertexOutput) -> @location(0) vec4 { return in.bg_color; } - // Sample glyph from atlas (using layer for texture array) - let glyph_sample = textureSample(atlas_texture, atlas_sampler, in.uv, in.glyph_layer); + // Sample glyph from atlas (using layer to index texture array) + 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 diff --git a/src/terminal.rs b/src/terminal.rs index 62fb6c3..ecfd4f9 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -2,7 +2,7 @@ use crate::graphics::{GraphicsCommand, ImageStorage}; use crate::keyboard::{query_response, KeyboardState}; -use crate::vt_parser::{CsiParams, Handler, Parser}; +use crate::vt_parser::{CsiParams, Handler}; use unicode_width::UnicodeWidthChar; /// Commands that the terminal can send to the application. @@ -265,50 +265,80 @@ struct AlternateScreen { } /// Timing stats for performance debugging. +/// Only populated when the `render_timing` feature is enabled. #[derive(Debug, Default)] pub struct ProcessingStats { + #[cfg(feature = "render_timing")] /// Total time spent in scroll_up operations (nanoseconds). pub scroll_up_ns: u64, + #[cfg(feature = "render_timing")] /// Number of scroll_up calls. pub scroll_up_count: u32, + #[cfg(feature = "render_timing")] /// Total time spent in scrollback operations (nanoseconds). pub scrollback_ns: u64, + #[cfg(feature = "render_timing")] /// Time in VecDeque pop_front. pub pop_front_ns: u64, + #[cfg(feature = "render_timing")] /// Time in VecDeque push_back. pub push_back_ns: u64, + #[cfg(feature = "render_timing")] /// Time in mem::swap. pub swap_ns: u64, + #[cfg(feature = "render_timing")] /// Total time spent in line clearing (nanoseconds). pub clear_line_ns: u64, + #[cfg(feature = "render_timing")] /// Total time spent in text handler (nanoseconds). pub text_handler_ns: u64, + #[cfg(feature = "render_timing")] + /// Total time spent in CSI handler (nanoseconds). + pub csi_handler_ns: u64, + #[cfg(feature = "render_timing")] + /// Number of CSI sequences processed. + pub csi_count: u32, + #[cfg(feature = "render_timing")] /// Number of characters processed. pub chars_processed: u32, + #[cfg(feature = "render_timing")] + /// Total time spent in VT parser (consume_input) - nanoseconds. + pub vt_parser_ns: u64, + #[cfg(feature = "render_timing")] + /// Number of consume_input calls. + pub consume_input_count: u32, } impl ProcessingStats { + #[cfg(feature = "render_timing")] pub fn reset(&mut self) { *self = Self::default(); } + #[cfg(not(feature = "render_timing"))] + pub fn reset(&mut self) {} + + #[cfg(feature = "render_timing")] pub fn log_if_slow(&self, threshold_ms: u64) { - let total_ms = (self.scroll_up_ns + self.text_handler_ns) / 1_000_000; + let total_ms = (self.scroll_up_ns + self.text_handler_ns + self.csi_handler_ns) / 1_000_000; if total_ms >= threshold_ms { + let vt_only_ns = self.vt_parser_ns.saturating_sub(self.text_handler_ns + self.csi_handler_ns); log::info!( - "TIMING: scroll_up={:.2}ms ({}x), scrollback={:.2}ms [pop={:.2}ms swap={:.2}ms push={:.2}ms], clear={:.2}ms, text={:.2}ms, chars={}", - self.scroll_up_ns as f64 / 1_000_000.0, - self.scroll_up_count, - self.scrollback_ns as f64 / 1_000_000.0, - self.pop_front_ns as f64 / 1_000_000.0, - self.swap_ns as f64 / 1_000_000.0, - self.push_back_ns as f64 / 1_000_000.0, - self.clear_line_ns as f64 / 1_000_000.0, + "[PARSE_DETAIL] text={:.2}ms ({}chars) csi={:.2}ms ({}x) vt_only={:.2}ms ({}calls) scroll={:.2}ms ({}x)", self.text_handler_ns as f64 / 1_000_000.0, self.chars_processed, + self.csi_handler_ns as f64 / 1_000_000.0, + self.csi_count, + vt_only_ns as f64 / 1_000_000.0, + self.consume_input_count, + self.scroll_up_ns as f64 / 1_000_000.0, + self.scroll_up_count, ); } } + + #[cfg(not(feature = "render_timing"))] + pub fn log_if_slow(&self, _threshold_ms: u64) {} } /// Kitty-style ring buffer for scrollback history. @@ -496,11 +526,6 @@ pub struct Terminal { pub focus_reporting: bool, /// Synchronized output mode (for reducing flicker). synchronized_output: bool, - /// Pool of pre-allocated empty lines to avoid allocation during scrolling. - /// When we need a new line, we pop from this pool instead of allocating. - line_pool: Vec>, - /// VT parser for escape sequence handling. - parser: Option, /// Performance timing stats (for debugging). pub stats: ProcessingStats, /// Command queue for terminal-to-application communication. @@ -517,21 +542,12 @@ pub struct Terminal { impl Terminal { /// Default scrollback limit (10,000 lines for better cache performance). pub const DEFAULT_SCROLLBACK_LIMIT: usize = 10_000; - - /// Size of the line pool for recycling allocations. - /// This avoids allocation during the first N scrolls before scrollback is full. - const LINE_POOL_SIZE: usize = 64; /// Creates a new terminal with the given dimensions and scrollback limit. pub fn new(cols: usize, rows: usize, scrollback_limit: usize) -> Self { log::info!("Terminal::new: cols={}, rows={}, scroll_bottom={}", cols, rows, rows.saturating_sub(1)); let grid = vec![vec![Cell::default(); cols]; rows]; let line_map: Vec = (0..rows).collect(); - - // Pre-allocate a pool of empty lines to avoid allocation during scrolling - let line_pool: Vec> = (0..Self::LINE_POOL_SIZE) - .map(|_| vec![Cell::default(); cols]) - .collect(); Self { grid, @@ -567,8 +583,6 @@ impl Terminal { bracketed_paste: false, focus_reporting: false, synchronized_output: false, - line_pool, - parser: Some(Parser::new()), stats: ProcessingStats::default(), command_queue: Vec::new(), image_storage: ImageStorage::new(), @@ -577,16 +591,6 @@ impl Terminal { } } - /// Return a line to the pool for reuse (if pool isn't full). - #[allow(dead_code)] - #[inline] - fn return_line_to_pool(&mut self, line: Vec) { - if self.line_pool.len() < Self::LINE_POOL_SIZE { - self.line_pool.push(line); - } - // Otherwise, let the line be dropped - } - /// Mark a specific line as dirty (needs redrawing). #[inline] pub fn mark_line_dirty(&mut self, line: usize) { @@ -642,6 +646,32 @@ impl Terminal { self.synchronized_output } + /// Advance cursor to next row, scrolling if necessary. + /// This is the common pattern: increment row, scroll if past scroll_bottom. + #[inline] + fn advance_row(&mut self) { + self.cursor_row += 1; + if self.cursor_row > self.scroll_bottom { + self.scroll_up(1); + self.cursor_row = self.scroll_bottom; + } + } + + /// Create a cell with current text attributes. + #[inline] + fn make_cell(&self, character: char, wide_continuation: bool) -> Cell { + Cell { + character, + fg_color: self.current_fg, + bg_color: self.current_bg, + bold: self.current_bold, + italic: self.current_italic, + underline_style: self.current_underline_style, + strikethrough: self.current_strikethrough, + wide_continuation, + } + } + /// Get the actual grid row index for a visual row. #[inline] pub fn grid_row(&self, visual_row: usize) -> usize { @@ -667,7 +697,7 @@ impl Terminal { let blank = self.blank_cell(); let row = &mut self.grid[grid_row]; // Ensure row has correct width (may differ after swap with scrollback post-resize) - row.resize(self.cols, blank.clone()); + row.resize(self.cols, blank); row.fill(blank); } @@ -695,16 +725,8 @@ impl Terminal { } } - /// Processes raw bytes from the PTY using the internal VT parser. - /// Uses Kitty-style architecture: UTF-8 decode until ESC, then parse escape sequences. - pub fn process(&mut self, bytes: &[u8]) { - // We need to temporarily take ownership of the parser to satisfy the borrow checker, - // since parse() needs &mut self for both parser and handler (Terminal). - // Use Option::take to avoid creating a new default parser each time. - if let Some(mut parser) = self.parser.take() { - parser.parse(bytes, self); - self.parser = Some(parser); - } + /// Mark terminal as dirty (needs redraw). Called after parsing. + pub fn mark_dirty(&mut self) { self.dirty = true; } @@ -836,7 +858,8 @@ impl Terminal { let region_size = self.scroll_bottom - self.scroll_top + 1; let n = n.min(region_size); - self.stats.scroll_up_count += n as u32; + #[cfg(feature = "render_timing")] + { self.stats.scroll_up_count += n as u32; } for _ in 0..n { // Save the top line's grid index before rotation @@ -869,14 +892,36 @@ impl Terminal { self.mark_region_dirty(self.scroll_top, self.scroll_bottom); } - /// Mark a range of lines as dirty efficiently. + /// Mark a range of lines as dirty efficiently using bitmask operations. #[inline] fn mark_region_dirty(&mut self, start: usize, end: usize) { - // For small regions (< 64 lines), this is faster than individual calls - for line in start..=end.min(255) { - let word = line / 64; - let bit = line % 64; - self.dirty_lines[word] |= 1u64 << bit; + let end = end.min(255); + if start > end { + return; + } + + // Process each 64-bit word that overlaps with [start, end] + let start_word = start / 64; + let end_word = end / 64; + + for word_idx in start_word..=end_word.min(3) { + let word_start = word_idx * 64; + let word_end = word_start + 63; + + // Calculate bit range within this word + let bit_start = if start > word_start { start - word_start } else { 0 }; + let bit_end = if end < word_end { end - word_start } else { 63 }; + + // Create mask for bits [bit_start, bit_end] + // mask = ((1 << (bit_end - bit_start + 1)) - 1) << bit_start + let num_bits = bit_end - bit_start + 1; + let mask = if num_bits >= 64 { + !0u64 + } else { + ((1u64 << num_bits) - 1) << bit_start + }; + + self.dirty_lines[word_idx] |= mask; } } @@ -1097,6 +1142,39 @@ impl Terminal { rows } + + /// Get a single visible row by index without allocation. + /// Returns None if row_idx is out of bounds. + #[inline] + pub fn get_visible_row(&self, row_idx: usize) -> Option<&Vec> { + if row_idx >= self.rows { + return None; + } + + if self.scroll_offset == 0 { + // No scrollback viewing, just return from grid via line_map + Some(&self.grid[self.line_map[row_idx]]) + } else { + // We're viewing scrollback + let scrollback_len = self.scrollback.len(); + let lines_from_scrollback = self.scroll_offset.min(self.rows); + + if row_idx < lines_from_scrollback { + // This row comes from scrollback + let scrollback_idx = scrollback_len - self.scroll_offset + row_idx; + self.scrollback.get(scrollback_idx) + .or_else(|| Some(&self.grid[self.line_map[row_idx]])) + } else { + // This row comes from the grid + let grid_visual_idx = row_idx - lines_from_scrollback; + if grid_visual_idx < self.rows { + Some(&self.grid[self.line_map[grid_visual_idx]]) + } else { + None + } + } + } + } /// Inserts n blank lines at the cursor position, scrolling lines below down. /// Uses line_map rotation for efficiency. @@ -1119,11 +1197,11 @@ impl Terminal { // Clear the recycled line (now at cursor position) self.clear_grid_row(recycled_grid_row); - - // Mark affected lines dirty - for line in self.cursor_row..=self.scroll_bottom { - self.mark_line_dirty(line); - } + } + + // Mark affected lines dirty once after all rotations + for line in self.cursor_row..=self.scroll_bottom { + self.mark_line_dirty(line); } } @@ -1148,11 +1226,11 @@ impl Terminal { // Clear the recycled line (now at bottom of scroll region) self.clear_grid_row(recycled_grid_row); - - // Mark affected lines dirty - for line in self.cursor_row..=self.scroll_bottom { - self.mark_line_dirty(line); - } + } + + // Mark affected lines dirty once after all rotations + for line in self.cursor_row..=self.scroll_bottom { + self.mark_line_dirty(line); } } @@ -1162,14 +1240,10 @@ impl Terminal { let blank = self.blank_cell(); let row = &mut self.grid[grid_row]; let n = n.min(self.cols - self.cursor_col); - // Remove n characters from the end - for _ in 0..n { - row.pop(); - } - // Insert n blank characters at cursor position - for _ in 0..n { - row.insert(self.cursor_col, blank); - } + // Truncate n characters from the end + row.truncate(self.cols - n); + // Insert n blank characters at cursor position (single O(cols) operation) + row.splice(self.cursor_col..self.cursor_col, std::iter::repeat(blank).take(n)); self.mark_line_dirty(self.cursor_row); } @@ -1179,16 +1253,11 @@ impl Terminal { let blank = self.blank_cell(); let row = &mut self.grid[grid_row]; let n = n.min(self.cols - self.cursor_col); - // Remove n characters at cursor position - for _ in 0..n { - if self.cursor_col < row.len() { - row.remove(self.cursor_col); - } - } + let end = (self.cursor_col + n).min(row.len()); + // Remove n characters at cursor position (single O(cols) operation) + row.drain(self.cursor_col..end); // Pad with blank characters at the end - while row.len() < self.cols { - row.push(blank); - } + row.resize(self.cols, blank); self.mark_line_dirty(self.cursor_row); } @@ -1197,21 +1266,18 @@ impl Terminal { let grid_row = self.line_map[self.cursor_row]; let n = n.min(self.cols - self.cursor_col); let blank = self.blank_cell(); - for i in 0..n { - if self.cursor_col + i < self.cols { - self.grid[grid_row][self.cursor_col + i] = blank; - } - } + // Fill range with blanks (bounds already guaranteed by min above) + self.grid[grid_row][self.cursor_col..self.cursor_col + n].fill(blank); self.mark_line_dirty(self.cursor_row); } /// Clears the current line from cursor to end. + #[inline] fn clear_line_from_cursor(&mut self) { let grid_row = self.line_map[self.cursor_row]; let blank = self.blank_cell(); - for col in self.cursor_col..self.cols { - self.grid[grid_row][col] = blank; - } + // Use slice fill for efficiency + self.grid[grid_row][self.cursor_col..].fill(blank); self.mark_line_dirty(self.cursor_row); } @@ -1243,9 +1309,12 @@ impl Terminal { } impl Handler for Terminal { - /// Handle a chunk of decoded text (Unicode codepoints). + /// Handle a chunk of decoded text (Unicode codepoints as u32). /// This includes control characters (0x00-0x1F except ESC). - fn text(&mut self, chars: &[char]) { + fn text(&mut self, codepoints: &[u32]) { + #[cfg(feature = "render_timing")] + let start = std::time::Instant::now(); + // Cache the current line to avoid repeated line_map lookups let mut cached_row = self.cursor_row; let mut grid_row = self.line_map[cached_row]; @@ -1253,25 +1322,27 @@ impl Handler for Terminal { // Mark the initial line as dirty (like Kitty's init_text_loop_line) self.mark_line_dirty(cached_row); - for &c in chars { - match c { + for &cp in codepoints { + // Fast path for ASCII control characters and printable ASCII + // These are the most common cases, so check them first using u32 directly + match cp { // Bell - '\x07' => { + 0x07 => { // BEL - ignore for now (could trigger visual bell) } // Backspace - '\x08' => { + 0x08 => { if self.cursor_col > 0 { self.cursor_col -= 1; } } // Tab - '\x09' => { + 0x09 => { let next_tab = (self.cursor_col / 8 + 1) * 8; self.cursor_col = next_tab.min(self.cols - 1); } // Line feed, Vertical tab, Form feed - '\x0A' | '\x0B' | '\x0C' => { + 0x0A | 0x0B | 0x0C => { let old_row = self.cursor_row; self.cursor_row += 1; if self.cursor_row > self.scroll_bottom { @@ -1286,21 +1357,17 @@ impl Handler for Terminal { self.mark_line_dirty(cached_row); } // Carriage return - '\x0D' => { + 0x0D => { self.cursor_col = 0; } // Fast path for printable ASCII (0x20-0x7E) - like Kitty // ASCII is always width 1, never zero-width, never wide - c if c >= ' ' && c <= '~' => { + cp if cp >= 0x20 && cp <= 0x7E => { // Handle wrap if self.cursor_col >= self.cols { if self.auto_wrap { self.cursor_col = 0; - self.cursor_row += 1; - if self.cursor_row > self.scroll_bottom { - self.scroll_up(1); - self.cursor_row = self.scroll_bottom; - } + self.advance_row(); cached_row = self.cursor_row; grid_row = self.line_map[cached_row]; self.mark_line_dirty(cached_row); @@ -1310,111 +1377,21 @@ impl Handler for Terminal { } // Write character directly - no wide char handling needed for ASCII - self.grid[grid_row][self.cursor_col] = Cell { - character: c, - fg_color: self.current_fg, - bg_color: self.current_bg, - bold: self.current_bold, - italic: self.current_italic, - underline_style: self.current_underline_style, - strikethrough: self.current_strikethrough, - wide_continuation: false, - }; + // SAFETY: cp is in 0x20..=0x7E which are valid ASCII chars + let c = unsafe { char::from_u32_unchecked(cp) }; + self.grid[grid_row][self.cursor_col] = self.make_cell(c, false); self.cursor_col += 1; } // Slow path for non-ASCII printable characters (including all Unicode) - c if c > '~' => { - // Determine character width using Unicode Standard Annex #11 - let char_width = c.width().unwrap_or(1); - - // Skip zero-width characters (combining marks, etc.) - if char_width == 0 { - // TODO: Handle combining characters - continue; - } - - // Handle wrap - if self.cursor_col >= self.cols { - if self.auto_wrap { - self.cursor_col = 0; - self.cursor_row += 1; - if self.cursor_row > self.scroll_bottom { - self.scroll_up(1); - self.cursor_row = self.scroll_bottom; - } - // Update cache after line change - cached_row = self.cursor_row; - grid_row = self.line_map[cached_row]; - // Mark the new line as dirty - self.mark_line_dirty(cached_row); - } else { - self.cursor_col = self.cols - 1; - } - } - - // For double-width characters at end of line, wrap first - if char_width == 2 && self.cursor_col == self.cols - 1 { - if self.auto_wrap { - self.grid[grid_row][self.cursor_col] = Cell::default(); - self.cursor_col = 0; - self.cursor_row += 1; - if self.cursor_row > self.scroll_bottom { - self.scroll_up(1); - self.cursor_row = self.scroll_bottom; - } - cached_row = self.cursor_row; - grid_row = self.line_map[cached_row]; - // Mark the new line as dirty - self.mark_line_dirty(cached_row); - } else { - continue; // Can't fit - } - } - - // Write character directly using cached grid_row - // Safety: ensure grid row has correct width (may differ after scrollback swap) - if self.grid[grid_row].len() != self.cols { - self.grid[grid_row].resize(self.cols, Cell::default()); - } - - // Handle overwriting wide character cells - if self.grid[grid_row][self.cursor_col].wide_continuation && self.cursor_col > 0 { - self.grid[grid_row][self.cursor_col - 1] = Cell::default(); - } - if char_width == 1 && self.cursor_col + 1 < self.cols - && self.grid[grid_row][self.cursor_col + 1].wide_continuation { - self.grid[grid_row][self.cursor_col + 1] = Cell::default(); - } - - self.grid[grid_row][self.cursor_col] = Cell { - character: c, - fg_color: self.current_fg, - bg_color: self.current_bg, - bold: self.current_bold, - italic: self.current_italic, - underline_style: self.current_underline_style, - strikethrough: self.current_strikethrough, - wide_continuation: false, - }; - self.cursor_col += 1; - - // For double-width, write continuation cell - if char_width == 2 && self.cursor_col < self.cols { - if self.cursor_col + 1 < self.cols - && self.grid[grid_row][self.cursor_col + 1].wide_continuation { - self.grid[grid_row][self.cursor_col + 1] = Cell::default(); - } - self.grid[grid_row][self.cursor_col] = Cell { - character: c, - fg_color: self.current_fg, - bg_color: self.current_bg, - bold: self.current_bold, - italic: self.current_italic, - underline_style: self.current_underline_style, - strikethrough: self.current_strikethrough, - wide_continuation: false, - }; - self.cursor_col += 1; + // Delegates to print_char() which handles wide characters, wrapping, etc. + cp if cp > 0x7E => { + // Convert to char, using replacement character for invalid codepoints + let c = char::from_u32(cp).unwrap_or('\u{FFFD}'); + self.print_char(c); + // Update cached values since print_char may have scrolled or wrapped + if cached_row != self.cursor_row { + cached_row = self.cursor_row; + grid_row = self.line_map[cached_row]; } } // Other control chars - ignore @@ -1422,6 +1399,12 @@ impl Handler for Terminal { } } // Dirty lines are marked incrementally above - no need for mark_all_lines_dirty() + + #[cfg(feature = "render_timing")] + { + self.stats.text_handler_ns += start.elapsed().as_nanos() as u64; + self.stats.chars_processed += codepoints.len() as u32; + } } /// Handle control characters embedded in escape sequences. @@ -1437,11 +1420,7 @@ impl Handler for Terminal { self.cursor_col = next_tab.min(self.cols - 1); } 0x0A | 0x0B | 0x0C => { - self.cursor_row += 1; - if self.cursor_row > self.scroll_bottom { - self.scroll_up(1); - self.cursor_row = self.scroll_bottom; - } + self.advance_row(); } 0x0D => { self.cursor_col = 0; @@ -1617,7 +1596,11 @@ impl Handler for Terminal { } /// Handle a complete CSI sequence. + #[inline] fn csi(&mut self, params: &CsiParams) { + #[cfg(feature = "render_timing")] + let start = std::time::Instant::now(); + let action = params.final_char as char; let primary = params.primary; let secondary = params.secondary; @@ -1766,13 +1749,40 @@ impl Handler for Terminal { self.insert_characters(n); } // Repeat preceding character (REP) + // Optimized like Kitty: batch writes for ASCII, avoid per-char overhead 'b' => { - let n = params.get(0, 1).max(1) as usize; - if self.cursor_col > 0 { + let n = (params.get(0, 1).max(1) as usize).min(65535); // Like Kitty's CSI_REP_MAX_REPETITIONS + if self.cursor_col > 0 && n > 0 { let grid_row = self.line_map[self.cursor_row]; let last_char = self.grid[grid_row][self.cursor_col - 1].character; - for _ in 0..n { - self.print_char(last_char); + let last_cp = last_char as u32; + + // Fast path for ASCII: direct grid write, no width lookup + if last_cp >= 0x20 && last_cp <= 0x7E { + let cell = self.make_cell(last_char, false); + self.mark_line_dirty(self.cursor_row); + + for _ in 0..n { + // Handle wrap + if self.cursor_col >= self.cols { + if self.auto_wrap { + self.cursor_col = 0; + self.advance_row(); + self.mark_line_dirty(self.cursor_row); + } else { + self.cursor_col = self.cols - 1; + } + } + // Direct write - recompute grid_row in case of scroll + let gr = self.line_map[self.cursor_row]; + self.grid[gr][self.cursor_col] = cell.clone(); + self.cursor_col += 1; + } + } else { + // Slow path for non-ASCII: use print_char for proper width handling + for _ in 0..n { + self.print_char(last_char); + } } } } @@ -1893,6 +1903,12 @@ impl Handler for Terminal { ); } } + + #[cfg(feature = "render_timing")] + { + self.stats.csi_handler_ns += start.elapsed().as_nanos() as u64; + self.stats.csi_count += 1; + } } fn save_cursor(&mut self) { @@ -2012,6 +2028,15 @@ impl Handler for Terminal { self.mark_line_dirty(visual_row); } } + + #[cfg(feature = "render_timing")] + fn add_vt_parser_ns(&mut self, ns: u64) { + self.stats.vt_parser_ns += ns; + self.stats.consume_input_count += 1; + } + + #[cfg(not(feature = "render_timing"))] + fn add_vt_parser_ns(&mut self, _ns: u64) {} } impl Terminal { @@ -2035,11 +2060,7 @@ impl Terminal { if self.cursor_col >= self.cols { if self.auto_wrap { self.cursor_col = 0; - self.cursor_row += 1; - if self.cursor_row > self.scroll_bottom { - self.scroll_up(1); - self.cursor_row = self.scroll_bottom; - } + self.advance_row(); } else { self.cursor_col = self.cols - 1; } @@ -2053,11 +2074,7 @@ impl Terminal { let grid_row = self.line_map[self.cursor_row]; self.grid[grid_row][self.cursor_col] = Cell::default(); self.cursor_col = 0; - self.cursor_row += 1; - if self.cursor_row > self.scroll_bottom { - self.scroll_up(1); - self.cursor_row = self.scroll_bottom; - } + self.advance_row(); } else { // Can't fit, don't print return; @@ -2080,16 +2097,7 @@ impl Terminal { } // Write the character to the first cell - self.grid[grid_row][self.cursor_col] = Cell { - character: c, - fg_color: self.current_fg, - bg_color: self.current_bg, - bold: self.current_bold, - italic: self.current_italic, - underline_style: self.current_underline_style, - strikethrough: self.current_strikethrough, - wide_continuation: false, - }; + self.grid[grid_row][self.cursor_col] = self.make_cell(c, false); self.mark_line_dirty(self.cursor_row); self.cursor_col += 1; @@ -2102,53 +2110,79 @@ impl Terminal { self.grid[grid_row][self.cursor_col + 1] = Cell::default(); } - self.grid[grid_row][self.cursor_col] = Cell { - character: ' ', // Placeholder - renderer will skip this - fg_color: self.current_fg, - bg_color: self.current_bg, - bold: self.current_bold, - italic: self.current_italic, - underline_style: self.current_underline_style, - strikethrough: self.current_strikethrough, - wide_continuation: true, - }; + self.grid[grid_row][self.cursor_col] = self.make_cell(' ', true); self.cursor_col += 1; } } + /// Parse extended color (SGR 38/48) and return the color and number of params consumed. + /// Returns (Color, params_consumed) or None if parsing failed. + /// + /// SAFETY: Caller must ensure i < params.num_params + #[inline(always)] + fn parse_extended_color(params: &CsiParams, i: usize) -> Option<(Color, usize)> { + let num = params.num_params; + let p = ¶ms.params; + let is_sub = ¶ms.is_sub_param; + + // Check for sub-parameter format (38:2:r:g:b or 38:5:idx) + if i + 1 < num && is_sub[i + 1] { + let mode = p[i + 1]; + if mode == 5 && i + 2 < num { + return Some((Color::Indexed(p[i + 2] as u8), 2)); + } else if mode == 2 && i + 4 < num { + return Some((Color::Rgb( + p[i + 2] as u8, + p[i + 3] as u8, + p[i + 4] as u8, + ), 4)); + } + } else if i + 2 < num { + // Regular format (38;2;r;g;b or 38;5;idx) + let mode = p[i + 1]; + if mode == 5 { + return Some((Color::Indexed(p[i + 2] as u8), 2)); + } else if mode == 2 && i + 4 < num { + return Some((Color::Rgb( + p[i + 2] as u8, + p[i + 3] as u8, + p[i + 4] as u8, + ), 4)); + } + } + None + } + /// Handle SGR (Select Graphic Rendition) parameters. + /// This is a hot path - called for every color/style change in terminal output. + #[inline(always)] fn handle_sgr(&mut self, params: &CsiParams) { - if params.num_params == 0 { - self.current_fg = Color::Default; - self.current_bg = Color::Default; - self.current_bold = false; - self.current_italic = false; - self.current_underline_style = 0; - self.current_strikethrough = false; + let num = params.num_params; + + // Fast path: SGR 0 (reset) with no params or explicit 0 + if num == 0 { + self.reset_sgr_attributes(); return; } + let p = ¶ms.params; + let is_sub = ¶ms.is_sub_param; let mut i = 0; - while i < params.num_params { - let code = params.params[i]; + + while i < num { + // SAFETY: i < num <= MAX_CSI_PARAMS, so index is always valid + let code = p[i]; match code { - 0 => { - self.current_fg = Color::Default; - self.current_bg = Color::Default; - self.current_bold = false; - self.current_italic = false; - self.current_underline_style = 0; - self.current_strikethrough = false; - } + 0 => self.reset_sgr_attributes(), 1 => self.current_bold = true, + // 2 => dim (not currently rendered) 3 => self.current_italic = true, 4 => { // Check for sub-parameter (4:x format for underline style) - if i + 1 < params.num_params && params.is_sub_param[i + 1] { - let style = params.params[i + 1]; + if i + 1 < num && is_sub[i + 1] { // 0=none, 1=single, 2=double, 3=curly, 4=dotted, 5=dashed - self.current_underline_style = (style as u8).min(5); + self.current_underline_style = (p[i + 1] as u8).min(5); i += 1; } else { // Plain SGR 4 = single underline @@ -2157,84 +2191,55 @@ impl Terminal { } 7 => std::mem::swap(&mut self.current_fg, &mut self.current_bg), 9 => self.current_strikethrough = true, + 21 => self.current_underline_style = 2, // Double underline 22 => self.current_bold = false, 23 => self.current_italic = false, 24 => self.current_underline_style = 0, 27 => std::mem::swap(&mut self.current_fg, &mut self.current_bg), 29 => self.current_strikethrough = false, + // Standard foreground colors (30-37) 30..=37 => self.current_fg = Color::Indexed((code - 30) as u8), 38 => { // Extended foreground color - if i + 1 < params.num_params && params.is_sub_param[i + 1] { - let mode = params.params[i + 1]; - if mode == 5 && i + 2 < params.num_params { - self.current_fg = Color::Indexed(params.params[i + 2] as u8); - i += 2; - } else if mode == 2 && i + 4 < params.num_params { - self.current_fg = Color::Rgb( - params.params[i + 2] as u8, - params.params[i + 3] as u8, - params.params[i + 4] as u8, - ); - i += 4; - } - } else if i + 2 < params.num_params { - let mode = params.params[i + 1]; - if mode == 5 { - self.current_fg = Color::Indexed(params.params[i + 2] as u8); - i += 2; - } else if mode == 2 && i + 4 < params.num_params { - self.current_fg = Color::Rgb( - params.params[i + 2] as u8, - params.params[i + 3] as u8, - params.params[i + 4] as u8, - ); - i += 4; - } + if let Some((color, consumed)) = Self::parse_extended_color(params, i) { + self.current_fg = color; + i += consumed; } } 39 => self.current_fg = Color::Default, + // Standard background colors (40-47) 40..=47 => self.current_bg = Color::Indexed((code - 40) as u8), 48 => { // Extended background color - if i + 1 < params.num_params && params.is_sub_param[i + 1] { - let mode = params.params[i + 1]; - if mode == 5 && i + 2 < params.num_params { - self.current_bg = Color::Indexed(params.params[i + 2] as u8); - i += 2; - } else if mode == 2 && i + 4 < params.num_params { - self.current_bg = Color::Rgb( - params.params[i + 2] as u8, - params.params[i + 3] as u8, - params.params[i + 4] as u8, - ); - i += 4; - } - } else if i + 2 < params.num_params { - let mode = params.params[i + 1]; - if mode == 5 { - self.current_bg = Color::Indexed(params.params[i + 2] as u8); - i += 2; - } else if mode == 2 && i + 4 < params.num_params { - self.current_bg = Color::Rgb( - params.params[i + 2] as u8, - params.params[i + 3] as u8, - params.params[i + 4] as u8, - ); - i += 4; - } + if let Some((color, consumed)) = Self::parse_extended_color(params, i) { + self.current_bg = color; + i += consumed; } } 49 => self.current_bg = Color::Default, + // Bright foreground colors (90-97) 90..=97 => self.current_fg = Color::Indexed((code - 90 + 8) as u8), + // Bright background colors (100-107) 100..=107 => self.current_bg = Color::Indexed((code - 100 + 8) as u8), _ => {} } i += 1; } } + + /// Reset all SGR attributes to defaults. + #[inline(always)] + fn reset_sgr_attributes(&mut self) { + self.current_fg = Color::Default; + self.current_bg = Color::Default; + self.current_bold = false; + self.current_italic = false; + self.current_underline_style = 0; + self.current_strikethrough = false; + } /// Handle Kitty keyboard protocol CSI sequences. + #[inline] fn handle_keyboard_protocol_csi(&mut self, params: &CsiParams) { match params.primary { b'?' => { @@ -2266,6 +2271,7 @@ impl Terminal { } /// Handle DEC private mode set (CSI ? Ps h). + #[inline] fn handle_dec_private_mode_set(&mut self, params: &CsiParams) { for i in 0..params.num_params { match params.params[i] { @@ -2334,6 +2340,7 @@ impl Terminal { } /// Handle DEC private mode reset (CSI ? Ps l). + #[inline] fn handle_dec_private_mode_reset(&mut self, params: &CsiParams) { for i in 0..params.num_params { match params.params[i] { diff --git a/src/vt_parser.rs b/src/vt_parser.rs index 07df5dd..42f20b1 100644 --- a/src/vt_parser.rs +++ b/src/vt_parser.rs @@ -9,7 +9,14 @@ //! 2. Pass decoded codepoints to the text handler, not raw bytes //! 3. Control characters (LF, CR, TAB, BS, etc.) are handled inline in text drawing //! 4. Only ESC triggers state machine transitions -//! 5. Use SIMD-accelerated byte search for finding escape sequence terminators +//! 5. Buffer is integrated into parser - I/O writes directly here +//! 6. Lock is released during parsing - I/O can continue while main parses + +use std::sync::Mutex; +use crate::simd_utf8::SimdUtf8Decoder; + +/// Buffer size - 1MB like Kitty +pub const BUF_SIZE: usize = 1024 * 1024; /// Maximum number of CSI parameters. pub const MAX_CSI_PARAMS: usize = 256; @@ -21,117 +28,6 @@ const MAX_OSC_LEN: usize = 262144; // 256KB, same as MAX_ESCAPE_LEN /// Maximum length of an escape sequence before we give up. const MAX_ESCAPE_LEN: usize = 262144; // 256KB like Kitty -/// Replacement character for invalid UTF-8. -const REPLACEMENT_CHAR: char = '\u{FFFD}'; - -/// UTF-8 decoder states (DFA-based, like Kitty uses). -const UTF8_ACCEPT: u8 = 0; -const UTF8_REJECT: u8 = 12; - -/// UTF-8 state transition and character class tables. -/// Based on Bjoern Hoehrmann's DFA decoder. -static UTF8_DECODE_TABLE: [u8; 364] = [ - // Character class lookup (0-255) - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, - 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, 9,9,9,9,9,9,9,9,9,9,9,9,9,9,9,9, - 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, - 8,8,2,2,2,2,2,2,2,2,2,2,2,2,2,2, 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2, - 10,3,3,3,3,3,3,3,3,3,3,3,3,4,3,3, 11,6,6,6,5,8,8,8,8,8,8,8,8,8,8,8, - // State transition table - 0,12,24,36,60,96,84,12,12,12,48,72, 12,12,12,12,12,12,12,12,12,12,12,12, - 12, 0,12,12,12,12,12, 0,12, 0,12,12, 12,24,12,12,12,12,12,24,12,24,12,12, - 12,12,12,12,12,12,12,24,12,12,12,12, 12,24,12,12,12,12,12,12,12,24,12,12, - 12,12,12,12,12,12,12,36,12,36,12,12, 12,36,12,12,12,12,12,36,12,36,12,12, - 12,36,12,12,12,12,12,12,12,12,12,12, -]; - -/// Decode a single UTF-8 byte using DFA. -#[inline] -fn decode_utf8(state: &mut u8, codep: &mut u32, byte: u8) -> u8 { - let char_class = UTF8_DECODE_TABLE[byte as usize]; - *codep = if *state == UTF8_ACCEPT { - (0xFF >> char_class) as u32 & byte as u32 - } else { - (byte as u32 & 0x3F) | (*codep << 6) - }; - *state = UTF8_DECODE_TABLE[256 + *state as usize + char_class as usize]; - *state -} - -/// UTF-8 decoder that decodes until ESC (0x1B) is found. -/// Returns (output_chars, bytes_consumed, found_esc). -#[derive(Debug, Default)] -pub struct Utf8Decoder { - state: u8, - codep: u32, -} - -impl Utf8Decoder { - pub fn new() -> Self { - Self::default() - } - - pub fn reset(&mut self) { - self.state = UTF8_ACCEPT; - self.codep = 0; - } - - /// Decode UTF-8 bytes until ESC is found. - /// Outputs decoded codepoints to the output buffer. - /// Returns (bytes_consumed, found_esc). - #[inline] - pub fn decode_to_esc(&mut self, src: &[u8], output: &mut Vec) -> (usize, bool) { - output.clear(); - // Pre-allocate capacity to avoid reallocations during decode. - // Worst case: one char per byte (ASCII). Kitty does the same. - output.reserve(src.len()); - let mut consumed = 0; - - for &byte in src { - consumed += 1; - - if byte == 0x1B { - // ESC found - emit replacement if we were in the middle of a sequence - if self.state != UTF8_ACCEPT { - output.push(REPLACEMENT_CHAR); - } - self.reset(); - return (consumed, true); - } - - let prev_state = self.state; - match decode_utf8(&mut self.state, &mut self.codep, byte) { - UTF8_ACCEPT => { - // SAFETY: The DFA decoder guarantees valid Unicode codepoints when - // state is ACCEPT. This is the same guarantee that Kitty relies on. - // Using unchecked avoids a redundant validity check in the hot path. - let c = unsafe { char::from_u32_unchecked(self.codep) }; - output.push(c); - } - UTF8_REJECT => { - // Invalid UTF-8 sequence - output.push(REPLACEMENT_CHAR); - self.state = UTF8_ACCEPT; - // If previous state was accept, we consumed a bad lead byte - // Otherwise, re-process this byte as a potential new sequence start - if prev_state != UTF8_ACCEPT { - consumed -= 1; - continue; - } - } - _ => { - // Continue accumulating multi-byte sequence - } - } - } - - (consumed, false) - } -} - /// Parser state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum State { @@ -170,6 +66,28 @@ enum CsiState { PostSecondary, } +/// Digit multipliers for reverse-order accumulation (like Kitty). +/// Digits are accumulated with multipliers, then divided at commit time. +/// This avoids a multiply on every digit, using a table lookup instead. +static DIGIT_MULTIPLIERS: [i64; 16] = [ + 10_000_000_000_000_000, + 1_000_000_000_000_000, + 100_000_000_000_000, + 10_000_000_000_000, + 1_000_000_000_000, + 100_000_000_000, + 10_000_000_000, + 1_000_000_000, + 100_000_000, + 10_000_000, + 1_000_000, + 100_000, + 10_000, + 1_000, + 100, + 10, +]; + /// Parsed CSI sequence data. #[derive(Debug, Clone)] pub struct CsiParams { @@ -243,18 +161,30 @@ impl CsiParams { } /// Add a digit to the current parameter. - #[inline] + /// Uses Kitty's reverse-order accumulation with lookup table. + #[inline(always)] fn add_digit(&mut self, digit: u8) { - self.accumulator = self.accumulator.saturating_mul(10).saturating_add((digit - b'0') as i64); - self.num_digits += 1; + // Like Kitty: accumulate with multipliers, divide at commit + if self.num_digits < DIGIT_MULTIPLIERS.len() { + self.accumulator += (digit - b'0') as i64 * DIGIT_MULTIPLIERS[self.num_digits]; + self.num_digits += 1; + } } /// Commit the current parameter. + #[inline] fn commit_param(&mut self) -> bool { if self.num_params >= MAX_CSI_PARAMS { return false; } - let value = (self.accumulator as i32).saturating_mul(self.multiplier); + // Convert reverse-order accumulator to final value + // Like Kitty: accumulator / digit_multipliers[num_digits - 1] + let value = if self.num_digits == 0 { + 0 + } else { + // Division converts from reverse-order accumulation + (self.accumulator / DIGIT_MULTIPLIERS[self.num_digits - 1]) as i32 * self.multiplier + }; self.params[self.num_params] = value; self.num_params += 1; self.accumulator = 0; @@ -271,16 +201,14 @@ pub struct Parser { pub state: State, /// CSI parameters being collected. pub csi: CsiParams, - /// UTF-8 decoder for text. - utf8: Utf8Decoder, - /// Decoded character buffer (reused to avoid allocation). - char_buf: Vec, + /// UTF-8 decoder for text (SIMD-optimized). + utf8: SimdUtf8Decoder, + /// Decoded codepoint buffer (reused to avoid allocation). + codepoint_buf: Vec, /// OSC string buffer. osc_buffer: Vec, /// DCS/APC/PM/SOS string buffer. string_buffer: Vec, - /// Intermediate byte for two-char escape sequences. - intermediate: u8, /// Number of bytes consumed in current escape sequence (for max length check). escape_len: usize, } @@ -290,17 +218,810 @@ impl Default for Parser { Self { state: State::Normal, csi: CsiParams::default(), - utf8: Utf8Decoder::new(), + utf8: SimdUtf8Decoder::new(), // Pre-allocate to match typical read buffer sizes (1MB) to avoid reallocation - char_buf: Vec::with_capacity(1024 * 1024), + codepoint_buf: Vec::with_capacity(1024 * 1024), osc_buffer: Vec::new(), string_buffer: Vec::new(), - intermediate: 0, escape_len: 0, } } } +/// Shared buffer state for I/O thread communication. +/// This tracks read/write positions like Kitty's PS struct. +struct BufferState { + /// Read tracking (like Kitty's read struct): + /// - pos: current parse position (advances as we parse) + /// - consumed: bytes that can be discarded (complete sequences only) + /// - sz: total valid bytes in buffer + read_pos: usize, + read_consumed: usize, + read_sz: usize, + /// Write tracking: pending = bytes written by I/O but not yet visible to reader + write_pending: usize, +} + +/// Kitty-style shared parser with integrated 1MB buffer. +/// +/// Like Kitty's PS struct, this owns the buffer AND all parser state. +/// I/O thread writes directly to this buffer, main thread parses in-place. +/// +/// Critical: Lock is RELEASED during parsing so I/O can continue writing. +pub struct SharedParser { + /// The 1MB buffer - I/O writes to end, main reads from front + buf: std::cell::UnsafeCell>, + /// Buffer state protected by mutex + state: Mutex, + /// Eventfd for waking I/O thread when space available + wakeup_fd: i32, + + // ========== Parser state (main thread only, not behind mutex) ========== + // These are copies of read_pos/read_sz/read_consumed for use while lock is released + /// Current parse position (main thread working copy) + parse_pos: std::cell::UnsafeCell, + /// Total valid bytes (main thread working copy) + parse_sz: std::cell::UnsafeCell, + /// Bytes that can be discarded (main thread working copy) + parse_consumed: std::cell::UnsafeCell, + /// Current parser state + vte_state: std::cell::UnsafeCell, + /// CSI parameters being collected + csi: std::cell::UnsafeCell, + /// UTF-8 decoder for text (SIMD-optimized) + utf8: std::cell::UnsafeCell, + /// Decoded codepoint buffer (reused to avoid allocation) + codepoint_buf: std::cell::UnsafeCell>, + /// OSC string buffer + osc_buffer: std::cell::UnsafeCell>, + /// DCS/APC/PM/SOS string buffer + string_buffer: std::cell::UnsafeCell>, + /// Number of bytes consumed in current escape sequence (for max length check) + escape_len: std::cell::UnsafeCell, +} + +// SAFETY: I/O thread only writes to buf[read_sz+write_pending..], main thread +// only reads buf[read_pos..read_sz]. Parser state is only used by main thread. +unsafe impl Sync for SharedParser {} +unsafe impl Send for SharedParser {} + +impl SharedParser { + /// Create a new shared parser with integrated buffer. + pub fn new() -> Self { + let wakeup_fd = unsafe { libc::eventfd(0, libc::EFD_NONBLOCK | libc::EFD_CLOEXEC) }; + if wakeup_fd < 0 { + panic!("Failed to create eventfd: {}", std::io::Error::last_os_error()); + } + + Self { + buf: std::cell::UnsafeCell::new(Box::new([0u8; BUF_SIZE])), + state: Mutex::new(BufferState { + read_pos: 0, + read_consumed: 0, + read_sz: 0, + write_pending: 0, + }), + wakeup_fd, + // Parser state - working copies for use while lock is released + parse_pos: std::cell::UnsafeCell::new(0), + parse_sz: std::cell::UnsafeCell::new(0), + parse_consumed: std::cell::UnsafeCell::new(0), + vte_state: std::cell::UnsafeCell::new(State::Normal), + csi: std::cell::UnsafeCell::new(CsiParams::default()), + utf8: std::cell::UnsafeCell::new(SimdUtf8Decoder::new()), + codepoint_buf: std::cell::UnsafeCell::new(Vec::with_capacity(BUF_SIZE)), + osc_buffer: std::cell::UnsafeCell::new(Vec::new()), + string_buffer: std::cell::UnsafeCell::new(Vec::new()), + escape_len: std::cell::UnsafeCell::new(0), + } + } + + /// Get the wakeup fd for I/O thread to poll on. + pub fn wakeup_fd(&self) -> i32 { + self.wakeup_fd + } + + // ========== I/O Thread API ========== + + /// Check if there's space for writing. Called by I/O thread. + pub fn has_space(&self) -> bool { + let state = self.state.lock().unwrap(); + state.read_sz + state.write_pending < BUF_SIZE + } + + /// Get write buffer for I/O thread. Returns (ptr, available_bytes). + /// Caller MUST call commit_write() after writing. + pub fn create_write_buffer(&self) -> (*mut u8, usize) { + let state = self.state.lock().unwrap(); + let write_offset = state.read_sz + state.write_pending; + let available = BUF_SIZE.saturating_sub(write_offset); + + if available == 0 { + return (std::ptr::null_mut(), 0); + } + + // SAFETY: I/O writes past read_sz+write_pending + let ptr = unsafe { (*self.buf.get()).as_mut_ptr().add(write_offset) }; + (ptr, available) + } + + /// Commit bytes written by I/O thread. + pub fn commit_write(&self, len: usize) { + let mut state = self.state.lock().unwrap(); + state.write_pending += len; + } + + /// Read from PTY fd into buffer. Returns bytes read, -1 for error. + pub fn read_from_fd(&self, fd: i32) -> isize { + let (ptr, available) = self.create_write_buffer(); + if available == 0 { + return 0; + } + + let result = unsafe { libc::read(fd, ptr as *mut libc::c_void, available) }; + + if result > 0 { + self.commit_write(result as usize); + } + result + } + + /// Drain the wakeup eventfd. + pub fn drain_wakeup(&self) { + let mut buf = 0u64; + unsafe { + libc::read(self.wakeup_fd, &mut buf as *mut u64 as *mut libc::c_void, 8); + } + } + + // ========== Main Thread API ========== + + /// Run a parse pass. This is the Kitty-style run_worker(): + /// 1. Lock, make pending visible + /// 2. UNLOCK during actual parsing (consume_input) + /// 3. Re-lock, add new pending, check for more data, repeat + /// 4. Final compaction and wake I/O if space created + /// + /// Returns true if any data was parsed. + pub fn run_parse_pass(&self, handler: &mut H) -> bool { + let mut parsed_any = false; + + // Lock for initial bookkeeping + let mut state = self.state.lock().unwrap(); + + // Make pending writes visible (like Kitty: self->read.sz += self->write.pending) + state.read_sz += state.write_pending; + state.write_pending = 0; + + // Check if there's data to parse (like Kitty: read.pos < read.sz) + let has_pending_input = state.read_pos < state.read_sz; + if !has_pending_input { + return false; + } + + let initial_pos = state.read_pos; + let initial_sz = state.read_sz; + + // Track if buffer was ever full during this parse pass (for wakeup decision) + // Like Kitty: pd->write_space_created = self->read.sz >= BUF_SZ (checked BEFORE compaction) + let mut buffer_was_ever_full = state.read_sz >= BUF_SIZE; + + // Reset consumed counter for this parse pass (like Kitty: self->read.consumed = 0) + state.read_consumed = 0; + + // Copy positions to UnsafeCell fields for use while lock is released + unsafe { + *self.parse_pos.get() = state.read_pos; + *self.parse_sz.get() = state.read_sz; + *self.parse_consumed.get() = state.read_pos; // consumed starts at current pos + } + + // Parse loop - release lock during parsing! + // Like Kitty's do { ... } while (self->read.pos < self->read.sz) + let mut loop_count = 0; + loop { + let parse_pos = unsafe { *self.parse_pos.get() }; + let parse_sz = unsafe { *self.parse_sz.get() }; + + if parse_pos >= parse_sz { + break; + } + + // RELEASE LOCK during parsing - I/O can continue writing! + drop(state); + + // Parse the data - consume_input updates parse_pos and parse_consumed + self.consume_input(handler); + parsed_any = true; + loop_count += 1; + + // Re-acquire lock + state = self.state.lock().unwrap(); + + // CRITICAL: Like Kitty line 1518, add new pending data INSIDE the loop + // This allows us to process data that arrived while we were parsing + state.read_sz += state.write_pending; + state.write_pending = 0; + + // Update buffer_was_ever_full if buffer is now full + if state.read_sz >= BUF_SIZE { + buffer_was_ever_full = true; + } + + // Update state with new positions from parsing + state.read_pos = unsafe { *self.parse_pos.get() }; + state.read_consumed = unsafe { *self.parse_consumed.get() }; + + // Update parse_sz to include new data for next iteration + unsafe { + *self.parse_sz.get() = state.read_sz; + } + + // If no more unparsed data, we're done (like Kitty: while read.pos < read.sz) + if state.read_pos >= state.read_sz { + break; + } + } + + let bytes_parsed = state.read_pos.saturating_sub(initial_pos); + if bytes_parsed > 0 || loop_count > 1 { + log::debug!("[PARSE] initial_pos={} initial_sz={} final_pos={} final_sz={} loops={} bytes={}", + initial_pos, initial_sz, state.read_pos, state.read_sz, loop_count, bytes_parsed); + } + + // Compaction - remove consumed bytes (like Kitty) + if state.read_consumed > 0 { + let old_sz = state.read_sz; + + // Like Kitty: pos -= consumed, sz -= consumed, memmove + state.read_pos = state.read_pos.saturating_sub(state.read_consumed); + state.read_sz = state.read_sz.saturating_sub(state.read_consumed); + + // memmove remaining data to front + if state.read_sz > 0 { + unsafe { + let buf = &mut *self.buf.get(); + std::ptr::copy( + buf.as_ptr().add(state.read_consumed), + buf.as_mut_ptr(), + state.read_sz, + ); + } + } + + let consumed = state.read_consumed; + state.read_consumed = 0; + + // Wake I/O thread if buffer was ever full during this pass and we freed space + // Like Kitty: if (pd.write_space_created) wakeup_io_loop() + if buffer_was_ever_full && state.read_sz < BUF_SIZE { + log::debug!("[PARSE] Waking I/O: was_full={} old_sz={} new_sz={} consumed={}", + buffer_was_ever_full, old_sz, state.read_sz, consumed); + drop(state); + let val = 1u64; + unsafe { + libc::write(self.wakeup_fd, &val as *const u64 as *const libc::c_void, 8); + } + return parsed_any; + } + } else if buffer_was_ever_full { + // Buffer was full but nothing consumed - stuck in partial sequence? + log::warn!("[PARSE] Buffer was full but read_consumed=0! read_pos={} read_sz={}", + state.read_pos, state.read_sz); + } + + drop(state); + parsed_any + } + + /// Check if there's pending data (for tick scheduling). + pub fn has_pending_data(&self) -> bool { + let state = self.state.lock().unwrap(); + state.read_pos < state.read_sz || state.write_pending > 0 + } + + // ========== Internal parsing methods (main thread only) ========== + + /// Main parsing dispatch - like Kitty's consume_input(). + /// Reads from buf[parse_pos..parse_sz] and updates positions. + /// + /// IMPORTANT: Unlike the previous implementation, this now loops internally + /// until the buffer is exhausted or we're waiting for more data in an incomplete + /// escape sequence. This reduces per-CSI overhead from 3 function calls to 1. + fn consume_input(&self, handler: &mut H) { + #[cfg(feature = "render_timing")] + let start = std::time::Instant::now(); + + // Get mutable access to parser state (SAFETY: only main thread calls this) + let parse_pos = unsafe { &mut *self.parse_pos.get() }; + let parse_sz = unsafe { *self.parse_sz.get() }; + let parse_consumed = unsafe { &mut *self.parse_consumed.get() }; + let vte_state = unsafe { &mut *self.vte_state.get() }; + let csi = unsafe { &mut *self.csi.get() }; + let utf8 = unsafe { &mut *self.utf8.get() }; + let codepoint_buf = unsafe { &mut *self.codepoint_buf.get() }; + let osc_buffer = unsafe { &mut *self.osc_buffer.get() }; + let string_buffer = unsafe { &mut *self.string_buffer.get() }; + let escape_len = unsafe { &mut *self.escape_len.get() }; + let buf = unsafe { &*self.buf.get() }; + + // Loop until buffer exhausted or waiting for more data + while *parse_pos < parse_sz { + match *vte_state { + State::Normal => { + // Like Kitty: consume_normal(self); self->read.consumed = self->read.pos; + Self::consume_normal_impl(handler, buf, parse_pos, parse_sz, utf8, codepoint_buf, vte_state, escape_len); + *parse_consumed = *parse_pos; + // consume_normal_impl sets vte_state to Escape if ESC found, so loop continues + } + State::Escape => { + // Like Kitty: if (consume_esc(self)) { self->read.consumed = self->read.pos; } + if Self::consume_escape_impl(handler, buf, parse_pos, parse_sz, *parse_consumed, vte_state, csi, osc_buffer, string_buffer, escape_len) { + *parse_consumed = *parse_pos; + // State changed, continue loop + } else { + // Need more data for escape sequence + break; + } + } + State::EscapeIntermediate(_) => { + if Self::consume_escape_intermediate_impl(handler, buf, parse_pos, parse_sz, vte_state) { + *parse_consumed = *parse_pos; + } else { + break; + } + } + State::Csi => { + // Like Kitty: if (consume_csi(self)) { self->read.consumed = self->read.pos; dispatch; SET_STATE(NORMAL); } + if Self::consume_csi_impl(handler, buf, parse_pos, parse_sz, *parse_consumed, csi, escape_len) { + *parse_consumed = *parse_pos; + if csi.is_valid { + handler.csi(csi); + } + *vte_state = State::Normal; + // Continue loop to process more data + } else { + // Need more data for CSI sequence + break; + } + } + State::Osc => { + if Self::consume_osc_impl(handler, buf, parse_pos, parse_sz, vte_state, osc_buffer, escape_len) { + *parse_consumed = *parse_pos; + *vte_state = State::Normal; + } else { + break; + } + } + State::Dcs | State::Apc | State::Pm | State::Sos => { + if Self::consume_string_impl(handler, buf, parse_pos, parse_sz, vte_state, string_buffer, escape_len) { + *parse_consumed = *parse_pos; + *vte_state = State::Normal; + } else { + break; + } + } + } + } + + #[cfg(feature = "render_timing")] + handler.add_vt_parser_ns(start.elapsed().as_nanos() as u64); + } + + /// Consume normal text - like Kitty's consume_normal(). + /// UTF-8 decodes until ESC is found using SIMD-optimized decoder. + #[inline] + fn consume_normal_impl( + handler: &mut H, + buf: &[u8; BUF_SIZE], + parse_pos: &mut usize, + parse_sz: usize, + utf8: &mut SimdUtf8Decoder, + codepoint_buf: &mut Vec, + vte_state: &mut State, + escape_len: &mut usize, + ) { + loop { + if *parse_pos >= parse_sz { + break; + } + + let remaining = &buf[*parse_pos..parse_sz]; + let (consumed, found_esc) = utf8.decode_to_esc(remaining, codepoint_buf); + *parse_pos += consumed; + + if !codepoint_buf.is_empty() { + handler.text(codepoint_buf); + } + + if found_esc { + *vte_state = State::Escape; + *escape_len = 0; + break; + } + } + } + + /// Consume escape sequence start - like Kitty's consume_esc(). + /// Returns true if sequence is complete (consumed = pos). + #[inline] + fn consume_escape_impl( + handler: &mut H, + buf: &[u8; BUF_SIZE], + parse_pos: &mut usize, + parse_sz: usize, + parse_consumed: usize, + vte_state: &mut State, + csi: &mut CsiParams, + osc_buffer: &mut Vec, + string_buffer: &mut Vec, + escape_len: &mut usize, + ) -> bool { + if *parse_pos >= parse_sz { + return false; + } + + let ch = buf[*parse_pos]; + *parse_pos += 1; + *escape_len += 1; + + // Like Kitty: is_first_char = read.pos - read.consumed == 1 + let is_first_char = *parse_pos - parse_consumed == 1; + + if is_first_char { + match ch { + b'[' => { *vte_state = State::Csi; csi.reset(); } + b']' => { *vte_state = State::Osc; osc_buffer.clear(); } + b'P' => { *vte_state = State::Dcs; string_buffer.clear(); } + b'_' => { *vte_state = State::Apc; string_buffer.clear(); } + b'^' => { *vte_state = State::Pm; string_buffer.clear(); } + b'X' => { *vte_state = State::Sos; string_buffer.clear(); } + // Two-char sequences - need another char + b'(' | b')' | b'*' | b'+' | b'-' | b'.' | b'/' | b'%' | b'#' | b' ' => { + *vte_state = State::EscapeIntermediate(ch); + return false; // Need more chars + } + // Single-char escape sequences + b'7' => { handler.save_cursor(); *vte_state = State::Normal; } + b'8' => { handler.restore_cursor(); *vte_state = State::Normal; } + b'c' => { handler.reset(); *vte_state = State::Normal; } + b'D' => { handler.index(); *vte_state = State::Normal; } + b'E' => { handler.newline(); *vte_state = State::Normal; } + b'H' => { handler.set_tab_stop(); *vte_state = State::Normal; } + b'M' => { handler.reverse_index(); *vte_state = State::Normal; } + b'=' => { handler.set_keypad_mode(true); *vte_state = State::Normal; } + b'>' => { handler.set_keypad_mode(false); *vte_state = State::Normal; } + b'\\' => { *vte_state = State::Normal; } // ST + _ => { + log::debug!("Unknown escape sequence: ESC {:02x}", ch); + *vte_state = State::Normal; + } + } + return true; + } else { + // Second char of two-char sequence - like Kitty's else branch + let prev_ch = buf[*parse_pos - 2]; + *vte_state = State::Normal; + + match prev_ch { + b'(' | b')' => { + let set = if prev_ch == b'(' { 0 } else { 1 }; + handler.designate_charset(set, ch); + } + b'#' => { + if ch == b'8' { + handler.screen_alignment(); + } + } + _ => {} + } + return true; + } + } + + /// Consume second byte of two-char escape sequence. + fn consume_escape_intermediate_impl( + handler: &mut H, + buf: &[u8; BUF_SIZE], + parse_pos: &mut usize, + parse_sz: usize, + vte_state: &mut State, + ) -> bool { + if *parse_pos >= parse_sz { + return false; + } + + let ch = buf[*parse_pos]; + *parse_pos += 1; + + let intermediate = match *vte_state { + State::EscapeIntermediate(i) => i, + _ => { *vte_state = State::Normal; return true; } + }; + + *vte_state = State::Normal; + + match intermediate { + b'(' | b')' => { + let set = if intermediate == b'(' { 0 } else { 1 }; + handler.designate_charset(set, ch); + } + b'#' => { + if ch == b'8' { + handler.screen_alignment(); + } + } + _ => {} + } + + true + } + + /// Consume CSI sequence - like Kitty's csi_parse_loop(). + /// Returns true when sequence is complete. + #[inline] + fn consume_csi_impl( + handler: &mut H, + buf: &[u8; BUF_SIZE], + parse_pos: &mut usize, + parse_sz: usize, + parse_consumed: usize, + csi: &mut CsiParams, + escape_len: &mut usize, + ) -> bool { + while *parse_pos < parse_sz { + let ch = buf[*parse_pos]; + *parse_pos += 1; + *escape_len += 1; + + // Handle embedded control characters + if ch <= 0x1F && ch != 0x1B { + handler.control(ch); + continue; + } + + match csi.state { + CsiState::Start => { + match ch { + b';' => { + csi.params[csi.num_params] = 0; + csi.num_params += 1; + csi.state = CsiState::Body; + } + b'0'..=b'9' => { + csi.add_digit(ch); + csi.state = CsiState::Body; + } + b'?' | b'>' | b'<' | b'=' => { + csi.primary = ch; + csi.state = CsiState::Body; + } + b'-' => { + csi.multiplier = -1; + csi.num_digits = 1; + csi.state = CsiState::Body; + } + b' ' | b'\'' | b'"' | b'!' | b'$' | b'#' | b'*' => { + csi.secondary = ch; + csi.state = CsiState::PostSecondary; + } + b'@'..=b'~' => { + csi.final_char = ch; + csi.is_valid = true; + return true; + } + _ => { + log::debug!("Invalid CSI character: {:02x}", ch); + return true; + } + } + } + CsiState::Body => { + match ch { + b'0'..=b'9' => { + csi.add_digit(ch); + } + b';' => { + if csi.num_digits == 0 { + csi.num_digits = 1; + } + if !csi.commit_param() { + return true; + } + csi.is_sub_param[csi.num_params] = false; + } + b':' => { + if !csi.commit_param() { + return true; + } + csi.is_sub_param[csi.num_params] = true; + } + b'-' if csi.num_digits == 0 => { + csi.multiplier = -1; + csi.num_digits = 1; + } + b' ' | b'\'' | b'"' | b'!' | b'$' | b'#' | b'*' => { + if !csi.commit_param() { + return true; + } + csi.secondary = ch; + csi.state = CsiState::PostSecondary; + } + b'@'..=b'~' => { + if csi.num_digits > 0 || csi.num_params > 0 { + csi.commit_param(); + } + csi.final_char = ch; + csi.is_valid = true; + return true; + } + _ => { + log::debug!("Invalid CSI body character: {:02x}", ch); + return true; + } + } + } + CsiState::PostSecondary => { + match ch { + b'@'..=b'~' => { + csi.final_char = ch; + csi.is_valid = true; + return true; + } + _ => { + log::debug!("Invalid CSI post-secondary character: {:02x}", ch); + return true; + } + } + } + } + } + + // Check max length + if *parse_pos - parse_consumed > MAX_ESCAPE_LEN { + log::debug!("CSI escape too long, ignoring"); + return true; + } + + false + } + + /// Consume OSC sequence. + fn consume_osc_impl( + handler: &mut H, + buf: &[u8; BUF_SIZE], + parse_pos: &mut usize, + parse_sz: usize, + vte_state: &mut State, + osc_buffer: &mut Vec, + escape_len: &mut usize, + ) -> bool { + while *parse_pos < parse_sz { + let ch = buf[*parse_pos]; + + match ch { + 0x07 => { + // BEL terminator + *parse_pos += 1; + handler.osc(osc_buffer); + return true; + } + 0x9C => { + // C1 ST terminator + *parse_pos += 1; + handler.osc(osc_buffer); + return true; + } + 0x1B => { + // Check for ESC \ + if *parse_pos + 1 < parse_sz && buf[*parse_pos + 1] == b'\\' { + *parse_pos += 2; + handler.osc(osc_buffer); + return true; + } else if *parse_pos + 1 < parse_sz { + // ESC followed by something else - abort OSC, start new escape + *parse_pos += 1; + handler.osc(osc_buffer); + *vte_state = State::Escape; + *escape_len = 0; + return false; + } else { + // ESC at end of buffer - need more data + return false; + } + } + _ => { + osc_buffer.push(ch); + *parse_pos += 1; + *escape_len += 1; + } + } + + if *escape_len > MAX_ESCAPE_LEN { + log::debug!("OSC sequence too long, aborting"); + return true; + } + } + + false + } + + /// Consume DCS/APC/PM/SOS string sequence. + fn consume_string_impl( + handler: &mut H, + buf: &[u8; BUF_SIZE], + parse_pos: &mut usize, + parse_sz: usize, + vte_state: &mut State, + string_buffer: &mut Vec, + escape_len: &mut usize, + ) -> bool { + while *parse_pos < parse_sz { + let ch = buf[*parse_pos]; + + match ch { + 0x9C => { + // C1 ST terminator + *parse_pos += 1; + Self::dispatch_string_command(handler, vte_state, string_buffer); + return true; + } + 0x1B => { + // Check for ESC \ + if *parse_pos + 1 < parse_sz && buf[*parse_pos + 1] == b'\\' { + *parse_pos += 2; + Self::dispatch_string_command(handler, vte_state, string_buffer); + return true; + } else if *parse_pos + 1 < parse_sz { + // ESC not followed by \ - include in buffer + string_buffer.push(ch); + *parse_pos += 1; + *escape_len += 1; + } else { + // ESC at end of buffer - need more data + return false; + } + } + _ => { + string_buffer.push(ch); + *parse_pos += 1; + *escape_len += 1; + } + } + + if *escape_len > MAX_ESCAPE_LEN { + log::debug!("String command too long, aborting"); + return true; + } + } + + false + } + + /// Dispatch string command to handler. + fn dispatch_string_command( + handler: &mut H, + vte_state: &State, + string_buffer: &[u8], + ) { + match vte_state { + State::Dcs => handler.dcs(string_buffer), + State::Apc => handler.apc(string_buffer), + State::Pm => handler.pm(string_buffer), + State::Sos => handler.sos(string_buffer), + _ => {} + } + } +} + +impl Drop for SharedParser { + fn drop(&mut self) { + unsafe { + libc::close(self.wakeup_fd); + } + } +} + impl Parser { /// Create a new parser. pub fn new() -> Self { @@ -318,10 +1039,9 @@ impl Parser { self.state = State::Normal; self.csi.reset(); self.utf8.reset(); - self.char_buf.clear(); + self.codepoint_buf.clear(); self.osc_buffer.clear(); self.string_buffer.clear(); - self.intermediate = 0; self.escape_len = 0; } @@ -333,12 +1053,12 @@ impl Parser { while pos < bytes.len() { match self.state { State::Normal => { - // Fast path: UTF-8 decode until ESC - let (consumed, found_esc) = self.utf8.decode_to_esc(&bytes[pos..], &mut self.char_buf); + // Fast path: UTF-8 decode until ESC using SIMD + let (consumed, found_esc) = self.utf8.decode_to_esc(&bytes[pos..], &mut self.codepoint_buf); - // Process decoded characters (text + control chars) - if !self.char_buf.is_empty() { - handler.text(&self.char_buf); + // Process decoded codepoints (text + control chars) + if !self.codepoint_buf.is_empty() { + handler.text(&self.codepoint_buf); } pos += consumed; @@ -418,7 +1138,6 @@ impl Parser { // Two-char sequences: ESC ( ESC ) ESC # ESC % ESC SP etc. b'(' | b')' | b'*' | b'+' | b'-' | b'.' | b'/' | b'%' | b'#' | b' ' => { self.state = State::EscapeIntermediate(ch); - self.intermediate = ch; 1 } // Single-char escape sequences @@ -497,7 +1216,11 @@ impl Parser { } let ch = bytes[pos]; - let intermediate = self.intermediate; + // Extract intermediate from state enum (eliminates redundant self.intermediate field) + let intermediate = match self.state { + State::EscapeIntermediate(i) => i, + _ => return 0, // Should never happen + }; self.escape_len += 1; self.state = State::Normal; @@ -541,16 +1264,15 @@ impl Parser { return consumed; } + // Handle control characters embedded in CSI (common to all states) + if ch <= 0x1F && ch != 0x1B { + handler.control(ch); + continue; + } + match self.csi.state { CsiState::Start => { match ch { - // Control characters embedded in CSI - handle them - 0x00..=0x1F => { - // Handle control chars (except ESC which would be weird here) - if ch != 0x1B { - handler.control(ch); - } - } b';' => { // Empty parameter = 0 self.csi.params[self.csi.num_params] = 0; @@ -591,11 +1313,6 @@ impl Parser { } CsiState::Body => { match ch { - 0x00..=0x1F => { - if ch != 0x1B { - handler.control(ch); - } - } b'0'..=b'9' => { self.csi.add_digit(ch); } @@ -648,11 +1365,6 @@ impl Parser { } CsiState::PostSecondary => { match ch { - 0x00..=0x1F => { - if ch != 0x1B { - handler.control(ch); - } - } // Final byte b'@'..=b'~' => { self.csi.final_char = ch; @@ -756,82 +1468,90 @@ impl Parser { } } + /// Dispatch the string command to the appropriate handler method. + #[inline] + fn dispatch_string_command(&self, handler: &mut H) { + match self.state { + State::Dcs => handler.dcs(&self.string_buffer), + State::Apc => handler.apc(&self.string_buffer), + State::Pm => handler.pm(&self.string_buffer), + State::Sos => handler.sos(&self.string_buffer), + _ => unreachable!("dispatch_string_command called in invalid state"), + } + } + /// Process DCS/APC/PM/SOS sequence bytes using SIMD-accelerated terminator search. /// Like Kitty's find_st_terminator + accumulate_st_terminated_esc_code. + /// Uses iterative approach to avoid stack overflow on malformed input. fn consume_string_command(&mut self, bytes: &[u8], pos: usize, handler: &mut H) -> usize { - let remaining = &bytes[pos..]; + let mut current_pos = pos; + let mut total_consumed = 0; - // Use SIMD-accelerated search to find ESC (0x1B) or C1 ST (0x9C) - if let Some(term_pos) = memchr::memchr2(0x1B, 0x9C, remaining) { - let terminator = remaining[term_pos]; + loop { + let remaining = &bytes[current_pos..]; - // Check max length before accepting - if self.escape_len + term_pos > MAX_ESCAPE_LEN { - log::debug!("String command too long, aborting"); - self.state = State::Normal; - return remaining.len(); - } - - match terminator { - 0x9C => { - // C1 ST terminator - copy data in bulk and dispatch - self.string_buffer.extend_from_slice(&remaining[..term_pos]); - match self.state { - State::Dcs => handler.dcs(&self.string_buffer), - State::Apc => handler.apc(&self.string_buffer), - State::Pm => handler.pm(&self.string_buffer), - State::Sos => handler.sos(&self.string_buffer), - _ => {} - } + // Use SIMD-accelerated search to find ESC (0x1B) or C1 ST (0x9C) + if let Some(term_pos) = memchr::memchr2(0x1B, 0x9C, remaining) { + let terminator = remaining[term_pos]; + + // Check max length before accepting + if self.escape_len + term_pos > MAX_ESCAPE_LEN { + log::debug!("String command too long, aborting"); self.state = State::Normal; - self.escape_len += term_pos + 1; - return term_pos + 1; + return total_consumed + remaining.len(); } - 0x1B => { - // ESC found - check if followed by \ for ST - if term_pos + 1 < remaining.len() && remaining[term_pos + 1] == b'\\' { - // ESC \ (ST) terminator + + match terminator { + 0x9C => { + // C1 ST terminator - copy data in bulk and dispatch self.string_buffer.extend_from_slice(&remaining[..term_pos]); - match self.state { - State::Dcs => handler.dcs(&self.string_buffer), - State::Apc => handler.apc(&self.string_buffer), - State::Pm => handler.pm(&self.string_buffer), - State::Sos => handler.sos(&self.string_buffer), - _ => {} - } + self.dispatch_string_command(handler); self.state = State::Normal; - self.escape_len += term_pos + 2; - return term_pos + 2; - } else if term_pos + 1 < remaining.len() { - // ESC not followed by \ - include ESC in data and continue - // (Unlike OSC, string commands include raw ESC that isn't ST) - self.string_buffer.extend_from_slice(&remaining[..=term_pos]); self.escape_len += term_pos + 1; - // Continue searching from after this ESC - let consumed = term_pos + 1; - return consumed + self.consume_string_command(bytes, pos + consumed, handler); - } else { - // ESC at end of buffer, need more data - // Copy everything before ESC, keep ESC for next parse - self.string_buffer.extend_from_slice(&remaining[..term_pos]); - self.escape_len += term_pos; - return term_pos; + return total_consumed + term_pos + 1; } + 0x1B => { + // ESC found - check if followed by \ for ST + if term_pos + 1 < remaining.len() && remaining[term_pos + 1] == b'\\' { + // ESC \ (ST) terminator + self.string_buffer.extend_from_slice(&remaining[..term_pos]); + self.dispatch_string_command(handler); + self.state = State::Normal; + self.escape_len += term_pos + 2; + return total_consumed + term_pos + 2; + } else if term_pos + 1 < remaining.len() { + // ESC not followed by \ - include ESC in data and continue + // (Unlike OSC, string commands include raw ESC that isn't ST) + self.string_buffer.extend_from_slice(&remaining[..=term_pos]); + self.escape_len += term_pos + 1; + // Continue searching from after this ESC (iterative, not recursive) + let consumed = term_pos + 1; + total_consumed += consumed; + current_pos += consumed; + continue; + } else { + // ESC at end of buffer, need more data + // Copy everything before ESC, keep ESC for next parse + self.string_buffer.extend_from_slice(&remaining[..term_pos]); + self.escape_len += term_pos; + return total_consumed + term_pos; + } + } + _ => unreachable!(), } - _ => unreachable!(), + } else { + // No terminator found - check max length + if self.escape_len + remaining.len() > MAX_ESCAPE_LEN { + log::debug!("String command too long, aborting"); + self.state = State::Normal; + return total_consumed + remaining.len(); + } + + // Buffer all remaining bytes for next parse call + self.string_buffer.extend_from_slice(remaining); + self.escape_len += remaining.len(); + return total_consumed + remaining.len(); } - } else { - // No terminator found - check max length - if self.escape_len + remaining.len() > MAX_ESCAPE_LEN { - log::debug!("String command too long, aborting"); - self.state = State::Normal; - return remaining.len(); - } - - // Buffer all remaining bytes for next parse call - self.string_buffer.extend_from_slice(remaining); - self.escape_len += remaining.len(); - return remaining.len(); } } } @@ -842,7 +1562,7 @@ impl Parser { /// (not bytes) for text, and control characters are expected to be handled /// inline in the text() method (like Kitty does). pub trait Handler { - /// Handle a chunk of decoded text (Unicode codepoints). + /// Handle a chunk of decoded text (Unicode codepoints as u32). /// /// This includes control characters (0x00-0x1F except ESC). /// The handler should process control chars like: @@ -853,7 +1573,10 @@ pub trait Handler { /// - BEL (0x07): bell /// /// ESC is never passed to this method - it triggers state transitions. - fn text(&mut self, chars: &[char]); + /// + /// Codepoints are passed as u32 for efficiency (avoiding char validation). + /// All codepoints are guaranteed to be valid Unicode (validated during UTF-8 decode). + fn text(&mut self, codepoints: &[u32]); /// Handle a single control character embedded in a CSI/OSC sequence. /// This is called for control chars (0x00-0x1F) that appear inside @@ -907,6 +1630,10 @@ pub trait Handler { /// Screen alignment test (DECALN). fn screen_alignment(&mut self) {} + + /// Add VT parser time (for performance tracking). + /// Called by the parser to report time spent in consume_input. + fn add_vt_parser_ns(&mut self, _ns: u64) {} } #[cfg(test)] @@ -914,7 +1641,7 @@ mod tests { use super::*; struct TestHandler { - text_chunks: Vec>, + text_chunks: Vec>, csi_count: usize, osc_count: usize, control_chars: Vec, @@ -932,8 +1659,8 @@ mod tests { } impl Handler for TestHandler { - fn text(&mut self, chars: &[char]) { - self.text_chunks.push(chars.to_vec()); + fn text(&mut self, codepoints: &[u32]) { + self.text_chunks.push(codepoints.to_vec()); } fn control(&mut self, byte: u8) { @@ -957,7 +1684,7 @@ mod tests { parser.parse(b"Hello, World!", &mut handler); assert_eq!(handler.text_chunks.len(), 1); - let text: String = handler.text_chunks[0].iter().collect(); + let text: String = handler.text_chunks[0].iter().filter_map(|&cp| char::from_u32(cp)).collect(); assert_eq!(text, "Hello, World!"); } @@ -969,7 +1696,7 @@ mod tests { parser.parse("Hello, 世界!".as_bytes(), &mut handler); assert_eq!(handler.text_chunks.len(), 1); - let text: String = handler.text_chunks[0].iter().collect(); + let text: String = handler.text_chunks[0].iter().filter_map(|&cp| char::from_u32(cp)).collect(); assert_eq!(text, "Hello, 世界!"); } @@ -982,7 +1709,7 @@ mod tests { parser.parse(b"Hello\nWorld\r!", &mut handler); assert_eq!(handler.text_chunks.len(), 1); - let text: String = handler.text_chunks[0].iter().collect(); + let text: String = handler.text_chunks[0].iter().filter_map(|&cp| char::from_u32(cp)).collect(); assert_eq!(text, "Hello\nWorld\r!"); } @@ -1005,8 +1732,8 @@ mod tests { parser.parse(b"Hello\x1b[1mWorld", &mut handler); assert_eq!(handler.text_chunks.len(), 2); - let text1: String = handler.text_chunks[0].iter().collect(); - let text2: String = handler.text_chunks[1].iter().collect(); + let text1: String = handler.text_chunks[0].iter().filter_map(|&cp| char::from_u32(cp)).collect(); + let text2: String = handler.text_chunks[1].iter().filter_map(|&cp| char::from_u32(cp)).collect(); assert_eq!(text1, "Hello"); assert_eq!(text2, "World"); assert_eq!(handler.csi_count, 1);