Skip to main content

par_term_render/
graphics_renderer.rs

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