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 pub clear_on_submit: bool,
106 pub mode: TextInputMode,
108 pub max_chars: Option<usize>,
110 pub allow_overwrite_mode: bool,
112 pub is_enabled: bool,
114 pub focus_on_pointer_down: bool,
116 pub unfocus_on_submit: bool,
118 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#[derive(Message)]
162pub struct SubmitText {
163 pub entity: Entity,
165 pub text: String,
167}
168
169#[derive(Copy, Clone, Debug, PartialEq)]
171pub enum TextInputMode {
172 MultiLine { wrap: Wrap },
175 SingleLine,
179}
180
181#[derive(Component)]
184pub enum TextInputFilter {
185 PositiveInteger,
188 Integer,
191 Decimal,
194 Hex,
197 Alphanumeric,
200 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 pub fn is_match(&self, text: &str) -> bool {
220 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 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#[derive(Component, Clone, Debug, Reflect)]
303#[reflect(Component, Default, Debug)]
304#[require(TextInputPromptLayoutInfo)]
305pub struct TextInputPrompt {
306 pub text: String,
308 pub font: Option<TextFont>,
311 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#[derive(Component, Copy, Clone, Debug, PartialEq, Reflect)]
337#[reflect(Component, Default, Debug, PartialEq)]
338pub struct TextInputStyle {
339 pub cursor_color: Color,
341 pub selection_color: Color,
343 pub selected_text_color: Option<Color>,
345 pub cursor_width: f32,
347 pub cursor_radius: f32,
349 pub cursor_height: f32,
351 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 pub shift: bool,
434 pub command: bool,
436 pub overwrite_mode: bool,
438}
439
440#[derive(Component, Default, Debug)]
442pub struct TextInputQueue {
443 pub actions: VecDeque<TextInputAction>,
444}
445
446impl TextInputQueue {
447 pub fn add(&mut self, action: TextInputAction) {
449 self.actions.push_back(action);
450 }
451
452 pub fn add_front(&mut self, action: TextInputAction) {
454 self.actions.push_front(action);
455 }
456
457 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}