use std::cell::RefCell;
use std::rc::Rc;
use teksilo_canvas::{Rect, Size, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::build_context::BuildContext;
use teksilo_core::event::{EventResponse, Key, WidgetEvent};
use teksilo_core::signal::{Prop, Signal};
use teksilo_core::styles::{RadioStyleConfig, RadioVariant, SharedRadioStyle};
use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::{TextRole, TextStyleRole, VAlignment};
use crate::button::InteractionState;
use crate::primitives::{HStack, MinSize, TextWidget, VStack};
use teksilo_i18n::LocalizedString;
pub struct RadioButton {
label: Option<LocalizedString>,
caption: Option<LocalizedString>,
value: usize,
selected: Signal<usize>,
enabled: Prop<bool>,
tooltip_text: Option<LocalizedString>,
rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
variant: RadioVariant,
style_override: Option<SharedRadioStyle>,
root_child_id: Option<WidgetId>,
group_ids: Option<Rc<RefCell<Vec<WidgetId>>>>,
}
impl RadioButton {
pub fn new(value: usize, selected: Signal<usize>) -> Self {
Self {
label: None,
caption: None,
value,
selected,
enabled: Prop::Static(true),
tooltip_text: None,
rich_tooltip_source: None,
composite_tooltip_content: None,
variant: RadioVariant::default(),
style_override: None,
root_child_id: None,
group_ids: None,
}
}
pub(crate) fn set_group_ids(&mut self, ids: Rc<RefCell<Vec<WidgetId>>>) {
self.group_ids = Some(ids);
}
pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
let ls: LocalizedString = label.into();
self.label = Some(ls);
self
}
pub fn caption(mut self, text: impl Into<LocalizedString>) -> Self {
let ls: LocalizedString = text.into();
self.caption = Some(ls);
self
}
pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
self.enabled = enabled.into();
self
}
pub fn variant(mut self, variant: RadioVariant) -> Self {
self.variant = variant;
self
}
pub fn style(mut self, style: impl teksilo_core::styles::RadioStyle) -> Self {
self.style_override = Some(Rc::new(style));
self
}
pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
self.tooltip_text = Some(text.into());
self.rich_tooltip_source = None;
self.composite_tooltip_content = None;
self
}
pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
self.tooltip_text = None;
self.composite_tooltip_content = None;
self
}
pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
self.tooltip_text = None;
self.composite_tooltip_content = None;
self
}
pub fn composite_tooltip(
mut self,
content: impl teksilo_core::widget::Widget + 'static,
) -> Self {
self.composite_tooltip_content = Some(Box::new(content));
self.tooltip_text = None;
self.rich_tooltip_source = None;
self
}
fn is_selected(&self) -> bool {
self.selected.get() == self.value
}
}
impl std::fmt::Debug for RadioButton {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RadioButton")
.field("label", &self.label)
.field("caption", &self.caption)
.field("value", &self.value)
.finish()
}
}
impl Widget for RadioButton {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
use crate::styles::recipe_radio_style as radio_dims;
let selected = self.selected.clone();
let value = self.value;
let variant = self.variant;
let self_id = ctx.self_id();
ctx.enabled_when(self_id, self.enabled.clone());
let effective_enabled = ctx.effective_enabled_signal(self_id);
let interaction = ctx.signal(InteractionState::Idle);
let is_selected = selected.map(move |s| *s == value);
let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
let is_focused = interaction
.map(|s| matches!(s, InteractionState::Focused))
.and(&ctx.focus_visible());
let is_disabled = effective_enabled.map(|on| !*on);
let style: SharedRadioStyle = self
.style_override
.clone()
.or_else(|| ctx.theme().style_slots.radio.clone())
.unwrap_or_else(|| Rc::new(crate::styles::RecipeRadioStyle::default()));
let cfg = RadioStyleConfig {
is_selected,
is_hovered,
is_pressed,
is_focused,
is_disabled,
variant,
};
let body_id = style.make_body(&cfg, ctx);
let mut row = HStack::new()
.spacing(radio_dims::RADIO_LABEL_GAP)
.add_child(body_id);
if let Some(ref label) = self.label {
let label_widget = TextWidget::new(label.clone())
.style(TextStyleRole::Body)
.color(TextRole::Primary)
.single_line()
.a11y_hidden();
let label_id = ctx.add(label_widget);
let label_column_id = if let Some(ref caption) = self.caption {
let caption_widget = TextWidget::new(caption.clone())
.style(TextStyleRole::Small)
.color(TextRole::Secondary)
.a11y_hidden();
let caption_id = ctx.add(caption_widget);
ctx.add(
VStack::new()
.spacing(2.0)
.add_child(label_id)
.add_child(caption_id),
)
} else {
label_id
};
row = row.add_child(label_column_id);
}
if self.caption.is_some() && self.label.is_some() {
row = row.alignment(VAlignment::Top);
}
let row_id = ctx.add(row);
let root_id = ctx.add(
MinSize::new(radio_dims::RADIO_HIT_AREA, radio_dims::RADIO_HIT_AREA).child_id(row_id),
);
if let Some(content) = self.composite_tooltip_content.take() {
let delay = ctx.theme().motion.tooltip_delay_heavy;
crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
} else if let Some(source) = self.rich_tooltip_source.take() {
let delay = ctx.theme().motion.tooltip_delay;
crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
} else if let Some(tooltip_text) = self.tooltip_text.clone() {
let delay = ctx.theme().motion.tooltip_delay;
crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
}
self.root_child_id = Some(root_id);
let sel_tap = self.selected.clone();
let sel_key = self.selected.clone();
let sel_access = self.selected.clone();
let int_tap = interaction.clone();
let int_hover = interaction.clone();
let int_key = interaction.clone();
let int_focus = interaction.clone();
let handler_set = HandlerSet::new()
.on_tap({
move |_pos, _ctx: &mut EventContext| {
sel_tap.set(value);
int_tap.set(InteractionState::Hovered);
}
})
.on_hover({
move |entered: bool, _ctx: &mut EventContext| {
if entered {
int_hover.set(InteractionState::Hovered);
} else {
int_hover.set(InteractionState::Idle);
}
}
})
.on_key({
move |event: &WidgetEvent, _ctx: &mut EventContext| -> EventResponse {
match event {
WidgetEvent::KeyDown {
key: Key::Space, ..
} => {
int_key.set(InteractionState::Pressed);
EventResponse::Handled
}
WidgetEvent::KeyUp {
key: Key::Space, ..
} => {
if int_key.get() != InteractionState::Pressed {
return EventResponse::Ignored;
}
sel_key.set(value);
int_key.set(InteractionState::Focused);
EventResponse::Handled
}
_ => EventResponse::Ignored,
}
}
})
.on_focus({
move |gained: bool, _ctx: &mut EventContext| {
if gained {
if int_focus.get() == InteractionState::Idle {
int_focus.set(InteractionState::Focused);
}
} else {
int_focus.set(InteractionState::Idle);
}
}
})
.on_access_action({
move |action: teksilo_core::accesskit::Action,
_ctx: &mut EventContext|
-> EventResponse {
if action == teksilo_core::accesskit::Action::Click {
sel_access.set(value);
EventResponse::Handled
} else {
EventResponse::Ignored
}
}
})
.focusable(true)
.cursor(CursorIcon::Pointer);
ctx.apply_self_handlers(handler_set);
vec![root_id]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
if let Some(root) = self.root_child_id
&& let Some(size) = ctx.child_size(root, proposal)
{
return (size).into();
}
proposal.resolve(0.0, 0.0).into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
child.size = Size::new(bounds.width, bounds.height);
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(teksilo_core::accesskit::Role::RadioButton);
if let Some(ref label) = self.label {
builder.set_name(label.resolve_now());
}
if let Some(ref caption) = self.caption {
builder.set_description(caption.resolve_now());
}
builder.set_toggled(self.is_selected());
if let Some(group_ids) = &self.group_ids {
for &id in group_ids.borrow().iter() {
builder.push_to_radio_group(teksilo_core::accessibility::widget_id_to_node_id(id));
}
}
builder.add_action(teksilo_core::accesskit::Action::Click);
builder.add_action(teksilo_core::accesskit::Action::Focus);
}
fn children(&self) -> Vec<WidgetId> {
self.root_child_id.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_core::event::Modifiers;
use teksilo_core::widget_tree::WidgetTree;
use teksilo_i18n::lit;
#[test]
fn selecting_one_deselects_others() {
use crate::primitives::VStack;
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
let r2 = tree.add(RadioButton::new(2, selected.clone()).label(lit!("C")));
let _root = tree.add(VStack::new().add_child(r0).add_child(r1).add_child(r2));
tree.layout(SizeProposal::exact(200.0, 300.0));
assert_eq!(selected.get(), 0);
tree.click(r1);
assert_eq!(selected.get(), 1);
tree.click(r2);
assert_eq!(selected.get(), 2);
tree.click(r0);
assert_eq!(selected.get(), 0);
}
#[test]
fn space_selects() {
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let _r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
tree.layout(SizeProposal::exact(200.0, 200.0));
tree.focus(r1);
tree.press_key(Key::Space, Modifiers::NONE);
assert_eq!(selected.get(), 1);
}
#[test]
fn lone_keyup_does_not_select() {
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let _r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
tree.layout(SizeProposal::exact(200.0, 200.0));
tree.focus(r1);
tree.dispatch_event(WidgetEvent::KeyUp {
key: Key::Space,
modifiers: Modifiers::NONE,
});
assert_eq!(selected.get(), 0, "a lone KeyUp must not select the radio");
tree.press_key(Key::Space, Modifiers::NONE);
assert_eq!(selected.get(), 1);
}
#[test]
fn accessibility() {
let selected = Signal::new(1_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
tree.layout(SizeProposal::exact(200.0, 200.0));
let info0 = tree.accessibility_node(r0);
assert_eq!(info0.role(), teksilo_core::accesskit::Role::RadioButton);
assert!(!info0.is_toggled());
let info1 = tree.accessibility_node(r1);
assert!(info1.is_toggled());
}
#[test]
fn accessibility_has_actions() {
let selected = Signal::new(0_usize);
let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
let r0 = tree.add(RadioButton::new(0, selected).label(lit!("A")));
tree.layout(SizeProposal::exact(200.0, 200.0));
let info = tree.accessibility_node(r0);
assert!(
info.actions()
.contains(&teksilo_core::accesskit::Action::Click)
);
}
}