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");
    }
}