Skip to content

Commit 68c4223

Browse files
ickshonpealice-i-cecileviridia
authored
multi-click support (#24023)
# Objective Implement minimal multi-click support. Add double click to select word and triple click to select all support to text input widgets. Fixes #23874 ## Solution New `MULTI_CLICK_DURATION` constant that sets the max time between clicks for them to count as consecutive. Added a field `count` to `Click`. 1 is a single click, 2 is a double click, 3 is a triple click and so on. It's a `u8` and saturates at 255 if you click that many times. Current multi-click state is stored in a field `clicking` on `PointerButtonState`. The `clicking` state is cleared after `MULTI_CLICK_DURATION` without another click. New `SelectWordAtPoint` `TextEdit`. New observer system `on_pointer_click` in `bevy_ui_widgets::text_input`. This queues `SelectWordAtPoint` for 2 clicks and `SelectAll` for 3 or more. `Click` events are dispatched on release, so they happen after `Press`. The `Press` for a double click might move the cursor but that doesn't affect the `SelectWordAtPoint` edit that is dispatched. # The implementation is deliberately minimal, I just added here what was needed for the text input. Left for followups: - Tracking the initial click position and returning the displacement with `Click`. - Spatial tolerance (clicks are only multi-clicks if the pointer hasn't moved too far). - Configurable multi-click duration. We could add a separate `MultiClick` event instead. It seems to me things would be more complicated with both `Click` and `MultiClick` observer systems though. ## Testing Text inputs now support double click to select a word and triple click to select all: ``` cargo run --example multiline_text_input ``` --------- Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com> Co-authored-by: Talin <viridia@gmail.com>
1 parent 0e49f63 commit 68c4223

6 files changed

Lines changed: 79 additions & 2 deletions

File tree

crates/bevy_picking/src/events.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ use crate::{
6363
pointer::{Location, PointerAction, PointerButton, PointerId, PointerInput, PointerMap},
6464
};
6565

66+
/// Maximum time between clicks for them to count as consecutive clicks.
67+
// TODO: add optional feature-flagged support for fetching this from the OS preferences
68+
pub const MULTI_CLICK_DURATION: Duration = Duration::from_millis(500);
69+
6670
/// Stores the common data needed for all pointer events.
6771
///
6872
/// The documentation for the [`pointer_events`] explains the events this module exposes and
@@ -312,6 +316,8 @@ pub struct Click {
312316
pub hit: HitData,
313317
/// Duration between the pointer pressed and lifted for this click
314318
pub duration: Duration,
319+
/// Number of consecutive clicks, starting at `1`.
320+
pub count: u8,
315321
}
316322

317323
/// Fires while a pointer is moving over the [target entity](EntityEvent::event_target).
@@ -470,6 +476,8 @@ pub struct Scroll {
470476
pub struct PointerButtonState {
471477
/// Stores the press location and start time for each button currently being pressed by the pointer.
472478
pub pressing: EntityHashMap<(Location, Instant, HitData)>,
479+
/// Stores the latest click time and count for each clicked entity.
480+
pub clicking: EntityHashMap<(Instant, u8)>,
473481
/// Stores the starting and current locations for each entity currently being dragged by the pointer.
474482
pub dragging: EntityHashMap<DragEntry>,
475483
/// Stores the hit data for each entity currently being dragged over by the pointer.
@@ -938,6 +946,9 @@ pub fn pointer_events(
938946
}
939947
PointerAction::Release(button) => {
940948
let state = pointer_state.get_mut(pointer_id, button);
949+
state
950+
.clicking
951+
.retain(|_, (last_click, _)| now - *last_click <= MULTI_CLICK_DURATION);
941952

942953
// Emit Click and Release events on all the previously hovered entities.
943954
for (hovered_entity, hit) in previous_hover_map
@@ -947,13 +958,19 @@ pub fn pointer_events(
947958
{
948959
// If this pointer previously pressed the hovered entity, emit a Click event
949960
if let Some((_, press_instant, _)) = state.pressing.get(&hovered_entity) {
961+
let count = state
962+
.clicking
963+
.get(&hovered_entity)
964+
.map_or(1, |(_, count)| count.saturating_add(1));
965+
state.clicking.insert(hovered_entity, (now, count));
950966
let click_event = Pointer::new(
951967
pointer_id,
952968
location.clone(),
953969
Click {
954970
button,
955971
hit: hit.clone(),
956972
duration: now - *press_instant,
973+
count,
957974
},
958975
hovered_entity,
959976
);

crates/bevy_text/src/text_edit.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ pub enum TextEdit {
153153
///
154154
/// Typically generated in response to shift-clicking within the text area.
155155
ShiftClickExtension(Vec2),
156+
/// Select the word at the given point.
157+
///
158+
/// Typically generated in response to a double click within the text area.
159+
SelectWordAtPoint(Vec2),
156160
/// Set the IME preedit/composing text at the cursor, or clear it if `value` is empty.
157161
///
158162
/// The preedit text is excluded from [`EditableText::value`](crate::EditableText::value).
@@ -274,6 +278,7 @@ impl TextEdit {
274278
driver.extend_selection_to_point(point.x, point.y);
275279
}
276280
TextEdit::ShiftClickExtension(point) => driver.shift_click_extension(point.x, point.y),
281+
TextEdit::SelectWordAtPoint(point) => driver.select_word_at_point(point.x, point.y),
277282
TextEdit::ImeSetCompose { value, cursor } => {
278283
if value.is_empty() {
279284
driver.clear_compose();

crates/bevy_ui_widgets/src/text_input.rs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use bevy_input_focus::{
1515
FocusCause, FocusGained, FocusLost, FocusedInput, InputFocus, InputFocusSystems,
1616
};
1717
use bevy_math::Vec2;
18-
use bevy_picking::events::{Drag, Pointer, Press, Release};
18+
use bevy_picking::events::{Click, Drag, Pointer, Press, Release};
1919
use bevy_picking::pointer::PointerButton;
2020
use bevy_text::{EditableText, PreeditCursor, TextEdit};
2121
use bevy_ui::widget::{scroll_editable_text, update_editable_text_layout, TextScroll};
@@ -203,6 +203,58 @@ fn on_pointer_press(
203203
press.propagate(false);
204204
}
205205

206+
/// System that processes pointer click events into text edit actions for [`EditableText`] widgets.
207+
///
208+
/// `Click`s follow `Press`, so multi-click `TextEdit`s are queued after those from the corresponding `Press`.
209+
///
210+
/// Note that this does not immediately apply the edits; they are queued up in [`EditableText::pending_edits`],
211+
/// and then applied later by the [`apply_text_edits`](`bevy_text::apply_text_edits`) system.
212+
fn on_pointer_click(
213+
mut click: On<Pointer<Click>>,
214+
mut text_input_query: Query<(
215+
&mut EditableText,
216+
&ComputedNode,
217+
&ComputedUiRenderTargetInfo,
218+
&UiGlobalTransform,
219+
&TextScroll,
220+
)>,
221+
ui_scale: Res<UiScale>,
222+
) {
223+
if click.button != PointerButton::Primary {
224+
return;
225+
}
226+
227+
let Ok((mut editable_text, node, target, transform, text_scroll)) =
228+
text_input_query.get_mut(click.entity)
229+
else {
230+
return;
231+
};
232+
233+
if editable_text.is_composing() {
234+
// The IME is active; all input needs to be routed there, including pointer multi-clicks.
235+
return;
236+
}
237+
238+
let Some(local_pos) = transform.try_inverse().map(|inverse| {
239+
inverse
240+
.transform_point2(click.pointer_location.position * target.scale_factor() / ui_scale.0)
241+
- node.content_box().min
242+
+ text_scroll.0
243+
}) else {
244+
return;
245+
};
246+
247+
match click.count {
248+
1 => {
249+
// No special processing required for single clicks. Presses set the cursor position and are handled by `on_pointer_press`.
250+
}
251+
2 => editable_text.queue_edit(TextEdit::SelectWordAtPoint(local_pos)),
252+
_ => editable_text.queue_edit(TextEdit::SelectAll),
253+
}
254+
255+
click.propagate(false);
256+
}
257+
206258
/// System that processes pointer drag events into text edit actions for [`EditableText`] widgets.
207259
///
208260
/// 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 {
488540
.add_observer(on_pointer_press)
489541
.add_observer(on_focus_lost_clear_ime)
490542
.add_observer(on_focus_select_all)
543+
.add_observer(on_pointer_click)
491544
.add_systems(
492545
PreUpdate,
493546
(

examples/ui/navigation/directional_navigation.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,7 @@ fn interact_with_focused_button(
464464
normal: None,
465465
extra: None,
466466
},
467+
count: 1,
467468
duration: Duration::from_secs_f32(0.1),
468469
},
469470
focused_entity,

examples/ui/navigation/directional_navigation_overrides.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,7 @@ fn interact_with_focused_button(
857857
normal: None,
858858
extra: None,
859859
},
860+
count: 1,
860861
duration: Duration::from_secs_f32(0.1),
861862
},
862863
focused_entity,

examples/ui/text/multiline_text_input.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
6161
..default()
6262
},
6363
TextLayout {
64-
linebreak: LineBreak::AnyCharacter,
64+
linebreak: LineBreak::WordOrCharacter,
6565
..default()
6666
},
6767
TextCursorStyle {

0 commit comments

Comments
 (0)