Skip to main content

frust_material/
state_layer.rs

1//! The shared M3 interaction-state overlay: a fill painted over a widget's
2//! shape, in the widget's own content color, at an opacity selected by its
3//! current hover/focus/pressed/dragged interaction state.
4//!
5//! **Source:** androidx Compose Material3 `StateTokens` (`v0_210`, retrieval
6//! 2026-07-17) — hover 8%, **focus 10%, pressed 10%**, dragged 16%. This
7//! supersedes the older material-web `v0.192` table (hover 8%, focus/pressed
8//! 12%, dragged 16%); `v0_210` is the table this helper ships.
9//!
10//! [`StateLayer`] carries no theme dependency and no interaction-detection
11//! logic of its own — a widget's own `event` handler calls the `set_*`
12//! setters to record state transitions (each returns whether the flag
13//! actually changed, so a caller can gate a redraw request on it), and its
14//! `paint` calls [`StateLayer::paint`] once per frame with the already
15//! theme-resolved content color (the 3-tier explicit/theme/fallback
16//! precedence stays the caller's responsibility — see
17//! `docs/CODE_STANDARDS.md`'s Theming conventions).
18//!
19//! When more than one state is active at once (e.g. a focused *and* pressed
20//! widget), the overlay opacity is the **maximum** of the active states'
21//! opacities — M3 does not stack multiple overlays additively, it shows the
22//! strongest one.
23//!
24//! # Relationship to `crate::interaction`
25//!
26//! This type now wraps [`crate::interaction::InteractionState`] and its
27//! `opacity()` method below delegates to
28//! [`InteractionState::max_active_opacity`]
29//! — the exact max-of-active behavior this module has always documented,
30//! preserved unchanged for this module's existing consumers
31//! (`list_item`/`card`/`chips`/`fab`/`switch`). `crate::interaction` also
32//! hosts a **precedence**-based resolver
33//! (`dragged > pressed > focused > hovered`, porting the reference's
34//! `M3EInteractionState.opacity` exactly) as its new documented default for
35//! any future consumer — see that module's doc for why the two resolvers
36//! agree numerically on this crate's own token table today, and why they
37//! are still kept as two distinct functions rather than one. This module's
38//! own public constants and `StateLayer` API are unchanged by that
39//! addition; every existing call site here keeps its current behavior.
40//!
41//! # Live vs. aspirational states
42//!
43//! Of the four interaction states this helper models, **`pressed` and `hovered`
44//! are wired by a shipping widget** ([`super::list_item`], the reference
45//! consumer) — the max-of-active-states rule above describes the full M3 design,
46//! but two of its four inputs still have no live signal source:
47//!
48//! - **`hovered` — wireable, and wired.** There is still no Enter/Leave phase
49//!   (`frust::authoring::PointerPhase` remains Down/Move/Up/Cancel), because hover
50//!   is a **claim**, not a phase: a widget calls
51//!   [`frust::authoring::EventCtx::claim_hover`] from its *uncaptured* `Move` arm
52//!   once it has hit-tested the pointer inside its own bounds, and the framework
53//!   records that claim down the pod chain. The claim is per-pass, so the pointer
54//!   moving anywhere else — including onto a widget this one never hears about —
55//!   drops the link with no leave event to deliver, which is exactly what a widget
56//!   could not do for itself before. Three rules bind a consumer: it keeps its own
57//!   hover flag and gates its `request_redraw` on
58//!   [`StateLayer::set_hovered`]'s changed-return (the frame source for hover
59//!   *gain* — `claim_hover` asks for none);
60//!   [`frust::authoring::PaintCtx::is_hovered`] is the authoritative read, so the
61//!   widget re-syncs `set_hovered` from it every paint (the pointer's *departure*
62//!   never reaches its `event`); and a claim from a **captured** pointer is refused
63//!   by construction, so a drag never tints the widget under the finger. That
64//!   refusal is structural for a captured pointer only — nothing tells a touch
65//!   contact from a mouse, so an uncaptured touch drag over a non-capturing
66//!   consumer does tint, transiently, until the `Up` at lift ends the link
67//!   (`docs/LIMITATIONS.md`'s `hover-window-leave-standing`).
68//! - **`focused` — the focus-routing prerequisite landed** (`material::dialog`/
69//!   `sheet`, `cupertino::alert_dialog`/`action_sheet` all now call
70//!   `EventCtx::request_focus` on a `Down` and dismiss on a focus-routed
71//!   `Key(Escape)`), but **still unwired here**: none of those
72//!   four modals paint their own `StateLayer`-shaped actionable surface — each
73//!   is a plain scrim + panel modal barrier (fills/hairlines), not an M3
74//!   interactive surface with a content color a state layer would tint. There
75//!   was deliberately nothing to wire `set_focused` into without inventing
76//!   chrome no design calls for. A future widget that both participates in
77//!   focus routing *and* paints its own `StateLayer` (e.g. a focusable list
78//!   item, chip, or button) is what will make this state live.
79//! - **`dragged` — unwired.** No catalog widget reports a drag into
80//!   [`StateLayer::set_dragged`] yet.
81//!
82//! The `set_focused`/`set_dragged` setters remain the stable API for when those
83//! upstream signals exist; they are deliberately kept, not dead-stripped, so a
84//! widget can adopt each state the moment its source lands without re-plumbing
85//! this helper — which is what let `set_hovered` be adopted above with no change
86//! to this file's own code.
87
88use frust::authoring::{PaintCtx, PaintScene};
89use kurbo::Rect;
90use peniko::Color;
91
92use crate::interaction::InteractionState;
93
94/// Dragged-state overlay opacity (source: androidx Compose Material3
95/// `StateTokens` v0_210, retrieved 2026-07-17).
96pub use crate::interaction::DRAGGED_OPACITY;
97/// Focus-state overlay opacity (source: androidx Compose Material3
98/// `StateTokens` v0_210, retrieved 2026-07-17 — supersedes material-web
99/// v0.192's 12%, see R18).
100pub use crate::interaction::FOCUS_OPACITY;
101/// Hover-state overlay opacity (source: androidx Compose Material3
102/// `StateTokens` v0_210, retrieved 2026-07-17).
103pub use crate::interaction::HOVER_OPACITY;
104pub use crate::interaction::PRESSED_OPACITY;
105
106/// Tracks a widget's hover/focus/pressed/dragged interaction state and paints
107/// the M3 state-layer overlay for it. See the [module docs](self).
108#[derive(Debug, Default, Clone, Copy, PartialEq)]
109pub struct StateLayer {
110    state: InteractionState,
111}
112
113impl StateLayer {
114    /// A fresh state layer with every interaction flag clear.
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    /// Record the widget's hover state. Returns whether the flag changed.
120    pub fn set_hovered(&mut self, hovered: bool) -> bool {
121        self.state.set_hovered(hovered)
122    }
123
124    /// Record the widget's focus state. Returns whether the flag changed.
125    pub fn set_focused(&mut self, focused: bool) -> bool {
126        self.state.set_focused(focused)
127    }
128
129    /// Record the widget's pressed state. Returns whether the flag changed.
130    pub fn set_pressed(&mut self, pressed: bool) -> bool {
131        self.state.set_pressed(pressed)
132    }
133
134    /// Record the widget's dragged state. Returns whether the flag changed.
135    pub fn set_dragged(&mut self, dragged: bool) -> bool {
136        self.state.set_dragged(dragged)
137    }
138
139    /// Whether any interaction state is currently active (i.e. whether
140    /// [`StateLayer::paint`] will paint anything).
141    pub fn is_active(&self) -> bool {
142        self.state.is_active()
143    }
144
145    /// The overlay opacity for the current state: the maximum of every active
146    /// state's opacity (see the [module docs](self)), or `0.0` if none are
147    /// active.
148    pub fn opacity(&self) -> f32 {
149        self.state.max_active_opacity()
150    }
151
152    /// Paint the overlay: a filled rounded rect covering `shape_rect` with
153    /// `radius`, filled with `content_color` at the current interaction
154    /// opacity (replacing, not multiplying, `content_color`'s own alpha — the
155    /// M3 spec expresses state layers as "content color at N% opacity"). A
156    /// no-op when no interaction state is active.
157    pub fn paint(
158        &self,
159        _ctx: &mut PaintCtx<'_>,
160        scene: &mut dyn PaintScene,
161        shape_rect: Rect,
162        radius: f64,
163        content_color: Color,
164    ) {
165        let opacity = self.opacity();
166        if opacity <= 0.0 {
167            return;
168        }
169        scene.fill_rounded_rect(
170            shape_rect.origin(),
171            shape_rect.size(),
172            radius,
173            with_alpha(content_color, opacity),
174        );
175    }
176}
177
178/// Return `color` with its alpha channel replaced by `alpha`.
179fn with_alpha(color: Color, alpha: f32) -> Color {
180    let c = color.components;
181    Color::new([c[0], c[1], c[2], alpha])
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use frust::authoring::PaintCtx;
188    use kurbo::{Point, Size};
189
190    #[test]
191    fn no_active_state_has_zero_opacity() {
192        let layer = StateLayer::new();
193        assert_eq!(layer.opacity(), 0.0);
194        assert!(!layer.is_active());
195    }
196
197    #[test]
198    fn each_state_selects_its_own_opacity() {
199        let mut layer = StateLayer::new();
200        layer.set_hovered(true);
201        assert_eq!(layer.opacity(), HOVER_OPACITY);
202
203        let mut layer = StateLayer::new();
204        layer.set_focused(true);
205        assert_eq!(layer.opacity(), FOCUS_OPACITY);
206
207        let mut layer = StateLayer::new();
208        layer.set_pressed(true);
209        assert_eq!(layer.opacity(), PRESSED_OPACITY);
210
211        let mut layer = StateLayer::new();
212        layer.set_dragged(true);
213        assert_eq!(layer.opacity(), DRAGGED_OPACITY);
214    }
215
216    #[test]
217    fn combined_states_use_the_maximum_opacity() {
218        let mut layer = StateLayer::new();
219        layer.set_hovered(true);
220        layer.set_pressed(true);
221        // pressed (0.10) > hover (0.08)
222        assert_eq!(layer.opacity(), PRESSED_OPACITY);
223
224        layer.set_dragged(true);
225        // dragged (0.16) is the strongest of the three
226        assert_eq!(layer.opacity(), DRAGGED_OPACITY);
227    }
228
229    #[test]
230    fn setters_report_whether_the_flag_changed() {
231        let mut layer = StateLayer::new();
232        assert!(layer.set_hovered(true), "false -> true is a change");
233        assert!(!layer.set_hovered(true), "true -> true is not a change");
234        assert!(layer.set_hovered(false), "true -> false is a change");
235    }
236
237    /// A recording scene that captures each rounded rect's `(origin, size,
238    /// radius, color)`, mirroring `button.rs`'s `RRectRecorder` test pattern.
239    #[derive(Default)]
240    struct RRectRecorder {
241        rrects: Vec<(Point, Size, f64, Color)>,
242    }
243
244    impl PaintScene for RRectRecorder {
245        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
246        fn draw_text(&mut self, _o: Point, _t: &str) {}
247        fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
248            self.rrects.push((o, s, radius, color));
249        }
250    }
251
252    #[test]
253    fn inactive_layer_paints_nothing() {
254        let layer = StateLayer::new();
255        let mut rec = RRectRecorder::default();
256        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(40.0, 40.0));
257        let shape = Rect::from_origin_size(Point::new(2.0, 2.0), Size::new(36.0, 36.0));
258        layer.paint(&mut ctx, &mut rec, shape, 8.0, Color::from_rgb8(0, 0, 0));
259        assert!(rec.rrects.is_empty());
260    }
261
262    #[test]
263    fn active_layer_paints_content_color_at_state_opacity() {
264        let mut layer = StateLayer::new();
265        layer.set_hovered(true);
266        let mut rec = RRectRecorder::default();
267        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(40.0, 40.0));
268        let shape = Rect::from_origin_size(Point::new(2.0, 2.0), Size::new(36.0, 36.0));
269        let content_color = Color::from_rgb8(0x10, 0x20, 0x30);
270        layer.paint(&mut ctx, &mut rec, shape, 8.0, content_color);
271
272        let (origin, size, radius, color) = rec.rrects.first().copied().expect("paints one rect");
273        assert_eq!(origin, shape.origin());
274        assert_eq!(size, shape.size());
275        assert_eq!(radius, 8.0);
276        assert_eq!(color, with_alpha(content_color, HOVER_OPACITY));
277    }
278}