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