Skip to main content

par_term_render/
graphics_renderer.rs

1use anyhow::Result;
2use std::collections::HashMap;
3use wgpu::*;
4
5use crate::gpu_utils;
6use par_term_config::ImageScalingMode;
7
8/// Instance data for a single sixel graphic
9#[repr(C)]
10#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
11struct SixelInstance {
12    position: [f32; 2],   // Screen position (normalized 0-1)
13    tex_coords: [f32; 4], // Texture coordinates (x, y, w, h) - normalized 0-1
14    size: [f32; 2],       // Image size in screen space (normalized 0-1)
15    alpha: f32,           // Global alpha multiplier
16    _padding: f32,        // Padding to align to 16 bytes
17}
18
19/// Metadata for a cached sixel texture
20struct SixelTextureInfo {
21    #[allow(dead_code)]
22    texture: Texture,
23    #[allow(dead_code)]
24    view: TextureView,
25    bind_group: BindGroup,
26    #[allow(dead_code)]
27    width: u32,
28    #[allow(dead_code)]
29    height: u32,
30}
31
32/// Graphics renderer for sixel images
33pub struct GraphicsRenderer {
34    // Rendering pipeline
35    pipeline: RenderPipeline,
36    bind_group_layout: BindGroupLayout,
37    sampler: Sampler,
38
39    // Instance buffer
40    instance_buffer: Buffer,
41    instance_capacity: usize,
42
43    // Texture cache: maps sixel ID to texture info
44    texture_cache: HashMap<u64, SixelTextureInfo>,
45
46    // Cell dimensions for positioning
47    cell_width: f32,
48    cell_height: f32,
49    window_padding: f32,
50    /// Vertical offset for content (e.g., tab bar height)
51    content_offset_y: f32,
52    /// Horizontal offset for content (e.g., tab bar on left)
53    content_offset_x: f32,
54
55    /// Global config: whether to preserve aspect ratio when rendering images
56    preserve_aspect_ratio: bool,
57}
58
59impl GraphicsRenderer {
60    /// Create a new graphics renderer
61    pub fn new(
62        device: &Device,
63        surface_format: TextureFormat,
64        cell_width: f32,
65        cell_height: f32,
66        window_padding: f32,
67        scaling_mode: ImageScalingMode,
68        preserve_aspect_ratio: bool,
69    ) -> Result<Self> {
70        // Create bind group layout for sixel textures
71        let bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
72            label: Some("Sixel Bind Group Layout"),
73            entries: &[
74                // Sixel texture
75                BindGroupLayoutEntry {
76                    binding: 0,
77                    visibility: ShaderStages::FRAGMENT,
78                    ty: BindingType::Texture {
79                        sample_type: TextureSampleType::Float { filterable: true },
80                        view_dimension: TextureViewDimension::D2,
81                        multisampled: false,
82                    },
83                    count: None,
84                },
85                // Sampler
86                BindGroupLayoutEntry {
87                    binding: 1,
88                    visibility: ShaderStages::FRAGMENT,
89                    ty: BindingType::Sampler(SamplerBindingType::Filtering),
90                    count: None,
91                },
92            ],
93        });
94
95        // Create sampler with configured filter mode
96        let sampler = gpu_utils::create_sampler_with_filter(
97            device,
98            scaling_mode.to_filter_mode(),
99            Some("Sixel Sampler"),
100        );
101
102        // Create rendering pipeline
103        let pipeline = Self::create_pipeline(device, surface_format, &bind_group_layout)?;
104
105        // Create instance buffer (initial capacity for 32 images)
106        let initial_capacity = 32;
107        let instance_buffer = device.create_buffer(&BufferDescriptor {
108            label: Some("Sixel Instance Buffer"),
109            size: (initial_capacity * std::mem::size_of::<SixelInstance>()) as u64,
110            usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
111            mapped_at_creation: false,
112        });
113
114        Ok(Self {
115            pipeline,
116            bind_group_layout,
117            sampler,
118            instance_buffer,
119            instance_capacity: initial_capacity,
120            texture_cache: HashMap::new(),
121            cell_width,
122            cell_height,
123            window_padding,
124            content_offset_y: 0.0,
125            content_offset_x: 0.0,
126            preserve_aspect_ratio,
127        })
128    }
129
130    /// Create the sixel rendering pipeline
131    fn create_pipeline(
132        device: &Device,
133        format: TextureFormat,
134        bind_group_layout: &BindGroupLayout,
135    ) -> Result<RenderPipeline> {
136        let shader = device.create_shader_module(ShaderModuleDescriptor {
137            label: Some("Sixel Shader"),
138            source: ShaderSource::Wgsl(include_str!("shaders/sixel.wgsl").into()),
139        });
140
141        let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
142            label: Some("Sixel Pipeline Layout"),
143            bind_group_layouts: &[bind_group_layout],
144            push_constant_ranges: &[],
145        });
146
147        Ok(device.create_render_pipeline(&RenderPipelineDescriptor {
148            label: Some("Sixel Pipeline"),
149            layout: Some(&pipeline_layout),
150            vertex: VertexState {
151                module: &shader,
152                entry_point: Some("vs_main"),
153                buffers: &[VertexBufferLayout {
154                    array_stride: std::mem::size_of::<SixelInstance>() as u64,
155                    step_mode: VertexStepMode::Instance,
156                    attributes: &vertex_attr_array![
157                        0 => Float32x2,  // position
158                        1 => Float32x4,  // tex_coords
159                        2 => Float32x2,  // size
160                        3 => Float32,    // alpha
161                    ],
162                }],
163                compilation_options: Default::default(),
164            },
165            fragment: Some(FragmentState {
166                module: &shader,
167                entry_point: Some("fs_main"),
168                targets: &[Some(ColorTargetState {
169                    format,
170                    // Use premultiplied alpha blending since shader outputs premultiplied colors
171                    blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING),
172                    write_mask: ColorWrites::ALL,
173                })],
174                compilation_options: Default::default(),
175            }),
176            primitive: PrimitiveState {
177                topology: PrimitiveTopology::TriangleStrip,
178                ..Default::default()
179            },
180            depth_stencil: None,
181            multisample: MultisampleState::default(),
182            multiview: None,
183            cache: None,
184        }))
185    }
186
187    /// Create or get a cached texture for a sixel graphic
188    ///
189    /// # Arguments
190    /// * `device` - WGPU device for creating textures
191    /// * `queue` - WGPU queue for writing texture data
192    /// * `id` - Unique identifier for this sixel graphic
193    /// * `rgba_data` - RGBA pixel data (width * height * 4 bytes)
194    /// * `width` - Image width in pixels
195    /// * `height` - Image height in pixels
196    pub fn get_or_create_texture(
197        &mut self,
198        device: &Device,
199        queue: &Queue,
200        id: u64,
201        rgba_data: &[u8],
202        width: u32,
203        height: u32,
204    ) -> Result<()> {
205        // Check if texture already exists in cache
206        // For animations, we need to update the texture data even if it exists
207        if let Some(tex_info) = self.texture_cache.get(&id) {
208            // Texture exists - update it if the data might have changed
209            // Validate data size
210            let expected_size = (width * height * 4) as usize;
211            if rgba_data.len() != expected_size {
212                return Err(anyhow::anyhow!(
213                    "Invalid RGBA data size: expected {}, got {}",
214                    expected_size,
215                    rgba_data.len()
216                ));
217            }
218
219            // Update existing texture with new pixel data (for animations)
220            queue.write_texture(
221                TexelCopyTextureInfo {
222                    texture: &tex_info.texture,
223                    mip_level: 0,
224                    origin: Origin3d::ZERO,
225                    aspect: TextureAspect::All,
226                },
227                rgba_data,
228                TexelCopyBufferLayout {
229                    offset: 0,
230                    bytes_per_row: Some(4 * width),
231                    rows_per_image: Some(height),
232                },
233                Extent3d {
234                    width,
235                    height,
236                    depth_or_array_layers: 1,
237                },
238            );
239
240            return Ok(());
241        }
242
243        // Validate data size
244        let expected_size = (width * height * 4) as usize;
245        if rgba_data.len() != expected_size {
246            return Err(anyhow::anyhow!(
247                "Invalid RGBA data size: expected {}, got {}",
248                expected_size,
249                rgba_data.len()
250            ));
251        }
252
253        // Create texture
254        let texture = device.create_texture(&TextureDescriptor {
255            label: Some(&format!("Sixel Texture {}", id)),
256            size: Extent3d {
257                width,
258                height,
259                depth_or_array_layers: 1,
260            },
261            mip_level_count: 1,
262            sample_count: 1,
263            dimension: TextureDimension::D2,
264            format: TextureFormat::Rgba8Unorm,
265            usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
266            view_formats: &[],
267        });
268
269        // Write RGBA data to texture
270        queue.write_texture(
271            TexelCopyTextureInfo {
272                texture: &texture,
273                mip_level: 0,
274                origin: Origin3d::ZERO,
275                aspect: TextureAspect::All,
276            },
277            rgba_data,
278            TexelCopyBufferLayout {
279                offset: 0,
280                bytes_per_row: Some(4 * width),
281                rows_per_image: Some(height),
282            },
283            Extent3d {
284                width,
285                height,
286                depth_or_array_layers: 1,
287            },
288        );
289
290        let view = texture.create_view(&TextureViewDescriptor::default());
291
292        // Create bind group for this texture
293        let bind_group = device.create_bind_group(&BindGroupDescriptor {
294            label: Some(&format!("Sixel Bind Group {}", id)),
295            layout: &self.bind_group_layout,
296            entries: &[
297                BindGroupEntry {
298                    binding: 0,
299                    resource: BindingResource::TextureView(&view),
300                },
301                BindGroupEntry {
302                    binding: 1,
303                    resource: BindingResource::Sampler(&self.sampler),
304                },
305            ],
306        });
307
308        // Cache texture info
309        self.texture_cache.insert(
310            id,
311            SixelTextureInfo {
312                texture,
313                view,
314                bind_group,
315                width,
316                height,
317            },
318        );
319
320        log::debug!(
321            "[GRAPHICS] Created sixel texture: id={}, size={}x{}, cache_size={}",
322            id,
323            width,
324            height,
325            self.texture_cache.len()
326        );
327
328        Ok(())
329    }
330
331    /// Render sixel graphics
332    ///
333    /// # Arguments
334    /// * `device` - WGPU device for creating buffers
335    /// * `queue` - WGPU queue for writing buffer data
336    /// * `render_pass` - Active render pass to render into
337    /// * `graphics` - Slice of sixel graphics to render with their positions
338    ///   Each tuple contains: (id, row, col, width_in_cells, height_in_cells, alpha, scroll_offset_rows)
339    /// * `window_width` - Window width in pixels
340    /// * `window_height` - Window height in pixels
341    pub fn render(
342        &mut self,
343        device: &Device,
344        queue: &Queue,
345        render_pass: &mut RenderPass,
346        graphics: &[(u64, isize, usize, usize, usize, f32, usize)],
347        window_width: f32,
348        window_height: f32,
349    ) -> Result<()> {
350        if graphics.is_empty() {
351            return Ok(());
352        }
353
354        // Build instance data
355        let mut instances = Vec::with_capacity(graphics.len());
356        for &(id, row, col, _width_cells, _height_cells, alpha, scroll_offset_rows) in graphics {
357            // Check if texture exists
358            if let Some(tex_info) = self.texture_cache.get(&id) {
359                // Calculate screen position (normalized 0-1, origin top-left)
360                // When scroll_offset_rows > 0, the image is partially scrolled off the top.
361                // Advance the y position by scroll_offset_rows so the visible portion
362                // starts at the correct screen row instead of above the viewport.
363                let adjusted_row = row + scroll_offset_rows as isize;
364                let x =
365                    (self.window_padding + self.content_offset_x + col as f32 * self.cell_width)
366                        / window_width;
367                let y = (self.window_padding
368                    + self.content_offset_y
369                    + adjusted_row as f32 * self.cell_height)
370                    / window_height;
371
372                // Calculate texture V offset for scrolled graphics
373                // scroll_offset_rows = terminal rows scrolled off top
374                // Each terminal row = cell_height pixels
375                let tex_v_start = if scroll_offset_rows > 0 && tex_info.height > 0 {
376                    let pixels_scrolled = scroll_offset_rows as f32 * self.cell_height;
377                    (pixels_scrolled / tex_info.height as f32).min(0.99)
378                } else {
379                    0.0
380                };
381                let tex_v_height = 1.0 - tex_v_start;
382
383                // Calculate display size based on aspect ratio preservation setting
384                let (width, height) = if self.preserve_aspect_ratio {
385                    // Use actual texture pixel dimensions to preserve aspect ratio
386                    // Rather than converting pixels→cells→pixels (which distorts non-square cells)
387                    let visible_height_pixels = if scroll_offset_rows > 0 {
388                        (tex_info.height as f32 * tex_v_height).max(1.0)
389                    } else {
390                        tex_info.height as f32
391                    };
392                    (
393                        tex_info.width as f32 / window_width,
394                        visible_height_pixels / window_height,
395                    )
396                } else {
397                    // Stretch to fill cell grid (ignore image aspect ratio)
398                    let cell_w = _width_cells as f32 * self.cell_width / window_width;
399                    let visible_cell_rows = if scroll_offset_rows > 0 {
400                        (_height_cells as f32 * tex_v_height).max(0.0)
401                    } else {
402                        _height_cells as f32
403                    };
404                    let cell_h = visible_cell_rows * self.cell_height / window_height;
405                    (cell_w, cell_h)
406                };
407
408                instances.push(SixelInstance {
409                    position: [x, y],
410                    tex_coords: [0.0, tex_v_start, 1.0, tex_v_height], // Crop from top
411                    size: [width, height],
412                    alpha,
413                    _padding: 0.0,
414                });
415            }
416        }
417
418        if instances.is_empty() {
419            return Ok(());
420        }
421
422        // Debug: log sixel rendering
423        log::debug!(
424            "[GRAPHICS] Rendering {} sixel graphics (from {} total graphics provided)",
425            instances.len(),
426            graphics.len()
427        );
428
429        // Resize instance buffer if needed
430        let required_capacity = instances.len();
431        if required_capacity > self.instance_capacity {
432            let new_capacity = (required_capacity * 2).max(32);
433            self.instance_buffer = device.create_buffer(&BufferDescriptor {
434                label: Some("Sixel Instance Buffer"),
435                size: (new_capacity * std::mem::size_of::<SixelInstance>()) as u64,
436                usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
437                mapped_at_creation: false,
438            });
439            self.instance_capacity = new_capacity;
440        }
441
442        // Write instance data to buffer
443        queue.write_buffer(&self.instance_buffer, 0, bytemuck::cast_slice(&instances));
444
445        // Set pipeline
446        render_pass.set_pipeline(&self.pipeline);
447
448        // Render each graphic with its specific bind group
449        render_pass.set_vertex_buffer(0, self.instance_buffer.slice(..));
450
451        // Use separate counter for instance index since we filtered out graphics without textures
452        let mut instance_idx = 0u32;
453        for &(id, _, _, _, _, _, _) in graphics {
454            if let Some(tex_info) = self.texture_cache.get(&id) {
455                render_pass.set_bind_group(0, &tex_info.bind_group, &[]);
456                render_pass.draw(0..4, instance_idx..(instance_idx + 1));
457                instance_idx += 1;
458            }
459        }
460
461        Ok(())
462    }
463
464    /// Remove a texture from the cache
465    #[allow(dead_code)]
466    pub fn remove_texture(&mut self, id: u64) {
467        self.texture_cache.remove(&id);
468    }
469
470    /// Clear all cached textures
471    #[allow(dead_code)]
472    pub fn clear_cache(&mut self) {
473        self.texture_cache.clear();
474    }
475
476    /// Get the number of cached textures
477    #[allow(dead_code)]
478    pub fn cache_size(&self) -> usize {
479        self.texture_cache.len()
480    }
481
482    /// Update cell dimensions (called when window is resized)
483    pub fn update_cell_dimensions(
484        &mut self,
485        cell_width: f32,
486        cell_height: f32,
487        window_padding: f32,
488    ) {
489        self.cell_width = cell_width;
490        self.cell_height = cell_height;
491        self.window_padding = window_padding;
492    }
493
494    /// Set vertical content offset (e.g., tab bar height)
495    pub fn set_content_offset_y(&mut self, offset: f32) {
496        self.content_offset_y = offset;
497    }
498
499    /// Set horizontal content offset (e.g., tab bar on left)
500    pub fn set_content_offset_x(&mut self, offset: f32) {
501        self.content_offset_x = offset;
502    }
503
504    /// Update the global aspect ratio preservation setting.
505    pub fn set_preserve_aspect_ratio(&mut self, preserve: bool) {
506        self.preserve_aspect_ratio = preserve;
507    }
508
509    /// Update the texture scaling mode (nearest vs linear filtering).
510    ///
511    /// This recreates the sampler and invalidates all cached textures
512    /// since their bind groups reference the old sampler.
513    pub fn update_scaling_mode(&mut self, device: &Device, scaling_mode: ImageScalingMode) {
514        self.sampler = gpu_utils::create_sampler_with_filter(
515            device,
516            scaling_mode.to_filter_mode(),
517            Some("Sixel Sampler"),
518        );
519        // Clear texture cache since bind groups reference the old sampler
520        self.texture_cache.clear();
521    }
522}