//! The counter: the smallest app that is the whole pattern, and the one
//! example every binding has in the same shape (`docs/adr/0021`, decision
//! 3). `view` rebuilds the tree from the model; a click arrives in
//! `on_event` as data and moves the model; nothing else happens.
//!
//! What every counter holds, so the four stay one shape: the count with
//! `+1`, `-1` and `reset`; a right-click that declares a `modal` menu on
//! the next frame — the core opens nothing, it reports the press and the
//! view puts a float there — with `+10` and `reset` in it; and a headless
//! drive that clicks all of it and exits non-zero on a wrong answer. The
//! messages are a `#[derive(Message)]` enum, the spelling the book's
//! step 3 teaches (`docs/adr/0039`, decision 6); the other bindings
//! build the same `{kind}` maps by hand.
//!
//! Run: cargo run -p kui-native --example counter [-- --headless]
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{Align, App, Core, FloatConfig, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Inc,
Dec,
Reset,
Add10,
/// The tag on the root's `on_context_menu` and on the menu's `modal`:
/// the press that asks for the menu, and the `dismiss` that ends it,
/// both arrive under it.
Menu,
}
#[derive(Default)]
struct Counter {
count: i64,
/// Where the menu is, in viewport px, while it is open.
menu: Option<(f32, f32)>,
}
impl App for Counter {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
// The whole example answers a secondary press. Any node can: the
// topmost one that declared `on_context_menu` is the one asked, so
// a row inside could offer its own menu and win over this one.
ui.with(
NodeSpec::column()
.fill()
.center()
.gap(24.0)
.on_context_menu(Msg::Menu),
|ui| {
ui.with(
NodeSpec::column()
.pad(32.0)
.gap(20.0)
.bg(t.surface)
.radius(12.0)
.border(1.0, t.border)
.cross_align(Align::Center)
.width(320.0),
|ui| {
ui.text("kui counter", TextStyle::new(14.0).color(t.muted));
ui.text(&self.count.to_string(), TextStyle::new(56.0));
ui.with(NodeSpec::row().gap(12.0), |ui| {
widgets::button(ui, "-1", Msg::Dec);
widgets::button(ui, "+1", Msg::Inc);
widgets::button(ui, "reset", Msg::Reset);
});
},
);
ui.text(
"right-click for a menu · clicks are data: view() never sees a callback",
TextStyle::new(12.0).color(t.faint),
);
},
);
// Declared last: floats stack in declaration order. One `modal`
// row is the whole difference between this and a plain float —
// Tab is scoped to it, the pointer cannot reach what is behind
// it, and Escape or a press outside comes back as `dismiss`.
if let Some((x, y)) = self.menu {
ui.with_keyed(
"menu",
NodeSpec::column()
.float(
FloatConfig::viewport()
.inside(Align::Start, Align::Start)
.offset(x, y)
// Opened near an edge, the menu would hang off
// the window; `fit` mirrors it back instead.
.fit(),
)
.modal(Msg::Menu)
.label("Actions")
.width(120.0)
.pad(4.0)
.gap(4.0)
.bg(t.raised)
.border(1.0, t.border_strong)
.radius(6.0),
|ui| {
widgets::button(ui, "+10", Msg::Add10);
widgets::button(ui, "reset", Msg::Reset);
},
);
}
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Inc) => self.count += 1,
Some(Msg::Dec) => self.count -= 1,
Some(Msg::Reset) => {
self.count = 0;
self.menu = None;
}
Some(Msg::Add10) => {
self.count += 10;
self.menu = None;
}
// The one tag, two events: a right-click — the core reports
// where it landed and opens nothing; the next frame's view is
// what puts a menu there — and a `dismiss`, Escape or a press
// outside the menu, where the core asks and the app decides.
// This one just closes.
Some(Msg::Menu) => {
if ev.kind() == Some("contextmenu") {
let at = |k| ev.payload.get_float(k).unwrap_or(0.0) as f32;
self.menu = Some((at("x"), at("y")));
} else {
self.menu = None;
}
}
None => {}
}
}
}
impl Example for Counter {
const KEYS: &'static [(&'static str, &'static str)] =
&[("right-click", "the modal menu"), ("Esc", "dismiss it")];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(560.0, 400.0)
}
/// The Rosetta drive: every counter clicks its buttons by label, opens
/// and dismisses its menu, and checks the model after each.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
let mut d = Drive::new(core, 560.0, 400.0);
d.frame(self);
let inc = d.key_of("+1").ok_or("no +1")?;
let dec = d.key_of("-1").ok_or("no -1")?;
d.click_key(self, inc);
d.click_key(self, inc);
d.click_key(self, dec);
d.frame(self);
d.check(self.count == 1, "two +1 and a -1 count to 1")?;
// The menu: a secondary press anywhere in the example, then the
// frame that declares the modal, then its +10.
d.input(
self,
kui_native::InputEvent::CursorMoved(kui_native::Vec2::new(40.0, 40.0)),
);
d.input(
self,
kui_native::InputEvent::MouseDown {
button: kui_native::MouseButton::Secondary,
clicks: 1,
},
);
d.input(
self,
kui_native::InputEvent::MouseUp {
button: kui_native::MouseButton::Secondary,
},
);
d.check(self.menu.is_some(), "a right-click asks for the menu")?;
d.frame(self);
let add10 = d.key_of("+10").ok_or("the menu did not open")?;
d.click_key(self, add10);
d.frame(self);
d.check(
self.count == 11 && self.menu.is_none(),
"+10 counts and closes the menu",
)?;
// Escape dismisses a reopened one without choosing.
self.menu = Some((40.0, 40.0));
d.frame(self);
d.key(self, "escape", Default::default());
d.check(self.menu.is_none(), "escape dismisses the menu")?;
d.frame(self);
let gone = d.key_of("+10").is_none();
d.check(gone, "and the frame after has no menu")
}
}
kui_devtools::main!(Counter::default());