Skip to main content

par_term_render/
graphics_renderer.rs

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