diff --git a/wgui/doc/widgets.md b/wgui/doc/widgets.md index ae8b2c1..1492b16 100644 --- a/wgui/doc/widgets.md +++ b/wgui/doc/widgets.md @@ -322,10 +322,48 @@ _Translated by key_ `box_size`: **float** (default: 24) +`value`: **string** + +_optional value that will be sent with internal events_ + `checked`: **int** (default: 0) --- +## Radiobox component + +### `` + +### A radio-box with label. + +#### Parameters + +`text`: **string** + +_Simple text_ + +`translation`: **string** + +_Translated by key_ + +`box_size`: **float** (default: 24) + +`value`: **string** + +_optional value that will be set as the RadioGroup's value_ + +`checked`: **int** (default: 0) + +--- + +## RadioGroup component + +### `` + +### A radio group. Place `` components inside this. + +--- + # Examples ## Simple layout diff --git a/wgui/src/components/checkbox.rs b/wgui/src/components/checkbox.rs index 71195c9..d5b5ba9 100644 --- a/wgui/src/components/checkbox.rs +++ b/wgui/src/components/checkbox.rs @@ -1,4 +1,4 @@ -use std::{cell::RefCell, rc::Rc}; +use std::{cell::RefCell, rc::{Rc, Weak}}; use taffy::{ prelude::{length, percent}, AlignItems, @@ -6,7 +6,7 @@ use taffy::{ use crate::{ animation::{Animation, AnimationEasing}, - components::{Component, ComponentBase, ComponentTrait, RefreshData}, + components::{radio_group::ComponentRadioGroup, Component, ComponentBase, ComponentTrait, RefreshData}, drawing::Color, event::{CallbackDataCommon, EventListenerCollection, EventListenerID, EventListenerKind}, i18n::Translation, @@ -25,6 +25,8 @@ pub struct Params { pub style: taffy::Style, pub box_size: f32, pub checked: bool, + pub radio_group: Option>, + pub value: Option>, } impl Default for Params { @@ -34,12 +36,15 @@ impl Default for Params { style: Default::default(), box_size: 24.0, checked: false, + radio_group: None, + value: None, } } } pub struct CheckboxToggleEvent { pub checked: bool, + pub value: Option>, } pub type CheckboxToggleCallback = Box anyhow::Result<()>>; @@ -49,6 +54,7 @@ struct State { hovered: bool, down: bool, on_toggle: Option, + self_ref: Weak, } #[allow(clippy::struct_field_names)] @@ -59,6 +65,8 @@ struct Data { //id_outer_box: WidgetID, // Rectangle, parent of container id_inner_box: WidgetID, // Rectangle, parent of outer_box id_label: WidgetID, // Label, parent of container + value: Option>, // arbitrary value assigned to the element + radio_group: Option>, } pub struct ComponentCheckbox { @@ -100,11 +108,27 @@ impl ComponentCheckbox { } pub fn set_checked(&self, common: &mut CallbackDataCommon, checked: bool) { - self.state.borrow_mut().checked = checked; + { + let mut state = self.state.borrow_mut(); + if state.checked == checked { + return; + } + state.checked = checked; + } set_box_checked(&common.state.widgets, &self.data, checked); common.alterables.mark_redraw(); } + pub fn get_value(&self) -> Option> { + self.data.value.clone() + } + + /// Set checked state without triggering visual changes. + pub(super) fn set_checked_internal(&self, checked: bool) { + self.state.borrow_mut().checked = checked; + } + + pub fn on_toggle(&self, func: CheckboxToggleCallback) { self.state.borrow_mut().on_toggle = Some(func); } @@ -222,13 +246,19 @@ fn register_event_mouse_release( if state.down { state.down = false; - state.checked = !state.checked; + if let Some(self_ref) = state.self_ref.upgrade() && let Some(radio) = data.radio_group.as_ref().and_then(|r| r.upgrade()) { + radio.set_selected_internal(common, &self_ref)?; + state.checked = true; // can't uncheck radiobox by clicking the checked box again + } else { + state.checked = !state.checked; + } + set_box_checked(&common.state.widgets, &data, state.checked); if state.hovered && let Some(on_toggle) = &state.on_toggle { - on_toggle(common, CheckboxToggleEvent { checked: state.checked })?; + on_toggle(common, CheckboxToggleEvent { checked: state.checked, value: data.value.clone() })?; } Ok(EventResult::Consumed) } else { @@ -262,6 +292,12 @@ pub fn construct(ess: &mut ConstructEssentials, params: Params) -> anyhow::Resul }; //style.align_self = Some(taffy::AlignSelf::Start); // do not stretch self to the parent style.gap = length(4.0); + + let (round_5, round_8) = if params.radio_group.is_some() { + (WLength::Percent(1.0), WLength::Percent(1.0)) + } else { + (WLength::Units(5.0), WLength::Units(8.0)) + }; let globals = ess.layout.state.globals.clone(); @@ -270,7 +306,7 @@ pub fn construct(ess: &mut ConstructEssentials, params: Params) -> anyhow::Resul WidgetRectangle::create(WidgetRectangleParams { color: Color::new(1.0, 1.0, 1.0, 0.0), border_color: Color::new(1.0, 1.0, 1.0, 0.0), - round: WLength::Units(5.0), + round: round_5, ..Default::default() }), style, @@ -288,7 +324,7 @@ pub fn construct(ess: &mut ConstructEssentials, params: Params) -> anyhow::Resul WidgetRectangle::create(WidgetRectangleParams { border: 2.0, border_color: Color::new(1.0, 1.0, 1.0, 1.0), - round: WLength::Units(8.0), + round: round_8, color: Color::new(1.0, 1.0, 1.0, 0.0), ..Default::default() }), @@ -304,7 +340,7 @@ pub fn construct(ess: &mut ConstructEssentials, params: Params) -> anyhow::Resul let (inner_box, _) = ess.layout.add_child( outer_box.id, WidgetRectangle::create(WidgetRectangleParams { - round: WLength::Units(5.0), + round: round_5, color: if params.checked { COLOR_CHECKED } else { COLOR_UNCHECKED }, ..Default::default() }), @@ -336,6 +372,8 @@ pub fn construct(ess: &mut ConstructEssentials, params: Params) -> anyhow::Resul id_container, id_inner_box: inner_box.id, id_label: label.id, + value: params.value, + radio_group: params.radio_group.as_ref().map(|x| Rc::downgrade(x)), }); let state = Rc::new(RefCell::new(State { @@ -343,6 +381,7 @@ pub fn construct(ess: &mut ConstructEssentials, params: Params) -> anyhow::Resul down: false, hovered: false, on_toggle: None, + self_ref: Weak::new(), })); let base = ComponentBase { @@ -361,6 +400,11 @@ pub fn construct(ess: &mut ConstructEssentials, params: Params) -> anyhow::Resul let checkbox = Rc::new(ComponentCheckbox { base, data, state }); + if let Some(radio) = params.radio_group.as_ref() { + radio.register_child(checkbox.clone(), params.checked); + checkbox.state.borrow_mut().self_ref = Rc::downgrade(&checkbox); + } + ess.layout.defer_component_refresh(Component(checkbox.clone())); Ok((root, checkbox)) } diff --git a/wgui/src/components/mod.rs b/wgui/src/components/mod.rs index 3dbe884..16ea0d4 100644 --- a/wgui/src/components/mod.rs +++ b/wgui/src/components/mod.rs @@ -9,6 +9,7 @@ use crate::{ pub mod button; pub mod checkbox; +pub mod radio_group; pub mod slider; pub mod tooltip; diff --git a/wgui/src/components/radio_group.rs b/wgui/src/components/radio_group.rs new file mode 100644 index 0000000..0dbb05a --- /dev/null +++ b/wgui/src/components/radio_group.rs @@ -0,0 +1,138 @@ +use std::{cell::RefCell, rc::Rc}; + +use taffy::Style; + +use crate::{ + components::{checkbox::ComponentCheckbox, Component, ComponentBase, ComponentTrait, RefreshData}, + event::CallbackDataCommon, + layout::WidgetPair, + widget::{div::WidgetDiv, ConstructEssentials}, +}; + +pub struct RadioValueChangeEvent { + pub value: Option>, +} + +pub type RadioValueChangeCallback = Box anyhow::Result<()>>; + +#[derive(Default)] +struct State { + radio_boxes: Vec>, + selected: Option>, + on_value_changed: Option, +} + +pub struct ComponentRadioGroup { + base: ComponentBase, + state: Rc>, +} + +impl ComponentRadioGroup { + pub(super) fn register_child(&self, child: Rc, checked: bool) { + let mut state = self.state.borrow_mut(); + if checked { + state.selected = Some(child.clone()); + for radio_box in &state.radio_boxes { + radio_box.set_checked_internal(false); + } + } + state.radio_boxes.push(child); + } + + // This doesn't `set_checked` on `selected` in order to avoid double borrow. + pub(super) fn set_selected_internal( + &self, + common: &mut CallbackDataCommon, + selected: &Rc, + ) -> anyhow::Result<()> { + let mut state = self.state.borrow_mut(); + if state.selected.as_ref().is_some_and(|b| Rc::ptr_eq(b, selected)) { + return Ok(()); + } + + let mut selected_found = false; + for radio_box in &state.radio_boxes { + if Rc::ptr_eq(radio_box, selected) { + selected_found = true; + } else { + radio_box.set_checked(common, false); + } + } + if !selected_found { + anyhow::bail!("RadioGroup set_active called with a non-child ComponentCheckbox!"); + } + state.selected = Some(selected.clone()); + + if let Some(on_value_changed) = state.on_value_changed.as_ref() { + on_value_changed( + common, + RadioValueChangeEvent { + value: selected.get_value(), + }, + )?; + } + + Ok(()) + } + + pub fn set_selected(&self, common: &mut CallbackDataCommon, selected: &Rc) -> anyhow::Result<()> { + self.set_selected_internal(common, selected)?; + if let Some(selected) = self.state.borrow().selected.as_ref() { + selected.set_checked(common, true); + } + Ok(()) + } + + #[must_use] + pub fn get_value(&self) -> Option> { + self.state.borrow().selected.as_ref().and_then(|b| b.get_value()) + } + + pub fn set_value(&self, value: &str) -> anyhow::Result<()> { + let mut state = self.state.borrow_mut(); + for radio_box in &state.radio_boxes { + if radio_box.get_value().is_some_and(|box_val| &*box_val == value) { + state.selected = Some(radio_box.clone()); + return Ok(()); + } + } + anyhow::bail!("No RadioBox found with value '{value}'") + } + + pub fn on_value_changed(&self, callback: RadioValueChangeCallback) { + self.state.borrow_mut().on_value_changed = Some(callback); + } +} + +impl ComponentTrait for ComponentRadioGroup { + fn base(&self) -> &ComponentBase { + &self.base + } + + fn base_mut(&mut self) -> &mut ComponentBase { + &mut self.base + } + + fn refresh(&self, _data: &mut RefreshData) { + // nothing to do + } +} + +pub fn construct( + ess: &mut ConstructEssentials, + style: taffy::Style, +) -> anyhow::Result<(WidgetPair, Rc)> { + let (root, _) = ess.layout.add_child(ess.parent, WidgetDiv::create(), style)?; + + let base = ComponentBase { + id: root.id, + ..Default::default() + }; + + let state = Rc::new(RefCell::new(State::default())); + + let checkbox = Rc::new(ComponentRadioGroup { base, state }); + + ess.layout.defer_component_refresh(Component(checkbox.clone())); + Ok((root, checkbox)) +} diff --git a/wgui/src/layout.rs b/wgui/src/layout.rs index 1fe1dd5..6e01962 100644 --- a/wgui/src/layout.rs +++ b/wgui/src/layout.rs @@ -265,6 +265,18 @@ impl Layout { ) } + pub fn get_parent(&self, widget_id: WidgetID) -> Option<(WidgetID, taffy::NodeId)> { + let Some(node_id) = self.state.nodes.get(widget_id) else { + return None; + }; + + self.state.tree.parent(*node_id).map(|parent_id| { + let parent_widget_id = self.state.tree.get_node_context(parent_id).unwrap(); + (*parent_widget_id, parent_id) + } + ) + } + fn collect_children_ids_recursive(&self, widget_id: WidgetID, out: &mut Vec<(WidgetID, taffy::NodeId)>) { let Some(node_id) = self.state.nodes.get(widget_id) else { return; diff --git a/wgui/src/parser/component_checkbox.rs b/wgui/src/parser/component_checkbox.rs index 166ea96..60824ac 100644 --- a/wgui/src/parser/component_checkbox.rs +++ b/wgui/src/parser/component_checkbox.rs @@ -1,18 +1,27 @@ use crate::{ - components::{Component, checkbox}, + components::{checkbox, radio_group::ComponentRadioGroup, Component}, i18n::Translation, layout::WidgetID, - parser::{AttribPair, ParserContext, parse_check_f32, parse_check_i32, process_component, style::parse_style}, + parser::{ + parse_check_f32, parse_check_i32, process_component, style::parse_style, AttribPair, Fetchable, ParserContext, + }, }; +pub enum CheckboxKind { + CheckBox, + RadioBox, +} + pub fn parse_component_checkbox( ctx: &mut ParserContext, parent_id: WidgetID, attribs: &[AttribPair], + kind: CheckboxKind, ) -> anyhow::Result { let mut box_size = 24.0; let mut translation = Translation::default(); let mut checked = 0; + let mut component_value = None; let style = parse_style(attribs); @@ -25,6 +34,9 @@ pub fn parse_component_checkbox( "translation" => { translation = Translation::from_translation_key(value); } + "value" => { + component_value = Some(value.into()); + } "box_size" => { parse_check_f32(value, &mut box_size); } @@ -35,6 +47,28 @@ pub fn parse_component_checkbox( } } + let mut radio_group = None; + + if matches!(kind, CheckboxKind::RadioBox) { + let mut maybe_parent_id = Some(parent_id); + + while let Some(parent_id) = maybe_parent_id { + if let Ok(radio) = ctx + .data_local + .fetch_component_from_widget_id_as::(parent_id) + { + radio_group = Some(radio); + break; + } + + maybe_parent_id = ctx.layout.get_parent(parent_id).map(|(widget_id, _)| widget_id); + } + + if radio_group.is_none() { + log::error!("RadioBox component without a Radio group!"); + } + } + let (widget, component) = checkbox::construct( &mut ctx.get_construct_essentials(parent_id), checkbox::Params { @@ -42,6 +76,8 @@ pub fn parse_component_checkbox( text: translation, checked: checked != 0, style, + radio_group, + value: component_value, }, )?; diff --git a/wgui/src/parser/component_radio_group.rs b/wgui/src/parser/component_radio_group.rs new file mode 100644 index 0000000..24ad7dd --- /dev/null +++ b/wgui/src/parser/component_radio_group.rs @@ -0,0 +1,22 @@ +use crate::{ + components::{radio_group, Component}, + layout::WidgetID, + parser::{parse_children, process_component, style::parse_style, AttribPair, ParserContext, ParserFile}, +}; + +pub fn parse_component_radio_group<'a>( + file: &'a ParserFile, + ctx: &mut ParserContext, + node: roxmltree::Node<'a, 'a>, + parent_id: WidgetID, + attribs: &[AttribPair], +) -> anyhow::Result { + let style = parse_style(attribs); + + let (widget, component) = radio_group::construct(&mut ctx.get_construct_essentials(parent_id), style)?; + + process_component(ctx, Component(component), widget.id, attribs); + parse_children(file, ctx, node, widget.id)?; + + Ok(widget.id) +} diff --git a/wgui/src/parser/mod.rs b/wgui/src/parser/mod.rs index 0c5a501..af81326 100644 --- a/wgui/src/parser/mod.rs +++ b/wgui/src/parser/mod.rs @@ -1,5 +1,6 @@ mod component_button; mod component_checkbox; +mod component_radio_group; mod component_slider; mod style; mod widget_div; @@ -9,15 +10,13 @@ mod widget_rectangle; mod widget_sprite; use crate::{ - assets::{AssetPath, AssetPathOwned, normalize_path}, + assets::{normalize_path, AssetPath, AssetPathOwned}, components::{Component, ComponentWeak}, drawing::{self}, globals::WguiGlobals, layout::{Layout, LayoutParams, LayoutState, Widget, WidgetID, WidgetMap, WidgetPair}, parser::{ - component_button::parse_component_button, component_checkbox::parse_component_checkbox, - component_slider::parse_component_slider, widget_div::parse_widget_div, widget_image::parse_widget_image, - widget_label::parse_widget_label, widget_rectangle::parse_widget_rectangle, widget_sprite::parse_widget_sprite, + component_button::parse_component_button, component_checkbox::{parse_component_checkbox, CheckboxKind}, component_radio_group::parse_component_radio_group, component_slider::parse_component_slider, widget_div::parse_widget_div, widget_image::parse_widget_image, widget_label::parse_widget_label, widget_rectangle::parse_widget_rectangle, widget_sprite::parse_widget_sprite }, widget::ConstructEssentials, }; @@ -909,7 +908,13 @@ fn parse_child<'a>( new_widget_id = Some(parse_component_slider(ctx, parent_id, &attribs)?); } "CheckBox" => { - new_widget_id = Some(parse_component_checkbox(ctx, parent_id, &attribs)?); + new_widget_id = Some(parse_component_checkbox(ctx, parent_id, &attribs, CheckboxKind::CheckBox)?); + } + "RadioBox" => { + new_widget_id = Some(parse_component_checkbox(ctx, parent_id, &attribs, CheckboxKind::RadioBox)?); + } + "RadioGroup" => { + new_widget_id = Some(parse_component_radio_group(file, ctx, child_node, parent_id, &attribs)?); } "" => { /* ignore */ } other_tag_name => {