par_term/
custom_shader_renderer.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`: Terminal content texture
7//!
8//! Ghostty-compatible cursor uniforms (v1.2.0+):
9//! - `iCurrentCursor`: Current cursor position (xy) and size (zw) in pixels
10//! - `iPreviousCursor`: Previous cursor position and size
11//! - `iCurrentCursorColor`: Current cursor RGBA color (with opacity baked in)
12//! - `iPreviousCursorColor`: Previous cursor RGBA color
13//! - `iTimeCursorChange`: Time when cursor last moved (same timebase as iTime)
14
15use anyhow::{Context, Result};
16use par_term_emu_core_rust::cursor::CursorStyle;
17use std::path::Path;
18use std::time::Instant;
19use wgpu::*;
20
21/// Uniform data passed to custom shaders
22/// Layout must match GLSL std140 rules:
23/// - vec2 aligned to 8 bytes
24/// - vec4 aligned to 16 bytes
25/// - float aligned to 4 bytes
26/// - struct size rounded to 16 bytes (largest alignment)
27#[repr(C)]
28#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
29struct CustomShaderUniforms {
30    /// Viewport resolution (iResolution.xy) - offset 0, size 8
31    resolution: [f32; 2],
32    /// Time in seconds since shader started (iTime) - offset 8, size 4
33    time: f32,
34    /// Time since last frame in seconds (iTimeDelta) - offset 12, size 4
35    time_delta: f32,
36    /// Mouse state (iMouse) - offset 16, size 16
37    /// xy = current position (if dragging) or last drag position
38    /// zw = click position (positive when held, negative when released)
39    mouse: [f32; 4],
40    /// Date/time (iDate) - offset 32, size 16
41    /// x = year, y = month (0-11), z = day (1-31), w = seconds since midnight
42    date: [f32; 4],
43    /// Window opacity for transparency support - offset 48, size 4
44    opacity: f32,
45    /// Text opacity (separate from window opacity) - offset 52, size 4
46    text_opacity: f32,
47    /// Full content mode: 1.0 = shader receives and outputs full content, 0.0 = background only
48    full_content_mode: f32,
49    /// Frame counter (iFrame) - offset 60, size 4
50    frame: f32,
51    /// Current frame rate in FPS (iFrameRate) - offset 64, size 4
52    frame_rate: f32,
53    /// Pixel aspect ratio (iResolution.z) - offset 68, size 4, usually 1.0
54    resolution_z: f32,
55    /// Padding to reach 80 bytes (multiple of 16) - offset 72, size 8
56    _pad1: [f32; 2],
57
58    // ============ Cursor uniforms (Ghostty-compatible, v1.2.0+) ============
59    // Offsets 80-159
60    /// Current cursor position (xy) and size (zw) in pixels - offset 80, size 16
61    current_cursor: [f32; 4],
62    /// Previous cursor position (xy) and size (zw) in pixels - offset 96, size 16
63    previous_cursor: [f32; 4],
64    /// Current cursor RGBA color (with opacity baked into alpha) - offset 112, size 16
65    current_cursor_color: [f32; 4],
66    /// Previous cursor RGBA color - offset 128, size 16
67    previous_cursor_color: [f32; 4],
68    /// Time when cursor last moved (same timebase as iTime) - offset 144, size 4
69    cursor_change_time: f32,
70
71    // ============ Cursor shader configuration uniforms ============
72    // Offsets 148-175
73    /// Cursor trail duration in seconds - offset 148, size 4
74    cursor_trail_duration: f32,
75    /// Cursor glow radius in pixels - offset 152, size 4
76    cursor_glow_radius: f32,
77    /// Cursor glow intensity (0.0-1.0) - offset 156, size 4
78    cursor_glow_intensity: f32,
79    /// User-configured cursor color for shader effects [R, G, B, 1.0] - offset 160, size 16
80    /// (placed last because vec4 must be aligned to 16 bytes in std140)
81    cursor_shader_color: [f32; 4],
82}
83// Total size: 176 bytes
84
85// Compile-time assertion to ensure uniform struct size matches expectations
86const _: () = assert!(
87    std::mem::size_of::<CustomShaderUniforms>() == 176,
88    "CustomShaderUniforms must be exactly 176 bytes for GPU compatibility"
89);
90
91/// Custom shader renderer that applies post-processing effects
92pub struct CustomShaderRenderer {
93    /// The render pipeline for the custom shader
94    pipeline: RenderPipeline,
95    /// Bind group for shader uniforms and textures
96    bind_group: BindGroup,
97    /// Uniform buffer for shader parameters
98    uniform_buffer: Buffer,
99    /// Intermediate texture to render terminal content into
100    intermediate_texture: Texture,
101    /// View of the intermediate texture
102    intermediate_texture_view: TextureView,
103    /// Start time for animation
104    start_time: Instant,
105    /// Whether animation is enabled
106    animation_enabled: bool,
107    /// Animation speed multiplier
108    animation_speed: f32,
109    /// Current texture dimensions
110    texture_width: u32,
111    texture_height: u32,
112    /// Surface format for compatibility
113    surface_format: TextureFormat,
114    /// Bind group layout for recreating bind groups on resize
115    bind_group_layout: BindGroupLayout,
116    /// Sampler for the intermediate texture
117    sampler: Sampler,
118    /// Window opacity for transparency
119    window_opacity: f32,
120    /// Text opacity (separate from window opacity)
121    text_opacity: f32,
122    /// Full content mode - shader receives and can manipulate full terminal content
123    full_content_mode: bool,
124    /// Frame counter for iFrame uniform
125    frame_count: u32,
126    /// Last frame time for calculating time delta
127    last_frame_time: Instant,
128    /// Current mouse position in pixels (xy)
129    mouse_position: [f32; 2],
130    /// Last click position in pixels (zw)
131    mouse_click_position: [f32; 2],
132    /// Whether mouse button is currently pressed (affects sign of zw)
133    mouse_button_down: bool,
134    /// Frame rate tracking: time accumulator for averaging
135    frame_time_accumulator: f32,
136    /// Frame rate tracking: frames in current second
137    frames_in_second: u32,
138    /// Current smoothed frame rate
139    current_frame_rate: f32,
140
141    // ============ Cursor tracking (Ghostty-compatible) ============
142    /// Current cursor position in cell coordinates (col, row)
143    current_cursor_pos: (usize, usize),
144    /// Previous cursor position in cell coordinates
145    previous_cursor_pos: (usize, usize),
146    /// Current cursor RGBA color
147    current_cursor_color: [f32; 4],
148    /// Previous cursor RGBA color
149    previous_cursor_color: [f32; 4],
150    /// Current cursor opacity (0.0 = invisible, 1.0 = fully visible)
151    current_cursor_opacity: f32,
152    /// Previous cursor opacity
153    previous_cursor_opacity: f32,
154    /// Time when cursor position last changed (same timebase as iTime)
155    cursor_change_time: f32,
156    /// Current cursor style (for size calculation)
157    current_cursor_style: CursorStyle,
158    /// Previous cursor style
159    previous_cursor_style: CursorStyle,
160    /// Cell width in pixels (for cursor position calculation)
161    cursor_cell_width: f32,
162    /// Cell height in pixels (for cursor position calculation)
163    cursor_cell_height: f32,
164    /// Window padding in pixels (for cursor position calculation)
165    cursor_window_padding: f32,
166
167    // ============ Cursor shader configuration ============
168    /// User-configured cursor color for shader effects [R, G, B, A]
169    cursor_shader_color: [f32; 4],
170    /// Cursor trail duration in seconds
171    cursor_trail_duration: f32,
172    /// Cursor glow radius in pixels
173    cursor_glow_radius: f32,
174    /// Cursor glow intensity (0.0-1.0)
175    cursor_glow_intensity: f32,
176}
177
178impl CustomShaderRenderer {
179    /// Create a new custom shader renderer from a GLSL shader file
180    ///
181    /// # Arguments
182    /// * `device` - The wgpu device
183    /// * `queue` - The wgpu queue
184    /// * `surface_format` - The surface texture format
185    /// * `shader_path` - Path to the GLSL shader file
186    /// * `width` - Initial viewport width
187    /// * `height` - Initial viewport height
188    /// * `animation_enabled` - Whether to animate iTime
189    /// * `animation_speed` - Animation speed multiplier
190    #[allow(clippy::too_many_arguments)]
191    pub fn new(
192        device: &Device,
193        _queue: &Queue,
194        surface_format: TextureFormat,
195        shader_path: &Path,
196        width: u32,
197        height: u32,
198        animation_enabled: bool,
199        animation_speed: f32,
200        window_opacity: f32,
201        text_opacity: f32,
202        full_content_mode: bool,
203    ) -> Result<Self> {
204        // Load the GLSL shader
205        let glsl_source = std::fs::read_to_string(shader_path)
206            .with_context(|| format!("Failed to read shader file: {}", shader_path.display()))?;
207
208        // Transpile GLSL to WGSL
209        let wgsl_source = transpile_glsl_to_wgsl(&glsl_source, shader_path)?;
210
211        log::info!(
212            "Loaded custom shader from {} ({} bytes GLSL -> {} bytes WGSL)",
213            shader_path.display(),
214            glsl_source.len(),
215            wgsl_source.len()
216        );
217        log::debug!("Generated WGSL:\n{}", wgsl_source);
218
219        // Create the shader module
220        // Pre-validate WGSL to surface errors gracefully
221        let module = naga::front::wgsl::parse_str(&wgsl_source)
222            .context("Custom shader WGSL parse failed")?;
223        let _info = naga::valid::Validator::new(
224            naga::valid::ValidationFlags::all(),
225            naga::valid::Capabilities::empty(),
226        )
227        .validate(&module)
228        .context("Custom shader WGSL validation failed")?;
229
230        let shader_module = device.create_shader_module(ShaderModuleDescriptor {
231            label: Some("Custom Shader Module"),
232            source: ShaderSource::Wgsl(wgsl_source.clone().into()),
233        });
234
235        // Create intermediate texture for terminal content
236        let (intermediate_texture, intermediate_texture_view) =
237            Self::create_intermediate_texture(device, surface_format, width, height);
238
239        // Create sampler for the intermediate texture
240        let sampler = device.create_sampler(&SamplerDescriptor {
241            label: Some("Custom Shader Sampler"),
242            address_mode_u: AddressMode::ClampToEdge,
243            address_mode_v: AddressMode::ClampToEdge,
244            address_mode_w: AddressMode::ClampToEdge,
245            mag_filter: FilterMode::Linear,
246            min_filter: FilterMode::Linear,
247            mipmap_filter: FilterMode::Linear,
248            ..Default::default()
249        });
250
251        // Create uniform buffer
252        let uniform_buffer = device.create_buffer(&BufferDescriptor {
253            label: Some("Custom Shader Uniforms"),
254            size: std::mem::size_of::<CustomShaderUniforms>() as u64,
255            usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
256            mapped_at_creation: false,
257        });
258
259        // Create bind group layout
260        let bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
261            label: Some("Custom Shader Bind Group Layout"),
262            entries: &[
263                // Uniform buffer (binding 0)
264                BindGroupLayoutEntry {
265                    binding: 0,
266                    visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
267                    ty: BindingType::Buffer {
268                        ty: BufferBindingType::Uniform,
269                        has_dynamic_offset: false,
270                        min_binding_size: None,
271                    },
272                    count: None,
273                },
274                // iChannel0 texture (binding 1)
275                BindGroupLayoutEntry {
276                    binding: 1,
277                    visibility: ShaderStages::FRAGMENT,
278                    ty: BindingType::Texture {
279                        sample_type: TextureSampleType::Float { filterable: true },
280                        view_dimension: TextureViewDimension::D2,
281                        multisampled: false,
282                    },
283                    count: None,
284                },
285                // Sampler (binding 2)
286                BindGroupLayoutEntry {
287                    binding: 2,
288                    visibility: ShaderStages::FRAGMENT,
289                    ty: BindingType::Sampler(SamplerBindingType::Filtering),
290                    count: None,
291                },
292            ],
293        });
294
295        // Create bind group
296        let bind_group = device.create_bind_group(&BindGroupDescriptor {
297            label: Some("Custom Shader Bind Group"),
298            layout: &bind_group_layout,
299            entries: &[
300                BindGroupEntry {
301                    binding: 0,
302                    resource: uniform_buffer.as_entire_binding(),
303                },
304                BindGroupEntry {
305                    binding: 1,
306                    resource: BindingResource::TextureView(&intermediate_texture_view),
307                },
308                BindGroupEntry {
309                    binding: 2,
310                    resource: BindingResource::Sampler(&sampler),
311                },
312            ],
313        });
314
315        // Create pipeline layout
316        let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
317            label: Some("Custom Shader Pipeline Layout"),
318            bind_group_layouts: &[&bind_group_layout],
319            push_constant_ranges: &[],
320        });
321
322        // Create render pipeline
323        let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
324            label: Some("Custom Shader Pipeline"),
325            layout: Some(&pipeline_layout),
326            vertex: VertexState {
327                module: &shader_module,
328                entry_point: Some("vs_main"),
329                buffers: &[],
330                compilation_options: Default::default(),
331            },
332            fragment: Some(FragmentState {
333                module: &shader_module,
334                entry_point: Some("fs_main"),
335                targets: &[Some(ColorTargetState {
336                    format: surface_format,
337                    blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING),
338                    write_mask: ColorWrites::ALL,
339                })],
340                compilation_options: Default::default(),
341            }),
342            primitive: PrimitiveState {
343                topology: PrimitiveTopology::TriangleStrip,
344                ..Default::default()
345            },
346            depth_stencil: None,
347            multisample: MultisampleState::default(),
348            multiview: None,
349            cache: None,
350        });
351
352        let now = Instant::now();
353        Ok(Self {
354            pipeline,
355            bind_group,
356            uniform_buffer,
357            intermediate_texture,
358            intermediate_texture_view,
359            start_time: now,
360            animation_enabled,
361            animation_speed,
362            texture_width: width,
363            texture_height: height,
364            surface_format,
365            bind_group_layout,
366            sampler,
367            window_opacity,
368            text_opacity,
369            full_content_mode,
370            frame_count: 0,
371            last_frame_time: now,
372            mouse_position: [0.0, 0.0],
373            mouse_click_position: [0.0, 0.0],
374            mouse_button_down: false,
375            frame_time_accumulator: 0.0,
376            frames_in_second: 0,
377            current_frame_rate: 60.0, // Start with reasonable default
378            // Cursor tracking (Ghostty-compatible)
379            current_cursor_pos: (0, 0),
380            previous_cursor_pos: (0, 0),
381            current_cursor_color: [1.0, 1.0, 1.0, 1.0], // White default
382            previous_cursor_color: [1.0, 1.0, 1.0, 1.0],
383            current_cursor_opacity: 1.0,
384            previous_cursor_opacity: 1.0,
385            cursor_change_time: 0.0,
386            current_cursor_style: CursorStyle::SteadyBlock,
387            previous_cursor_style: CursorStyle::SteadyBlock,
388            cursor_cell_width: 10.0, // Will be updated from renderer
389            cursor_cell_height: 20.0,
390            cursor_window_padding: 0.0,
391            // Cursor shader configuration (defaults match config.rs)
392            cursor_shader_color: [1.0, 1.0, 1.0, 1.0], // White
393            cursor_trail_duration: 0.5,
394            cursor_glow_radius: 80.0,
395            cursor_glow_intensity: 0.3,
396        })
397    }
398
399    /// Create the intermediate texture for rendering terminal content
400    fn create_intermediate_texture(
401        device: &Device,
402        format: TextureFormat,
403        width: u32,
404        height: u32,
405    ) -> (Texture, TextureView) {
406        let texture = device.create_texture(&TextureDescriptor {
407            label: Some("Custom Shader Intermediate Texture"),
408            size: Extent3d {
409                width: width.max(1),
410                height: height.max(1),
411                depth_or_array_layers: 1,
412            },
413            mip_level_count: 1,
414            sample_count: 1,
415            dimension: TextureDimension::D2,
416            format,
417            usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
418            view_formats: &[],
419        });
420
421        let view = texture.create_view(&TextureViewDescriptor::default());
422        (texture, view)
423    }
424
425    /// Get a view of the intermediate texture for rendering terminal content into
426    pub fn intermediate_texture_view(&self) -> &TextureView {
427        &self.intermediate_texture_view
428    }
429
430    /// Resize the intermediate texture when window size changes
431    pub fn resize(&mut self, device: &Device, width: u32, height: u32) {
432        if width == self.texture_width && height == self.texture_height {
433            return;
434        }
435
436        self.texture_width = width;
437        self.texture_height = height;
438
439        // Recreate intermediate texture
440        let (texture, view) =
441            Self::create_intermediate_texture(device, self.surface_format, width, height);
442        self.intermediate_texture = texture;
443        self.intermediate_texture_view = view;
444
445        // Recreate bind group with new texture view
446        self.bind_group = device.create_bind_group(&BindGroupDescriptor {
447            label: Some("Custom Shader Bind Group"),
448            layout: &self.bind_group_layout,
449            entries: &[
450                BindGroupEntry {
451                    binding: 0,
452                    resource: self.uniform_buffer.as_entire_binding(),
453                },
454                BindGroupEntry {
455                    binding: 1,
456                    resource: BindingResource::TextureView(&self.intermediate_texture_view),
457                },
458                BindGroupEntry {
459                    binding: 2,
460                    resource: BindingResource::Sampler(&self.sampler),
461                },
462            ],
463        });
464    }
465
466    /// Render the custom shader effect to the output texture
467    ///
468    /// This should be called after the terminal content has been rendered to the
469    /// intermediate texture obtained via `intermediate_texture_view()`.
470    pub fn render(
471        &mut self,
472        device: &Device,
473        queue: &Queue,
474        output_view: &TextureView,
475    ) -> Result<()> {
476        let now = Instant::now();
477
478        // Calculate time value
479        let time = if self.animation_enabled {
480            self.start_time.elapsed().as_secs_f32() * self.animation_speed.max(0.0)
481        } else {
482            0.0
483        };
484
485        // Calculate time delta
486        let time_delta = now.duration_since(self.last_frame_time).as_secs_f32();
487        self.last_frame_time = now;
488
489        // Update frame rate calculation (smoothed over ~1 second)
490        self.frame_time_accumulator += time_delta;
491        self.frames_in_second += 1;
492        if self.frame_time_accumulator >= 1.0 {
493            self.current_frame_rate = self.frames_in_second as f32 / self.frame_time_accumulator;
494            self.frame_time_accumulator = 0.0;
495            self.frames_in_second = 0;
496        }
497
498        // Increment frame counter
499        self.frame_count = self.frame_count.wrapping_add(1);
500
501        // Calculate iMouse uniform
502        // xy = current position (Shadertoy uses bottom-left origin, so flip Y)
503        // zw = click position (positive when button down, negative when up)
504        let height = self.texture_height as f32;
505        let mouse_y_flipped = height - self.mouse_position[1];
506        let click_y_flipped = height - self.mouse_click_position[1];
507
508        let mouse = if self.mouse_button_down {
509            // When dragging, xy = current position, zw = positive click position
510            [
511                self.mouse_position[0],
512                mouse_y_flipped,
513                self.mouse_click_position[0],
514                click_y_flipped,
515            ]
516        } else {
517            // When not dragging, xy = last drag position (or 0), zw = negative click position
518            [
519                self.mouse_position[0],
520                mouse_y_flipped,
521                -self.mouse_click_position[0].abs(),
522                -click_y_flipped.abs(),
523            ]
524        };
525
526        // Calculate iDate uniform
527        // x = year, y = month (0-11), z = day (1-31), w = seconds since midnight
528        let date = {
529            use std::time::{SystemTime, UNIX_EPOCH};
530            let now_sys = SystemTime::now();
531            let since_epoch = now_sys.duration_since(UNIX_EPOCH).unwrap_or_default();
532            let secs = since_epoch.as_secs();
533
534            // Calculate date components (simplified UTC calculation)
535            // This is a basic implementation - for more accuracy, consider using chrono
536            let days_since_epoch = secs / 86400;
537            let secs_today = (secs % 86400) as f32;
538
539            // Approximate year/month/day calculation
540            // Starting from 1970-01-01
541            let mut year = 1970i32;
542            let mut remaining_days = days_since_epoch as i32;
543
544            loop {
545                let days_in_year = if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
546                    366
547                } else {
548                    365
549                };
550                if remaining_days < days_in_year {
551                    break;
552                }
553                remaining_days -= days_in_year;
554                year += 1;
555            }
556
557            let is_leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
558            let days_in_months: [i32; 12] = if is_leap {
559                [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
560            } else {
561                [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
562            };
563
564            let mut month = 0i32;
565            for (i, &days) in days_in_months.iter().enumerate() {
566                if remaining_days < days {
567                    month = i as i32;
568                    break;
569                }
570                remaining_days -= days;
571            }
572
573            let day = remaining_days + 1; // Days are 1-indexed
574
575            [year as f32, month as f32, day as f32, secs_today]
576        };
577
578        // Calculate cursor pixel positions
579        let (curr_x, curr_y) =
580            self.cursor_to_pixels(self.current_cursor_pos.0, self.current_cursor_pos.1);
581        let (prev_x, prev_y) =
582            self.cursor_to_pixels(self.previous_cursor_pos.0, self.previous_cursor_pos.1);
583
584        // Debug: Log cursor position info periodically (every 60 frames)
585        if self.frame_count.is_multiple_of(60) {
586            log::debug!(
587                "CURSOR_SHADER: pos=({},{}) -> pixels=({:.1},{:.1}), cell=({:.1}x{:.1}), padding={:.1}, resolution={}x{}",
588                self.current_cursor_pos.0, self.current_cursor_pos.1,
589                curr_x, curr_y,
590                self.cursor_cell_width, self.cursor_cell_height,
591                self.cursor_window_padding,
592                self.texture_width, self.texture_height
593            );
594        }
595
596        // Update uniforms
597        let uniforms = CustomShaderUniforms {
598            resolution: [self.texture_width as f32, self.texture_height as f32],
599            time,
600            time_delta,
601            mouse,
602            date,
603            opacity: self.window_opacity,
604            text_opacity: self.text_opacity,
605            full_content_mode: if self.full_content_mode { 1.0 } else { 0.0 },
606            frame: self.frame_count as f32,
607            frame_rate: self.current_frame_rate,
608            resolution_z: 1.0, // Pixel aspect ratio, usually 1.0
609            _pad1: [0.0, 0.0],
610            // Cursor uniforms (Ghostty-compatible)
611            // Cursor dimensions vary by style:
612            // - Block: full cell width x height
613            // - Beam/Bar: thin width (2px) x full height
614            // - Underline: full width x thin height (2px)
615            current_cursor: [
616                curr_x,
617                curr_y,
618                self.cursor_width_for_style(self.current_cursor_style),
619                self.cursor_height_for_style(self.current_cursor_style),
620            ],
621            previous_cursor: [
622                prev_x,
623                prev_y,
624                self.cursor_width_for_style(self.previous_cursor_style),
625                self.cursor_height_for_style(self.previous_cursor_style),
626            ],
627            current_cursor_color: [
628                self.current_cursor_color[0],
629                self.current_cursor_color[1],
630                self.current_cursor_color[2],
631                self.current_cursor_color[3] * self.current_cursor_opacity,
632            ],
633            previous_cursor_color: [
634                self.previous_cursor_color[0],
635                self.previous_cursor_color[1],
636                self.previous_cursor_color[2],
637                self.previous_cursor_color[3] * self.previous_cursor_opacity,
638            ],
639            cursor_change_time: self.cursor_change_time,
640            // Cursor shader configuration (floats first, then vec4 at aligned offset)
641            cursor_trail_duration: self.cursor_trail_duration,
642            cursor_glow_radius: self.cursor_glow_radius,
643            cursor_glow_intensity: self.cursor_glow_intensity,
644            cursor_shader_color: self.cursor_shader_color,
645        };
646
647        queue.write_buffer(&self.uniform_buffer, 0, bytemuck::cast_slice(&[uniforms]));
648
649        // Create command encoder
650        let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
651            label: Some("Custom Shader Encoder"),
652        });
653
654        // Render pass
655        {
656            let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
657                label: Some("Custom Shader Render Pass"),
658                color_attachments: &[Some(RenderPassColorAttachment {
659                    view: output_view,
660                    resolve_target: None,
661                    ops: Operations {
662                        // Clear to transparent to support window transparency
663                        load: LoadOp::Clear(Color::TRANSPARENT),
664                        store: StoreOp::Store,
665                    },
666                    depth_slice: None,
667                })],
668                depth_stencil_attachment: None,
669                timestamp_writes: None,
670                occlusion_query_set: None,
671            });
672
673            render_pass.set_pipeline(&self.pipeline);
674            render_pass.set_bind_group(0, &self.bind_group, &[]);
675            render_pass.draw(0..4, 0..1);
676        }
677
678        queue.submit(std::iter::once(encoder.finish()));
679
680        Ok(())
681    }
682
683    /// Check if animation is enabled
684    #[allow(dead_code)]
685    pub fn animation_enabled(&self) -> bool {
686        self.animation_enabled
687    }
688
689    /// Set animation enabled state
690    #[allow(dead_code)]
691    pub fn set_animation_enabled(&mut self, enabled: bool) {
692        self.animation_enabled = enabled;
693        if enabled {
694            // Reset start time when enabling animation
695            self.start_time = Instant::now();
696        }
697    }
698
699    /// Update animation speed multiplier
700    pub fn set_animation_speed(&mut self, speed: f32) {
701        self.animation_speed = speed.max(0.0);
702    }
703
704    /// Update window opacity (content alpha passed to shader)
705    pub fn set_opacity(&mut self, opacity: f32) {
706        self.window_opacity = opacity.clamp(0.0, 1.0);
707    }
708
709    /// Update full content mode
710    pub fn set_full_content_mode(&mut self, enabled: bool) {
711        self.full_content_mode = enabled;
712    }
713
714    /// Check if full content mode is enabled
715    #[allow(dead_code)]
716    pub fn full_content_mode(&self) -> bool {
717        self.full_content_mode
718    }
719
720    /// Update mouse position in pixel coordinates
721    ///
722    /// # Arguments
723    /// * `x` - Mouse X position in pixels (0 = left edge)
724    /// * `y` - Mouse Y position in pixels (0 = top edge, will be flipped for shader)
725    pub fn set_mouse_position(&mut self, x: f32, y: f32) {
726        self.mouse_position = [x, y];
727    }
728
729    /// Update mouse button state and click position
730    ///
731    /// Call this when the left mouse button is pressed or released.
732    ///
733    /// # Arguments
734    /// * `pressed` - True if mouse button is now pressed, false if released
735    /// * `x` - Mouse X position in pixels at time of click/release
736    /// * `y` - Mouse Y position in pixels at time of click/release (will be flipped for shader)
737    pub fn set_mouse_button(&mut self, pressed: bool, x: f32, y: f32) {
738        self.mouse_button_down = pressed;
739        if pressed {
740            // Record click position when button is pressed
741            self.mouse_click_position = [x, y];
742        }
743    }
744
745    // ============ Cursor tracking methods (Ghostty-compatible) ============
746
747    /// Update cursor position and appearance for shader effects
748    ///
749    /// This method tracks cursor movement and records the time of change,
750    /// enabling Ghostty-compatible cursor trail effects and animations.
751    ///
752    /// # Arguments
753    /// * `col` - Cursor column position (0-based)
754    /// * `row` - Cursor row position (0-based)
755    /// * `opacity` - Cursor opacity (0.0 = invisible, 1.0 = fully visible)
756    /// * `cursor_color` - Cursor RGBA color
757    /// * `style` - Cursor style (Block, Beam, Underline)
758    pub fn update_cursor(
759        &mut self,
760        col: usize,
761        row: usize,
762        opacity: f32,
763        cursor_color: [f32; 4],
764        style: CursorStyle,
765    ) {
766        let new_pos = (col, row);
767        let style_changed = style != self.current_cursor_style;
768        let pos_changed = new_pos != self.current_cursor_pos;
769
770        if pos_changed || style_changed {
771            // Store previous state before updating
772            self.previous_cursor_pos = self.current_cursor_pos;
773            self.previous_cursor_opacity = self.current_cursor_opacity;
774            self.previous_cursor_color = self.current_cursor_color;
775            self.previous_cursor_style = self.current_cursor_style;
776            self.current_cursor_pos = new_pos;
777            self.current_cursor_style = style;
778
779            // Record time of change (same timebase as iTime)
780            self.cursor_change_time = if self.animation_enabled {
781                self.start_time.elapsed().as_secs_f32() * self.animation_speed.max(0.0)
782            } else {
783                0.0
784            };
785
786            if pos_changed {
787                log::trace!(
788                    "Cursor moved: ({}, {}) -> ({}, {}), change_time={:.3}",
789                    self.previous_cursor_pos.0,
790                    self.previous_cursor_pos.1,
791                    col,
792                    row,
793                    self.cursor_change_time
794                );
795            }
796        }
797        self.current_cursor_opacity = opacity;
798        self.current_cursor_color = cursor_color;
799    }
800
801    /// Update cell dimensions for cursor pixel position calculation
802    ///
803    /// # Arguments
804    /// * `cell_width` - Cell width in pixels
805    /// * `cell_height` - Cell height in pixels
806    /// * `padding` - Window padding in pixels
807    pub fn update_cell_dimensions(&mut self, cell_width: f32, cell_height: f32, padding: f32) {
808        self.cursor_cell_width = cell_width;
809        self.cursor_cell_height = cell_height;
810        self.cursor_window_padding = padding;
811    }
812
813    /// Convert cursor cell coordinates to pixel coordinates
814    ///
815    /// Returns (x, y) in pixels from top-left corner of the window.
816    fn cursor_to_pixels(&self, col: usize, row: usize) -> (f32, f32) {
817        let x = self.cursor_window_padding + (col as f32 * self.cursor_cell_width);
818        let y = self.cursor_window_padding + (row as f32 * self.cursor_cell_height);
819        (x, y)
820    }
821
822    /// Get cursor width in pixels based on cursor style
823    fn cursor_width_for_style(&self, style: CursorStyle) -> f32 {
824        match style {
825            // Block cursor: full cell width
826            CursorStyle::SteadyBlock | CursorStyle::BlinkingBlock => self.cursor_cell_width,
827            // Beam/Bar cursor: thin vertical line (2 pixels)
828            CursorStyle::SteadyBar | CursorStyle::BlinkingBar => 2.0,
829            // Underline cursor: full cell width
830            CursorStyle::SteadyUnderline | CursorStyle::BlinkingUnderline => self.cursor_cell_width,
831        }
832    }
833
834    /// Get cursor height in pixels based on cursor style
835    fn cursor_height_for_style(&self, style: CursorStyle) -> f32 {
836        match style {
837            // Block cursor: full cell height
838            CursorStyle::SteadyBlock | CursorStyle::BlinkingBlock => self.cursor_cell_height,
839            // Beam/Bar cursor: full cell height
840            CursorStyle::SteadyBar | CursorStyle::BlinkingBar => self.cursor_cell_height,
841            // Underline cursor: thin horizontal line (2 pixels)
842            CursorStyle::SteadyUnderline | CursorStyle::BlinkingUnderline => 2.0,
843        }
844    }
845
846    /// Check if cursor animation might need continuous rendering
847    ///
848    /// Returns true if a cursor trail animation is likely still in progress
849    /// (within 1 second of the last cursor movement).
850    pub fn cursor_needs_animation(&self) -> bool {
851        if self.animation_enabled {
852            let current_time =
853                self.start_time.elapsed().as_secs_f32() * self.animation_speed.max(0.0);
854            // Allow 1 second for cursor trail animations to complete
855            (current_time - self.cursor_change_time) < 1.0
856        } else {
857            false
858        }
859    }
860
861    /// Update cursor shader configuration from config values
862    ///
863    /// # Arguments
864    /// * `color` - Cursor color for shader effects [R, G, B] (0-255)
865    /// * `trail_duration` - Duration of cursor trail effect in seconds
866    /// * `glow_radius` - Radius of cursor glow effect in pixels
867    /// * `glow_intensity` - Intensity of cursor glow effect (0.0-1.0)
868    pub fn update_cursor_shader_config(
869        &mut self,
870        color: [u8; 3],
871        trail_duration: f32,
872        glow_radius: f32,
873        glow_intensity: f32,
874    ) {
875        self.cursor_shader_color = [
876            color[0] as f32 / 255.0,
877            color[1] as f32 / 255.0,
878            color[2] as f32 / 255.0,
879            1.0,
880        ];
881        self.cursor_trail_duration = trail_duration.max(0.0);
882        self.cursor_glow_radius = glow_radius.max(0.0);
883        self.cursor_glow_intensity = glow_intensity.clamp(0.0, 1.0);
884    }
885
886    /// Reload the shader from a source string
887    ///
888    /// This method compiles the new shader source and replaces the current pipeline.
889    /// If compilation fails, returns an error and the old shader remains active.
890    ///
891    /// # Arguments
892    /// * `device` - The wgpu device
893    /// * `source` - The GLSL shader source code
894    /// * `name` - A name for error messages (e.g., "editor")
895    ///
896    /// # Returns
897    /// Ok(()) if successful, Err with error message if compilation fails
898    pub fn reload_from_source(&mut self, device: &Device, source: &str, name: &str) -> Result<()> {
899        // Transpile GLSL to WGSL
900        let wgsl_source = transpile_glsl_to_wgsl_source(source, name)?;
901
902        log::info!(
903            "Reloading custom shader from source ({} bytes GLSL -> {} bytes WGSL)",
904            source.len(),
905            wgsl_source.len()
906        );
907        log::debug!("Generated WGSL:\n{}", wgsl_source);
908
909        // Pre-validate WGSL to surface errors gracefully
910        let module = naga::front::wgsl::parse_str(&wgsl_source)
911            .context("Custom shader WGSL parse failed")?;
912        let _info = naga::valid::Validator::new(
913            naga::valid::ValidationFlags::all(),
914            naga::valid::Capabilities::empty(),
915        )
916        .validate(&module)
917        .context("Custom shader WGSL validation failed")?;
918
919        // Create the shader module
920        let shader_module = device.create_shader_module(ShaderModuleDescriptor {
921            label: Some("Custom Shader Module (reloaded)"),
922            source: ShaderSource::Wgsl(wgsl_source.into()),
923        });
924
925        // Create pipeline layout
926        let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
927            label: Some("Custom Shader Pipeline Layout (reloaded)"),
928            bind_group_layouts: &[&self.bind_group_layout],
929            push_constant_ranges: &[],
930        });
931
932        // Create render pipeline
933        let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
934            label: Some("Custom Shader Pipeline (reloaded)"),
935            layout: Some(&pipeline_layout),
936            vertex: VertexState {
937                module: &shader_module,
938                entry_point: Some("vs_main"),
939                buffers: &[],
940                compilation_options: Default::default(),
941            },
942            fragment: Some(FragmentState {
943                module: &shader_module,
944                entry_point: Some("fs_main"),
945                targets: &[Some(ColorTargetState {
946                    format: self.surface_format,
947                    blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING),
948                    write_mask: ColorWrites::ALL,
949                })],
950                compilation_options: Default::default(),
951            }),
952            primitive: PrimitiveState {
953                topology: PrimitiveTopology::TriangleStrip,
954                ..Default::default()
955            },
956            depth_stencil: None,
957            multisample: MultisampleState::default(),
958            multiview: None,
959            cache: None,
960        });
961
962        // Success! Replace the old pipeline
963        self.pipeline = pipeline;
964
965        // Reset animation timer
966        self.start_time = Instant::now();
967
968        log::info!("Custom shader reloaded successfully from source");
969        Ok(())
970    }
971}
972
973/// Transpile a Ghostty/Shadertoy-style GLSL shader to WGSL
974///
975/// The input shader should have a `mainImage(out vec4 fragColor, in vec2 fragCoord)` function
976/// and can use the following uniforms:
977/// - `iTime`: Time in seconds
978/// - `iTimeDelta`: Time since last frame in seconds
979/// - `iFrame`: Frame counter
980/// - `iFrameRate`: Current frame rate in FPS
981/// - `iResolution`: Viewport resolution (vec2, z available as iResolutionZ)
982/// - `iMouse`: Mouse state (xy=current pos, zw=click pos)
983/// - `iDate`: Date/time (year, month, day, seconds since midnight)
984/// - `iChannel0`: Terminal content texture (sampler2D)
985/// - `iOpacity`: Window opacity (par-term specific)
986/// - `iTextOpacity`: Text opacity (par-term specific)
987/// - `iFullContent`: Full content mode flag (par-term specific)
988///
989/// Ghostty-compatible cursor uniforms (v1.2.0+):
990/// - `iCurrentCursor`: Current cursor position (xy) and size (zw) in pixels
991/// - `iPreviousCursor`: Previous cursor position and size
992/// - `iCurrentCursorColor`: Current cursor RGBA color (opacity baked into alpha)
993/// - `iPreviousCursorColor`: Previous cursor RGBA color
994/// - `iTimeCursorChange`: Time when cursor last moved (same timebase as iTime)
995///
996/// Cursor shader configuration uniforms (par-term specific):
997/// - `iCursorShaderColor`: User-configured cursor color for effects (RGBA)
998/// - `iCursorTrailDuration`: Trail effect duration in seconds
999/// - `iCursorGlowRadius`: Glow effect radius in pixels
1000/// - `iCursorGlowIntensity`: Glow effect intensity (0.0-1.0)
1001fn transpile_glsl_to_wgsl(glsl_source: &str, shader_path: &Path) -> Result<String> {
1002    // Wrap the Shadertoy-style shader in a proper GLSL fragment shader
1003    // We need to:
1004    // 1. Add version and precision qualifiers
1005    // 2. Declare uniforms and samplers
1006    // 3. Add input/output declarations
1007    // 4. Add a main() that calls mainImage()
1008
1009    let wrapped_glsl = format!(
1010        r#"#version 450
1011
1012// Uniforms - must match Rust struct layout (std140)
1013// Total size: 192 bytes
1014layout(set = 0, binding = 0) uniform Uniforms {{
1015    vec2 iResolution;      // offset 0, size 8 - Viewport resolution
1016    float iTime;           // offset 8, size 4 - Time in seconds
1017    float iTimeDelta;      // offset 12, size 4 - Time since last frame
1018    vec4 iMouse;           // offset 16, size 16 - Mouse state (xy=current, zw=click)
1019    vec4 iDate;            // offset 32, size 16 - Date (year, month, day, seconds)
1020    float iOpacity;        // offset 48, size 4 - Window opacity
1021    float iTextOpacity;    // offset 52, size 4 - Text opacity
1022    float iFullContent;    // offset 56, size 4 - Full content mode (1.0 = enabled)
1023    float iFrame;          // offset 60, size 4 - Frame counter
1024    float iFrameRate;      // offset 64, size 4 - Current FPS
1025    float iResolutionZ;    // offset 68, size 4 - Pixel aspect ratio (usually 1.0)
1026    vec2 _pad1;            // offset 72, size 8 - Padding
1027
1028    // Cursor uniforms (Ghostty-compatible, v1.2.0+)
1029    vec4 iCurrentCursor;       // offset 80, size 16 - xy=position, zw=size (pixels)
1030    vec4 iPreviousCursor;      // offset 96, size 16 - xy=previous position, zw=size
1031    vec4 iCurrentCursorColor;  // offset 112, size 16 - RGBA (opacity baked into alpha)
1032    vec4 iPreviousCursorColor; // offset 128, size 16 - RGBA previous color
1033    float iTimeCursorChange;   // offset 144, size 4 - Time when cursor last moved
1034
1035    // Cursor shader configuration uniforms
1036    float iCursorTrailDuration;// offset 148, size 4 - Trail effect duration (seconds)
1037    float iCursorGlowRadius;   // offset 152, size 4 - Glow effect radius (pixels)
1038    float iCursorGlowIntensity;// offset 156, size 4 - Glow effect intensity (0-1)
1039    vec4 iCursorShaderColor;   // offset 160, size 16 - User-configured cursor color (aligned to 16)
1040}};                            // total: 176 bytes
1041
1042// Terminal content texture (iChannel0)
1043layout(set = 0, binding = 1) uniform texture2D _iChannel0Tex;
1044layout(set = 0, binding = 2) uniform sampler _iChannel0Sampler;
1045
1046// Combined sampler for texture() calls
1047#define iChannel0 sampler2D(_iChannel0Tex, _iChannel0Sampler)
1048
1049// Input from vertex shader
1050layout(location = 0) in vec2 v_uv;
1051
1052// Output color
1053layout(location = 0) out vec4 outColor;
1054
1055// ============ User shader code begins ============
1056
1057{glsl_source}
1058
1059// ============ User shader code ends ============
1060
1061void main() {{
1062    vec2 fragCoord = v_uv * iResolution;
1063    vec4 shaderColor;
1064    mainImage(shaderColor, fragCoord);
1065
1066    if (iFullContent > 0.5) {{
1067        // Full content mode: shader output is used directly
1068        // The shader has full control over the terminal content via iChannel0
1069        // Apply window opacity to the shader's alpha output
1070        outColor = vec4(shaderColor.rgb * iOpacity, shaderColor.a * iOpacity);
1071    }} else {{
1072        // Background-only mode: text is composited cleanly on top
1073        // Sample terminal to detect text pixels
1074        vec4 terminalColor = texture(iChannel0, v_uv);
1075        float hasText = step(0.01, terminalColor.a);
1076
1077        // Text pixels: use terminal color with text opacity
1078        // Background pixels: use shader output with window opacity
1079        vec3 textCol = terminalColor.rgb;
1080        vec3 bgCol = shaderColor.rgb;
1081
1082        // Composite: text over shader background
1083        float textA = hasText * iTextOpacity;
1084        float bgA = (1.0 - hasText) * iOpacity;
1085
1086        vec3 finalRgb = textCol * textA + bgCol * bgA;
1087        float finalA = textA + bgA;
1088
1089        outColor = vec4(finalRgb, finalA);
1090    }}
1091}}
1092"#
1093    );
1094
1095    // Parse GLSL using naga
1096    let mut parser = naga::front::glsl::Frontend::default();
1097    let options = naga::front::glsl::Options::from(naga::ShaderStage::Fragment);
1098
1099    let module = parser.parse(&options, &wrapped_glsl).map_err(|errors| {
1100        let error_messages: Vec<String> = errors
1101            .errors
1102            .iter()
1103            .map(|e| format!("  {:?}", e.kind))
1104            .collect();
1105        anyhow::anyhow!(
1106            "GLSL parse error in '{}'. Errors:\n{}",
1107            shader_path.display(),
1108            error_messages.join("\n")
1109        )
1110    })?;
1111
1112    // Validate the module
1113    let info = naga::valid::Validator::new(
1114        naga::valid::ValidationFlags::all(),
1115        naga::valid::Capabilities::all(),
1116    )
1117    .validate(&module)
1118    .map_err(|e| {
1119        anyhow::anyhow!(
1120            "Shader validation failed for '{}': {:?}",
1121            shader_path.display(),
1122            e
1123        )
1124    })?;
1125
1126    // Generate WGSL output for fragment shader
1127    let mut fragment_wgsl = String::new();
1128    let mut writer =
1129        naga::back::wgsl::Writer::new(&mut fragment_wgsl, naga::back::wgsl::WriterFlags::empty());
1130
1131    writer.write(&module, &info).map_err(|e| {
1132        anyhow::anyhow!(
1133            "WGSL generation failed for '{}': {:?}",
1134            shader_path.display(),
1135            e
1136        )
1137    })?;
1138
1139    // The generated WGSL will have a main() function but we need to rename it to fs_main
1140    // and add a vertex shader
1141    let fragment_wgsl = fragment_wgsl.replace("fn main(", "fn fs_main(");
1142
1143    // Build the complete shader with vertex shader
1144    let full_wgsl = format!(
1145        r#"// Auto-generated WGSL from GLSL shader: {}
1146
1147struct VertexOutput {{
1148    @builtin(position) position: vec4<f32>,
1149    @location(0) uv: vec2<f32>,
1150}}
1151
1152@vertex
1153fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {{
1154    var out: VertexOutput;
1155
1156    // Generate full-screen quad vertices (triangle strip)
1157    let x = f32(vertex_index & 1u);
1158    let y = f32((vertex_index >> 1u) & 1u);
1159
1160    // Full screen in NDC
1161    out.position = vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
1162    out.uv = vec2<f32>(x, y);
1163
1164    return out;
1165}}
1166
1167// ============ Fragment shader (transpiled from GLSL) ============
1168
1169{fragment_wgsl}
1170"#,
1171        shader_path.display()
1172    );
1173
1174    Ok(full_wgsl)
1175}
1176
1177/// Transpile a Ghostty/Shadertoy-style GLSL shader to WGSL from source string
1178///
1179/// Same as `transpile_glsl_to_wgsl` but takes a source string and name instead of a file path.
1180fn transpile_glsl_to_wgsl_source(glsl_source: &str, name: &str) -> Result<String> {
1181    // Wrap the Shadertoy-style shader in a proper GLSL fragment shader
1182    let wrapped_glsl = format!(
1183        r#"#version 450
1184
1185// Uniforms - must match Rust struct layout (std140)
1186// Total size: 192 bytes
1187layout(set = 0, binding = 0) uniform Uniforms {{
1188    vec2 iResolution;      // offset 0, size 8 - Viewport resolution
1189    float iTime;           // offset 8, size 4 - Time in seconds
1190    float iTimeDelta;      // offset 12, size 4 - Time since last frame
1191    vec4 iMouse;           // offset 16, size 16 - Mouse state (xy=current, zw=click)
1192    vec4 iDate;            // offset 32, size 16 - Date (year, month, day, seconds)
1193    float iOpacity;        // offset 48, size 4 - Window opacity
1194    float iTextOpacity;    // offset 52, size 4 - Text opacity
1195    float iFullContent;    // offset 56, size 4 - Full content mode (1.0 = enabled)
1196    float iFrame;          // offset 60, size 4 - Frame counter
1197    float iFrameRate;      // offset 64, size 4 - Current FPS
1198    float iResolutionZ;    // offset 68, size 4 - Pixel aspect ratio (usually 1.0)
1199    vec2 _pad1;            // offset 72, size 8 - Padding
1200
1201    // Cursor uniforms (Ghostty-compatible, v1.2.0+)
1202    vec4 iCurrentCursor;       // offset 80, size 16 - xy=position, zw=size (pixels)
1203    vec4 iPreviousCursor;      // offset 96, size 16 - xy=previous position, zw=size
1204    vec4 iCurrentCursorColor;  // offset 112, size 16 - RGBA (opacity baked into alpha)
1205    vec4 iPreviousCursorColor; // offset 128, size 16 - RGBA previous color
1206    float iTimeCursorChange;   // offset 144, size 4 - Time when cursor last moved
1207
1208    // Cursor shader configuration uniforms
1209    float iCursorTrailDuration;// offset 148, size 4 - Trail effect duration (seconds)
1210    float iCursorGlowRadius;   // offset 152, size 4 - Glow effect radius (pixels)
1211    float iCursorGlowIntensity;// offset 156, size 4 - Glow effect intensity (0-1)
1212    vec4 iCursorShaderColor;   // offset 160, size 16 - User-configured cursor color (aligned to 16)
1213}};                            // total: 176 bytes
1214
1215// Terminal content texture (iChannel0)
1216layout(set = 0, binding = 1) uniform texture2D _iChannel0Tex;
1217layout(set = 0, binding = 2) uniform sampler _iChannel0Sampler;
1218
1219// Combined sampler for texture() calls
1220#define iChannel0 sampler2D(_iChannel0Tex, _iChannel0Sampler)
1221
1222// Input from vertex shader
1223layout(location = 0) in vec2 v_uv;
1224
1225// Output color
1226layout(location = 0) out vec4 outColor;
1227
1228// ============ User shader code begins ============
1229
1230{glsl_source}
1231
1232// ============ User shader code ends ============
1233
1234void main() {{
1235    vec2 fragCoord = v_uv * iResolution;
1236    vec4 shaderColor;
1237    mainImage(shaderColor, fragCoord);
1238
1239    if (iFullContent > 0.5) {{
1240        // Full content mode: shader output is used directly
1241        // The shader has full control over the terminal content via iChannel0
1242        // Apply window opacity to the shader's alpha output
1243        outColor = vec4(shaderColor.rgb * iOpacity, shaderColor.a * iOpacity);
1244    }} else {{
1245        // Background-only mode: text is composited cleanly on top
1246        // Sample terminal to detect text pixels
1247        vec4 terminalColor = texture(iChannel0, v_uv);
1248        float hasText = step(0.01, terminalColor.a);
1249
1250        // Text pixels: use terminal color with text opacity
1251        // Background pixels: use shader output with window opacity
1252        vec3 textCol = terminalColor.rgb;
1253        vec3 bgCol = shaderColor.rgb;
1254
1255        // Composite: text over shader background
1256        float textA = hasText * iTextOpacity;
1257        float bgA = (1.0 - hasText) * iOpacity;
1258
1259        vec3 finalRgb = textCol * textA + bgCol * bgA;
1260        float finalA = textA + bgA;
1261
1262        outColor = vec4(finalRgb, finalA);
1263    }}
1264}}
1265"#
1266    );
1267
1268    // Parse GLSL using naga
1269    let mut parser = naga::front::glsl::Frontend::default();
1270    let options = naga::front::glsl::Options::from(naga::ShaderStage::Fragment);
1271
1272    let module = parser.parse(&options, &wrapped_glsl).map_err(|errors| {
1273        let error_messages: Vec<String> = errors
1274            .errors
1275            .iter()
1276            .map(|e| format!("  {:?}", e.kind))
1277            .collect();
1278        anyhow::anyhow!(
1279            "GLSL parse error in '{}'. Errors:\n{}",
1280            name,
1281            error_messages.join("\n")
1282        )
1283    })?;
1284
1285    // Validate the module
1286    let info = naga::valid::Validator::new(
1287        naga::valid::ValidationFlags::all(),
1288        naga::valid::Capabilities::all(),
1289    )
1290    .validate(&module)
1291    .map_err(|e| anyhow::anyhow!("Shader validation failed for '{}': {:?}", name, e))?;
1292
1293    // Generate WGSL output for fragment shader
1294    let mut fragment_wgsl = String::new();
1295    let mut writer =
1296        naga::back::wgsl::Writer::new(&mut fragment_wgsl, naga::back::wgsl::WriterFlags::empty());
1297
1298    writer
1299        .write(&module, &info)
1300        .map_err(|e| anyhow::anyhow!("WGSL generation failed for '{}': {:?}", name, e))?;
1301
1302    // The generated WGSL will have a main() function but we need to rename it to fs_main
1303    // and add a vertex shader
1304    let fragment_wgsl = fragment_wgsl.replace("fn main(", "fn fs_main(");
1305
1306    // Build the complete shader with vertex shader
1307    let full_wgsl = format!(
1308        r#"// Auto-generated WGSL from GLSL shader: {}
1309
1310struct VertexOutput {{
1311    @builtin(position) position: vec4<f32>,
1312    @location(0) uv: vec2<f32>,
1313}}
1314
1315@vertex
1316fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {{
1317    var out: VertexOutput;
1318
1319    // Generate full-screen quad vertices (triangle strip)
1320    let x = f32(vertex_index & 1u);
1321    let y = f32((vertex_index >> 1u) & 1u);
1322
1323    // Full screen in NDC
1324    out.position = vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
1325    out.uv = vec2<f32>(x, y);
1326
1327    return out;
1328}}
1329
1330// ============ Fragment shader (transpiled from GLSL) ============
1331
1332{fragment_wgsl}
1333"#,
1334        name
1335    );
1336
1337    Ok(full_wgsl)
1338}