counter.rs
raw
//! 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<i32>, 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::<Text, _>(
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::<Container, _>(
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");
}
}