Skip to main content

rosace_widgets/tree/
shader_paint.rs

1//! `ShaderPaint` (D124/Phase 33) — a leaf widget that fills its rect with a
2//! registered custom shader [`ShaderMaterial`].
3//!
4//! This is the widget D109 promised ("a `ShaderPaint` widget, own type, not
5//! a `CustomPaint` mode-switch") and never landed. It is a thin, honest
6//! layer over the already-shipped `PaintCtx::shader_fill` — the registry,
7//! the `DrawCommand::ShaderFill` plumbing, and the compositor's eager
8//! pipeline compilation all predate it (D109).
9//!
10//! **Rectangular by design.** The widget fills a plain rect; any rounded/
11//! shaped output is the material's own fragment shader's job (there is no
12//! rounded-clip primitive in the pipeline — the starter `glow` material,
13//! for instance, does its own radial falloff). Wiring a material as a
14//! *rounded* surface background is the `Container`/`Card` `.material()`
15//! path (Phase 33 Step 3), not this widget.
16//!
17//! **Decorative by default.** No semantics entry and no hit region — it is
18//! paint, not a control. Wrap it in a `Pressable`/`Button` if you need
19//! interaction.
20//!
21//! ```rust,ignore
22//! // once at startup:
23//! rosace_shader::materials::register_starter_materials();
24//! // in build():
25//! ShaderPaint::new(rosace_shader::materials::gradient(a, b, 0.6, 0.3))
26//!     .size(200.0, 120.0)
27//!     .animated()
28//! ```
29
30use rosace_shader::ShaderMaterial;
31use super::{Widget, LayoutCtx, PaintCtx};
32
33pub struct ShaderPaint {
34    material: ShaderMaterial,
35    width: Option<f32>,
36    height: Option<f32>,
37    /// When set, the material's standard `time` uniform slot (byte offset 0,
38    /// see `rosace_shader::materials::patch_time`) is advanced from the
39    /// animation clock every frame, and the widget requests the next frame —
40    /// EVENT-driven, honoring the D123 "no free-running loops" rule (frames
41    /// are requested, not spun).
42    animated: bool,
43}
44
45impl ShaderPaint {
46    pub fn new(material: ShaderMaterial) -> Self {
47        Self { material, width: None, height: None, animated: false }
48    }
49
50    pub fn width(mut self, w: f32) -> Self { self.width = Some(w); self }
51    pub fn height(mut self, h: f32) -> Self { self.height = Some(h); self }
52    pub fn size(mut self, w: f32, h: f32) -> Self {
53        self.width = Some(w);
54        self.height = Some(h);
55        self
56    }
57
58    /// Drive the material's `time` uniform from a live clock (for the
59    /// starter animated materials — gradient flow, grain, glow pulse). No
60    /// effect on a material whose shader ignores `time`.
61    ///
62    /// GPU-resident (D109 maturity, 2026-07-18): this no longer repaints
63    /// the widget per frame — the quad is recorded ONCE with
64    /// `animate_time`, and the platform patches the time uniform straight
65    /// into the cached GPU quad at every present. Continuous animation
66    /// costs a 16-byte buffer write per frame, not a CPU tree repaint
67    /// (which at full-window size was a real 110%-CPU debug-build loop).
68    pub fn animated(mut self) -> Self { self.animated = true; self }
69}
70
71impl Widget for ShaderPaint {
72    fn layout(&self, ctx: &LayoutCtx) -> rosace_core::types::Size {
73        let c = ctx.constraints;
74        let w = self.width.unwrap_or_else(|| c.max_width_f32());
75        let h = self.height.unwrap_or_else(|| c.max_height_f32());
76        c.constrain(rosace_core::types::Size {
77            width:  if w.is_finite() { w } else { 0.0 },
78            height: if h.is_finite() { h } else { 0.0 },
79        })
80    }
81
82    fn paint(&self, ctx: &mut PaintCtx) {
83        let rect = ctx.rect;
84
85        // Honest CPU/web degradation: paint the fallback color FIRST (a
86        // normal fill). On the GPU path the shader quad below covers it; on
87        // softbuffer/web (where `ShaderFill` is dropped) it is what remains.
88        // Opaque materials set a fallback and it's invisible on GPU;
89        // translucent ones (e.g. glow) set None and nothing is painted here.
90        if let Some(fallback) = self.material.fallback {
91            ctx.fill_rect(rect, fallback);
92        }
93
94        if self.animated {
95            ctx.shader_fill_animated(rect, self.material.pipeline, self.material.uniforms.clone());
96        } else {
97            ctx.shader_fill(rect, self.material.pipeline, self.material.uniforms.clone());
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use rosace_layout::Constraints;
106    use rosace_render::Color;
107    use rosace_shader::PipelineId;
108
109    fn test_material() -> ShaderMaterial {
110        ShaderMaterial::new(PipelineId::user(0x1000), vec![0u8; 16]).fallback(Color::rgb(20, 20, 40))
111    }
112
113    #[test]
114    fn explicit_size_is_honored() {
115        let font = rosace_render::FontCache::embedded();
116        let theme = rosace_theme::built_in::dark_theme();
117        let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
118        let size = ShaderPaint::new(test_material()).size(200.0, 120.0).layout(&ctx);
119        assert_eq!((size.width, size.height), (200.0, 120.0));
120    }
121
122    #[test]
123    fn unsized_fills_available_bounded_space() {
124        let font = rosace_render::FontCache::embedded();
125        let theme = rosace_theme::built_in::dark_theme();
126        let ctx = LayoutCtx::new(Constraints::loose(300.0, 250.0), &font, &theme);
127        let size = ShaderPaint::new(test_material()).layout(&ctx);
128        assert_eq!((size.width, size.height), (300.0, 250.0));
129    }
130
131    #[test]
132    fn paint_records_a_shader_fill_command() {
133        let font = rosace_render::FontCache::embedded();
134        let theme = rosace_theme::built_in::dark_theme();
135        let mut recorder = rosace_render::PictureRecorder::new();
136        let tree = std::rc::Rc::new(std::cell::RefCell::new(super::super::render_tree::RenderTree::new()));
137        let rect = rosace_core::types::Rect {
138            origin: rosace_core::types::Point { x: 0.0, y: 0.0 },
139            size: rosace_core::types::Size { width: 100.0, height: 100.0 },
140        };
141        let mut ctx = PaintCtx::root(&mut recorder, rect, &font, theme, tree);
142        ShaderPaint::new(test_material()).paint(&mut ctx);
143        let picture = recorder.finish();
144        // A fallback fill + the shader fill both recorded.
145        let has_shader = picture.commands.iter().any(|c| matches!(c, rosace_render::DrawCommand::ShaderFill { .. }));
146        assert!(has_shader, "ShaderPaint must record a ShaderFill draw command");
147    }
148}