use std::sync::Arc;
use blinc_core::{Color, State, Transform};
use blinc_theme::{ColorToken, ThemeState};
use crate::css_parser::{active_stylesheet, ElementState, Stylesheet};
use crate::div::{div, ElementBuilder};
use crate::element::RenderProps;
use crate::element_style::ElementStyle;
use crate::key::InstanceKey;
use crate::stateful::{stateful_with_key, ButtonState};
use crate::text::text;
use crate::tree::{LayoutNodeId, LayoutTree};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RadioLayout {
#[default]
Vertical,
Horizontal,
}
#[derive(Clone)]
struct RadioOption {
value: String,
label: String,
disabled: bool,
}
#[derive(Clone)]
#[allow(clippy::type_complexity)]
pub struct RadioGroupConfig {
selected: State<String>,
options: Vec<RadioOption>,
pub layout: RadioLayout,
pub label: Option<String>,
pub disabled: bool,
pub gap: f32,
pub outer_size: f32,
pub inner_size: f32,
pub border_width: f32,
pub selected_color: Option<Color>,
pub border_color: Option<Color>,
pub hover_border_color: Option<Color>,
pub label_color: Option<Color>,
pub label_font_size: f32,
pub css_group_id: Option<String>,
pub on_change: Option<Arc<dyn Fn(&str) + Send + Sync>>,
}
impl RadioGroupConfig {
fn new(selected: State<String>) -> Self {
Self {
selected,
options: Vec::new(),
layout: RadioLayout::default(),
label: None,
disabled: false,
gap: 8.0,
outer_size: 18.0,
inner_size: 8.0,
border_width: 2.0,
selected_color: None,
border_color: None,
hover_border_color: None,
label_color: None,
label_font_size: 14.0,
css_group_id: None,
on_change: None,
}
}
}
struct RadioStyleOverrides {
selected_color: Color,
border_color: Color,
hover_border_color: Color,
label_color: Color,
opacity: Option<f32>,
background: Option<Color>,
}
impl RadioStyleOverrides {
fn apply(&mut self, style: &ElementStyle) {
if let Some(color) = style.accent_color {
self.selected_color = color;
}
if let Some(color) = style.border_color {
self.border_color = color;
self.hover_border_color = color;
}
if let Some(color) = style.text_color {
self.label_color = color;
}
if let Some(o) = style.opacity {
self.opacity = Some(o);
}
if let Some(blinc_core::Brush::Solid(color)) = style.background {
self.background = Some(color);
}
}
}
fn apply_css_overrides_radio(
stylesheet: &Stylesheet,
element_id: &str,
is_selected: bool,
is_hovered: bool,
is_disabled: bool,
overrides: &mut RadioStyleOverrides,
) {
if let Some(base) = stylesheet.get(element_id) {
overrides.apply(base);
}
if is_selected {
if let Some(s) = stylesheet.get_with_state(element_id, ElementState::Checked) {
overrides.apply(s);
}
}
if is_hovered {
if let Some(s) = stylesheet.get_with_state(element_id, ElementState::Hover) {
overrides.apply(s);
}
}
if is_disabled {
if let Some(s) = stylesheet.get_with_state(element_id, ElementState::Disabled) {
overrides.apply(s);
}
}
}
fn build_radio_button(
config: &RadioGroupConfig,
option: &RadioOption,
instance_key: &InstanceKey,
button_css_id: Option<String>,
) -> crate::stateful::Stateful<ButtonState> {
let outer_size = config.outer_size;
let inner_size = config.inner_size;
let border_width = config.border_width;
let option_value = option.value.clone();
let option_disabled = option.disabled || config.disabled;
let option_label = option.label.clone();
let selected_state = config.selected.clone();
let selected_state_for_click = config.selected.clone();
let on_change = config.on_change.clone();
let value_for_click = option.value.clone();
let cfg_selected_color = config.selected_color;
let cfg_border_color = config.border_color;
let cfg_hover_border_color = config.hover_border_color;
let cfg_label_color = config.label_color;
let cfg_label_font_size = config.label_font_size;
let css_id_for_state = button_css_id.clone();
let stateful_key = instance_key.derive(&option.value);
let mut radio = stateful_with_key::<ButtonState>(&stateful_key)
.deps([selected_state.signal_id()])
.on_state(move |ctx| {
let state = ctx.state();
let theme = ThemeState::get();
let is_selected = selected_state.get() == option_value;
let is_hovered = matches!(state, ButtonState::Hovered | ButtonState::Pressed);
let is_pressed = matches!(state, ButtonState::Pressed);
let mut overrides = RadioStyleOverrides {
selected_color: cfg_selected_color
.unwrap_or_else(|| theme.color(ColorToken::Primary)),
border_color: cfg_border_color
.unwrap_or_else(|| theme.color(ColorToken::BorderSecondary)),
hover_border_color: cfg_hover_border_color
.unwrap_or_else(|| theme.color(ColorToken::Primary)),
label_color: cfg_label_color.unwrap_or_else(|| {
if option_disabled {
theme.color(ColorToken::TextTertiary)
} else {
theme.color(ColorToken::TextPrimary)
}
}),
opacity: None,
background: None,
};
if let Some(ref css_id) = css_id_for_state {
if let Some(stylesheet) = active_stylesheet() {
apply_css_overrides_radio(
&stylesheet,
css_id,
is_selected,
is_hovered,
option_disabled,
&mut overrides,
);
}
}
let border_color = if is_selected {
overrides.selected_color
} else if is_hovered && !option_disabled {
overrides.hover_border_color
} else {
overrides.border_color
};
let scale = if is_hovered && !option_disabled {
1.05
} else {
1.0
};
let inner_scale = if is_pressed && !option_disabled {
0.8
} else {
1.0
};
let mut circle = div()
.w(outer_size)
.h(outer_size)
.rounded(outer_size / 2.0)
.border(border_width, border_color)
.items_center()
.justify_center()
.transform(Transform::scale(scale, scale));
if let Some(bg) = overrides.background {
circle = circle.bg(bg);
}
if is_selected {
let inner_dot = div()
.w(inner_size)
.h(inner_size)
.rounded(inner_size / 2.0)
.bg(overrides.selected_color)
.transform(Transform::scale(inner_scale, inner_scale));
circle = circle.child(inner_dot);
}
let mut visual = div()
.flex_row()
.gap(8.0)
.items_center()
.cursor_pointer()
.child(circle)
.child(
text(&option_label)
.size(cfg_label_font_size)
.color(overrides.label_color),
);
if let Some(opacity) = overrides.opacity {
visual = visual.opacity(opacity);
} else if option_disabled {
visual = visual.opacity(0.5);
}
visual
});
if let Some(ref css_id) = button_css_id {
radio = radio.id(css_id);
}
radio = radio.on_click(move |_| {
if option_disabled {
return;
}
let current = selected_state_for_click.get();
if current != value_for_click {
selected_state_for_click.set(value_for_click.clone());
if let Some(ref callback) = on_change {
callback(&value_for_click);
}
}
});
radio
}
pub struct RadioGroup {
inner: crate::div::Div,
}
impl RadioGroup {
fn with_config(instance_key: &InstanceKey, config: RadioGroupConfig) -> Self {
let gap = config.gap;
let mut options_container = match config.layout {
RadioLayout::Vertical => div().flex_col().gap(gap),
RadioLayout::Horizontal => div().flex_row().gap(gap).flex_wrap(),
};
for option in &config.options {
let button_css_id = config.css_group_id.as_ref().map(|group_id| {
let sanitized = option.value.replace(' ', "-");
format!("{group_id}-{sanitized}")
});
options_container = options_container.child(build_radio_button(
&config,
option,
instance_key,
button_css_id,
));
}
let inner = if let Some(ref label_text) = config.label {
let theme = ThemeState::get();
let label_color = if config.disabled {
theme.color(ColorToken::TextTertiary)
} else {
theme.color(ColorToken::TextSecondary)
};
div()
.flex_col()
.gap(6.0)
.child(text(label_text).size(13.0).color(label_color))
.child(options_container)
} else {
options_container
};
Self { inner }
}
}
impl ElementBuilder for RadioGroup {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.inner.build(tree)
}
fn render_props(&self) -> RenderProps {
self.inner.render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.inner.children_builders()
}
fn element_type_id(&self) -> crate::div::ElementTypeId {
self.inner.element_type_id()
}
fn semantic_type_name(&self) -> Option<&'static str> {
Some("radio")
}
fn layout_style(&self) -> Option<&taffy::Style> {
self.inner.layout_style()
}
fn event_handlers(&self) -> Option<&crate::event_handler::EventHandlers> {
ElementBuilder::event_handlers(&self.inner)
}
fn element_id(&self) -> Option<&str> {
self.inner.element_id()
}
fn element_classes(&self) -> &[String] {
self.inner.element_classes()
}
}
pub struct RadioGroupBuilder {
key: InstanceKey,
config: RadioGroupConfig,
built: std::cell::OnceCell<RadioGroup>,
}
impl RadioGroupBuilder {
#[track_caller]
pub fn new(selected: &State<String>) -> Self {
Self {
key: InstanceKey::new("radio"),
config: RadioGroupConfig::new(selected.clone()),
built: std::cell::OnceCell::new(),
}
}
fn get_or_build(&self) -> &RadioGroup {
self.built
.get_or_init(|| RadioGroup::with_config(&self.key, self.config.clone()))
}
pub fn id(mut self, id: impl Into<String>) -> Self {
self.config.css_group_id = Some(id.into());
self
}
pub fn option(mut self, value: impl Into<String>, label: impl Into<String>) -> Self {
self.config.options.push(RadioOption {
value: value.into(),
label: label.into(),
disabled: false,
});
self
}
pub fn option_disabled(mut self, value: impl Into<String>, label: impl Into<String>) -> Self {
self.config.options.push(RadioOption {
value: value.into(),
label: label.into(),
disabled: true,
});
self
}
pub fn horizontal(mut self) -> Self {
self.config.layout = RadioLayout::Horizontal;
self
}
pub fn vertical(mut self) -> Self {
self.config.layout = RadioLayout::Vertical;
self
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.config.label = Some(label.into());
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.config.disabled = disabled;
self
}
pub fn gap(mut self, gap: f32) -> Self {
self.config.gap = gap;
self
}
pub fn selected_color(mut self, color: impl Into<Color>) -> Self {
self.config.selected_color = Some(color.into());
self
}
pub fn border_color(mut self, color: impl Into<Color>) -> Self {
self.config.border_color = Some(color.into());
self
}
pub fn hover_border_color(mut self, color: impl Into<Color>) -> Self {
self.config.hover_border_color = Some(color.into());
self
}
pub fn on_change<F>(mut self, callback: F) -> Self
where
F: Fn(&str) + Send + Sync + 'static,
{
self.config.on_change = Some(Arc::new(callback));
self
}
}
impl ElementBuilder for RadioGroupBuilder {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.get_or_build().build(tree)
}
fn render_props(&self) -> RenderProps {
self.get_or_build().render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.get_or_build().children_builders()
}
fn element_type_id(&self) -> crate::div::ElementTypeId {
self.get_or_build().element_type_id()
}
fn semantic_type_name(&self) -> Option<&'static str> {
Some("radio")
}
fn layout_style(&self) -> Option<&taffy::Style> {
self.get_or_build().layout_style()
}
fn event_handlers(&self) -> Option<&crate::event_handler::EventHandlers> {
self.get_or_build().event_handlers()
}
fn element_id(&self) -> Option<&str> {
self.get_or_build().element_id()
}
fn element_classes(&self) -> &[String] {
self.get_or_build().element_classes()
}
}
#[track_caller]
pub fn radio_group(selected: &State<String>) -> RadioGroupBuilder {
RadioGroupBuilder::new(selected)
}