Skip to main content

bevy_ui_text_input/
lib.rs

1pub mod actions;
2pub mod clipboard;
3pub mod edit;
4pub mod render;
5pub mod text_input_pipeline;
6
7use std::collections::VecDeque;
8
9use actions::TextInputAction;
10use bevy::app::{Plugin, PostUpdate};
11use bevy::asset::AssetEventSystems;
12use bevy::color::Color;
13use bevy::color::palettes::css::SKY_BLUE;
14use bevy::color::palettes::tailwind::GRAY_400;
15use bevy::ecs::component::Component;
16use bevy::ecs::entity::Entity;
17use bevy::ecs::lifecycle::HookContext;
18use bevy::ecs::message::Message;
19use bevy::ecs::observer::Observer;
20use bevy::ecs::query::Changed;
21use bevy::ecs::resource::Resource;
22use bevy::ecs::schedule::IntoScheduleConfigs;
23use bevy::ecs::system::Query;
24use bevy::ecs::world::DeferredWorld;
25use bevy::input_focus::InputFocus;
26use bevy::math::{Rect, Vec2};
27use bevy::prelude::ReflectComponent;
28use bevy::reflect::{Reflect, std_traits::ReflectDefault};
29use bevy::render::{ExtractSchedule, RenderApp};
30use bevy::text::{GlyphAtlasInfo, LineHeight, TextFont};
31use bevy::text::{Justify, TextColor};
32use bevy::ui::{Node, UiSystems};
33use bevy::ui_render::{RenderUiSystems, extract_text_sections};
34use cosmic_text::{Buffer, Change, Edit, Editor, Metrics, Wrap};
35use edit::{
36    cursor_blink_system, mouse_wheel_scroll, on_drag_text_input, on_focused_keyboard_input,
37    on_move_clear_multi_click, on_multi_click_set_selection, on_text_input_pressed,
38    process_text_input_queues,
39};
40use render::{extract_text_input_nodes, extract_text_input_prompts};
41use text_input_pipeline::{
42    TextInputPipeline, remove_dropped_font_atlas_sets_from_text_input_pipeline,
43    text_input_prompt_system, text_input_system,
44};
45
46pub struct TextInputPlugin;
47
48impl Plugin for TextInputPlugin {
49    fn build(&self, app: &mut bevy::app::App) {
50        app.add_message::<SubmitText>()
51            .add_plugins(bevy::input_focus::InputDispatchPlugin)
52            .init_resource::<TextInputGlobalState>()
53            .init_resource::<TextInputPipeline>()
54            .init_resource::<clipboard::Clipboard>()
55            .add_systems(
56                PostUpdate,
57                (
58                    remove_dropped_font_atlas_sets_from_text_input_pipeline
59                        .before(AssetEventSystems),
60                    (
61                        cursor_blink_system,
62                        mouse_wheel_scroll,
63                        process_text_input_queues,
64                        update_text_input_contents,
65                        text_input_system,
66                        text_input_prompt_system,
67                    )
68                        .chain()
69                        .in_set(UiSystems::PostLayout),
70                ),
71            );
72
73        let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
74            return;
75        };
76
77        render_app.add_systems(
78            ExtractSchedule,
79            (extract_text_input_prompts, extract_text_input_nodes)
80                .chain()
81                .in_set(RenderUiSystems::ExtractText)
82                .after(extract_text_sections),
83        );
84    }
85}
86
87#[derive(Component, Debug, Clone)]
88#[require(
89    Node,
90    TextInputBuffer,
91    TextFont,
92    TextInputLayoutInfo,
93    TextInputStyle,
94    TextColor,
95    TextInputQueue,
96    LineHeight
97)]
98#[component(
99    on_add = on_add_textinputnode,
100    on_remove = on_remove_unfocus,
101)]
102pub struct TextInputNode {
103    /// Whether the text should be cleared on submission
104    /// (Shift-Enter or just Enter in single-line mode)
105    pub clear_on_submit: bool,
106    /// Type of text input
107    pub mode: TextInputMode,
108    /// Maximum number of characters that can entered into the input buffer
109    pub max_chars: Option<usize>,
110    /// Should overwrite mode be available
111    pub allow_overwrite_mode: bool,
112    /// Can the text input be activated
113    pub is_enabled: bool,
114    /// Activate on pointer down
115    pub focus_on_pointer_down: bool,
116    /// Deactivate after text submitted
117    pub unfocus_on_submit: bool,
118    /// Text justification
119    pub justification: Justify,
120}
121
122impl Default for TextInputNode {
123    fn default() -> Self {
124        Self {
125            clear_on_submit: true,
126            mode: TextInputMode::default(),
127            max_chars: None,
128            allow_overwrite_mode: true,
129            is_enabled: true,
130            focus_on_pointer_down: true,
131            unfocus_on_submit: true,
132            justification: Justify::Left,
133        }
134    }
135}
136
137fn on_add_textinputnode(mut world: DeferredWorld, context: HookContext) {
138    for mut observer in [
139        Observer::new(on_drag_text_input),
140        Observer::new(on_text_input_pressed),
141        Observer::new(on_multi_click_set_selection),
142        Observer::new(on_move_clear_multi_click),
143        Observer::new(on_focused_keyboard_input),
144    ] {
145        observer.watch_entity(context.entity);
146        world.commands().spawn(observer);
147    }
148}
149
150fn on_remove_unfocus(mut world: DeferredWorld, context: HookContext) {
151    let mut input_focus = world.resource_mut::<InputFocus>();
152    if input_focus.0 == Some(context.entity) {
153        input_focus.0 = None;
154    }
155}
156
157#[deprecated(since = "0.6.0", note = "Use `SubmitText` instead")]
158pub type TextSubmitEvent = SubmitText;
159
160/// Sent when a text input submits its text
161#[derive(Message)]
162pub struct SubmitText {
163    /// The text input entity that submitted the text
164    pub entity: Entity,
165    /// The submitted text
166    pub text: String,
167}
168
169/// Mode of text input
170#[derive(Copy, Clone, Debug, PartialEq)]
171pub enum TextInputMode {
172    /// Scrolling text input
173    /// Submit on shift-enter
174    MultiLine { wrap: Wrap },
175    /// Single line text input
176    /// Scrolls horizontally
177    /// Submit on enter
178    SingleLine,
179}
180
181/// Any actions that modify a text input's text so that it fails
182/// to pass the filter are not applied.
183#[derive(Component)]
184pub enum TextInputFilter {
185    /// Positive integer input
186    /// accepts only digits
187    PositiveInteger,
188    /// Integer input
189    /// accepts only digits and a leading sign
190    Integer,
191    /// Decimal input
192    /// accepts only digits, a decimal point and a leading sign
193    Decimal,
194    /// Hexadecimal input
195    /// accepts only `0-9`, `a-f` and `A-F`
196    Hex,
197    /// Alphanumeric input
198    /// accepts only `0-9`, `a-z` and `A-Z`
199    Alphanumeric,
200    /// Custom filter
201    Custom(Box<dyn Fn(&str) -> bool + Send + Sync>),
202}
203
204impl core::fmt::Debug for TextInputFilter {
205    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
206        match self {
207            Self::PositiveInteger => f.write_str("PositiveInteger"),
208            Self::Integer => f.write_str("Integer"),
209            Self::Decimal => f.write_str("Decimal"),
210            Self::Hex => f.write_str("Hex"),
211            Self::Alphanumeric => f.write_str("Alphanumeric"),
212            Self::Custom(_) => f.write_str("Custom"),
213        }
214    }
215}
216
217impl TextInputFilter {
218    /// Returns true if the text passes the filter
219    pub fn is_match(&self, text: &str) -> bool {
220        // Always passes if the input is empty unless using a custom filter
221        if text.is_empty() && !matches!(self, Self::Custom(_)) {
222            return true;
223        }
224
225        match self {
226            TextInputFilter::PositiveInteger => text.chars().all(|c| c.is_ascii_digit()),
227            TextInputFilter::Integer => text
228                .strip_prefix('-')
229                .unwrap_or(text)
230                .chars()
231                .all(|c| c.is_ascii_digit()),
232            TextInputFilter::Decimal => text
233                .strip_prefix('-')
234                .unwrap_or(text)
235                .chars()
236                .try_fold(true, |is_int, c| match c {
237                    '.' if is_int => Ok(false),
238                    c if c.is_ascii_digit() => Ok(is_int),
239                    _ => Err(()),
240                })
241                .is_ok(),
242            TextInputFilter::Hex => text.chars().all(|c| c.is_ascii_hexdigit()),
243            TextInputFilter::Alphanumeric => text.chars().all(|c| c.is_ascii_alphanumeric()),
244            TextInputFilter::Custom(is_match) => is_match(text),
245        }
246    }
247
248    /// Create a custom filter
249    pub fn custom(filter_fn: impl Fn(&str) -> bool + Send + Sync + 'static) -> Self {
250        Self::Custom(Box::new(filter_fn))
251    }
252}
253
254impl Default for TextInputMode {
255    fn default() -> Self {
256        Self::MultiLine {
257            wrap: Wrap::WordOrGlyph,
258        }
259    }
260}
261
262impl TextInputMode {
263    pub fn wrap(&self) -> Wrap {
264        match self {
265            TextInputMode::MultiLine { wrap } => *wrap,
266            _ => Wrap::None,
267        }
268    }
269}
270
271#[derive(Component, Debug)]
272pub struct TextInputBuffer {
273    pub editor: Editor<'static>,
274    pub(crate) selection_rects: Vec<Rect>,
275    pub(crate) cursor_blink_time: f32,
276    pub(crate) needs_update: bool,
277    pub(crate) prompt_buffer: Option<Buffer>,
278    pub(crate) changes: cosmic_undo_2::Commands<Change>,
279}
280
281impl TextInputBuffer {
282    pub fn get_text(&self) -> String {
283        self.editor.with_buffer(get_text)
284    }
285}
286
287impl Default for TextInputBuffer {
288    fn default() -> Self {
289        Self {
290            editor: Editor::new(Buffer::new_empty(Metrics::new(20.0, 20.0))),
291            selection_rects: vec![],
292            cursor_blink_time: 0.,
293            needs_update: true,
294            prompt_buffer: None,
295            changes: cosmic_undo_2::Commands::default(),
296        }
297    }
298}
299
300/// Prompt displayed when the input is empty (including whitespace).
301/// Optional component.
302#[derive(Component, Clone, Debug, Reflect)]
303#[reflect(Component, Default, Debug)]
304#[require(TextInputPromptLayoutInfo)]
305pub struct TextInputPrompt {
306    /// Prompt's text
307    pub text: String,
308    /// The prompt's font.
309    /// If none, the text input's font is used.
310    pub font: Option<TextFont>,
311    /// The color of the prompt's text.
312    /// If none, the text input's `TextColor` is used.
313    pub color: Option<Color>,
314}
315
316impl TextInputPrompt {
317    pub fn new(text: impl Into<String>) -> Self {
318        Self {
319            text: text.into(),
320            ..Default::default()
321        }
322    }
323}
324
325impl Default for TextInputPrompt {
326    fn default() -> Self {
327        Self {
328            text: "Enter some text here".into(),
329            font: None,
330            color: Some(bevy::color::palettes::css::GRAY.into()),
331        }
332    }
333}
334
335/// Styling for a text cursor
336#[derive(Component, Copy, Clone, Debug, PartialEq, Reflect)]
337#[reflect(Component, Default, Debug, PartialEq)]
338pub struct TextInputStyle {
339    /// Color of the cursor
340    pub cursor_color: Color,
341    /// Selection color
342    pub selection_color: Color,
343    /// Selected text tint, if unset uses the `TextColor`
344    pub selected_text_color: Option<Color>,
345    /// Width of the cursor
346    pub cursor_width: f32,
347    /// Corner radius in logical pixels
348    pub cursor_radius: f32,
349    /// Normalized height of the cursor relative to the text block's line height.
350    pub cursor_height: f32,
351    /// Time cursor blinks in seconds
352    pub blink_interval: f32,
353}
354
355impl Default for TextInputStyle {
356    fn default() -> Self {
357        Self {
358            cursor_color: GRAY_400.into(),
359            selection_color: SKY_BLUE.into(),
360            selected_text_color: None,
361            cursor_width: 3.,
362            cursor_radius: 0.,
363            cursor_height: 1.,
364            blink_interval: 0.5,
365        }
366    }
367}
368
369fn get_text(buffer: &Buffer) -> String {
370    buffer
371        .lines
372        .iter()
373        .map(|buffer_line| buffer_line.text())
374        .fold(String::new(), |mut out, line| {
375            if !out.is_empty() {
376                out.push('\n');
377            }
378            out.push_str(line);
379            out
380        })
381}
382
383#[derive(Component, Clone, Default, Debug, Reflect)]
384#[reflect(Component, Default, Debug)]
385pub struct TextInputLayoutInfo {
386    pub glyphs: Vec<TextInputGlyph>,
387    pub size: Vec2,
388}
389
390#[derive(Component, Clone, Default, Debug, Reflect)]
391#[reflect(Component, Default, Debug)]
392pub struct TextInputPromptLayoutInfo {
393    pub glyphs: Vec<TextInputGlyph>,
394    pub size: Vec2,
395}
396
397#[derive(Debug, Clone, Reflect)]
398pub struct TextInputGlyph {
399    pub position: Vec2,
400    pub size: Vec2,
401    pub atlas_info: GlyphAtlasInfo,
402    pub span_index: usize,
403    pub line_index: usize,
404    pub byte_index: usize,
405    pub byte_length: usize,
406}
407
408#[derive(Default, Debug, Component, PartialEq)]
409pub struct TextInputContents {
410    text: String,
411}
412
413impl TextInputContents {
414    pub fn get(&self) -> &str {
415        &self.text
416    }
417}
418
419pub fn update_text_input_contents(
420    mut query: Query<(&TextInputBuffer, &mut TextInputContents), Changed<TextInputBuffer>>,
421) {
422    for (buffer, mut contents) in query.iter_mut() {
423        let text = buffer.get_text();
424        if contents.text != text {
425            contents.text = text;
426        }
427    }
428}
429
430#[derive(Resource, Default)]
431pub struct TextInputGlobalState {
432    /// Shift is held down
433    pub shift: bool,
434    /// Ctrl or Command key is held down
435    pub command: bool,
436    /// If true typed glyphs overwrite the glyph at the current cursor position, instead of inserting before it.
437    pub overwrite_mode: bool,
438}
439
440/// Queued `TextInputActions` to be processed by `process_text_input_queues` and applied to the `TextInputBuffer`
441#[derive(Component, Default, Debug)]
442pub struct TextInputQueue {
443    pub actions: VecDeque<TextInputAction>,
444}
445
446impl TextInputQueue {
447    /// Queue an action to be processed by `process_text_input_queues`
448    pub fn add(&mut self, action: TextInputAction) {
449        self.actions.push_back(action);
450    }
451
452    /// Add an action to the front of the queue
453    pub fn add_front(&mut self, action: TextInputAction) {
454        self.actions.push_front(action);
455    }
456
457    /// True if the queue is empty
458    pub fn is_empty(&self) -> bool {
459        self.actions.is_empty()
460    }
461}
462
463impl Iterator for TextInputQueue {
464    type Item = TextInputAction;
465
466    fn next(&mut self) -> Option<Self::Item> {
467        self.actions.pop_front()
468    }
469}