tests.rs
raw
use std::cell::RefCell;
use std::rc::Rc;
use guiduck_scene::geom::{Point, Size};
use guiduck_scene::paint::color::palette::css;
use taffy::prelude::{length, percent};
use super::*;
use crate::clipboard::NoClipboard;
use crate::text::TextContext;
use crate::widget::{Button, Container, Text, WidgetTree};
type Log = Rc<RefCell<Vec<String>>>;
fn log_handler(log: &Log, name: &str) -> impl FnMut(&mut EventCtx, &EventData) + 'static {
let log = log.clone();
let name = name.to_owned();
move |_ctx, _ev| log.borrow_mut().push(name.clone())
}
/// Root (400x300) containing a 100x100 box at (50, 50) containing a 40x40
/// box at its (10, 10).
fn fixture() -> (WidgetTree, WidgetId, WidgetId, WidgetId) {
let mut tree = WidgetTree::with_text_context(TextContext::hermetic([]));
let root = tree.insert(
Container::new(),
taffy::Style {
size: taffy::Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
padding: taffy::Rect::length(50.0_f32),
..Default::default()
},
None,
);
let outer = tree.insert(
Container::new(),
taffy::Style {
size: taffy::Size {
width: length(100.0_f32),
height: length(100.0_f32),
},
padding: taffy::Rect::length(10.0_f32),
..Default::default()
},
Some(root),
);
let inner = tree.insert(
Container::new(),
taffy::Style {
size: taffy::Size {
width: length(40.0_f32),
height: length(40.0_f32),
},
..Default::default()
},
Some(outer),
);
tree.compute_layout(Size::new(400.0, 300.0));
(tree, root, outer, inner)
}
#[test]
fn hit_test_finds_topmost_descendant() {
let (tree, root, outer, inner) = fixture();
assert_eq!(tree.hit_test(Point::new(65.0, 65.0)), Some(inner));
assert_eq!(tree.hit_test(Point::new(140.0, 140.0)), Some(outer));
assert_eq!(tree.hit_test(Point::new(5.0, 5.0)), Some(root));
assert_eq!(tree.hit_test(Point::new(500.0, 500.0)), None);
}
#[test]
fn enter_and_leave_follow_the_hover_chain() {
let (mut tree, root, outer, inner) = fixture();
let log: Log = Log::default();
for (id, name) in [(root, "root"), (outer, "outer"), (inner, "inner")] {
tree.on_event(
id,
EventKind::PointerEnter,
log_handler(&log, &format!("enter-{name}")),
);
tree.on_event(
id,
EventKind::PointerLeave,
log_handler(&log, &format!("leave-{name}")),
);
}
tree.dispatch_pointer(
PointerInput::Moved(Point::new(65.0, 65.0)),
&mut NoClipboard,
);
assert_eq!(
log.borrow().as_slice(),
["enter-root", "enter-outer", "enter-inner"],
"enters fire outermost-first"
);
log.borrow_mut().clear();
// Move to a point inside outer but outside inner.
tree.dispatch_pointer(
PointerInput::Moved(Point::new(140.0, 140.0)),
&mut NoClipboard,
);
assert_eq!(
log.borrow().as_slice(),
["leave-inner"],
"only the widget actually left fires"
);
log.borrow_mut().clear();
tree.dispatch_pointer(PointerInput::Left, &mut NoClipboard);
assert_eq!(
log.borrow().as_slice(),
["leave-outer", "leave-root"],
"leaves fire innermost-first"
);
}
#[test]
fn capture_then_bubble_with_stop_propagation() {
let (mut tree, root, outer, inner) = fixture();
let log: Log = Log::default();
for (id, name) in [(root, "root"), (outer, "outer"), (inner, "inner")] {
tree.add_handler(
id,
EventKind::PointerDown,
Phase::Capture,
log_handler(&log, &format!("capture-{name}")),
);
tree.on_event(
id,
EventKind::PointerDown,
log_handler(&log, &format!("bubble-{name}")),
);
}
tree.dispatch_pointer(
PointerInput::Down {
time: std::time::Instant::now(),
pos: Point::new(65.0, 65.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
assert_eq!(
log.borrow().as_slice(),
[
"capture-root",
"capture-outer",
"capture-inner",
"bubble-inner",
"bubble-outer",
"bubble-root",
]
);
// Stopping in outer's bubble handler prevents root's bubble handler.
log.borrow_mut().clear();
tree.on_event(outer, EventKind::PointerUp, |ctx, _ev| {
ctx.stop_propagation()
});
tree.on_event(
root,
EventKind::PointerUp,
log_handler(&log, "bubble-root-up"),
);
tree.dispatch_pointer(
PointerInput::Up {
pos: Point::new(65.0, 65.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
assert!(
!log.borrow().iter().any(|e| e == "bubble-root-up"),
"propagation stopped before the root"
);
}
#[test]
fn click_requires_press_and_release_on_same_target() {
let (mut tree, root, outer, inner) = fixture();
let log: Log = Log::default();
tree.on_event(inner, EventKind::Click, log_handler(&log, "click-inner"));
tree.on_event(outer, EventKind::Click, log_handler(&log, "click-outer"));
let _ = root;
// Press and release on inner: click (which also bubbles through outer).
tree.dispatch_pointer(
PointerInput::Down {
time: std::time::Instant::now(),
pos: Point::new(65.0, 65.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
tree.dispatch_pointer(
PointerInput::Up {
pos: Point::new(66.0, 66.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
assert_eq!(log.borrow().as_slice(), ["click-inner", "click-outer"]);
// Press on inner, drag out, release on outer: no click anywhere.
log.borrow_mut().clear();
tree.dispatch_pointer(
PointerInput::Down {
time: std::time::Instant::now(),
pos: Point::new(65.0, 65.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
tree.dispatch_pointer(
PointerInput::Up {
pos: Point::new(140.0, 140.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
assert!(log.borrow().is_empty());
}
/// A CountedClick handler on the path holds the click until the burst window
/// closes, then delivers one event carrying the confirmed count.
#[test]
fn counted_click_resolves_after_the_window() {
let (mut tree, _root, _outer, inner) = fixture();
let log: Log = Log::default();
{
let log = log.clone();
tree.on_event(inner, EventKind::CountedClick, move |_ctx, ev| {
let count = ev.pointer().map_or(0, |p| p.click_count);
log.borrow_mut().push(format!("counted-{count}"));
});
}
let t0 = std::time::Instant::now();
let ms = std::time::Duration::from_millis;
tree.dispatch_pointer(
PointerInput::Down {
time: t0,
pos: Point::new(65.0, 65.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
tree.dispatch_pointer(
PointerInput::Up {
pos: Point::new(65.0, 65.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
assert!(
log.borrow().is_empty(),
"CountedClick is held until the window closes"
);
let deadline = tree
.next_wake(t0)
.expect("a pending burst schedules a wake");
tree.tick(t0 + ms(100));
assert!(log.borrow().is_empty(), "still pending inside the window");
tree.tick(deadline);
assert_eq!(
log.borrow().as_slice(),
["counted-1"],
"one confirmed single"
);
}
/// A double within the window resolves to a single CountedClick of count 2 —
/// never a count-1 event first — because the window is anchored at the first
/// press and the count is only reported once settled.
#[test]
fn counted_click_coalesces_a_double() {
let (mut tree, _root, _outer, inner) = fixture();
let log: Log = Log::default();
{
let log = log.clone();
tree.on_event(inner, EventKind::CountedClick, move |_ctx, ev| {
let count = ev.pointer().map_or(0, |p| p.click_count);
log.borrow_mut().push(format!("counted-{count}"));
});
}
let t0 = std::time::Instant::now();
let ms = std::time::Duration::from_millis;
for press in [0_u64, 80] {
tree.dispatch_pointer(
PointerInput::Down {
time: t0 + ms(press),
pos: Point::new(65.0, 65.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
tree.dispatch_pointer(
PointerInput::Up {
pos: Point::new(65.0, 65.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
}
assert!(log.borrow().is_empty(), "held through the whole burst");
// Anchored at the first down, the window closes at t0 + 200ms.
tree.tick(t0 + ms(200));
assert_eq!(
log.borrow().as_slice(),
["counted-2"],
"one event carrying the final count"
);
}
/// A burst settles a whole window after the press that started it, and the
/// widget it landed on can be gone by then — an application whose handler
/// rebuilt the region the click was in. The resolution has no one to tell, and
/// says so rather than asking the tree about a widget it no longer has.
#[test]
fn a_counted_click_whose_target_was_removed_is_dropped() {
let (mut tree, _root, _outer, inner) = fixture();
let log: Log = Log::default();
tree.on_event(inner, EventKind::CountedClick, log_handler(&log, "counted"));
let t0 = std::time::Instant::now();
let ms = std::time::Duration::from_millis;
tree.dispatch_pointer(
PointerInput::Down {
time: t0,
pos: Point::new(65.0, 65.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
tree.dispatch_pointer(
PointerInput::Up {
pos: Point::new(65.0, 65.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
// The click's own handling retired the widget it landed on, before the
// window it is held for has closed.
tree.remove(inner);
tree.tick(t0 + ms(200));
assert!(
log.borrow().is_empty(),
"a widget that is gone hears nothing"
);
}
/// With nothing on the path distinguishing counts, a click is never held: the
/// Click fires immediately and no wake is scheduled.
#[test]
fn a_plain_click_path_is_never_deferred() {
let (mut tree, _root, _outer, inner) = fixture();
let log: Log = Log::default();
tree.on_event(inner, EventKind::Click, log_handler(&log, "click"));
let t0 = std::time::Instant::now();
tree.dispatch_pointer(
PointerInput::Down {
time: t0,
pos: Point::new(65.0, 65.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
tree.dispatch_pointer(
PointerInput::Up {
pos: Point::new(65.0, 65.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
assert_eq!(log.borrow().as_slice(), ["click"], "Click is immediate");
assert_eq!(
tree.next_wake(t0),
None,
"no pending burst, so no wake owed"
);
}
/// A button (or checkbox) consumes its click: the widget's own handler runs,
/// but the click does not also bubble to an enclosing container's `:on-click`.
/// A click on the container alone still fires it.
#[test]
fn a_button_consumes_its_click() {
let mut tree = WidgetTree::with_text_context(TextContext::hermetic([]));
let root = tree.insert(
Container::new(),
taffy::Style {
size: taffy::Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
},
None,
);
let button = tree.insert(
Button::new("go"),
taffy::Style {
size: taffy::Size {
width: length(80.0_f32),
height: length(30.0_f32),
},
..Default::default()
},
Some(root),
);
tree.compute_layout(Size::new(200.0, 100.0));
let log: Log = Log::default();
tree.on_event(root, EventKind::Click, log_handler(&log, "container"));
tree.on_event(button, EventKind::Click, log_handler(&log, "button"));
let click = |tree: &mut WidgetTree, pos: Point| {
tree.dispatch_pointer(
PointerInput::Down {
time: std::time::Instant::now(),
pos,
button: PointerButton::Left,
},
&mut NoClipboard,
);
tree.dispatch_pointer(
PointerInput::Up {
pos,
button: PointerButton::Left,
},
&mut NoClipboard,
);
};
// Click on the button (its top-left is the root's origin; center ~40,15).
click(&mut tree, Point::new(40.0, 15.0));
assert_eq!(
log.borrow().as_slice(),
["button"],
"the button's click is its own and did not bubble to the container",
);
// Click on the container, outside the button.
log.borrow_mut().clear();
click(&mut tree, Point::new(150.0, 90.0));
assert_eq!(
log.borrow().as_slice(),
["container"],
"a click on the container alone still fires it",
);
}
#[test]
fn local_coordinates_are_widget_relative() {
let (mut tree, _root, _outer, inner) = fixture();
let seen: Rc<RefCell<Option<PointerEvent>>> = Rc::default();
let seen2 = seen.clone();
tree.on_event(inner, EventKind::PointerDown, move |_ctx, ev| {
*seen2.borrow_mut() = ev.pointer().copied();
});
tree.dispatch_pointer(
PointerInput::Down {
time: std::time::Instant::now(),
pos: Point::new(65.0, 72.0),
button: PointerButton::Left,
},
&mut NoClipboard,
);
let event = seen.borrow().expect("handler ran");
// inner's absolute origin is (60, 60): root padding 50 + outer padding 10.
assert_eq!(event.local, Point::new(5.0, 12.0));
assert_eq!(event.window, Point::new(65.0, 72.0));
assert_eq!(event.button, Some(PointerButton::Left));
}
#[test]
fn signal_binding_updates_widget_through_frames() {
let mut tree = WidgetTree::with_text_context(TextContext::hermetic([sample_font()]));
let root = tree.insert(
Container::new(),
taffy::Style {
size: taffy::Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
},
None,
);
let label = tree.insert(
Text::new("initial").family("DejaVu Sans"),
taffy::Style::default(),
Some(root),
);
let count = guiduck_signals::Signal::new(0);
tree.bind::<Text, _>(
label,
move || format!("count: {}", count.get()),
|text, value| text.set_text(value),
);
assert!(tree.needs_frame(), "initial binding queued a command");
tree.render_frame(Size::new(300.0, 200.0));
assert_eq!(tree.widget::<Text>(label).unwrap().text(), "count: 0");
assert!(!tree.needs_frame(), "quiescent after the frame");
count.set(7);
assert!(tree.needs_frame(), "signal write schedules a frame");
tree.render_frame(Size::new(300.0, 200.0));
assert_eq!(tree.widget::<Text>(label).unwrap().text(), "count: 7");
assert!(!tree.needs_frame());
count.set(7);
assert!(!tree.needs_frame(), "equal write must not schedule a frame");
}
#[test]
fn handler_commands_mutate_widgets_next_frame() {
let (mut tree, _root, outer, _inner) = fixture();
tree.on_event(outer, EventKind::PointerEnter, move |ctx, _ev| {
ctx.mutate::<Container>(ctx.current, |c| {
c.set_background(css::ORANGE);
});
});
tree.dispatch_pointer(
PointerInput::Moved(Point::new(140.0, 140.0)),
&mut NoClipboard,
);
assert!(tree.needs_frame(), "hover queued a mutation");
tree.render_frame(Size::new(400.0, 300.0));
assert!(!tree.needs_frame());
}
fn sample_font() -> guiduck_scene::paint::Blob<u8> {
static FONT: &[u8] = include_bytes!("../../../guiduck-samples/fonts/DejaVuSans.ttf");
guiduck_scene::paint::Blob::new(std::sync::Arc::new(FONT))
}
#[test]
fn disabled_widgets_are_inert_but_their_enabled_ancestors_react() {
let (mut tree, _root, outer, inner) = fixture();
let log: Log = Log::default();
tree.on_event(inner, EventKind::Click, log_handler(&log, "inner"));
tree.on_event(outer, EventKind::Click, log_handler(&log, "outer"));
tree.set_enabled(inner, false);
// A click landing on the disabled inner box: inner is inert, but the
// press behaves as a click on outer (as if on its padding).
let pos = Point::new(70.0, 70.0);
tree.dispatch_pointer(
PointerInput::Down {
time: std::time::Instant::now(),
pos,
button: PointerButton::Left,
},
&mut NoClipboard,
);
tree.dispatch_pointer(
PointerInput::Up {
pos,
button: PointerButton::Left,
},
&mut NoClipboard,
);
assert_eq!(log.borrow().as_slice(), ["outer"]);
// Hover likewise: outer hovers, inner does not.
tree.dispatch_pointer(PointerInput::Moved(pos), &mut NoClipboard);
assert!(tree.interaction_state(outer).hover);
assert!(!tree.interaction_state(inner).hover);
assert!(tree.interaction_state(inner).disabled);
assert!(!tree.interaction_state(outer).disabled);
}
#[test]
fn disabling_an_ancestor_disables_the_subtree() {
let (mut tree, _root, outer, inner) = fixture();
let log: Log = Log::default();
tree.on_event(inner, EventKind::Click, log_handler(&log, "inner"));
tree.set_enabled(outer, false);
assert!(tree.effectively_disabled(inner), "inherited");
assert!(tree.is_enabled(inner), "own flag untouched");
let pos = Point::new(70.0, 70.0);
tree.dispatch_pointer(
PointerInput::Down {
time: std::time::Instant::now(),
pos,
button: PointerButton::Left,
},
&mut NoClipboard,
);
tree.dispatch_pointer(
PointerInput::Up {
pos,
button: PointerButton::Left,
},
&mut NoClipboard,
);
assert!(log.borrow().is_empty(), "the whole subtree is inert");
}
#[test]
fn disabling_the_focused_widget_moves_focus() {
let mut tree = WidgetTree::with_text_context(TextContext::hermetic([sample_font()]));
let root = tree.insert(
Container::new(),
taffy::Style {
size: taffy::Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
},
None,
);
let first = tree.insert(
crate::widget::TextInput::new(13.0).family("DejaVu Sans"),
taffy::Style::default(),
Some(root),
);
let second = tree.insert(
crate::widget::TextInput::new(13.0).family("DejaVu Sans"),
taffy::Style::default(),
Some(root),
);
tree.render_frame(Size::new(400.0, 300.0));
assert_eq!(tree.focus(), Some(first));
tree.set_enabled(first, false);
tree.render_frame(Size::new(400.0, 300.0));
assert_eq!(
tree.focus(),
Some(second),
"focus leaves a disabled widget for the next focusable"
);
// Tab traversal skips the disabled one entirely.
tree.focus_step(false);
assert_eq!(tree.focus(), Some(second), "only one focusable remains");
}
#[test]
fn shortcuts_fire_only_when_the_focused_widget_declines() {
let mut tree = WidgetTree::with_text_context(TextContext::hermetic([sample_font()]));
let root = tree.insert(
Container::new(),
taffy::Style {
size: taffy::Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
},
None,
);
tree.insert(
crate::widget::TextInput::new(13.0).family("DejaVu Sans"),
taffy::Style {
size: taffy::Size {
width: length(100.0_f32),
height: length(24.0_f32),
},
..Default::default()
},
Some(root),
);
tree.render_frame(Size::new(400.0, 300.0));
let fired = Rc::new(RefCell::new(0));
{
let fired = fired.clone();
tree.on_shortcut(Keystroke::ctrl("s"), move || {
*fired.borrow_mut() += 1;
});
}
let bare = Rc::new(RefCell::new(0));
{
let bare = bare.clone();
tree.on_shortcut(
Keystroke {
key: Key::Character("s".into()),
modifiers: Modifiers::default(),
},
move || {
*bare.borrow_mut() += 1;
},
);
}
// Ctrl+S: the text input declines it, the accelerator fires.
let consumed = tree.dispatch_key(
&KeyInput {
key: Key::Character("s".into()),
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
pressed: true,
},
&mut NoClipboard,
);
assert!(consumed);
assert_eq!(*fired.borrow(), 1);
// Bare "s": the focused text input types it; the bare shortcut must
// not steal the keystroke.
tree.dispatch_key(
&KeyInput {
key: Key::Character("s".into()),
modifiers: Modifiers::default(),
pressed: true,
},
&mut NoClipboard,
);
assert_eq!(*bare.borrow(), 0, "typing beats bare-letter shortcuts");
}
/// Every key an accelerator can *declare* spells the same as the key the
/// platform *sends*, so `:accel "Ctrl+S"` and `event.key` are one notation.
///
/// The two vocabularies are deliberately different sets — `Key` has
/// `Backspace`, `Tab`, and `Other`, which an accelerator may not name — but
/// where they overlap they must agree, and the match below is exhaustive, so
/// a new `AccelKey` variant does not compile until it has been given its
/// runtime counterpart here.
#[test]
fn an_accelerator_and_an_observed_key_spell_the_same() {
use guiduck_component_core::accel::{AccelIr, AccelKey};
let cases = [
AccelKey::Character("S".into()),
AccelKey::Enter,
AccelKey::Delete,
AccelKey::Home,
AccelKey::End,
AccelKey::Left,
AccelKey::Right,
AccelKey::Up,
AccelKey::Down,
];
for accel_key in cases {
let key = match &accel_key {
AccelKey::Character(c) => Key::Character(c.clone()),
AccelKey::Enter => Key::Enter,
AccelKey::Delete => Key::Delete,
AccelKey::Home => Key::Home,
AccelKey::End => Key::End,
AccelKey::Left => Key::Left,
AccelKey::Right => Key::Right,
AccelKey::Up => Key::Up,
AccelKey::Down => Key::Down,
};
// Bare, and under every modifier, so the join order is pinned too.
for (ctrl, alt, shift, logo) in [
(false, false, false, false),
(true, false, false, false),
(true, true, true, true),
(false, false, true, false),
] {
let declared = AccelIr {
key: accel_key.clone(),
ctrl,
shift,
alt,
logo,
};
let observed = KeyInput {
key: key.clone(),
modifiers: Modifiers {
ctrl,
shift,
alt,
logo,
},
pressed: true,
};
assert_eq!(
declared.display(),
observed.spelling().expect("a named key"),
"{accel_key:?} spells differently declared and observed"
);
}
}
}
/// The key we did not model has no name, which is what stops `:on-key` from
/// reporting a keystroke an application could never match.
#[test]
fn an_unmodelled_key_has_no_spelling() {
assert_eq!(Key::Other.name(), None);
assert_eq!(
KeyInput {
key: Key::Other,
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
pressed: true,
}
.spelling(),
None,
);
}
/// A tree with two buttons under one container, and the layout computed.
fn two_buttons() -> (WidgetTree, WidgetId, WidgetId, WidgetId) {
let mut tree = WidgetTree::with_text_context(TextContext::hermetic([sample_font()]));
let root = tree.insert(Container::new(), taffy::Style::default(), None);
let first = tree.insert(Button::new("first"), taffy::Style::default(), Some(root));
let second = tree.insert(Button::new("second"), taffy::Style::default(), Some(root));
tree.compute_layout(Size::new(400.0, 300.0));
(tree, root, first, second)
}
/// `:focused true` takes focus, and `false` gives it up.
#[test]
fn set_focused_drives_focus_both_ways() {
let (mut tree, _root, first, _second) = two_buttons();
tree.set_focused(first, true);
assert_eq!(tree.focus(), Some(first));
tree.set_focused(first, false);
assert_eq!(tree.focus(), None);
}
/// The asymmetry that makes `:focused` order-independent: clearing a widget
/// that is *not* focused does nothing, so when focus moves between two widgets
/// driven by one piece of state, the falling and rising bindings can run in
/// either order and land in the same place.
#[test]
fn clearing_focused_on_an_unfocused_widget_does_nothing() {
let (mut tree, _root, first, second) = two_buttons();
// The order a reconciler happens to produce: the new widget rises first,
// then the old one falls. The stale `false` must not undo the `true`.
tree.set_focused(first, true);
tree.set_focused(second, true);
tree.set_focused(first, false);
assert_eq!(tree.focus(), Some(second), "the stale clear was ignored");
// And the other order, for the same end state.
let (mut tree, _root, first, second) = two_buttons();
tree.set_focused(first, true);
tree.set_focused(first, false);
tree.set_focused(second, true);
assert_eq!(tree.focus(), Some(second));
}
/// Focus changes report to the widgets that gained and lost it — and to
/// nobody else, because "am I focused" is not a fact an ancestor can answer.
#[test]
fn focus_changes_report_to_the_widgets_themselves() {
let (mut tree, root, first, second) = two_buttons();
let log: Log = Rc::new(RefCell::new(Vec::new()));
for (id, name) in [(root, "root"), (first, "first"), (second, "second")] {
let log = log.clone();
let name = name.to_owned();
tree.on_event(id, EventKind::FocusChange, move |_ctx, ev| {
let EventData::FocusChanged(focused) = ev else {
panic!("focus wires receive FocusChanged");
};
log.borrow_mut().push(format!("{name}={focused}"));
});
}
tree.set_focus(Some(first));
assert_eq!(*log.borrow(), ["first=true"]);
tree.set_focus(Some(second));
assert_eq!(
*log.borrow(),
["first=true", "first=false", "second=true"],
"the loser hears first, and the container hears nothing"
);
}
/// A handler that asks the tree during a focus report sees the settled
/// answer, not the transition it is being told about.
#[test]
fn a_focus_report_arrives_after_focus_has_moved() {
let (mut tree, _root, first, _second) = two_buttons();
let seen: Rc<RefCell<Option<bool>>> = Rc::new(RefCell::new(None));
let commands = tree.commands();
let seen_in_handler = seen.clone();
let target = first;
tree.on_event(first, EventKind::FocusChange, move |_ctx, _ev| {
let seen = seen_in_handler.clone();
commands.push(move |tree| *seen.borrow_mut() = Some(tree.focus() == Some(target)));
});
tree.set_focus(Some(first));
tree.render_frame(Size::new(400.0, 300.0));
assert_eq!(*seen.borrow(), Some(true));
}
/// A key the focused widget declines reaches its `:on-key` wire, and then its
/// ancestors' — outwards only, so the widget it was typed into always answers
/// first.
#[test]
fn an_unconsumed_key_bubbles_from_the_focused_widget() {
let (mut tree, root, first, _second) = two_buttons();
let log: Log = Rc::new(RefCell::new(Vec::new()));
for (id, name) in [(root, "root"), (first, "button")] {
let log = log.clone();
let name = name.to_owned();
tree.on_event(id, EventKind::Key, move |_ctx, ev| {
let EventData::Key(spelling) = ev else {
panic!("key wires receive Key");
};
log.borrow_mut().push(format!("{name}:{spelling}"));
});
}
tree.set_focus(Some(first));
let used = tree.dispatch_key(
&KeyInput {
key: Key::Escape,
modifiers: Modifiers::default(),
pressed: true,
},
&mut NoClipboard,
);
assert_eq!(*log.borrow(), ["button:Escape", "root:Escape"]);
assert!(!used, "nobody consumed it, so the key is still unclaimed");
}
/// Consuming the event consumes the key: it stops bubbling, and the layers
/// below (overlay dismissal, shortcuts, Tab) never see it.
#[test]
fn consuming_a_key_event_consumes_the_key() {
let (mut tree, root, first, _second) = two_buttons();
let reached_root = Rc::new(RefCell::new(0));
let shortcut_fired = Rc::new(RefCell::new(0));
tree.on_event(first, EventKind::Key, |ctx, _ev| ctx.stop_propagation());
let seen = reached_root.clone();
tree.on_event(root, EventKind::Key, move |_ctx, _ev| {
*seen.borrow_mut() += 1;
});
let fired = shortcut_fired.clone();
tree.on_shortcut(
Keystroke {
key: Key::Escape,
modifiers: Modifiers::default(),
},
move || *fired.borrow_mut() += 1,
);
tree.set_focus(Some(first));
let used = tree.dispatch_key(
&KeyInput {
key: Key::Escape,
modifiers: Modifiers::default(),
pressed: true,
},
&mut NoClipboard,
);
assert!(used);
assert_eq!(*reached_root.borrow(), 0, "consuming stopped the bubble");
assert_eq!(*shortcut_fired.borrow(), 0, "and stopped the shortcut");
}
/// The widget's own handling still comes first: what a control consumes never
/// reaches a key wire, so typing into a field cannot trigger an app's
/// bare-letter action.
#[test]
fn a_key_the_focused_widget_consumes_never_reaches_a_wire() {
let mut tree = WidgetTree::with_text_context(TextContext::hermetic([sample_font()]));
let root = tree.insert(Container::new(), taffy::Style::default(), None);
let field = tree.insert(
crate::widget::TextInput::new(14.0),
taffy::Style::default(),
Some(root),
);
tree.compute_layout(Size::new(400.0, 300.0));
let saw = Rc::new(RefCell::new(0));
let seen = saw.clone();
tree.on_event(root, EventKind::Key, move |_ctx, _ev| {
*seen.borrow_mut() += 1;
});
tree.set_focus(Some(field));
tree.dispatch_key(
&KeyInput {
key: Key::Character("a".into()),
modifiers: Modifiers::default(),
pressed: true,
},
&mut NoClipboard,
);
assert_eq!(*saw.borrow(), 0, "the field typed it");
// Escape is not a text-input key, so that one does reach the wire.
tree.dispatch_key(
&KeyInput {
key: Key::Escape,
modifiers: Modifiers::default(),
pressed: true,
},
&mut NoClipboard,
);
assert_eq!(*saw.borrow(), 1);
}
/// With no focusable widget anywhere, a key wire still fires — from the root.
/// The focus invariant covers every other case, so this is the one shape where
/// there is nothing focused to start from, and a wire that went silently dead
/// there would be a trap with no diagnostic behind it.
#[test]
fn a_key_wire_fires_from_the_root_when_nothing_is_focusable() {
let mut tree = WidgetTree::with_text_context(TextContext::hermetic([]));
let root = tree.insert(Container::new(), taffy::Style::default(), None);
let child = tree.insert(Container::new(), taffy::Style::default(), Some(root));
tree.compute_layout(Size::new(100.0, 100.0));
assert_eq!(tree.focus(), None, "a container is not focusable");
let log: Log = Rc::new(RefCell::new(Vec::new()));
for (id, name) in [(root, "root"), (child, "child")] {
let log = log.clone();
let name = name.to_owned();
tree.on_event(id, EventKind::Key, move |_ctx, ev| {
let EventData::Key(spelling) = ev else {
panic!("key wires receive Key");
};
log.borrow_mut().push(format!("{name}:{spelling}"));
});
}
tree.dispatch_key(
&KeyInput {
key: Key::Character("q".into()),
modifiers: Modifiers {
ctrl: true,
..Default::default()
},
pressed: true,
},
&mut NoClipboard,
);
// From the root, so only the root's own wire — there is no focused widget
// to have started deeper.
assert_eq!(*log.borrow(), ["root:Ctrl+Q"]);
}