Skip to main content

cranpose_ui_graphics/
render_effect.rs

1//! Render effects that can be applied to graphics layers.
2//!
3//! Matches the Jetpack Compose `RenderEffect` API with extensions for custom
4//! WGSL shaders (`RuntimeShader`).
5
6use crate::LayerShape;
7use std::sync::{Arc, Mutex, OnceLock, Weak};
8
9const RUNTIME_SHADER_INLINE_UNIFORMS: usize = 16;
10
11/// Edge treatment for blur effects at the boundary of the blurred region.
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
13pub enum TileMode {
14    /// Clamp to the edge pixel color.
15    #[default]
16    Clamp,
17    /// Repeat the gradient/effect from start to end.
18    Repeated,
19    /// Mirror the gradient/effect every other repetition.
20    Mirror,
21    /// Treat pixels outside the boundary as transparent.
22    Decal,
23}
24
25/// Controls blur behavior outside source bounds.
26///
27/// This mirrors Compose's `BlurredEdgeTreatment`:
28/// - bounded treatment (`shape != None`) clips blur output and uses `TileMode::Clamp`
29/// - unbounded treatment (`shape == None`) does not clip and uses `TileMode::Decal`
30#[derive(Clone, Copy, Debug, PartialEq)]
31pub struct BlurredEdgeTreatment {
32    shape: Option<LayerShape>,
33}
34
35impl BlurredEdgeTreatment {
36    /// Bounded treatment that clips to a rectangle.
37    pub const RECTANGLE: Self = Self {
38        shape: Some(LayerShape::Rectangle),
39    };
40
41    /// Unbounded treatment that does not clip blurred output.
42    pub const UNBOUNDED: Self = Self { shape: None };
43
44    /// Bounded treatment with a specific clip shape.
45    pub const fn with_shape(shape: LayerShape) -> Self {
46        Self { shape: Some(shape) }
47    }
48
49    pub fn shape(self) -> Option<LayerShape> {
50        self.shape
51    }
52
53    pub fn clip(self) -> bool {
54        self.shape.is_some()
55    }
56
57    pub fn tile_mode(self) -> TileMode {
58        if self.clip() {
59            TileMode::Clamp
60        } else {
61            TileMode::Decal
62        }
63    }
64}
65
66impl Default for BlurredEdgeTreatment {
67    fn default() -> Self {
68        Self::RECTANGLE
69    }
70}
71
72/// A custom WGSL shader effect, analogous to Android's `RuntimeShader`.
73///
74/// The shader source must be a complete WGSL module that declares:
75/// ```wgsl
76/// @group(0) @binding(0) var input_texture: texture_2d<f32>;
77/// @group(0) @binding(1) var input_sampler: sampler;
78/// @group(1) @binding(0) var<uniform> u: array<vec4<f32>, 64>;
79/// ```
80///
81/// Float uniforms are packed linearly into the `u` array. Access them in WGSL
82/// as `u[index / 4][index % 4]` for individual floats, or `u[index / 4].xy`
83/// for vec2, etc. User uniforms may use indices `0..248`; slots `248..256`
84/// are reserved for renderer metadata.
85///
86/// RuntimeShader pipelines operate on premultiplied-alpha textures. Custom
87/// shaders should preserve premultiplied output semantics.
88#[derive(Clone, Debug)]
89pub struct RuntimeShader {
90    source: Arc<str>,
91    source_hash: u64,
92    uniforms: RuntimeShaderUniforms,
93    input_padding: f32,
94    output_padding: f32,
95}
96
97#[derive(Clone, Debug, PartialEq)]
98struct RuntimeShaderUniforms {
99    len: usize,
100    inline: [f32; RUNTIME_SHADER_INLINE_UNIFORMS],
101    heap: Option<Vec<f32>>,
102}
103
104impl RuntimeShaderUniforms {
105    fn new() -> Self {
106        Self {
107            len: 0,
108            inline: [0.0; RUNTIME_SHADER_INLINE_UNIFORMS],
109            heap: None,
110        }
111    }
112
113    fn as_slice(&self) -> &[f32] {
114        if let Some(heap) = &self.heap {
115            heap.as_slice()
116        } else {
117            &self.inline[..self.len]
118        }
119    }
120
121    fn len(&self) -> usize {
122        self.as_slice().len()
123    }
124
125    fn ensure_len(&mut self, min_len: usize) {
126        if let Some(heap) = &mut self.heap {
127            if heap.len() < min_len {
128                heap.resize(min_len, 0.0);
129            }
130            return;
131        }
132
133        if min_len <= RUNTIME_SHADER_INLINE_UNIFORMS {
134            self.len = self.len.max(min_len);
135            return;
136        }
137
138        let mut heap = Vec::with_capacity(min_len);
139        heap.extend_from_slice(&self.inline[..self.len]);
140        heap.resize(min_len, 0.0);
141        self.heap = Some(heap);
142    }
143
144    fn set(&mut self, index: usize, value: f32) {
145        if let Some(heap) = &mut self.heap {
146            heap[index] = value;
147        } else {
148            self.inline[index] = value;
149        }
150    }
151
152    #[cfg(test)]
153    fn is_inline(&self) -> bool {
154        self.heap.is_none()
155    }
156}
157
158/// Error returned when a shader uniform write targets renderer-owned storage.
159#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
160pub enum RuntimeShaderUniformError {
161    #[error(
162        "uniform range starting at {index} with width {width} exceeds user uniform range 0..{max_user_uniforms}; slots {reserved_start}..{max_uniforms} are reserved for renderer data"
163    )]
164    OutOfUserRange {
165        index: usize,
166        width: usize,
167        max_user_uniforms: usize,
168        reserved_start: usize,
169        max_uniforms: usize,
170    },
171}
172
173impl RuntimeShader {
174    /// Total uniform storage size in floats (64 vec4s = 256 floats).
175    ///
176    /// The final slots are reserved for renderer-managed data.
177    pub const MAX_UNIFORMS: usize = 256;
178    /// First renderer-reserved uniform slot.
179    pub const RESERVED_UNIFORM_START: usize = 248;
180    /// Maximum user-addressable uniform count.
181    pub const MAX_USER_UNIFORMS: usize = Self::RESERVED_UNIFORM_START;
182
183    /// Create a new RuntimeShader from WGSL source code.
184    #[track_caller]
185    pub fn new(wgsl_source: &str) -> Self {
186        let (source, source_hash) =
187            cached_shader_source(std::panic::Location::caller(), wgsl_source);
188        Self {
189            source,
190            source_hash,
191            uniforms: RuntimeShaderUniforms::new(),
192            input_padding: 0.0,
193            output_padding: 0.0,
194        }
195    }
196
197    /// Create a RuntimeShader from shared WGSL source code.
198    ///
199    /// This avoids repeatedly copying large shader modules for animated effects
200    /// that rebuild only their uniform payload every frame.
201    pub fn from_shared_source(source: Arc<str>) -> Self {
202        let source_hash = cached_shared_shader_source_hash(&source);
203        Self {
204            source,
205            source_hash,
206            uniforms: RuntimeShaderUniforms::new(),
207            input_padding: 0.0,
208            output_padding: 0.0,
209        }
210    }
211
212    /// Declares how far the shader may sample outside its effect rect, in
213    /// logical pixels. Backdrop rendering uses this to capture enough input
214    /// around refractive and displacement shaders.
215    pub fn set_input_padding(&mut self, padding: f32) {
216        self.input_padding = if padding.is_finite() {
217            padding.max(0.0)
218        } else {
219            0.0
220        };
221    }
222
223    /// Returns the declared input padding in logical pixels.
224    pub fn input_padding(&self) -> f32 {
225        self.input_padding
226    }
227
228    /// Declares how far the shader WRITES outside its effect rect, in logical
229    /// pixels. Backdrop compositing widens its scissor by this amount so
230    /// SDF-driven coverage (rim glow, wobble, glued neighbor shapes) can
231    /// extend past the node bounds instead of being clipped to them.
232    pub fn set_output_padding(&mut self, padding: f32) {
233        self.output_padding = if padding.is_finite() {
234            padding.max(0.0)
235        } else {
236            0.0
237        };
238    }
239
240    /// Returns the declared output padding in logical pixels.
241    pub fn output_padding(&self) -> f32 {
242        self.output_padding
243    }
244
245    /// Set a single float uniform at the given index.
246    ///
247    /// Invalid renderer-reserved ranges are ignored. Use [`Self::try_set_float`]
248    /// when the caller needs to handle invalid uniform writes explicitly.
249    pub fn set_float(&mut self, index: usize, value: f32) {
250        let _ = self.try_set_float(index, value);
251    }
252
253    /// Set a single float uniform at the given index.
254    pub fn try_set_float(
255        &mut self,
256        index: usize,
257        value: f32,
258    ) -> Result<(), RuntimeShaderUniformError> {
259        self.try_ensure_capacity(index, 1)?;
260        self.uniforms.set(index, value);
261        Ok(())
262    }
263
264    /// Set a vec2 uniform at the given index (consumes indices `[index, index+1]`).
265    ///
266    /// Invalid renderer-reserved ranges are ignored. Use [`Self::try_set_float2`]
267    /// when the caller needs to handle invalid uniform writes explicitly.
268    pub fn set_float2(&mut self, index: usize, x: f32, y: f32) {
269        let _ = self.try_set_float2(index, x, y);
270    }
271
272    /// Set a vec2 uniform at the given index (consumes indices `[index, index+1]`).
273    pub fn try_set_float2(
274        &mut self,
275        index: usize,
276        x: f32,
277        y: f32,
278    ) -> Result<(), RuntimeShaderUniformError> {
279        self.try_ensure_capacity(index, 2)?;
280        self.uniforms.set(index, x);
281        self.uniforms.set(index + 1, y);
282        Ok(())
283    }
284
285    /// Set a vec4 uniform at the given index (consumes indices `[index..index+4]`).
286    ///
287    /// Invalid renderer-reserved ranges are ignored. Use [`Self::try_set_float4`]
288    /// when the caller needs to handle invalid uniform writes explicitly.
289    pub fn set_float4(&mut self, index: usize, x: f32, y: f32, z: f32, w: f32) {
290        let _ = self.try_set_float4(index, x, y, z, w);
291    }
292
293    /// Set a vec4 uniform at the given index (consumes indices `[index..index+4]`).
294    pub fn try_set_float4(
295        &mut self,
296        index: usize,
297        x: f32,
298        y: f32,
299        z: f32,
300        w: f32,
301    ) -> Result<(), RuntimeShaderUniformError> {
302        self.try_ensure_capacity(index, 4)?;
303        self.uniforms.set(index, x);
304        self.uniforms.set(index + 1, y);
305        self.uniforms.set(index + 2, z);
306        self.uniforms.set(index + 3, w);
307        Ok(())
308    }
309
310    /// Get the WGSL source code.
311    pub fn source(&self) -> &str {
312        &self.source
313    }
314
315    /// Get the uniform data as a float slice (for uploading to GPU).
316    pub fn uniforms(&self) -> &[f32] {
317        self.uniforms.as_slice()
318    }
319
320    /// Get the uniform data padded to full 256-float array (for GPU uniform buffer).
321    pub fn uniforms_padded(&self) -> [f32; Self::MAX_UNIFORMS] {
322        let mut padded = [0.0f32; Self::MAX_UNIFORMS];
323        let len = self.uniforms.len().min(Self::MAX_UNIFORMS);
324        padded[..len].copy_from_slice(&self.uniforms.as_slice()[..len]);
325        padded
326    }
327
328    /// Compute a hash of the shader source for pipeline caching.
329    pub fn source_hash(&self) -> u64 {
330        self.source_hash
331    }
332
333    fn try_ensure_capacity(
334        &mut self,
335        index: usize,
336        width: usize,
337    ) -> Result<(), RuntimeShaderUniformError> {
338        let min_len = index
339            .checked_add(width)
340            .ok_or_else(|| Self::uniform_range_error(index, width))?;
341        if min_len > Self::MAX_USER_UNIFORMS {
342            return Err(Self::uniform_range_error(index, width));
343        }
344        self.uniforms.ensure_len(min_len);
345        Ok(())
346    }
347
348    fn uniform_range_error(index: usize, width: usize) -> RuntimeShaderUniformError {
349        RuntimeShaderUniformError::OutOfUserRange {
350            index,
351            width,
352            max_user_uniforms: Self::MAX_USER_UNIFORMS,
353            reserved_start: Self::RESERVED_UNIFORM_START,
354            max_uniforms: Self::MAX_UNIFORMS,
355        }
356    }
357}
358
359impl PartialEq for RuntimeShader {
360    fn eq(&self, other: &Self) -> bool {
361        self.source_hash == other.source_hash
362            && (Arc::ptr_eq(&self.source, &other.source)
363                || self.source.as_ref() == other.source.as_ref())
364            && self.uniforms == other.uniforms
365            && self.input_padding.to_bits() == other.input_padding.to_bits()
366            && self.output_padding.to_bits() == other.output_padding.to_bits()
367    }
368}
369
370fn hash_shader_source(source: &str) -> u64 {
371    const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
372    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
373
374    source
375        .as_bytes()
376        .iter()
377        .fold(FNV_OFFSET_BASIS, |hash, byte| {
378            (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME)
379        })
380}
381
382#[derive(Clone, Copy, Debug, PartialEq, Eq)]
383struct ShaderSourceCallsite {
384    file: &'static str,
385    line: u32,
386    column: u32,
387}
388
389struct CachedShaderSource {
390    callsite: ShaderSourceCallsite,
391    source_hash: u64,
392    source: Arc<str>,
393}
394
395struct CachedSharedShaderSourceHash {
396    byte_ptr: usize,
397    len: usize,
398    source_hash: u64,
399    source: Weak<str>,
400}
401
402fn cached_shared_shader_source_hash(source: &Arc<str>) -> u64 {
403    static CACHE: OnceLock<Mutex<Vec<CachedSharedShaderSourceHash>>> = OnceLock::new();
404    let byte_ptr = source.as_ptr() as usize;
405    let len = source.len();
406    let mut cache = CACHE
407        .get_or_init(|| Mutex::new(Vec::new()))
408        .lock()
409        .unwrap_or_else(|poisoned| poisoned.into_inner());
410
411    cache.retain(|entry| entry.source.strong_count() > 0);
412    if let Some(entry) = cache.iter().find(|entry| {
413        entry.byte_ptr == byte_ptr
414            && entry.len == len
415            && entry
416                .source
417                .upgrade()
418                .is_some_and(|cached| Arc::ptr_eq(&cached, source))
419    }) {
420        return entry.source_hash;
421    }
422
423    let source_hash = hash_shader_source(source);
424    cache.push(CachedSharedShaderSourceHash {
425        byte_ptr,
426        len,
427        source_hash,
428        source: Arc::downgrade(source),
429    });
430    source_hash
431}
432
433fn cached_shader_source(
434    location: &'static std::panic::Location<'static>,
435    source: &str,
436) -> (Arc<str>, u64) {
437    static CACHE: OnceLock<Mutex<Vec<CachedShaderSource>>> = OnceLock::new();
438    let callsite = ShaderSourceCallsite {
439        file: location.file(),
440        line: location.line(),
441        column: location.column(),
442    };
443    let mut cache = CACHE
444        .get_or_init(|| Mutex::new(Vec::new()))
445        .lock()
446        .unwrap_or_else(|poisoned| poisoned.into_inner());
447
448    if let Some(entry) = cache.iter_mut().find(|entry| entry.callsite == callsite) {
449        if entry.source.as_ref() == source {
450            return (entry.source.clone(), entry.source_hash);
451        }
452        let source_hash = hash_shader_source(source);
453        entry.source_hash = source_hash;
454        entry.source = Arc::<str>::from(source);
455        return (entry.source.clone(), entry.source_hash);
456    }
457
458    let source_hash = hash_shader_source(source);
459    let shared = Arc::<str>::from(source);
460    cache.push(CachedShaderSource {
461        callsite,
462        source_hash,
463        source: shared.clone(),
464    });
465    (shared, source_hash)
466}
467
468/// A render effect applied to a graphics layer's rendered content.
469///
470/// Matches Jetpack Compose's `RenderEffect` sealed class hierarchy,
471/// extended with `Shader` for custom WGSL effects.
472#[derive(Clone, Debug, PartialEq)]
473pub enum RenderEffect {
474    /// Gaussian blur applied to the layer's rendered content.
475    Blur {
476        radius_x: f32,
477        radius_y: f32,
478        edge_treatment: TileMode,
479    },
480    /// Offset the rendered content by a fixed amount.
481    Offset { offset_x: f32, offset_y: f32 },
482    /// Apply a custom WGSL shader effect.
483    Shader { shader: RuntimeShader },
484    /// Chain two effects: apply `first`, then apply `second` to the result.
485    Chain {
486        first: Box<RenderEffect>,
487        second: Box<RenderEffect>,
488    },
489}
490
491impl RenderEffect {
492    /// Create a blur effect with equal radius in both directions.
493    pub fn blur(radius: f32) -> Self {
494        Self::blur_with_edge_treatment(radius, TileMode::default())
495    }
496
497    /// Create a blur effect with equal radius in both directions and explicit
498    /// edge treatment semantics.
499    pub fn blur_with_edge_treatment(radius: f32, edge_treatment: TileMode) -> Self {
500        Self::Blur {
501            radius_x: radius,
502            radius_y: radius,
503            edge_treatment,
504        }
505    }
506
507    /// Create a blur effect with separate horizontal and vertical radii.
508    pub fn blur_xy(radius_x: f32, radius_y: f32, edge_treatment: TileMode) -> Self {
509        Self::Blur {
510            radius_x,
511            radius_y,
512            edge_treatment,
513        }
514    }
515
516    /// Create an offset effect.
517    pub fn offset(offset_x: f32, offset_y: f32) -> Self {
518        Self::Offset { offset_x, offset_y }
519    }
520
521    /// Create a custom shader effect from a RuntimeShader.
522    pub fn runtime_shader(shader: RuntimeShader) -> Self {
523        Self::Shader { shader }
524    }
525
526    /// Chain this effect with another: `self` is applied first, then `other`.
527    pub fn then(self, other: RenderEffect) -> Self {
528        Self::Chain {
529            first: Box::new(self),
530            second: Box::new(other),
531        }
532    }
533
534    /// Returns `true` if this effect or any chained sub-effect is a
535    /// `RuntimeShader`. Animated shaders produce different output every frame,
536    /// so layer surface caching is counterproductive for them.
537    pub fn contains_runtime_shader(&self) -> bool {
538        match self {
539            RenderEffect::Shader { .. } => true,
540            RenderEffect::Chain { first, second } => {
541                first.contains_runtime_shader() || second.contains_runtime_shader()
542            }
543            _ => false,
544        }
545    }
546
547    /// Maximum logical-pixel input padding required by this effect.
548    pub fn input_padding(&self) -> f32 {
549        match self {
550            RenderEffect::Blur {
551                radius_x, radius_y, ..
552            } => radius_x.abs().max(radius_y.abs()),
553            RenderEffect::Offset { offset_x, offset_y } => offset_x.abs().max(offset_y.abs()),
554            RenderEffect::Shader { shader } => shader.input_padding(),
555            RenderEffect::Chain { first, second } => first.input_padding() + second.input_padding(),
556        }
557    }
558
559    /// Maximum logical-pixel distance this effect WRITES outside its rect.
560    /// Only runtime shaders may declare one (SDF coverage past node bounds);
561    /// blur/offset stay confined to their tight rect.
562    pub fn output_padding(&self) -> f32 {
563        match self {
564            RenderEffect::Blur { .. } | RenderEffect::Offset { .. } => 0.0,
565            RenderEffect::Shader { shader } => shader.output_padding(),
566            RenderEffect::Chain { first, second } => {
567                first.output_padding() + second.output_padding()
568            }
569        }
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use crate::RoundedCornerShape;
577
578    #[test]
579    fn runtime_shader_set_uniforms() {
580        let mut shader = RuntimeShader::new("// test");
581        shader.set_float(0, 1.0);
582        shader.set_float2(2, 3.0, 4.0);
583        shader.set_float4(4, 5.0, 6.0, 7.0, 8.0);
584
585        assert_eq!(shader.uniforms()[0], 1.0);
586        assert_eq!(shader.uniforms()[1], 0.0); // gap
587        assert_eq!(shader.uniforms()[2], 3.0);
588        assert_eq!(shader.uniforms()[3], 4.0);
589        assert_eq!(shader.uniforms()[4], 5.0);
590        assert_eq!(shader.uniforms()[5], 6.0);
591        assert_eq!(shader.uniforms()[6], 7.0);
592        assert_eq!(shader.uniforms()[7], 8.0);
593    }
594
595    #[test]
596    fn runtime_shader_padded() {
597        let mut shader = RuntimeShader::new("// test");
598        shader.set_float(0, 42.0);
599        let padded = shader.uniforms_padded();
600        assert_eq!(padded[0], 42.0);
601        assert_eq!(padded[1], 0.0);
602        assert_eq!(padded[255], 0.0);
603    }
604
605    #[test]
606    fn blur_and_offset_declare_input_padding() {
607        assert_eq!(
608            RenderEffect::blur_xy(6.0, 12.0, TileMode::Clamp).input_padding(),
609            12.0
610        );
611        assert_eq!(RenderEffect::offset(-8.0, 3.0).input_padding(), 8.0);
612    }
613
614    #[test]
615    fn chained_effect_padding_accumulates_sampling_ranges() {
616        let mut shader = RuntimeShader::new("// test");
617        shader.set_input_padding(9.0);
618        let effect = RenderEffect::blur_xy(4.0, 6.0, TileMode::Clamp)
619            .then(RenderEffect::runtime_shader(shader))
620            .then(RenderEffect::offset(2.0, -5.0));
621
622        assert_eq!(effect.input_padding(), 20.0);
623    }
624
625    #[test]
626    fn runtime_shader_keeps_common_uniform_payload_inline() {
627        let mut shader = RuntimeShader::new("// test");
628        shader.set_float4(0, 1.0, 2.0, 3.0, 4.0);
629        shader.set_float4(4, 5.0, 6.0, 7.0, 8.0);
630        shader.set_float4(8, 9.0, 10.0, 11.0, 12.0);
631        shader.set_float4(12, 13.0, 14.0, 15.0, 16.0);
632
633        assert!(shader.uniforms.is_inline());
634        assert_eq!(shader.uniforms().len(), 16);
635
636        shader.set_float(16, 17.0);
637        assert!(!shader.uniforms.is_inline());
638        assert_eq!(shader.uniforms()[16], 17.0);
639    }
640
641    #[test]
642    fn runtime_shader_try_set_reports_reserved_uniform_slots() {
643        let mut shader = RuntimeShader::new("// test");
644
645        let err = shader
646            .try_set_float(RuntimeShader::RESERVED_UNIFORM_START, 1.0)
647            .unwrap_err();
648        assert_eq!(
649            err,
650            RuntimeShaderUniformError::OutOfUserRange {
651                index: RuntimeShader::RESERVED_UNIFORM_START,
652                width: 1,
653                max_user_uniforms: RuntimeShader::MAX_USER_UNIFORMS,
654                reserved_start: RuntimeShader::RESERVED_UNIFORM_START,
655                max_uniforms: RuntimeShader::MAX_UNIFORMS,
656            }
657        );
658        assert!(shader.uniforms().is_empty());
659
660        let err = shader
661            .try_set_float4(RuntimeShader::MAX_USER_UNIFORMS - 3, 1.0, 2.0, 3.0, 4.0)
662            .unwrap_err();
663        assert_eq!(
664            err,
665            RuntimeShaderUniformError::OutOfUserRange {
666                index: RuntimeShader::MAX_USER_UNIFORMS - 3,
667                width: 4,
668                max_user_uniforms: RuntimeShader::MAX_USER_UNIFORMS,
669                reserved_start: RuntimeShader::RESERVED_UNIFORM_START,
670                max_uniforms: RuntimeShader::MAX_UNIFORMS,
671            }
672        );
673    }
674
675    #[test]
676    fn runtime_shader_setters_ignore_invalid_uniform_slots_without_panicking() {
677        let mut shader = RuntimeShader::new("// test");
678        shader.set_float(0, 7.0);
679
680        shader.set_float(RuntimeShader::RESERVED_UNIFORM_START, 1.0);
681        shader.set_float4(RuntimeShader::MAX_USER_UNIFORMS - 3, 1.0, 2.0, 3.0, 4.0);
682
683        assert_eq!(shader.uniforms(), &[7.0]);
684    }
685
686    #[test]
687    fn render_effect_chaining() {
688        let blur = RenderEffect::blur(10.0);
689        let offset = RenderEffect::offset(5.0, 5.0);
690        let chained = blur.then(offset);
691        match chained {
692            RenderEffect::Chain { first, second } => {
693                assert!(matches!(*first, RenderEffect::Blur { .. }));
694                assert!(matches!(*second, RenderEffect::Offset { .. }));
695            }
696            _ => panic!("expected Chain"),
697        }
698    }
699
700    #[test]
701    fn blur_convenience() {
702        let effect = RenderEffect::blur(15.0);
703        match effect {
704            RenderEffect::Blur {
705                radius_x,
706                radius_y,
707                edge_treatment,
708            } => {
709                assert_eq!(radius_x, 15.0);
710                assert_eq!(radius_y, 15.0);
711                assert_eq!(edge_treatment, TileMode::Clamp);
712            }
713            _ => panic!("expected Blur"),
714        }
715    }
716
717    #[test]
718    fn blur_with_edge_treatment_uses_explicit_mode() {
719        let effect = RenderEffect::blur_with_edge_treatment(6.0, TileMode::Decal);
720        match effect {
721            RenderEffect::Blur {
722                radius_x,
723                radius_y,
724                edge_treatment,
725            } => {
726                assert_eq!(radius_x, 6.0);
727                assert_eq!(radius_y, 6.0);
728                assert_eq!(edge_treatment, TileMode::Decal);
729            }
730            _ => panic!("expected Blur"),
731        }
732    }
733
734    #[test]
735    fn source_hash_consistent() {
736        let s1 = RuntimeShader::new("fn main() {}");
737        let s2 = RuntimeShader::new("fn main() {}");
738        assert_eq!(s1.source_hash(), s2.source_hash());
739    }
740
741    #[test]
742    fn runtime_shader_from_shared_source_reuses_shared_source() {
743        let source = Arc::<str>::from("fn fragment() -> vec4<f32> { return vec4<f32>(1.0); }");
744        let s1 = RuntimeShader::from_shared_source(source.clone());
745        let s2 = RuntimeShader::from_shared_source(source);
746
747        assert!(Arc::ptr_eq(&s1.source, &s2.source));
748        assert_eq!(s1.source_hash(), s2.source_hash());
749    }
750
751    fn runtime_shader_from_reuse_callsite(source: &str) -> RuntimeShader {
752        RuntimeShader::new(source)
753    }
754
755    fn runtime_shader_from_replacement_callsite(source: &str) -> RuntimeShader {
756        RuntimeShader::new(source)
757    }
758
759    #[test]
760    fn runtime_shader_new_reuses_same_callsite_source() {
761        let source = "fn fragment() -> vec4<f32> { return vec4<f32>(1.0); }";
762        let s1 = runtime_shader_from_reuse_callsite(source);
763        let s2 = runtime_shader_from_reuse_callsite(source);
764
765        assert!(Arc::ptr_eq(&s1.source, &s2.source));
766        assert_eq!(s1.source_hash(), s2.source_hash());
767    }
768
769    #[test]
770    fn runtime_shader_new_replaces_changed_callsite_source() {
771        let s1 = runtime_shader_from_replacement_callsite("fn a() {}");
772        let s2 = runtime_shader_from_replacement_callsite("fn b() {}");
773
774        assert!(!Arc::ptr_eq(&s1.source, &s2.source));
775        assert_ne!(s1.source_hash(), s2.source_hash());
776        assert_eq!(s2.source(), "fn b() {}");
777    }
778
779    #[test]
780    fn runtime_shader_source_storage_has_no_process_global_interner() {
781        let source = include_str!("render_effect.rs");
782        let blocked_static = ["static ", "INTERNER"].concat();
783        let blocked_type = ["ShaderSource", "Interner"].concat();
784
785        assert!(
786            !source.contains(&blocked_static) && !source.contains(&blocked_type),
787            "RuntimeShader source sharing must be explicit via from_shared_source, not a process-global interner"
788        );
789    }
790
791    #[test]
792    fn blur_xy_preserves_tile_mode() {
793        let effect = RenderEffect::blur_xy(3.0, 7.0, TileMode::Clamp);
794        match effect {
795            RenderEffect::Blur {
796                radius_x,
797                radius_y,
798                edge_treatment,
799            } => {
800                assert_eq!(radius_x, 3.0);
801                assert_eq!(radius_y, 7.0);
802                assert_eq!(edge_treatment, TileMode::Clamp);
803            }
804            _ => panic!("expected Blur"),
805        }
806    }
807
808    #[test]
809    fn offset_constructor_sets_components() {
810        let effect = RenderEffect::offset(11.0, -5.0);
811        match effect {
812            RenderEffect::Offset { offset_x, offset_y } => {
813                assert_eq!(offset_x, 11.0);
814                assert_eq!(offset_y, -5.0);
815            }
816            _ => panic!("expected Offset"),
817        }
818    }
819
820    #[test]
821    fn runtime_shader_equality_is_source_value_based() {
822        let mut s1 = RuntimeShader::new("fn main() {}");
823        let mut s2 = RuntimeShader::new("fn main() {}");
824        s1.set_float(0, 1.0);
825        s2.set_float(0, 1.0);
826        assert_eq!(s1, s2);
827    }
828
829    #[test]
830    fn blurred_edge_treatment_defaults_to_bounded_rectangle() {
831        let treatment = BlurredEdgeTreatment::default();
832        assert_eq!(treatment.shape(), Some(LayerShape::Rectangle));
833        assert!(treatment.clip());
834        assert_eq!(treatment.tile_mode(), TileMode::Clamp);
835    }
836
837    #[test]
838    fn blurred_edge_treatment_unbounded_uses_decal_and_no_clip() {
839        let treatment = BlurredEdgeTreatment::UNBOUNDED;
840        assert_eq!(treatment.shape(), None);
841        assert!(!treatment.clip());
842        assert_eq!(treatment.tile_mode(), TileMode::Decal);
843    }
844
845    #[test]
846    fn blurred_edge_treatment_with_shape_uses_bounded_mode() {
847        let rounded = LayerShape::Rounded(RoundedCornerShape::uniform(8.0));
848        let treatment = BlurredEdgeTreatment::with_shape(rounded);
849        assert_eq!(treatment.shape(), Some(rounded));
850        assert!(treatment.clip());
851        assert_eq!(treatment.tile_mode(), TileMode::Clamp);
852    }
853}