Skip to main content

par_term_render/
graphics_renderer.rs

1// ARC-009 TODO: This file is 726 lines (limit: 800 — approaching threshold). When it
2// exceeds 800 lines, extract into a graphics_renderer/ sub-module directory:
3//
4//   upload.rs    — Texture upload / cache invalidation logic
5//   layout.rs    — Graphics placement and scaling calculations
6//
7// Tracking: Issue ARC-009 in AUDIT.md.
8
9use crate::error::RenderError;
10use crate::gpu_utils;
11use par_term_config::ImageScalingMode;
12use std::collections::HashMap;
13use std::time::Instant;
14use wgpu::*;
15
16/// Maximum number of textures to cache before evicting least-recently-used entries.
17/// This prevents unbounded GPU memory growth when displaying many inline images.
18const MAX_TEXTURE_CACHE_SIZE: usize = 100;
19
20/// Initial capacity of the graphics instance buffer (number of simultaneous inline images).
21/// The buffer will grow automatically if more images are needed.
22const INITIAL_GRAPHICS_INSTANCE_CAPACITY: usize = 32;
23
24/// Instance data for a single sixel graphic
25#[repr(C)]
26#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
27struct SixelInstance {
28    position: [f32; 2],   // Screen position (normalized 0-1)
29    tex_coords: [f32; 4], // Texture coordinates (x, y, w, h) - normalized 0-1
30    size: [f32; 2],       // Image size in screen space (normalized 0-1)
31    alpha: f32,           // Global alpha multiplier
32    _padding: f32,        // Padding to align to 16 bytes
33}
34
35/// Window and pane geometry for a single [`GraphicsRenderer::render_for_pane`] call.
36#[derive(Debug, Clone, Copy)]
37pub struct PaneRenderGeometry {
38    pub window_width: f32,
39    pub window_height: f32,
40    pub pane_origin_x: f32,
41    pub pane_origin_y: f32,
42}
43
44/// Parameters describing a single inline graphic to render.
45///
46/// Passed as a slice to [`GraphicsRenderer::render`] and
47/// [`GraphicsRenderer::render_for_pane`] so that callers use named fields
48/// rather than a positional 7-element tuple.
49#[derive(Debug, Clone, Copy)]
50pub struct GraphicRenderInfo {
51    /// Unique identifier for this graphic (used to look up the cached texture)
52    pub id: u64,
53    /// Screen row at which the graphic starts (can be negative when scrolled partially off top)
54    pub screen_row: isize,
55    /// Screen column at which the graphic starts
56    pub col: usize,
57    /// Width of the graphic in terminal cells
58    pub width_cells: usize,
59    /// Height of the graphic in terminal cells
60    pub height_cells: usize,
61    /// Global alpha multiplier (0.0 = fully transparent, 1.0 = fully opaque)
62    pub alpha: f32,
63    /// Number of rows clipped from the top when the graphic is partially scrolled off-screen
64    pub scroll_offset_rows: usize,
65}
66
67/// Metadata for a cached sixel texture
68struct SixelTextureInfo {
69    texture: Texture,
70    #[allow(dead_code)] // GPU lifetime: must outlive the bind_group which references this view
71    view: TextureView,
72    bind_group: BindGroup,
73    width: u32,
74    height: u32,
75}
76
77/// Cached texture wrapper with LRU tracking
78struct CachedTexture {
79    texture: SixelTextureInfo,
80    /// Timestamp of last access for LRU eviction
81    last_used: Instant,
82}
83
84/// Graphics renderer for sixel images
85pub struct GraphicsRenderer {
86    // Rendering pipeline
87    pipeline: RenderPipeline,
88    bind_group_layout: BindGroupLayout,
89    sampler: Sampler,
90
91    // Instance buffer
92    instance_buffer: Buffer,
93    instance_capacity: usize,
94
95    // Texture cache: maps sixel ID to texture info with LRU tracking
96    texture_cache: HashMap<u64, CachedTexture>,
97
98    // Cell dimensions for positioning
99    cell_width: f32,
100    cell_height: f32,
101    window_padding: f32,
102    /// Vertical offset for content (e.g., tab bar height)
103    content_offset_y: f32,
104    /// Horizontal offset for content (e.g., tab bar on left)
105    content_offset_x: f32,
106
107    /// Global config: whether to preserve aspect ratio when rendering images
108    preserve_aspect_ratio: bool,
109}
110
111impl GraphicsRenderer {
112    /// Create a new graphics renderer
113    pub fn new(
114        device: &Device,
115        surface_format: TextureFormat,
116        cell_width: f32,
117        cell_height: f32,
118        window_padding: f32,
119        scaling_mode: ImageScalingMode,
120        preserve_aspect_ratio: bool,
121    ) -> Result<Self, RenderError> {
122        // Create bind group layout for sixel textures
123        let bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
124            label: Some("Sixel Bind Group Layout"),
125            entries: &[
126                // Sixel texture
127                BindGroupLayoutEntry {
128                    binding: 0,
129                    visibility: ShaderStages::FRAGMENT,
130                    ty: BindingType::Texture {
131                        sample_type: TextureSampleType::Float { filterable: true },
132                        view_dimension: TextureViewDimension::D2,
133                        multisampled: false,
134                    },
135                    count: None,
136                },
137                // Sampler
138                BindGroupLayoutEntry {
139                    binding: 1,
140                    visibility: ShaderStages::FRAGMENT,
141                    ty: BindingType::Sampler(SamplerBindingType::Filtering),
142                    count: None,
143                },
144            ],
145        });
146
147        // Create sampler with configured filter mode
148        let sampler = gpu_utils::create_sampler_with_filter(
149            device,
150            scaling_mode.to_filter_mode(),
151            Some("Sixel Sampler"),
152        );
153
154        // Create rendering pipeline
155        let pipeline = Self::create_pipeline(device, surface_format, &bind_group_layout)?;
156
157        // Create instance buffer (initial capacity for INITIAL_GRAPHICS_INSTANCE_CAPACITY images)
158        let initial_capacity = INITIAL_GRAPHICS_INSTANCE_CAPACITY;
159        let instance_buffer = device.create_buffer(&BufferDescriptor {
160            label: Some("Sixel Instance Buffer"),
161            size: (initial_capacity * std::mem::size_of::<SixelInstance>()) as u64,
162            usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
163            mapped_at_creation: false,
164        });
165
166        Ok(Self {
167            pipeline,
168            bind_group_layout,
169            sampler,
170            instance_buffer,
171            instance_capacity: initial_capacity,
172            texture_cache: HashMap::new(),
173            cell_width,
174            cell_height,
175            window_padding,
176            content_offset_y: 0.0,
177            content_offset_x: 0.0,
178            preserve_aspect_ratio,
179        })
180    }
181
182    /// Create the sixel rendering pipeline
183    fn create_pipeline(
184        device: &Device,
185        format: TextureFormat,
186        bind_group_layout: &BindGroupLayout,
187    ) -> Result<RenderPipeline, RenderError> {
188        let shader = device.create_shader_module(ShaderModuleDescriptor {
189            label: Some("Sixel Shader"),
190            source: ShaderSource::Wgsl(include_str!("shaders/sixel.wgsl").into()),
191        });
192
193        let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
194            label: Some("Sixel Pipeline Layout"),
195            bind_group_layouts: &[Some(bind_group_layout)],
196            immediate_size: 0,
197        });
198
199        Ok(device.create_render_pipeline(&RenderPipelineDescriptor {
200            label: Some("Sixel Pipeline"),
201            layout: Some(&pipeline_layout),
202            vertex: VertexState {
203                module: &shader,
204                entry_point: Some("vs_main"),
205                buffers: &[VertexBufferLayout {
206                    array_stride: std::mem::size_of::<SixelInstance>() as u64,
207                    step_mode: VertexStepMode::Instance,
208                    attributes: &vertex_attr_array![
209                        0 => Float32x2,  // position
210                        1 => Float32x4,  // tex_coords
211                        2 => Float32x2,  // size
212                        3 => Float32,    // alpha
213                    ],
214                }],
215                compilation_options: Default::default(),
216            },
217            fragment: Some(FragmentState {
218                module: &shader,
219                entry_point: Some("fs_main"),
220                targets: &[Some(ColorTargetState {
221                    format,
222                    // Use premultiplied alpha blending since shader outputs premultiplied colors
223                    blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING),
224                    write_mask: ColorWrites::ALL,
225                })],
226                compilation_options: Default::default(),
227            }),
228            primitive: PrimitiveState {
229                topology: PrimitiveTopology::TriangleStrip,
230                ..Default::default()
231            },
232            depth_stencil: None,
233            multisample: MultisampleState::default(),
234            cache: None,
235            multiview_mask: None,
236        }))
237    }
238
239    /// Create or get a cached texture for a sixel graphic
240    ///
241    /// # Arguments
242    /// * `device` - WGPU device for creating textures
243    /// * `queue` - WGPU queue for writing texture data
244    /// * `id` - Unique identifier for this sixel graphic
245    /// * `rgba_data` - RGBA pixel data (width * height * 4 bytes)
246    /// * `width` - Image width in pixels
247    /// * `height` - Image height in pixels
248    pub fn get_or_create_texture(
249        &mut self,
250        device: &Device,
251        queue: &Queue,
252        id: u64,
253        rgba_data: &[u8],
254        width: u32,
255        height: u32,
256    ) -> Result<(), RenderError> {
257        // Check if texture already exists in cache
258        // For animations, we need to update the texture data even if it exists
259        if let Some(cached) = self.texture_cache.get_mut(&id) {
260            // Update LRU timestamp on cache hit
261            cached.last_used = Instant::now();
262
263            // Kitty TGP virtual placements (high-bit flag set on the cache id;
264            // see par-term-render/src/renderer/graphics.rs) reuse the same
265            // image data every frame — they're static placements anchored by
266            // grid placeholder cells, not animations. Re-uploading the
267            // pixels per frame here costs ~640 KB × 60 fps for a 400×400
268            // image, saturating the GPU command queue and freezing the pane.
269            // For these IDs, treat the cache hit as final.
270            const VIRTUAL_PLACEMENT_ID_FLAG: u64 = 1u64 << 63;
271            if id & VIRTUAL_PLACEMENT_ID_FLAG != 0 {
272                return Ok(());
273            }
274
275            // Texture exists - update it if the data might have changed
276            // Validate data size
277            let expected_size = (width * height * 4) as usize;
278            if rgba_data.len() != expected_size {
279                return Err(RenderError::InvalidTextureData {
280                    expected: expected_size,
281                    actual: rgba_data.len(),
282                });
283            }
284
285            // Update existing texture with new pixel data (for animations)
286            queue.write_texture(
287                TexelCopyTextureInfo {
288                    texture: &cached.texture.texture,
289                    mip_level: 0,
290                    origin: Origin3d::ZERO,
291                    aspect: TextureAspect::All,
292                },
293                rgba_data,
294                TexelCopyBufferLayout {
295                    offset: 0,
296                    bytes_per_row: Some(4 * width),
297                    rows_per_image: Some(height),
298                },
299                Extent3d {
300                    width,
301                    height,
302                    depth_or_array_layers: 1,
303                },
304            );
305
306            return Ok(());
307        }
308
309        // Validate data size
310        let expected_size = (width * height * 4) as usize;
311        if rgba_data.len() != expected_size {
312            return Err(RenderError::InvalidTextureData {
313                expected: expected_size,
314                actual: rgba_data.len(),
315            });
316        }
317
318        // Evict least-recently-used texture if cache is full
319        if self.texture_cache.len() >= MAX_TEXTURE_CACHE_SIZE
320            && let Some((&lru_id, _)) = self
321                .texture_cache
322                .iter()
323                .min_by_key(|(_, cached)| cached.last_used)
324        {
325            log::debug!(
326                "[GRAPHICS] Evicting LRU texture: id={}, cache_size={}",
327                lru_id,
328                self.texture_cache.len()
329            );
330            self.texture_cache.remove(&lru_id);
331        }
332
333        // Create texture
334        let texture = device.create_texture(&TextureDescriptor {
335            label: Some(&format!("Sixel Texture {}", id)),
336            size: Extent3d {
337                width,
338                height,
339                depth_or_array_layers: 1,
340            },
341            mip_level_count: 1,
342            sample_count: 1,
343            dimension: TextureDimension::D2,
344            format: TextureFormat::Rgba8Unorm,
345            usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
346            view_formats: &[],
347        });
348
349        // Write RGBA data to texture
350        queue.write_texture(
351            TexelCopyTextureInfo {
352                texture: &texture,
353                mip_level: 0,
354                origin: Origin3d::ZERO,
355                aspect: TextureAspect::All,
356            },
357            rgba_data,
358            TexelCopyBufferLayout {
359                offset: 0,
360                bytes_per_row: Some(4 * width),
361                rows_per_image: Some(height),
362            },
363            Extent3d {
364                width,
365                height,
366                depth_or_array_layers: 1,
367            },
368        );
369
370        let view = texture.create_view(&TextureViewDescriptor::default());
371
372        // Create bind group for this texture
373        let bind_group = device.create_bind_group(&BindGroupDescriptor {
374            label: Some(&format!("Sixel Bind Group {}", id)),
375            layout: &self.bind_group_layout,
376            entries: &[
377                BindGroupEntry {
378                    binding: 0,
379                    resource: BindingResource::TextureView(&view),
380                },
381                BindGroupEntry {
382                    binding: 1,
383                    resource: BindingResource::Sampler(&self.sampler),
384                },
385            ],
386        });
387
388        // Cache texture info with current timestamp
389        self.texture_cache.insert(
390            id,
391            CachedTexture {
392                texture: SixelTextureInfo {
393                    texture,
394                    view,
395                    bind_group,
396                    width,
397                    height,
398                },
399                last_used: Instant::now(),
400            },
401        );
402
403        log::debug!(
404            "[GRAPHICS] Created sixel texture: id={}, size={}x{}, cache_size={}/{}",
405            id,
406            width,
407            height,
408            self.texture_cache.len(),
409            MAX_TEXTURE_CACHE_SIZE
410        );
411
412        Ok(())
413    }
414
415    /// Render sixel graphics
416    ///
417    /// # Arguments
418    /// * `device` - WGPU device for creating buffers
419    /// * `queue` - WGPU queue for writing buffer data
420    /// * `render_pass` - Active render pass to render into
421    /// * `graphics` - Slice of [`GraphicRenderInfo`] describing each graphic's position and dimensions
422    /// * `window_width` - Window width in pixels
423    /// * `window_height` - Window height in pixels
424    pub fn render(
425        &mut self,
426        device: &Device,
427        queue: &Queue,
428        render_pass: &mut RenderPass,
429        graphics: &[GraphicRenderInfo],
430        window_width: f32,
431        window_height: f32,
432    ) -> Result<(), RenderError> {
433        if graphics.is_empty() {
434            return Ok(());
435        }
436
437        // Build instance data
438        let mut instances = Vec::with_capacity(graphics.len());
439        for g in graphics {
440            let (id, row, col, _width_cells, _height_cells, alpha, scroll_offset_rows) = (
441                g.id,
442                g.screen_row,
443                g.col,
444                g.width_cells,
445                g.height_cells,
446                g.alpha,
447                g.scroll_offset_rows,
448            );
449            // Check if texture exists and update LRU timestamp
450            if let Some(cached) = self.texture_cache.get_mut(&id) {
451                cached.last_used = Instant::now();
452                let tex_info = &cached.texture;
453
454                // Calculate screen position (normalized 0-1, origin top-left)
455                // When scroll_offset_rows > 0, the image is partially scrolled off the top.
456                // Advance the y position by scroll_offset_rows so the visible portion
457                // starts at the correct screen row instead of above the viewport.
458                let adjusted_row = row + scroll_offset_rows as isize;
459                let x =
460                    (self.window_padding + self.content_offset_x + col as f32 * self.cell_width)
461                        / window_width;
462                let y = (self.window_padding
463                    + self.content_offset_y
464                    + adjusted_row as f32 * self.cell_height)
465                    / window_height;
466
467                // Calculate texture V offset for scrolled graphics
468                // scroll_offset_rows = terminal rows scrolled off top
469                // Each terminal row = cell_height pixels
470                let tex_v_start = if scroll_offset_rows > 0 && tex_info.height > 0 {
471                    let pixels_scrolled = scroll_offset_rows as f32 * self.cell_height;
472                    (pixels_scrolled / tex_info.height as f32).min(0.99)
473                } else {
474                    0.0
475                };
476                let tex_v_height = 1.0 - tex_v_start;
477
478                // Calculate display size based on aspect ratio preservation setting.
479                //
480                // Kitty TGP virtual placements (high-bit flag on the id) are
481                // anchored to a *cell extent* (`c × r` in the a=p command), and
482                // the cell-grid scan in renderer/graphics.rs::scan_placeholder_cells
483                // already records that extent in `width_cells/height_cells`. The
484                // backing texture is the originally-transmitted image at its
485                // native pixel size, which may not match the placement footprint
486                // (e.g. a 400×400 image placed in a 40×20 cell area on a
487                // 10×20-px-cell terminal happens to match exactly, but a 600×450
488                // image with c=20,r=10 should still draw inside 200×200 cells,
489                // not at 600×450 pixels).
490                //
491                // For virtual placements, always size by the cell extent so the
492                // image stays inside its placement footprint; aspect ratio is
493                // the placement author's responsibility (they pre-scale to the
494                // cell area before transmission). For all other graphics, keep
495                // the existing texture-pixel-size behavior.
496                const VIRTUAL_PLACEMENT_ID_FLAG: u64 = 1u64 << 63;
497                let is_virtual_placement = id & VIRTUAL_PLACEMENT_ID_FLAG != 0;
498                let (width, height) = if self.preserve_aspect_ratio && !is_virtual_placement {
499                    // Use actual texture pixel dimensions to preserve aspect ratio
500                    // Rather than converting pixels→cells→pixels (which distorts non-square cells)
501                    let visible_height_pixels = if scroll_offset_rows > 0 {
502                        (tex_info.height as f32 * tex_v_height).max(1.0)
503                    } else {
504                        tex_info.height as f32
505                    };
506                    (
507                        tex_info.width as f32 / window_width,
508                        visible_height_pixels / window_height,
509                    )
510                } else {
511                    // Stretch to fill cell grid (ignore image aspect ratio)
512                    let cell_w = _width_cells as f32 * self.cell_width / window_width;
513                    let visible_cell_rows = if scroll_offset_rows > 0 {
514                        (_height_cells as f32 * tex_v_height).max(0.0)
515                    } else {
516                        _height_cells as f32
517                    };
518                    let cell_h = visible_cell_rows * self.cell_height / window_height;
519                    (cell_w, cell_h)
520                };
521
522                instances.push(SixelInstance {
523                    position: [x, y],
524                    tex_coords: [0.0, tex_v_start, 1.0, tex_v_height], // Crop from top
525                    size: [width, height],
526                    alpha,
527                    _padding: 0.0,
528                });
529            }
530        }
531
532        if instances.is_empty() {
533            return Ok(());
534        }
535
536        // Debug: log sixel rendering
537        log::debug!(
538            "[GRAPHICS] Rendering {} sixel graphics (from {} total graphics provided)",
539            instances.len(),
540            graphics.len()
541        );
542
543        // Resize instance buffer if needed
544        let required_capacity = instances.len();
545        if required_capacity > self.instance_capacity {
546            let new_capacity = (required_capacity * 2).max(32);
547            self.instance_buffer = device.create_buffer(&BufferDescriptor {
548                label: Some("Sixel Instance Buffer"),
549                size: (new_capacity * std::mem::size_of::<SixelInstance>()) as u64,
550                usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
551                mapped_at_creation: false,
552            });
553            self.instance_capacity = new_capacity;
554        }
555
556        // Write instance data to buffer
557        queue.write_buffer(&self.instance_buffer, 0, bytemuck::cast_slice(&instances));
558
559        // Set pipeline
560        render_pass.set_pipeline(&self.pipeline);
561
562        // Render each graphic with its specific bind group
563        render_pass.set_vertex_buffer(0, self.instance_buffer.slice(..));
564
565        // Use separate counter for instance index since we filtered out graphics without textures
566        let mut instance_idx = 0u32;
567        for g in graphics {
568            if let Some(cached) = self.texture_cache.get(&g.id) {
569                render_pass.set_bind_group(0, &cached.texture.bind_group, &[]);
570                render_pass.draw(0..4, instance_idx..(instance_idx + 1));
571                instance_idx += 1;
572            }
573        }
574
575        Ok(())
576    }
577
578    /// Render sixel graphics for a specific pane using explicit origin coordinates.
579    ///
580    /// Identical to [`render`] but uses `pane_origin_x`/`pane_origin_y` for positioning
581    /// instead of the global `window_padding + content_offset` values, so graphics are
582    /// placed relative to the pane rather than the full window.
583    ///
584    /// # Arguments
585    /// * `device` - WGPU device for creating buffers
586    /// * `queue` - WGPU queue for writing buffer data
587    /// * `render_pass` - Active render pass to render into
588    /// * `graphics` - Slice of [`GraphicRenderInfo`] describing each graphic's position and dimensions
589    /// * `window_width` - Window width in pixels
590    /// * `window_height` - Window height in pixels
591    /// * `pane_origin_x` - X pixel coordinate of the pane's content origin
592    /// * `pane_origin_y` - Y pixel coordinate of the pane's content origin
593    pub fn render_for_pane(
594        &mut self,
595        device: &Device,
596        queue: &Queue,
597        render_pass: &mut RenderPass,
598        graphics: &[GraphicRenderInfo],
599        pane_geometry: PaneRenderGeometry,
600    ) -> Result<(), RenderError> {
601        let PaneRenderGeometry {
602            window_width,
603            window_height,
604            pane_origin_x,
605            pane_origin_y,
606        } = pane_geometry;
607        if graphics.is_empty() {
608            return Ok(());
609        }
610
611        // Build instance data
612        let mut instances = Vec::with_capacity(graphics.len());
613        for g in graphics {
614            let (id, row, col, _width_cells, _height_cells, alpha, scroll_offset_rows) = (
615                g.id,
616                g.screen_row,
617                g.col,
618                g.width_cells,
619                g.height_cells,
620                g.alpha,
621                g.scroll_offset_rows,
622            );
623            // Check if texture exists and update LRU timestamp
624            if let Some(cached) = self.texture_cache.get_mut(&id) {
625                cached.last_used = Instant::now();
626                let tex_info = &cached.texture;
627
628                // Calculate screen position using the pane's content origin.
629                let adjusted_row = row + scroll_offset_rows as isize;
630                let x = (pane_origin_x + col as f32 * self.cell_width) / window_width;
631                let y = (pane_origin_y + adjusted_row as f32 * self.cell_height) / window_height;
632
633                // Calculate texture V offset for scrolled graphics
634                let tex_v_start = if scroll_offset_rows > 0 && tex_info.height > 0 {
635                    let pixels_scrolled = scroll_offset_rows as f32 * self.cell_height;
636                    (pixels_scrolled / tex_info.height as f32).min(0.99)
637                } else {
638                    0.0
639                };
640                let tex_v_height = 1.0 - tex_v_start;
641
642                // Calculate display size based on aspect ratio preservation setting.
643                // Virtual placements (high-bit flag on id) are sized by their cell
644                // extent, not by the backing texture's pixel dimensions — see the
645                // matching block in `render_graphics` for the full rationale.
646                const VIRTUAL_PLACEMENT_ID_FLAG: u64 = 1u64 << 63;
647                let is_virtual_placement = id & VIRTUAL_PLACEMENT_ID_FLAG != 0;
648                let (width, height) = if self.preserve_aspect_ratio && !is_virtual_placement {
649                    let visible_height_pixels = if scroll_offset_rows > 0 {
650                        (tex_info.height as f32 * tex_v_height).max(1.0)
651                    } else {
652                        tex_info.height as f32
653                    };
654                    (
655                        tex_info.width as f32 / window_width,
656                        visible_height_pixels / window_height,
657                    )
658                } else {
659                    let cell_w = _width_cells as f32 * self.cell_width / window_width;
660                    let visible_cell_rows = if scroll_offset_rows > 0 {
661                        (_height_cells as f32 * tex_v_height).max(0.0)
662                    } else {
663                        _height_cells as f32
664                    };
665                    let cell_h = visible_cell_rows * self.cell_height / window_height;
666                    (cell_w, cell_h)
667                };
668
669                instances.push(SixelInstance {
670                    position: [x, y],
671                    tex_coords: [0.0, tex_v_start, 1.0, tex_v_height],
672                    size: [width, height],
673                    alpha,
674                    _padding: 0.0,
675                });
676            }
677        }
678
679        if instances.is_empty() {
680            return Ok(());
681        }
682
683        // Resize instance buffer if needed
684        let required_capacity = instances.len();
685        if required_capacity > self.instance_capacity {
686            let new_capacity = (required_capacity * 2).max(32);
687            self.instance_buffer = device.create_buffer(&BufferDescriptor {
688                label: Some("Sixel Instance Buffer"),
689                size: (new_capacity * std::mem::size_of::<SixelInstance>()) as u64,
690                usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
691                mapped_at_creation: false,
692            });
693            self.instance_capacity = new_capacity;
694        }
695
696        // Write instance data to buffer
697        queue.write_buffer(&self.instance_buffer, 0, bytemuck::cast_slice(&instances));
698
699        // Set pipeline
700        render_pass.set_pipeline(&self.pipeline);
701        render_pass.set_vertex_buffer(0, self.instance_buffer.slice(..));
702
703        let mut instance_idx = 0u32;
704        for g in graphics {
705            if let Some(cached) = self.texture_cache.get(&g.id) {
706                render_pass.set_bind_group(0, &cached.texture.bind_group, &[]);
707                render_pass.draw(0..4, instance_idx..(instance_idx + 1));
708                instance_idx += 1;
709            }
710        }
711
712        Ok(())
713    }
714
715    /// Remove a texture from the cache
716    pub fn remove_texture(&mut self, id: u64) {
717        self.texture_cache.remove(&id);
718    }
719
720    /// Clear all cached textures
721    pub fn clear_cache(&mut self) {
722        self.texture_cache.clear();
723    }
724
725    /// Get the number of cached textures
726    pub fn cache_size(&self) -> usize {
727        self.texture_cache.len()
728    }
729
730    /// Update cell dimensions (called when window is resized)
731    pub fn update_cell_dimensions(
732        &mut self,
733        cell_width: f32,
734        cell_height: f32,
735        window_padding: f32,
736    ) {
737        self.cell_width = cell_width;
738        self.cell_height = cell_height;
739        self.window_padding = window_padding;
740    }
741
742    /// Set vertical content offset (e.g., tab bar height)
743    pub fn set_content_offset_y(&mut self, offset: f32) {
744        self.content_offset_y = offset;
745    }
746
747    /// Set horizontal content offset (e.g., tab bar on left)
748    pub fn set_content_offset_x(&mut self, offset: f32) {
749        self.content_offset_x = offset;
750    }
751
752    /// Update the global aspect ratio preservation setting.
753    pub fn set_preserve_aspect_ratio(&mut self, preserve: bool) {
754        self.preserve_aspect_ratio = preserve;
755    }
756
757    /// Update the texture scaling mode (nearest vs linear filtering).
758    ///
759    /// This recreates the sampler and invalidates all cached textures
760    /// since their bind groups reference the old sampler.
761    pub fn update_scaling_mode(&mut self, device: &Device, scaling_mode: ImageScalingMode) {
762        self.sampler = gpu_utils::create_sampler_with_filter(
763            device,
764            scaling_mode.to_filter_mode(),
765            Some("Sixel Sampler"),
766        );
767        // Clear texture cache since bind groups reference the old sampler
768        self.texture_cache.clear();
769    }
770}