use bevy_a11y::AccessibilitySystems;
use bevy_app::{App, Plugin, PostUpdate, PreUpdate};
use bevy_ecs::prelude::*;
use bevy_input::keyboard::{Key, KeyboardInput};
use bevy_input::{ButtonInput, InputSystems};
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::pointer::PointerButton;
use bevy_reflect::Reflect;
use bevy_text::{EditableText, PreeditCursor, TextEdit};
use bevy_ui::widget::{scroll_editable_text, update_editable_text_layout, TextScroll};
use bevy_ui::UiSystems;
use bevy_ui::{
widget::TextNodeFlags, ComputedNode, ComputedUiRenderTargetInfo, ContentSize, Node,
UiGlobalTransform, UiScale,
};
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 COMMAND: u8 = if cfg!(target_os = "macos") {
SUPER
} else {
CTRL
};
const WORD: u8 = if cfg!(target_os = "macos") { ALT } else { CTRL };
const SHIFT_WORD: u8 = SHIFT | WORD;
#[cfg(target_os = "macos")]
const SHIFT_SUPER: u8 = SHIFT | SUPER;
const SHIFT_COMMAND: u8 = SHIFT | COMMAND;
#[cfg(not(target_os = "macos"))]
const SHIFT_ALT: u8 = SHIFT | ALT;
fn on_focused_keyboard_input(
mut keyboard_input: On<FocusedInput<KeyboardInput>>,
mut query: Query<&mut EditableText>,
keys: Res<ButtonInput<Key>>,
) {
let Ok(mut editable_text) = 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 mut should_propagate = true;
let mut queue_edit = |edit| {
if 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),
(COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("a") => {
queue_edit(TextEdit::SelectAll);
}
(COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("c") => {
queue_edit(TextEdit::Copy);
}
(COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("x") => queue_edit(TextEdit::Cut),
(COMMAND, Key::Character(c)) if c.eq_ignore_ascii_case("v") => {
queue_edit(TextEdit::Paste);
}
#[cfg(not(target_os = "macos"))]
(SHIFT, Key::Delete) => queue_edit(TextEdit::Cut),
(WORD, Key::Backspace) => queue_edit(TextEdit::BackspaceWord),
(WORD, Key::Delete) => queue_edit(TextEdit::DeleteWord),
#[cfg(target_os = "macos")]
(SUPER | SHIFT_SUPER, Key::ArrowLeft) => queue_edit(TextEdit::HardLineStart(shift_pressed)),
#[cfg(target_os = "macos")]
(SUPER | SHIFT_SUPER, Key::ArrowRight) => queue_edit(TextEdit::HardLineEnd(shift_pressed)),
#[cfg(not(target_os = "macos"))]
(ALT | SHIFT_ALT, Key::Home) => queue_edit(TextEdit::HardLineStart(shift_pressed)),
#[cfg(not(target_os = "macos"))]
(ALT | SHIFT_ALT, Key::End) => queue_edit(TextEdit::HardLineEnd(shift_pressed)),
(WORD | SHIFT_WORD, Key::ArrowLeft) => queue_edit(TextEdit::WordLeft(shift_pressed)),
(WORD | SHIFT_WORD, Key::ArrowRight) => 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)),
(COMMAND | SHIFT_COMMAND, Key::ArrowUp) => queue_edit(TextEdit::TextStart(shift_pressed)),
(COMMAND | SHIFT_COMMAND, Key::ArrowDown) => 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)),
(COMMAND | SHIFT_COMMAND, Key::Home) => queue_edit(TextEdit::TextStart(shift_pressed)),
(COMMAND | SHIFT_COMMAND, Key::End) => 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, Key::Backspace) => queue_edit(TextEdit::Backspace),
(NONE, Key::Delete) => queue_edit(TextEdit::Delete),
(NONE, Key::Escape) => queue_edit(TextEdit::CollapseSelection),
(NONE | SHIFT, Key::Character(_)) | (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<Pointer<Press>>,
mut text_input_query: Query<(
&mut EditableText,
&ComputedNode,
&ComputedUiRenderTargetInfo,
&UiGlobalTransform,
&TextScroll,
)>,
keys: Res<ButtonInput<Key>>,
mut input_focus: ResMut<InputFocus>,
ui_scale: Res<UiScale>,
) {
if press.button != PointerButton::Primary {
return;
}
let Ok((mut editable_text, node, target, transform, text_scroll)) =
text_input_query.get_mut(press.entity)
else {
return;
};
input_focus.set(press.entity, FocusCause::Pressed);
press.propagate(false);
if editable_text.is_composing() {
return;
}
let Some(local_pos) = transform.try_inverse().map(|inverse| {
inverse
.transform_point2(press.pointer_location.position * target.scale_factor() / ui_scale.0)
- node.content_box().min
+ text_scroll.0
}) 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<Pointer<Drag>>,
mut text_input_query: Query<(
&mut EditableText,
&ComputedNode,
&ComputedUiRenderTargetInfo,
&UiGlobalTransform,
&TextScroll,
)>,
ui_scale: Res<UiScale>,
) {
if drag.button != PointerButton::Primary {
return;
}
let Ok((mut editable_text, node, target, transform, text_scroll)) =
text_input_query.get_mut(drag.entity)
else {
return;
};
drag.propagate(false);
if editable_text.is_composing() {
return;
}
let Some(current_local_pos) = transform.try_inverse().map(|inverse| {
inverse
.transform_point2(drag.pointer_location.position * target.scale_factor() / ui_scale.0)
- node.content_box().min
+ text_scroll.0
}) else {
return;
};
editable_text
.pending_edits
.push(TextEdit::ExtendSelectionToPoint(current_local_pos));
}
fn on_ime_input(
mut ime_reader: MessageReader<Ime>,
input_focus: Res<InputFocus>,
mut editable_text_query: Query<&mut EditableText>,
) {
let Some(focused_entity) = input_focus.get() else {
ime_reader.read().for_each(drop);
return;
};
let Ok(mut editable_text) = editable_text_query.get_mut(focused_entity) else {
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,
&ComputedNode,
&UiGlobalTransform,
&ComputedUiRenderTargetInfo,
&TextScroll,
)>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
ui_scale: Res<UiScale>,
) {
let Some(focused) = input_focus.get() else {
return;
};
let Ok((editable_text, node, transform, target, text_scroll)) =
editable_text_query.get(focused)
else {
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 - text_scroll.0;
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>,
editable_text_query: Query<(), With<EditableText>>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
) {
if !input_focus.is_changed() {
return;
}
let Ok(mut window) = windows.single_mut() else {
return;
};
let editable_text_focused = input_focus
.get()
.is_some_and(|e| editable_text_query.contains(e));
window.ime_enabled = editable_text_focused;
}
fn on_focus_lost(trigger: On<FocusLost>, mut editable_text_query: Query<&mut EditableText>) {
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>)>,
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::Navigated => {
if select_all_on_focus {
editable_text.queue_edit(TextEdit::SelectAll);
}
}
}
}
}
fn apply_queued_select_all(
mut pointer_releases: MessageReader<Pointer<Release>>,
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 EditableTextInputPlugin;
impl Plugin for EditableTextInputPlugin {
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)
.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)
.after(scroll_editable_text)
.ambiguous_with(InputFocusSystems::FocusChangeEvents),
)
.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>()
.register_required_components::<EditableText, TextScroll>();
}
}