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}