use bevy::a11y::AccessibilityNode;
use bevy::input_focus::tab_navigation::{TabGroup, TabIndex};
use bevy::input_focus::{FocusCause, InputFocus, InputFocusVisible};
use bevy::prelude::*;
use serde_json::json;
use super::sync_focus;
use crate::bridge::{FocusState, OutboundResource};
use crate::protocol::op::Op;
use crate::protocol::outbound::Outbound;
use crate::protocol::{NodeId, ROOT_ID};
use crate::reconcile::test_util::{create, ent, op_app_with, update};
use crate::reconcile::{apply_interaction_styles, apply_js_ops};
type Outbox = tokio::sync::mpsc::UnboundedReceiver<Outbound>;
fn focus_app(setup: impl FnOnce(&mut App)) -> (App, crossbeam_channel::Sender<Vec<Op>>, Outbox) {
let (mut app, tx) = op_app_with(|app| {
app.init_resource::<InputFocus>()
.init_resource::<InputFocusVisible>()
.add_systems(
Update,
(
sync_focus.after(apply_js_ops),
apply_interaction_styles.after(sync_focus),
),
);
setup(app);
});
let (out, rx) = tokio::sync::mpsc::unbounded_channel();
app.insert_resource(OutboundResource(out));
(app, tx, rx)
}
fn focus(app: &mut App, id: NodeId) {
let entity = ent(app, id);
app.world_mut()
.resource_mut::<InputFocus>()
.set(entity, FocusCause::Navigated);
}
fn focused(app: &App) -> Option<Entity> {
app.world().resource::<InputFocus>().get()
}
fn events(rx: &mut Outbox) -> Vec<(NodeId, String)> {
std::iter::from_fn(|| rx.try_recv().ok())
.filter_map(|out| match out {
Outbound::ElementEvent { id, event, .. } => Some((id, event)),
_ => None,
})
.collect()
}
fn sent(pairs: &[(NodeId, &str)]) -> Vec<(NodeId, String)> {
pairs.iter().map(|(id, e)| (*id, (*e).to_owned())).collect()
}
fn background(app: &App, id: NodeId) -> Color {
app.world().get::<BackgroundColor>(ent(app, id)).unwrap().0
}
#[test]
fn tab_index_stamps_and_unsets() {
let (mut app, tx, _rx) = focus_app(|_| {});
tx.send(vec![
create(1, "node", json!({ "tabIndex": 0 })),
create(2, "node", json!({})),
])
.unwrap();
app.update();
let (node, plain) = (ent(&app, 1), ent(&app, 2));
assert_eq!(app.world().get::<TabIndex>(node), Some(&TabIndex(0)));
assert!(app.world().get::<AccessibilityNode>(node).is_some());
assert!(app.world().get::<TabIndex>(plain).is_none());
assert!(app.world().get::<AccessibilityNode>(plain).is_none());
tx.send(vec![update(1, "node", json!({ "tabIndex": -1 }), &[], &[])])
.unwrap();
app.update();
assert_eq!(app.world().get::<TabIndex>(node), Some(&TabIndex(-1)));
tx.send(vec![update(1, "node", json!({}), &["tabIndex"], &[])])
.unwrap();
app.update();
assert!(app.world().get::<TabIndex>(node).is_none());
}
#[test]
fn an_input_is_focusable_by_default() {
let (mut app, tx, _rx) = focus_app(|_| {});
tx.send(vec![
create(1, "editableText", json!({})),
create(2, "editableText", json!({ "tabIndex": -1 })),
])
.unwrap();
app.update();
let index = |app: &App, id| app.world().get::<TabIndex>(ent(app, id)).copied();
assert_eq!(index(&app, 1), Some(TabIndex(0)));
assert_eq!(index(&app, 2), Some(TabIndex(-1)));
tx.send(vec![update(
2,
"editableText",
json!({}),
&["tabIndex"],
&[],
)])
.unwrap();
app.update();
assert_eq!(index(&app, 2), Some(TabIndex(0)));
}
#[test]
fn a_root_is_a_tab_group() {
let (mut app, tx, _rx) = focus_app(|_| {});
tx.send(vec![create(1, "root", json!({}))]).unwrap();
app.update();
let group = app.world().get::<TabGroup>(ent(&app, 1)).expect("a group");
assert_eq!((group.order, group.modal), (1, false));
}
#[test]
fn blur_then_focus_once_per_frame() {
let (mut app, tx, mut rx) = focus_app(|_| {});
let handlers = || json!({ "tabIndex": 0, "onFocus": true, "onBlur": true });
tx.send(vec![
create(1, "node", handlers()),
create(2, "node", handlers()),
create(3, "node", json!({ "tabIndex": 0 })),
])
.unwrap();
app.update();
focus(&mut app, 1);
app.update();
assert_eq!(events(&mut rx), sent(&[(1, "focus")]));
focus(&mut app, 2);
app.update();
assert_eq!(events(&mut rx), sent(&[(1, "blur"), (2, "focus")]));
focus(&mut app, 1);
focus(&mut app, 2);
app.update();
assert_eq!(events(&mut rx), []);
focus(&mut app, 3);
app.update();
assert_eq!(events(&mut rx), sent(&[(2, "blur")]));
app.world_mut().resource_mut::<InputFocus>().clear();
app.update();
assert_eq!(events(&mut rx), [], "node 3 has no handlers");
tx.send(vec![update(1, "node", json!({}), &["onFocus"], &[])])
.unwrap();
focus(&mut app, 1);
app.update();
assert_eq!(events(&mut rx), []);
}
#[test]
fn focus_style_lands_the_same_frame() {
let (mut app, tx, _rx) = focus_app(|_| {});
tx.send(vec![create(
1,
"node",
json!({
"tabIndex": 0,
"style": { "backgroundColor": "red" },
"focusStyle": { "backgroundColor": "blue" },
}),
)])
.unwrap();
app.update();
let (red, blue) = (
crate::ui_map::parse_color("red"),
crate::ui_map::parse_color("blue"),
);
assert_eq!(background(&app, 1), red);
focus(&mut app, 1);
app.update();
assert_eq!(background(&app, 1), blue);
app.world_mut().resource_mut::<InputFocus>().clear();
app.update();
assert_eq!(background(&app, 1), red);
}
#[test]
fn a_focus_style_added_while_focused_applies() {
let (mut app, tx, _rx) = focus_app(|_| {});
tx.send(vec![create(1, "node", json!({ "tabIndex": 0 }))])
.unwrap();
app.update();
focus(&mut app, 1);
app.update();
tx.send(vec![update(
1,
"node",
json!({ "focusStyle": { "backgroundColor": "blue" } }),
&[],
&[],
)])
.unwrap();
app.update();
assert_eq!(background(&app, 1), crate::ui_map::parse_color("blue"));
}
#[test]
fn an_unmounted_focused_node_releases_the_focus() {
let (mut app, tx, mut rx) = focus_app(|_| {});
tx.send(vec![
create(1, "node", json!({ "tabIndex": 0, "onBlur": true })),
Op::Append {
parent: ROOT_ID,
child: 1,
},
])
.unwrap();
app.update();
focus(&mut app, 1);
app.update();
assert!(focused(&app).is_some());
tx.send(vec![Op::Remove {
parent: ROOT_ID,
child: 1,
}])
.unwrap();
app.update();
assert_eq!(focused(&app), None);
assert_eq!(events(&mut rx), []);
}
#[test]
fn a_disabled_control_gives_the_focus_up() {
let (mut app, tx, mut rx) = focus_app(|_| {});
tx.send(vec![create(1, "editableText", json!({ "onBlur": true }))])
.unwrap();
app.update();
focus(&mut app, 1);
app.update();
let index = |app: &App| app.world().get::<TabIndex>(ent(app, 1)).copied();
tx.send(vec![update(
1,
"editableText",
json!({ "disabled": true }),
&[],
&[],
)])
.unwrap();
app.update();
assert_eq!(focused(&app), None);
assert_eq!(events(&mut rx), sent(&[(1, "blur")]));
assert_eq!(index(&app), None, "nothing to press or Tab to");
tx.send(vec![update(
1,
"editableText",
json!({}),
&["disabled"],
&[],
)])
.unwrap();
app.update();
assert_eq!(index(&app), Some(TabIndex(0)));
}
#[test]
fn a_disabled_button_is_not_focusable() {
let (mut app, tx, _rx) = focus_app(|_| {});
tx.send(vec![create(
1,
"button",
json!({ "tabIndex": 0, "disabled": true }),
)])
.unwrap();
app.update();
let button = ent(&app, 1);
let state = |app: &App| {
let entity = app.world().entity(button);
(
entity.contains::<bevy::ui::InteractionDisabled>(),
entity.contains::<TabIndex>(),
)
};
assert_eq!(state(&app), (true, false));
tx.send(vec![update(
1,
"button",
json!({ "disabled": false }),
&[],
&[],
)])
.unwrap();
app.update();
assert_eq!(state(&app), (false, true));
}
#[test]
fn the_indicator_follows_input_focus_visible() {
let (mut app, tx, _rx) = focus_app(|_| {});
let styled = || json!({ "tabIndex": 0, "focusStyle": { "backgroundColor": "blue" } });
tx.send(vec![
create(1, "node", styled()),
create(
2,
"editableText",
json!({
"style": { "backgroundColor": "red" },
"focusVisibleStyle": { "backgroundColor": "blue" },
}),
),
])
.unwrap();
app.update();
let state = |app: &App, id| *app.world().get::<FocusState>(ent(app, id)).unwrap();
let on = |visible| FocusState {
focused: true,
visible,
};
focus(&mut app, 1);
app.update();
assert_eq!(state(&app, 1), on(false));
app.world_mut().resource_mut::<InputFocusVisible>().0 = true;
app.update();
assert_eq!(state(&app, 1), on(true));
app.world_mut().resource_mut::<InputFocusVisible>().0 = false;
app.update();
assert_eq!(state(&app, 1), on(false));
focus(&mut app, 2);
app.update();
assert_eq!(state(&app, 1), FocusState::default());
assert_eq!(state(&app, 2), on(true));
assert_eq!(background(&app, 2), crate::ui_map::parse_color("blue"));
}
#[test]
fn tab_walks_the_focusables() {
use bevy::input::keyboard::{Key, KeyCode, KeyboardInput};
use bevy::input::{ButtonState, InputPlugin};
use bevy::input_focus::InputDispatchPlugin;
use bevy::input_focus::tab_navigation::TabNavigationPlugin;
use bevy::window::PrimaryWindow;
let (mut app, tx, mut rx) = focus_app(|app| {
app.add_plugins((InputPlugin, InputDispatchPlugin, TabNavigationPlugin))
.add_message::<bevy::input::gamepad::GamepadButtonChangedEvent>()
.add_message::<bevy::input::mouse::MouseWheel>();
});
let window = app
.world_mut()
.spawn((Window::default(), PrimaryWindow))
.id();
let root = app.world().resource::<crate::bridge::JsBridge>().nodes[&ROOT_ID];
app.world_mut().entity_mut(root).insert(TabGroup::new(0));
let mut ops = Vec::new();
for (id, index) in [(1, 0), (2, -1), (3, 0)] {
ops.push(create(
id,
"node",
json!({ "tabIndex": index, "onFocus": true }),
));
ops.push(Op::Append {
parent: ROOT_ID,
child: id,
});
}
tx.send(ops).unwrap();
app.update();
for id in 1..=3 {
let entity = ent(&app, id);
app.world_mut()
.entity_mut(entity)
.insert(InheritedVisibility::VISIBLE);
}
let tab = |app: &mut App| {
for state in [ButtonState::Pressed, ButtonState::Released] {
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::Tab,
logical_key: Key::Tab,
state,
text: None,
repeat: false,
window,
});
app.update();
}
focused(app)
};
assert_eq!(tab(&mut app), Some(ent(&app, 1)));
assert_eq!(tab(&mut app), Some(ent(&app, 3)), "-1 is skipped");
assert_eq!(tab(&mut app), Some(ent(&app, 1)), "and Tab wraps");
assert!(app.world().resource::<InputFocusVisible>().0);
assert_eq!(
events(&mut rx),
sent(&[(1, "focus"), (3, "focus"), (1, "focus")])
);
}
#[test]
fn enter_and_space_click_a_focused_button() {
use bevy::input::keyboard::{Key, KeyCode, KeyboardInput};
use bevy::input::{ButtonState, InputPlugin};
use bevy::input_focus::InputDispatchPlugin;
use bevy::window::PrimaryWindow;
let (mut app, tx, mut rx) = focus_app(|app| {
app.add_plugins((
InputPlugin,
InputDispatchPlugin,
bevy::ui_widgets::ButtonPlugin,
))
.add_message::<bevy::input::gamepad::GamepadButtonChangedEvent>()
.add_message::<bevy::input::mouse::MouseWheel>()
.add_observer(super::click_focused_button);
});
let window = app
.world_mut()
.spawn((Window::default(), PrimaryWindow))
.id();
tx.send(vec![
create(1, "button", json!({ "tabIndex": 0, "onClick": true })),
create(2, "node", json!({ "tabIndex": 0, "onClick": true })),
create(
3,
"button",
json!({ "tabIndex": 0, "onClick": true, "disabled": true }),
),
])
.unwrap();
app.update();
let mut key = |app: &mut App, key_code, state, repeat| {
app.world_mut().write_message(KeyboardInput {
key_code,
logical_key: Key::Enter,
state,
text: None,
repeat,
window,
});
app.update();
std::iter::from_fn(|| rx.try_recv().ok())
.filter_map(|out| match out {
Outbound::UiEvent { event } => Some((event.id, event.kind)),
_ => None,
})
.collect::<Vec<_>>()
};
let click = [(1, "click".to_owned())];
let (down, up) = (ButtonState::Pressed, ButtonState::Released);
focus(&mut app, 1);
assert_eq!(key(&mut app, KeyCode::Enter, down, false), click);
assert_eq!(key(&mut app, KeyCode::Enter, down, true), [], "a repeat");
assert_eq!(key(&mut app, KeyCode::Enter, up, false), [], "the release");
assert_eq!(key(&mut app, KeyCode::Space, down, false), click);
assert_eq!(key(&mut app, KeyCode::KeyA, down, false), []);
focus(&mut app, 2);
assert_eq!(key(&mut app, KeyCode::Enter, down, false), [], "a node");
focus(&mut app, 3);
assert_eq!(key(&mut app, KeyCode::Enter, down, false), []);
assert_eq!(focused(&app), None);
}