use blinc_core::{Color, State, Transform};
use blinc_layout::css_parser::{active_stylesheet, ElementState, Stylesheet};
use blinc_layout::div::ElementTypeId;
use blinc_layout::element::RenderProps;
use blinc_layout::element_style::ElementStyle;
use blinc_layout::prelude::*;
use blinc_layout::stateful::{stateful_with_key, ButtonState};
use blinc_layout::tree::{LayoutNodeId, LayoutTree};
use blinc_theme::{ColorToken, SpacingToken, ThemeState};
use std::sync::Arc;
use super::label::{label, LabelSize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RadioSize {
Small,
#[default]
Medium,
Large,
}
impl RadioSize {
fn outer_size(&self) -> f32 {
match self {
RadioSize::Small => 14.0,
RadioSize::Medium => 18.0,
RadioSize::Large => 22.0,
}
}
fn inner_size(&self) -> f32 {
match self {
RadioSize::Small => 6.0,
RadioSize::Medium => 8.0,
RadioSize::Large => 10.0,
}
}
fn border_width(&self) -> f32 {
match self {
RadioSize::Small => 1.5,
RadioSize::Medium => 2.0,
RadioSize::Large => 2.0,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RadioLayout {
#[default]
Vertical,
Horizontal,
}
#[derive(Clone)]
struct RadioOption {
value: String,
label: String,
disabled: bool,
}
pub struct RadioGroup {
inner: Div,
}
impl RadioGroup {
pub fn new(selected: &State<String>) -> Self {
Self::with_config(RadioGroupConfig::new(selected.clone()))
}
fn with_config(config: RadioGroupConfig) -> Self {
let theme = ThemeState::get();
let default_gap = theme.spacing_value(SpacingToken::Space1);
let gap = config.gap.unwrap_or(default_gap);
let mut options_container = match config.layout {
RadioLayout::Vertical => div().flex_col().gap(gap).h_fit(),
RadioLayout::Horizontal => div().flex_row().gap(gap).flex_wrap().h_fit(),
};
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, theme, button_css_id));
}
let mut inner = if let Some(ref label_text) = config.label {
let spacing = theme.spacing_value(SpacingToken::Space2);
let mut lbl = label(label_text).size(LabelSize::Medium);
if config.disabled {
lbl = lbl.disabled(true);
}
div()
.flex_col()
.gap(spacing)
.h_fit()
.child(lbl)
.child(options_container)
} else {
options_container
};
for c in &config.classes {
inner = inner.class(c);
}
Self { inner }
}
}
fn build_radio_button(
config: &RadioGroupConfig,
option: &RadioOption,
theme: &ThemeState,
button_css_id: Option<String>,
) -> Stateful<ButtonState> {
let outer_size = config.size.outer_size();
let inner_size = config.size.inner_size();
let border_width = config.size.border_width();
let selected_color = config
.selected_color
.unwrap_or_else(|| theme.color(ColorToken::Primary));
let border = config
.border_color
.unwrap_or_else(|| theme.color(ColorToken::BorderSecondary));
let hover_border = config
.hover_border_color
.unwrap_or_else(|| theme.color(ColorToken::Primary));
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 label_color = if option_disabled {
theme.color(ColorToken::TextTertiary)
} else {
theme.color(ColorToken::TextPrimary)
};
let css_id_for_state = button_css_id.clone();
let stateful_key = button_css_id
.as_deref()
.map(|id| format!("radio-{id}"))
.unwrap_or_else(|| format!("radio-{}", option.value));
let has_custom_colors = config.selected_color.is_some()
|| config.border_color.is_some()
|| config.hover_border_color.is_some();
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 mut overrides = RadioStyleOverrides {
selected_color,
border_color: border,
hover_border_color: hover_border,
label_color,
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 matches!(state, ButtonState::Pressed) && !option_disabled {
0.8
} else {
1.0
};
let mut circle = div()
.class("cn-radio")
.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 is_selected {
circle = circle.class("cn-radio--selected");
}
if option_disabled {
circle = circle.class("cn-radio--disabled");
}
if let Some(bg) = overrides.background {
circle = circle.bg(bg);
}
if is_selected {
let inner_dot = div()
.class("cn-radio-dot")
.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_px(theme.spacing_value(SpacingToken::Space4))
.items_center()
.cursor_pointer()
.child(circle)
.child(text(&option_label).size(14.0).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
}
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);
}
}
}
#[derive(Clone)]
#[allow(clippy::type_complexity)]
struct RadioGroupConfig {
selected: State<String>,
options: Vec<RadioOption>,
size: RadioSize,
layout: RadioLayout,
label: Option<String>,
disabled: bool,
gap: Option<f32>,
selected_color: Option<Color>,
border_color: Option<Color>,
hover_border_color: Option<Color>,
on_change: Option<Arc<dyn Fn(&str) + Send + Sync>>,
css_group_id: Option<String>,
classes: Vec<String>,
}
impl RadioGroupConfig {
fn new(selected: State<String>) -> Self {
Self {
selected,
options: Vec::new(),
size: RadioSize::default(),
layout: RadioLayout::default(),
label: None,
disabled: false,
gap: None,
selected_color: None,
border_color: None,
hover_border_color: None,
on_change: None,
css_group_id: None,
classes: Vec::new(),
}
}
}
pub struct RadioGroupBuilder {
config: RadioGroupConfig,
built: std::cell::OnceCell<RadioGroup>,
}
impl RadioGroupBuilder {
pub fn new(selected: &State<String>) -> Self {
Self {
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.config.clone()))
}
pub fn class(mut self, name: impl Into<String>) -> Self {
self.config.classes.push(name.into());
self
}
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 size(mut self, size: RadioSize) -> Self {
self.config.size = size;
self
}
pub fn layout(mut self, layout: RadioLayout) -> Self {
self.config.layout = layout;
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 = Some(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
}
pub fn build_component(self) -> RadioGroup {
RadioGroup::with_config(self.config)
}
}
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) -> ElementTypeId {
self.inner.element_type_id()
}
fn element_classes(&self) -> &[String] {
self.inner.element_classes()
}
}
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) -> ElementTypeId {
self.get_or_build().element_type_id()
}
fn element_classes(&self) -> &[String] {
self.get_or_build().element_classes()
}
}
pub fn radio_group(selected: &State<String>) -> RadioGroupBuilder {
RadioGroupBuilder::new(selected)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_radio_sizes() {
assert_eq!(RadioSize::Small.outer_size(), 14.0);
assert_eq!(RadioSize::Medium.outer_size(), 18.0);
assert_eq!(RadioSize::Large.outer_size(), 22.0);
}
#[test]
fn test_radio_inner_sizes() {
assert_eq!(RadioSize::Small.inner_size(), 6.0);
assert_eq!(RadioSize::Medium.inner_size(), 8.0);
assert_eq!(RadioSize::Large.inner_size(), 10.0);
}
#[test]
fn test_radio_border_widths() {
assert_eq!(RadioSize::Small.border_width(), 1.5);
assert_eq!(RadioSize::Medium.border_width(), 2.0);
assert_eq!(RadioSize::Large.border_width(), 2.0);
}
fn init_theme() {
let _ = ThemeState::try_get().unwrap_or_else(|| {
ThemeState::init_default();
ThemeState::get()
});
}
}