From df35dba24faccf90dc4fe472d2c51e44bf684ecb Mon Sep 17 00:00:00 2001 From: Aleksander Date: Wed, 13 Aug 2025 19:42:48 +0200 Subject: [PATCH] wgui: checkbox component --- uidev/assets/gui/various_widgets.xml | 10 +- uidev/src/testbed/testbed_generic.rs | 15 +- wgui/src/components/button.rs | 38 +-- wgui/src/components/checkbox.rs | 381 ++++++++++++++++++++++++++ wgui/src/components/mod.rs | 1 + wgui/src/components/slider.rs | 7 - wgui/src/parser/component_checkbox.rs | 57 ++++ wgui/src/parser/mod.rs | 29 +- 8 files changed, 503 insertions(+), 35 deletions(-) create mode 100644 wgui/src/components/checkbox.rs create mode 100644 wgui/src/parser/component_checkbox.rs diff --git a/uidev/assets/gui/various_widgets.xml b/uidev/assets/gui/various_widgets.xml index 6b7fb00a..87e92933 100644 --- a/uidev/assets/gui/various_widgets.xml +++ b/uidev/assets/gui/various_widgets.xml @@ -10,14 +10,20 @@
diff --git a/uidev/src/testbed/testbed_generic.rs b/uidev/src/testbed/testbed_generic.rs index 58db9562..44d5dfac 100644 --- a/uidev/src/testbed/testbed_generic.rs +++ b/uidev/src/testbed/testbed_generic.rs @@ -6,6 +6,7 @@ use wgui::{ components::{ Component, button::{ButtonClickCallback, ComponentButton}, + checkbox::ComponentCheckbox, }, event::EventListenerCollection, globals::WguiGlobals, @@ -60,8 +61,7 @@ impl TestbedGeneric { let (layout, state) = wgui::parser::new_layout_from_assets(globals, listeners, XML_PATH, false)?; - let label_cur_option = - state.fetch_widget::(&layout.state, "label_current_option")?; + let label_cur_option = state.fetch_widget(&layout.state, "label_current_option")?; let button_click_me = state.fetch_component_as::("button_click_me")?; let button = button_click_me.clone(); @@ -82,6 +82,17 @@ impl TestbedGeneric { handle_button_click(button_aqua, label_cur_option.clone(), "Clicked aqua"); handle_button_click(button_yellow, label_cur_option.clone(), "Clicked yellow"); + let cb_first = state.fetch_component_as::("cb_first")?; + let label = label_cur_option.clone(); + cb_first.on_toggle(Box::new(move |e| { + let mut widget = label.get_as_mut::(); + widget.set_text( + &mut e.state.globals.i18n(), + Translation::from_raw_text(&format!("checkbox toggle: {}", e.checked)), + ); + Ok(()) + })); + Ok(Self { layout, state }) } } diff --git a/wgui/src/components/button.rs b/wgui/src/components/button.rs index 2ed7797d..a47f8c42 100644 --- a/wgui/src/components/button.rs +++ b/wgui/src/components/button.rs @@ -25,10 +25,6 @@ pub struct Params { pub text_style: TextStyle, } -fn get_color2(color: &drawing::Color) -> drawing::Color { - color.lerp(&Color::new(0.0, 0.0, 0.0, color.a), 0.2) -} - impl Default for Params { fn default() -> Self { Self { @@ -58,9 +54,9 @@ struct Data { initial_color: drawing::Color, initial_color2: drawing::Color, initial_border_color: drawing::Color, - text_id: WidgetID, // Text - rect_id: WidgetID, // Rectangle - text_node: taffy::NodeId, + id_label: WidgetID, // Label + id_rect: WidgetID, // Rectangle + node_label: taffy::NodeId, } pub struct ComponentButton { @@ -83,12 +79,12 @@ impl ComponentButton { state .widgets - .call(self.data.text_id, |label: &mut WidgetLabel| { + .call(self.data.id_label, |label: &mut WidgetLabel| { label.set_text(&mut globals.i18n(), text); }); alterables.mark_redraw(); - alterables.mark_dirty(self.data.text_node); + alterables.mark_dirty(self.data.node_label); } pub fn on_click(&self, func: ButtonClickCallback) { @@ -96,6 +92,10 @@ impl ComponentButton { } } +fn get_color2(color: &drawing::Color) -> drawing::Color { + color.lerp(&Color::new(0.0, 0.0, 0.0, color.a), 0.2) +} + fn anim_hover(rect: &mut WidgetRectangle, data: &Data, pos: f32, pressed: bool) { let brightness = pos * if pressed { 0.75 } else { 0.5 }; let border_brightness = pos; @@ -139,7 +139,7 @@ fn register_event_mouse_enter( ) { listeners.register( listener_handles, - data.rect_id, + data.id_rect, EventListenerKind::MouseEnter, Box::new(move |common, event_data, _, _| { common.alterables.trigger_haptics(); @@ -162,7 +162,7 @@ fn register_event_mouse_leave( ) { listeners.register( listener_handles, - data.rect_id, + data.id_rect, EventListenerKind::MouseLeave, Box::new(move |common, event_data, _, _| { common.alterables.trigger_haptics(); @@ -185,7 +185,7 @@ fn register_event_mouse_press( ) { listeners.register( listener_handles, - data.rect_id, + data.id_rect, EventListenerKind::MousePress, Box::new(move |common, event_data, _, _| { let mut state = state.borrow_mut(); @@ -213,7 +213,7 @@ fn register_event_mouse_release( ) { listeners.register( listener_handles, - data.rect_id, + data.id_rect, EventListenerKind::MouseRelease, Box::new(move |common, event_data, _, _| { let rect = event_data.obj.get_as_mut::(); @@ -256,7 +256,7 @@ pub fn construct( let globals = layout.state.globals.clone(); - let (rect_id, _) = layout.add_child( + let (id_rect, _) = layout.add_child( parent, WidgetRectangle::create(WidgetRectangleParams { color: params.color, @@ -271,8 +271,8 @@ pub fn construct( let light_text = (params.color.r + params.color.g + params.color.b) < 1.5; - let (text_id, text_node) = layout.add_child( - rect_id, + let (id_label, node_label) = layout.add_child( + id_rect, WidgetLabel::create( &mut globals.i18n(), WidgetLabelParams { @@ -294,9 +294,9 @@ pub fn construct( )?; let data = Rc::new(Data { - text_id, - rect_id, - text_node, + id_label, + id_rect, + node_label, initial_color: params.color, initial_color2: get_color2(¶ms.color), initial_border_color: params.border_color, diff --git a/wgui/src/components/checkbox.rs b/wgui/src/components/checkbox.rs new file mode 100644 index 00000000..470e6f16 --- /dev/null +++ b/wgui/src/components/checkbox.rs @@ -0,0 +1,381 @@ +use std::{cell::RefCell, rc::Rc}; +use taffy::{ + AlignItems, JustifyContent, + prelude::{length, percent}, +}; + +use crate::{ + animation::{Animation, AnimationEasing}, + components::{Component, ComponentBase, ComponentTrait, InitData}, + drawing::Color, + event::{EventAlterables, EventListenerCollection, EventListenerKind, ListenerHandleVec}, + i18n::Translation, + layout::{self, Layout, LayoutState, WidgetID}, + renderer_vk::text::{FontWeight, TextStyle}, + widget::{ + label::{WidgetLabel, WidgetLabelParams}, + rectangle::{WidgetRectangle, WidgetRectangleParams}, + util::WLength, + }, +}; + +pub struct Params { + pub text: Translation, + pub style: taffy::Style, + pub box_size: f32, + pub checked: bool, +} + +impl Default for Params { + fn default() -> Self { + Self { + text: Translation::from_raw_text(""), + style: Default::default(), + box_size: 24.0, + checked: false, + } + } +} + +pub struct CheckboxToggleEvent<'a> { + pub state: &'a LayoutState, + pub alterables: &'a mut EventAlterables, + pub checked: bool, +} +pub type CheckboxToggleCallback = Box anyhow::Result<()>>; + +struct State { + checked: bool, + hovered: bool, + down: bool, + on_toggle: Option, +} + +struct Data { + id_container: WidgetID, // Rectangle, transparent if not hovered + + //id_outer_box: WidgetID, // Rectangle, parent of container + id_inner_box: WidgetID, // Rectangle, parent of outer_box + id_label: WidgetID, // Label, parent of container + + node_label: taffy::NodeId, +} + +pub struct ComponentCheckbox { + base: ComponentBase, + data: Rc, + state: Rc>, +} + +impl ComponentTrait for ComponentCheckbox { + fn base(&mut self) -> &mut ComponentBase { + &mut self.base + } + + fn init(&self, _data: &mut InitData) {} +} + +const COLOR_CHECKED: Color = Color::new(0.1, 0.5, 1.0, 1.0); +const COLOR_UNCHECKED: Color = Color::new(0.1, 0.5, 1.0, 0.0); + +fn set_box_checked(widgets: &layout::WidgetMap, data: &Data, checked: bool) { + widgets.call(data.id_inner_box, |rect: &mut WidgetRectangle| { + rect.params.color = if checked { + COLOR_CHECKED + } else { + COLOR_UNCHECKED + } + }); +} + +impl ComponentCheckbox { + pub fn set_text(&self, state: &LayoutState, alterables: &mut EventAlterables, text: Translation) { + let globals = state.globals.clone(); + + state + .widgets + .call(self.data.id_label, |label: &mut WidgetLabel| { + label.set_text(&mut globals.i18n(), text); + }); + + alterables.mark_redraw(); + alterables.mark_dirty(self.data.node_label); + } + + pub fn set_checked(&self, state: &LayoutState, alterables: &mut EventAlterables, checked: bool) { + self.state.borrow_mut().checked = checked; + set_box_checked(&state.widgets, &self.data, checked); + alterables.mark_redraw(); + } + + pub fn on_toggle(&self, func: CheckboxToggleCallback) { + self.state.borrow_mut().on_toggle = Some(func); + } +} + +fn anim_hover(rect: &mut WidgetRectangle, pos: f32, pressed: bool) { + let brightness = pos * if pressed { 0.6 } else { 0.4 }; + rect.params.border = 2.0; + rect.params.color.a = brightness; + rect.params.border_color.a = rect.params.color.a; + if pressed { + rect.params.border_color.a += 0.4; + } +} + +fn anim_hover_in(state: Rc>, widget_id: WidgetID) -> Animation { + Animation::new( + widget_id, + 5, + AnimationEasing::OutQuad, + Box::new(move |common, anim_data| { + let rect = anim_data.obj.get_as_mut::(); + anim_hover(rect, anim_data.pos, state.borrow().down); + common.alterables.mark_redraw(); + }), + ) +} + +fn anim_hover_out(state: Rc>, widget_id: WidgetID) -> Animation { + Animation::new( + widget_id, + 8, + AnimationEasing::OutQuad, + Box::new(move |common, anim_data| { + let rect = anim_data.obj.get_as_mut::(); + anim_hover(rect, 1.0 - anim_data.pos, state.borrow().down); + common.alterables.mark_redraw(); + }), + ) +} + +fn register_event_mouse_enter( + data: Rc, + state: Rc>, + listeners: &mut EventListenerCollection, + listener_handles: &mut ListenerHandleVec, +) { + listeners.register( + listener_handles, + data.id_container, + EventListenerKind::MouseEnter, + Box::new(move |common, event_data, _, _| { + common.alterables.trigger_haptics(); + common + .alterables + .animate(anim_hover_in(state.clone(), event_data.widget_id)); + state.borrow_mut().hovered = true; + Ok(()) + }), + ); +} + +fn register_event_mouse_leave( + data: Rc, + state: Rc>, + listeners: &mut EventListenerCollection, + listener_handles: &mut ListenerHandleVec, +) { + listeners.register( + listener_handles, + data.id_container, + EventListenerKind::MouseLeave, + Box::new(move |common, event_data, _, _| { + common.alterables.trigger_haptics(); + common + .alterables + .animate(anim_hover_out(state.clone(), event_data.widget_id)); + state.borrow_mut().hovered = false; + Ok(()) + }), + ); +} + +fn register_event_mouse_press( + data: Rc, + state: Rc>, + listeners: &mut EventListenerCollection, + listener_handles: &mut ListenerHandleVec, +) { + listeners.register( + listener_handles, + data.id_container, + EventListenerKind::MousePress, + Box::new(move |common, event_data, _, _| { + let mut state = state.borrow_mut(); + + let rect = event_data.obj.get_as_mut::(); + anim_hover(rect, 1.0, true); + + if state.hovered { + state.down = true; + } + + common.alterables.trigger_haptics(); + common.alterables.mark_redraw(); + + Ok(()) + }), + ); +} + +fn register_event_mouse_release( + data: Rc, + state: Rc>, + listeners: &mut EventListenerCollection, + listener_handles: &mut ListenerHandleVec, +) { + listeners.register( + listener_handles, + data.id_container, + EventListenerKind::MouseRelease, + Box::new(move |common, event_data, _, _| { + let rect = event_data.obj.get_as_mut::(); + anim_hover(rect, 1.0, false); + + let mut state = state.borrow_mut(); + if state.down { + state.down = false; + + state.checked = !state.checked; + set_box_checked(&common.state.widgets, &data, state.checked); + + if state.hovered { + if let Some(on_toggle) = &state.on_toggle { + on_toggle(CheckboxToggleEvent { + state: common.state, + alterables: common.alterables, + checked: state.checked, + })?; + } + } + } + + common.alterables.trigger_haptics(); + common.alterables.mark_redraw(); + + Ok(()) + }), + ); +} + +pub fn construct( + layout: &mut Layout, + listeners: &mut EventListenerCollection, + parent: WidgetID, + params: Params, +) -> anyhow::Result> { + let mut style = params.style; + + // force-override style + style.flex_wrap = taffy::FlexWrap::NoWrap; + style.align_items = Some(AlignItems::Center); + style.justify_content = Some(JustifyContent::Center); + style.padding = taffy::Rect { + left: length(4.0), + right: length(8.0), + top: length(4.0), + bottom: length(4.0), + }; + //style.align_self = Some(taffy::AlignSelf::Start); // do not stretch self to the parent + style.gap = length(4.0); + + let globals = layout.state.globals.clone(); + + let (id_container, _) = layout.add_child( + parent, + 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), + ..Default::default() + })?, + style, + )?; + + let box_size = taffy::Size { + width: length(params.box_size), + height: length(params.box_size), + }; + + let (id_outer_box, _) = layout.add_child( + id_container, + WidgetRectangle::create(WidgetRectangleParams { + border: 2.0, + border_color: Color::new(1.0, 1.0, 1.0, 1.0), + round: WLength::Units(8.0), + color: Color::new(1.0, 1.0, 1.0, 0.0), + ..Default::default() + })?, + taffy::Style { + size: box_size, + padding: taffy::Rect::length(4.0), + min_size: box_size, + max_size: box_size, + ..Default::default() + }, + )?; + + let (id_inner_box, _) = layout.add_child( + id_outer_box, + WidgetRectangle::create(WidgetRectangleParams { + round: WLength::Units(5.0), + color: if params.checked { + COLOR_CHECKED + } else { + COLOR_UNCHECKED + }, + ..Default::default() + })?, + taffy::Style { + size: taffy::Size { + width: percent(1.0), + height: percent(1.0), + }, + ..Default::default() + }, + )?; + + let (id_label, node_label) = layout.add_child( + id_container, + WidgetLabel::create( + &mut globals.i18n(), + WidgetLabelParams { + content: params.text, + style: TextStyle { + weight: Some(FontWeight::Bold), + ..Default::default() + }, + }, + )?, + taffy::Style { + ..Default::default() + }, + )?; + + let data = Rc::new(Data { + node_label, + id_container, + id_label, + id_inner_box, + }); + + let state = Rc::new(RefCell::new(State { + checked: params.checked, + down: false, + hovered: false, + on_toggle: None, + })); + + let mut base = ComponentBase::default(); + + register_event_mouse_enter(data.clone(), state.clone(), listeners, &mut base.lhandles); + register_event_mouse_leave(data.clone(), state.clone(), listeners, &mut base.lhandles); + register_event_mouse_press(data.clone(), state.clone(), listeners, &mut base.lhandles); + register_event_mouse_release(data.clone(), state.clone(), listeners, &mut base.lhandles); + + let checkbox = Rc::new(ComponentCheckbox { base, data, state }); + + layout.defer_component_init(Component(checkbox.clone())); + Ok(checkbox) +} diff --git a/wgui/src/components/mod.rs b/wgui/src/components/mod.rs index aa2a3696..ddf798ce 100644 --- a/wgui/src/components/mod.rs +++ b/wgui/src/components/mod.rs @@ -7,6 +7,7 @@ use crate::{ }; pub mod button; +pub mod checkbox; pub mod slider; pub struct InitData<'a> { diff --git a/wgui/src/components/slider.rs b/wgui/src/components/slider.rs index 8ba47856..15d97d14 100644 --- a/wgui/src/components/slider.rs +++ b/wgui/src/components/slider.rs @@ -359,13 +359,6 @@ pub fn construct( position: taffy::Position::Absolute, align_items: Some(taffy::AlignItems::Center), justify_content: Some(taffy::JustifyContent::Center), - margin: taffy::Rect { - // FIXME: temporary just for testing - left: percent(0.5), - bottom: length(0.0), - right: length(0.0), - top: length(0.0), - }, ..Default::default() }; diff --git a/wgui/src/parser/component_checkbox.rs b/wgui/src/parser/component_checkbox.rs new file mode 100644 index 00000000..48a76606 --- /dev/null +++ b/wgui/src/parser/component_checkbox.rs @@ -0,0 +1,57 @@ +use crate::{ + components::{Component, checkbox}, + i18n::Translation, + layout::WidgetID, + parser::{ + ParserContext, ParserFile, iter_attribs, parse_check_f32, parse_check_i32, process_component, + style::parse_style, + }, +}; + +pub fn parse_component_checkbox<'a, U1, U2>( + file: &'a ParserFile, + ctx: &mut ParserContext, + node: roxmltree::Node<'a, 'a>, + parent_id: WidgetID, +) -> anyhow::Result<()> { + let mut box_size = 24.0; + let mut translation = Translation::default(); + let mut checked = 0; + + let attribs: Vec<_> = iter_attribs(file, ctx, &node, false).collect(); + let style = parse_style(&attribs); + + for (key, value) in attribs { + match key.as_ref() { + "text" => { + translation = Translation::from_raw_text(&value); + } + "translation" => { + translation = Translation::from_translation_key(&value); + } + "box_size" => { + parse_check_f32(value.as_ref(), &mut box_size); + } + "checked" => { + parse_check_i32(value.as_ref(), &mut checked); + } + _ => {} + } + } + + let component = checkbox::construct( + ctx.layout, + ctx.listeners, + parent_id, + checkbox::Params { + box_size, + text: translation, + checked: checked != 0, + style, + }, + )?; + + process_component(file, ctx, node, Component(component))?; + + Ok(()) +} diff --git a/wgui/src/parser/mod.rs b/wgui/src/parser/mod.rs index d0575ce7..4e907b42 100644 --- a/wgui/src/parser/mod.rs +++ b/wgui/src/parser/mod.rs @@ -1,4 +1,5 @@ mod component_button; +mod component_checkbox; mod component_slider; mod style; mod widget_div; @@ -14,9 +15,10 @@ use crate::{ globals::WguiGlobals, layout::{Layout, LayoutState, Widget, WidgetID}, parser::{ - component_button::parse_component_button, component_slider::parse_component_slider, - widget_div::parse_widget_div, 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, + component_slider::parse_component_slider, widget_div::parse_widget_div, + widget_label::parse_widget_label, widget_rectangle::parse_widget_rectangle, + widget_sprite::parse_widget_sprite, }, }; use ouroboros::self_referencing; @@ -92,12 +94,12 @@ impl ParserState { } } - pub fn fetch_widget(&self, state: &LayoutState, id: &str) -> anyhow::Result { + pub fn fetch_widget(&self, state: &LayoutState, id: &str) -> anyhow::Result { let widget_id = self.get_widget_id(id)?; let widget = state .widgets .get(widget_id) - .ok_or_else(|| anyhow::anyhow!("fetch_widget_as({}): widget not found", id))?; + .ok_or_else(|| anyhow::anyhow!("fetch_widget({}): widget not found", id))?; Ok(widget.clone()) } @@ -253,10 +255,24 @@ fn parse_percent(value: &str) -> Option { Some(val / 100.0) } +fn parse_i32(value: &str) -> Option { + value.parse::().ok() +} + fn parse_f32(value: &str) -> Option { value.parse::().ok() } +fn parse_check_i32(value: &str, num: &mut i32) -> bool { + if let Some(value) = parse_i32(value) { + *num = value; + true + } else { + print_invalid_value(value); + false + } +} + fn parse_check_f32(value: &str, num: &mut f32) -> bool { if let Some(value) = parse_f32(value) { *num = value; @@ -660,6 +676,9 @@ fn parse_children<'a, U1, U2>( "slider" => { parse_component_slider(file, ctx, child_node, parent_id)?; } + "check_box" => { + parse_component_checkbox(file, ctx, child_node, parent_id)?; + } "" => { /* ignore */ } other_tag_name => { parse_widget_other(other_tag_name, file, ctx, child_node, parent_id)?;