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