Skip to main content

par_term_render/
graphics_renderer.rs

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