//! The counter demo: reactive state driving a widget, with hover and //! pressed visual feedback — the milestone app for signals, events, and //! dirty tracking. use guiduck_core::signals::Signal; use guiduck_core::{Container, EventKind, Text, WidgetId, WidgetTree, taffy}; use guiduck_scene::paint::Brush; use guiduck_scene::paint::color::palette::css; use taffy::prelude::{length, percent}; use crate::{SAMPLE_FONT_FAMILY, sample_text_context}; fn button_brush(hovered: bool, pressed: bool) -> Brush { if pressed { css::DARK_SLATE_BLUE.into() } else if hovered { css::CORNFLOWER_BLUE.into() } else { css::STEEL_BLUE.into() } } /// Build the counter UI. Returns the tree, the count signal, and the button /// widget id (tests use the id to locate the click target). pub fn counter_tree() -> (WidgetTree, Signal, WidgetId) { let mut tree = WidgetTree::with_text_context(sample_text_context()); let root = tree.insert( Container::new().background(css::WHITE), taffy::Style { size: taffy::Size { width: percent(1.0_f32), height: percent(1.0_f32), }, flex_direction: taffy::FlexDirection::Column, padding: taffy::Rect::length(16.0_f32), gap: taffy::Size { width: length(16.0_f32), height: length(16.0_f32), }, align_items: Some(taffy::AlignItems::FLEX_START), ..Default::default() }, None, ); let label = tree.insert( Text::new("Count: ?") .font_size(18.0) .family(SAMPLE_FONT_FAMILY) .brush(css::BLACK), taffy::Style::default(), Some(root), ); let button = tree.insert( Container::new() .background(button_brush(false, false)) .corner_radius(8.0), taffy::Style { size: taffy::Size { width: length(120.0_f32), height: length(40.0_f32), }, justify_content: Some(taffy::JustifyContent::CENTER), align_items: Some(taffy::AlignItems::CENTER), ..Default::default() }, Some(root), ); tree.insert( Text::new("+1") .font_size(16.0) .family(SAMPLE_FONT_FAMILY) .brush(css::WHITE), taffy::Style::default(), Some(button), ); // Reactive state: the label is bound to the count. let count = Signal::new(0); tree.bind::( label, move || format!("Count: {}", count.get()), |text, value| text.set_text(value), ); // Interaction state drives the button's fill. Hover and pressed are // signals too, so the visual state flows through the same // signal-to-binding path as the label. (The style engine replaces this // with declarative state rules.) let hovered = Signal::new(false); let pressed = Signal::new(false); tree.bind::( button, move || button_brush(hovered.get(), pressed.get()), |container, brush| container.set_background(brush), ); tree.on_event(button, EventKind::PointerEnter, move |_ctx, _ev| { hovered.set(true); }); tree.on_event(button, EventKind::PointerLeave, move |_ctx, _ev| { hovered.set(false); pressed.set(false); }); tree.on_event(button, EventKind::PointerDown, move |_ctx, _ev| { pressed.set(true); }); tree.on_event(button, EventKind::PointerUp, move |_ctx, _ev| { pressed.set(false); }); tree.on_event(button, EventKind::Click, move |_ctx, _ev| { count.set(count.get_untracked() + 1); }); (tree, count, button) } #[cfg(test)] mod tests { use guiduck_core::{NoClipboard, PointerButton, PointerInput}; use guiduck_scene::geom::{Point, Size}; use super::*; const VIEWPORT: Size = Size::new(400.0, 300.0); /// The click target used by the interaction script; keep in sync with /// scripts/m2-interaction.sh. const BUTTON_POINT: Point = Point::new(76.0, 80.0); #[test] fn scripted_click_point_hits_the_button() { let (mut tree, _count, button) = counter_tree(); tree.render_frame(VIEWPORT); assert_eq!( tree.hit_test(BUTTON_POINT) .map(|id| tree.parent(id).unwrap_or(id) == button || id == button), Some(true), "script click point must land on the button (or its label)" ); } #[test] fn clicks_increment_and_render_the_count() { let (mut tree, count, _button) = counter_tree(); tree.render_frame(VIEWPORT); for _ in 0..3 { tree.dispatch_pointer( PointerInput::Down { time: std::time::Instant::now(), pos: BUTTON_POINT, button: PointerButton::Left, }, &mut NoClipboard, ); tree.dispatch_pointer( PointerInput::Up { pos: BUTTON_POINT, button: PointerButton::Left, }, &mut NoClipboard, ); } assert!(tree.needs_frame()); tree.render_frame(VIEWPORT); assert_eq!(count.get_untracked(), 3); assert!(!tree.needs_frame(), "quiescent after rendering the clicks"); } }