Skip to main content

par_term_render/cell_renderer/
background.rs

1// ARC-009 TODO: When this file exceeds the 800-line limit, extract into
2// cell_renderer/ siblings:
3//
4//   bg_image_pipeline.rs — Background-image texture loading and wgpu pipeline setup
5//   bg_color_pipeline.rs — Solid-color background quad pipeline
6//
7// Tracking: Issue ARC-009 in AUDIT.md.
8
9use super::CellRenderer;
10use crate::custom_shader_renderer::textures::ChannelTexture;
11use crate::error::RenderError;
12use par_term_config::color_u8_to_f32;
13use std::collections::HashMap;
14
15/// Parameters for preparing a per-pane background GPU bind group.
16pub(crate) struct PaneBgBindGroupParams {
17    pub pane_x: f32,
18    pub pane_y: f32,
19    pub pane_width: f32,
20    pub pane_height: f32,
21    pub mode: par_term_config::BackgroundImageMode,
22    pub opacity: f32,
23    pub darken: f32,
24}
25
26/// Cached GPU texture for a per-pane background image
27pub(crate) struct PaneBackgroundEntry {
28    #[allow(dead_code)] // GPU lifetime: must outlive the TextureView created from it
29    pub(crate) texture: wgpu::Texture,
30    pub(crate) view: wgpu::TextureView,
31    pub(crate) sampler: wgpu::Sampler,
32    pub(crate) width: u32,
33    pub(crate) height: u32,
34}
35
36/// Cached per-pane uniform buffer and bind group for background rendering.
37///
38/// The uniform buffer is reused across frames via `queue.write_buffer()`.
39/// The bind group is recreated only when the pane's image path changes, since it
40/// is the only part of the entry that references the texture.
41pub(crate) struct PaneBgUniformEntry {
42    /// Image path this entry's bind group was built against.
43    pub(crate) path: String,
44    pub(crate) uniform_buffer: wgpu::Buffer,
45    pub(crate) bind_group: wgpu::BindGroup,
46}
47
48/// Background image/solid-color texture state and per-pane cache.
49pub(crate) struct BackgroundImageState {
50    pub(crate) bg_image_texture: Option<wgpu::Texture>,
51    pub(crate) bg_image_mode: par_term_config::BackgroundImageMode,
52    pub(crate) bg_image_opacity: f32,
53    pub(crate) bg_image_width: u32,
54    pub(crate) bg_image_height: u32,
55    /// When true, current background is a solid color (not an image).
56    /// Solid colors should be rendered via clear color to respect window_opacity,
57    /// not via bg_image_pipeline which would cover the transparent background.
58    pub(crate) bg_is_solid_color: bool,
59    /// The solid background color [R, G, B] as floats (0.0-1.0).
60    /// Only used when bg_is_solid_color is true.
61    pub(crate) solid_bg_color: [f32; 3],
62    /// Cache of per-pane background textures keyed by image path
63    pub(crate) pane_bg_cache: HashMap<String, PaneBackgroundEntry>,
64    /// Cache of per-pane uniform buffers and bind groups keyed by **pane index**.
65    /// Reused across frames via `queue.write_buffer()` to avoid per-frame GPU allocations.
66    ///
67    /// ARC-004: this was keyed by image path, but the uniform carries the pane's
68    /// position and size, so two panes sharing one background image aliased onto a
69    /// single buffer — previously masked by the `queue.submit` between panes, and
70    /// fatal once they are batched. The entry records its path so the bind group
71    /// can be rebuilt when a pane's image changes.
72    pub(crate) pane_bg_uniform_cache: HashMap<usize, PaneBgUniformEntry>,
73}
74
75impl CellRenderer {
76    pub(crate) fn load_background_image(&mut self, path: &str) -> Result<(), RenderError> {
77        log::info!("Loading background image from: {}", path);
78        let img = image::open(path)
79            .map_err(|e| {
80                log::error!("Failed to open background image '{}': {}", path, e);
81                RenderError::ImageLoad {
82                    path: path.to_string(),
83                    source: e,
84                }
85            })?
86            .to_rgba8();
87        log::info!("Background image loaded: {}x{}", img.width(), img.height());
88        let (width, height) = img.dimensions();
89        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
90            label: Some("bg image"),
91            size: wgpu::Extent3d {
92                width,
93                height,
94                depth_or_array_layers: 1,
95            },
96            mip_level_count: 1,
97            sample_count: 1,
98            dimension: wgpu::TextureDimension::D2,
99            format: wgpu::TextureFormat::Rgba8UnormSrgb,
100            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
101            view_formats: &[],
102        });
103        self.queue.write_texture(
104            wgpu::TexelCopyTextureInfo {
105                texture: &texture,
106                mip_level: 0,
107                origin: wgpu::Origin3d::ZERO,
108                aspect: wgpu::TextureAspect::All,
109            },
110            &img,
111            wgpu::TexelCopyBufferLayout {
112                offset: 0,
113                bytes_per_row: Some(4 * width),
114                rows_per_image: Some(height),
115            },
116            wgpu::Extent3d {
117                width,
118                height,
119                depth_or_array_layers: 1,
120            },
121        );
122
123        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
124        let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
125            mag_filter: wgpu::FilterMode::Linear,
126            min_filter: wgpu::FilterMode::Linear,
127            ..Default::default()
128        });
129
130        self.pipelines.bg_image_bind_group =
131            Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor {
132                label: Some("bg image bind group"),
133                layout: &self.pipelines.bg_image_bind_group_layout,
134                entries: &[
135                    wgpu::BindGroupEntry {
136                        binding: 0,
137                        resource: wgpu::BindingResource::TextureView(&view),
138                    },
139                    wgpu::BindGroupEntry {
140                        binding: 1,
141                        resource: wgpu::BindingResource::Sampler(&sampler),
142                    },
143                    wgpu::BindGroupEntry {
144                        binding: 2,
145                        resource: self.buffers.bg_image_uniform_buffer.as_entire_binding(),
146                    },
147                ],
148            }));
149        self.bg_state.bg_image_texture = Some(texture);
150        self.bg_state.bg_image_width = width;
151        self.bg_state.bg_image_height = height;
152        self.bg_state.bg_is_solid_color = false; // This is an image, not a solid color
153        self.update_bg_image_uniforms(None);
154        Ok(())
155    }
156
157    /// Update the background image uniform buffer.
158    ///
159    /// # Arguments
160    /// * `window_opacity_override` - If `Some(v)`, use `v` as the window opacity instead of
161    ///   `self.window_opacity`. Pass `Some(1.0)` when rendering to an intermediate texture
162    ///   so that window-level opacity is applied later by the shader wrapper, avoiding any
163    ///   need to temporarily mutate `self.window_opacity`.
164    pub(crate) fn update_bg_image_uniforms(&mut self, window_opacity_override: Option<f32>) {
165        // Shader uniform struct layout (48 bytes):
166        //   image_size: vec2<f32>    @ offset 0  (8 bytes)
167        //   window_size: vec2<f32>   @ offset 8  (8 bytes)
168        //   mode: u32                @ offset 16 (4 bytes)
169        //   opacity: f32             @ offset 20 (4 bytes)
170        //   pane_offset: vec2<f32>   @ offset 24 (8 bytes) - (0,0) for global
171        //   surface_size: vec2<f32>  @ offset 32 (8 bytes) - same as window_size for global
172        //   darken: f32              @ offset 40 (4 bytes) - 0.0 for global
173        let mut data = [0u8; 48];
174
175        let w = self.config.width as f32;
176        let h = self.config.height as f32;
177
178        // image_size (vec2<f32>)
179        data[0..4].copy_from_slice(&(self.bg_state.bg_image_width as f32).to_le_bytes());
180        data[4..8].copy_from_slice(&(self.bg_state.bg_image_height as f32).to_le_bytes());
181
182        // window_size (vec2<f32>)
183        data[8..12].copy_from_slice(&w.to_le_bytes());
184        data[12..16].copy_from_slice(&h.to_le_bytes());
185
186        // mode (u32)
187        data[16..20].copy_from_slice(&(self.bg_state.bg_image_mode as u32).to_le_bytes());
188
189        // opacity (f32) - combine bg_image_opacity with effective window_opacity
190        let win_opacity = window_opacity_override.unwrap_or(self.window_opacity);
191        let effective_opacity = self.bg_state.bg_image_opacity * win_opacity;
192        data[20..24].copy_from_slice(&effective_opacity.to_le_bytes());
193
194        // pane_offset (vec2<f32>) - (0,0) for global background
195        // bytes 24..32 are already zeros
196
197        // surface_size (vec2<f32>) - same as window_size for global
198        data[32..36].copy_from_slice(&w.to_le_bytes());
199        data[36..40].copy_from_slice(&h.to_le_bytes());
200
201        // darken (f32) - 0.0 for global background (no darkening)
202        // bytes 40..44 are already zeros
203
204        self.queue
205            .write_buffer(&self.buffers.bg_image_uniform_buffer, 0, &data);
206    }
207
208    pub fn set_background_image(
209        &mut self,
210        path: Option<&str>,
211        mode: par_term_config::BackgroundImageMode,
212        opacity: f32,
213    ) {
214        self.bg_state.bg_image_mode = mode;
215        self.bg_state.bg_image_opacity = opacity;
216        if let Some(p) = path {
217            log::info!("Loading background image: {}", p);
218            if let Err(e) = self.load_background_image(p) {
219                log::error!("Failed to load background image '{}': {}", p, e);
220            }
221            // Note: bg_is_solid_color is set in load_background_image
222        } else {
223            self.bg_state.bg_image_texture = None;
224            self.pipelines.bg_image_bind_group = None;
225            self.bg_state.bg_image_width = 0;
226            self.bg_state.bg_image_height = 0;
227            self.bg_state.bg_is_solid_color = false;
228        }
229        self.update_bg_image_uniforms(None);
230    }
231
232    pub fn update_background_image_opacity(&mut self, opacity: f32) {
233        self.bg_state.bg_image_opacity = opacity;
234        self.update_bg_image_uniforms(None);
235    }
236
237    pub fn update_background_image_opacity_only(&mut self, opacity: f32) {
238        self.bg_state.bg_image_opacity = opacity;
239        self.update_bg_image_uniforms(None);
240    }
241
242    /// Create a ChannelTexture from the current background image for use in custom shaders.
243    ///
244    /// Returns None if no background image is loaded.
245    /// The returned ChannelTexture shares the same underlying texture data with the
246    /// cell renderer's background image - no copy is made.
247    pub fn get_background_as_channel_texture(&self) -> Option<ChannelTexture> {
248        let texture = self.bg_state.bg_image_texture.as_ref()?;
249
250        // Create a new view and sampler for use by the custom shader
251        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
252        let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
253            mag_filter: wgpu::FilterMode::Linear,
254            min_filter: wgpu::FilterMode::Linear,
255            address_mode_u: wgpu::AddressMode::Repeat,
256            address_mode_v: wgpu::AddressMode::Repeat,
257            address_mode_w: wgpu::AddressMode::Repeat,
258            ..Default::default()
259        });
260
261        Some(ChannelTexture::from_view(
262            view,
263            sampler,
264            self.bg_state.bg_image_width,
265            self.bg_state.bg_image_height,
266        ))
267    }
268
269    /// Check if a background image is currently loaded.
270    pub fn has_background_image(&self) -> bool {
271        self.bg_state.bg_image_texture.is_some()
272    }
273
274    /// Check if a solid color background is currently set.
275    pub fn is_solid_color_background(&self) -> bool {
276        self.bg_state.bg_is_solid_color
277    }
278
279    /// Get the solid background color as normalized RGB values.
280    /// Returns the color even if not in solid color mode.
281    pub fn solid_background_color(&self) -> [f32; 3] {
282        self.bg_state.solid_bg_color
283    }
284
285    /// Get the solid background color as a wgpu::Color with window_opacity applied.
286    /// Returns None if not in solid color mode.
287    pub fn get_solid_color_as_clear(&self) -> Option<wgpu::Color> {
288        if self.bg_state.bg_is_solid_color {
289            Some(wgpu::Color {
290                r: self.bg_state.solid_bg_color[0] as f64 * self.window_opacity as f64,
291                g: self.bg_state.solid_bg_color[1] as f64 * self.window_opacity as f64,
292                b: self.bg_state.solid_bg_color[2] as f64 * self.window_opacity as f64,
293                a: self.window_opacity as f64,
294            })
295        } else {
296            None
297        }
298    }
299
300    /// Create a solid color texture for use as background.
301    ///
302    /// Creates a small (4x4) texture filled with the specified color.
303    /// Uses Stretch mode for solid colors to fill the entire window.
304    /// Transparency is controlled by window_opacity, not the texture alpha.
305    pub fn create_solid_color_texture(&mut self, color: [u8; 3]) {
306        let norm = color_u8_to_f32(color);
307        log::info!(
308            "[BACKGROUND] create_solid_color_texture: RGB({}, {}, {}) -> normalized ({:.3}, {:.3}, {:.3})",
309            color[0],
310            color[1],
311            color[2],
312            norm[0],
313            norm[1],
314            norm[2]
315        );
316        let size = 4u32; // 4x4 for proper linear filtering
317        let mut pixels = Vec::with_capacity((size * size * 4) as usize);
318        for _ in 0..(size * size) {
319            pixels.push(color[0]);
320            pixels.push(color[1]);
321            pixels.push(color[2]);
322            pixels.push(255); // Fully opaque - window_opacity controls transparency
323        }
324
325        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
326            label: Some("bg solid color"),
327            size: wgpu::Extent3d {
328                width: size,
329                height: size,
330                depth_or_array_layers: 1,
331            },
332            mip_level_count: 1,
333            sample_count: 1,
334            dimension: wgpu::TextureDimension::D2,
335            format: wgpu::TextureFormat::Rgba8UnormSrgb,
336            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
337            view_formats: &[],
338        });
339
340        self.queue.write_texture(
341            wgpu::TexelCopyTextureInfo {
342                texture: &texture,
343                mip_level: 0,
344                origin: wgpu::Origin3d::ZERO,
345                aspect: wgpu::TextureAspect::All,
346            },
347            &pixels,
348            wgpu::TexelCopyBufferLayout {
349                offset: 0,
350                bytes_per_row: Some(4 * size),
351                rows_per_image: Some(size),
352            },
353            wgpu::Extent3d {
354                width: size,
355                height: size,
356                depth_or_array_layers: 1,
357            },
358        );
359
360        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
361        let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
362            mag_filter: wgpu::FilterMode::Linear,
363            min_filter: wgpu::FilterMode::Linear,
364            ..Default::default()
365        });
366
367        self.pipelines.bg_image_bind_group =
368            Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor {
369                label: Some("bg solid color bind group"),
370                layout: &self.pipelines.bg_image_bind_group_layout,
371                entries: &[
372                    wgpu::BindGroupEntry {
373                        binding: 0,
374                        resource: wgpu::BindingResource::TextureView(&view),
375                    },
376                    wgpu::BindGroupEntry {
377                        binding: 1,
378                        resource: wgpu::BindingResource::Sampler(&sampler),
379                    },
380                    wgpu::BindGroupEntry {
381                        binding: 2,
382                        resource: self.buffers.bg_image_uniform_buffer.as_entire_binding(),
383                    },
384                ],
385            }));
386
387        self.bg_state.bg_image_texture = Some(texture);
388        self.bg_state.bg_image_width = size;
389        self.bg_state.bg_image_height = size;
390        // Use Stretch mode for solid colors to fill the window
391        self.bg_state.bg_image_mode = par_term_config::BackgroundImageMode::Stretch;
392        // Use 1.0 as base opacity - window_opacity is applied in update_bg_image_uniforms()
393        self.bg_state.bg_image_opacity = 1.0;
394        // Mark this as a solid color for tracking purposes
395        self.bg_state.bg_is_solid_color = true;
396        self.bg_state.solid_bg_color = color_u8_to_f32(color);
397        self.update_bg_image_uniforms(None);
398    }
399
400    /// Create a ChannelTexture from a solid color for shader iChannel0.
401    ///
402    /// Creates a small texture with the specified color that can be used
403    /// as a channel texture in custom shaders. The texture is fully opaque;
404    /// window_opacity controls overall transparency.
405    pub fn get_solid_color_as_channel_texture(&self, color: [u8; 3]) -> ChannelTexture {
406        log::info!(
407            "get_solid_color_as_channel_texture: RGB({},{},{})",
408            color[0],
409            color[1],
410            color[2]
411        );
412        let size = 4u32;
413        let mut pixels = Vec::with_capacity((size * size * 4) as usize);
414        for _ in 0..(size * size) {
415            pixels.push(color[0]);
416            pixels.push(color[1]);
417            pixels.push(color[2]);
418            pixels.push(255); // Fully opaque
419        }
420
421        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
422            label: Some("solid color channel texture"),
423            size: wgpu::Extent3d {
424                width: size,
425                height: size,
426                depth_or_array_layers: 1,
427            },
428            mip_level_count: 1,
429            sample_count: 1,
430            dimension: wgpu::TextureDimension::D2,
431            format: wgpu::TextureFormat::Rgba8UnormSrgb,
432            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
433            view_formats: &[],
434        });
435
436        self.queue.write_texture(
437            wgpu::TexelCopyTextureInfo {
438                texture: &texture,
439                mip_level: 0,
440                origin: wgpu::Origin3d::ZERO,
441                aspect: wgpu::TextureAspect::All,
442            },
443            &pixels,
444            wgpu::TexelCopyBufferLayout {
445                offset: 0,
446                bytes_per_row: Some(4 * size),
447                rows_per_image: Some(size),
448            },
449            wgpu::Extent3d {
450                width: size,
451                height: size,
452                depth_or_array_layers: 1,
453            },
454        );
455
456        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
457        let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
458            mag_filter: wgpu::FilterMode::Linear,
459            min_filter: wgpu::FilterMode::Linear,
460            address_mode_u: wgpu::AddressMode::Repeat,
461            address_mode_v: wgpu::AddressMode::Repeat,
462            address_mode_w: wgpu::AddressMode::Repeat,
463            ..Default::default()
464        });
465
466        ChannelTexture::from_view_and_texture(view, sampler, size, size, texture)
467    }
468
469    /// Set background based on mode (Default, Color, or Image).
470    ///
471    /// This unified method handles all background types and should be used
472    /// instead of calling individual methods directly.
473    pub fn set_background(
474        &mut self,
475        mode: par_term_config::BackgroundMode,
476        color: [u8; 3],
477        image_path: Option<&str>,
478        image_mode: par_term_config::BackgroundImageMode,
479        image_opacity: f32,
480        image_enabled: bool,
481    ) {
482        log::info!(
483            "[BACKGROUND] set_background: mode={:?}, color=RGB({}, {}, {}), image_path={:?}",
484            mode,
485            color[0],
486            color[1],
487            color[2],
488            image_path
489        );
490        match mode {
491            par_term_config::BackgroundMode::Default => {
492                // Create a solid color texture from the theme background color.
493                // This ensures bg_image_pipeline renders a full-screen opaque quad,
494                // preventing macOS per-pixel alpha transparency artifacts that occur
495                // when relying solely on LoadOp::Clear for background coverage.
496                let bg_u8: [u8; 3] = [
497                    (self.background_color[0] * 255.0).round() as u8,
498                    (self.background_color[1] * 255.0).round() as u8,
499                    (self.background_color[2] * 255.0).round() as u8,
500                ];
501                self.create_solid_color_texture(bg_u8);
502                // Override: this is the theme default, not user-set solid color.
503                // Shader sync code uses bg_is_solid_color to distinguish Color vs Image mode.
504                self.bg_state.bg_is_solid_color = false;
505            }
506            par_term_config::BackgroundMode::Color => {
507                // create_solid_color_texture sets bg_is_solid_color = true
508                self.create_solid_color_texture(color);
509            }
510            par_term_config::BackgroundMode::Image => {
511                if image_enabled {
512                    // set_background_image sets bg_is_solid_color = false
513                    self.set_background_image(image_path, image_mode, image_opacity);
514                } else {
515                    // Image disabled - clear texture
516                    self.bg_state.bg_image_texture = None;
517                    self.pipelines.bg_image_bind_group = None;
518                    self.bg_state.bg_image_width = 0;
519                    self.bg_state.bg_image_height = 0;
520                    self.bg_state.bg_is_solid_color = false;
521                }
522            }
523        }
524    }
525
526    /// Load a per-pane background image into the texture cache.
527    /// Returns Ok(true) if the image was newly loaded, Ok(false) if already cached.
528    pub(crate) fn load_pane_background(&mut self, path: &str) -> Result<bool, RenderError> {
529        if self.bg_state.pane_bg_cache.contains_key(path) {
530            return Ok(false);
531        }
532
533        // Expand tilde in path (e.g., ~/images/bg.png -> /home/user/images/bg.png)
534        let expanded = if let Some(rest) = path.strip_prefix("~/") {
535            if let Some(home) = dirs::home_dir() {
536                home.join(rest).to_string_lossy().to_string()
537            } else {
538                path.to_string()
539            }
540        } else {
541            path.to_string()
542        };
543
544        log::info!("Loading per-pane background image: {}", expanded);
545        let img = image::open(&expanded)
546            .map_err(|e| {
547                log::error!("Failed to open pane background image '{}': {}", path, e);
548                RenderError::ImageLoad {
549                    path: expanded.clone(),
550                    source: e,
551                }
552            })?
553            .to_rgba8();
554
555        let (width, height) = img.dimensions();
556        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
557            label: Some("pane bg image"),
558            size: wgpu::Extent3d {
559                width,
560                height,
561                depth_or_array_layers: 1,
562            },
563            mip_level_count: 1,
564            sample_count: 1,
565            dimension: wgpu::TextureDimension::D2,
566            format: wgpu::TextureFormat::Rgba8UnormSrgb,
567            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
568            view_formats: &[],
569        });
570
571        self.queue.write_texture(
572            wgpu::TexelCopyTextureInfo {
573                texture: &texture,
574                mip_level: 0,
575                origin: wgpu::Origin3d::ZERO,
576                aspect: wgpu::TextureAspect::All,
577            },
578            &img,
579            wgpu::TexelCopyBufferLayout {
580                offset: 0,
581                bytes_per_row: Some(4 * width),
582                rows_per_image: Some(height),
583            },
584            wgpu::Extent3d {
585                width,
586                height,
587                depth_or_array_layers: 1,
588            },
589        );
590
591        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
592        let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
593            mag_filter: wgpu::FilterMode::Linear,
594            min_filter: wgpu::FilterMode::Linear,
595            ..Default::default()
596        });
597
598        self.bg_state.pane_bg_cache.insert(
599            path.to_string(),
600            super::background::PaneBackgroundEntry {
601                texture,
602                view,
603                sampler,
604                width,
605                height,
606            },
607        );
608
609        Ok(true)
610    }
611
612    /// Prepare a per-pane background bind group and uniform buffer for one pane.
613    ///
614    /// The cache is keyed by `pane_index`, not by image path: the uniform carries
615    /// the pane's position and size, so two panes sharing one image need two
616    /// buffers (ARC-004). On the first call for a pane — and whenever its image
617    /// path changes — the buffer and bind group are allocated; otherwise only the
618    /// uniform contents are rewritten, so no GPU allocation happens per frame.
619    ///
620    /// Call this before starting the render pass, then retrieve the bind group from
621    /// `self.bg_state.pane_bg_uniform_cache.get(&pane_index)` inside the render pass.
622    ///
623    /// The texture entry must already be loaded into `bg_state.pane_bg_cache`.
624    pub(crate) fn prepare_pane_bg_bind_group(
625        &mut self,
626        pane_index: usize,
627        path: &str,
628        p: PaneBgBindGroupParams,
629    ) {
630        let PaneBgBindGroupParams {
631            pane_x,
632            pane_y,
633            pane_width,
634            pane_height,
635            mode,
636            opacity,
637            darken,
638        } = p;
639        // Look up the texture entry; do nothing if it hasn't been loaded yet.
640        let entry = match self.bg_state.pane_bg_cache.get(path) {
641            Some(e) => e,
642            None => return,
643        };
644
645        // Shader uniform struct layout (48 bytes):
646        //   image_size: vec2<f32>    @ offset 0  (8 bytes)
647        //   window_size: vec2<f32>   @ offset 8  (8 bytes) - pane dimensions
648        //   mode: u32                @ offset 16 (4 bytes)
649        //   opacity: f32             @ offset 20 (4 bytes)
650        //   pane_offset: vec2<f32>   @ offset 24 (8 bytes) - pane position in window
651        //   surface_size: vec2<f32>  @ offset 32 (8 bytes) - window dimensions
652        //   darken: f32              @ offset 40 (4 bytes)
653        let mut data = [0u8; 48];
654        // image_size (vec2<f32>)
655        data[0..4].copy_from_slice(&(entry.width as f32).to_le_bytes());
656        data[4..8].copy_from_slice(&(entry.height as f32).to_le_bytes());
657        // window_size (pane dimensions for UV calculation)
658        data[8..12].copy_from_slice(&pane_width.to_le_bytes());
659        data[12..16].copy_from_slice(&pane_height.to_le_bytes());
660        // mode (u32)
661        data[16..20].copy_from_slice(&(mode as u32).to_le_bytes());
662        // opacity (combine with window_opacity)
663        let effective_opacity = opacity * self.window_opacity;
664        data[20..24].copy_from_slice(&effective_opacity.to_le_bytes());
665        // pane_offset (vec2<f32>) - pane position within the window
666        data[24..28].copy_from_slice(&pane_x.to_le_bytes());
667        data[28..32].copy_from_slice(&pane_y.to_le_bytes());
668        // surface_size (vec2<f32>) - full window dimensions
669        let surface_w = self.config.width as f32;
670        let surface_h = self.config.height as f32;
671        data[32..36].copy_from_slice(&surface_w.to_le_bytes());
672        data[36..40].copy_from_slice(&surface_h.to_le_bytes());
673        // darken (f32)
674        data[40..44].copy_from_slice(&darken.to_le_bytes());
675
676        let reusable = self
677            .bg_state
678            .pane_bg_uniform_cache
679            .get(&pane_index)
680            .is_some_and(|cached| cached.path == path);
681        if reusable {
682            // Reuse existing buffer — just update its contents, no GPU allocation.
683            let cached = self
684                .bg_state
685                .pane_bg_uniform_cache
686                .get(&pane_index)
687                .expect("uniform cache entry must exist after the reuse check");
688            self.queue.write_buffer(&cached.uniform_buffer, 0, &data);
689        } else {
690            // First use for this pane, or its image changed: allocate and cache.
691            let uniform_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
692                label: Some("pane bg uniform buffer"),
693                size: 48,
694                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
695                mapped_at_creation: false,
696            });
697            self.queue.write_buffer(&uniform_buffer, 0, &data);
698
699            // Re-fetch entry after the mutable borrow of self above.
700            let entry = self
701                .bg_state
702                .pane_bg_cache
703                .get(path)
704                .expect("pane_bg_cache entry must exist — checked at top of function");
705
706            let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
707                label: Some("pane bg bind group"),
708                layout: &self.pipelines.bg_image_bind_group_layout,
709                entries: &[
710                    wgpu::BindGroupEntry {
711                        binding: 0,
712                        resource: wgpu::BindingResource::TextureView(&entry.view),
713                    },
714                    wgpu::BindGroupEntry {
715                        binding: 1,
716                        resource: wgpu::BindingResource::Sampler(&entry.sampler),
717                    },
718                    wgpu::BindGroupEntry {
719                        binding: 2,
720                        resource: uniform_buffer.as_entire_binding(),
721                    },
722                ],
723            });
724
725            self.bg_state.pane_bg_uniform_cache.insert(
726                pane_index,
727                super::background::PaneBgUniformEntry {
728                    path: path.to_string(),
729                    uniform_buffer,
730                    bind_group,
731                },
732            );
733        }
734    }
735
736    /// Evict per-pane uniform cache entries whose paths are no longer in the texture cache.
737    ///
738    /// Call this when a pane is destroyed or its background image changes, so that stale
739    /// GPU buffers are freed.
740    pub fn evict_pane_bg_uniform_cache(&mut self) {
741        let textures = &self.bg_state.pane_bg_cache;
742        self.bg_state
743            .pane_bg_uniform_cache
744            .retain(|_, entry| textures.contains_key(entry.path.as_str()));
745    }
746}