Skip to content
17 changes: 17 additions & 0 deletions crates/bevy_picking/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
ickshonpe marked this conversation as resolved.
Comment on lines +67 to +68
Copy link
Copy Markdown
Contributor

@SpecificProtagonist SpecificProtagonist Apr 29, 2026

Choose a reason for hiding this comment

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

This should be a resource instead so it can be overwritten.
(I wish that there already was a crate to fetch this setting, but I haven't found one)

Copy link
Copy Markdown
Contributor

@viridia viridia Apr 29, 2026

Choose a reason for hiding this comment

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

There's no crate that does this AFAIK. Instead, we'll eventually need to add OS-specific code in bevy_platform. For example, the windows version looks like this:

#[cfg(target_os = "windows")]
mod platform {
    use super::*;
 
    pub fn get() -> Option<Duration> {
        // GetDoubleClickTime returns the interval in milliseconds.
        // https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getdoubleclicktime
        extern "system" {
            fn GetDoubleClickTime() -> u32;
        }
        let ms = unsafe { GetDoubleClickTime() };
        if ms == 0 {
            None
        } else {
            Some(Duration::from_millis(ms as u64))
        }
    }
}

Also, on Linux there's no single setting, you have to parse the Gnome or KDE settings files, this will take some time at startup.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Which is why a separate crate would be nice – Bevy isn't the only library having this issue.

Copy link
Copy Markdown
Contributor Author

@ickshonpe ickshonpe Apr 29, 2026

Choose a reason for hiding this comment

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

Should this be a resource instead? (I wish that there already was a crate to fetch this setting, but I haven't found one)

I'm just using a constant here because I wanted to keep this a narrow PR focused on the multi-click implementation. It should definitely be user configurable, or fetched from the OS if possible, though.

Copy link
Copy Markdown
Contributor Author

@ickshonpe ickshonpe Apr 29, 2026

Choose a reason for hiding this comment

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

There's spatial tolerance as well, which we'd also want to get from the OS. Left it out from this PR because it has to account for scale factor (which is annoying).


/// Stores the common data needed for all pointer events.
///
/// The documentation for the [`pointer_events`] explains the events this module exposes and
Expand Down Expand Up @@ -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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

New minigame idea just dropped!

}

/// Fires while a pointer is moving over the [target entity](EntityEvent::event_target).
Expand Down Expand Up @@ -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<DragEntry>,
/// Stores the hit data for each entity currently being dragged over by the pointer.
Expand Down Expand Up @@ -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
Expand All @@ -947,13 +958,19 @@ 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(),
Click {
button,
hit: hit.clone(),
duration: now - *press_instant,
count,
},
hovered_entity,
);
Expand Down
5 changes: 5 additions & 0 deletions crates/bevy_text/src/text_edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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();
Expand Down
55 changes: 54 additions & 1 deletion crates/bevy_ui_widgets/src/text_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<Pointer<Click>>,
mut text_input_query: Query<(
&mut EditableText,
&ComputedNode,
&ComputedUiRenderTargetInfo,
&UiGlobalTransform,
&TextScroll,
)>,
ui_scale: Res<UiScale>,
) {
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`],
Expand Down Expand Up @@ -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,
(
Expand Down
1 change: 1 addition & 0 deletions examples/ui/navigation/directional_navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ fn interact_with_focused_button(
normal: None,
extra: None,
},
count: 1,
duration: Duration::from_secs_f32(0.1),
},
focused_entity,
Expand Down
1 change: 1 addition & 0 deletions examples/ui/navigation/directional_navigation_overrides.rs
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,7 @@ fn interact_with_focused_button(
normal: None,
extra: None,
},
count: 1,
duration: Duration::from_secs_f32(0.1),
},
focused_entity,
Expand Down
2 changes: 1 addition & 1 deletion examples/ui/text/multiline_text_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
..default()
},
TextLayout {
linebreak: LineBreak::AnyCharacter,
linebreak: LineBreak::WordOrCharacter,
..default()
},
TextCursorStyle {
Expand Down