haalka/
text_input.rs

1//! Reactive text input widget and adjacent utilities, a thin wrapper around [`bevy_ui_text_input`] integrated with [`Signal`]s.
2
3use std::{ops::Not, sync::{Arc, OnceLock}};
4
5use bevy_input_focus::InputFocus;
6use bevy_ecs::system::*;
7use bevy_ecs::prelude::*;
8use bevy_ui::prelude::*;
9use bevy_utils::prelude::*;
10use bevy_app::prelude::*;
11use bevy_picking::prelude::*;
12use bevy_text::{cosmic_text::{Edit, Selection}, TextColor, TextFont};
13
14use crate::impl_haalka_methods;
15
16use super::{
17    el::El, element::{ElementWrapper, Nameable, UiRootable}, pointer_event_aware::{PointerEventAware, CursorOnHoverable}, raw::{RawElWrapper, register_system}, mouse_wheel_scrollable::MouseWheelScrollable,
18    utils::clone, viewport_mutable::ViewportMutable, global_event_aware::GlobalEventAware,
19    raw::{observe, utils::remove_system_holder_on_remove}
20};
21use apply::Apply;
22use bevy_ui_text_input::{actions::TextInputAction, text_input_pipeline::TextInputPipeline, *};
23use futures_signals::signal::{Mutable, Signal, SignalExt};
24use paste::paste;
25
26/// Reactive text input widget, a thin wrapper around [`bevy_ui_text_input`] integrated with [`Signal`]s.
27#[derive(Default)]
28pub struct TextInput {
29    el: El<Node>,
30}
31
32impl ElementWrapper for TextInput {
33    type EL = El<Node>;
34    fn element_mut(&mut self) -> &mut Self::EL {
35        &mut self.el
36    }
37}
38
39impl GlobalEventAware for TextInput {}
40impl Nameable for TextInput {}
41impl PointerEventAware for TextInput {}
42impl MouseWheelScrollable for TextInput {}
43impl UiRootable for TextInput {}
44impl ViewportMutable for TextInput {}
45impl CursorOnHoverable for TextInput {}
46
47// TODO: allow managing multiple spans reactively
48impl TextInput {
49    #[allow(missing_docs, clippy::new_without_default)]
50    pub fn new() -> Self {
51        let el = El::<Node>::new().update_raw_el(|raw_el| {
52            raw_el
53                .insert((
54                    TextInputNode {
55                        clear_on_submit: false,
56                        ..default()
57                    },
58                    Pickable::default(),
59                    LastSignalText::default()
60                ))
61        });
62        Self { el }
63    }
64
65    /// Run a function with this input's [`TextInputBuffer`] with access to [`ResMut<TextInputPipeline>`].
66    pub fn with_buffer(
67        self,
68        f: impl FnOnce(Mut<TextInputBuffer>, ResMut<TextInputPipeline>) + Send + 'static,
69    ) -> Self {
70        // .on_spawn_with_system doesn't work because it requires FnMut
71        self.update_raw_el(|raw_el| raw_el.on_spawn(move |world, entity| {
72            // TODO: is this stuff repeated for every call ?
73            #[allow(clippy::type_complexity)]
74            let mut system_state: SystemState<(
75                Query<&mut TextInputBuffer>,
76                ResMut<TextInputPipeline>,
77            )> = SystemState::new(world);
78            let (mut buffers, text_input_pipeline) = system_state.get_mut(world);
79            if let Ok(buffer) = buffers.get_mut(entity) {
80                f(buffer, text_input_pipeline)
81            }
82        }))
83    }
84
85    /// Reactively run a function with this input's [`TextInputBuffer`] and the output of the [`Signal`] with access to [`ResMut<TextInputPipeline>`].
86    pub fn on_signal_with_buffer<T: Send + 'static>(
87        self,
88        signal: impl Signal<Item = T> + Send + 'static,
89        mut f: impl FnMut(Mut<TextInputBuffer>, ResMut<TextInputPipeline>, T) + Send + Sync + 'static,
90    ) -> Self {
91        self.update_raw_el(move |raw_el| {
92            raw_el.on_signal_with_system(
93                signal,
94                move |In((entity, value)): In<(Entity, T)>,
95                    mut buffers: Query<&mut TextInputBuffer>,
96                    text_input_pipeline: ResMut<TextInputPipeline>| {
97                    if let Ok(buffer) = buffers.get_mut(entity) {
98                        f(buffer, text_input_pipeline, value)
99                    };
100                },
101            )
102        })
103    }
104
105    /// Set the text of this input.
106    pub fn text(self, text_option: impl Into<Option<String>>) -> Self {
107        let text = text_option.into().unwrap_or_default();
108        self.with_text_input_queue(move |mut text_input_queue| {
109            queue_set_text_actions(&mut text_input_queue, text);
110        })
111    }
112
113    /// Reactively set the text of this input. If the signal outputs [`None`] the text is set to an empty string.
114    pub fn text_signal<S: Signal<Item = impl Into<Option<String>>> + Send + 'static>(
115        mut self,
116        text_option_signal_option: impl Into<Option<S>>,
117    ) -> Self {
118        if let Some(text_option_signal) = text_option_signal_option.into() {
119            self = self.update_raw_el(|raw_el| {
120                raw_el.on_signal_with_system(
121                    text_option_signal.map(|text_option| text_option.into().unwrap_or_default()),
122                    |In((entity, text)): In<(Entity, String)>,
123                     mut last_text_query: Query<&mut LastSignalText>,
124                     mut text_input_queues: Query<&mut TextInputQueue>| {
125                        if let Ok(mut last_text) = last_text_query.get_mut(entity) {
126                            // only queue an update if the incoming signal value is different
127                            // from the last value we set from a signal. This prevents redundant updates.
128                            if last_text.0 != text {
129                                last_text.0 = text.clone();
130                                if let Ok(mut queue) = text_input_queues.get_mut(entity) {
131                                    queue_set_text_actions(&mut queue, text);
132                                }
133                            }
134                        }
135                    },
136                )
137            });
138        }
139        self
140    }
141
142    /// When this input's focused state changes, run a system which takes [`In`](`System::In`)
143    /// this input's [`Entity`] and its current focused state.
144    pub fn on_focused_change_with_system<Marker>(
145        self,
146        handler: impl IntoSystem<In<(Entity, bool,)>, (), Marker> + Send + 'static,
147    ) -> Self {
148        self.update_raw_el(|raw_el| {
149            let system_holder = Arc::new(OnceLock::new());
150            raw_el
151            .with_entity(|mut entity| { entity.insert(Focusable { is_focused: false }); })
152            .on_spawn(clone!((system_holder) move |world, entity| {
153                let system = register_system(world, handler);
154                let _ = system_holder.set(system);
155                observe(world, entity, move |event: Trigger<FocusedChange>, mut commands: Commands| {
156                    commands.run_system_with(system, (entity, event.event().0))
157                });
158            }))
159            .apply(remove_system_holder_on_remove(system_holder.clone()))
160        })
161    }
162
163    /// When this input's focused state changes, run a function with its current focused state.
164    pub fn on_focused_change(self, mut handler: impl FnMut(bool) + Send + Sync + 'static) -> Self {
165        self.on_focused_change_with_system(move |In((_, is_focused))| handler(is_focused))
166    }
167
168    /// Sync a [`Mutable`] with this input's focused state.
169    pub fn focused_sync(self, focused: Mutable<bool>) -> Self {
170        self.on_focused_change(move |is_focused| focused.set_neq(is_focused))
171    }
172
173    /// Set the focused state of this input.
174    pub fn focus_option(mut self, focus_option: impl Into<Option<bool>>) -> Self {
175        if Into::<Option<bool>>::into(focus_option).unwrap_or(false) {
176            self = self.update_raw_el(|raw_el| raw_el.on_spawn_with_system(|In(entity), mut commands: Commands| {
177                commands.insert_resource(InputFocus(Some(entity)));
178            }));
179        }
180        self
181    }
182
183    /// Focus this input.
184    pub fn focus(self) -> Self {
185        self.focus_option(true)
186    }
187
188    /// Reactively focus this input.
189    pub fn focus_signal<S: Signal<Item = bool> + Send + 'static>(
190        mut self,
191        focus_signal_option: impl Into<Option<S>>,
192    ) -> Self {
193        if let Some(focus_signal) = focus_signal_option.into() {
194            self = self.update_raw_el(|raw_el| {
195                raw_el.on_signal_with_system(focus_signal, |In((entity, focus)), mut focused_option: ResMut<InputFocus>| {
196                    if focus {
197                        focused_option.0 = Some(entity);
198                    } else if let Some(focused) = focused_option.0 && focused == entity {
199                        focused_option.0 = None;
200                    }
201                })
202            })
203        }
204        self
205    }
206
207    /// When the string in this input changes, run a `handler` [`System`] which takes [`In`](System::In) the [`Entity`] of this input's [`Entity`] and the new [`String`].
208    pub fn on_change_with_system<Marker>(self, handler: impl IntoSystem<In<(Entity, String)>, (), Marker> + Send + 'static) -> Self {
209        self.update_raw_el(|raw_el| {
210            let system_holder = Arc::new(OnceLock::new());
211            raw_el.on_spawn(clone!((system_holder) move |world, entity| {
212                let system = register_system(world, handler);
213                let _ = system_holder.set(system);
214                observe(world, entity, move |change: Trigger<TextInputChange>, mut commands: Commands| {
215                    commands.run_system_with(system, (change.target(), change.event().0.clone()));
216                });
217            }))
218            .with_entity(|mut entity| { entity.insert_if_new((ListenToChanges, TextInputContents::default())); })
219            .apply(remove_system_holder_on_remove(system_holder))
220        })
221    }
222
223    /// When the text of this input changes, run a function with the new text.
224    pub fn on_change(self, mut handler: impl FnMut(String) + Send + Sync + 'static) -> Self {
225        self.on_change_with_system(move |In((_, text))| handler(text))
226    }
227
228    /// Sync a [`Mutable`] with the text of this input.
229    pub fn on_change_sync(self, string: Mutable<String>) -> Self {
230        self.on_change_with_system(
231            move |In((entity, text)): In<(Entity, String)>, mut last_text_query: Query<&mut LastSignalText>| {
232                if let Ok(mut last_text) = last_text_query.get_mut(entity) {
233                    // only update the mutable if the change is NOT an echo of a value just set by a signal.
234                    if last_text.0 != text {
235                        last_text.0 = text.clone();
236                        string.set_neq(text);
237                    }
238                }
239            },
240        )
241    }
242}
243
244/// A component to store the last text value that was successfully applied by [`TextInput::text_signal`].
245/// This is used to prevent echo updates from [`TextInput::on_change_sync`] when a two-way binding is active.
246#[derive(Component, Default)]
247struct LastSignalText(String);
248
249fn queue_set_text_actions(
250    text_input_queue: &mut TextInputQueue,
251    text: String,
252) {
253    for action in [
254        TextInputAction::Edit(actions::TextInputEdit::SelectAll),
255        TextInputAction::Edit(actions::TextInputEdit::Paste(text)),
256    ] {
257        text_input_queue.add(action);
258    }
259}
260
261#[derive(Component)]
262struct ListenToChanges;
263
264#[derive(Event)]
265struct TextInputChange(String);
266
267#[allow(clippy::type_complexity)]
268fn on_change(contents: Query<(Entity, &TextInputContents), (Changed<TextInputContents>, With<ListenToChanges>)>, mut commands: Commands) {
269    for (entity, contents) in contents.iter() {
270        commands.trigger_targets(TextInputChange(contents.get().to_string()), entity);
271    }
272}
273
274#[derive(Event)]
275struct FocusedChange(bool);
276
277#[derive(Component)]
278struct Focusable {
279    is_focused: bool,
280}
281
282fn on_focus_changed(
283    focused_option: Res<InputFocus>,
284    mut text_inputs: Query<(Entity, &mut Focusable)>,
285    mut commands: Commands,
286) {
287    for (entity, mut focusable) in text_inputs.iter_mut() {
288        if Some(entity) == focused_option.0 {
289            // TODO: remove condition when https://github.com/Dimchikkk/bevy_cosmic_edit/issues/145
290            if focusable.is_focused.not() {
291                focusable.is_focused = true;
292                commands.trigger_targets(FocusedChange(true), entity);
293            }
294        } else if focusable.is_focused {
295            focusable.is_focused = false;
296            commands.trigger_targets(FocusedChange(false), entity);
297        }
298    }
299}
300
301impl_haalka_methods! {
302    TextInput {
303        node: Node,
304        text_input_node: TextInputNode,
305        text_input_buffer: TextInputBuffer,
306        text_font: TextFont,
307        text_input_layout_info: TextInputLayoutInfo,
308        text_input_style: TextInputStyle,
309        text_color: TextColor,
310        text_input_prompt: TextInputPrompt,
311        text_input_queue: TextInputQueue,
312    }
313}
314
315/// Marker [`Resource`] to prevent clearing selection on focus change.
316#[derive(Resource, Default)]
317pub struct ClearSelectionOnFocusChangeDisabled;
318
319fn clear_selection_on_focus_change(
320    input_focus: Res<InputFocus>,
321    mut text_input_pipeline: ResMut<TextInputPipeline>,
322    mut buffers: Query<&mut TextInputBuffer>,
323    mut previous_input_focus: Local<Option<Entity>>,
324) {
325    if *previous_input_focus != input_focus.0 {
326        if let Some(entity) = *previous_input_focus && let Ok(mut buffer) = buffers.get_mut(entity) {
327            buffer
328                .editor
329                .borrow_with(&mut text_input_pipeline.font_system)
330                .set_selection(Selection::None);
331        }
332        *previous_input_focus = input_focus.0;
333    }
334}
335
336pub(super) fn plugin(app: &mut App) {
337    app
338    .add_plugins(TextInputPlugin)
339    .add_systems(
340        Update,
341        (
342            on_change.run_if(any_with_component::<ListenToChanges>),
343            on_focus_changed.run_if(resource_changed_or_removed::<InputFocus>),
344            clear_selection_on_focus_change.run_if(not(resource_exists::<ClearSelectionOnFocusChangeDisabled>))
345        )
346            .run_if(any_with_component::<TextInputNode>),
347    );
348}