Skip to main content

frust_material/button/
gradient.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// whose `lib/components/buttons/` tree is itself vendored from m3e_buttons
3// (MIT, © 2026 Mudit Purohit) — `utils/m3e_button_gradient_fill.dart`,
4// `utils/m3e_button_gradient_foreground.dart`,
5// `utils/m3e_button_gradient_outline.dart`,
6// `utils/m3e_button_gradient_overlay.dart`,
7// `utils/m3e_button_gradient_surface.dart`, the gradient fields of
8// `styles/m3e_button_decoration.dart`, and `res/m3e_button_constants.dart`'s
9// `kDisabledBackgroundAlpha`. `GradientAlignment`'s `within_rect` and
10// [`implied_stops`]'s stop math port Flutter's own `dart:ui`/`painting`
11// `Alignment`/`LinearGradient`/`RadialGradient`/`SweepGradient` `createShader`
12// formulas — framework source, not vendored anywhere in this repo, cited by
13// public behavior rather than by file/line.
14
15//! Gradient decoration layers for [`super::ButtonWidget`]'s
16//! [`super::ButtonDecoration`] seam: a ported [`GradientButtonDecoration`]
17//! implementation painting fill/overlay/outline/foreground gradients over (or
18//! instead of) the core's solid layers.
19//!
20//! # Gradient classes ported (from `buttons/utils/`)
21//!
22//! | Reference | Here | Note |
23//! |---|---|---|
24//! | `m3eGradientFillLayer` / `m3eGradientBackgroundBuilder` (`m3e_button_gradient_fill.dart:6`/`:32`) | [`GradientButtonDecoration::paint_fill`](super::ButtonDecoration::paint_fill) | disabled dims to [`DISABLED_CONTAINER_OPACITY`] — see below |
25//! | `m3eGradientForegroundLayer` / `m3eGradientForegroundBuilder` (`m3e_button_gradient_foreground.dart:4`/`:22`) | [`super::ButtonDecoration::foreground_brush`] | label only, not the icon — see that method's doc |
26//! | `m3eGradientOutlineLayer` / `M3EGradientOutlinePainter` / `m3eOutlineWidth` (`m3e_button_gradient_outline.dart:4`/`:26`/`:74`) | [`GradientButtonDecoration::paint_outline`](super::ButtonDecoration::paint_outline) | strokes [`ButtonSurface::outline_width`] regardless of variant, matching upstream's unconditional apply |
27//! | `m3eOverlayOpacityFor` / `m3eStatesForInteraction` / `m3eGradientOverlayLayer` / `m3eResolveGradientOverlay` / `m3eUsesGradientOverlay` (`m3e_button_gradient_overlay.dart:6`/`:23`/`:37`/`:68`/`:90`) | [`GradientStates::from_surface`] + [`GradientButtonDecoration::paint_overlay`](super::ButtonDecoration::paint_overlay) | opacity reuses [`InteractionState::resolve_opacity`], numerically identical to `m3eOverlayOpacityFor`'s own table |
28//! | `m3eGradientSurfaceBuilder` (`m3e_button_gradient_surface.dart:11`) | the seam itself: [`super::ButtonDecoration`]'s fill→overlay→outline paint order | assembled here as three independent trait methods rather than one nested builder closure, since [`super::ButtonWidget::paint`] already calls the three in that order |
29//! | `m3eGradientSpanRect` (`m3e_button_gradient_span.dart:6`) | **not ported** | maps a button's local rect into a *sibling*'s box via `GlobalKey`/`RenderBox.findRenderObject`/`localToGlobal` — [`super::ButtonSurface`] carries no cross-widget lookup, and this is `ButtonGroup`'s shared-gradient-span concern (a future task), not a single button's |
30//!
31//! Flutter's `LinearGradient`/`RadialGradient`/`SweepGradient` themselves are
32//! stock `dart:ui` classes, not authored anywhere in `material_3_expressive`
33//! — [`GradientSpec`] ports their `createShader` position/stop math (see
34//! [`GradientAlignment::within_rect`] and [`implied_stops`]) since the button
35//! utils above only ever consume a `Gradient`, never define one.
36//!
37//! # `PaintScene` gradient-support finding: **native**
38//!
39//! [`peniko::Brush::Gradient`] flows unmodified through
40//! [`frust::authoring::PaintScene::fill_rounded_rect_brush`]/`fill_path`/
41//! `stroke_path`/`draw_glyph_run` into a real
42//! [`frust_scene::Command`](https://docs.rs/frust-scene) carrying the brush,
43//! and the engine's own compiler encodes it through its gradient LUT
44//! (`crates/frust-engine/src/compile/paint.rs`'s `encode_brush`), while the
45//! CPU oracle hands a `Brush::Gradient` straight to
46//! `vello_cpu::PaintType::Gradient` (`crates/frust-testing/src/oracle_cpu.rs`)
47//! — so this module paints real gradients on both rasterizers, not a
48//! banded-solid approximation.
49//! The one gap is [`frust::authoring::PaintScene::fill_rect_brush`] (the
50//! *unrounded* rect variant), whose default falls back to a solid color for a
51//! non-`SceneBuilder` recorder scene; this module never calls it — every fill
52//! goes through `fill_rounded_rect_brush`, which the button core already uses
53//! for its own solid container fill.
54//!
55//! # Scope narrowed from the reference (both documented, not silent)
56//!
57//! - **Foreground gradient tints the label, not the icon.** See
58//!   [`super::ButtonDecoration::foreground_brush`]'s doc for why: a gradient
59//!   brush painted directly on a glyph run is pixel-equivalent to upstream's
60//!   `ShaderMask`+`BlendMode.srcIn`, but the icon is an opaque nested
61//!   [`frust::authoring::ChildPod`] this seam cannot reach into.
62//! - **No `GradientTransform`/focal-point radial/custom `TileMode`.** Every
63//!   gradient in this repo's own `material_3_expressive` (including its
64//!   playground examples, `example/lib/pages/playground/do/buttons_playground.dart:152`)
65//!   uses plain `LinearGradient(colors: [...])` with implied stops and no
66//!   transform or focal point — [`GradientSpec`] ports exactly that surface
67//!   (colors, explicit stops, begin/end/center/angles) and always resolves
68//!   [`peniko::Extend::Pad`] (`dart:ui`'s own `TileMode.clamp` default),
69//!   rather than speculatively porting knobs nothing in this codebase
70//!   exercises.
71
72use std::rc::Rc;
73
74use frust::authoring::PaintScene;
75use kurbo::{Point, Shape, Size};
76use peniko::{
77    Brush, Color, ColorStop, Gradient, GradientKind, LinearGradientPosition,
78    RadialGradientPosition, SweepGradientPosition,
79};
80
81use crate::interaction::DISABLED_CONTAINER_OPACITY;
82
83use super::core::{PATH_TOLERANCE, inset_stroke_rect};
84use super::{ButtonDecoration, ButtonSurface, DecorationOutcome};
85
86// ---- Alignment + stop math (Flutter's own dart:ui Gradient math) ----------
87
88/// A normalized `[-1, 1] x [-1, 1]` position, ported from `dart:ui`'s
89/// `Alignment` — the coordinate space a Flutter `LinearGradient`'s
90/// `begin`/`end` and a `RadialGradient`/`SweepGradient`'s `center` are
91/// expressed in.
92#[derive(Clone, Copy, Debug, PartialEq)]
93pub struct GradientAlignment {
94    pub x: f64,
95    pub y: f64,
96}
97
98impl GradientAlignment {
99    /// `Alignment.centerLeft` — `LinearGradientSpec`'s default `begin`.
100    pub const CENTER_LEFT: Self = Self { x: -1.0, y: 0.0 };
101    /// `Alignment.centerRight` — `LinearGradientSpec`'s default `end`.
102    pub const CENTER_RIGHT: Self = Self { x: 1.0, y: 0.0 };
103    /// `Alignment.center` — `RadialGradientSpec`/`SweepGradientSpec`'s
104    /// default `center`.
105    pub const CENTER: Self = Self { x: 0.0, y: 0.0 };
106
107    /// `Alignment.withinRect`: map this `[-1, 1]` position into the
108    /// window-space box at `origin`/`size` — `x_px = origin.x + (1+x)/2 *
109    /// width`, likewise for `y`. `origin` is window-space (matching
110    /// [`ButtonSurface::origin`]) because a brush's own coordinates, unlike a
111    /// `PaintScene` path, are never auto-translated by the scene's `origin`
112    /// argument — see this module's `PaintScene` gradient-support finding.
113    pub fn within_rect(&self, origin: Point, size: Size) -> Point {
114        let half_w = size.width / 2.0;
115        let half_h = size.height / 2.0;
116        Point::new(
117            origin.x + half_w + self.x * half_w,
118            origin.y + half_h + self.y * half_h,
119        )
120    }
121}
122
123/// Evenly-spaced fallback stops for a gradient whose `stops` is unset —
124/// `dart:ui`'s `Gradient._impliedStops()`: `n` colors get `n` stops at `i /
125/// (n - 1)`; a single color pins to one stop at `0.0`; zero colors yields no
126/// stops.
127pub fn implied_stops(color_count: usize) -> Vec<f32> {
128    match color_count {
129        0 => Vec::new(),
130        1 => vec![0.0],
131        n => {
132            let separation = 1.0 / (n - 1) as f32;
133            (0..n).map(|i| i as f32 * separation).collect()
134        }
135    }
136}
137
138/// Zip `colors` with `stops` (or [`implied_stops`] when `stops` is `None`)
139/// into the [`ColorStop`] list [`Gradient::with_stops`] takes.
140fn color_stops(colors: &[Color], stops: &Option<Vec<f32>>) -> Vec<ColorStop> {
141    let resolved = stops.clone().unwrap_or_else(|| implied_stops(colors.len()));
142    colors
143        .iter()
144        .zip(resolved)
145        .map(|(color, stop)| ColorStop::from((stop, *color)))
146        .collect()
147}
148
149// ---- Gradient specs (the reference's `LinearGradient`/`RadialGradient`/
150// ---- `SweepGradient`, as consumed by `WidgetStateProperty<Gradient?>`) ----
151
152/// A linear gradient along the line from `begin` to `end` — `dart:ui`'s
153/// `LinearGradient`.
154#[derive(Clone, Debug, PartialEq)]
155pub struct LinearGradientSpec {
156    pub begin: GradientAlignment,
157    pub end: GradientAlignment,
158    pub colors: Vec<Color>,
159    pub stops: Option<Vec<f32>>,
160}
161
162impl LinearGradientSpec {
163    /// A gradient through `colors` with evenly-spaced stops, running left to
164    /// right (`Alignment.centerLeft` to `Alignment.centerRight`) —
165    /// `LinearGradient`'s own defaults.
166    pub fn new(colors: impl Into<Vec<Color>>) -> Self {
167        Self {
168            begin: GradientAlignment::CENTER_LEFT,
169            end: GradientAlignment::CENTER_RIGHT,
170            colors: colors.into(),
171            stops: None,
172        }
173    }
174
175    /// Override the start alignment.
176    pub fn begin(mut self, begin: GradientAlignment) -> Self {
177        self.begin = begin;
178        self
179    }
180
181    /// Override the end alignment.
182    pub fn end(mut self, end: GradientAlignment) -> Self {
183        self.end = end;
184        self
185    }
186
187    /// Override the color stops (must match `colors`' length).
188    pub fn stops(mut self, stops: Vec<f32>) -> Self {
189        self.stops = Some(stops);
190        self
191    }
192
193    fn resolve(&self, origin: Point, size: Size) -> Gradient {
194        let start = self.begin.within_rect(origin, size);
195        let end = self.end.within_rect(origin, size);
196        Gradient::new_linear(start, end)
197            .with_stops(color_stops(&self.colors, &self.stops).as_slice())
198    }
199}
200
201/// A radial gradient centered at `center`, reaching `radius` (a fraction of
202/// the paint box's shortest side) — `dart:ui`'s `RadialGradient`, minus its
203/// `focal`/`focalRadius` two-point knobs (see this module's Scope Narrowed
204/// section).
205#[derive(Clone, Debug, PartialEq)]
206pub struct RadialGradientSpec {
207    pub center: GradientAlignment,
208    /// Fraction of the box's shortest side, matching `RadialGradient.radius`
209    /// (which is likewise a fraction, not an absolute length).
210    pub radius: f64,
211    pub colors: Vec<Color>,
212    pub stops: Option<Vec<f32>>,
213}
214
215impl RadialGradientSpec {
216    /// A gradient through `colors`, centered (`Alignment.center`) at radius
217    /// `0.5` — `RadialGradient`'s own defaults.
218    pub fn new(colors: impl Into<Vec<Color>>) -> Self {
219        Self {
220            center: GradientAlignment::CENTER,
221            radius: 0.5,
222            colors: colors.into(),
223            stops: None,
224        }
225    }
226
227    /// Override the center alignment.
228    pub fn center(mut self, center: GradientAlignment) -> Self {
229        self.center = center;
230        self
231    }
232
233    /// Override the radius (a fraction of the shortest side).
234    pub fn radius(mut self, radius: f64) -> Self {
235        self.radius = radius;
236        self
237    }
238
239    /// Override the color stops (must match `colors`' length).
240    pub fn stops(mut self, stops: Vec<f32>) -> Self {
241        self.stops = Some(stops);
242        self
243    }
244
245    fn resolve(&self, origin: Point, size: Size) -> Gradient {
246        let center = self.center.within_rect(origin, size);
247        // `RadialGradient.createShader`: `radius * rect.shortestSide`.
248        let shortest_side = size.width.min(size.height);
249        let radius = (self.radius * shortest_side) as f32;
250        Gradient::new_radial(center, radius)
251            .with_stops(color_stops(&self.colors, &self.stops).as_slice())
252    }
253}
254
255/// A sweep (conic) gradient rotating around `center` from `start_angle` to
256/// `end_angle`, in radians measured clockwise from the positive X-axis (a
257/// Y-down coordinate system) — `dart:ui`'s `SweepGradient`, and the exact
258/// convention [`peniko::SweepGradientPosition`] itself documents, so no
259/// angle-flip is needed at the boundary.
260#[derive(Clone, Debug, PartialEq)]
261pub struct SweepGradientSpec {
262    pub center: GradientAlignment,
263    pub start_angle: f64,
264    pub end_angle: f64,
265    pub colors: Vec<Color>,
266    pub stops: Option<Vec<f32>>,
267}
268
269impl SweepGradientSpec {
270    /// A gradient through `colors`, centered (`Alignment.center`) sweeping a
271    /// full turn (`0` to `2π`) — `SweepGradient`'s own defaults.
272    pub fn new(colors: impl Into<Vec<Color>>) -> Self {
273        Self {
274            center: GradientAlignment::CENTER,
275            start_angle: 0.0,
276            end_angle: std::f64::consts::TAU,
277            colors: colors.into(),
278            stops: None,
279        }
280    }
281
282    /// Override the center alignment.
283    pub fn center(mut self, center: GradientAlignment) -> Self {
284        self.center = center;
285        self
286    }
287
288    /// Override the start/end sweep angles, in radians.
289    pub fn angles(mut self, start_angle: f64, end_angle: f64) -> Self {
290        self.start_angle = start_angle;
291        self.end_angle = end_angle;
292        self
293    }
294
295    /// Override the color stops (must match `colors`' length).
296    pub fn stops(mut self, stops: Vec<f32>) -> Self {
297        self.stops = Some(stops);
298        self
299    }
300
301    fn resolve(&self, origin: Point, size: Size) -> Gradient {
302        let center = self.center.within_rect(origin, size);
303        Gradient::new_sweep(center, self.start_angle as f32, self.end_angle as f32)
304            .with_stops(color_stops(&self.colors, &self.stops).as_slice())
305    }
306}
307
308/// The reference's `Gradient` sealed hierarchy as consumed by a button
309/// decoration — one of [`LinearGradientSpec`]/[`RadialGradientSpec`]/
310/// [`SweepGradientSpec`].
311#[derive(Clone, Debug, PartialEq)]
312pub enum GradientSpec {
313    Linear(LinearGradientSpec),
314    Radial(RadialGradientSpec),
315    Sweep(SweepGradientSpec),
316}
317
318impl GradientSpec {
319    /// Resolve to a real [`peniko::Gradient`] against the window-space box at
320    /// `origin`/`size` — the reference's `Gradient.createShader(Rect rect)`.
321    pub fn resolve(&self, origin: Point, size: Size) -> Gradient {
322        match self {
323            Self::Linear(spec) => spec.resolve(origin, size),
324            Self::Radial(spec) => spec.resolve(origin, size),
325            Self::Sweep(spec) => spec.resolve(origin, size),
326        }
327    }
328}
329
330impl From<LinearGradientSpec> for GradientSpec {
331    fn from(spec: LinearGradientSpec) -> Self {
332        Self::Linear(spec)
333    }
334}
335
336impl From<RadialGradientSpec> for GradientSpec {
337    fn from(spec: RadialGradientSpec) -> Self {
338        Self::Radial(spec)
339    }
340}
341
342impl From<SweepGradientSpec> for GradientSpec {
343    fn from(spec: SweepGradientSpec) -> Self {
344        Self::Sweep(spec)
345    }
346}
347
348// ---- Per-state resolution (m3eStatesForInteraction) ------------------------
349
350/// The `Set<WidgetState>` snapshot a [`GradientProperty`] resolves against —
351/// `m3eStatesForInteraction` (`m3e_button_gradient_overlay.dart:23`).
352#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
353pub struct GradientStates {
354    pub hovered: bool,
355    pub focused: bool,
356    pub pressed: bool,
357    pub dragged: bool,
358    pub disabled: bool,
359}
360
361impl GradientStates {
362    /// Derive the states a [`ButtonSurface`]'s live interaction state and
363    /// `enabled` flag represent.
364    pub fn from_surface(surface: &ButtonSurface) -> Self {
365        Self {
366            hovered: surface.state.hovered,
367            focused: surface.state.focused,
368            pressed: surface.state.pressed,
369            dragged: surface.state.dragged,
370            disabled: !surface.enabled,
371        }
372    }
373}
374
375/// A per-state gradient resolver — the reference's `WidgetStateProperty<Gradient?>`.
376pub type GradientProperty = Rc<dyn Fn(GradientStates) -> Option<GradientSpec>>;
377
378/// A [`GradientProperty`] that resolves to `spec` in every state —
379/// `WidgetStateProperty.all(gradient)`, the shape every gradient button in
380/// `material_3_expressive`'s own playground uses
381/// (`example/lib/pages/playground/do/buttons_playground.dart:151`).
382pub fn constant_gradient(spec: impl Into<GradientSpec>) -> GradientProperty {
383    let spec = spec.into();
384    Rc::new(move |_states| Some(spec.clone()))
385}
386
387// ---- The decoration ---------------------------------------------------------
388
389/// A [`ButtonDecoration`] painting gradient fill/overlay/outline/foreground
390/// layers — the reference's `M3EButtonDecoration` gradient quadruple
391/// (`backgroundGradient`/`overlayGradient`/`outlineGradient`/
392/// `foregroundGradient`, `m3e_button_decoration.dart:83`-`:92`) realized
393/// against this crate's [`ButtonDecoration`] seam. See the [module docs](self)
394/// for the full gradient-class inventory and scope notes.
395#[derive(Clone, Default)]
396pub struct GradientButtonDecoration {
397    background: Option<GradientProperty>,
398    foreground: Option<GradientProperty>,
399    overlay: Option<GradientProperty>,
400    outline: Option<GradientProperty>,
401}
402
403impl GradientButtonDecoration {
404    /// A decoration with no layer configured — every hook falls through to
405    /// [`DecorationOutcome::Skipped`]/`None` until a layer is set.
406    pub fn new() -> Self {
407        Self::default()
408    }
409
410    /// Set the container fill gradient (`backgroundGradient`).
411    pub fn background(mut self, property: GradientProperty) -> Self {
412        self.background = Some(property);
413        self
414    }
415
416    /// Set the label ink gradient (`foregroundGradient`). See
417    /// [`ButtonDecoration::foreground_brush`] for the label-only scope note.
418    pub fn foreground(mut self, property: GradientProperty) -> Self {
419        self.foreground = Some(property);
420        self
421    }
422
423    /// Set the interaction state-layer gradient (`overlayGradient`).
424    pub fn overlay(mut self, property: GradientProperty) -> Self {
425        self.overlay = Some(property);
426        self
427    }
428
429    /// Set the hairline gradient (`outlineGradient`).
430    pub fn outline(mut self, property: GradientProperty) -> Self {
431        self.outline = Some(property);
432        self
433    }
434}
435
436impl ButtonDecoration for GradientButtonDecoration {
437    fn paint_fill(&self, surface: &ButtonSurface, scene: &mut dyn PaintScene) -> DecorationOutcome {
438        let Some(property) = &self.background else {
439            return DecorationOutcome::Skipped;
440        };
441        let Some(spec) = property(GradientStates::from_surface(surface)) else {
442            return DecorationOutcome::Skipped;
443        };
444        let mut gradient = spec.resolve(surface.origin, surface.size);
445        if !surface.enabled {
446            // `m3eGradientFillLayer`'s `disabled` `Opacity` wrap
447            // (`m3e_button_gradient_fill.dart:19`) at
448            // `M3EButtonConstants.kDisabledBackgroundAlpha` (12%,
449            // `m3e_button_constants.dart:30`) — numerically the same 12% as
450            // this crate's own [`DISABLED_CONTAINER_OPACITY`], reused rather
451            // than re-declared.
452            gradient = gradient.multiply_alpha(DISABLED_CONTAINER_OPACITY);
453        }
454        scene.fill_rounded_rect_brush(
455            surface.origin,
456            surface.size,
457            surface.radius,
458            &Brush::Gradient(gradient),
459        );
460        DecorationOutcome::Painted
461    }
462
463    fn paint_overlay(
464        &self,
465        surface: &ButtonSurface,
466        scene: &mut dyn PaintScene,
467    ) -> DecorationOutcome {
468        let Some(property) = &self.overlay else {
469            return DecorationOutcome::Skipped;
470        };
471        // `m3eUsesGradientOverlay` (`m3e_button_gradient_overlay.dart:90`)
472        // checks only that the *property* is configured, not what it
473        // resolves to for the current state — configuring one unconditionally
474        // suppresses the core's own state layer, matching upstream's
475        // `overlayColor: transparent` override (`m3e_button_style.dart:47`).
476        if let Some(spec) = property(GradientStates::from_surface(surface)) {
477            // `m3eOverlayOpacityFor` (`m3e_button_gradient_overlay.dart:6`):
478            // disabled -> 0, else the precedence table
479            // `InteractionState::resolve_opacity` already ports.
480            let opacity = if surface.enabled {
481                surface.state.resolve_opacity()
482            } else {
483                0.0
484            };
485            if opacity > 0.0 {
486                let gradient = spec
487                    .resolve(surface.origin, surface.size)
488                    .multiply_alpha(opacity);
489                scene.fill_rounded_rect_brush(
490                    surface.origin,
491                    surface.size,
492                    surface.radius,
493                    &Brush::Gradient(gradient),
494                );
495            }
496        }
497        DecorationOutcome::Painted
498    }
499
500    fn paint_outline(
501        &self,
502        surface: &ButtonSurface,
503        scene: &mut dyn PaintScene,
504    ) -> DecorationOutcome {
505        let Some(property) = &self.outline else {
506            return DecorationOutcome::Skipped;
507        };
508        let Some(spec) = property(GradientStates::from_surface(surface)) else {
509            return DecorationOutcome::Skipped;
510        };
511        if surface.outline_width <= 0.0 {
512            // `m3eGradientOutlineLayer`: `if (width <= 0 ...) return child;`
513            // (`m3e_button_gradient_outline.dart:11`).
514            return DecorationOutcome::Skipped;
515        }
516        let gradient = spec.resolve(surface.origin, surface.size);
517        let path = inset_stroke_rect(surface.size, surface.radius, surface.outline_width)
518            .to_path(PATH_TOLERANCE);
519        scene.stroke_path(
520            surface.origin,
521            &path,
522            surface.outline_width,
523            &Brush::Gradient(gradient),
524        );
525        DecorationOutcome::Painted
526    }
527
528    fn foreground_brush(&self, surface: &ButtonSurface) -> Option<Brush> {
529        let property = self.foreground.as_ref()?;
530        let spec = property(GradientStates::from_surface(surface))?;
531        Some(Brush::Gradient(spec.resolve(surface.origin, surface.size)))
532    }
533}
534
535/// Re-expresses `brush`'s gradient geometry (if any) as an offset from
536/// `run_origin` rather than the window origin it was resolved against —
537/// [`ButtonDecoration::foreground_brush`]'s return value is window-space,
538/// matching every other [`ButtonSurface`]-anchored brush (see
539/// [`GradientAlignment::within_rect`]'s doc), but a
540/// [`frust::authoring::scene::GlyphRun`]'s `brush` is painted under the
541/// run's OWN `transform` — unlike [`PaintScene::fill_rounded_rect_brush`]/
542/// `stroke_path`, whose separate `origin` argument never auto-translates
543/// their brush (see this module's `PaintScene` gradient-support finding),
544/// the glyph-run render path applies `transform` to the active paint too —
545/// `frust-engine`'s compiler composes the run's transform into the brush it
546/// encodes (`compile/mod.rs`'s `compile_glyph_run` -> `encode_paint`) and the
547/// CPU oracle's `set_transform(run.transform)` runs before `set_brush`
548/// (`frust-testing/src/oracle_cpu.rs`'s `draw_glyph_run`), on both
549/// rasterizers. Calling this with the exact origin the run's
550/// `transform` will translate from — [`super::ButtonWidget`]'s paint pass's
551/// `label_origin` — cancels that re-application, so the label's gradient
552/// lands at the same window position [`ButtonDecoration::foreground_brush`]
553/// resolved, regardless of where the label sits within the button. A
554/// non-gradient brush (nothing to shift) is returned unchanged.
555pub(super) fn brush_relative_to_run_origin(brush: &Brush, run_origin: Point) -> Brush {
556    let Brush::Gradient(gradient) = brush else {
557        return brush.clone();
558    };
559    let shift = |p: Point| Point::new(p.x - run_origin.x, p.y - run_origin.y);
560    let mut gradient = gradient.clone();
561    gradient.kind = match gradient.kind {
562        GradientKind::Linear(pos) => LinearGradientPosition {
563            start: shift(pos.start),
564            end: shift(pos.end),
565        }
566        .into(),
567        GradientKind::Radial(pos) => RadialGradientPosition {
568            start_center: shift(pos.start_center),
569            start_radius: pos.start_radius,
570            end_center: shift(pos.end_center),
571            end_radius: pos.end_radius,
572        }
573        .into(),
574        GradientKind::Sweep(pos) => SweepGradientPosition {
575            center: shift(pos.center),
576            start_angle: pos.start_angle,
577            end_angle: pos.end_angle,
578        }
579        .into(),
580    };
581    Brush::Gradient(gradient)
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use crate::button::{ButtonView, ButtonWidget, button};
588    use frust::authoring::scene::GlyphRun;
589    use frust::authoring::text::TextContext;
590    use frust::authoring::{BoxConstraints, BuildCtx, LayoutCtx, PaintCtx, View, Widget};
591    use kurbo::BezPath;
592    use peniko::{Extend, GradientKind};
593    use std::any::Any;
594
595    fn colors(hexes: &[u32]) -> Vec<Color> {
596        hexes
597            .iter()
598            .map(|&h| {
599                Color::from_rgb8(
600                    ((h >> 16) & 0xFF) as u8,
601                    ((h >> 8) & 0xFF) as u8,
602                    (h & 0xFF) as u8,
603                )
604            })
605            .collect()
606    }
607
608    // ---- implied_stops ------------------------------------------------
609
610    #[test]
611    fn implied_stops_matches_dart_uis_own_table() {
612        assert_eq!(implied_stops(0), Vec::<f32>::new());
613        assert_eq!(implied_stops(1), vec![0.0]);
614        assert_eq!(implied_stops(2), vec![0.0, 1.0]);
615        assert_eq!(implied_stops(3), vec![0.0, 0.5, 1.0]);
616        assert_eq!(implied_stops(4), vec![0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0]);
617    }
618
619    // ---- gradient class 1: Linear, x2 sizes ----------------------------
620
621    #[test]
622    fn linear_gradient_resolves_begin_end_and_implied_stops_at_two_sizes() {
623        let spec = LinearGradientSpec::new(colors(&[0x6750A4, 0x9A82DB]));
624        for (origin, size) in [
625            (Point::new(0.0, 0.0), Size::new(100.0, 40.0)),
626            (Point::new(10.0, 20.0), Size::new(200.0, 80.0)),
627        ] {
628            let gradient = spec.resolve(origin, size);
629            let GradientKind::Linear(pos) = gradient.kind else {
630                panic!("expected a linear gradient kind");
631            };
632            // centerLeft -> (origin.x, origin.y + size.height / 2);
633            // centerRight -> (origin.x + size.width, same y).
634            assert_eq!(
635                pos.start,
636                Point::new(origin.x, origin.y + size.height / 2.0)
637            );
638            assert_eq!(
639                pos.end,
640                Point::new(origin.x + size.width, origin.y + size.height / 2.0)
641            );
642            assert_eq!(gradient.extend, Extend::Pad);
643            let stops: Vec<(f32, Color)> = gradient
644                .stops
645                .iter()
646                .map(|s| (s.offset, s.color.to_alpha_color()))
647                .collect();
648            assert_eq!(
649                stops,
650                vec![(0.0, colors(&[0x6750A4])[0]), (1.0, colors(&[0x9A82DB])[0]),]
651            );
652        }
653    }
654
655    #[test]
656    fn linear_gradient_explicit_begin_end_and_stops_are_honored() {
657        let spec = LinearGradientSpec::new(colors(&[0xFF0000, 0x00FF00, 0x0000FF]))
658            .begin(GradientAlignment { x: 0.0, y: -1.0 })
659            .end(GradientAlignment { x: 0.0, y: 1.0 })
660            .stops(vec![0.0, 0.25, 1.0]);
661        let origin = Point::new(0.0, 0.0);
662        let size = Size::new(64.0, 32.0);
663        let gradient = spec.resolve(origin, size);
664        let GradientKind::Linear(pos) = gradient.kind else {
665            panic!("expected a linear gradient kind");
666        };
667        // topCenter -> (origin.x + width/2, origin.y); bottomCenter -> (.., origin.y + height).
668        assert_eq!(pos.start, Point::new(32.0, 0.0));
669        assert_eq!(pos.end, Point::new(32.0, 32.0));
670        let offsets: Vec<f32> = gradient.stops.iter().map(|s| s.offset).collect();
671        assert_eq!(offsets, vec![0.0, 0.25, 1.0]);
672    }
673
674    // ---- gradient class 2: Radial, x2 sizes ----------------------------
675
676    #[test]
677    fn radial_gradient_resolves_center_and_shortest_side_radius_at_two_sizes() {
678        let spec = RadialGradientSpec::new(colors(&[0xFFFFFF, 0x000000]));
679        for (origin, size) in [
680            (Point::new(0.0, 0.0), Size::new(100.0, 40.0)),
681            (Point::new(5.0, 5.0), Size::new(60.0, 90.0)),
682        ] {
683            let gradient = spec.resolve(origin, size);
684            let GradientKind::Radial(pos) = gradient.kind else {
685                panic!("expected a radial gradient kind");
686            };
687            let expected_center =
688                Point::new(origin.x + size.width / 2.0, origin.y + size.height / 2.0);
689            assert_eq!(pos.start_center, expected_center);
690            assert_eq!(pos.end_center, expected_center);
691            assert_eq!(pos.start_radius, 0.0);
692            let shortest_side = size.width.min(size.height);
693            assert_eq!(pos.end_radius, (0.5 * shortest_side) as f32);
694        }
695    }
696
697    #[test]
698    fn radial_gradient_radius_override_scales_with_shortest_side() {
699        let spec = RadialGradientSpec::new(colors(&[0xFFFFFF, 0x000000])).radius(1.0);
700        let gradient = spec.resolve(Point::new(0.0, 0.0), Size::new(50.0, 200.0));
701        let GradientKind::Radial(pos) = gradient.kind else {
702            panic!("expected a radial gradient kind");
703        };
704        assert_eq!(pos.end_radius, 50.0, "radius scales off the shortest side");
705    }
706
707    // ---- gradient class 3: Sweep, x2 sizes -----------------------------
708
709    #[test]
710    fn sweep_gradient_defaults_to_a_full_clockwise_turn_at_two_sizes() {
711        let spec = SweepGradientSpec::new(colors(&[0xFF0000, 0x00FF00, 0x0000FF]));
712        for (origin, size) in [
713            (Point::new(0.0, 0.0), Size::new(80.0, 80.0)),
714            (Point::new(3.0, 4.0), Size::new(120.0, 40.0)),
715        ] {
716            let gradient = spec.resolve(origin, size);
717            let GradientKind::Sweep(pos) = gradient.kind else {
718                panic!("expected a sweep gradient kind");
719            };
720            assert_eq!(
721                pos.center,
722                Point::new(origin.x + size.width / 2.0, origin.y + size.height / 2.0)
723            );
724            assert_eq!(pos.start_angle, 0.0);
725            assert_eq!(pos.end_angle, std::f32::consts::TAU);
726            assert_eq!(gradient.stops.len(), 3);
727        }
728    }
729
730    // ---- GradientButtonDecoration: recording scene ----------------------
731
732    #[derive(Default)]
733    struct Recorder {
734        rrect_brushes: Vec<Brush>,
735        rrects: Vec<(Point, Size, f64, Color)>,
736        stroke_brushes: Vec<Brush>,
737        strokes: Vec<f64>,
738        runs: Vec<Brush>,
739    }
740
741    impl PaintScene for Recorder {
742        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
743        fn draw_text(&mut self, _o: Point, _t: &str) {}
744        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
745            self.rrects.push((o, s, radius, color));
746        }
747        fn fill_rounded_rect_brush(&mut self, _o: Point, _s: Size, _radius: f64, brush: &Brush) {
748            self.rrect_brushes.push(brush.clone());
749        }
750        fn stroke_path(&mut self, _o: Point, _path: &BezPath, width: f64, brush: &Brush) {
751            self.stroke_brushes.push(brush.clone());
752            self.strokes.push(width);
753        }
754        fn draw_glyph_run(&mut self, run: GlyphRun) {
755            self.runs.push(run.brush);
756        }
757    }
758
759    fn surface(state: InteractionStateForTest) -> ButtonSurface {
760        use crate::interaction::InteractionState;
761        ButtonSurface {
762            origin: Point::new(4.0, 8.0),
763            size: Size::new(120.0, 40.0),
764            radius: 20.0,
765            container: Color::TRANSPARENT,
766            content: Color::from_rgb8(0, 0, 0),
767            outline: None,
768            outline_width: 1.0,
769            enabled: state.enabled,
770            state: InteractionState {
771                hovered: state.hovered,
772                focused: false,
773                pressed: false,
774                dragged: false,
775            },
776        }
777    }
778
779    struct InteractionStateForTest {
780        enabled: bool,
781        hovered: bool,
782    }
783
784    fn enabled_idle() -> InteractionStateForTest {
785        InteractionStateForTest {
786            enabled: true,
787            hovered: false,
788        }
789    }
790
791    fn enabled_hovered() -> InteractionStateForTest {
792        InteractionStateForTest {
793            enabled: true,
794            hovered: true,
795        }
796    }
797
798    fn disabled() -> InteractionStateForTest {
799        InteractionStateForTest {
800            enabled: false,
801            hovered: false,
802        }
803    }
804
805    fn linear_two_stop() -> GradientProperty {
806        constant_gradient(LinearGradientSpec::new(colors(&[0x6750A4, 0x9A82DB])))
807    }
808
809    #[test]
810    fn unconfigured_layers_skip_and_paint_nothing() {
811        let deco = GradientButtonDecoration::new();
812        let surf = surface(enabled_idle());
813        let mut rec = Recorder::default();
814        assert_eq!(deco.paint_fill(&surf, &mut rec), DecorationOutcome::Skipped);
815        assert_eq!(
816            deco.paint_overlay(&surf, &mut rec),
817            DecorationOutcome::Skipped
818        );
819        assert_eq!(
820            deco.paint_outline(&surf, &mut rec),
821            DecorationOutcome::Skipped
822        );
823        assert!(deco.foreground_brush(&surf).is_none());
824        assert!(rec.rrect_brushes.is_empty());
825        assert!(rec.stroke_brushes.is_empty());
826    }
827
828    #[test]
829    fn fill_gradient_paints_and_reports_painted_suppressing_the_core_fill() {
830        let deco = GradientButtonDecoration::new().background(linear_two_stop());
831        let surf = surface(enabled_idle());
832        let mut rec = Recorder::default();
833        assert_eq!(deco.paint_fill(&surf, &mut rec), DecorationOutcome::Painted);
834        assert_eq!(rec.rrect_brushes.len(), 1);
835        assert!(matches!(rec.rrect_brushes[0], Brush::Gradient(_)));
836    }
837
838    #[test]
839    fn disabled_fill_gradient_dims_to_disabled_container_opacity() {
840        let deco = GradientButtonDecoration::new().background(linear_two_stop());
841        let surf = surface(disabled());
842        let mut rec = Recorder::default();
843        deco.paint_fill(&surf, &mut rec);
844        let Brush::Gradient(gradient) = &rec.rrect_brushes[0] else {
845            panic!("expected a gradient brush");
846        };
847        for stop in gradient.stops.iter() {
848            assert_eq!(stop.color.components[3], DISABLED_CONTAINER_OPACITY);
849        }
850    }
851
852    #[test]
853    fn overlay_gradient_reports_painted_even_at_zero_opacity_but_paints_nothing() {
854        // `m3eUsesGradientOverlay` unconditionally suppresses the core's own
855        // state layer whenever the property is configured, independent of
856        // whether *this* state resolves visible opacity.
857        let deco = GradientButtonDecoration::new().overlay(linear_two_stop());
858        let surf = surface(enabled_idle());
859        let mut rec = Recorder::default();
860        assert_eq!(
861            deco.paint_overlay(&surf, &mut rec),
862            DecorationOutcome::Painted
863        );
864        assert!(
865            rec.rrect_brushes.is_empty(),
866            "zero opacity paints no overlay quad"
867        );
868    }
869
870    #[test]
871    fn overlay_gradient_paints_at_the_hovered_states_opacity() {
872        let deco = GradientButtonDecoration::new().overlay(linear_two_stop());
873        let surf = surface(enabled_hovered());
874        let mut rec = Recorder::default();
875        deco.paint_overlay(&surf, &mut rec);
876        assert_eq!(rec.rrect_brushes.len(), 1);
877        let Brush::Gradient(gradient) = &rec.rrect_brushes[0] else {
878            panic!("expected a gradient brush");
879        };
880        for stop in gradient.stops.iter() {
881            assert_eq!(stop.color.components[3], crate::interaction::HOVER_OPACITY);
882        }
883    }
884
885    #[test]
886    fn disabled_overlay_gradient_paints_nothing_but_still_suppresses_the_core_layer() {
887        let deco = GradientButtonDecoration::new().overlay(linear_two_stop());
888        let surf = surface(disabled());
889        let mut rec = Recorder::default();
890        assert_eq!(
891            deco.paint_overlay(&surf, &mut rec),
892            DecorationOutcome::Painted
893        );
894        assert!(rec.rrect_brushes.is_empty());
895    }
896
897    #[test]
898    fn outline_gradient_strokes_at_the_surfaces_outline_width() {
899        let deco = GradientButtonDecoration::new().outline(linear_two_stop());
900        let surf = surface(enabled_idle());
901        let mut rec = Recorder::default();
902        assert_eq!(
903            deco.paint_outline(&surf, &mut rec),
904            DecorationOutcome::Painted
905        );
906        assert_eq!(rec.strokes, vec![surf.outline_width]);
907        assert!(matches!(rec.stroke_brushes[0], Brush::Gradient(_)));
908    }
909
910    #[test]
911    fn foreground_brush_resolves_a_gradient_brush_when_configured() {
912        let deco = GradientButtonDecoration::new().foreground(linear_two_stop());
913        let surf = surface(enabled_idle());
914        let brush = deco.foreground_brush(&surf);
915        assert!(matches!(brush, Some(Brush::Gradient(_))));
916    }
917
918    // ---- coordinate-space regression: gradient tracks the glyph run -----
919
920    /// A [`PaintScene`] that captures the *whole* [`GlyphRun`] (transform,
921    /// glyphs, brush) rather than just its brush — needed to reconstruct
922    /// what the render backend would actually draw (see
923    /// [`brush_relative_to_run_origin`]'s doc).
924    #[derive(Default)]
925    struct GlyphRunRecorder {
926        runs: Vec<GlyphRun>,
927    }
928    impl PaintScene for GlyphRunRecorder {
929        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
930        fn draw_text(&mut self, _o: Point, _t: &str) {}
931        fn fill_rounded_rect(&mut self, _o: Point, _s: Size, _radius: f64, _color: Color) {}
932        fn fill_rounded_rect_brush(&mut self, _o: Point, _s: Size, _radius: f64, _brush: &Brush) {}
933        fn stroke_path(&mut self, _o: Point, _path: &BezPath, _width: f64, _brush: &Brush) {}
934        fn draw_glyph_run(&mut self, run: GlyphRun) {
935            self.runs.push(run);
936        }
937    }
938
939    /// What the render backend actually paints a linear gradient's `start`
940    /// at: `run.transform * gradient.start` — `frust-engine`'s
941    /// `compile_glyph_run` composes the run's transform into the brush it
942    /// encodes, and the CPU oracle's `set_transform(run.transform)` runs
943    /// before `set_brush`, both applying the run's transform to its active
944    /// paint, not just its glyph outlines (`crates/frust-engine/src/compile`,
945    /// `crates/frust-testing/src/oracle_cpu.rs`).
946    fn rendered_linear_start(run: &GlyphRun) -> Point {
947        let Brush::Gradient(gradient) = &run.brush else {
948            panic!("expected a gradient brush");
949        };
950        let GradientKind::Linear(pos) = gradient.kind else {
951            panic!("expected a linear gradient kind");
952        };
953        run.transform * pos.start
954    }
955
956    /// The label's glyph run and its foreground gradient must stay locked
957    /// together across consecutive paints at different origins (simulating
958    /// a sliding page transition): both move by exactly the origin delta,
959    /// and the gradient sits at the same offset from the glyph run in every
960    /// frame — never a growing/shrinking drift.
961    #[test]
962    fn gradient_label_tracks_the_glyph_run_across_consecutive_paints_at_different_origins() {
963        let deco: Rc<dyn ButtonDecoration> =
964            Rc::new(GradientButtonDecoration::new().foreground(linear_two_stop()));
965        let view = button::<(), _>("Save", |_| {}).decoration(deco);
966        let mut widget = build(&view);
967
968        let mut tcx = TextContext::new();
969        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
970        let size = widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(300.0, 100.0)));
971
972        let origin_delta = 10.0;
973        let mut rec1 = GlyphRunRecorder::default();
974        widget.paint(&mut PaintCtx::new(Point::new(10.0, 0.0), size), &mut rec1);
975        let mut rec2 = GlyphRunRecorder::default();
976        widget.paint(
977            &mut PaintCtx::new(Point::new(10.0 + origin_delta, 0.0), size),
978            &mut rec2,
979        );
980
981        let run1 = &rec1.runs[0];
982        let run2 = &rec2.runs[0];
983        let glyph1 = run1.transform * Point::new(run1.glyphs[0].x as f64, run1.glyphs[0].y as f64);
984        let glyph2 = run2.transform * Point::new(run2.glyphs[0].x as f64, run2.glyphs[0].y as f64);
985        let grad1 = rendered_linear_start(run1);
986        let grad2 = rendered_linear_start(run2);
987
988        assert_eq!(
989            glyph2.x - glyph1.x,
990            origin_delta,
991            "glyph run must move by exactly the origin delta"
992        );
993        assert_eq!(
994            grad2.x - grad1.x,
995            origin_delta,
996            "gradient must move by exactly the origin delta too, not a doubled (or otherwise \
997             mismatched) amount a coordinate-space bug between the brush and the run's own \
998             transform would produce"
999        );
1000        assert_eq!(
1001            grad1.x - glyph1.x,
1002            grad2.x - glyph2.x,
1003            "the gradient's offset from its own glyph run must be the same constant in both \
1004             frames, never one that drifts with the button's position"
1005        );
1006    }
1007
1008    // ---- end-to-end: the seam actually wired through ButtonWidget -------
1009
1010    fn build<S: 'static>(view: &ButtonView<S>) -> ButtonWidget {
1011        let mut counter = 0u64;
1012        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
1013    }
1014
1015    #[test]
1016    fn a_full_gradient_decoration_suppresses_every_core_solid_layer_and_paints_gradients() {
1017        let deco: Rc<dyn ButtonDecoration> = Rc::new(
1018            GradientButtonDecoration::new()
1019                .background(linear_two_stop())
1020                .outline(linear_two_stop())
1021                .foreground(linear_two_stop()),
1022        );
1023        let view = button::<(), _>("Save", |_| {}).decoration(deco);
1024        let mut widget = build(&view);
1025
1026        let mut tcx = TextContext::new();
1027        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
1028        let size = widget.layout(&mut lctx, &BoxConstraints::loose(Size::new(300.0, 100.0)));
1029
1030        let mut rec = Recorder::default();
1031        let mut pctx = PaintCtx::new(Point::ZERO, size);
1032        widget.paint(&mut pctx, &mut rec);
1033
1034        assert!(
1035            rec.rrects.is_empty(),
1036            "the solid fill must be suppressed by the gradient decoration"
1037        );
1038        assert!(
1039            !rec.rrect_brushes.is_empty(),
1040            "a gradient fill must have painted instead"
1041        );
1042        assert!(
1043            rec.stroke_brushes
1044                .iter()
1045                .any(|b| matches!(b, Brush::Gradient(_))),
1046            "the outline must have painted as a gradient stroke"
1047        );
1048        assert!(
1049            rec.runs.iter().any(|b| matches!(b, Brush::Gradient(_))),
1050            "the label run must have been re-brushed with the foreground gradient"
1051        );
1052    }
1053}