Skip to main content

par_term_config/
shader_config.rs

1//! Shader configuration resolution.
2//!
3//! Handles merging of per-shader configurations from multiple sources:
4//! 1. User overrides (from config.yaml shader_configs)
5//! 2. Shader metadata defaults (from embedded YAML in shader files)
6//! 3. Global defaults (from defaults.rs / Config struct)
7//!
8//! # Three-Tier Resolution Chain
9//!
10//! Shader configuration follows a three-tier priority system, from highest to lowest:
11//!
12//! ```text
13//! Tier 1 — User override  (config.yaml → shader_configs / cursor_shader_configs)
14//!     ↓ (field absent → fall through)
15//! Tier 2 — Shader metadata  (embedded YAML header inside the .glsl file)
16//!     ↓ (field absent → fall through)
17//! Tier 3 — Global defaults  (Config struct fields, e.g. custom_shader_animation_speed)
18//! ```
19//!
20//! Each field is resolved independently through this chain: a user override for
21//! `animation_speed` doesn't block metadata defaults from supplying `brightness`.
22//!
23//! ## How Each Tier Is Populated
24//!
25//! - **Tier 1** (`ShaderConfig` / `CursorShaderConfig`): loaded from `config.yaml`
26//!   under the `shader_configs` / `cursor_shader_configs` maps, keyed by shader name.
27//!   All fields are `Option<T>` — absent means "don't override".
28//!
29//! - **Tier 2** (`ShaderMetadata` / `CursorShaderMetadata`): parsed by
30//!   `parse_shader_metadata()` / `parse_cursor_shader_metadata()` from a YAML block
31//!   embedded at the top of the `.glsl` file. Cached in
32//!   `ShaderMetadataCache` / `CursorShaderMetadataCache` (in `shader_metadata.rs`)
33//!   so disk reads happen only once per shader file per session.
34//!
35//! - **Tier 3** (`Config` fields): the global `Config` struct holds scalar defaults
36//!   for every shader parameter (e.g., `custom_shader_animation_speed: f32`). These
37//!   are always present and act as the final fallback.
38//!
39//! ## Entry Points
40//!
41//! - [`resolve_shader_config`]: resolve a background shader config.
42//! - [`resolve_cursor_shader_config`]: resolve a cursor shader config.
43//! - [`ResolvedShaderConfig::for_shader`]: convenience wrapper that looks up the user
44//!   override by shader name and delegates to `resolve_shader_config`.
45//!
46//! ## Caching
47//!
48//! Metadata parsing is cached via `ShaderMetadataCache` to avoid re-reading `.glsl`
49//! files on every frame. The cache is held in `WindowState` and populated lazily
50//! on first use of each shader. Config resolution itself is not cached — it is cheap
51//! (a few `Option::and_then` calls) and runs only when the active shader changes.
52
53use crate::config::Config;
54use crate::types::{
55    CursorShaderConfig, CursorShaderMetadata, ResolvedCursorShaderConfig, ResolvedShaderConfig,
56    ShaderBackgroundBlendMode, ShaderConfig, ShaderMetadata,
57};
58use std::collections::BTreeMap;
59use std::path::PathBuf;
60
61/// Resolve a shader configuration by merging sources in priority order.
62///
63/// Priority (highest to lowest):
64/// 1. User overrides from config.yaml
65/// 2. Defaults embedded in shader metadata
66/// 3. Global defaults from Config
67///
68/// # Arguments
69/// * `user_override` - Optional user overrides from config.yaml
70/// * `metadata` - Optional shader metadata with embedded defaults
71/// * `config` - Global config for fallback values
72///
73/// # Returns
74/// A fully resolved configuration with all values filled in
75pub fn resolve_shader_config(
76    user_override: Option<&ShaderConfig>,
77    metadata: Option<&ShaderMetadata>,
78    config: &Config,
79) -> ResolvedShaderConfig {
80    // Extract metadata defaults if available
81    let meta_defaults = metadata.map(|m| &m.defaults);
82
83    // Helper to resolve a single value through the priority chain
84    macro_rules! resolve {
85        ($field:ident, $global:expr) => {
86            user_override
87                .and_then(|o| o.$field.clone())
88                .or_else(|| meta_defaults.and_then(|m| m.$field.clone()))
89                .unwrap_or($global)
90        };
91    }
92
93    // Helper for Option<String> -> Option<PathBuf> with path resolution
94    // An explicit empty string means "no texture" (don't fall back to defaults)
95    macro_rules! resolve_path {
96        ($field:ident, $global:expr) => {{
97            // Check for user override first
98            if let Some(override_val) = user_override.and_then(|o| o.$field.clone()) {
99                if override_val.is_empty() {
100                    None // User explicitly cleared this channel
101                } else {
102                    Some(Config::resolve_texture_path(&override_val))
103                }
104            } else {
105                // No user override, fall back to metadata then global
106                let path_str: Option<String> =
107                    meta_defaults.and_then(|m| m.$field.clone()).or($global);
108                path_str
109                    .filter(|p| !p.is_empty())
110                    .map(|p| Config::resolve_texture_path(&p))
111            }
112        }};
113    }
114
115    let mut custom_uniforms = metadata
116        .map(|m| m.defaults.uniforms.clone())
117        .unwrap_or_default();
118    if let Some(user_override) = user_override {
119        custom_uniforms.extend(user_override.uniforms.clone());
120    }
121
122    let global_brightness = config.shader.custom_shader_brightness;
123    let default_brightness = crate::defaults::custom_shader_brightness();
124    let brightness = user_override
125        .and_then(|override_config| override_config.brightness)
126        .or_else(|| {
127            if (global_brightness - default_brightness).abs() > f32::EPSILON {
128                Some(global_brightness)
129            } else {
130                meta_defaults.and_then(|defaults| defaults.brightness)
131            }
132        })
133        .unwrap_or(global_brightness);
134
135    ResolvedShaderConfig {
136        animation_speed: resolve!(animation_speed, config.shader.custom_shader_animation_speed),
137        brightness,
138        text_opacity: resolve!(text_opacity, config.shader.custom_shader_text_opacity),
139        full_content: resolve!(full_content, config.shader.custom_shader_full_content),
140        channel0: resolve_path!(channel0, config.shader.custom_shader_channel0.clone()),
141        channel1: resolve_path!(channel1, config.shader.custom_shader_channel1.clone()),
142        channel2: resolve_path!(channel2, config.shader.custom_shader_channel2.clone()),
143        channel3: resolve_path!(channel3, config.shader.custom_shader_channel3.clone()),
144        cubemap: resolve_path!(cubemap, config.shader.custom_shader_cubemap.clone()),
145        cubemap_enabled: resolve!(cubemap_enabled, config.shader.custom_shader_cubemap_enabled),
146        use_background_as_channel0: resolve!(
147            use_background_as_channel0,
148            config.shader.custom_shader_use_background_as_channel0
149        ),
150        background_channel0_blend_mode: resolve!(
151            background_channel0_blend_mode,
152            config.shader.custom_shader_background_channel0_blend_mode
153        ),
154        auto_dim_under_text: resolve!(
155            auto_dim_under_text,
156            config.shader.custom_shader_auto_dim_under_text
157        ),
158        auto_dim_strength: resolve!(
159            auto_dim_strength,
160            config.shader.custom_shader_auto_dim_strength
161        )
162        .clamp(0.0, 1.0),
163        custom_uniforms,
164    }
165}
166
167/// Resolve a cursor shader configuration by merging sources in priority order.
168///
169/// Priority (highest to lowest):
170/// 1. User overrides from config.yaml cursor_shader_configs
171/// 2. Defaults embedded in cursor shader metadata
172/// 3. Global defaults from Config
173///
174/// # Arguments
175/// * `user_override` - Optional user overrides from config.yaml
176/// * `metadata` - Optional cursor shader metadata with embedded defaults
177/// * `config` - Global config for fallback values
178///
179/// # Returns
180/// A fully resolved cursor shader configuration with all values filled in
181pub fn resolve_cursor_shader_config(
182    user_override: Option<&CursorShaderConfig>,
183    metadata: Option<&CursorShaderMetadata>,
184    config: &Config,
185) -> ResolvedCursorShaderConfig {
186    // Extract metadata defaults if available
187    let meta_defaults = metadata.map(|m| &m.defaults);
188
189    // Helper to resolve a cursor-specific value through the priority chain
190    macro_rules! resolve_cursor {
191        ($field:ident, $global:expr) => {
192            user_override
193                .and_then(|o| o.$field)
194                .or_else(|| meta_defaults.and_then(|m| m.$field))
195                .unwrap_or($global)
196        };
197    }
198
199    // Resolve base shader settings (animation_speed comes from base)
200    let animation_speed = user_override
201        .and_then(|o| o.base.animation_speed)
202        .or_else(|| meta_defaults.and_then(|m| m.base.animation_speed))
203        .unwrap_or(config.shader.cursor_shader_animation_speed);
204
205    // Build a minimal resolved base config for cursor shader
206    // (cursor shaders don't use most of the base shader features)
207    let base = ResolvedShaderConfig {
208        animation_speed,
209        brightness: 1.0,
210        text_opacity: 1.0,
211        full_content: true, // Cursor shaders always use full content
212        channel0: None,
213        channel1: None,
214        channel2: None,
215        channel3: None,
216        cubemap: None,
217        cubemap_enabled: false,
218        use_background_as_channel0: false,
219        background_channel0_blend_mode: ShaderBackgroundBlendMode::Replace,
220        auto_dim_under_text: false,
221        auto_dim_strength: 0.35,
222        custom_uniforms: BTreeMap::new(),
223    };
224
225    // Resolve cursor-specific values
226    let hides_cursor = resolve_cursor!(hides_cursor, config.shader.cursor_shader_hides_cursor);
227    let disable_in_alt_screen = resolve_cursor!(
228        disable_in_alt_screen,
229        config.shader.cursor_shader_disable_in_alt_screen
230    );
231    let glow_radius = resolve_cursor!(glow_radius, config.shader.cursor_shader_glow_radius);
232    let glow_intensity =
233        resolve_cursor!(glow_intensity, config.shader.cursor_shader_glow_intensity);
234    let trail_duration =
235        resolve_cursor!(trail_duration, config.shader.cursor_shader_trail_duration);
236    let cursor_color = user_override
237        .and_then(|o| o.cursor_color)
238        .or_else(|| meta_defaults.and_then(|m| m.cursor_color))
239        .unwrap_or(config.shader.cursor_shader_color);
240
241    ResolvedCursorShaderConfig {
242        base,
243        hides_cursor,
244        disable_in_alt_screen,
245        glow_radius,
246        glow_intensity,
247        trail_duration,
248        cursor_color,
249    }
250}
251
252impl ResolvedShaderConfig {
253    /// Resolve a shader config for a specific shader.
254    ///
255    /// This is a convenience method that looks up the user override and
256    /// combines it with metadata and global config.
257    ///
258    /// # Arguments
259    /// * `shader_name` - Name of the shader file (e.g., "crt.glsl")
260    /// * `metadata` - Optional shader metadata
261    /// * `config` - Global config
262    pub fn for_shader(
263        shader_name: &str,
264        metadata: Option<&ShaderMetadata>,
265        config: &Config,
266    ) -> Self {
267        let user_override = config.get_shader_override(shader_name);
268        resolve_shader_config(user_override, metadata, config)
269    }
270
271    /// Get channel paths as an array suitable for passing to the renderer.
272    pub fn channel_paths(&self) -> [Option<PathBuf>; 4] {
273        [
274            self.channel0.clone(),
275            self.channel1.clone(),
276            self.channel2.clone(),
277            self.channel3.clone(),
278        ]
279    }
280
281    /// Get the cubemap path if configured.
282    pub fn cubemap_path(&self) -> Option<&PathBuf> {
283        if self.cubemap_enabled {
284            self.cubemap.as_ref()
285        } else {
286            None
287        }
288    }
289}
290
291impl ResolvedCursorShaderConfig {
292    /// Resolve a cursor shader config for a specific shader.
293    ///
294    /// # Arguments
295    /// * `shader_name` - Name of the cursor shader file
296    /// * `metadata` - Optional cursor shader metadata
297    /// * `config` - Global config
298    pub fn for_shader(
299        shader_name: &str,
300        metadata: Option<&CursorShaderMetadata>,
301        config: &Config,
302    ) -> Self {
303        let user_override = config.get_cursor_shader_override(shader_name);
304        resolve_cursor_shader_config(user_override, metadata, config)
305    }
306}
307
308/// Default global values for shader configuration.
309///
310/// These are used when neither user override nor metadata provide a value.
311pub mod global_defaults {
312    pub const ANIMATION_SPEED: f32 = 1.0;
313    pub const BRIGHTNESS: f32 = 1.0;
314    pub const TEXT_OPACITY: f32 = 1.0;
315    pub const FULL_CONTENT: bool = false;
316    pub const CUBEMAP_ENABLED: bool = true;
317
318    // Cursor shader defaults
319    pub const GLOW_RADIUS: f32 = 80.0;
320    pub const GLOW_INTENSITY: f32 = 0.3;
321    pub const TRAIL_DURATION: f32 = 0.5;
322    pub const CURSOR_COLOR: [u8; 3] = [255, 255, 255];
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::{ShaderBackgroundBlendMode, ShaderConfig, ShaderUniformValue};
329    use std::collections::BTreeMap;
330
331    fn make_test_config() -> Config {
332        Config::default()
333    }
334
335    #[test]
336    fn resolves_background_channel0_blend_mode_from_global_default() {
337        let config = Config::default();
338        let resolved = resolve_shader_config(None, None, &config);
339
340        assert_eq!(
341            resolved.background_channel0_blend_mode,
342            ShaderBackgroundBlendMode::Replace
343        );
344    }
345
346    #[test]
347    fn resolves_background_channel0_blend_mode_override_over_metadata() {
348        let config = Config::default();
349        let metadata = ShaderMetadata {
350            name: Some("Blend Metadata".to_string()),
351            defaults: ShaderConfig {
352                background_channel0_blend_mode: Some(ShaderBackgroundBlendMode::Multiply),
353                ..Default::default()
354            },
355            ..Default::default()
356        };
357        let override_config = ShaderConfig {
358            background_channel0_blend_mode: Some(ShaderBackgroundBlendMode::Screen),
359            ..Default::default()
360        };
361
362        let resolved = resolve_shader_config(Some(&override_config), Some(&metadata), &config);
363
364        assert_eq!(
365            resolved.background_channel0_blend_mode,
366            ShaderBackgroundBlendMode::Screen
367        );
368    }
369
370    #[test]
371    fn preserves_builtin_texture_ids_from_shader_config_sources() {
372        const BUILTIN: &str = "builtin://noise/value-256";
373
374        let mut global_config = Config::default();
375        global_config.shader.custom_shader_channel0 = Some(BUILTIN.to_string());
376        let resolved = resolve_shader_config(None, None, &global_config);
377        assert_eq!(
378            resolved
379                .channel0
380                .expect("global channel0")
381                .display()
382                .to_string(),
383            BUILTIN
384        );
385
386        let metadata = ShaderMetadata {
387            defaults: ShaderConfig {
388                channel0: Some(BUILTIN.to_string()),
389                ..Default::default()
390            },
391            ..Default::default()
392        };
393        let resolved = resolve_shader_config(None, Some(&metadata), &Config::default());
394        assert_eq!(
395            resolved
396                .channel0
397                .expect("metadata channel0")
398                .display()
399                .to_string(),
400            BUILTIN
401        );
402
403        let mut override_config = Config::default();
404        override_config.shader_overrides.shader_configs.insert(
405            "test.glsl".to_string(),
406            ShaderConfig {
407                channel0: Some(BUILTIN.to_string()),
408                ..Default::default()
409            },
410        );
411        let resolved = ResolvedShaderConfig::for_shader("test.glsl", None, &override_config);
412        assert_eq!(
413            resolved
414                .channel0
415                .expect("override channel0")
416                .display()
417                .to_string(),
418            BUILTIN
419        );
420    }
421
422    #[test]
423    fn test_resolve_with_no_overrides() {
424        let config = make_test_config();
425        let resolved = resolve_shader_config(None, None, &config);
426
427        assert_eq!(
428            resolved.animation_speed,
429            config.shader.custom_shader_animation_speed
430        );
431        assert_eq!(resolved.brightness, config.shader.custom_shader_brightness);
432        assert_eq!(
433            resolved.text_opacity,
434            config.shader.custom_shader_text_opacity
435        );
436        assert_eq!(
437            resolved.full_content,
438            config.shader.custom_shader_full_content
439        );
440    }
441
442    #[test]
443    fn test_resolve_with_metadata_defaults() {
444        let config = make_test_config();
445        let shader_defaults = ShaderConfig {
446            animation_speed: Some(0.5),
447            brightness: Some(0.7),
448            ..Default::default()
449        };
450
451        let metadata = ShaderMetadata {
452            name: Some("Test".to_string()),
453            defaults: shader_defaults,
454            ..Default::default()
455        };
456
457        let resolved = resolve_shader_config(None, Some(&metadata), &config);
458
459        assert_eq!(resolved.animation_speed, 0.5);
460        assert_eq!(resolved.brightness, 0.7);
461        // Others should use global defaults
462        assert_eq!(
463            resolved.text_opacity,
464            config.shader.custom_shader_text_opacity
465        );
466    }
467
468    #[test]
469    fn test_resolve_with_user_override() {
470        let config = make_test_config();
471        let user_override = ShaderConfig {
472            animation_speed: Some(2.0),
473            brightness: Some(0.9),
474            ..Default::default()
475        };
476
477        let shader_defaults = ShaderConfig {
478            animation_speed: Some(0.5), // Should be overridden
479            text_opacity: Some(0.8),    // Should be used (no user override)
480            ..Default::default()
481        };
482
483        let metadata = ShaderMetadata {
484            name: Some("Test".to_string()),
485            defaults: shader_defaults,
486            ..Default::default()
487        };
488
489        let resolved = resolve_shader_config(Some(&user_override), Some(&metadata), &config);
490
491        // User override takes priority
492        assert_eq!(resolved.animation_speed, 2.0);
493        assert_eq!(resolved.brightness, 0.9);
494        // Metadata default used when no user override
495        assert_eq!(resolved.text_opacity, 0.8);
496    }
497
498    #[test]
499    fn global_brightness_override_beats_metadata_default() {
500        let mut config = make_test_config();
501        config.shader.custom_shader_brightness = 0.42;
502        let metadata = ShaderMetadata {
503            name: Some("Test".to_string()),
504            defaults: ShaderConfig {
505                brightness: Some(0.7),
506                ..Default::default()
507            },
508            ..Default::default()
509        };
510
511        let resolved = resolve_shader_config(None, Some(&metadata), &config);
512
513        assert_eq!(resolved.brightness, 0.42);
514    }
515
516    #[test]
517    fn resolve_custom_uniforms_user_override_beats_metadata_default() {
518        let config = make_test_config();
519        let user_override = ShaderConfig {
520            uniforms: BTreeMap::from([
521                ("iGlow".to_string(), ShaderUniformValue::Float(0.9)),
522                ("iUserOnly".to_string(), ShaderUniformValue::Bool(true)),
523            ]),
524            ..Default::default()
525        };
526        let metadata = ShaderMetadata {
527            defaults: ShaderConfig {
528                uniforms: BTreeMap::from([
529                    ("iGlow".to_string(), ShaderUniformValue::Float(0.4)),
530                    ("iMetaOnly".to_string(), ShaderUniformValue::Bool(false)),
531                ]),
532                ..Default::default()
533            },
534            ..Default::default()
535        };
536
537        let resolved = resolve_shader_config(Some(&user_override), Some(&metadata), &config);
538
539        assert_eq!(
540            resolved.custom_uniforms.get("iGlow"),
541            Some(&ShaderUniformValue::Float(0.9))
542        );
543        assert_eq!(
544            resolved.custom_uniforms.get("iMetaOnly"),
545            Some(&ShaderUniformValue::Bool(false))
546        );
547        assert_eq!(
548            resolved.custom_uniforms.get("iUserOnly"),
549            Some(&ShaderUniformValue::Bool(true))
550        );
551    }
552
553    #[test]
554    fn resolve_custom_uniforms_metadata_default_used_when_no_override() {
555        let config = make_test_config();
556        let metadata = ShaderMetadata {
557            defaults: ShaderConfig {
558                uniforms: BTreeMap::from([("iGlow".to_string(), ShaderUniformValue::Float(0.4))]),
559                ..Default::default()
560            },
561            ..Default::default()
562        };
563
564        let resolved = resolve_shader_config(None, Some(&metadata), &config);
565
566        assert_eq!(
567            resolved.custom_uniforms.get("iGlow"),
568            Some(&ShaderUniformValue::Float(0.4))
569        );
570    }
571
572    #[test]
573    fn test_shader_config_uniforms_yaml_roundtrip() {
574        let config = ShaderConfig {
575            uniforms: BTreeMap::from([
576                ("iGlow".to_string(), ShaderUniformValue::Float(0.75)),
577                ("iEnabled".to_string(), ShaderUniformValue::Bool(true)),
578            ]),
579            ..Default::default()
580        };
581
582        let yaml = serde_yaml_ng::to_string(&config).expect("serialize shader config");
583        let roundtrip: ShaderConfig =
584            serde_yaml_ng::from_str(&yaml).expect("deserialize shader config");
585
586        assert_eq!(roundtrip, config);
587    }
588
589    #[test]
590    fn test_shader_config_color_uniforms_serialize_as_hex() {
591        let config = ShaderConfig {
592            uniforms: BTreeMap::from([
593                (
594                    "iTint".to_string(),
595                    ShaderUniformValue::Color(crate::types::shader::ShaderColorValue([
596                        1.0, 0.5, 0.0, 1.0,
597                    ])),
598                ),
599                (
600                    "iOverlay".to_string(),
601                    ShaderUniformValue::Color(crate::types::shader::ShaderColorValue([
602                        1.0, 0.5, 0.0, 0.8,
603                    ])),
604                ),
605            ]),
606            ..Default::default()
607        };
608
609        let yaml = serde_yaml_ng::to_string(&config).expect("serialize shader config");
610
611        assert!(yaml.contains("iTint: '#ff8000'"));
612        assert!(yaml.contains("iOverlay: '#ff8000cc'"));
613        let roundtrip: ShaderConfig =
614            serde_yaml_ng::from_str(&yaml).expect("deserialize shader config");
615        assert_eq!(
616            roundtrip.uniforms.get("iTint"),
617            Some(&ShaderUniformValue::Color(
618                crate::types::shader::ShaderColorValue([1.0, 128.0 / 255.0, 0.0, 1.0])
619            ))
620        );
621        assert_eq!(
622            roundtrip.uniforms.get("iOverlay"),
623            Some(&ShaderUniformValue::Color(
624                crate::types::shader::ShaderColorValue([1.0, 128.0 / 255.0, 0.0, 204.0 / 255.0])
625            ))
626        );
627    }
628
629    #[test]
630    fn test_channel_paths() {
631        let resolved = ResolvedShaderConfig {
632            channel0: Some(PathBuf::from("/path/to/tex0.png")),
633            channel1: None,
634            channel2: Some(PathBuf::from("/path/to/tex2.png")),
635            channel3: None,
636            ..Default::default()
637        };
638
639        let paths = resolved.channel_paths();
640        assert!(paths[0].is_some());
641        assert!(paths[1].is_none());
642        assert!(paths[2].is_some());
643        assert!(paths[3].is_none());
644    }
645
646    #[test]
647    fn test_cubemap_path_respects_enabled() {
648        let mut resolved = ResolvedShaderConfig {
649            cubemap: Some(PathBuf::from("/path/to/cubemap")),
650            cubemap_enabled: true,
651            ..Default::default()
652        };
653
654        assert!(resolved.cubemap_path().is_some());
655
656        resolved.cubemap_enabled = false;
657        assert!(resolved.cubemap_path().is_none());
658    }
659}