use std::rc::Rc;
use frust::authoring::{Action, Role};
use frust::authoring::{
AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, ThemeTextColor, ThemeTextType,
TypedArgCallback, View, Widget, any,
};
use frust::{AnimationController, FrameTime, Theme};
use kurbo::{Circle, Point, Rect, Shape, Size};
use peniko::{Brush, Color};
use super::press::presses;
use super::state_layer::StateLayer;
use crate::interaction::{DISABLED_CONTENT_OPACITY, HapticSignal, MaterialHaptics};
use crate::tokens::MaterialMotion;
const HIT_SIZE: f64 = 40.0;
const RING_SIZE: f64 = 20.0;
const DOT_SIZE: f64 = 10.0;
const BORDER_WIDTH: f64 = 2.0;
const LABEL_GAP: f64 = 8.0;
const STROKE_TOLERANCE: f64 = 0.1;
const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
const ERROR: Color = Color::from_rgb8(0xB3, 0x26, 0x1E);
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn resolve_colors(
theme: Option<&Theme>,
selected: bool,
error: bool,
enabled: bool,
) -> (Color, Color) {
match theme {
Some(theme) => {
let scheme = theme.scheme();
let ring = if !enabled {
with_alpha(scheme.on_surface, DISABLED_CONTENT_OPACITY)
} else if error {
scheme.error
} else if selected {
scheme.primary
} else {
scheme.on_surface_variant
};
let state_layer = if selected {
scheme.primary
} else {
scheme.on_surface
};
(ring, state_layer)
}
None => {
let ring = if !enabled {
with_alpha(ON_SURFACE, DISABLED_CONTENT_OPACITY)
} else if error {
ERROR
} else if selected {
PRIMARY
} else {
ON_SURFACE_VARIANT
};
let state_layer = if selected { PRIMARY } else { ON_SURFACE };
(ring, state_layer)
}
}
}
fn inside(pos: Point, size: Size) -> bool {
pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
}
fn label_view<State: 'static>(label: String) -> AnyView<State> {
any::<State, _>(
frust::text(label)
.themed_role(ThemeTextColor::OnSurface)
.themed_family(ThemeTextType::BodyLarge),
)
}
fn bind_on_changed<State: 'static, T: Clone + 'static>(
on_changed: &TypedArgCallback<State, T>,
value: T,
) -> frust::authoring::ErasedCallback {
let on_changed = on_changed.clone();
Box::new(move |ctx: &mut EventCtx| {
let state = ctx.state_mut::<State>();
on_changed(state, value.clone());
})
}
pub struct RadioView<State: 'static, T: PartialEq + Clone + 'static> {
value: T,
group_value: T,
on_changed: Option<TypedArgCallback<State, T>>,
label: Option<String>,
error: bool,
}
pub fn radio<State: 'static, T: PartialEq + Clone + 'static>(
value: T,
group_value: T,
) -> RadioView<State, T> {
RadioView {
value,
group_value,
on_changed: None,
label: None,
error: false,
}
}
#[allow(non_snake_case)]
pub fn Radio<State: 'static, T: PartialEq + Clone + 'static>(
value: T,
group_value: T,
) -> RadioView<State, T> {
radio(value, group_value)
}
impl<State: 'static, T: PartialEq + Clone + 'static> RadioView<State, T> {
pub fn on_changed<F: Fn(&mut State, T) + 'static>(mut self, on_changed: F) -> Self {
self.on_changed = Some(Rc::new(on_changed));
self
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn error(mut self, error: bool) -> Self {
self.error = error;
self
}
}
pub struct RadioWidget {
selected: bool,
enabled: bool,
error: bool,
anim: AnimationController,
state_layer: StateLayer,
label_pod: Option<ChildPod>,
label_text: Option<String>,
pressed: bool,
captured: bool,
on_changed: Option<frust::authoring::ErasedCallback>,
}
impl<State: 'static, T: PartialEq + Clone + 'static> View<State> for RadioView<State, T> {
type Element = RadioWidget;
fn build(&self, ctx: &mut BuildCtx<'_>) -> RadioWidget {
let selected = self.value == self.group_value;
let enabled = self.on_changed.is_some();
let label_pod = self
.label
.as_ref()
.map(|label| frust::authoring::build_child(&label_view::<State>(label.clone()), ctx));
let mut anim = AnimationController::new(MaterialMotion::SHORT_4)
.with_curve(MaterialMotion::EMPHASIZED_DECELERATE);
if selected {
anim.forward();
anim.advance(FrameTime::ZERO);
anim.advance(FrameTime::from_nanos(
MaterialMotion::SHORT_4.as_nanos() as u64
));
}
RadioWidget {
selected,
enabled,
error: self.error,
anim,
state_layer: StateLayer::new(),
label_pod,
label_text: self.label.clone(),
pressed: false,
captured: false,
on_changed: self
.on_changed
.as_ref()
.map(|cb| bind_on_changed::<State, T>(cb, self.value.clone())),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut RadioWidget,
ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
element.on_changed = self
.on_changed
.as_ref()
.map(|cb| bind_on_changed::<State, T>(cb, self.value.clone()));
let selected = self.value == self.group_value;
if element.selected != selected {
element.selected = selected;
if selected {
element.anim.forward();
} else {
element.anim.reverse();
}
flags |= ChangeFlags::PAINT;
}
let enabled = self.on_changed.is_some();
if element.enabled != enabled {
element.enabled = enabled;
if !enabled {
element.pressed = false;
element.captured = false;
element.state_layer.set_pressed(false);
}
flags |= ChangeFlags::PAINT;
}
if element.error != self.error {
element.error = self.error;
flags |= ChangeFlags::PAINT;
}
match (&prev.label, &self.label) {
(None, None) => {}
(Some(p), Some(n)) if p == n => {}
(Some(_), Some(n)) => {
element.label_text = Some(n.clone());
let pod = element.label_pod.as_mut().expect("label pod present");
let prev_view = label_view::<State>(prev.label.clone().unwrap_or_default());
let next_view = label_view::<State>(n.clone());
flags |= frust::authoring::rebuild_child(&prev_view, &next_view, pod, ctx);
}
(None, Some(n)) => {
element.label_text = Some(n.clone());
element.label_pod = Some(frust::authoring::build_child(
&label_view::<State>(n.clone()),
ctx,
));
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
(Some(p), None) => {
element.label_text = None;
let mut pod = element.label_pod.take().expect("label pod present");
let prev_view = label_view::<State>(p.clone());
frust::authoring::teardown_child(&prev_view, &mut pod, ctx);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
}
flags
}
fn teardown(&self, element: &mut RadioWidget, ctx: &mut BuildCtx<'_>) {
if let (Some(label), Some(pod)) = (&self.label, element.label_pod.as_mut()) {
let label_view = label_view::<State>(label.clone());
frust::authoring::teardown_child(&label_view, pod, ctx);
}
}
}
impl Widget for RadioWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let Some(label) = self.label_pod.as_mut() else {
return bc.constrain(Size::new(HIT_SIZE, HIT_SIZE));
};
let label_max = Size::new(
(bc.max().width - HIT_SIZE - LABEL_GAP).max(0.0),
bc.max().height,
);
let label_size = label.layout_child(ctx, &BoxConstraints::loose(label_max));
let height = label_size.height.max(HIT_SIZE);
label.set_origin(Point::new(
HIT_SIZE + LABEL_GAP,
(height - label_size.height) / 2.0,
));
bc.constrain(Size::new(HIT_SIZE + LABEL_GAP + label_size.width, height))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let (ring_color, state_layer_color) =
resolve_colors(theme, self.selected, self.error, self.enabled);
if self.anim.advance(ctx.frame_time()) {
ctx.request_frame();
}
let progress = self.anim.value();
let o = ctx.origin();
let cy = ctx.size().height / 2.0;
let center_local = Point::new(HIT_SIZE / 2.0, cy);
let center_abs = Point::new(o.x + HIT_SIZE / 2.0, o.y + cy);
self.state_layer
.set_hovered(self.enabled && ctx.is_hovered());
self.state_layer.paint(
ctx,
scene,
Rect::from_center_size(center_abs, Size::new(HIT_SIZE, HIT_SIZE)),
HIT_SIZE / 2.0,
state_layer_color,
);
let ring_radius = (RING_SIZE - BORDER_WIDTH) / 2.0;
let ring_path = Circle::new(center_local, ring_radius).to_path(STROKE_TOLERANCE);
scene.stroke_path(o, &ring_path, BORDER_WIDTH, &Brush::Solid(ring_color));
let dot_diam = DOT_SIZE * progress;
scene.fill_rounded_rect(
Point::new(center_abs.x - dot_diam / 2.0, center_abs.y - dot_diam / 2.0),
Size::new(dot_diam, dot_diam),
dot_diam / 2.0,
ring_color,
);
if let Some(label) = self.label_pod.as_mut() {
label.paint_child(ctx, scene);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
if !self.enabled {
return EventResult::Ignored;
}
match p.phase {
PointerPhase::Down => {
if !presses(p) {
return EventResult::Ignored;
}
self.pressed = true;
self.captured = true;
self.state_layer.set_pressed(true);
ctx.capture_pointer();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Move => {
if !self.captured {
let over = inside(p.position, ctx.size());
if over {
ctx.claim_hover();
}
if self.state_layer.set_hovered(over) {
ctx.request_redraw();
}
return EventResult::Ignored;
}
let inside_now = inside(p.position, ctx.size());
self.pressed = inside_now;
self.state_layer.set_pressed(inside_now);
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Up => {
if !self.captured {
return EventResult::Ignored;
}
if inside(p.position, ctx.size())
&& let Some(on_changed) = self.on_changed.as_mut()
{
MaterialHaptics::fire(HapticSignal::None);
(on_changed)(ctx);
}
self.pressed = false;
self.captured = false;
self.state_layer.set_pressed(false);
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
if !self.captured {
return EventResult::Ignored;
}
self.pressed = false;
self.captured = false;
self.state_layer.set_pressed(false);
ctx.request_redraw();
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_node(Role::RadioButton, |node| {
if let Some(label) = &self.label_text {
node.set_label(label.as_str());
}
node.set_selected(self.selected);
if self.enabled {
node.add_action(Action::Click);
}
});
}
frust::authoring::visit_children!(label_pod);
}
#[cfg(test)]
mod tests {
use super::*;
use std::any::Any;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Freq {
Daily,
Weekly,
Monthly,
}
struct GroupState {
selected: Freq,
changes: u32,
}
fn on_changed(s: &mut GroupState, v: Freq) {
s.selected = v;
s.changes += 1;
}
fn built(value: Freq, group_value: Freq, enabled: bool) -> RadioWidget {
let view = radio::<GroupState, Freq>(value, group_value);
let view = if enabled {
view.on_changed(on_changed)
} else {
view
};
let mut counter = 0u64;
View::<GroupState>::build(&view, &mut BuildCtx::new(&mut counter))
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(frust::authoring::PointerEvent {
phase,
position: Point::new(x, y),
button: frust::authoring::PointerButton::Primary,
})
}
fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(frust::authoring::PointerEvent {
phase,
position: Point::new(x, y),
button: frust::authoring::PointerButton::Secondary,
})
}
fn dispatch(w: &mut RadioWidget, state: &mut GroupState, event: &InputEvent) {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
w.event(&mut ctx, event);
}
#[test]
fn select_and_deselect_across_a_three_member_group() {
let mut state = GroupState {
selected: Freq::Daily,
changes: 0,
};
let daily = built(Freq::Daily, state.selected, true);
let mut weekly = built(Freq::Weekly, state.selected, true);
let monthly = built(Freq::Monthly, state.selected, true);
assert!(daily.selected);
assert!(!weekly.selected);
assert!(!monthly.selected);
dispatch(&mut weekly, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
dispatch(&mut weekly, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
assert_eq!(state.selected, Freq::Weekly);
assert_eq!(state.changes, 1);
assert!(
!weekly.selected,
"must not self-mutate — still false until the app rebuilds"
);
let daily = built(Freq::Daily, state.selected, true);
let weekly = built(Freq::Weekly, state.selected, true);
let monthly = built(Freq::Monthly, state.selected, true);
assert!(!daily.selected, "Daily deselected");
assert!(weekly.selected, "Weekly now selected");
assert!(!monthly.selected);
let mut monthly = monthly;
dispatch(&mut monthly, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
dispatch(&mut monthly, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
assert_eq!(state.selected, Freq::Monthly);
assert_eq!(state.changes, 2);
let daily = built(Freq::Daily, state.selected, true);
let weekly = built(Freq::Weekly, state.selected, true);
let monthly = built(Freq::Monthly, state.selected, true);
assert!(!daily.selected);
assert!(!weekly.selected, "Weekly deselected");
assert!(monthly.selected, "Monthly now selected");
}
#[test]
fn already_selected_still_fires() {
let mut w = built(Freq::Daily, Freq::Daily, true);
let mut state = GroupState {
selected: Freq::Daily,
changes: 0,
};
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
assert_eq!(state.changes, 1);
assert!(w.selected, "still selected until the app rebuilds it");
}
#[test]
fn up_outside_does_not_fire() {
let mut w = built(Freq::Weekly, Freq::Daily, true);
let mut state = GroupState {
selected: Freq::Daily,
changes: 0,
};
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 500.0, 12.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 12.0));
assert_eq!(state.changes, 0);
}
#[test]
fn hover_move_without_down_is_ignored_noop() {
let mut w = built(Freq::Weekly, Freq::Daily, true);
let mut state = GroupState {
selected: Freq::Daily,
changes: 0,
};
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
let result = w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
assert!(matches!(result, EventResult::Ignored));
assert!(!w.pressed);
assert_eq!(state.changes, 0);
}
#[test]
fn hover_move_inside_claims_hover_and_requests_redraw_once() {
let mut w = built(Freq::Weekly, Freq::Daily, true);
let mut state = GroupState {
selected: Freq::Daily,
changes: 0,
};
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
assert!(ctx.needs_redraw(), "hover gain must request a redraw");
assert_eq!(w.state_layer.opacity(), crate::interaction::HOVER_OPACITY);
}
#[test]
fn paint_self_corrects_hover_off_when_the_pod_link_is_gone() {
let mut w = built(Freq::Weekly, Freq::Daily, true);
let mut state = GroupState {
selected: Freq::Daily,
changes: 0,
};
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
w.event(&mut ctx, &ev(PointerPhase::Move, 5.0, 12.0));
assert!(w.state_layer.opacity() > 0.0, "hover claimed");
let mut paint_ctx = PaintCtx::new(Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
let mut rec = Recorder::default();
w.paint(&mut paint_ctx, &mut rec);
assert_eq!(
w.state_layer.opacity(),
0.0,
"paint self-corrected the flag"
);
}
#[test]
fn cancel_clears_armed_state() {
let mut w = built(Freq::Weekly, Freq::Daily, true);
let mut state = GroupState {
selected: Freq::Daily,
changes: 0,
};
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
assert!(w.captured);
dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 5.0, 12.0));
assert!(!w.captured, "Cancel disarms the press");
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
assert_eq!(state.changes, 0);
}
#[test]
fn a_secondary_press_never_presses_captures_or_fires() {
let mut w = built(Freq::Weekly, Freq::Daily, true);
let mut state = GroupState {
selected: Freq::Daily,
changes: 0,
};
dispatch(
&mut w,
&mut state,
&secondary_ev(PointerPhase::Down, 5.0, 12.0),
);
assert!(!w.pressed);
assert!(!w.captured);
dispatch(
&mut w,
&mut state,
&secondary_ev(PointerPhase::Up, 5.0, 12.0),
);
assert_eq!(state.changes, 0);
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 12.0));
assert!(w.pressed);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 12.0));
assert_eq!(state.changes, 1);
}
#[test]
fn disabled_radio_ignores_every_pointer_phase() {
let mut w = built(Freq::Weekly, Freq::Daily, false);
assert!(!w.enabled);
let mut state = GroupState {
selected: Freq::Daily,
changes: 0,
};
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE));
let down = w.event(&mut ctx, &ev(PointerPhase::Down, 5.0, 12.0));
assert!(matches!(down, EventResult::Ignored));
assert!(!w.pressed);
assert!(!w.captured);
}
fn paint_rec(w: &mut RadioWidget, theme: Option<&Theme>) -> Recorder {
let mut rec = Recorder::default();
let mut ctx = match theme {
Some(t) => PaintCtx::new(Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE)).with_theme(t),
None => PaintCtx::new(Point::ZERO, Size::new(HIT_SIZE, HIT_SIZE)),
};
w.paint(&mut ctx, &mut rec);
rec
}
#[derive(Default)]
struct Recorder {
rrects: Vec<(Point, Size, f64, Color)>,
strokes: Vec<Color>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn fill_rounded_rect(&mut self, o: Point, s: Size, radius: f64, color: Color) {
self.rrects.push((o, s, radius, color));
}
fn stroke_path(&mut self, _o: Point, _path: &kurbo::BezPath, _width: f64, brush: &Brush) {
if let Brush::Solid(c) = brush {
self.strokes.push(*c);
}
}
}
#[test]
fn dot_scale_in_progress_observable_at_zero_mid_and_settled() {
let mut counter = 0u64;
let prev = radio::<GroupState, Freq>(Freq::Daily, Freq::Weekly).on_changed(on_changed);
let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
assert!(!w.selected);
assert_eq!(w.anim.value(), 0.0);
let next = radio::<GroupState, Freq>(Freq::Daily, Freq::Daily).on_changed(on_changed);
let flags =
View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert!(w.selected);
assert!(flags.needs_paint());
assert!(w.anim.is_animating(), "rebuild started the forward drive");
let rec0 = paint_rec(&mut w, None);
assert_eq!(
rec0.rrects[0].1,
Size::ZERO,
"dot has not started scaling in yet"
);
let half_ns = (MaterialMotion::SHORT_4.as_nanos() / 2) as u64;
w.anim.advance(FrameTime::from_nanos(half_ns));
let rec_mid = paint_rec(&mut w, None);
let mid_diam = rec_mid.rrects[0].1.width;
assert!(
mid_diam > 0.0 && mid_diam < DOT_SIZE,
"dot mid-animation diameter should be strictly between 0 and {DOT_SIZE}, got {mid_diam}"
);
let full_ns = MaterialMotion::SHORT_4.as_nanos() as u64;
w.anim.advance(FrameTime::from_nanos(full_ns));
assert!(!w.anim.is_animating(), "fling settled");
let rec_done = paint_rec(&mut w, None);
assert_eq!(rec_done.rrects[0].1, Size::new(DOT_SIZE, DOT_SIZE));
}
#[test]
fn deselect_also_animates_both_ways() {
let mut counter = 0u64;
let prev = radio::<GroupState, Freq>(Freq::Daily, Freq::Daily).on_changed(on_changed);
let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
assert_eq!(w.anim.value(), 1.0);
let next = radio::<GroupState, Freq>(Freq::Daily, Freq::Weekly).on_changed(on_changed);
View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert!(!w.selected);
assert!(w.anim.is_animating(), "rebuild started the reverse drive");
let full_ns = MaterialMotion::SHORT_4.as_nanos() as u64;
w.anim.advance(FrameTime::ZERO); w.anim.advance(FrameTime::from_nanos(full_ns));
assert!(!w.anim.is_animating());
assert_eq!(w.anim.value(), 0.0);
let rec = paint_rec(&mut w, None);
assert_eq!(rec.rrects[0].1, Size::ZERO, "dot fully scaled back out");
}
#[test]
fn error_flavor_recolors_ring_unthemed() {
let mut w = built(Freq::Weekly, Freq::Daily, true);
w.error = true;
let rec = paint_rec(&mut w, None);
assert_eq!(rec.strokes, vec![ERROR]);
}
#[test]
fn error_flavor_recolors_ring_themed() {
let theme = crate::baseline();
let scheme = theme.scheme();
let mut w = built(Freq::Weekly, Freq::Daily, true);
w.error = true;
let rec = paint_rec(&mut w, Some(&theme));
assert_eq!(rec.strokes, vec![scheme.error]);
let mut selected = built(Freq::Daily, Freq::Daily, true);
selected.error = true;
let rec = paint_rec(&mut selected, Some(&theme));
assert_eq!(rec.strokes, vec![scheme.error]);
selected.state_layer.set_pressed(true);
let rec = paint_rec(&mut selected, Some(&theme));
assert_eq!(rec.rrects[0].3, with_alpha(scheme.primary, 0.10));
}
#[test]
fn disabled_wins_over_error() {
let theme = crate::baseline();
let scheme = theme.scheme();
let mut w = built(Freq::Weekly, Freq::Daily, false);
w.error = true;
let rec = paint_rec(&mut w, Some(&theme));
assert_eq!(rec.strokes, vec![with_alpha(scheme.on_surface, 0.38)]);
}
#[test]
fn unthemed_paint_uses_fallback_constants() {
let mut off = built(Freq::Weekly, Freq::Daily, true);
let rec = paint_rec(&mut off, None);
assert_eq!(rec.strokes, vec![ON_SURFACE_VARIANT]);
let mut on = built(Freq::Daily, Freq::Daily, true);
let rec = paint_rec(&mut on, None);
assert_eq!(rec.strokes, vec![PRIMARY]);
assert_eq!(rec.rrects[0].3, PRIMARY, "the fully-settled dot is PRIMARY");
}
#[test]
fn themed_paint_resolves_roles() {
let theme = crate::baseline();
let scheme = theme.scheme();
let mut off = built(Freq::Weekly, Freq::Daily, true);
let rec = paint_rec(&mut off, Some(&theme));
assert_eq!(rec.strokes, vec![scheme.on_surface_variant]);
let mut on = built(Freq::Daily, Freq::Daily, true);
let rec = paint_rec(&mut on, Some(&theme));
assert_eq!(rec.strokes, vec![scheme.primary]);
}
#[test]
fn rebuild_adopts_new_selected_value() {
let mut counter = 0u64;
let prev = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily).on_changed(on_changed);
let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
assert!(!w.selected);
let next = radio::<GroupState, Freq>(Freq::Weekly, Freq::Weekly).on_changed(on_changed);
let flags =
View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert!(w.selected);
assert!(flags.needs_paint());
}
#[test]
fn rebuild_toggles_enabled_state() {
let mut counter = 0u64;
let prev = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily);
let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
assert!(!w.enabled);
let next = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily).on_changed(on_changed);
let flags =
View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert!(w.enabled);
assert!(flags.needs_paint());
}
#[test]
fn disabling_mid_press_clears_the_press_and_the_capture() {
let mut counter = 0u64;
let prev = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily).on_changed(on_changed);
let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
w.pressed = true;
w.captured = true;
w.state_layer.set_pressed(true);
let next = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily);
let flags =
View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert!(!w.pressed);
assert!(!w.captured);
assert_eq!(
w.state_layer.opacity(),
0.0,
"the state-layer's own pressed flag clears too"
);
assert!(flags.needs_paint());
}
const HOVER_DISABLE_WINDOW: Size = Size::new(200.0, 200.0);
struct HoverDisableHarness {
root: frust_core::RenderRoot<GroupState, RadioView<GroupState, Freq>>,
state: GroupState,
tcx: frust::authoring::text::TextContext,
enabled: bool,
}
impl HoverDisableHarness {
fn new(enabled: bool) -> Self {
let mut h = HoverDisableHarness {
root: frust_core::RenderRoot::new(),
state: GroupState {
selected: Freq::Daily,
changes: 0,
},
tcx: frust::authoring::text::TextContext::new(),
enabled,
};
h.sync();
h
}
fn sync(&mut self) {
let enabled = self.enabled;
let mut app = move |_: &mut GroupState| {
let view = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily);
if enabled {
view.on_changed(on_changed)
} else {
view
}
};
self.root.rebuild(&mut app, &mut self.state);
self.root
.layout_with_text(HOVER_DISABLE_WINDOW, &mut self.tcx as &mut dyn Any);
}
fn dispatch(&mut self, phase: PointerPhase, x: f64, y: f64) {
self.root.event(&mut self.state, &ev(phase, x, y));
}
fn paint(&mut self) -> Recorder {
let mut rec = Recorder::default();
self.root.paint(&mut rec, frust::FrameTime::ZERO);
rec
}
}
#[test]
fn a_disabled_radio_drops_a_standing_hover_claim_at_paint() {
let mut h = HoverDisableHarness::new(true);
h.dispatch(PointerPhase::Move, 5.0, 12.0);
let rec = h.paint();
assert_eq!(
rec.rrects.len(),
2,
"hovered+enabled radio paints the state-layer overlay plus the dot"
);
h.enabled = false;
h.sync();
let rec = h.paint();
assert_eq!(
rec.rrects.len(),
1,
"paint's self-correction must gate on `enabled`: state_layer.opacity() \
drops to 0.0 (the overlay fill is skipped entirely), so only the dot paints"
);
}
#[test]
fn label_presence_change_builds_and_tears_down_the_child() {
let mut counter = 0u64;
let prev = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily).on_changed(on_changed);
let mut w = View::<GroupState>::build(&prev, &mut BuildCtx::new(&mut counter));
assert!(w.label_pod.is_none());
let next = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily)
.on_changed(on_changed)
.label("Weekly");
let flags =
View::<GroupState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert!(w.label_pod.is_some());
assert_eq!(w.label_text.as_deref(), Some("Weekly"));
assert!(flags.needs_layout());
let prev2 = next;
let next2 = radio::<GroupState, Freq>(Freq::Weekly, Freq::Daily).on_changed(on_changed);
let flags =
View::<GroupState>::rebuild(&next2, &prev2, &mut w, &mut BuildCtx::new(&mut counter));
assert!(w.label_pod.is_none());
assert!(w.label_text.is_none());
assert!(flags.needs_layout());
}
#[test]
fn semantics_reports_role_label_selected_and_bounds() {
fn logic(_s: &mut ()) -> RadioView<(), Freq> {
radio::<(), Freq>(Freq::Daily, Freq::Daily)
.on_changed(|_, _| {})
.label("daily")
}
let mut root: frust_core::RenderRoot<(), RadioView<(), Freq>> =
frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = frust::authoring::text::TextContext::new();
root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::RadioButton)
.expect("radio contributes a Role::RadioButton node");
assert_eq!(node.label(), Some("daily"));
assert_eq!(node.is_selected(), Some(true));
assert!(node.supports_action(Action::Click));
let bounds = node.bounds().expect("radio node has bounds");
assert_eq!((bounds.x0, bounds.y0), (0.0, 0.0));
}
#[test]
fn semantics_omits_click_action_when_disabled() {
fn logic(_s: &mut ()) -> RadioView<(), Freq> {
radio::<(), Freq>(Freq::Daily, Freq::Weekly)
}
let mut root: frust_core::RenderRoot<(), RadioView<(), Freq>> =
frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = frust::authoring::text::TextContext::new();
root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
let update = root.semantics();
let (_, node) = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::RadioButton)
.expect("radio contributes a Role::RadioButton node");
assert!(!node.supports_action(Action::Click));
assert_eq!(node.is_selected(), Some(false));
}
}