Skip to main content

par_term_render/
graphics_renderer.rs

1use crate::error::RenderError;
2use crate::gpu_utils;
3use crate::wgpu_conversions::ImageScalingModeWgpu;
4use par_term_config::ImageScalingMode;
5use std::collections::HashMap;
6use std::time::Instant;
7use wgpu::*;
8
9mod layout;
10mod upload;
11
12pub use layout::PaneRenderGeometry;
13use layout::compute_graphic_geometry;
14use upload::{CachedTexture, texture_bytes};
15
16/// Initial capacity of the graphics instance buffer (number of simultaneous inline images).
17/// The buffer will grow automatically if more images are needed.
18const INITIAL_GRAPHICS_INSTANCE_CAPACITY: usize = 32;
19
20/// Instance data for a single sixel graphic
21#[repr(C)]
22#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
23struct SixelInstance {
24    position: [f32; 2],   // Screen position (normalized 0-1)
25    tex_coords: [f32; 4], // Texture coordinates (x, y, w, h) - normalized 0-1
26    size: [f32; 2],       // Image size in screen space (normalized 0-1)
27    alpha: f32,           // Global alpha multiplier
28    _padding: f32,        // Padding to align to 16 bytes
29}
30
31/// Parameters describing a single inline graphic to render.
32///
33/// Passed as a slice to [`GraphicsRenderer::render`] and
34/// [`GraphicsRenderer::render_for_pane`] so that callers use named fields
35/// rather than a positional 7-element tuple.
36#[derive(Debug, Clone, Copy)]
37pub struct GraphicRenderInfo {
38    /// Unique identifier for this graphic (used to look up the cached texture)
39    pub id: u64,
40    /// Screen row at which the graphic starts (can be negative when scrolled partially off top)
41    pub screen_row: isize,
42    /// Screen column at which the graphic starts
43    pub col: usize,
44    /// Width of the graphic in terminal cells
45    pub width_cells: usize,
46    /// Height of the graphic in terminal cells
47    pub height_cells: usize,
48    /// Global alpha multiplier (0.0 = fully transparent, 1.0 = fully opaque)
49    pub alpha: f32,
50    /// Number of rows clipped from the top when the graphic is partially scrolled off-screen
51    pub scroll_offset_rows: usize,
52    /// Kitty destination pixel offsets within the first cell.
53    pub destination_offset_x: u32,
54    pub destination_offset_y: u32,
55    /// Source crop rectangle in native texture pixels: x, y, width, height.
56    pub source_crop: [u32; 4],
57    /// Whether Kitty supplied `c=` (columns) in the placement.
58    pub has_cols: bool,
59    /// Whether Kitty supplied `r=` (rows) in the placement.
60    pub has_rows: bool,
61}
62
63/// Graphics renderer for sixel images
64pub struct GraphicsRenderer {
65    // Rendering pipeline
66    pipeline: RenderPipeline,
67    bind_group_layout: BindGroupLayout,
68    sampler: Sampler,
69
70    // Instance buffer
71    instance_buffer: Buffer,
72    instance_capacity: usize,
73
74    // Texture cache: maps sixel ID to texture info with LRU tracking
75    texture_cache: HashMap<u64, CachedTexture>,
76    /// Total pixel bytes across cached textures — the byte-budget LRU's ledger,
77    /// kept in sync at every mutation (insert, evict, remove, clear).
78    texture_cache_bytes: u64,
79
80    // Cell dimensions for positioning
81    cell_width: f32,
82    cell_height: f32,
83    window_padding: f32,
84    /// Vertical offset for content (e.g., tab bar height)
85    content_offset_y: f32,
86    /// Horizontal offset for content (e.g., tab bar on left)
87    content_offset_x: f32,
88
89    /// Global config: whether to preserve aspect ratio when rendering images
90    preserve_aspect_ratio: bool,
91}
92
93impl GraphicsRenderer {
94    /// Create a new graphics renderer
95    pub fn new(
96        device: &Device,
97        surface_format: TextureFormat,
98        cell_width: f32,
99        cell_height: f32,
100        window_padding: f32,
101        scaling_mode: ImageScalingMode,
102        preserve_aspect_ratio: bool,
103    ) -> Result<Self, RenderError> {
104        // Create bind group layout for sixel textures
105        let bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
106            label: Some("Sixel Bind Group Layout"),
107            entries: &[
108                // Sixel texture
109                BindGroupLayoutEntry {
110                    binding: 0,
111                    visibility: ShaderStages::FRAGMENT,
112                    ty: BindingType::Texture {
113                        sample_type: TextureSampleType::Float { filterable: true },
114                        view_dimension: TextureViewDimension::D2,
115                        multisampled: false,
116                    },
117                    count: None,
118                },
119                // Sampler
120                BindGroupLayoutEntry {
121                    binding: 1,
122                    visibility: ShaderStages::FRAGMENT,
123                    ty: BindingType::Sampler(SamplerBindingType::Filtering),
124                    count: None,
125                },
126            ],
127        });
128
129        // Create sampler with configured filter mode
130        let sampler = gpu_utils::create_sampler_with_filter(
131            device,
132            scaling_mode.to_filter_mode(),
133            Some("Sixel Sampler"),
134        );
135
136        // Create rendering pipeline
137        let pipeline = Self::create_pipeline(device, surface_format, &bind_group_layout)?;
138
139        // Create instance buffer (initial capacity for INITIAL_GRAPHICS_INSTANCE_CAPACITY images)
140        let initial_capacity = INITIAL_GRAPHICS_INSTANCE_CAPACITY;
141        let instance_buffer = device.create_buffer(&BufferDescriptor {
142            label: Some("Sixel Instance Buffer"),
143            size: (initial_capacity * std::mem::size_of::<SixelInstance>()) as u64,
144            usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
145            mapped_at_creation: false,
146        });
147
148        Ok(Self {
149            pipeline,
150            bind_group_layout,
151            sampler,
152            instance_buffer,
153            instance_capacity: initial_capacity,
154            texture_cache: HashMap::new(),
155            texture_cache_bytes: 0,
156            cell_width,
157            cell_height,
158            window_padding,
159            content_offset_y: 0.0,
160            content_offset_x: 0.0,
161            preserve_aspect_ratio,
162        })
163    }
164
165    /// Create the sixel rendering pipeline
166    fn create_pipeline(
167        device: &Device,
168        format: TextureFormat,
169        bind_group_layout: &BindGroupLayout,
170    ) -> Result<RenderPipeline, RenderError> {
171        let shader = device.create_shader_module(ShaderModuleDescriptor {
172            label: Some("Sixel Shader"),
173            source: ShaderSource::Wgsl(include_str!("shaders/sixel.wgsl").into()),
174        });
175
176        let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
177            label: Some("Sixel Pipeline Layout"),
178            bind_group_layouts: &[Some(bind_group_layout)],
179            immediate_size: 0,
180        });
181
182        Ok(device.create_render_pipeline(&RenderPipelineDescriptor {
183            label: Some("Sixel Pipeline"),
184            layout: Some(&pipeline_layout),
185            vertex: VertexState {
186                module: &shader,
187                entry_point: Some("vs_main"),
188                buffers: &[Some(VertexBufferLayout {
189                    array_stride: std::mem::size_of::<SixelInstance>() as u64,
190                    step_mode: VertexStepMode::Instance,
191                    attributes: &vertex_attr_array![
192                        0 => Float32x2,  // position
193                        1 => Float32x4,  // tex_coords
194                        2 => Float32x2,  // size
195                        3 => Float32,    // alpha
196                    ],
197                })],
198                compilation_options: Default::default(),
199            },
200            fragment: Some(FragmentState {
201                module: &shader,
202                entry_point: Some("fs_main"),
203                targets: &[Some(ColorTargetState {
204                    format,
205                    // Use premultiplied alpha blending since shader outputs premultiplied colors
206                    blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING),
207                    write_mask: ColorWrites::ALL,
208                })],
209                compilation_options: Default::default(),
210            }),
211            primitive: PrimitiveState {
212                topology: PrimitiveTopology::TriangleStrip,
213                ..Default::default()
214            },
215            depth_stencil: None,
216            multisample: MultisampleState::default(),
217            cache: None,
218            multiview_mask: None,
219        }))
220    }
221
222    /// Render sixel graphics
223    ///
224    /// # Arguments
225    /// * `device` - WGPU device for creating buffers
226    /// * `queue` - WGPU queue for writing buffer data
227    /// * `render_pass` - Active render pass to render into
228    /// * `graphics` - Slice of [`GraphicRenderInfo`] describing each graphic's position and dimensions
229    /// * `window_width` - Window width in pixels
230    /// * `window_height` - Window height in pixels
231    pub fn render(
232        &mut self,
233        device: &Device,
234        queue: &Queue,
235        render_pass: &mut RenderPass,
236        graphics: &[GraphicRenderInfo],
237        window_width: f32,
238        window_height: f32,
239    ) -> Result<(), RenderError> {
240        if graphics.is_empty() {
241            return Ok(());
242        }
243
244        // Build instance data
245        let mut instances = Vec::with_capacity(graphics.len());
246        for g in graphics {
247            let (
248                id,
249                row,
250                col,
251                _width_cells,
252                _height_cells,
253                alpha,
254                _scroll_offset_rows,
255                dest_off_x,
256                dest_off_y,
257                crop,
258                has_cols,
259                has_rows,
260            ) = (
261                g.id,
262                g.screen_row,
263                g.col,
264                g.width_cells,
265                g.height_cells,
266                g.alpha,
267                g.scroll_offset_rows,
268                g.destination_offset_x,
269                g.destination_offset_y,
270                g.source_crop,
271                g.has_cols,
272                g.has_rows,
273            );
274            // Check if texture exists and update LRU timestamp
275            if let Some(cached) = self.texture_cache.get_mut(&id) {
276                cached.last_used = Instant::now();
277                let tex_info = &cached.texture;
278
279                // Signed pixel-space top relative to content area. A Y
280                // offset can place the top at a non-row-aligned position,
281                // so clipping must be computed in pixels, not integer rows.
282                let top_px = row as f32 * self.cell_height + dest_off_y as f32;
283                let clip_px = (-top_px).max(0.0);
284                let draw_y_px = top_px.max(0.0);
285                let x = (self.window_padding
286                    + self.content_offset_x
287                    + col as f32 * self.cell_width
288                    + dest_off_x as f32)
289                    / window_width;
290                let y = (self.window_padding + self.content_offset_y + draw_y_px) / window_height;
291
292                const VIRTUAL_PLACEMENT_ID_FLAG: u64 = 1u64 << 63;
293                let is_virtual_placement = id & VIRTUAL_PLACEMENT_ID_FLAG != 0;
294                let (tex_coords, size) = compute_graphic_geometry(
295                    tex_info.width as f32,
296                    tex_info.height as f32,
297                    crop,
298                    _width_cells,
299                    _height_cells,
300                    self.cell_width,
301                    self.cell_height,
302                    clip_px,
303                    has_cols,
304                    has_rows,
305                    self.preserve_aspect_ratio,
306                    is_virtual_placement,
307                    window_width,
308                    window_height,
309                );
310
311                instances.push(SixelInstance {
312                    position: [x, y],
313                    tex_coords,
314                    size,
315                    alpha,
316                    _padding: 0.0,
317                });
318            }
319        }
320
321        if instances.is_empty() {
322            return Ok(());
323        }
324
325        // Debug: log sixel rendering
326        log::debug!(
327            "[GRAPHICS] Rendering {} sixel graphics (from {} total graphics provided)",
328            instances.len(),
329            graphics.len()
330        );
331
332        // Resize instance buffer if needed
333        let required_capacity = instances.len();
334        if required_capacity > self.instance_capacity {
335            let new_capacity = (required_capacity * 2).max(32);
336            self.instance_buffer = device.create_buffer(&BufferDescriptor {
337                label: Some("Sixel Instance Buffer"),
338                size: (new_capacity * std::mem::size_of::<SixelInstance>()) as u64,
339                usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
340                mapped_at_creation: false,
341            });
342            self.instance_capacity = new_capacity;
343        }
344
345        // Write instance data to buffer
346        queue.write_buffer(&self.instance_buffer, 0, bytemuck::cast_slice(&instances));
347
348        // Set pipeline
349        render_pass.set_pipeline(&self.pipeline);
350
351        // Render each graphic with its specific bind group
352        render_pass.set_vertex_buffer(0, self.instance_buffer.slice(..));
353
354        // Use separate counter for instance index since we filtered out graphics without textures
355        let mut instance_idx = 0u32;
356        for g in graphics {
357            if let Some(cached) = self.texture_cache.get(&g.id) {
358                render_pass.set_bind_group(0, &cached.texture.bind_group, &[]);
359                render_pass.draw(0..4, instance_idx..(instance_idx + 1));
360                instance_idx += 1;
361            }
362        }
363
364        Ok(())
365    }
366
367    /// Render sixel graphics for a specific pane using explicit origin coordinates.
368    ///
369    /// Identical to [`Self::render`] but uses `pane_origin_x`/`pane_origin_y` for positioning
370    /// instead of the global `window_padding + content_offset` values, so graphics are
371    /// placed relative to the pane rather than the full window.
372    ///
373    /// # Arguments
374    /// * `device` - WGPU device for creating buffers
375    /// * `queue` - WGPU queue for writing buffer data
376    /// * `render_pass` - Active render pass to render into
377    /// * `graphics` - Slice of [`GraphicRenderInfo`] describing each graphic's position and dimensions
378    /// * `window_width` - Window width in pixels
379    /// * `window_height` - Window height in pixels
380    /// * `pane_origin_x` - X pixel coordinate of the pane's content origin
381    /// * `pane_origin_y` - Y pixel coordinate of the pane's content origin
382    pub fn render_for_pane(
383        &mut self,
384        device: &Device,
385        queue: &Queue,
386        render_pass: &mut RenderPass,
387        graphics: &[GraphicRenderInfo],
388        pane_geometry: PaneRenderGeometry,
389    ) -> Result<(), RenderError> {
390        let PaneRenderGeometry {
391            window_width,
392            window_height,
393            pane_origin_x,
394            pane_origin_y,
395        } = pane_geometry;
396        if graphics.is_empty() {
397            return Ok(());
398        }
399
400        // Build instance data
401        let mut instances = Vec::with_capacity(graphics.len());
402        for g in graphics {
403            let (
404                id,
405                row,
406                col,
407                _width_cells,
408                _height_cells,
409                alpha,
410                _scroll_offset_rows,
411                dest_off_x,
412                dest_off_y,
413                crop,
414                has_cols,
415                has_rows,
416            ) = (
417                g.id,
418                g.screen_row,
419                g.col,
420                g.width_cells,
421                g.height_cells,
422                g.alpha,
423                g.scroll_offset_rows,
424                g.destination_offset_x,
425                g.destination_offset_y,
426                g.source_crop,
427                g.has_cols,
428                g.has_rows,
429            );
430            // Check if texture exists and update LRU timestamp
431            if let Some(cached) = self.texture_cache.get_mut(&id) {
432                cached.last_used = Instant::now();
433                let tex_info = &cached.texture;
434
435                let top_px = row as f32 * self.cell_height + dest_off_y as f32;
436                let clip_px = (-top_px).max(0.0);
437                let draw_y_px = top_px.max(0.0);
438                let x = (pane_origin_x + col as f32 * self.cell_width + dest_off_x as f32)
439                    / window_width;
440                let y = (pane_origin_y + draw_y_px) / window_height;
441
442                const VIRTUAL_PLACEMENT_ID_FLAG: u64 = 1u64 << 63;
443                let is_virtual_placement = id & VIRTUAL_PLACEMENT_ID_FLAG != 0;
444                let (tex_coords, size) = compute_graphic_geometry(
445                    tex_info.width as f32,
446                    tex_info.height as f32,
447                    crop,
448                    _width_cells,
449                    _height_cells,
450                    self.cell_width,
451                    self.cell_height,
452                    clip_px,
453                    has_cols,
454                    has_rows,
455                    self.preserve_aspect_ratio,
456                    is_virtual_placement,
457                    window_width,
458                    window_height,
459                );
460
461                instances.push(SixelInstance {
462                    position: [x, y],
463                    tex_coords,
464                    size,
465                    alpha,
466                    _padding: 0.0,
467                });
468            }
469        }
470
471        if instances.is_empty() {
472            return Ok(());
473        }
474
475        // Resize instance buffer if needed
476        let required_capacity = instances.len();
477        if required_capacity > self.instance_capacity {
478            let new_capacity = (required_capacity * 2).max(32);
479            self.instance_buffer = device.create_buffer(&BufferDescriptor {
480                label: Some("Sixel Instance Buffer"),
481                size: (new_capacity * std::mem::size_of::<SixelInstance>()) as u64,
482                usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
483                mapped_at_creation: false,
484            });
485            self.instance_capacity = new_capacity;
486        }
487
488        // Write instance data to buffer
489        queue.write_buffer(&self.instance_buffer, 0, bytemuck::cast_slice(&instances));
490
491        // Set pipeline
492        render_pass.set_pipeline(&self.pipeline);
493        render_pass.set_vertex_buffer(0, self.instance_buffer.slice(..));
494
495        let mut instance_idx = 0u32;
496        for g in graphics {
497            if let Some(cached) = self.texture_cache.get(&g.id) {
498                render_pass.set_bind_group(0, &cached.texture.bind_group, &[]);
499                render_pass.draw(0..4, instance_idx..(instance_idx + 1));
500                instance_idx += 1;
501            }
502        }
503
504        Ok(())
505    }
506
507    /// Remove a texture from the cache
508    pub fn remove_texture(&mut self, id: u64) {
509        if let Some(removed) = self.texture_cache.remove(&id) {
510            self.texture_cache_bytes -= texture_bytes(&removed.texture);
511        }
512    }
513
514    /// Clear all cached textures
515    pub fn clear_cache(&mut self) {
516        self.texture_cache.clear();
517        self.texture_cache_bytes = 0;
518    }
519
520    /// Get the number of cached textures
521    pub fn cache_size(&self) -> usize {
522        self.texture_cache.len()
523    }
524
525    /// Update cell dimensions (called when window is resized)
526    pub fn update_cell_dimensions(
527        &mut self,
528        cell_width: f32,
529        cell_height: f32,
530        window_padding: f32,
531    ) {
532        self.cell_width = cell_width;
533        self.cell_height = cell_height;
534        self.window_padding = window_padding;
535    }
536
537    /// Set vertical content offset (e.g., tab bar height)
538    pub fn set_content_offset_y(&mut self, offset: f32) {
539        self.content_offset_y = offset;
540    }
541
542    /// Set horizontal content offset (e.g., tab bar on left)
543    pub fn set_content_offset_x(&mut self, offset: f32) {
544        self.content_offset_x = offset;
545    }
546
547    /// Update the global aspect ratio preservation setting.
548    pub fn set_preserve_aspect_ratio(&mut self, preserve: bool) {
549        self.preserve_aspect_ratio = preserve;
550    }
551
552    /// Update the texture scaling mode (nearest vs linear filtering).
553    ///
554    /// This recreates the sampler and invalidates all cached textures
555    /// since their bind groups reference the old sampler.
556    pub fn update_scaling_mode(&mut self, device: &Device, scaling_mode: ImageScalingMode) {
557        self.sampler = gpu_utils::create_sampler_with_filter(
558            device,
559            scaling_mode.to_filter_mode(),
560            Some("Sixel Sampler"),
561        );
562        // Clear texture cache since bind groups reference the old sampler
563        self.texture_cache.clear();
564        self.texture_cache_bytes = 0;
565    }
566}