use accesskit::Role;
use bevy_a11y::{AccessibilityNode, AccessibilitySystems};
use bevy_app::{App, Plugin, PostUpdate, PreUpdate};
use bevy_ecs::{prelude::*, reflect::ReflectComponent};
use bevy_input::keyboard::{Key, KeyCode, KeyboardInput};
use bevy_input::{ButtonInput, InputSystems};
use bevy_input_focus::{
FocusCause, FocusGained, FocusLost, FocusedInput, InputFocus, InputFocusSystems,
};
use bevy_math::Vec2;
use bevy_picking::events::{PointerDrag, PointerPress, PointerRelease, PointerState};
use bevy_picking::pointer::PointerButton;
use bevy_reflect::Reflect;
use bevy_text::{
scrollable_text_layout_width, EditableText, EditableTextSystems, PreeditCursor, TextEdit,
TextLayout, TextLayoutInfo, TextReadWriteMode,
};
use bevy_time::{Real, Time};
use bevy_ui::widget::{sync_editable_text_viewports, update_editable_text_layout};
use bevy_ui::{
widget::TextNodeFlags, ComputedNode, ComputedUiRenderTargetInfo, ContentSize, Node,
UiGlobalTransform, UiScale,
};
use bevy_ui::{InteractionDisabled, UiSystems};
use bevy_window::{Ime, PrimaryWindow, Window};
const NONE: u8 = 0;
const SUPER: u8 = 1;
const CTRL: u8 = 2;
const ALT: u8 = 4;
const SHIFT: u8 = 8;
const SHIFT_SUPER: u8 = SHIFT | SUPER;
const SHIFT_ALT: u8 = SHIFT | ALT;
fn mac_host() -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
cfg!(target_os = "macos")
}
#[cfg(target_arch = "wasm32")]
{
use bevy_platform::sync::OnceLock;
static MAC: OnceLock<bool> = OnceLock::new();
*MAC.get_or_init(|| {
web_sys::window().is_some_and(|w| {
let nav = w.navigator();
let platform = nav.platform().unwrap_or_default();
platform.starts_with("Mac")
|| platform.starts_with("iP")
|| (platform.is_empty() && nav.user_agent().unwrap_or_default().contains("Mac"))
})
})
}
}
#[derive(Component, Clone, Default, Reflect)]
#[require(EditableText, AccessibilityNode(accesskit::Node::new(Role::TextInput)))]
#[reflect(Component)]
pub struct TextInput;
const AUTOSCROLL_BASE_SPEED: f32 = 0.75;
const AUTOSCROLL_MAX_SPEED: f32 = 2.0;
const AUTOSCROLL_RAMP_DISTANCE: f32 = 0.5;
fn matches_edit_shortcut(input: &KeyboardInput, character: &str, key_code: KeyCode) -> bool {
match &input.logical_key {
Key::Character(c) if c.is_ascii() => c.eq_ignore_ascii_case(character),
Key::Character(_) => input.key_code == key_code,
_ => false,
}
}
fn on_focused_keyboard_input(
mut keyboard_input: On<FocusedInput<KeyboardInput>>,
mut query: Query<
(
&mut EditableText,
&TextReadWriteMode,
Has<InteractionDisabled>,
),
With<TextInput>,
>,
mut input_focus: ResMut<InputFocus>,
keys: Res<ButtonInput<Key>>,
) {
let Ok((mut editable_text, rwmode, disabled)) = query.get_mut(keyboard_input.focused_entity)
else {
return; };
if editable_text.is_composing() {
keyboard_input.propagate(false);
return;
}
let allow_newlines = editable_text.allow_newlines;
let mod_flags = (SUPER * u8::from(keys.pressed(Key::Super)))
| (CTRL * u8::from(keys.pressed(Key::Control)))
| (ALT * u8::from(keys.pressed(Key::Alt)))
| (SHIFT * u8::from(keys.pressed(Key::Shift)));
let shift_pressed = (mod_flags & SHIFT) != 0;
let mac = mac_host();
let command = if mac { SUPER } else { CTRL };
let word = if mac { ALT } else { CTRL };
let shift_command = SHIFT | command;
let shift_word = SHIFT | word;
let mut should_propagate = true;
let mut queue_edit = |edit: TextEdit| {
if (*rwmode == TextReadWriteMode::Editable
|| (!edit.is_destructive() && (!disabled || *rwmode == TextReadWriteMode::ReadOnly)))
&& keyboard_input.input.state.is_pressed()
{
editable_text.queue_edit(edit);
}
should_propagate = false;
};
match (mod_flags, &keyboard_input.input.logical_key) {
(NONE, Key::Copy) => queue_edit(TextEdit::Copy),
(NONE, Key::Cut) => queue_edit(TextEdit::Cut),
(NONE, Key::Paste) => queue_edit(TextEdit::Paste),
(m, Key::Character(_))
if m == command && matches_edit_shortcut(&keyboard_input.input, "a", KeyCode::KeyA) =>
{
queue_edit(TextEdit::SelectAll);
}
(m, Key::Character(_))
if m == command && matches_edit_shortcut(&keyboard_input.input, "c", KeyCode::KeyC) =>
{
queue_edit(TextEdit::Copy);
}
(m, Key::Character(_))
if m == command && matches_edit_shortcut(&keyboard_input.input, "x", KeyCode::KeyX) =>
{
queue_edit(TextEdit::Cut);
}
(m, Key::Character(_))
if m == command && matches_edit_shortcut(&keyboard_input.input, "v", KeyCode::KeyV) =>
{
queue_edit(TextEdit::Paste);
}
(SHIFT, Key::Delete) if !mac => queue_edit(TextEdit::Cut),
(m, Key::Backspace) if m == word => queue_edit(TextEdit::BackspaceWord),
(m, Key::Delete) if m == word => queue_edit(TextEdit::DeleteWord),
(SUPER | SHIFT_SUPER, Key::ArrowLeft) if mac => {
queue_edit(TextEdit::HardLineStart(shift_pressed));
}
(SUPER | SHIFT_SUPER, Key::ArrowRight) if mac => {
queue_edit(TextEdit::HardLineEnd(shift_pressed));
}
(ALT | SHIFT_ALT, Key::Home) if !mac => queue_edit(TextEdit::HardLineStart(shift_pressed)),
(ALT | SHIFT_ALT, Key::End) if !mac => queue_edit(TextEdit::HardLineEnd(shift_pressed)),
(m, Key::ArrowLeft) if m == word || m == shift_word => {
queue_edit(TextEdit::WordLeft(shift_pressed));
}
(m, Key::ArrowRight) if m == word || m == shift_word => {
queue_edit(TextEdit::WordRight(shift_pressed));
}
(NONE | SHIFT, Key::ArrowLeft) => queue_edit(TextEdit::Left(shift_pressed)),
(NONE | SHIFT, Key::ArrowRight) => queue_edit(TextEdit::Right(shift_pressed)),
(m, Key::ArrowUp) if mac && (m == command || m == shift_command) => {
queue_edit(TextEdit::TextStart(shift_pressed));
}
(m, Key::ArrowDown) if mac && (m == command || m == shift_command) => {
queue_edit(TextEdit::TextEnd(shift_pressed));
}
(NONE | SHIFT, Key::ArrowUp) => queue_edit(TextEdit::Up(shift_pressed)),
(NONE | SHIFT, Key::ArrowDown) => queue_edit(TextEdit::Down(shift_pressed)),
(CTRL, Key::ArrowUp) if !mac => queue_edit(TextEdit::ScrollByLines(-1.0)),
(CTRL, Key::ArrowDown) if !mac => queue_edit(TextEdit::ScrollByLines(1.0)),
(m, Key::Home) if m == command || m == shift_command => {
queue_edit(TextEdit::TextStart(shift_pressed));
}
(m, Key::End) if m == command || m == shift_command => {
queue_edit(TextEdit::TextEnd(shift_pressed));
}
(NONE | SHIFT, Key::Home) => queue_edit(TextEdit::LineStart(shift_pressed)),
(NONE | SHIFT, Key::End) => queue_edit(TextEdit::LineEnd(shift_pressed)),
(NONE | SHIFT, Key::Backspace) => queue_edit(TextEdit::Backspace),
(NONE, Key::Delete) => queue_edit(TextEdit::Delete),
(NONE, Key::Escape) => {
queue_edit(TextEdit::CollapseSelection);
if keyboard_input.input.state.is_pressed() {
input_focus.clear();
}
should_propagate = true;
}
(NONE | SHIFT, Key::Character(_) | Key::Unidentified(_)) | (NONE, Key::Space) => {
if let Some(text) = &keyboard_input.input.text
&& !text.is_empty()
{
queue_edit(TextEdit::Insert(text.clone()));
}
}
(NONE, Key::Enter) if allow_newlines => {
queue_edit(TextEdit::Insert("\n".into()));
}
_ => {
}
}
keyboard_input.propagate(should_propagate);
}
fn on_pointer_press(
mut press: On<PointerPress>,
mut text_input_query: Query<
(
&mut EditableText,
&TextReadWriteMode,
&ComputedNode,
&ComputedUiRenderTargetInfo,
&UiGlobalTransform,
),
With<TextInput>,
>,
keys: Res<ButtonInput<Key>>,
ui_scale: Res<UiScale>,
) {
if press.button != PointerButton::Primary {
return;
}
let Ok((mut editable_text, rwmode, node, target, transform)) =
text_input_query.get_mut(press.entity)
else {
return;
};
if *rwmode == TextReadWriteMode::Static {
return;
}
press.propagate(false);
if editable_text.is_composing() {
return;
}
let Some(local_pos) = transform.try_inverse().and_then(|inverse| {
let local_pos =
inverse.transform_point2(press.pointer.position * target.scale_factor() / ui_scale.0);
node.content_box()
.contains(local_pos)
.then(|| local_pos - node.content_box().min + editable_text.viewport.offset)
}) else {
return;
};
match press.count {
1 => {
editable_text
.pending_edits
.push(if keys.pressed(Key::Shift) {
TextEdit::ShiftClickExtension
} else {
TextEdit::MoveToPoint
}(local_pos));
}
2 => editable_text.queue_edit(TextEdit::SelectWordAtPoint(local_pos)),
_ => editable_text.queue_edit(TextEdit::SelectAll),
}
}
fn on_pointer_drag(
mut drag: On<PointerDrag>,
mut text_input_query: Query<
(
&mut EditableText,
&TextReadWriteMode,
&ComputedNode,
&ComputedUiRenderTargetInfo,
&UiGlobalTransform,
),
With<TextInput>,
>,
ui_scale: Res<UiScale>,
) {
if drag.button != PointerButton::Primary {
return;
}
let Ok((mut editable_text, rwmode, node, target, transform)) =
text_input_query.get_mut(drag.entity)
else {
return;
};
if *rwmode == TextReadWriteMode::Static {
return;
}
drag.propagate(false);
if editable_text.is_composing() {
return;
}
let Some(local_point) = transform.try_inverse().map(|inverse| {
inverse.transform_point2(drag.pointer.position * target.scale_factor() / ui_scale.0)
}) else {
return;
};
let clamped_local_point = node.content_box().clamp_point(local_point);
let current_offset = editable_text.viewport.offset;
editable_text.queue_edit(TextEdit::ExtendSelectionToPoint(
clamped_local_point - node.content_box().min + current_offset,
));
}
pub(crate) fn text_input_autoscroll_system(
time: Res<Time<Real>>,
pointer_state: Res<PointerState>,
input_focus: Res<InputFocus>,
mut text_input_query: Query<(
&mut EditableText,
&ComputedNode,
&ComputedUiRenderTargetInfo,
&UiGlobalTransform,
&TextLayoutInfo,
&TextLayout,
)>,
ui_scale: Res<UiScale>,
) {
let Some(entity) = input_focus.get() else {
return;
};
let Some(pointer_position) = pointer_state
.pointer_buttons
.iter()
.filter(|((_, button), ..)| *button == PointerButton::Primary)
.find_map(|(_, state)| state.dragging.get(&entity).map(|drag| drag.latest_pos))
else {
return;
};
let Ok((mut editable_text, node, target, transform, layout_info, text_layout)) =
text_input_query.get_mut(entity)
else {
return;
};
if editable_text.is_composing()
|| editable_text
.pending_edits
.iter()
.any(|edit| matches!(edit, TextEdit::ImeSetCompose { value, .. } if !value.is_empty()))
{
return;
}
if editable_text.viewport.size.cmple(Vec2::ZERO).any() {
return;
}
let Some(local_point) = transform.try_inverse().map(|inverse| {
inverse.transform_point2(pointer_position * target.scale_factor() / ui_scale.0)
}) else {
return;
};
let clamped_local_point = node.content_box().clamp_point(local_point);
let signed_distance = local_point - clamped_local_point;
if signed_distance == Vec2::ZERO {
return;
}
let scroll_delta = Vec2::new(
autoscroll_axis(
signed_distance.x,
editable_text.viewport.size.x,
time.delta_secs(),
),
autoscroll_axis(
signed_distance.y,
editable_text.viewport.size.y,
time.delta_secs(),
),
);
let full_layout_size = Vec2::new(
scrollable_text_layout_width(
text_layout.linebreak,
layout_info.size.x,
editable_text.viewport.size.x,
layout_info.cursor.map(|(_, rect)| rect),
),
layout_info.size.y,
);
let clamped_offset = (editable_text.viewport.offset + scroll_delta).clamp(
Vec2::ZERO,
(full_layout_size - editable_text.viewport.size).max(Vec2::ZERO),
);
let clamped_scroll_delta = clamped_offset - editable_text.viewport.offset;
if clamped_scroll_delta == Vec2::ZERO {
return;
}
editable_text.queue_edit(TextEdit::ScrollBy(clamped_scroll_delta));
editable_text.queue_edit(TextEdit::ExtendSelectionToPoint(
clamped_local_point - node.content_box().min + clamped_offset,
));
}
fn autoscroll_axis(overflow: f32, view_size: f32, time_delta: f32) -> f32 {
if overflow == 0. || view_size == 0. {
return 0.;
}
let ramp_distance = (overflow.abs() / (view_size * AUTOSCROLL_RAMP_DISTANCE)).min(1.0);
let speed =
AUTOSCROLL_BASE_SPEED + ramp_distance * (AUTOSCROLL_MAX_SPEED - AUTOSCROLL_BASE_SPEED);
overflow.signum() * view_size * speed * time_delta
}
fn on_ime_input(
mut ime_reader: MessageReader<Ime>,
input_focus: Res<InputFocus>,
mut editable_text_query: Query<
(
&mut EditableText,
&TextReadWriteMode,
Has<InteractionDisabled>,
),
With<TextInput>,
>,
) {
let Some(focused_entity) = input_focus.get() else {
ime_reader.read().for_each(drop);
return;
};
let Ok((mut editable_text, rwmode, disabled)) = editable_text_query.get_mut(focused_entity)
else {
ime_reader.read().for_each(drop);
return;
};
if *rwmode != TextReadWriteMode::Editable || disabled {
ime_reader.read().for_each(drop);
return;
}
for ime in ime_reader.read() {
match ime {
Ime::Preedit { value, cursor, .. } => {
editable_text.queue_edit(TextEdit::ImeSetCompose {
value: value.as_str().into(),
cursor: cursor.map(|(anchor, focus)| PreeditCursor { anchor, focus }),
});
}
Ime::Commit { value, .. } => {
editable_text.queue_edit(TextEdit::ImeCommit {
value: value.as_str().into(),
});
}
Ime::Disabled { .. } => {
editable_text.queue_edit(TextEdit::clear_ime_compose());
}
Ime::Enabled { .. } => {
editable_text.queue_edit(TextEdit::clear_ime_compose());
}
}
}
}
fn update_ime_position(
input_focus: Res<InputFocus>,
editable_text_query: Query<
(
&EditableText,
&TextReadWriteMode,
&ComputedNode,
&UiGlobalTransform,
&ComputedUiRenderTargetInfo,
Has<InteractionDisabled>,
),
With<TextInput>,
>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
ui_scale: Res<UiScale>,
) {
let Some(focused) = input_focus.get() else {
return;
};
let Ok((editable_text, rwmode, node, transform, target, disabled)) =
editable_text_query.get(focused)
else {
return;
};
if *rwmode != TextReadWriteMode::Editable || disabled {
return;
}
let Ok(mut window) = windows.single_mut() else {
return;
};
let area = editable_text.editor.ime_cursor_area();
let parley_local = Vec2::new(area.x0 as f32, area.y1 as f32);
let ui_local = parley_local + node.content_box().min - editable_text.viewport.offset;
window.ime_position =
transform.affine().transform_point2(ui_local) * ui_scale.0 / target.scale_factor();
}
fn listen_for_ime_input_when_text_input_focused(
input_focus: Res<InputFocus>,
mut editable_text_query: Query<(&mut EditableText, Ref<TextReadWriteMode>), With<TextInput>>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
) {
let focused_entity = input_focus.get();
let Ok(mut window) = windows.single_mut() else {
return;
};
let focused = focused_entity.and_then(|e| editable_text_query.get_mut(e).ok());
let editable_text_focused = focused.is_some_and(|(mut editable_text, rwmode)| {
let rwmode_changed = rwmode.is_changed();
if rwmode_changed && *rwmode != TextReadWriteMode::Editable {
editable_text.queue_edit(TextEdit::clear_ime_compose());
}
*rwmode == TextReadWriteMode::Editable
});
window.ime_enabled = editable_text_focused;
}
fn on_focus_lost(
trigger: On<FocusLost>,
mut editable_text_query: Query<&mut EditableText, With<TextInput>>,
) {
if let Ok(mut editable_text) = editable_text_query.get_mut(trigger.entity) {
editable_text.queue_edit(TextEdit::clear_ime_compose());
editable_text.queue_edit(TextEdit::CollapseSelection);
}
}
#[derive(Component, Clone, Default, Reflect)]
#[reflect(Component)]
pub struct SelectAllOnFocus;
#[derive(Resource, Default, Reflect)]
#[reflect(Resource)]
struct QueuedSelectAll(Option<Entity>);
fn on_focus_select_all(
focus_gained: On<FocusGained>,
mut q_text_input: Query<(&mut EditableText, Has<SelectAllOnFocus>), With<TextInput>>,
mut queued_select_all: ResMut<QueuedSelectAll>,
) {
let target = focus_gained.event_target();
if let Ok((mut editable_text, select_all_on_focus)) = q_text_input.get_mut(target) {
match focus_gained.event().cause {
FocusCause::Pressed => {
if select_all_on_focus {
queued_select_all.0 = Some(target);
}
}
FocusCause::Auto | FocusCause::Navigated => {
if select_all_on_focus {
editable_text.queue_edit(TextEdit::SelectAll);
}
}
}
}
}
fn apply_queued_select_all(
mut pointer_releases: MessageReader<PointerRelease>,
mut queued_select_all: ResMut<QueuedSelectAll>,
mut q_text_input: Query<&mut EditableText, With<SelectAllOnFocus>>,
) {
let Some(target) = queued_select_all.0 else {
return;
};
for pointer_release in pointer_releases.read() {
if pointer_release.button == PointerButton::Primary
&& let Ok(mut editable_text) = q_text_input.get_mut(target)
{
editable_text.queue_edit(TextEdit::SelectAllIfCollapsed);
queued_select_all.0 = None;
}
}
}
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub enum ImeSystems {
HandleEvents,
ToggleWindowIMEInput,
UpdatePosition,
}
pub struct TextInputPlugin;
impl Plugin for TextInputPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<QueuedSelectAll>()
.add_observer(on_focused_keyboard_input)
.add_observer(on_pointer_drag)
.add_observer(on_pointer_press)
.add_observer(on_focus_lost)
.add_observer(on_focus_select_all)
.configure_sets(
PreUpdate,
(
ImeSystems::ToggleWindowIMEInput,
ImeSystems::HandleEvents,
ImeSystems::UpdatePosition,
)
.chain(),
)
.add_systems(
PreUpdate,
(
on_ime_input.in_set(ImeSystems::HandleEvents),
listen_for_ime_input_when_text_input_focused
.in_set(ImeSystems::ToggleWindowIMEInput),
)
.after(InputSystems)
.after(InputFocusSystems::Dispatch)
.after(UiSystems::Focus),
)
.add_systems(
PostUpdate,
update_ime_position
.in_set(ImeSystems::UpdatePosition)
.in_set(UiSystems::PostLayout)
.before(AccessibilitySystems::Update)
.after(update_editable_text_layout)
.ambiguous_with(InputFocusSystems::FocusChangeEvents),
)
.add_systems(
PostUpdate,
text_input_autoscroll_system
.after(sync_editable_text_viewports)
.before(EditableTextSystems),
)
.add_systems(
PostUpdate,
apply_queued_select_all
.in_set(UiSystems::PostLayout)
.before(update_editable_text_layout),
);
app.register_required_components::<EditableText, Node>()
.register_required_components::<EditableText, TextNodeFlags>()
.register_required_components::<EditableText, ContentSize>();
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy_app::Update;
use bevy_input::{
keyboard::{KeyCode, NativeKey, NativeKeyCode},
ButtonState, InputPlugin,
};
use bevy_input_focus::InputDispatchPlugin;
use bevy_math::Rect;
use bevy_picking::{events::DragEntry, pointer::PointerId};
use core::time::Duration;
#[test]
fn steam_osk_unidentified_keys_insert_produced_text() {
let mut app = App::new();
app.add_plugins((InputPlugin, InputDispatchPlugin))
.init_resource::<InputFocus>()
.add_observer(on_focused_keyboard_input);
let window = app
.world_mut()
.spawn((Window::default(), PrimaryWindow))
.id();
let editable_text = app
.world_mut()
.spawn((EditableText::default(), TextInput))
.id();
app.insert_resource(InputFocus::from_entity(editable_text));
for (text, repeat) in [("J", false), ("#", true)] {
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::Unidentified(NativeKeyCode::Windows(0)),
logical_key: Key::Unidentified(NativeKey::Windows(231)),
state: ButtonState::Pressed,
text: Some(text.into()),
repeat,
window,
});
}
app.update();
assert_eq!(
app.world()
.entity(editable_text)
.get::<EditableText>()
.unwrap()
.pending_edits,
[TextEdit::Insert("J".into()), TextEdit::Insert("#".into())]
);
}
#[test]
fn autoscroll_speed_is_zero_inside_then_ramps_and_caps() {
let visible_size = 100.0;
assert_eq!(autoscroll_axis(0.0, visible_size, 1.0), 0.0);
assert_eq!(autoscroll_axis(25.0, visible_size, 1.0), 137.5);
assert_eq!(autoscroll_axis(-25.0, visible_size, 1.0), -137.5);
assert_eq!(autoscroll_axis(50.0, visible_size, 1.0), 200.0);
assert_eq!(autoscroll_axis(-100.0, visible_size, 1.0), -200.0);
}
#[test]
fn autoscroll_displacement_is_frame_rate_independent() {
let overflow = 25.0;
let view_size = 100.0;
let one_frame = autoscroll_axis(overflow, view_size, 1.0 / 30.0);
let two_frames = autoscroll_axis(overflow, view_size, 1.0 / 60.0) * 2.0;
assert!((one_frame - two_frames).abs() < 1e-5);
}
fn autoscroll_app(initial_drag_pos: Vec2) -> (App, Entity) {
let mut app = App::new();
app.init_resource::<Time<Real>>()
.init_resource::<PointerState>()
.init_resource::<UiScale>()
.add_systems(Update, text_input_autoscroll_system);
let mut editable_text = EditableText::default();
editable_text.viewport.size = Vec2::splat(100.0);
let entity = app
.world_mut()
.spawn((
editable_text,
ComputedNode {
size: Vec2::splat(100.0),
..Default::default()
},
ComputedUiRenderTargetInfo::default(),
UiGlobalTransform::default(),
TextLayoutInfo {
size: Vec2::splat(300.0),
..Default::default()
},
))
.id();
app.insert_resource(InputFocus::from_entity(entity));
app.world_mut()
.resource_mut::<PointerState>()
.get_mut(PointerId::Mouse, PointerButton::Primary)
.dragging
.insert(
entity,
DragEntry {
start_pos: initial_drag_pos,
latest_pos: initial_drag_pos,
},
);
app.world_mut()
.resource_mut::<Time<Real>>()
.advance_by(Duration::from_secs_f32(1.0 / 60.0));
(app, entity)
}
#[test]
fn active_drag_autoscrolls_until_reentry() {
let (mut app, entity) = autoscroll_app(Vec2::new(100.0, 0.0));
app.update();
assert_eq!(
app.world()
.entity(entity)
.get::<EditableText>()
.unwrap()
.pending_edits
.len(),
2
);
app.world_mut()
.entity_mut(entity)
.get_mut::<EditableText>()
.unwrap()
.pending_edits
.clear();
app.update();
assert_eq!(
app.world()
.entity(entity)
.get::<EditableText>()
.unwrap()
.pending_edits
.len(),
2
);
app.world_mut()
.entity_mut(entity)
.get_mut::<EditableText>()
.unwrap()
.pending_edits
.clear();
app.world_mut()
.resource_mut::<PointerState>()
.get_mut(PointerId::Mouse, PointerButton::Primary)
.dragging
.get_mut(&entity)
.unwrap()
.latest_pos = Vec2::ZERO;
app.update();
assert!(app
.world()
.entity(entity)
.get::<EditableText>()
.unwrap()
.pending_edits
.is_empty());
}
#[test]
fn autoscroll_into_cursor_space() {
let (mut app, entity) = autoscroll_app(Vec2::new(100.0, 0.0));
let mut entity_mut = app.world_mut().entity_mut(entity);
let mut editable_text = entity_mut.get_mut::<EditableText>().unwrap();
editable_text.viewport.reveal_caret(
Rect::new(295.0, 0.0, 300.0, 20.0),
Vec2::new(300.0, 300.0),
Vec2::ZERO,
core::iter::empty(),
);
assert_eq!(editable_text.viewport.offset.x, 200.0);
entity_mut.insert(TextLayout::no_wrap());
entity_mut.get_mut::<TextLayoutInfo>().unwrap().cursor =
Some((true, Rect::new(295.0, 0.0, 320.0, 20.0)));
app.update();
assert!(matches!(
app.world().entity(entity).get::<EditableText>().unwrap().pending_edits.first(),
Some(TextEdit::ScrollBy(delta)) if 0. < delta.x
));
}
#[test]
fn autoscroll_stops_after_release_cancellation_or_focus_loss() {
let (mut app, entity) = autoscroll_app(Vec2::new(100.0, 0.0));
app.update();
assert_eq!(
app.world()
.entity(entity)
.get::<EditableText>()
.unwrap()
.pending_edits
.len(),
2
);
app.world_mut()
.entity_mut(entity)
.get_mut::<EditableText>()
.unwrap()
.pending_edits
.clear();
app.insert_resource(InputFocus::default());
app.update();
let editable_text = app.world().entity(entity).get::<EditableText>().unwrap();
assert!(editable_text.pending_edits.is_empty());
}
#[test]
fn ime_composition_stops_autoscroll() {
let (mut app, entity) = autoscroll_app(Vec2::new(100.0, 0.0));
app.world_mut()
.entity_mut(entity)
.get_mut::<EditableText>()
.unwrap()
.queue_edit(TextEdit::ImeSetCompose {
value: "compose".into(),
cursor: Some(PreeditCursor {
anchor: 0,
focus: 7,
}),
});
app.update();
assert_eq!(
app.world()
.entity(entity)
.get::<EditableText>()
.unwrap()
.pending_edits
.len(),
1
);
}
fn shortcut_keyboard_input(logical_key: Key, key_code: KeyCode) -> KeyboardInput {
KeyboardInput {
key_code,
logical_key,
state: ButtonState::Pressed,
text: None,
repeat: false,
window: World::default().spawn_empty().id(),
}
}
#[test]
fn logical_key_wins_on_latin_layouts() {
let event = shortcut_keyboard_input(Key::Character("c".into()), KeyCode::KeyC);
assert!(matches_edit_shortcut(&event, "c", KeyCode::KeyC));
let event = shortcut_keyboard_input(Key::Character("a".into()), KeyCode::KeyQ);
assert!(matches_edit_shortcut(&event, "a", KeyCode::KeyA));
assert!(!matches_edit_shortcut(&event, "q", KeyCode::KeyQ));
}
#[test]
fn physical_fallback_on_non_latin_layouts() {
let event = shortcut_keyboard_input(Key::Character("с".into()), KeyCode::KeyC);
assert!(matches_edit_shortcut(&event, "c", KeyCode::KeyC));
assert!(!matches_edit_shortcut(&event, "a", KeyCode::KeyA));
}
#[test]
fn named_keys_never_match() {
let event = shortcut_keyboard_input(Key::Enter, KeyCode::Enter);
assert!(!matches_edit_shortcut(&event, "c", KeyCode::KeyC));
}
#[test]
fn escape_blurs_field_and_propagates_to_window() {
#[derive(Resource, Default)]
struct WindowSawEscape(u32);
let mut app = App::new();
app.add_plugins((InputPlugin, InputDispatchPlugin))
.init_resource::<InputFocus>()
.init_resource::<WindowSawEscape>()
.add_observer(on_focused_keyboard_input);
app.world_mut().register_component::<ChildOf>();
let window = app
.world_mut()
.spawn((Window::default(), PrimaryWindow))
.id();
let editable_text = app
.world_mut()
.spawn((TextInput, EditableText::default()))
.id();
app.world_mut().entity_mut(window).observe(
move |input: On<FocusedInput<KeyboardInput>>, mut saw: ResMut<WindowSawEscape>| {
if matches!(input.input.logical_key, Key::Escape) && input.input.state.is_pressed()
{
assert_eq!(input.focused_entity, window);
assert_eq!(input.original_event_target(), editable_text);
saw.0 += 1;
}
},
);
app.insert_resource(InputFocus::from_entity(editable_text));
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::Escape,
logical_key: Key::Escape,
state: ButtonState::Pressed,
text: None,
repeat: false,
window,
});
app.update();
assert!(app.world().resource::<InputFocus>().get().is_none());
assert_eq!(app.world().resource::<WindowSawEscape>().0, 1);
}
}