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