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