Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an Activate triggered event to bevy_ui #17267

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/bevy_ui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ bevy_ecs = { path = "../bevy_ecs", version = "0.16.0-dev" }
bevy_hierarchy = { path = "../bevy_hierarchy", version = "0.16.0-dev" }
bevy_image = { path = "../bevy_image", version = "0.16.0-dev" }
bevy_input = { path = "../bevy_input", version = "0.16.0-dev" }
bevy_input_focus = { path = "../bevy_input_focus", version = "0.16.0-dev" }
bevy_math = { path = "../bevy_math", version = "0.16.0-dev" }
bevy_reflect = { path = "../bevy_reflect", version = "0.16.0-dev", features = [
"bevy",
Expand Down
152 changes: 152 additions & 0 deletions crates/bevy_ui/src/actions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//! Semantically meaningful actions that can be performed on UI elements.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see why you're adding this, but it feels like it might be too opinionated. A generic "activate" action seems like it would be context specific. For example, I can easily imagine a button that wants to "activate" on release, that only "activates" while held, or only if it is held for a period of time, or that wants to activate even if it is not focused, or a widget that cannot be "activated" at all.

If we're going to go this route, I think we should embrace it as a "high level focused button system" that uses familiar and less opinionated terminology and encapsulates the whole lifecycle (ex: something like Pressed / Released or FocusedPress/FocusedRelease), and makes that state poll-able (to allow people to distinguish between "just pressed" and "currently pressed" each frame). And it would need to be able to accommodate things like "firing the release event for an entity if focus changes while pressed".

I also think that a lot of the utility / UX improvement of this impl comes from performing an action based on multiple input types (aka this is providing the same utility that an action manager would provide).

Investing in an upstream action system would let each widget express things like:

if actions.just_pressed(UiSelect) {
  if let Some(entity) = input_focus.get() {
    // do_thing
  }
}

if actions.just_released(UiSelect) {
  if let Some(entity) = input_focus.get() {
    // do_thing
  }
}

From there, we could build the "high level entity focus based virtual button system" on top:

entity.observe(|trigger: Trigger<FocusedPress>| {
})

entity.observe(|trigger: Trigger<FocusedRelease>| {
})

However I think introducing this at all seriously complicates the "event handling story", as users now need to decide to listen for either Pointer<Pressed> or FocusedPress. These are two separate generic input event systems that both perform very similar roles (and Pointer<Pressed> directly drives the focus system). It also means that people that want pointer information in the context of FocusedPress would need to grab that from somewhere else. I'm thinking a "virtual input-action-driven Pointer", as you used #17224 is probably the better move, as it allows everyone to snap to the single, already-generic pointer API. All of the boilerplate of producing the generic pointer info would be internal bevy logic (so not user facing), and it would all serve a purpose (people want to able to write logic about pointer position, read hit data, etc).

Imagine a button that when pressed, applies a visual effect at the point it was pressed at. If a context-free event like FocusedPress is used, they would need to somehow grab the current pointer state separately (if it was driven by a pointer) and if it was driven by a button, pick some fall back logic. By embracing Pointer as the "one api" and implementing a virtual pointer system, we can make an opinionated choice about where it exists on screen (ex: the "middle" of the focused UI element). Then consumers of the API can blissfully code to the Pointer api (and receive all of its benefits). This also allows the "virtual pointer" behavior to be user-configurable. Ex: maybe they want to trigger the "middle pointer click" on a given input action. And by nature of supporting "multiple pointers", we get all of the same behaviors "for free" / they behave consistently (ex: the most recent pointer change "stealing" focus), without users needing to think about supporting both use cases.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in agreement with you here, at least on the first part. I'm OK with the concept of an Activate event, but I think that the decision as to when it should be triggered is widget-specific.

Consider the case of a checkbox: emitting an Activate event is not all that useful, what you really want is a ValueChanged(bool) event. A slider emits a ValueChanged(f32) and so on. Different widgets will emit different "semantic" events which are up-conversions of low-level "physical" events.

Different button behaviors that you describe (activate on middle button and so on) could easily be implemented as distinct widget types - it's just a set of observers. However, we don't want developers to have to constantly re-implement Button or Checkbox behaviors, because the "correct" behaviors are subtle and easy to get wrong, especially when we add a11y into the mix.

This is why I have been lobbying for a collection of "core widgets" in Bevy. These would be "headless" (unstyled) widgets that implement PushButton, Checkbox, Slider, Spinbox and other behaviors, and would include the proper focus handling and accessibility goodness.

These headless widgets would be designed to work in conjunction with BSN or whatever templating system - they are just components after all, with a handful of registered observers to update the component state and re-emit events.

Such widgets could use Activate and ValueChanged<T>, so I think it's helpful to define these events in a standard way; but I don't like the idea of triggering them broadly.

//!
//! Rather than listening for raw keyboard or picking events, UI elements should listen to these actions
//! for all functional behavior. This allows for more consistent behavior across different input devices
//! and makes it easier to customize input mappings.
//!
//! By contrast, cosmetic behavior like hover effects should generally be implemented by reading the [`Interaction`](crate::focus::Interaction) component,
//! the [`InputFocus`](bevy_input_focus::InputFocus) resource or in response to various [`Pointer`](bevy_picking::events::Pointer) events.
//!
//! # Event bubbling
//!
//! All of the events in this module are will automatically bubble up the entity hierarchy.
//! This allows for more responsiveness to the users' input, as the event will be
//! consumed by the first entity that cares about it.
//!
//! When responding to these events, make sure to call [`Trigger::propagate`] with `false`
//! to prevent the event from being consumed by other later entities.
//!
//! # Systems
//!
//! Various public systems are provided to trigger these actions in response to raw input events.
//! These systems run in [`PreUpdate`](bevy_app::main_schedule::PreUpdate) as part of [`UiSystem::Actions`](crate::UiSystem::Actions).
//! They are all enabled by default in the [`UiPlugin`](crate::UiPlugin),
//! but are split apart for more control over when / if they are run via run conditions.
//!
//! To disable them entirely, set [`UiPlugin::actions`](crate::UiPlugin::actions) to `false`.

use bevy_ecs::prelude::*;
use bevy_hierarchy::Parent;
use bevy_input::{
gamepad::{Gamepad, GamepadButton},
keyboard::KeyCode,
ButtonInput,
};
use bevy_input_focus::InputFocus;
use bevy_picking::events::{Click, Pointer};
use bevy_reflect::prelude::*;

use crate::Node;

/// A system which triggers the [`Activate`](crate::Activate) action
/// when an entity with the [`Node`] component is clicked.
pub fn activate_ui_elements_on_click(
mut click_events: EventReader<Pointer<Click>>,
node_query: Query<(), With<Node>>,
mut commands: Commands,
) {
for click in click_events.read() {
if node_query.contains(click.target) {
commands.trigger_targets(Activate, click.target);
}
}
}

/// A system which activates the [`Activate`](crate::Activate) action
/// when [`KeyCode::Enter`] is first pressed.
pub fn activate_focus_on_enter(
keyboard_input: Res<ButtonInput<KeyCode>>,
input_focus: Res<InputFocus>,
mut commands: Commands,
) {
if keyboard_input.just_pressed(KeyCode::Enter) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard-coding these to specific inputs isn't ideal. Seems like this is begging for an input-manager-defined remappable action 😄

if let Some(focused_entity) = input_focus.get() {
commands.trigger_targets(Activate, focused_entity);
}
}
}

/// A system which activates the [`Activate`](crate::Activate) action
/// when [`GamepadButton::South`] is first pressed on any controller.
///
/// This system is generally not suitable for local co-op games,
/// as *any* gamepad can activate the focused element.
///
/// # Warning
///
/// Note that for Nintendo Switch controllers, the "A" button (commonly used as "activate"),
/// is *not* the South button. It's instead the [`GamepadButton::East`].
pub fn activate_focus_on_gamepad_south(
input_focus: Res<InputFocus>,
gamepads: Query<&Gamepad>,
mut commands: Commands,
) {
for gamepad in gamepads.iter() {
if gamepad.just_pressed(GamepadButton::South) {
if let Some(focused_entity) = input_focus.get() {
commands.trigger_targets(Activate, focused_entity);
// Only send one activate event per frame,
// even if multiple gamepads pressed the button.
return;
}
}
}
}

/// A system which activates the [`Activate`](crate::Activate) action
/// when the [`GamepadButtonType::South`] is pressed.

/// Activate a UI element.
///
/// This is typically triggered by a mouse click (or press),
/// the enter key press on the focused element,
/// or the "A" button on a gamepad.
///
/// [`Button`](crate::widget::Button)s should respond to this action via an observer to perform their primary action.
///
/// # Bubbling
///
/// This event will bubble up the entity hierarchy.
/// Make sure to call [`Trigger::propagate`] with `false` to prevent the event from being consumed by other later entities.
///
/// # Example
///
/// ```rust
/// use bevy_ecs::prelude::*;
/// use bevy_input_focus::InputFocus;
/// use bevy_ui::Activate;
/// use bevy_input::keyboard::KeyCode;
///
/// // This system is already added to the `UiPlugin` by default.
/// fn activate_focus_on_enter(keyboard_input: Res<ButtonInput<KeyCode>>, input_focus: Res<InputFocus>, mut commands: Commands) {
/// if keyboard_input.just_pressed(KeyCode::Enter) {
/// if let Some(focused_entity) = input_focus.get() {
/// commands.trigger_targets(Activate, focused_entity);
/// }
/// }
/// }
///
/// fn spawn_my_button(mut commands: Commands) {
/// // This observer will only watch this entity;
/// // use a global observer to respond to *any* Activate event.
/// commands.spawn(Button).observe(activate_my_button);
/// }
///
/// fn activate_my_button(trigger: Trigger<Activate>) {
/// let button_entity = trigger.target();
/// println!("The button with the entity ID {button_entity} was activated!");
/// // We've handled the event, so don't let it bubble up.
/// trigger.propagate(false);
/// }
///
/// # assert_is_system!(activate_focus_on_enter);
/// # assert_is_system!(spawn_my_button);
/// ```
#[derive(Component, Debug, Default, Copy, Clone, PartialEq, Eq, Hash, Reflect)]
#[reflect(Component, Default, PartialEq, Hash)]
pub struct Activate;

impl Event for Activate {
type Traversal = &'static Parent;
const AUTO_PROPAGATE: bool = true;
}
30 changes: 30 additions & 0 deletions crates/bevy_ui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ mod render;
mod stack;
mod ui_node;

pub mod actions;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "pub use module::*`" style below is pretty terrible for documentation: both because "pile of stuff" and because it breaks module docs.

I've broken from convention here: I would like to change the other modules in this crate in another PR.


pub use focus::*;
pub use geometry::*;
pub use layout::*;
Expand Down Expand Up @@ -83,10 +85,18 @@ use update::{update_clipping_system, update_target_camera_system};
pub struct UiPlugin {
/// If set to false, the UI's rendering systems won't be added to the `RenderApp` and no UI elements will be drawn.
/// The layout and interaction components will still be updated as normal.
///
/// Default is true.
pub enable_rendering: bool,
/// Whether to add the UI picking backend to the app.
///
/// Default is true.
#[cfg(feature = "bevy_ui_picking_backend")]
pub add_picking: bool,
/// If set to true, bevy_ui will listen for input events and send them to UI entities.
///
/// Default is true.
pub actions: bool,
}

impl Default for UiPlugin {
Expand All @@ -95,6 +105,7 @@ impl Default for UiPlugin {
enable_rendering: true,
#[cfg(feature = "bevy_ui_picking_backend")]
add_picking: true,
actions: true,
}
}
}
Expand All @@ -106,6 +117,10 @@ pub enum UiSystem {
///
/// Runs in [`PreUpdate`].
Focus,
/// Various UI-centric actions, such as activating buttons, are computed from input.
///
/// Runs in [`PreUpdate`], after [`InputSystem`].
Actions,
/// All UI systems in [`PostUpdate`] will run in or after this label.
Prepare,
/// After this label, the ui layout state has been updated.
Expand Down Expand Up @@ -149,6 +164,7 @@ impl Plugin for UiPlugin {
app.init_resource::<UiSurface>()
.init_resource::<UiScale>()
.init_resource::<UiStack>()
.register_type::<actions::Activate>()
.register_type::<BackgroundColor>()
.register_type::<CalculatedClip>()
.register_type::<ComputedNode>()
Expand Down Expand Up @@ -187,6 +203,20 @@ impl Plugin for UiPlugin {
ui_focus_system.in_set(UiSystem::Focus).after(InputSystem),
);

if self.actions {
app.configure_sets(PreUpdate, UiSystem::Actions.after(InputSystem));

app.add_systems(
PreUpdate,
(
actions::activate_focus_on_enter,
actions::activate_ui_elements_on_click,
actions::activate_focus_on_gamepad_south,
)
.in_set(UiSystem::Actions),
);
}

let ui_layout_system_config = ui_layout_system
.in_set(UiSystem::Layout)
.before(TransformSystem::TransformPropagate);
Expand Down
4 changes: 3 additions & 1 deletion crates/bevy_ui/src/widget/button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ use bevy_ecs::{
};
use bevy_reflect::{std_traits::ReflectDefault, Reflect};

/// Marker struct for buttons
/// A marker struct for buttons.
///
/// Buttons should use an observer to listen for the [`Activate`](crate::Activate) action to perform their primary action.
#[derive(Component, Debug, Default, Clone, Copy, PartialEq, Eq, Reflect)]
#[reflect(Component, Default, Debug, PartialEq)]
#[require(Node, FocusPolicy(|| FocusPolicy::Block), Interaction)]
Expand Down
Loading