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/// Compute UV coordinates and display size for an inline graphic instance.
46///
47/// Maps the source crop rectangle to the destination cell extent, applying
48/// scroll clipping in destination space (so a scrolled row removes the same
49/// fraction of source and destination). Returns `(tex_coords, size)`.
50#[allow(clippy::too_many_arguments)]
51fn compute_graphic_geometry(
52    tex_w: f32,
53    tex_h: f32,
54    crop: [u32; 4],
55    width_cells: usize,
56    height_cells: usize,
57    cell_w: f32,
58    cell_h: f32,
59    clip_px: f32,
60    has_cols: bool,
61    has_rows: bool,
62    preserve_aspect: bool,
63    is_virtual: bool,
64    window_w: f32,
65    window_h: f32,
66) -> ([f32; 4], [f32; 2]) {
67    let has_crop = crop != [0, 0, 0, 0];
68
69    // Effective source rectangle (zero extents normalize to texture edges).
70    let (sx, sy, sw, sh) = if has_crop && tex_w > 0.0 && tex_h > 0.0 {
71        let x = (crop[0] as f32).min(tex_w);
72        let y = (crop[1] as f32).min(tex_h);
73        let w = if crop[2] > 0 {
74            (crop[2] as f32).min(tex_w - x)
75        } else {
76            tex_w - x
77        };
78        let h = if crop[3] > 0 {
79            (crop[3] as f32).min(tex_h - y)
80        } else {
81            tex_h - y
82        };
83        (x, y, w.max(0.0), h.max(0.0))
84    } else {
85        (0.0, 0.0, tex_w, tex_h)
86    };
87
88    // Un-clipped destination size in pixels, chosen per axis:
89    // - Virtual / both c+r: exact cell rectangle.
90    // - c-only: cell width × aspect-derived exact height (no cell rounding).
91    // - r-only: cell height × aspect-derived exact width.
92    // - neither: natural source crop size (or full texture when
93    //   preserve_aspect, else cell-derived fallback).
94    // Empty crop intersection (crop at the texture edge yields sw/sh=0)
95    // produces nothing to draw; return zero size immediately.
96    if sw <= 0.0 || sh <= 0.0 {
97        return ([0.0, 0.0, 0.0, 0.0], [0.0, 0.0]);
98    }
99    let aspect = sw / sh;
100    let (dest_w, dest_h) = if is_virtual || (has_cols && has_rows) {
101        (width_cells as f32 * cell_w, height_cells as f32 * cell_h)
102    } else if has_cols && !has_rows {
103        let dw = width_cells as f32 * cell_w;
104        (dw, dw / aspect)
105    } else if has_rows && !has_cols {
106        let dh = height_cells as f32 * cell_h;
107        (dh * aspect, dh)
108    } else if has_crop && sw > 0.0 && sh > 0.0 {
109        (sw, sh)
110    } else if preserve_aspect && tex_w > 0.0 && tex_h > 0.0 {
111        (tex_w, tex_h)
112    } else {
113        (width_cells as f32 * cell_w, height_cells as f32 * cell_h)
114    };
115
116    // Destination clip fraction.
117    let visible_frac = if dest_h > 0.0 {
118        ((dest_h - clip_px) / dest_h).clamp(0.0, 1.0)
119    } else {
120        0.0
121    };
122    let scrolled_frac = if dest_h > 0.0 {
123        (clip_px / dest_h).clamp(0.0, 1.0)
124    } else {
125        0.0
126    };
127
128    // UV: map scrolled/visible destination fractions onto the source rect.
129    let uv = if sw > 0.0 && sh > 0.0 && tex_w > 0.0 && tex_h > 0.0 {
130        [
131            sx / tex_w,
132            (sy + sh * scrolled_frac) / tex_h,
133            sw / tex_w,
134            (sh * visible_frac) / tex_h,
135        ]
136    } else {
137        [0.0, 0.0, 1.0, 1.0]
138    };
139
140    let size = (dest_w / window_w, dest_h * visible_frac / window_h);
141
142    (uv, size.into())
143}
144
145#[cfg(test)]
146mod geometry_tests {
147    use super::compute_graphic_geometry;
148
149    const WW: f32 = 800.0;
150    const WH: f32 = 600.0;
151    const CW: f32 = 10.0;
152    const CH: f32 = 20.0;
153
154    /// 100px source in 40px dest (r=2), scrolled 1 row (20px):
155    /// clip fraction 0.5, UV starts at pixel 50, 50px visible.
156    #[test]
157    fn no_crop_both_cells_uses_dest_fraction_for_uv() {
158        let (uv, size) = compute_graphic_geometry(
159            100.0,
160            100.0,
161            [0, 0, 0, 0],
162            10,
163            2,
164            CW,
165            CH,
166            20.0, // clip_px
167            true,
168            true, // has_cols, has_rows
169            false,
170            false, // preserve_aspect, is_virtual
171            WW,
172            WH,
173        );
174        let expected_uv_y = 50.0 / 100.0;
175        let expected_uv_h = 50.0 / 100.0;
176        assert!((uv[1] - expected_uv_y).abs() < 1e-5);
177        assert!((uv[3] - expected_uv_h).abs() < 1e-5);
178        assert!((size[1] - 20.0 / WH).abs() < 1e-5);
179    }
180
181    /// 25px source crop, no c/r, scrolled 20px: dest_h=25, 5px visible.
182    #[test]
183    fn natural_crop_without_cells_uses_crop_height_for_dest() {
184        let (uv, size) = compute_graphic_geometry(
185            100.0,
186            100.0,
187            [0, 0, 0, 25],
188            1,
189            1,
190            CW,
191            CH,
192            20.0,
193            false,
194            false,
195            false,
196            false,
197            WW,
198            WH,
199        );
200        let expected_uv_y = (0.0 + 25.0 * 0.8) / 100.0;
201        let expected_uv_h = (25.0 * 0.2) / 100.0;
202        assert!((uv[1] - expected_uv_y).abs() < 1e-5);
203        assert!((uv[3] - expected_uv_h).abs() < 1e-5);
204        assert!((size[1] - 5.0 / WH).abs() < 1e-5);
205    }
206
207    /// row=-1, Y=5: clip 15px, UV reflects 15/60 scrolled fraction.
208    #[test]
209    fn y_offset_produces_sub_row_clip() {
210        let top_px = -1.0 * CH + 5.0;
211        let clip_px = (-top_px).max(0.0);
212        assert_eq!(clip_px, 15.0);
213
214        let (uv, size) = compute_graphic_geometry(
215            100.0,
216            100.0,
217            [0, 0, 0, 0],
218            10,
219            3,
220            CW,
221            CH,
222            clip_px,
223            true,
224            true,
225            false,
226            false,
227            WW,
228            WH,
229        );
230        assert!((uv[1] - 25.0 / 100.0).abs() < 1e-5);
231        assert!((uv[3] - 75.0 / 100.0).abs() < 1e-5);
232        assert!((size[1] - 45.0 / WH).abs() < 1e-5);
233    }
234
235    /// 100×100 source, c=5 only, cell 10×20: dest_w=50, dest_h=50 (aspect 1:1).
236    #[test]
237    fn c_only_computes_exact_height_from_aspect() {
238        let (_uv, size) = compute_graphic_geometry(
239            100.0,
240            100.0,
241            [0, 0, 0, 0],
242            5,
243            3,
244            CW,
245            CH,
246            0.0,
247            true,
248            false, // has_cols only
249            false,
250            false,
251            WW,
252            WH,
253        );
254        // dest_h = 50px / 600px (aspect-derived, not cell-rounded)
255        assert!((size[0] - 50.0 / WW).abs() < 1e-5);
256        assert!((size[1] - 50.0 / WH).abs() < 1e-5);
257    }
258
259    /// 100×100 source, r=2 only, cell 10×20: dest_h=40, dest_w=40 (aspect 1:1).
260    #[test]
261    fn r_only_computes_exact_width_from_aspect() {
262        let (_uv, size) = compute_graphic_geometry(
263            100.0,
264            100.0,
265            [0, 0, 0, 0],
266            4,
267            2,
268            CW,
269            CH,
270            0.0,
271            false,
272            true, // has_rows only
273            false,
274            false,
275            WW,
276            WH,
277        );
278        assert!((size[0] - 40.0 / WW).abs() < 1e-5);
279        assert!((size[1] - 40.0 / WH).abs() < 1e-5);
280    }
281
282    /// 100×50 source (2:1), c=5, cell 10×20: dest_w=50, dest_h=25.
283    #[test]
284    fn c_only_wide_source_computes_proportional_height() {
285        let (_uv, size) = compute_graphic_geometry(
286            100.0,
287            50.0,
288            [0, 0, 0, 0],
289            5,
290            1,
291            CW,
292            CH,
293            0.0,
294            true,
295            false,
296            false,
297            false,
298            WW,
299            WH,
300        );
301        assert!((size[0] - 50.0 / WW).abs() < 1e-5);
302        assert!((size[1] - 25.0 / WH).abs() < 1e-5);
303    }
304
305    /// Crop at the texture edge (source_x=100 on 100px image) yields sw=0.
306    /// Zero-size intersection must return zero output, not a full-image
307    /// fallback or NaN from aspect division.
308    #[test]
309    fn zero_size_crop_at_edge_returns_zero_output() {
310        let (uv, size) = compute_graphic_geometry(
311            100.0,
312            100.0,
313            [100, 0, 0, 0],
314            5,
315            3,
316            CW,
317            CH,
318            0.0,
319            true,
320            false,
321            false,
322            false,
323            WW,
324            WH,
325        );
326        // Zero crop → zero UV and zero size
327        assert_eq!(uv, [0.0, 0.0, 0.0, 0.0]);
328        assert_eq!(size, [0.0, 0.0]);
329    }
330}
331
332/// Parameters describing a single inline graphic to render.
333///
334/// Passed as a slice to [`GraphicsRenderer::render`] and
335/// [`GraphicsRenderer::render_for_pane`] so that callers use named fields
336/// rather than a positional 7-element tuple.
337#[derive(Debug, Clone, Copy)]
338pub struct GraphicRenderInfo {
339    /// Unique identifier for this graphic (used to look up the cached texture)
340    pub id: u64,
341    /// Screen row at which the graphic starts (can be negative when scrolled partially off top)
342    pub screen_row: isize,
343    /// Screen column at which the graphic starts
344    pub col: usize,
345    /// Width of the graphic in terminal cells
346    pub width_cells: usize,
347    /// Height of the graphic in terminal cells
348    pub height_cells: usize,
349    /// Global alpha multiplier (0.0 = fully transparent, 1.0 = fully opaque)
350    pub alpha: f32,
351    /// Number of rows clipped from the top when the graphic is partially scrolled off-screen
352    pub scroll_offset_rows: usize,
353    /// Kitty destination pixel offsets within the first cell.
354    pub destination_offset_x: u32,
355    pub destination_offset_y: u32,
356    /// Source crop rectangle in native texture pixels: x, y, width, height.
357    pub source_crop: [u32; 4],
358    /// Whether Kitty supplied `c=` (columns) in the placement.
359    pub has_cols: bool,
360    /// Whether Kitty supplied `r=` (rows) in the placement.
361    pub has_rows: bool,
362}
363
364/// Metadata for a cached sixel texture
365struct SixelTextureInfo {
366    texture: Texture,
367    #[allow(dead_code)] // GPU lifetime: must outlive the bind_group which references this view
368    view: TextureView,
369    bind_group: BindGroup,
370    width: u32,
371    height: u32,
372}
373
374/// Cached texture wrapper with LRU tracking
375struct CachedTexture {
376    texture: SixelTextureInfo,
377    /// Timestamp of last access for LRU eviction
378    last_used: Instant,
379}
380
381/// Graphics renderer for sixel images
382pub struct GraphicsRenderer {
383    // Rendering pipeline
384    pipeline: RenderPipeline,
385    bind_group_layout: BindGroupLayout,
386    sampler: Sampler,
387
388    // Instance buffer
389    instance_buffer: Buffer,
390    instance_capacity: usize,
391
392    // Texture cache: maps sixel ID to texture info with LRU tracking
393    texture_cache: HashMap<u64, CachedTexture>,
394
395    // Cell dimensions for positioning
396    cell_width: f32,
397    cell_height: f32,
398    window_padding: f32,
399    /// Vertical offset for content (e.g., tab bar height)
400    content_offset_y: f32,
401    /// Horizontal offset for content (e.g., tab bar on left)
402    content_offset_x: f32,
403
404    /// Global config: whether to preserve aspect ratio when rendering images
405    preserve_aspect_ratio: bool,
406}
407
408impl GraphicsRenderer {
409    /// Create a new graphics renderer
410    pub fn new(
411        device: &Device,
412        surface_format: TextureFormat,
413        cell_width: f32,
414        cell_height: f32,
415        window_padding: f32,
416        scaling_mode: ImageScalingMode,
417        preserve_aspect_ratio: bool,
418    ) -> Result<Self, RenderError> {
419        // Create bind group layout for sixel textures
420        let bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
421            label: Some("Sixel Bind Group Layout"),
422            entries: &[
423                // Sixel texture
424                BindGroupLayoutEntry {
425                    binding: 0,
426                    visibility: ShaderStages::FRAGMENT,
427                    ty: BindingType::Texture {
428                        sample_type: TextureSampleType::Float { filterable: true },
429                        view_dimension: TextureViewDimension::D2,
430                        multisampled: false,
431                    },
432                    count: None,
433                },
434                // Sampler
435                BindGroupLayoutEntry {
436                    binding: 1,
437                    visibility: ShaderStages::FRAGMENT,
438                    ty: BindingType::Sampler(SamplerBindingType::Filtering),
439                    count: None,
440                },
441            ],
442        });
443
444        // Create sampler with configured filter mode
445        let sampler = gpu_utils::create_sampler_with_filter(
446            device,
447            scaling_mode.to_filter_mode(),
448            Some("Sixel Sampler"),
449        );
450
451        // Create rendering pipeline
452        let pipeline = Self::create_pipeline(device, surface_format, &bind_group_layout)?;
453
454        // Create instance buffer (initial capacity for INITIAL_GRAPHICS_INSTANCE_CAPACITY images)
455        let initial_capacity = INITIAL_GRAPHICS_INSTANCE_CAPACITY;
456        let instance_buffer = device.create_buffer(&BufferDescriptor {
457            label: Some("Sixel Instance Buffer"),
458            size: (initial_capacity * std::mem::size_of::<SixelInstance>()) as u64,
459            usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
460            mapped_at_creation: false,
461        });
462
463        Ok(Self {
464            pipeline,
465            bind_group_layout,
466            sampler,
467            instance_buffer,
468            instance_capacity: initial_capacity,
469            texture_cache: HashMap::new(),
470            cell_width,
471            cell_height,
472            window_padding,
473            content_offset_y: 0.0,
474            content_offset_x: 0.0,
475            preserve_aspect_ratio,
476        })
477    }
478
479    /// Create the sixel rendering pipeline
480    fn create_pipeline(
481        device: &Device,
482        format: TextureFormat,
483        bind_group_layout: &BindGroupLayout,
484    ) -> Result<RenderPipeline, RenderError> {
485        let shader = device.create_shader_module(ShaderModuleDescriptor {
486            label: Some("Sixel Shader"),
487            source: ShaderSource::Wgsl(include_str!("shaders/sixel.wgsl").into()),
488        });
489
490        let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
491            label: Some("Sixel Pipeline Layout"),
492            bind_group_layouts: &[Some(bind_group_layout)],
493            immediate_size: 0,
494        });
495
496        Ok(device.create_render_pipeline(&RenderPipelineDescriptor {
497            label: Some("Sixel Pipeline"),
498            layout: Some(&pipeline_layout),
499            vertex: VertexState {
500                module: &shader,
501                entry_point: Some("vs_main"),
502                buffers: &[Some(VertexBufferLayout {
503                    array_stride: std::mem::size_of::<SixelInstance>() as u64,
504                    step_mode: VertexStepMode::Instance,
505                    attributes: &vertex_attr_array![
506                        0 => Float32x2,  // position
507                        1 => Float32x4,  // tex_coords
508                        2 => Float32x2,  // size
509                        3 => Float32,    // alpha
510                    ],
511                })],
512                compilation_options: Default::default(),
513            },
514            fragment: Some(FragmentState {
515                module: &shader,
516                entry_point: Some("fs_main"),
517                targets: &[Some(ColorTargetState {
518                    format,
519                    // Use premultiplied alpha blending since shader outputs premultiplied colors
520                    blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING),
521                    write_mask: ColorWrites::ALL,
522                })],
523                compilation_options: Default::default(),
524            }),
525            primitive: PrimitiveState {
526                topology: PrimitiveTopology::TriangleStrip,
527                ..Default::default()
528            },
529            depth_stencil: None,
530            multisample: MultisampleState::default(),
531            cache: None,
532            multiview_mask: None,
533        }))
534    }
535
536    /// Create or get a cached texture for a sixel graphic
537    ///
538    /// # Arguments
539    /// * `device` - WGPU device for creating textures
540    /// * `queue` - WGPU queue for writing texture data
541    /// * `id` - Unique identifier for this sixel graphic
542    /// * `rgba_data` - RGBA pixel data (width * height * 4 bytes)
543    /// * `width` - Image width in pixels
544    /// * `height` - Image height in pixels
545    pub fn get_or_create_texture(
546        &mut self,
547        device: &Device,
548        queue: &Queue,
549        id: u64,
550        rgba_data: &[u8],
551        width: u32,
552        height: u32,
553    ) -> Result<(), RenderError> {
554        // Check if texture already exists in cache
555        // For animations, we need to update the texture data even if it exists
556        if let Some(cached) = self.texture_cache.get_mut(&id) {
557            // Update LRU timestamp on cache hit
558            cached.last_used = Instant::now();
559
560            // Kitty TGP virtual placements (high-bit flag set on the cache id;
561            // see par-term-render/src/renderer/graphics.rs) reuse the same
562            // image data every frame — they're static placements anchored by
563            // grid placeholder cells, not animations. Re-uploading the
564            // pixels per frame here costs ~640 KB × 60 fps for a 400×400
565            // image, saturating the GPU command queue and freezing the pane.
566            // For these IDs, treat the cache hit as final.
567            const VIRTUAL_PLACEMENT_ID_FLAG: u64 = 1u64 << 63;
568            if id & VIRTUAL_PLACEMENT_ID_FLAG != 0 {
569                return Ok(());
570            }
571
572            // Texture exists - update it if the data might have changed
573            // Validate data size
574            let expected_size = (width * height * 4) as usize;
575            if rgba_data.len() != expected_size {
576                return Err(RenderError::InvalidTextureData {
577                    expected: expected_size,
578                    actual: rgba_data.len(),
579                });
580            }
581
582            // Update existing texture with new pixel data (for animations)
583            queue.write_texture(
584                TexelCopyTextureInfo {
585                    texture: &cached.texture.texture,
586                    mip_level: 0,
587                    origin: Origin3d::ZERO,
588                    aspect: TextureAspect::All,
589                },
590                rgba_data,
591                TexelCopyBufferLayout {
592                    offset: 0,
593                    bytes_per_row: Some(4 * width),
594                    rows_per_image: Some(height),
595                },
596                Extent3d {
597                    width,
598                    height,
599                    depth_or_array_layers: 1,
600                },
601            );
602
603            return Ok(());
604        }
605
606        // Validate data size
607        let expected_size = (width * height * 4) as usize;
608        if rgba_data.len() != expected_size {
609            return Err(RenderError::InvalidTextureData {
610                expected: expected_size,
611                actual: rgba_data.len(),
612            });
613        }
614
615        // Evict least-recently-used texture if cache is full
616        if self.texture_cache.len() >= MAX_TEXTURE_CACHE_SIZE
617            && let Some((&lru_id, _)) = self
618                .texture_cache
619                .iter()
620                .min_by_key(|(_, cached)| cached.last_used)
621        {
622            log::debug!(
623                "[GRAPHICS] Evicting LRU texture: id={}, cache_size={}",
624                lru_id,
625                self.texture_cache.len()
626            );
627            self.texture_cache.remove(&lru_id);
628        }
629
630        // Create texture
631        let texture = device.create_texture(&TextureDescriptor {
632            label: Some(&format!("Sixel Texture {}", id)),
633            size: Extent3d {
634                width,
635                height,
636                depth_or_array_layers: 1,
637            },
638            mip_level_count: 1,
639            sample_count: 1,
640            dimension: TextureDimension::D2,
641            format: TextureFormat::Rgba8Unorm,
642            usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
643            view_formats: &[],
644        });
645
646        // Write RGBA data to texture
647        queue.write_texture(
648            TexelCopyTextureInfo {
649                texture: &texture,
650                mip_level: 0,
651                origin: Origin3d::ZERO,
652                aspect: TextureAspect::All,
653            },
654            rgba_data,
655            TexelCopyBufferLayout {
656                offset: 0,
657                bytes_per_row: Some(4 * width),
658                rows_per_image: Some(height),
659            },
660            Extent3d {
661                width,
662                height,
663                depth_or_array_layers: 1,
664            },
665        );
666
667        let view = texture.create_view(&TextureViewDescriptor::default());
668
669        // Create bind group for this texture
670        let bind_group = device.create_bind_group(&BindGroupDescriptor {
671            label: Some(&format!("Sixel Bind Group {}", id)),
672            layout: &self.bind_group_layout,
673            entries: &[
674                BindGroupEntry {
675                    binding: 0,
676                    resource: BindingResource::TextureView(&view),
677                },
678                BindGroupEntry {
679                    binding: 1,
680                    resource: BindingResource::Sampler(&self.sampler),
681                },
682            ],
683        });
684
685        // Cache texture info with current timestamp
686        self.texture_cache.insert(
687            id,
688            CachedTexture {
689                texture: SixelTextureInfo {
690                    texture,
691                    view,
692                    bind_group,
693                    width,
694                    height,
695                },
696                last_used: Instant::now(),
697            },
698        );
699
700        log::debug!(
701            "[GRAPHICS] Created sixel texture: id={}, size={}x{}, cache_size={}/{}",
702            id,
703            width,
704            height,
705            self.texture_cache.len(),
706            MAX_TEXTURE_CACHE_SIZE
707        );
708
709        Ok(())
710    }
711
712    /// Render sixel graphics
713    ///
714    /// # Arguments
715    /// * `device` - WGPU device for creating buffers
716    /// * `queue` - WGPU queue for writing buffer data
717    /// * `render_pass` - Active render pass to render into
718    /// * `graphics` - Slice of [`GraphicRenderInfo`] describing each graphic's position and dimensions
719    /// * `window_width` - Window width in pixels
720    /// * `window_height` - Window height in pixels
721    pub fn render(
722        &mut self,
723        device: &Device,
724        queue: &Queue,
725        render_pass: &mut RenderPass,
726        graphics: &[GraphicRenderInfo],
727        window_width: f32,
728        window_height: f32,
729    ) -> Result<(), RenderError> {
730        if graphics.is_empty() {
731            return Ok(());
732        }
733
734        // Build instance data
735        let mut instances = Vec::with_capacity(graphics.len());
736        for g in graphics {
737            let (
738                id,
739                row,
740                col,
741                _width_cells,
742                _height_cells,
743                alpha,
744                _scroll_offset_rows,
745                dest_off_x,
746                dest_off_y,
747                crop,
748                has_cols,
749                has_rows,
750            ) = (
751                g.id,
752                g.screen_row,
753                g.col,
754                g.width_cells,
755                g.height_cells,
756                g.alpha,
757                g.scroll_offset_rows,
758                g.destination_offset_x,
759                g.destination_offset_y,
760                g.source_crop,
761                g.has_cols,
762                g.has_rows,
763            );
764            // Check if texture exists and update LRU timestamp
765            if let Some(cached) = self.texture_cache.get_mut(&id) {
766                cached.last_used = Instant::now();
767                let tex_info = &cached.texture;
768
769                // Signed pixel-space top relative to content area. A Y
770                // offset can place the top at a non-row-aligned position,
771                // so clipping must be computed in pixels, not integer rows.
772                let top_px = row as f32 * self.cell_height + dest_off_y as f32;
773                let clip_px = (-top_px).max(0.0);
774                let draw_y_px = top_px.max(0.0);
775                let x = (self.window_padding
776                    + self.content_offset_x
777                    + col as f32 * self.cell_width
778                    + dest_off_x as f32)
779                    / window_width;
780                let y = (self.window_padding + self.content_offset_y + draw_y_px) / window_height;
781
782                const VIRTUAL_PLACEMENT_ID_FLAG: u64 = 1u64 << 63;
783                let is_virtual_placement = id & VIRTUAL_PLACEMENT_ID_FLAG != 0;
784                let (tex_coords, size) = compute_graphic_geometry(
785                    tex_info.width as f32,
786                    tex_info.height as f32,
787                    crop,
788                    _width_cells,
789                    _height_cells,
790                    self.cell_width,
791                    self.cell_height,
792                    clip_px,
793                    has_cols,
794                    has_rows,
795                    self.preserve_aspect_ratio,
796                    is_virtual_placement,
797                    window_width,
798                    window_height,
799                );
800
801                instances.push(SixelInstance {
802                    position: [x, y],
803                    tex_coords,
804                    size,
805                    alpha,
806                    _padding: 0.0,
807                });
808            }
809        }
810
811        if instances.is_empty() {
812            return Ok(());
813        }
814
815        // Debug: log sixel rendering
816        log::debug!(
817            "[GRAPHICS] Rendering {} sixel graphics (from {} total graphics provided)",
818            instances.len(),
819            graphics.len()
820        );
821
822        // Resize instance buffer if needed
823        let required_capacity = instances.len();
824        if required_capacity > self.instance_capacity {
825            let new_capacity = (required_capacity * 2).max(32);
826            self.instance_buffer = device.create_buffer(&BufferDescriptor {
827                label: Some("Sixel Instance Buffer"),
828                size: (new_capacity * std::mem::size_of::<SixelInstance>()) as u64,
829                usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
830                mapped_at_creation: false,
831            });
832            self.instance_capacity = new_capacity;
833        }
834
835        // Write instance data to buffer
836        queue.write_buffer(&self.instance_buffer, 0, bytemuck::cast_slice(&instances));
837
838        // Set pipeline
839        render_pass.set_pipeline(&self.pipeline);
840
841        // Render each graphic with its specific bind group
842        render_pass.set_vertex_buffer(0, self.instance_buffer.slice(..));
843
844        // Use separate counter for instance index since we filtered out graphics without textures
845        let mut instance_idx = 0u32;
846        for g in graphics {
847            if let Some(cached) = self.texture_cache.get(&g.id) {
848                render_pass.set_bind_group(0, &cached.texture.bind_group, &[]);
849                render_pass.draw(0..4, instance_idx..(instance_idx + 1));
850                instance_idx += 1;
851            }
852        }
853
854        Ok(())
855    }
856
857    /// Render sixel graphics for a specific pane using explicit origin coordinates.
858    ///
859    /// Identical to [`Self::render`] but uses `pane_origin_x`/`pane_origin_y` for positioning
860    /// instead of the global `window_padding + content_offset` values, so graphics are
861    /// placed relative to the pane rather than the full window.
862    ///
863    /// # Arguments
864    /// * `device` - WGPU device for creating buffers
865    /// * `queue` - WGPU queue for writing buffer data
866    /// * `render_pass` - Active render pass to render into
867    /// * `graphics` - Slice of [`GraphicRenderInfo`] describing each graphic's position and dimensions
868    /// * `window_width` - Window width in pixels
869    /// * `window_height` - Window height in pixels
870    /// * `pane_origin_x` - X pixel coordinate of the pane's content origin
871    /// * `pane_origin_y` - Y pixel coordinate of the pane's content origin
872    pub fn render_for_pane(
873        &mut self,
874        device: &Device,
875        queue: &Queue,
876        render_pass: &mut RenderPass,
877        graphics: &[GraphicRenderInfo],
878        pane_geometry: PaneRenderGeometry,
879    ) -> Result<(), RenderError> {
880        let PaneRenderGeometry {
881            window_width,
882            window_height,
883            pane_origin_x,
884            pane_origin_y,
885        } = pane_geometry;
886        if graphics.is_empty() {
887            return Ok(());
888        }
889
890        // Build instance data
891        let mut instances = Vec::with_capacity(graphics.len());
892        for g in graphics {
893            let (
894                id,
895                row,
896                col,
897                _width_cells,
898                _height_cells,
899                alpha,
900                _scroll_offset_rows,
901                dest_off_x,
902                dest_off_y,
903                crop,
904                has_cols,
905                has_rows,
906            ) = (
907                g.id,
908                g.screen_row,
909                g.col,
910                g.width_cells,
911                g.height_cells,
912                g.alpha,
913                g.scroll_offset_rows,
914                g.destination_offset_x,
915                g.destination_offset_y,
916                g.source_crop,
917                g.has_cols,
918                g.has_rows,
919            );
920            // Check if texture exists and update LRU timestamp
921            if let Some(cached) = self.texture_cache.get_mut(&id) {
922                cached.last_used = Instant::now();
923                let tex_info = &cached.texture;
924
925                let top_px = row as f32 * self.cell_height + dest_off_y as f32;
926                let clip_px = (-top_px).max(0.0);
927                let draw_y_px = top_px.max(0.0);
928                let x = (pane_origin_x + col as f32 * self.cell_width + dest_off_x as f32)
929                    / window_width;
930                let y = (pane_origin_y + draw_y_px) / window_height;
931
932                const VIRTUAL_PLACEMENT_ID_FLAG: u64 = 1u64 << 63;
933                let is_virtual_placement = id & VIRTUAL_PLACEMENT_ID_FLAG != 0;
934                let (tex_coords, size) = compute_graphic_geometry(
935                    tex_info.width as f32,
936                    tex_info.height as f32,
937                    crop,
938                    _width_cells,
939                    _height_cells,
940                    self.cell_width,
941                    self.cell_height,
942                    clip_px,
943                    has_cols,
944                    has_rows,
945                    self.preserve_aspect_ratio,
946                    is_virtual_placement,
947                    window_width,
948                    window_height,
949                );
950
951                instances.push(SixelInstance {
952                    position: [x, y],
953                    tex_coords,
954                    size,
955                    alpha,
956                    _padding: 0.0,
957                });
958            }
959        }
960
961        if instances.is_empty() {
962            return Ok(());
963        }
964
965        // Resize instance buffer if needed
966        let required_capacity = instances.len();
967        if required_capacity > self.instance_capacity {
968            let new_capacity = (required_capacity * 2).max(32);
969            self.instance_buffer = device.create_buffer(&BufferDescriptor {
970                label: Some("Sixel Instance Buffer"),
971                size: (new_capacity * std::mem::size_of::<SixelInstance>()) as u64,
972                usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
973                mapped_at_creation: false,
974            });
975            self.instance_capacity = new_capacity;
976        }
977
978        // Write instance data to buffer
979        queue.write_buffer(&self.instance_buffer, 0, bytemuck::cast_slice(&instances));
980
981        // Set pipeline
982        render_pass.set_pipeline(&self.pipeline);
983        render_pass.set_vertex_buffer(0, self.instance_buffer.slice(..));
984
985        let mut instance_idx = 0u32;
986        for g in graphics {
987            if let Some(cached) = self.texture_cache.get(&g.id) {
988                render_pass.set_bind_group(0, &cached.texture.bind_group, &[]);
989                render_pass.draw(0..4, instance_idx..(instance_idx + 1));
990                instance_idx += 1;
991            }
992        }
993
994        Ok(())
995    }
996
997    /// Remove a texture from the cache
998    pub fn remove_texture(&mut self, id: u64) {
999        self.texture_cache.remove(&id);
1000    }
1001
1002    /// Clear all cached textures
1003    pub fn clear_cache(&mut self) {
1004        self.texture_cache.clear();
1005    }
1006
1007    /// Get the number of cached textures
1008    pub fn cache_size(&self) -> usize {
1009        self.texture_cache.len()
1010    }
1011
1012    /// Update cell dimensions (called when window is resized)
1013    pub fn update_cell_dimensions(
1014        &mut self,
1015        cell_width: f32,
1016        cell_height: f32,
1017        window_padding: f32,
1018    ) {
1019        self.cell_width = cell_width;
1020        self.cell_height = cell_height;
1021        self.window_padding = window_padding;
1022    }
1023
1024    /// Set vertical content offset (e.g., tab bar height)
1025    pub fn set_content_offset_y(&mut self, offset: f32) {
1026        self.content_offset_y = offset;
1027    }
1028
1029    /// Set horizontal content offset (e.g., tab bar on left)
1030    pub fn set_content_offset_x(&mut self, offset: f32) {
1031        self.content_offset_x = offset;
1032    }
1033
1034    /// Update the global aspect ratio preservation setting.
1035    pub fn set_preserve_aspect_ratio(&mut self, preserve: bool) {
1036        self.preserve_aspect_ratio = preserve;
1037    }
1038
1039    /// Update the texture scaling mode (nearest vs linear filtering).
1040    ///
1041    /// This recreates the sampler and invalidates all cached textures
1042    /// since their bind groups reference the old sampler.
1043    pub fn update_scaling_mode(&mut self, device: &Device, scaling_mode: ImageScalingMode) {
1044        self.sampler = gpu_utils::create_sampler_with_filter(
1045            device,
1046            scaling_mode.to_filter_mode(),
1047            Some("Sixel Sampler"),
1048        );
1049        // Clear texture cache since bind groups reference the old sampler
1050        self.texture_cache.clear();
1051    }
1052}