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