Skip to main content

par_term_render/cell_renderer/
background.rs

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