use std::rc::Rc;
use frust::authoring::text::{
FontFamily, FontWeight, GenericSlot, TextContext, TextLayout, TextStyle,
};
use frust::authoring::{Action, Role};
use frust::authoring::{
BoxConstraints, BuildCtx, ChangeFlags, ErasedCallback, EventCtx, EventResult, InputEvent,
LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, erase_callback,
};
use frust::{ShapeScale, Theme};
use kurbo::{Point, Rect, RoundedRect, Shape, Size, Vec2};
use peniko::{Brush, Color};
use crate::press::presses;
const RING: f64 = 16.0;
const RING_BORDER: f64 = 1.5;
const DOT_INSET: f64 = 3.0;
const GAP: f64 = 9.0;
const PATH_TOLERANCE: f64 = 0.1;
const LABEL_SIZE: f32 = 13.0;
const RADIO_RING_OFF: Color = Color::from_rgb8(0x3e, 0x3f, 0x44);
const RADIO_ACCENT: Color = Color::from_rgb8(0xff, 0xb6, 0x27);
const RADIO_LABEL_FG: Color = Color::from_rgb8(0xf2, 0xea, 0xd9);
fn ui_face() -> FontFamily {
FontFamily::stack_with_generic(["IBM Plex Mono"], GenericSlot::Monospace)
}
fn label_style(theme: Option<&Theme>, color: Color) -> TextStyle {
TextStyle {
family: theme.map_or_else(ui_face, |t| t.type_scale.body_large.family.clone()),
weight: FontWeight::REGULAR,
..TextStyle::new(LABEL_SIZE, color)
}
}
struct RadioColors {
ring: Color,
dot: Color,
label: Color,
}
fn resolve_radio_colors(theme: Option<&Theme>, selected: bool) -> RadioColors {
match theme {
Some(theme) => {
let scheme = theme.scheme();
RadioColors {
ring: if selected {
scheme.primary
} else {
scheme.outline
},
dot: scheme.primary_container,
label: scheme.on_surface,
}
}
None => RadioColors {
ring: if selected {
RADIO_ACCENT
} else {
RADIO_RING_OFF
},
dot: RADIO_ACCENT,
label: RADIO_LABEL_FG,
},
}
}
fn ring_radius(theme: Option<&Theme>, size: Size) -> f64 {
match theme {
Some(theme) => ShapeScale::resolve(theme.shape.full, size.width, size.height),
None => size.height / 2.0,
}
}
struct GlyphLabel {
content: String,
layout: Option<TextLayout>,
laid_out_style: Option<TextStyle>,
}
impl GlyphLabel {
fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
layout: None,
laid_out_style: None,
}
}
fn set_content(&mut self, content: impl Into<String>) {
let content = content.into();
if self.content != content {
self.content = content;
self.layout = None;
}
}
fn layout(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
if let Some(cached) = &self.layout
&& self.laid_out_style.as_ref() == Some(style)
{
return cached.size();
}
let text_ctx = ctx.text_context::<TextContext>();
let laid = text_ctx.layout(&self.content, style, None);
let size = laid.size();
self.layout = Some(laid);
self.laid_out_style = Some(style.clone());
size
}
fn paint(&self, origin: Point, scene: &mut dyn PaintScene) {
if let Some(layout) = &self.layout {
for run in layout.to_scene_runs(origin) {
scene.draw_glyph_run(run);
}
}
}
}
type OnSelect<State> = Rc<dyn Fn(&mut State)>;
pub struct RadioView<State: 'static> {
selected: bool,
label: Option<String>,
on_select: OnSelect<State>,
}
pub fn radio<State: 'static, F: Fn(&mut State) + 'static>(
selected: bool,
on_select: F,
) -> RadioView<State> {
RadioView {
selected,
label: None,
on_select: Rc::new(on_select),
}
}
#[allow(non_snake_case)]
pub fn Radio<State: 'static, F: Fn(&mut State) + 'static>(
selected: bool,
on_select: F,
) -> RadioView<State> {
radio(selected, on_select)
}
impl<State: 'static> RadioView<State> {
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
}
pub struct RadioWidget {
selected: bool,
label: Option<GlyphLabel>,
label_text: Option<String>,
label_size: Size,
captured: bool,
on_select: ErasedCallback,
}
fn inside(pos: Point, size: Size) -> bool {
pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
}
impl<State: 'static> View<State> for RadioView<State> {
type Element = RadioWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> RadioWidget {
RadioWidget {
selected: self.selected,
label: self.label.as_ref().map(GlyphLabel::new),
label_text: self.label.clone(),
label_size: Size::ZERO,
captured: false,
on_select: erase_callback(&self.on_select),
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut RadioWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.on_select = erase_callback(&self.on_select);
let mut flags = ChangeFlags::NONE;
if prev.selected != self.selected {
element.selected = self.selected;
flags |= ChangeFlags::PAINT;
}
if prev.label != self.label {
element.label_text = self.label.clone();
match (&mut element.label, &self.label) {
(Some(existing), Some(text)) => existing.set_content(text.clone()),
(slot @ None, Some(text)) => *slot = Some(GlyphLabel::new(text.clone())),
(slot, None) => *slot = None,
}
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
flags
}
}
impl Widget for RadioWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let theme = Theme::from_layout_ctx(ctx);
let label_color = resolve_radio_colors(theme, self.selected).label;
let label_style = label_style(theme, label_color);
self.label_size = match &mut self.label {
Some(label) => label.layout(ctx, &label_style),
None => Size::ZERO,
};
let gap = if self.label.is_some() { GAP } else { 0.0 };
let width = RING + gap + self.label_size.width;
let height = RING.max(self.label_size.height);
bc.constrain(Size::new(width, height))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let colors = resolve_radio_colors(theme, self.selected);
let ring_size = Size::new(RING, RING);
let radius = ring_radius(theme, ring_size);
let origin = ctx.origin();
let height = ctx.size().height;
let ring_origin = origin + Vec2::new(0.0, ((height - RING) / 2.0).max(0.0));
let inset = RING_BORDER / 2.0;
let outline = RoundedRect::from_rect(
Rect::from_origin_size(Point::ORIGIN, ring_size).inset(-inset),
(radius - inset).max(0.0),
);
scene.stroke_path(
ring_origin,
&outline.to_path(PATH_TOLERANCE),
RING_BORDER,
&Brush::Solid(colors.ring),
);
if self.selected {
let dot_size = RING - 2.0 * DOT_INSET;
scene.fill_rounded_rect(
ring_origin + Vec2::new(DOT_INSET, DOT_INSET),
Size::new(dot_size, dot_size),
dot_size / 2.0,
colors.dot,
);
}
if let Some(label) = &self.label {
let label_y = origin.y + ((height - self.label_size.height) / 2.0).max(0.0);
label.paint(Point::new(origin.x + RING + GAP, label_y), scene);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
match p.phase {
PointerPhase::Down => {
if !presses(p) {
return EventResult::Ignored;
}
self.captured = true;
ctx.capture_pointer();
EventResult::Handled
}
PointerPhase::Move => {
if self.captured {
EventResult::Handled
} else {
EventResult::Ignored
}
}
PointerPhase::Up => {
if !self.captured {
return EventResult::Ignored;
}
self.captured = false;
if inside(p.position, ctx.size()) {
(self.on_select)(ctx);
}
EventResult::Handled
}
PointerPhase::Cancel => {
if !self.captured {
return EventResult::Ignored;
}
self.captured = false;
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_node(Role::RadioButton, |node| {
node.set_selected(self.selected);
node.add_action(Action::Click);
if let Some(label) = &self.label_text {
node.set_label(label.as_str());
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::{PointerButton, PointerEvent};
use std::any::Any;
#[derive(Default)]
struct RadioState {
selects: u32,
}
#[derive(Default)]
struct Recorder {
rrects: Vec<(Point, Size, f64, Color)>,
strokes: Vec<Color>,
glyph_colors: 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, _p: &kurbo::BezPath, _w: f64, brush: &Brush) {
if let Brush::Solid(c) = brush {
self.strokes.push(*c);
}
}
fn draw_glyph_run(&mut self, run: frust::authoring::scene::GlyphRun) {
if let Brush::Solid(c) = run.brush {
self.glyph_colors.push(c);
}
}
}
fn widget(selected: bool) -> RadioWidget {
build(&labelled(selected))
}
fn labelled(selected: bool) -> RadioView<RadioState> {
radio::<RadioState, _>(selected, |s: &mut RadioState| {
s.selects += 1;
})
.label("daily")
}
fn build(view: &RadioView<RadioState>) -> RadioWidget {
let mut counter = 0u64;
View::<RadioState>::build(view, &mut BuildCtx::new(&mut counter))
}
fn layout_and_paint(w: &mut RadioWidget, theme: Option<&Theme>) -> Recorder {
let mut tcx = TextContext::new();
let mut lctx = match theme {
Some(t) => {
LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), Some(t as &dyn Any))
}
None => LayoutCtx::with_text_context(&mut tcx as &mut dyn Any),
};
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 100.0)));
let mut rec = Recorder::default();
let mut pctx = match theme {
Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
None => PaintCtx::new(Point::ZERO, size),
};
w.paint(&mut pctx, &mut rec);
rec
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Secondary,
})
}
fn dispatch(w: &mut RadioWidget, state: &mut RadioState, event: &InputEvent, size: Size) {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
w.event(&mut ctx, event);
}
#[test]
fn unthemed_unselected_paints_no_dot() {
let mut w = widget(false);
let rec = layout_and_paint(&mut w, None);
assert_eq!(rec.strokes, vec![RADIO_RING_OFF], "unselected ring color");
assert!(rec.rrects.is_empty(), "unselected paints no inset dot");
}
#[test]
fn unthemed_selected_paints_the_accent_ring_and_dot() {
let mut w = widget(true);
let rec = layout_and_paint(&mut w, None);
assert_eq!(rec.strokes, vec![RADIO_ACCENT], "selected ring color");
assert_eq!(rec.rrects.len(), 1, "selected paints the inset dot");
assert_eq!(rec.rrects[0].3, RADIO_ACCENT);
assert_eq!(
rec.rrects[0].1,
Size::new(RING - 2.0 * DOT_INSET, RING - 2.0 * DOT_INSET)
);
}
#[test]
fn glyph_dark_theme_splits_ring_ink_from_the_bright_fill_dot() {
let theme = crate::baseline();
let scheme = theme.scheme();
let mut off = widget(false);
let rec = layout_and_paint(&mut off, Some(&theme));
assert_eq!(rec.strokes, vec![scheme.outline]);
assert!(rec.rrects.is_empty());
let mut on = widget(true);
let rec = layout_and_paint(&mut on, Some(&theme));
assert_eq!(rec.strokes, vec![scheme.primary], "ring uses accent ink");
assert_eq!(
rec.rrects[0].3, scheme.primary_container,
"dot uses the bright fill, never accent text ink"
);
assert_eq!(scheme.primary, scheme.primary_container);
}
#[test]
fn glyph_light_theme_splits_ring_ink_from_the_dot_into_two_distinct_hexes() {
let theme = crate::baseline().with_brightness(frust::Brightness::Light);
let scheme = theme.scheme();
let mut on = widget(true);
let rec = layout_and_paint(&mut on, Some(&theme));
assert_eq!(rec.strokes, vec![scheme.primary]);
assert_eq!(rec.rrects[0].3, scheme.primary_container);
assert_ne!(
scheme.primary, scheme.primary_container,
"light is where the accent-role split actually diverges — a \
darkened text tone vs the still-bright fill"
);
}
#[test]
fn label_ink_resolves_on_surface_when_themed_and_the_fallback_hex_unthemed() {
let mut w = widget(false);
let rec = layout_and_paint(&mut w, None);
assert_eq!(rec.glyph_colors, vec![RADIO_LABEL_FG]);
let theme = crate::baseline();
let mut w = widget(false);
let rec = layout_and_paint(&mut w, Some(&theme));
assert_eq!(rec.glyph_colors, vec![theme.scheme().on_surface]);
}
#[test]
fn a_labelless_radio_paints_no_glyph_run_and_is_ring_width_only() {
let view = radio::<RadioState, _>(false, |_s: &mut RadioState| {});
let mut w = build(&view);
let mut tcx = TextContext::new();
let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(400.0, 100.0)));
assert_eq!(size, Size::new(RING, RING), "no label, no gap, ring-only");
let rec = layout_and_paint(&mut w, None);
assert!(rec.glyph_colors.is_empty());
}
#[test]
fn release_inside_fires_on_select_and_never_self_mutates() {
let mut w = widget(false);
let mut state = RadioState::default();
let slab = Size::new(200.0, 24.0);
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 8.0), slab);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 8.0, 8.0), slab);
assert_eq!(state.selects, 1);
assert!(!w.selected, "radio must not mutate its own selected flag");
}
#[test]
fn an_already_selected_radio_still_fires_on_release() {
let mut w = widget(true);
let mut state = RadioState::default();
let slab = Size::new(200.0, 24.0);
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 8.0), slab);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 8.0, 8.0), slab);
assert_eq!(state.selects, 1);
assert!(w.selected, "still selected until the app rebuilds it");
}
#[test]
fn release_outside_does_not_fire() {
let mut w = widget(false);
let mut state = RadioState::default();
let slab = Size::new(200.0, 24.0);
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 8.0), slab);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Move, 500.0, 8.0),
slab,
);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 500.0, 8.0), slab);
assert_eq!(state.selects, 0);
assert!(!w.captured, "the release disarms either way");
}
#[test]
fn cancel_disarms_the_press() {
let mut w = widget(false);
let mut state = RadioState::default();
let slab = Size::new(200.0, 24.0);
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 8.0), slab);
assert!(w.captured);
dispatch(
&mut w,
&mut state,
&ev(PointerPhase::Cancel, 8.0, 8.0),
slab,
);
assert!(!w.captured);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 8.0, 8.0), slab);
assert_eq!(state.selects, 0, "a post-cancel Up is not this gesture");
}
#[test]
fn a_secondary_press_never_captures_or_fires() {
let mut w = widget(false);
let mut state = RadioState::default();
let slab = Size::new(200.0, 24.0);
dispatch(
&mut w,
&mut state,
&secondary_ev(PointerPhase::Down, 8.0, 8.0),
slab,
);
assert!(!w.captured);
dispatch(
&mut w,
&mut state,
&secondary_ev(PointerPhase::Up, 8.0, 8.0),
slab,
);
assert_eq!(state.selects, 0);
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 8.0, 8.0), slab);
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 8.0, 8.0), slab);
assert_eq!(state.selects, 1);
}
#[test]
fn an_unarmed_move_is_ignored() {
let mut w = widget(false);
let mut state = RadioState::default();
let state_any: &mut dyn Any = &mut state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(200.0, 24.0));
let result = w.event(&mut ctx, &ev(PointerPhase::Move, 8.0, 8.0));
assert!(matches!(result, EventResult::Ignored));
assert!(!ctx.needs_redraw(), "a hover must not request a redraw");
}
#[test]
fn semantics_reports_a_labelled_selected_radio_button() {
fn logic(_s: &mut RadioState) -> RadioView<RadioState> {
radio::<RadioState, _>(true, |_s: &mut RadioState| {}).label("daily")
}
let mut root: frust_core::RenderRoot<RadioState, RadioView<RadioState>> =
frust_core::RenderRoot::new();
let mut state = RadioState::default();
root.rebuild(&mut logic, &mut state);
let mut tcx = 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("the radio contributes a Role::RadioButton node");
assert_eq!(node.is_selected(), Some(true));
assert!(node.supports_action(Action::Click));
assert_eq!(node.label(), Some("daily"));
}
#[test]
fn semantics_mirrors_an_unselected_unlabelled_radio() {
fn logic(_s: &mut RadioState) -> RadioView<RadioState> {
radio::<RadioState, _>(false, |_s: &mut RadioState| {})
}
let mut root: frust_core::RenderRoot<RadioState, RadioView<RadioState>> =
frust_core::RenderRoot::new();
let mut state = RadioState::default();
root.rebuild(&mut logic, &mut state);
let mut tcx = 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("the radio contributes a Role::RadioButton node");
assert_eq!(node.is_selected(), Some(false));
assert_eq!(node.label(), None);
}
#[test]
fn a_selected_change_alone_never_forces_a_relayout() {
let prev = radio::<RadioState, _>(false, |_s: &mut RadioState| {}).label("daily");
let mut w = build(&prev);
let next = radio::<RadioState, _>(true, |_s: &mut RadioState| {}).label("daily");
let mut counter = 0u64;
let flags =
View::<RadioState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert!(w.selected);
assert!(flags.needs_paint());
assert!(
!flags.needs_layout(),
"the ring/label geometry never changes on selection alone"
);
}
#[test]
fn a_label_change_forces_a_relayout_and_republish() {
let prev = radio::<RadioState, _>(false, |_s: &mut RadioState| {});
let mut w = build(&prev);
assert_eq!(w.label_text, None);
let next = radio::<RadioState, _>(false, |_s: &mut RadioState| {}).label("weekly");
let mut counter = 0u64;
let flags =
View::<RadioState>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert_eq!(w.label_text.as_deref(), Some("weekly"));
assert!(flags.needs_layout());
assert!(flags.needs_paint());
}
#[cfg(feature = "bundled-fonts")]
fn labelled_radio(_: &mut ()) -> RadioView<()> {
radio(true, |_s: &mut ()| {}).label("weekly")
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn the_label_paints_in_its_role_face_under_the_glyph_theme() {
use crate::badge::typeface_probe::{Face, painted_faces};
let faces = painted_faces(labelled_radio, crate::baseline(), Size::new(400.0, 100.0));
assert_eq!(faces, [Face::PlexMono]);
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn the_label_follows_a_live_theme_family_swap() {
use crate::badge::typeface_probe::{Face, faces_across_a_live_swap};
let (before, after) = faces_across_a_live_swap(labelled_radio, Size::new(400.0, 100.0));
assert_eq!(before, [Face::PlexMono]);
assert_eq!(after, [Face::SpaceMono]);
}
}