Skip to main content

par_term_render/custom_shader_renderer/
mod.rs

1//! Custom shader renderer for post-processing effects
2//!
3//! Supports Ghostty/Shadertoy-style GLSL shaders with the following uniforms:
4//! - `iTime`: Time in seconds (animated or fixed at 0.0)
5//! - `iResolution`: Viewport resolution (width, height, 1.0)
6//! - `iChannel0-3`: User texture channels (Shadertoy compatible)
7//! - `iChannel4`: Terminal content texture
8//! - `iTimeKeyPress`: Time when last key was pressed (same timebase as iTime)
9//!
10//! Ghostty-compatible cursor uniforms (v1.2.0+):
11//! - `iCurrentCursor`: Current cursor position (xy) and size (zw) in pixels
12//! - `iPreviousCursor`: Previous cursor position and size
13//! - `iCurrentCursorColor`: Current cursor RGBA color (with opacity baked in)
14//! - `iPreviousCursorColor`: Previous cursor RGBA color
15//! - `iTimeCursorChange`: Time when cursor last moved (same timebase as iTime)
16
17use anyhow::{Context, Result};
18use par_term_emu_core_rust::cursor::CursorStyle;
19use std::path::Path;
20use std::time::Instant;
21use wgpu::*;
22
23mod cubemap;
24mod cursor;
25pub mod pipeline;
26pub mod textures;
27pub mod transpiler;
28pub mod types;
29
30use cubemap::CubemapTexture;
31use pipeline::{create_bind_group, create_bind_group_layout, create_render_pipeline};
32use textures::{ChannelTexture, load_channel_textures};
33use transpiler::{transpile_glsl_to_wgsl, transpile_glsl_to_wgsl_source};
34use types::CustomShaderUniforms;
35
36/// Custom shader renderer that applies post-processing effects
37pub struct CustomShaderRenderer {
38    /// The render pipeline for the custom shader
39    pub(crate) pipeline: RenderPipeline,
40    /// Bind group for shader uniforms and textures
41    pub(crate) bind_group: BindGroup,
42    /// Uniform buffer for shader parameters
43    pub(crate) uniform_buffer: Buffer,
44    /// Intermediate texture to render terminal content into
45    pub(crate) intermediate_texture: Texture,
46    /// View of the intermediate texture
47    pub(crate) intermediate_texture_view: TextureView,
48    /// Start time for animation
49    pub(crate) start_time: Instant,
50    /// Whether animation is enabled
51    pub(crate) animation_enabled: bool,
52    /// Animation speed multiplier
53    pub(crate) animation_speed: f32,
54    /// Current texture dimensions
55    pub(crate) texture_width: u32,
56    pub(crate) texture_height: u32,
57    /// Surface format for compatibility
58    pub(crate) surface_format: TextureFormat,
59    /// Bind group layout for recreating bind groups on resize
60    pub(crate) bind_group_layout: BindGroupLayout,
61    /// Sampler for the intermediate texture
62    pub(crate) sampler: Sampler,
63    /// Display scale factor for DPI scaling (e.g., 2.0 on Retina)
64    pub(crate) scale_factor: f32,
65    /// Window opacity for transparency
66    pub(crate) window_opacity: f32,
67    /// When true, text is always rendered at full opacity (overrides text_opacity)
68    pub(crate) keep_text_opaque: bool,
69    /// Full content mode - shader receives and can manipulate full terminal content
70    pub(crate) full_content_mode: bool,
71    /// Brightness multiplier for shader output (0.05-1.0)
72    pub(crate) brightness: f32,
73    /// Frame counter for iFrame uniform
74    pub(crate) frame_count: u32,
75    /// Last frame time for calculating time delta
76    pub(crate) last_frame_time: Instant,
77    /// Current mouse position in pixels (xy)
78    pub(crate) mouse_position: [f32; 2],
79    /// Last click position in pixels (zw)
80    pub(crate) mouse_click_position: [f32; 2],
81    /// Whether mouse button is currently pressed (affects sign of zw)
82    pub(crate) mouse_button_down: bool,
83    /// Frame rate tracking: time accumulator for averaging
84    pub(crate) frame_time_accumulator: f32,
85    /// Frame rate tracking: frames in current second
86    pub(crate) frames_in_second: u32,
87    /// Current smoothed frame rate
88    pub(crate) current_frame_rate: f32,
89
90    // ============ Cursor tracking (Ghostty-compatible) ============
91    /// Current cursor position in cell coordinates (col, row)
92    pub(crate) current_cursor_pos: (usize, usize),
93    /// Previous cursor position in cell coordinates
94    pub(crate) previous_cursor_pos: (usize, usize),
95    /// Current cursor RGBA color
96    pub(crate) current_cursor_color: [f32; 4],
97    /// Previous cursor RGBA color
98    pub(crate) previous_cursor_color: [f32; 4],
99    /// Current cursor opacity (0.0 = invisible, 1.0 = fully visible)
100    pub(crate) current_cursor_opacity: f32,
101    /// Previous cursor opacity
102    pub(crate) previous_cursor_opacity: f32,
103    /// Time when cursor position last changed (same timebase as iTime)
104    pub(crate) cursor_change_time: f32,
105    /// Current cursor style (for size calculation)
106    pub(crate) current_cursor_style: CursorStyle,
107    /// Previous cursor style
108    pub(crate) previous_cursor_style: CursorStyle,
109    /// Cell width in pixels (for cursor position calculation)
110    pub(crate) cursor_cell_width: f32,
111    /// Cell height in pixels (for cursor position calculation)
112    pub(crate) cursor_cell_height: f32,
113    /// Window padding in pixels (for cursor position calculation)
114    pub(crate) cursor_window_padding: f32,
115    /// Vertical content offset in pixels (e.g., tab bar height)
116    pub(crate) cursor_content_offset_y: f32,
117    /// Horizontal content offset in pixels (e.g., tab bar on left)
118    pub(crate) cursor_content_offset_x: f32,
119
120    // ============ Cursor shader configuration ============
121    /// User-configured cursor color for shader effects [R, G, B, A]
122    pub(crate) cursor_shader_color: [f32; 4],
123    /// Cursor trail duration in seconds
124    pub(crate) cursor_trail_duration: f32,
125    /// Cursor glow radius in pixels
126    pub(crate) cursor_glow_radius: f32,
127    /// Cursor glow intensity (0.0-1.0)
128    pub(crate) cursor_glow_intensity: f32,
129
130    // ============ Key press tracking ============
131    /// Time when a key was last pressed (same timebase as iTime)
132    pub(crate) key_press_time: f32,
133
134    // ============ Channel textures (iChannel0-3) ============
135    /// Texture channels 0-3 (placeholders or loaded textures, Shadertoy compatible)
136    pub(crate) channel_textures: [ChannelTexture; 4],
137
138    // ============ Cubemap texture (iCubemap) ============
139    /// Cubemap texture for environment mapping (placeholder or loaded)
140    pub(crate) cubemap: CubemapTexture,
141
142    // ============ Background image as iChannel0 ============
143    /// When true, use the background image texture as iChannel0 instead of the configured texture
144    pub(crate) use_background_as_channel0: bool,
145    /// Background texture to use as iChannel0 when use_background_as_channel0 is true
146    /// This is a reference texture (view + sampler + dimensions) from the cell renderer
147    pub(crate) background_channel_texture: Option<ChannelTexture>,
148
149    // ============ Solid background color ============
150    /// Solid background color [R, G, B, A] for shader compositing.
151    /// When A > 0, the shader uses this color as background instead of shader output.
152    /// RGB values are NOT premultiplied.
153    pub(crate) background_color: [f32; 4],
154
155    // ============ Progress bar state ============
156    /// Progress bar data [state, percent, isActive, activeCount]
157    pub(crate) progress_data: [f32; 4],
158
159    // ============ Content inset for panels ============
160    /// Right content inset in pixels (e.g., AI Inspector panel).
161    /// The shader renders to a viewport offset by this amount from the left.
162    pub(crate) content_inset_right: f32,
163}
164
165impl CustomShaderRenderer {
166    /// Create a new custom shader renderer from a GLSL shader file
167    #[allow(clippy::too_many_arguments)]
168    pub fn new(
169        device: &Device,
170        queue: &Queue,
171        surface_format: TextureFormat,
172        shader_path: &Path,
173        width: u32,
174        height: u32,
175        animation_enabled: bool,
176        animation_speed: f32,
177        window_opacity: f32,
178        full_content_mode: bool,
179        channel_paths: &[Option<std::path::PathBuf>; 4],
180        cubemap_path: Option<&Path>,
181    ) -> Result<Self> {
182        // Load the GLSL shader
183        let glsl_source = std::fs::read_to_string(shader_path)
184            .with_context(|| format!("Failed to read shader file: {}", shader_path.display()))?;
185
186        // Transpile GLSL to WGSL
187        let wgsl_source = transpile_glsl_to_wgsl(&glsl_source, shader_path)?;
188
189        log::info!(
190            "Loaded custom shader from {} ({} bytes GLSL -> {} bytes WGSL)",
191            shader_path.display(),
192            glsl_source.len(),
193            wgsl_source.len()
194        );
195        log::debug!("Generated WGSL:\n{}", wgsl_source);
196
197        // DEBUG: Write generated WGSL to file for inspection
198        let shader_name = shader_path
199            .file_stem()
200            .and_then(|s| s.to_str())
201            .unwrap_or("unknown");
202        let debug_filename = format!("/tmp/par_term_{}_shader.wgsl", shader_name);
203        if let Err(e) = std::fs::write(&debug_filename, &wgsl_source) {
204            log::warn!("Failed to write debug shader: {}", e);
205        } else {
206            log::info!("Wrote debug shader to {}", debug_filename);
207        }
208
209        // Pre-validate WGSL
210        let module = naga::front::wgsl::parse_str(&wgsl_source)
211            .context("Custom shader WGSL parse failed")?;
212        let _info = naga::valid::Validator::new(
213            naga::valid::ValidationFlags::all(),
214            naga::valid::Capabilities::empty(),
215        )
216        .validate(&module)
217        .context("Custom shader WGSL validation failed")?;
218
219        let shader_module = device.create_shader_module(ShaderModuleDescriptor {
220            label: Some("Custom Shader Module"),
221            source: ShaderSource::Wgsl(wgsl_source.clone().into()),
222        });
223
224        // Create intermediate texture for terminal content
225        let (intermediate_texture, intermediate_texture_view) =
226            Self::create_intermediate_texture(device, surface_format, width, height);
227
228        // Create sampler for the intermediate texture (terminal content)
229        // Use Nearest filtering to keep text crisp and pixel-perfect
230        let sampler = device.create_sampler(&SamplerDescriptor {
231            label: Some("Custom Shader Sampler"),
232            address_mode_u: AddressMode::ClampToEdge,
233            address_mode_v: AddressMode::ClampToEdge,
234            address_mode_w: AddressMode::ClampToEdge,
235            mag_filter: FilterMode::Nearest,
236            min_filter: FilterMode::Nearest,
237            mipmap_filter: FilterMode::Nearest,
238            ..Default::default()
239        });
240
241        // Load channel textures (iChannel0-3)
242        let channel_textures = load_channel_textures(device, queue, channel_paths);
243
244        // Load cubemap texture (iCubemap)
245        let cubemap = match cubemap_path {
246            Some(path) => match CubemapTexture::from_prefix(device, queue, path) {
247                Ok(cm) => cm,
248                Err(e) => {
249                    log::error!("Failed to load cubemap '{}': {}", path.display(), e);
250                    CubemapTexture::placeholder(device, queue)
251                }
252            },
253            None => CubemapTexture::placeholder(device, queue),
254        };
255
256        // Create uniform buffer
257        let uniform_buffer = device.create_buffer(&BufferDescriptor {
258            label: Some("Custom Shader Uniforms"),
259            size: std::mem::size_of::<CustomShaderUniforms>() as u64,
260            usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
261            mapped_at_creation: false,
262        });
263
264        // Create bind group layout and bind group
265        let bind_group_layout = create_bind_group_layout(device);
266        let bind_group = create_bind_group(
267            device,
268            &bind_group_layout,
269            &uniform_buffer,
270            &intermediate_texture_view,
271            &sampler,
272            &channel_textures,
273            &cubemap,
274        );
275
276        // Create render pipeline
277        let pipeline = create_render_pipeline(
278            device,
279            &shader_module,
280            &bind_group_layout,
281            surface_format,
282            Some("Custom Shader Pipeline"),
283        );
284
285        let now = Instant::now();
286        Ok(Self {
287            pipeline,
288            bind_group,
289            uniform_buffer,
290            intermediate_texture,
291            intermediate_texture_view,
292            start_time: now,
293            animation_enabled,
294            animation_speed,
295            texture_width: width,
296            texture_height: height,
297            surface_format,
298            bind_group_layout,
299            sampler,
300            window_opacity,
301            keep_text_opaque: false,
302            scale_factor: 1.0,
303            full_content_mode,
304            brightness: 1.0,
305            frame_count: 0,
306            last_frame_time: now,
307            mouse_position: [0.0, 0.0],
308            mouse_click_position: [0.0, 0.0],
309            mouse_button_down: false,
310            frame_time_accumulator: 0.0,
311            frames_in_second: 0,
312            current_frame_rate: 60.0,
313            current_cursor_pos: (0, 0),
314            previous_cursor_pos: (0, 0),
315            current_cursor_color: [1.0, 1.0, 1.0, 1.0],
316            previous_cursor_color: [1.0, 1.0, 1.0, 1.0],
317            current_cursor_opacity: 1.0,
318            previous_cursor_opacity: 1.0,
319            cursor_change_time: 0.0,
320            current_cursor_style: CursorStyle::SteadyBlock,
321            previous_cursor_style: CursorStyle::SteadyBlock,
322            cursor_cell_width: 10.0,
323            cursor_cell_height: 20.0,
324            cursor_window_padding: 0.0,
325            cursor_content_offset_y: 0.0,
326            cursor_content_offset_x: 0.0,
327            cursor_shader_color: [1.0, 1.0, 1.0, 1.0],
328            cursor_trail_duration: 0.5,
329            cursor_glow_radius: 80.0,
330            cursor_glow_intensity: 0.3,
331            key_press_time: 0.0,
332            channel_textures,
333            cubemap,
334            use_background_as_channel0: false,
335            background_channel_texture: None,
336            background_color: [0.0, 0.0, 0.0, 0.0], // No solid background by default
337            progress_data: [0.0, 0.0, 0.0, 0.0],
338            content_inset_right: 0.0,
339        })
340    }
341
342    /// Create the intermediate texture for rendering terminal content
343    fn create_intermediate_texture(
344        device: &Device,
345        format: TextureFormat,
346        width: u32,
347        height: u32,
348    ) -> (Texture, TextureView) {
349        let texture = device.create_texture(&TextureDescriptor {
350            label: Some("Custom Shader Intermediate Texture"),
351            size: Extent3d {
352                width: width.max(1),
353                height: height.max(1),
354                depth_or_array_layers: 1,
355            },
356            mip_level_count: 1,
357            sample_count: 1,
358            dimension: TextureDimension::D2,
359            format,
360            usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
361            view_formats: &[],
362        });
363
364        let view = texture.create_view(&TextureViewDescriptor::default());
365        (texture, view)
366    }
367
368    /// Get a view of the intermediate texture for rendering terminal content into
369    pub fn intermediate_texture_view(&self) -> &TextureView {
370        &self.intermediate_texture_view
371    }
372
373    /// Clear the intermediate texture (e.g., when switching to split pane mode)
374    ///
375    /// This prevents old single-pane content from showing through the shader.
376    pub fn clear_intermediate_texture(&self, device: &Device, queue: &Queue) {
377        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
378            label: Some("Clear Intermediate Texture Encoder"),
379        });
380
381        {
382            let _clear_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
383                label: Some("Clear Intermediate Texture Pass"),
384                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
385                    view: &self.intermediate_texture_view,
386                    resolve_target: None,
387                    ops: wgpu::Operations {
388                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
389                        store: wgpu::StoreOp::Store,
390                    },
391                    depth_slice: None,
392                })],
393                depth_stencil_attachment: None,
394                timestamp_writes: None,
395                occlusion_query_set: None,
396            });
397        }
398
399        queue.submit(std::iter::once(encoder.finish()));
400    }
401
402    /// Resize the intermediate texture when window size changes
403    pub fn resize(&mut self, device: &Device, width: u32, height: u32) {
404        if width == self.texture_width && height == self.texture_height {
405            return;
406        }
407
408        self.texture_width = width;
409        self.texture_height = height;
410
411        // Recreate intermediate texture
412        let (texture, view) =
413            Self::create_intermediate_texture(device, self.surface_format, width, height);
414        self.intermediate_texture = texture;
415        self.intermediate_texture_view = view;
416
417        // Recreate bind group with new texture view (handles background as channel0 if enabled)
418        self.recreate_bind_group(device);
419    }
420
421    /// Render the custom shader effect to the output texture
422    ///
423    /// # Arguments
424    /// * `device` - The GPU device
425    /// * `queue` - The command queue
426    /// * `output_view` - The texture view to render to
427    /// * `apply_opacity` - Whether to apply window opacity. Set to `false` when rendering
428    ///   to an intermediate texture that will be processed by another shader (to avoid
429    ///   double-applying opacity).
430    pub fn render(
431        &mut self,
432        device: &Device,
433        queue: &Queue,
434        output_view: &TextureView,
435        apply_opacity: bool,
436    ) -> Result<()> {
437        self.render_with_clear_color(
438            device,
439            queue,
440            output_view,
441            apply_opacity,
442            Color::TRANSPARENT,
443        )
444    }
445
446    /// Render the custom shader with a specified clear color.
447    /// Use this for solid background colors where the clear color provides the background.
448    pub fn render_with_clear_color(
449        &mut self,
450        device: &Device,
451        queue: &Queue,
452        output_view: &TextureView,
453        apply_opacity: bool,
454        clear_color: Color,
455    ) -> Result<()> {
456        let now = Instant::now();
457
458        // Calculate time value
459        let time = if self.animation_enabled {
460            self.start_time.elapsed().as_secs_f32() * self.animation_speed.max(0.0)
461        } else {
462            0.0
463        };
464
465        // Calculate time delta
466        let time_delta = now.duration_since(self.last_frame_time).as_secs_f32();
467        self.last_frame_time = now;
468
469        // Update frame rate calculation
470        self.frame_time_accumulator += time_delta;
471        self.frames_in_second += 1;
472        if self.frame_time_accumulator >= 1.0 {
473            self.current_frame_rate = self.frames_in_second as f32 / self.frame_time_accumulator;
474            self.frame_time_accumulator = 0.0;
475            self.frames_in_second = 0;
476        }
477
478        self.frame_count = self.frame_count.wrapping_add(1);
479
480        // Calculate uniforms
481        let uniforms = self.build_uniforms(time, time_delta, apply_opacity);
482        queue.write_buffer(&self.uniform_buffer, 0, bytemuck::cast_slice(&[uniforms]));
483
484        // Create command encoder and render
485        let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
486            label: Some("Custom Shader Encoder"),
487        });
488
489        {
490            let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
491                label: Some("Custom Shader Render Pass"),
492                color_attachments: &[Some(RenderPassColorAttachment {
493                    view: output_view,
494                    resolve_target: None,
495                    ops: Operations {
496                        load: LoadOp::Clear(clear_color),
497                        store: StoreOp::Store,
498                    },
499                    depth_slice: None,
500                })],
501                depth_stencil_attachment: None,
502                timestamp_writes: None,
503                occlusion_query_set: None,
504            });
505
506            // Note: We intentionally do NOT set a viewport here to exclude the panel area.
507            // The viewport approach doesn't work because fragCoord in WGSL is relative to
508            // the render target, not the viewport, causing UV coordinate mismatches.
509            // The opaque panel (PANEL_BG with alpha 255) covers any shader output under it.
510
511            render_pass.set_pipeline(&self.pipeline);
512            render_pass.set_bind_group(0, &self.bind_group, &[]);
513            render_pass.draw(0..4, 0..1);
514        }
515
516        queue.submit(std::iter::once(encoder.finish()));
517        Ok(())
518    }
519
520    /// Build the uniform buffer data
521    fn build_uniforms(
522        &self,
523        time: f32,
524        time_delta: f32,
525        apply_opacity: bool,
526    ) -> CustomShaderUniforms {
527        // Calculate iMouse uniform
528        let height = self.texture_height as f32;
529        let mouse_y_flipped = height - self.mouse_position[1];
530        let click_y_flipped = height - self.mouse_click_position[1];
531
532        let mouse = if self.mouse_button_down {
533            [
534                self.mouse_position[0],
535                mouse_y_flipped,
536                self.mouse_click_position[0],
537                click_y_flipped,
538            ]
539        } else {
540            [
541                self.mouse_position[0],
542                mouse_y_flipped,
543                -self.mouse_click_position[0].abs(),
544                -click_y_flipped.abs(),
545            ]
546        };
547
548        // Calculate iDate uniform
549        let date = Self::calculate_date();
550
551        // Calculate cursor pixel positions
552        let (curr_x, curr_y) =
553            self.cursor_to_pixels(self.current_cursor_pos.0, self.current_cursor_pos.1);
554        let (prev_x, prev_y) =
555            self.cursor_to_pixels(self.previous_cursor_pos.0, self.previous_cursor_pos.1);
556
557        // When rendering to intermediate texture (for further shader processing),
558        // use 0.0 to signal "chain mode" to the shader. This tells the shader to:
559        // - Use full background color for RGB (not premultiplied by opacity)
560        // - Output terminal-only alpha (so next shader can detect transparent areas)
561        // The final shader in the chain will apply actual window opacity.
562        let effective_opacity = if apply_opacity {
563            self.window_opacity
564        } else {
565            0.0 // Chain mode: shader detects this and preserves transparency info
566        };
567
568        // Resolution stays at full texture size for correct UV sampling
569        // The viewport (set in render) limits where output appears
570        CustomShaderUniforms {
571            resolution: [self.texture_width as f32, self.texture_height as f32],
572            time,
573            time_delta,
574            mouse,
575            date,
576            opacity: effective_opacity,
577            // When keep_text_opaque is true, text stays at full opacity (1.0)
578            // When false, text uses the same opacity as the window background
579            text_opacity: if self.keep_text_opaque || !apply_opacity {
580                1.0
581            } else {
582                self.window_opacity
583            },
584            full_content_mode: if self.full_content_mode { 1.0 } else { 0.0 },
585            frame: self.frame_count as f32,
586            frame_rate: self.current_frame_rate,
587            resolution_z: 1.0,
588            brightness: self.brightness,
589            key_press_time: self.key_press_time,
590            current_cursor: [
591                curr_x,
592                curr_y,
593                self.cursor_width_for_style(self.current_cursor_style, self.scale_factor),
594                self.cursor_height_for_style(self.current_cursor_style, self.scale_factor),
595            ],
596            previous_cursor: [
597                prev_x,
598                prev_y,
599                self.cursor_width_for_style(self.previous_cursor_style, self.scale_factor),
600                self.cursor_height_for_style(self.previous_cursor_style, self.scale_factor),
601            ],
602            current_cursor_color: [
603                self.current_cursor_color[0],
604                self.current_cursor_color[1],
605                self.current_cursor_color[2],
606                self.current_cursor_color[3] * self.current_cursor_opacity,
607            ],
608            previous_cursor_color: [
609                self.previous_cursor_color[0],
610                self.previous_cursor_color[1],
611                self.previous_cursor_color[2],
612                self.previous_cursor_color[3] * self.previous_cursor_opacity,
613            ],
614            cursor_change_time: self.cursor_change_time,
615            cursor_trail_duration: self.cursor_trail_duration,
616            cursor_glow_radius: self.cursor_glow_radius,
617            cursor_glow_intensity: self.cursor_glow_intensity,
618            cursor_shader_color: self.cursor_shader_color,
619            channel0_resolution: self.effective_channel0_resolution(),
620            channel1_resolution: self.channel_textures[1].resolution(),
621            channel2_resolution: self.channel_textures[2].resolution(),
622            channel3_resolution: self.channel_textures[3].resolution(),
623            channel4_resolution: [
624                self.texture_width as f32,
625                self.texture_height as f32,
626                1.0,
627                0.0,
628            ],
629            cubemap_resolution: self.cubemap.resolution(),
630            background_color: self.background_color,
631            progress: self.progress_data,
632        }
633    }
634
635    /// Calculate the iDate uniform value
636    fn calculate_date() -> [f32; 4] {
637        use std::time::{SystemTime, UNIX_EPOCH};
638        let now_sys = SystemTime::now();
639        let since_epoch = now_sys.duration_since(UNIX_EPOCH).unwrap_or_default();
640        let secs = since_epoch.as_secs();
641
642        let days_since_epoch = secs / 86400;
643        let secs_today = (secs % 86400) as f32;
644
645        let mut year = 1970i32;
646        let mut remaining_days = days_since_epoch as i32;
647
648        loop {
649            let days_in_year = if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
650                366
651            } else {
652                365
653            };
654            if remaining_days < days_in_year {
655                break;
656            }
657            remaining_days -= days_in_year;
658            year += 1;
659        }
660
661        let is_leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
662        let days_in_months: [i32; 12] = if is_leap {
663            [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
664        } else {
665            [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
666        };
667
668        let mut month = 0i32;
669        for (i, &days) in days_in_months.iter().enumerate() {
670            if remaining_days < days {
671                month = i as i32;
672                break;
673            }
674            remaining_days -= days;
675        }
676
677        let day = remaining_days + 1;
678        [year as f32, month as f32, day as f32, secs_today]
679    }
680
681    /// Check if animation is enabled
682    #[allow(dead_code)]
683    pub fn animation_enabled(&self) -> bool {
684        self.animation_enabled
685    }
686
687    /// Set animation enabled state
688    #[allow(dead_code)]
689    pub fn set_animation_enabled(&mut self, enabled: bool) {
690        self.animation_enabled = enabled;
691        if enabled {
692            self.start_time = Instant::now();
693        }
694    }
695
696    /// Update animation speed multiplier
697    pub fn set_animation_speed(&mut self, speed: f32) {
698        self.animation_speed = speed.max(0.0);
699    }
700
701    /// Update window opacity
702    pub fn set_opacity(&mut self, opacity: f32) {
703        self.window_opacity = opacity.clamp(0.0, 1.0);
704    }
705
706    /// Update shader brightness multiplier
707    pub fn set_brightness(&mut self, brightness: f32) {
708        self.brightness = brightness.clamp(0.05, 1.0);
709    }
710
711    /// Update full content mode
712    pub fn set_full_content_mode(&mut self, enabled: bool) {
713        self.full_content_mode = enabled;
714    }
715
716    /// Check if full content mode is enabled
717    #[allow(dead_code)]
718    pub fn full_content_mode(&self) -> bool {
719        self.full_content_mode
720    }
721
722    /// Set whether text should always be rendered at full opacity
723    /// When true, overrides text_opacity to 1.0
724    pub fn set_keep_text_opaque(&mut self, keep_opaque: bool) {
725        self.keep_text_opaque = keep_opaque;
726    }
727
728    /// Update mouse position in pixel coordinates
729    pub fn set_mouse_position(&mut self, x: f32, y: f32) {
730        self.mouse_position = [x, y];
731    }
732
733    /// Update mouse button state and click position
734    pub fn set_mouse_button(&mut self, pressed: bool, x: f32, y: f32) {
735        self.mouse_button_down = pressed;
736        if pressed {
737            self.mouse_click_position = [x, y];
738        }
739    }
740
741    /// Update key press time for shader effects
742    ///
743    /// Call this when a key is pressed to enable key-press-based shader effects
744    /// like screen pulses or typing animations.
745    pub fn update_key_press(&mut self) {
746        self.key_press_time = if self.animation_enabled {
747            self.start_time.elapsed().as_secs_f32() * self.animation_speed.max(0.0)
748        } else {
749            0.0
750        };
751        log::trace!("Key pressed at shader time={:.3}", self.key_press_time);
752    }
753
754    /// Update a channel texture at runtime
755    #[allow(dead_code)]
756    pub fn update_channel_texture(
757        &mut self,
758        device: &Device,
759        queue: &Queue,
760        channel: u8,
761        path: Option<&std::path::Path>,
762    ) -> Result<()> {
763        if !(1..=4).contains(&channel) {
764            anyhow::bail!("Invalid channel index: {} (must be 1-4)", channel);
765        }
766
767        let index = (channel - 1) as usize;
768
769        let new_texture = match path {
770            Some(p) => ChannelTexture::from_file(device, queue, p)?,
771            None => ChannelTexture::placeholder(device, queue),
772        };
773
774        self.channel_textures[index] = new_texture;
775
776        // Use recreate_bind_group to properly handle use_background_as_channel0 logic
777        self.recreate_bind_group(device);
778
779        log::info!(
780            "Updated iChannel{} texture: {}",
781            channel,
782            path.map(|p| p.display().to_string())
783                .unwrap_or_else(|| "placeholder".to_string())
784        );
785
786        Ok(())
787    }
788
789    /// Update the cubemap texture at runtime
790    #[allow(dead_code)]
791    pub fn update_cubemap(
792        &mut self,
793        device: &Device,
794        queue: &Queue,
795        path: Option<&std::path::Path>,
796    ) -> Result<()> {
797        let new_cubemap = match path {
798            Some(p) => CubemapTexture::from_prefix(device, queue, p)?,
799            None => CubemapTexture::placeholder(device, queue),
800        };
801
802        self.cubemap = new_cubemap;
803
804        // Use recreate_bind_group to properly handle use_background_as_channel0 logic
805        self.recreate_bind_group(device);
806
807        log::info!(
808            "Updated cubemap texture: {}",
809            path.map(|p| p.display().to_string())
810                .unwrap_or_else(|| "placeholder".to_string())
811        );
812
813        Ok(())
814    }
815
816    /// Set whether to use the background image as iChannel0.
817    ///
818    /// When enabled and a background texture is set, the background image will be
819    /// used as iChannel0 instead of the configured channel0 texture file.
820    ///
821    /// Note: This only updates the flag. Use `update_use_background_as_channel0`
822    /// if you also need to recreate the bind group.
823    #[allow(dead_code)]
824    pub fn set_use_background_as_channel0(&mut self, use_background: bool) {
825        if self.use_background_as_channel0 != use_background {
826            self.use_background_as_channel0 = use_background;
827            log::info!("use_background_as_channel0 set to {}", use_background);
828        }
829    }
830
831    /// Check if using background image as iChannel0.
832    #[allow(dead_code)]
833    pub fn use_background_as_channel0(&self) -> bool {
834        self.use_background_as_channel0
835    }
836
837    /// Set the background texture to use as iChannel0 when enabled.
838    ///
839    /// Call this whenever the background image changes to update the shader's
840    /// channel0 binding. The device parameter is needed to recreate the bind group.
841    ///
842    /// When use_background_as_channel0 is enabled, the background texture takes
843    /// priority over any configured channel0 texture.
844    ///
845    /// # Arguments
846    /// * `device` - The wgpu device
847    /// * `texture` - The background texture (view, sampler, dimensions), or None to clear
848    pub fn set_background_texture(&mut self, device: &Device, texture: Option<ChannelTexture>) {
849        self.background_channel_texture = texture;
850
851        // Recreate bind group if we're using background as channel0
852        // The background texture takes priority over configured channel0 when enabled
853        if self.use_background_as_channel0 {
854            self.recreate_bind_group(device);
855        }
856    }
857
858    /// Set the solid background color for shader compositing.
859    ///
860    /// When set (alpha > 0), the shader uses this color as background instead of shader output.
861    /// This allows solid background colors to show through properly with window transparency.
862    ///
863    /// # Arguments
864    /// * `color` - RGB color values [R, G, B] (0.0-1.0, NOT premultiplied)
865    /// * `active` - Whether solid color mode is active (sets alpha to 1.0 or 0.0)
866    pub fn set_background_color(&mut self, color: [f32; 3], active: bool) {
867        self.background_color = [color[0], color[1], color[2], if active { 1.0 } else { 0.0 }];
868    }
869
870    /// Update progress bar state for shader effects.
871    ///
872    /// # Arguments
873    /// * `state` - Progress state (0=hidden, 1=normal, 2=error, 3=indeterminate, 4=warning)
874    /// * `percent` - Progress percentage as 0.0-1.0
875    /// * `is_active` - 1.0 if any progress bar is active, 0.0 otherwise
876    /// * `active_count` - Total count of active bars (simple + named)
877    pub fn update_progress(&mut self, state: f32, percent: f32, is_active: f32, active_count: f32) {
878        self.progress_data = [state, percent, is_active, active_count];
879    }
880
881    /// Check if channel0 has a real configured texture (not just a 1x1 placeholder).
882    fn channel0_has_real_texture(&self) -> bool {
883        let ch0 = &self.channel_textures[0];
884        // Placeholder textures are 1x1
885        ch0.width > 1 || ch0.height > 1
886    }
887
888    /// Get the effective channel0 resolution for the iChannelResolution uniform.
889    ///
890    /// This follows the same priority as texture selection:
891    /// 1. If use_background_as_channel0 is enabled and background exists, use its resolution
892    /// 2. Otherwise use channel0 texture resolution (whether configured or placeholder)
893    fn effective_channel0_resolution(&self) -> [f32; 4] {
894        if self.use_background_as_channel0 {
895            self.background_channel_texture
896                .as_ref()
897                .map(|t| t.resolution())
898                .unwrap_or_else(|| self.channel_textures[0].resolution())
899        } else {
900            self.channel_textures[0].resolution()
901        }
902    }
903
904    /// Recreate the bind group, using background texture for channel0 if enabled.
905    ///
906    /// Priority for iChannel0:
907    /// 1. If use_background_as_channel0 is enabled and background exists, use background
908    /// 2. If channel0 has a configured texture (not placeholder), use it
909    /// 3. Otherwise use the placeholder
910    ///
911    /// This is called when:
912    /// - The background texture changes (and use_background_as_channel0 is true)
913    /// - use_background_as_channel0 flag changes
914    /// - The window resizes (intermediate texture changes)
915    fn recreate_bind_group(&mut self, device: &Device) {
916        // Priority: use_background_as_channel0 (explicit override) > configured channel0 > placeholder
917        let channel0_texture = if self.use_background_as_channel0 {
918            // User explicitly wants background image as channel0
919            self.background_channel_texture
920                .as_ref()
921                .unwrap_or(&self.channel_textures[0])
922        } else if self.channel0_has_real_texture() {
923            // Channel0 has a real texture configured
924            &self.channel_textures[0]
925        } else {
926            // Use the placeholder
927            &self.channel_textures[0]
928        };
929
930        // Create a temporary array with the potentially swapped channel0
931        let effective_channels = [
932            channel0_texture,
933            &self.channel_textures[1],
934            &self.channel_textures[2],
935            &self.channel_textures[3],
936        ];
937
938        self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
939            label: Some("Custom Shader Bind Group"),
940            layout: &self.bind_group_layout,
941            entries: &[
942                wgpu::BindGroupEntry {
943                    binding: 0,
944                    resource: self.uniform_buffer.as_entire_binding(),
945                },
946                // iChannel0 (background or configured texture)
947                wgpu::BindGroupEntry {
948                    binding: 1,
949                    resource: wgpu::BindingResource::TextureView(&effective_channels[0].view),
950                },
951                wgpu::BindGroupEntry {
952                    binding: 2,
953                    resource: wgpu::BindingResource::Sampler(&effective_channels[0].sampler),
954                },
955                // iChannel1
956                wgpu::BindGroupEntry {
957                    binding: 3,
958                    resource: wgpu::BindingResource::TextureView(&effective_channels[1].view),
959                },
960                wgpu::BindGroupEntry {
961                    binding: 4,
962                    resource: wgpu::BindingResource::Sampler(&effective_channels[1].sampler),
963                },
964                // iChannel2
965                wgpu::BindGroupEntry {
966                    binding: 5,
967                    resource: wgpu::BindingResource::TextureView(&effective_channels[2].view),
968                },
969                wgpu::BindGroupEntry {
970                    binding: 6,
971                    resource: wgpu::BindingResource::Sampler(&effective_channels[2].sampler),
972                },
973                // iChannel3
974                wgpu::BindGroupEntry {
975                    binding: 7,
976                    resource: wgpu::BindingResource::TextureView(&effective_channels[3].view),
977                },
978                wgpu::BindGroupEntry {
979                    binding: 8,
980                    resource: wgpu::BindingResource::Sampler(&effective_channels[3].sampler),
981                },
982                // iChannel4 (terminal content)
983                wgpu::BindGroupEntry {
984                    binding: 9,
985                    resource: wgpu::BindingResource::TextureView(&self.intermediate_texture_view),
986                },
987                wgpu::BindGroupEntry {
988                    binding: 10,
989                    resource: wgpu::BindingResource::Sampler(&self.sampler),
990                },
991                // iCubemap
992                wgpu::BindGroupEntry {
993                    binding: 11,
994                    resource: wgpu::BindingResource::TextureView(&self.cubemap.view),
995                },
996                wgpu::BindGroupEntry {
997                    binding: 12,
998                    resource: wgpu::BindingResource::Sampler(&self.cubemap.sampler),
999                },
1000            ],
1001        });
1002    }
1003
1004    /// Update the use_background_as_channel0 setting and recreate bind group if needed.
1005    ///
1006    /// Call this when the setting changes in the UI or config.
1007    pub fn update_use_background_as_channel0(&mut self, device: &Device, use_background: bool) {
1008        if self.use_background_as_channel0 != use_background {
1009            self.use_background_as_channel0 = use_background;
1010            self.recreate_bind_group(device);
1011            log::info!("use_background_as_channel0 toggled to {}", use_background);
1012        }
1013    }
1014
1015    /// Reload the shader from a source string
1016    pub fn reload_from_source(&mut self, device: &Device, source: &str, name: &str) -> Result<()> {
1017        let wgsl_source = transpile_glsl_to_wgsl_source(source, name)?;
1018
1019        log::info!(
1020            "Reloading custom shader from source ({} bytes GLSL -> {} bytes WGSL)",
1021            source.len(),
1022            wgsl_source.len()
1023        );
1024        log::debug!("Generated WGSL:\n{}", wgsl_source);
1025
1026        // Pre-validate WGSL
1027        let module = naga::front::wgsl::parse_str(&wgsl_source)
1028            .context("Custom shader WGSL parse failed")?;
1029        let _info = naga::valid::Validator::new(
1030            naga::valid::ValidationFlags::all(),
1031            naga::valid::Capabilities::empty(),
1032        )
1033        .validate(&module)
1034        .context("Custom shader WGSL validation failed")?;
1035
1036        let shader_module = device.create_shader_module(ShaderModuleDescriptor {
1037            label: Some("Custom Shader Module (reloaded)"),
1038            source: ShaderSource::Wgsl(wgsl_source.into()),
1039        });
1040
1041        self.pipeline = create_render_pipeline(
1042            device,
1043            &shader_module,
1044            &self.bind_group_layout,
1045            self.surface_format,
1046            Some("Custom Shader Pipeline (reloaded)"),
1047        );
1048
1049        self.start_time = Instant::now();
1050
1051        log::info!("Custom shader reloaded successfully from source");
1052        Ok(())
1053    }
1054
1055    /// Set the right content inset (e.g., AI Inspector panel).
1056    ///
1057    /// When non-zero, the shader will render to a viewport that excludes
1058    /// the right inset area, ensuring effects don't appear under the panel.
1059    pub fn set_content_inset_right(&mut self, inset: f32) {
1060        self.content_inset_right = inset;
1061    }
1062}