Created
April 19, 2023 19:24
-
-
Save tigregalis/616099785106630c73dde6557e32b251 to your computer and use it in GitHub Desktop.
bevy-click-handling
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[package] | |
name = "bevy-click-handling" | |
version = "0.1.0" | |
edition = "2021" | |
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | |
[dependencies] | |
bevy = { git = "https://github.com/bevyengine/bevy", rev = "fe852fd0adbce6856f5886d66d20d62cfc936287" } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
use bevy::{ | |
ecs::{ | |
query::WorldQuery, | |
system::{BoxedSystem, SystemState}, | |
}, | |
input::InputSystem, | |
prelude::*, | |
render::camera::NormalizedRenderTarget, | |
ui::{FocusPolicy, RelativeCursorPosition, UiStack, UiSystem}, | |
window::PrimaryWindow, | |
}; | |
// -- application -- | |
fn main() { | |
App::new() | |
.add_plugins(DefaultPlugins) | |
// add the EventHandlerPlugin | |
.add_plugin(EventHandlerPlugin) | |
.add_systems(Startup, setup) | |
.run(); | |
} | |
#[derive(Component)] | |
struct CounterState { | |
counter: isize, | |
} | |
const NORMAL_BUTTON: Color = Color::rgb(0.15, 0.15, 0.15); | |
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) { | |
// ui camera | |
commands.spawn(Camera2dBundle::default()); | |
commands | |
.spawn(NodeBundle { | |
style: Style { | |
size: Size::new(Val::Percent(100.0), Val::Percent(100.0)), | |
align_items: AlignItems::Center, | |
justify_content: JustifyContent::Center, | |
..default() | |
}, | |
..default() | |
}) | |
.with_children(|parent| { | |
parent | |
.spawn(ButtonBundle { | |
style: Style { | |
size: Size::new(Val::Px(150.0), Val::Px(65.0)), | |
// horizontally center child text | |
justify_content: JustifyContent::Center, | |
// vertically center child text | |
align_items: AlignItems::Center, | |
..default() | |
}, | |
background_color: NORMAL_BUTTON.into(), | |
..default() | |
}) | |
// add some entity-specific state with a component | |
.insert(CounterState { counter: 0 }) | |
// add an On<Click> handler | |
.insert(On::<Click>::new( | |
move |In(click_event): In<Click>, // the first parameter is In<Click> | |
mut this: Query<(&mut CounterState, &Children)>, // any other system params are fine | |
mut text_children: Query< | |
&mut Text, | |
(With<Parent>, Without<CounterState>), | |
>| { | |
if let Ok((mut counter_state, children)) = this.get_mut(click_event.target) | |
{ | |
counter_state.counter += 1; | |
bevy::log::info!( | |
"Button {target:?} clicked {count} time(s).", | |
target = click_event.target, | |
count = counter_state.counter | |
); | |
if let Ok(mut text) = | |
text_children.get_mut(*children.iter().next().unwrap()) | |
{ | |
text.sections[0].value = format!("{}", counter_state.counter); | |
} | |
} | |
}, | |
)) | |
.insert(On::<Mousedown>::new( | |
|In(mousedown_event): In<Mousedown>| { | |
bevy::log::info!( | |
"Button {target:?} (mousedown at {location:?}).", | |
target = mousedown_event.target, | |
location = mousedown_event.location, | |
) | |
}, | |
)) | |
.with_children(|parent| { | |
parent.spawn(TextBundle::from_section( | |
"0", | |
TextStyle { | |
font: asset_server.load("fonts/FiraSans-Bold.ttf"), | |
font_size: 40.0, | |
color: Color::rgb(0.9, 0.9, 0.9), | |
}, | |
)); | |
}); | |
}); | |
} | |
// -- Plugin -- | |
pub struct EventHandlerPlugin; | |
impl Plugin for EventHandlerPlugin { | |
fn build(&self, app: &mut App) { | |
app | |
// register events, these must also implement Target | |
.add_event::<Mousedown>() | |
.add_event::<Mouseup>() | |
.add_event::<Click>() | |
// register a system(s) to emit events | |
.add_systems( | |
PreUpdate, | |
emit_ui_click_events_system | |
.in_set(UiSystem::Focus) | |
.after(InputSystem), | |
) | |
// register the generic `run_handlers::<Event>` system for each event | |
.add_systems(PostUpdate, run_handlers::<Mousedown>) | |
.add_systems(PostUpdate, run_handlers::<Mouseup>) | |
.add_systems(PostUpdate, run_handlers::<Click>); | |
} | |
} | |
// -- UI mouse interaction events -- | |
/// Mousedown fires when mouse is just pressed and target is under the cursor | |
#[derive(Clone)] | |
pub struct Mousedown { | |
pub target: Entity, | |
pub location: Vec2, | |
} | |
impl Target for Mousedown { | |
fn target(&self) -> Entity { | |
self.target | |
} | |
} | |
/// Mouseup fires when mouse is just released and target is under the cursor | |
#[derive(Clone)] | |
pub struct Mouseup { | |
pub target: Entity, | |
pub location: Vec2, | |
} | |
impl Target for Mouseup { | |
fn target(&self) -> Entity { | |
self.target | |
} | |
} | |
/// Click fires when mousedown target and mouseup target are the same | |
#[derive(Clone)] | |
pub struct Click { | |
pub target: Entity, | |
pub location: Vec2, | |
} | |
impl Target for Click { | |
fn target(&self) -> Entity { | |
self.target | |
} | |
} | |
// -- UI mouse interaction event emitting system -- | |
#[derive(Default)] | |
pub struct MouseState { | |
mousedown_target: Option<Entity>, | |
} | |
#[derive(WorldQuery)] | |
#[world_query(mutable)] | |
pub struct NodeQuery { | |
entity: Entity, | |
node: &'static Node, | |
global_transform: &'static GlobalTransform, | |
relative_cursor_position: Option<&'static mut RelativeCursorPosition>, | |
focus_policy: Option<&'static FocusPolicy>, | |
calculated_clip: Option<&'static CalculatedClip>, | |
computed_visibility: Option<&'static ComputedVisibility>, | |
} | |
/// adapted from [`bevy::ui::ui_focus_system`] | |
pub fn emit_ui_click_events_system( | |
mut state: Local<MouseState>, | |
mut clicks: EventWriter<Click>, | |
mut mousedowns: EventWriter<Mousedown>, | |
mut mouseups: EventWriter<Mouseup>, | |
camera: Query<(&Camera, Option<&UiCameraConfig>)>, | |
windows: Query<&Window>, | |
mouse_button_input: Res<Input<MouseButton>>, | |
touches_input: Res<Touches>, | |
ui_stack: Res<UiStack>, | |
mut node_query: Query<NodeQuery>, | |
primary_window: Query<Entity, With<PrimaryWindow>>, | |
) { | |
let primary_window = primary_window.iter().next(); | |
let mouse_released = | |
mouse_button_input.just_released(MouseButton::Left) || touches_input.any_just_released(); | |
let mouse_pressed = | |
mouse_button_input.just_pressed(MouseButton::Left) || touches_input.any_just_pressed(); | |
let is_ui_disabled = | |
|camera_ui| matches!(camera_ui, Some(&UiCameraConfig { show_ui: false, .. })); | |
let cursor_position = camera | |
.iter() | |
.filter(|(_, camera_ui)| !is_ui_disabled(*camera_ui)) | |
.filter_map(|(camera, _)| { | |
if let Some(NormalizedRenderTarget::Window(window_ref)) = | |
camera.target.normalize(primary_window) | |
{ | |
Some(window_ref) | |
} else { | |
None | |
} | |
}) | |
.find_map(|window_ref| { | |
windows | |
.get(window_ref.entity()) | |
.ok() | |
.and_then(|window| window.cursor_position()) | |
}) | |
.or_else(|| touches_input.first_pressed_position()); | |
// prepare an iterator that contains all the nodes that have the cursor in their rect, | |
// from the top node to the bottom one. this will also reset the interaction to `None` | |
// for all nodes encountered that are no longer hovered. | |
let mut hovered_nodes = ui_stack | |
.uinodes | |
.iter() | |
// reverse the iterator to traverse the tree from closest nodes to furthest | |
.rev() | |
.filter_map(|entity| { | |
if let Ok(node) = node_query.get_mut(*entity) { | |
// Nodes that are not rendered should not be interactable | |
if let Some(computed_visibility) = node.computed_visibility { | |
if !computed_visibility.is_visible() { | |
return None; | |
} | |
} | |
let position = node.global_transform.translation(); | |
let ui_position = position.truncate(); | |
let extents = node.node.size() / 2.0; | |
let mut min = ui_position - extents; | |
if let Some(clip) = node.calculated_clip { | |
min = Vec2::max(min, clip.clip.min); | |
} | |
// The mouse position relative to the node | |
// (0., 0.) is the top-left corner, (1., 1.) is the bottom-right corner | |
let relative_cursor_position = cursor_position.map(|cursor_position| { | |
Vec2::new( | |
(cursor_position.x - min.x) / node.node.size().x, | |
(cursor_position.y - min.y) / node.node.size().y, | |
) | |
}); | |
// If the current cursor position is within the bounds of the node, consider it for | |
// clicking | |
let relative_cursor_position_component = RelativeCursorPosition { | |
normalized: relative_cursor_position, | |
}; | |
let contains_cursor = relative_cursor_position_component.mouse_over(); | |
// Save the relative cursor position to the correct component | |
if let Some(mut node_relative_cursor_position_component) = | |
node.relative_cursor_position | |
{ | |
*node_relative_cursor_position_component = relative_cursor_position_component; | |
} | |
if contains_cursor { | |
Some(*entity) | |
} else { | |
None | |
} | |
} else { | |
None | |
} | |
}) | |
.collect::<Vec<Entity>>() | |
.into_iter(); | |
// set Clicked or Hovered on top nodes. as soon as a node with a `Block` focus policy is detected, | |
// the iteration will stop on it because it "captures" the interaction. | |
let mut iter = node_query.iter_many_mut(hovered_nodes.by_ref()); | |
while let Some(node) = iter.fetch_next() { | |
if mouse_pressed { | |
state.mousedown_target = Some(node.entity); | |
// send mousedown event | |
mousedowns.send(Mousedown { | |
target: node.entity, | |
location: cursor_position.unwrap(), | |
}); | |
} | |
if mouse_released { | |
// send mouseup event | |
mouseups.send(Mouseup { | |
target: node.entity, | |
location: cursor_position.unwrap(), | |
}); | |
if state.mousedown_target == Some(node.entity) { | |
// send click event | |
clicks.send(Click { | |
target: node.entity, | |
location: cursor_position.unwrap(), | |
}); | |
} | |
} | |
// TODO: Focus policy / more than one | |
match node.focus_policy.unwrap_or(&FocusPolicy::Block) { | |
FocusPolicy::Block => { | |
break; | |
} | |
FocusPolicy::Pass => { /* allow the next node to be hovered/clicked */ } | |
} | |
} | |
if mouse_released { | |
// always reset mousedown target to none when released | |
state.mousedown_target = None; | |
} | |
} | |
// -- Generic event handling logic -- | |
/// This trait must be implemented for event handling types, pointing to the target of the event (the owner of the event handler) | |
pub trait Target { | |
fn target(&self) -> Entity; | |
} | |
#[derive(Default)] | |
pub struct Handler { | |
system: Option<BoxedSystem>, | |
initialized: bool, | |
} | |
impl Handler { | |
fn new<Ev: Clone + Event + Target, Params>(this: impl IntoSystem<Ev, (), Params>) -> Self { | |
Self { | |
system: Some(Box::new(IntoSystem::into_system( | |
inject_event_into_system.pipe(this), | |
))), | |
initialized: false, | |
} | |
} | |
fn run(&mut self, world: &mut World) { | |
if let Some(this) = &mut self.system { | |
if !self.initialized { | |
this.initialize(world); | |
self.initialized = true; | |
} | |
this.run((), world); | |
}; | |
} | |
} | |
#[derive(Resource)] | |
struct CurrentEvent<Ev>(Ev); | |
fn inject_event_into_system<Ev: Clone + Event + Target>(current: Res<CurrentEvent<Ev>>) -> Ev { | |
current.0.clone() | |
} | |
/// Register the `On<Event>` component on your entities, with a handler. | |
/// The handler is a system that runs when an event is fired, where the first parameter is an `In<Event>` | |
/// and all other parameters are normal Bevy system parameters. | |
#[derive(Component)] | |
struct On<Ev: Clone + Event + Target>(Handler, std::marker::PhantomData<Ev>); | |
impl<Ev: Clone + Event + Target> On<Ev> { | |
fn new<Params>(this: impl IntoSystem<Ev, (), Params>) -> Self { | |
Self(Handler::new(this), std::marker::PhantomData) | |
} | |
} | |
impl<Ev: Clone + Event + Target> Default for On<Ev> { | |
fn default() -> Self { | |
Self(Handler::default(), std::marker::PhantomData) | |
} | |
} | |
// We need to cache system state, otherwise the event reader will read the resource twice | |
#[derive(Resource)] | |
struct CachedSystemState<Ev: Clone + Event + Target> { | |
state: SystemState<( | |
EventReader<'static, 'static, Ev>, | |
Query<'static, 'static, &'static mut On<Ev>>, | |
)>, | |
} | |
pub fn run_handlers<Ev: Clone + Event + Target>(world: &mut World) { | |
if !world.contains_resource::<CachedSystemState<Ev>>() { | |
let state = SystemState::<(EventReader<Ev>, Query<&mut On<Ev>>)>::new(world); | |
world.insert_resource(CachedSystemState { state }); | |
}; | |
// We need to use a resource scope, to pull the cached system state out of the world | |
world.resource_scope(|world, mut system_state: Mut<CachedSystemState<Ev>>| { | |
let system_state = &mut system_state.state; | |
let (mut click_events, mut click_handlers) = system_state.get_mut(world); | |
let mut handlers_to_run = Vec::with_capacity(click_handlers.iter().count()); | |
// Take the handlers out of the world | |
for ev in click_events.iter() { | |
if let Ok(mut handler) = click_handlers.get_mut(ev.target()) { | |
handlers_to_run.push((ev.clone(), std::mem::take(&mut *handler))); | |
}; | |
} | |
// Run the handlers on the world | |
for (ev, handler) in handlers_to_run.iter_mut() { | |
{ | |
world.insert_resource(CurrentEvent(ev.clone())); | |
} | |
handler.0.run(world); | |
} | |
// Put the handlers back into the world | |
let (_, mut click_handlers) = system_state.get_mut(world); | |
for (ev, used_handler) in handlers_to_run { | |
if let Ok(mut handler) = click_handlers.get_mut(ev.target()) { | |
*handler = used_handler; | |
} | |
} | |
}); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment