diff --git a/crates/bevy_picking/src/events.rs b/crates/bevy_picking/src/events.rs index 25efafcfe2a95..252bc896f4dc3 100644 --- a/crates/bevy_picking/src/events.rs +++ b/crates/bevy_picking/src/events.rs @@ -63,6 +63,10 @@ use crate::{ pointer::{Location, PointerAction, PointerButton, PointerId, PointerInput, PointerMap}, }; +/// Maximum time between clicks for them to count as consecutive clicks. +// TODO: add optional feature-flagged support for fetching this from the OS preferences +pub const MULTI_CLICK_DURATION: Duration = Duration::from_millis(500); + /// Stores the common data needed for all pointer events. /// /// The documentation for the [`pointer_events`] explains the events this module exposes and @@ -312,6 +316,8 @@ pub struct Click { pub hit: HitData, /// Duration between the pointer pressed and lifted for this click pub duration: Duration, + /// Number of consecutive clicks, starting at `1`. + pub count: u8, } /// Fires while a pointer is moving over the [target entity](EntityEvent::event_target). @@ -470,6 +476,8 @@ pub struct Scroll { pub struct PointerButtonState { /// Stores the press location and start time for each button currently being pressed by the pointer. pub pressing: EntityHashMap<(Location, Instant, HitData)>, + /// Stores the latest click time and count for each clicked entity. + pub clicking: EntityHashMap<(Instant, u8)>, /// Stores the starting and current locations for each entity currently being dragged by the pointer. pub dragging: EntityHashMap, /// Stores the hit data for each entity currently being dragged over by the pointer. @@ -938,6 +946,9 @@ pub fn pointer_events( } PointerAction::Release(button) => { let state = pointer_state.get_mut(pointer_id, button); + state + .clicking + .retain(|_, (last_click, _)| now - *last_click <= MULTI_CLICK_DURATION); // Emit Click and Release events on all the previously hovered entities. for (hovered_entity, hit) in previous_hover_map @@ -947,6 +958,11 @@ pub fn pointer_events( { // If this pointer previously pressed the hovered entity, emit a Click event if let Some((_, press_instant, _)) = state.pressing.get(&hovered_entity) { + let count = state + .clicking + .get(&hovered_entity) + .map_or(1, |(_, count)| count.saturating_add(1)); + state.clicking.insert(hovered_entity, (now, count)); let click_event = Pointer::new( pointer_id, location.clone(), @@ -954,6 +970,7 @@ pub fn pointer_events( button, hit: hit.clone(), duration: now - *press_instant, + count, }, hovered_entity, ); diff --git a/crates/bevy_text/src/text_edit.rs b/crates/bevy_text/src/text_edit.rs index 49d9acd5b4d41..6d817b3283f8e 100644 --- a/crates/bevy_text/src/text_edit.rs +++ b/crates/bevy_text/src/text_edit.rs @@ -153,6 +153,10 @@ pub enum TextEdit { /// /// Typically generated in response to shift-clicking within the text area. ShiftClickExtension(Vec2), + /// Select the word at the given point. + /// + /// Typically generated in response to a double click within the text area. + SelectWordAtPoint(Vec2), /// Set the IME preedit/composing text at the cursor, or clear it if `value` is empty. /// /// The preedit text is excluded from [`EditableText::value`](crate::EditableText::value). @@ -274,6 +278,7 @@ impl TextEdit { driver.extend_selection_to_point(point.x, point.y); } TextEdit::ShiftClickExtension(point) => driver.shift_click_extension(point.x, point.y), + TextEdit::SelectWordAtPoint(point) => driver.select_word_at_point(point.x, point.y), TextEdit::ImeSetCompose { value, cursor } => { if value.is_empty() { driver.clear_compose(); diff --git a/crates/bevy_ui_widgets/src/text_input.rs b/crates/bevy_ui_widgets/src/text_input.rs index 02c2978ac999c..fd7a2e6df181c 100644 --- a/crates/bevy_ui_widgets/src/text_input.rs +++ b/crates/bevy_ui_widgets/src/text_input.rs @@ -15,7 +15,7 @@ use bevy_input_focus::{ FocusCause, FocusGained, FocusLost, FocusedInput, InputFocus, InputFocusSystems, }; use bevy_math::Vec2; -use bevy_picking::events::{Drag, Pointer, Press, Release}; +use bevy_picking::events::{Click, Drag, Pointer, Press, Release}; use bevy_picking::pointer::PointerButton; use bevy_text::{EditableText, PreeditCursor, TextEdit}; use bevy_ui::widget::{scroll_editable_text, update_editable_text_layout, TextScroll}; @@ -203,6 +203,58 @@ fn on_pointer_press( press.propagate(false); } +/// System that processes pointer click events into text edit actions for [`EditableText`] widgets. +/// +/// `Click`s follow `Press`, so multi-click `TextEdit`s are queued after those from the corresponding `Press`. +/// +/// Note that this does not immediately apply the edits; they are queued up in [`EditableText::pending_edits`], +/// and then applied later by the [`apply_text_edits`](`bevy_text::apply_text_edits`) system. +fn on_pointer_click( + mut click: On>, + mut text_input_query: Query<( + &mut EditableText, + &ComputedNode, + &ComputedUiRenderTargetInfo, + &UiGlobalTransform, + &TextScroll, + )>, + ui_scale: Res, +) { + if click.button != PointerButton::Primary { + return; + } + + let Ok((mut editable_text, node, target, transform, text_scroll)) = + text_input_query.get_mut(click.entity) + else { + return; + }; + + if editable_text.is_composing() { + // The IME is active; all input needs to be routed there, including pointer multi-clicks. + return; + } + + let Some(local_pos) = transform.try_inverse().map(|inverse| { + inverse + .transform_point2(click.pointer_location.position * target.scale_factor() / ui_scale.0) + - node.content_box().min + + text_scroll.0 + }) else { + return; + }; + + match click.count { + 1 => { + // No special processing required for single clicks. Presses set the cursor position and are handled by `on_pointer_press`. + } + 2 => editable_text.queue_edit(TextEdit::SelectWordAtPoint(local_pos)), + _ => editable_text.queue_edit(TextEdit::SelectAll), + } + + click.propagate(false); +} + /// System that processes pointer drag events into text edit actions for [`EditableText`] widgets. /// /// Note that this does not immediately apply the edits; they are queued up in [`EditableText::pending_edits`], @@ -488,6 +540,7 @@ impl Plugin for EditableTextInputPlugin { .add_observer(on_pointer_press) .add_observer(on_focus_lost_clear_ime) .add_observer(on_focus_select_all) + .add_observer(on_pointer_click) .add_systems( PreUpdate, ( diff --git a/examples/ui/navigation/directional_navigation.rs b/examples/ui/navigation/directional_navigation.rs index c2db2f7f8efbc..f48de658f6d1c 100644 --- a/examples/ui/navigation/directional_navigation.rs +++ b/examples/ui/navigation/directional_navigation.rs @@ -464,6 +464,7 @@ fn interact_with_focused_button( normal: None, extra: None, }, + count: 1, duration: Duration::from_secs_f32(0.1), }, focused_entity, diff --git a/examples/ui/navigation/directional_navigation_overrides.rs b/examples/ui/navigation/directional_navigation_overrides.rs index 21d57edcc9497..b833c3d460d13 100644 --- a/examples/ui/navigation/directional_navigation_overrides.rs +++ b/examples/ui/navigation/directional_navigation_overrides.rs @@ -857,6 +857,7 @@ fn interact_with_focused_button( normal: None, extra: None, }, + count: 1, duration: Duration::from_secs_f32(0.1), }, focused_entity, diff --git a/examples/ui/text/multiline_text_input.rs b/examples/ui/text/multiline_text_input.rs index 5d37b2619897f..44247b583f7ef 100644 --- a/examples/ui/text/multiline_text_input.rs +++ b/examples/ui/text/multiline_text_input.rs @@ -61,7 +61,7 @@ fn setup(mut commands: Commands, asset_server: Res) { ..default() }, TextLayout { - linebreak: LineBreak::AnyCharacter, + linebreak: LineBreak::WordOrCharacter, ..default() }, TextCursorStyle {