diff --git a/crates/bevy_ui/src/focus.rs b/crates/bevy_ui/src/focus.rs index bf01512d57b80..b0ec69cfd834e 100644 --- a/crates/bevy_ui/src/focus.rs +++ b/crates/bevy_ui/src/focus.rs @@ -2,12 +2,12 @@ use crate::{CalculatedClip, Node}; use bevy_core::FloatOrd; use bevy_ecs::{ entity::Entity, - prelude::Component, + prelude::{Component, Mut}, reflect::ReflectComponent, - system::{Local, Query, Res}, + system::{Local, Query, Res, Resource}, }; use bevy_input::{mouse::MouseButton, touch::Touches, Input}; -use bevy_math::Vec2; +use bevy_math::{Vec2, Vec3}; use bevy_reflect::{Reflect, ReflectDeserialize}; use bevy_transform::components::GlobalTransform; use bevy_window::Windows; @@ -56,82 +56,77 @@ pub struct State { entities_to_reset: SmallVec<[Entity; 1]>, } +pub type NodeQuery<'a> = ( + Entity, + &'a Node, + &'a GlobalTransform, + &'a mut Interaction, + Option<&'a FocusPolicy>, + Option<&'a CalculatedClip>, +); + /// The system that sets Interaction for all UI elements based on the mouse cursor activity #[allow(clippy::type_complexity)] pub fn ui_focus_system( - mut state: Local, + state: Local, windows: Res, mouse_button_input: Res>, touches_input: Res, - mut node_query: Query<( + node_query: Query<( Entity, &Node, &GlobalTransform, - Option<&mut Interaction>, + &mut Interaction, Option<&FocusPolicy>, Option<&CalculatedClip>, )>, ) { - let cursor_position = windows - .get_primary() - .and_then(|window| window.cursor_position()); - - // reset entities that were both clicked and released in the last frame - for entity in state.entities_to_reset.drain(..) { - if let Ok(mut interaction) = node_query.get_component_mut::(entity) { - *interaction = Interaction::None; - } - } + focus_ui( + state, + windows, + mouse_button_input, + touches_input, + node_query, + ) +} - let mouse_released = - mouse_button_input.just_released(MouseButton::Left) || touches_input.just_released(0); - if mouse_released { - for (_entity, _node, _global_transform, interaction, _focus_policy, _clip) in - node_query.iter_mut() - { - if let Some(mut interaction) = interaction { - if *interaction == Interaction::Clicked { - *interaction = Interaction::None; - } - } - } - } +#[allow(clippy::type_complexity)] +fn focus_ui( + mut state: Local, + windows: Res, + mouse_button_input: Res>, + touches_input: Res, + mut node_query: Query<( + Entity, + &Node, + &GlobalTransform, + &mut Interaction, + Option<&FocusPolicy>, + Option<&CalculatedClip>, + )>, +) { + reset_interactions( + &mut node_query, + &mouse_button_input, + &touches_input, + &windows.get_cursor_position(), + &mut state, + ); - let mouse_clicked = - mouse_button_input.just_pressed(MouseButton::Left) || touches_input.just_released(0); + let cursor_position = match windows.get_cursor_position() { + None => return, + Some(cursor_position) => cursor_position, + }; let mut moused_over_z_sorted_nodes = node_query .iter_mut() .filter_map( |(entity, node, global_transform, interaction, focus_policy, clip)| { let position = global_transform.translation; - let ui_position = position.truncate(); - let extents = node.size / 2.0; - let mut min = ui_position - extents; - let mut max = ui_position + extents; - if let Some(clip) = clip { - min = Vec2::max(min, clip.clip.min); - max = Vec2::min(max, clip.clip.max); - } - // if the current cursor position is within the bounds of the node, consider it for - // clicking - let contains_cursor = if let Some(cursor_position) = cursor_position { - (min.x..max.x).contains(&cursor_position.x) - && (min.y..max.y).contains(&cursor_position.y) - } else { - false - }; - if contains_cursor { + if contains_cursor(&cursor_position, node, position, clip) { Some((entity, focus_policy, interaction, FloatOrd(position.z))) } else { - if let Some(mut interaction) = interaction { - if *interaction == Interaction::Hovered - || (cursor_position.is_none() && *interaction != Interaction::None) - { - *interaction = Interaction::None; - } - } None } }, @@ -140,23 +135,88 @@ pub fn ui_focus_system( moused_over_z_sorted_nodes.sort_by_key(|(_, _, _, z)| -*z); - let mut moused_over_z_sorted_nodes = moused_over_z_sorted_nodes.into_iter(); - // set Clicked or Hovered on top nodes - for (entity, focus_policy, interaction, _) in moused_over_z_sorted_nodes.by_ref() { - if let Some(mut interaction) = interaction { - if mouse_clicked { - // only consider nodes with Interaction "clickable" - if *interaction != Interaction::Clicked { - *interaction = Interaction::Clicked; - // if the mouse was simultaneously released, reset this Interaction in the next - // frame - if mouse_released { - state.entities_to_reset.push(entity); - } - } - } else if *interaction == Interaction::None { - *interaction = Interaction::Hovered; + set_top_nodes_as_clicked_or_hovered( + moused_over_z_sorted_nodes, + mouse_button_input, + touches_input, + state, + ) +} + +fn contains_cursor( + cursor_position: &Vec2, + node: &Node, + position: Vec3, + clip: Option<&CalculatedClip>, +) -> bool { + let ui_position = position.truncate(); + let extents = node.size / 2.0; + let mut min = ui_position - extents; + let mut max = ui_position + extents; + if let Some(clip) = clip { + min = Vec2::max(min, clip.clip.min); + max = Vec2::min(max, clip.clip.max); + } + + (min.x..max.x).contains(&cursor_position.x) && (min.y..max.y).contains(&cursor_position.y) +} + +fn reset_interactions( + node_query: &mut Query<( + Entity, + &Node, + &GlobalTransform, + &mut Interaction, + Option<&FocusPolicy>, + Option<&CalculatedClip>, + )>, + mouse_button_input: &Input, + touches_input: &Touches, + cursor_position: &Option, + state: &mut State, +) { + let mouse_release = + mouse_button_input.just_released(MouseButton::Left) || touches_input.just_released(0); + let input_should_leave_button_clicked = cursor_position.is_some() && !mouse_release; + + for (_entity, _node, _global_transform, mut interaction, _focus_policy, _clip) in + node_query.iter_mut() + { + if input_should_leave_button_clicked && *interaction == Interaction::Clicked { + continue; + } + *interaction = Interaction::None; + } + + // reset entities that were both clicked and released in the last frame + for entity in state.entities_to_reset.drain(..) { + if let Ok(mut interaction) = node_query.get_component_mut::(entity) { + *interaction = Interaction::None; + } + } +} + +fn set_top_nodes_as_clicked_or_hovered( + moused_over_z_sorted_nodes: Vec<(Entity, Option<&FocusPolicy>, Mut, FloatOrd)>, + mouse_button_input: Res>, + touches_input: Res, + mut state: Local, +) { + let mouse_released = + mouse_button_input.just_released(MouseButton::Left) || touches_input.just_released(0); + + let mouse_clicked = + mouse_button_input.just_pressed(MouseButton::Left) || touches_input.just_released(0); + + for (entity, focus_policy, mut interaction, _) in moused_over_z_sorted_nodes { + if mouse_clicked { + // if the mouse was simultaneously released, reset this Interaction in the next frame + if *interaction != Interaction::Clicked && mouse_released { + state.entities_to_reset.push(entity); } + *interaction = Interaction::Clicked; + } else if *interaction == Interaction::None { + *interaction = Interaction::Hovered; } match focus_policy.cloned().unwrap_or(FocusPolicy::Block) { @@ -166,12 +226,458 @@ pub fn ui_focus_system( FocusPolicy::Pass => { /* allow the next node to be hovered/clicked */ } } } - // reset lower nodes to None - for (_entity, _focus_policy, interaction, _) in moused_over_z_sorted_nodes { - if let Some(mut interaction) = interaction { - if *interaction != Interaction::None { - *interaction = Interaction::None; +} + +trait CursorResource: Resource { + fn get_cursor_position(&self) -> Option; +} + +impl CursorResource for Windows { + fn get_cursor_position(&self) -> Option { + self.get_primary() + .and_then(|window| window.cursor_position()) + } +} + +#[cfg(test)] +mod tests { + use bevy_app::App; + use bevy_ecs::event::Events; + use bevy_ecs::prelude::ParallelSystemDescriptorCoercion; + use bevy_ecs::query::Changed; + use bevy_input::touch::{touch_screen_input_system, TouchInput, TouchPhase}; + use bevy_math::Vec3; + + use super::*; + + const NODE_SIZE: f32 = 5.0; + + #[test] + fn test_sets_hovered_nodes() { + let test_sets = vec![ + vec![(None, Interaction::None)], + vec![(Some((0., 0.)), Interaction::None)], + vec![(Some((10., 10.)), Interaction::Hovered)], + vec![ + (Some((10., 10.)), Interaction::Hovered), + (Some((0., 0.)), Interaction::None), + ], + vec![ + (Some((10., 10.)), Interaction::Hovered), + (None, Interaction::None), + ], + ]; + + for test_set in test_sets { + test_hovered_nodes(test_set); + } + } + + fn test_hovered_nodes(test_set: Vec<(Option<(f32, f32)>, Interaction)>) { + let mut app = TestApp::new(); + let entity = app.spawn_node_entity_at(10., 10.); + + for (cursor_position, expected_interaction) in test_set { + app.set_cursor_position(cursor_position); + + app.run_step(); + + let interaction = app.get_interaction(entity); + assert_eq!( + &expected_interaction, interaction, + "for position {:?}", + cursor_position, + ); + } + } + + #[test] + fn test_sets_clicked_nodes() { + let test_sets_mouse = vec![ + vec![(None, Interaction::None)], + vec![(Some((0., 0.)), Interaction::None)], + vec![(Some((10., 10.)), Interaction::Clicked)], + vec![ + (Some((10., 10.)), Interaction::Clicked), + (Some((0., 0.)), Interaction::Clicked), + ], + vec![ + (Some((10., 10.)), Interaction::Clicked), + (None, Interaction::None), + ], + ]; + let test_sets_touch = vec![ + vec![(None, Interaction::None)], + vec![(Some((0., 0.)), Interaction::None)], + vec![(Some((10., 10.)), Interaction::Clicked)], + vec![ + (Some((10., 10.)), Interaction::Clicked), + (Some((0., 0.)), Interaction::None), + ], + vec![ + (Some((10., 10.)), Interaction::Clicked), + (None, Interaction::None), + ], + ]; + + for mouse_test_set in test_sets_mouse { + test_clicked_nodes(mouse_test_set, false); + } + for touch_test_set in test_sets_touch { + test_clicked_nodes(touch_test_set, true); + } + } + + fn test_clicked_nodes(test_set: Vec<(Option<(f32, f32)>, Interaction)>, touch: bool) { + let mut app = TestApp::new(); + let entity = app.spawn_node_entity_at(10., 10.); + + for (cursor_position, expected_interaction) in test_set { + app.set_cursor_position(cursor_position); + if touch { + app.set_screen_touched(); + } else { + app.set_mouse_clicked(); } + + app.run_step(); + + let interaction = app.get_interaction(entity); + assert_eq!( + &expected_interaction, interaction, + "for position {:?}", + cursor_position, + ); + } + } + + #[test] + fn test_sets_click_stacked_nodes() { + let test_sets = vec![ + (None, Interaction::None), + (Some(FocusPolicy::Block), Interaction::None), + (Some(FocusPolicy::Pass), Interaction::Clicked), + ]; + + for (focus_policy, expected_interaction) in test_sets { + test_click_stacked_nodes(focus_policy, expected_interaction); + } + } + + fn test_click_stacked_nodes( + focus_policy: Option, + expected_interaction: Interaction, + ) { + let mut app = TestApp::new(); + let background_entity = app.spawn_node_entity_with_z_at(10., 10., 0., focus_policy); + let foreground_entity = app.spawn_node_entity_with_z_at(10., 10., 5., focus_policy); + + app.set_cursor_position(Some((10., 10.))); + app.set_mouse_clicked(); + + app.run_step(); + + assert_eq!( + &Interaction::Clicked, + app.get_interaction(foreground_entity) + ); + assert_eq!( + &expected_interaction, + app.get_interaction(background_entity) + ); + } + + #[test] + fn hover_one_node_then_click_the_other_where_both_overlap() { + let mut app = TestApp::new(); + let background_node_position = 8.; + let background_entity = app.spawn_node_entity_with_z_at( + background_node_position, + background_node_position, + 0., + Some(FocusPolicy::Block), + ); + let foreground_entity = + app.spawn_node_entity_with_z_at(10., 10., 5., Some(FocusPolicy::Block)); + + app.set_cursor_position(Some((6., 6.))); + + app.run_step(); + + assert_eq!(&Interaction::None, app.get_interaction(foreground_entity)); + assert_eq!( + &Interaction::Hovered, + app.get_interaction(background_entity) + ); + + app.set_cursor_position(Some((background_node_position, background_node_position))); + app.set_mouse_clicked(); + + app.run_step(); + + assert_eq!( + &Interaction::Clicked, + app.get_interaction(foreground_entity) + ); + assert_eq!(&Interaction::None, app.get_interaction(background_entity)); + } + + #[test] + fn click_then_move_away_and_release_mouse_button() { + let mut app = TestApp::new(); + let entity = app.spawn_node_entity_at(10., 10.); + + app.set_cursor_position(Some((10., 10.))); + app.set_mouse_clicked(); + + app.run_step(); + assert_eq!(&Interaction::Clicked, app.get_interaction(entity)); + + app.set_cursor_position(Some((0., 0.))); + + app.run_step(); + assert_eq!(&Interaction::Clicked, app.get_interaction(entity)); + + app.set_mouse_released(); + + app.run_step(); + assert_eq!(&Interaction::None, app.get_interaction(entity)); + } + + #[test] + fn click_and_keep_pressed() { + let mut app = TestApp::new(); + let entity = app.spawn_node_entity_at(10., 10.); + app.set_cursor_position(Some((10., 10.))); + app.set_mouse_clicked(); + + app.run_step(); + assert_eq!(&Interaction::Clicked, app.get_interaction(entity)); + + app.run_step(); + assert_eq!(&Interaction::Clicked, app.get_interaction(entity)); + } + + #[test] + fn click_and_release() { + let mut app = TestApp::new(); + let entity = app.spawn_node_entity_at(10., 10.); + app.set_cursor_position(Some((10., 10.))); + + app.set_mouse_clicked(); + app.run_step(); + assert_eq!(&Interaction::Clicked, app.get_interaction(entity)); + + app.set_mouse_released(); + app.run_step(); + assert_eq!(&Interaction::Hovered, app.get_interaction(entity)); + } + + #[test] + fn click_and_release_in_single_frame() { + let mut app = TestApp::new(); + let entity = app.spawn_node_entity_at(10., 10.); + app.set_cursor_position(Some((10., 10.))); + + app.set_mouse_clicked(); + app.set_mouse_released(); + app.run_step(); + assert_eq!(&Interaction::Clicked, app.get_interaction(entity)); + + app.run_step(); + assert_eq!(&Interaction::Hovered, app.get_interaction(entity)); + } + + #[test] + fn change_detection_journey() { + let mut app = TestApp::new(); + app.spawn_node_entity_at(10., 10.); + + app.run_step(); + + app.expect_no_changed_interaction("mouse does still not touch target"); + app.run_step(); + + app.set_cursor_position(Some((10., 10.))); + app.expect_changed_interaction("mouse hovers target"); + app.run_step(); + + app.expect_no_changed_interaction("mouse still hovers target"); + app.run_step(); + + app.set_mouse_clicked(); + app.expect_changed_interaction("mouse clicked target"); + app.run_step(); + + app.expect_no_changed_interaction("mouse button still clicked"); + app.run_step(); + + app.set_cursor_position(Some((0., 0.))); + app.expect_no_changed_interaction("mouse dragged away, but button still clicked"); + app.run_step(); + } + + struct TestApp { + app: App, + } + + impl TestApp { + fn new() -> TestApp { + let mut app = App::new(); + app.init_resource::>() + .init_resource::() + .init_resource::() + .init_resource::() + .add_event::() + .add_system(touch_screen_input_system.before("under_test")) + .add_system(focus_ui::.label("under_test")) + .add_system(watch_changes.after("under_test")); + + TestApp { app } + } + + fn set_cursor_position(&mut self, cursor_position: Option<(f32, f32)>) { + let cursor_position = cursor_position.map(|(x, y)| Vec2::new(x, y)); + self.app.insert_resource(WindowsDouble { cursor_position }); + } + + fn set_screen_touched(&mut self) { + self.app + .world + .get_resource_mut::>() + .unwrap() + .send(TouchInput { + phase: TouchPhase::Ended, + position: Default::default(), + force: None, + id: 0, + }) + } + + fn set_mouse_clicked(&mut self) { + let mut mouse_input = self + .app + .world + .get_resource_mut::>() + .unwrap(); + mouse_input.press(MouseButton::Left); + } + + fn set_mouse_released(&mut self) { + let mut mouse_input = self + .app + .world + .get_resource_mut::>() + .unwrap(); + mouse_input.release(MouseButton::Left); + } + + fn spawn_node_entity_at(&mut self, x: f32, y: f32) -> Entity { + self.app + .world + .spawn() + .insert(GlobalTransform { + translation: Vec3::new(x, y, 0.0), + ..GlobalTransform::default() + }) + .insert(Node { + size: Vec2::new(NODE_SIZE, NODE_SIZE), + }) + .insert(Interaction::None) + .id() + } + + fn spawn_node_entity_with_z_at( + &mut self, + x: f32, + y: f32, + z: f32, + focus_policy: Option, + ) -> Entity { + let mut entity = self.app.world.spawn(); + if let Some(focus_policy) = focus_policy { + entity.insert(focus_policy); + } + + entity + .insert(GlobalTransform { + translation: Vec3::new(x, y, z), + ..GlobalTransform::default() + }) + .insert(Node { + size: Vec2::new(NODE_SIZE, NODE_SIZE), + }) + .insert(Interaction::None) + .id() + } + + fn run_step(&mut self) { + self.app.schedule.run_once(&mut self.app.world); + + let mut mouse_input = self + .app + .world + .get_resource_mut::>() + .unwrap(); + mouse_input.clear(); + } + + fn get_interaction(&self, entity: Entity) -> &Interaction { + self.app.world.get::(entity).unwrap() + } + + fn expect_changed_interaction(&mut self, message: &str) { + let mut changed_interaction_expectation = self + .app + .world + .get_resource_mut::() + .unwrap(); + changed_interaction_expectation.0 = Some(true); + changed_interaction_expectation.1 = message.to_string(); + } + + fn expect_no_changed_interaction(&mut self, message: &str) { + let mut changed_interaction_expectation = self + .app + .world + .get_resource_mut::() + .unwrap(); + changed_interaction_expectation.0 = Some(false); + changed_interaction_expectation.1 = message.to_string(); + } + } + + #[derive(Default)] + struct ChangedInteractionExpectation(Option, String); + + fn watch_changes( + query: Query>, + expected_changed_interaction: Res, + ) { + match expected_changed_interaction.0 { + Some(true) => assert!( + query.iter().count() > 0, + "{}", + expected_changed_interaction.1.as_str() + ), + Some(false) => assert_eq!( + query.iter().count(), + 0, + "{}", + expected_changed_interaction.1.as_str() + ), + None => {} + } + } + + #[derive(Debug, Default)] + struct WindowsDouble { + cursor_position: Option, + } + + impl CursorResource for WindowsDouble { + fn get_cursor_position(&self) -> Option { + self.cursor_position } } }