diff --git a/Cargo.toml b/Cargo.toml index 85cafbfab70bd..46be07fd52231 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -131,6 +131,7 @@ default = [ "animation", "bevy_asset", "bevy_audio", + "bevy_clipboard", "bevy_color", "bevy_core_pipeline", "bevy_core_widgets", @@ -307,6 +308,9 @@ bevy_log = ["bevy_internal/bevy_log"] # Enable input focus subsystem bevy_input_focus = ["bevy_internal/bevy_input_focus"] +# Clipboard access +bevy_clipboard = ["bevy_internal/bevy_clipboard"] + # Headless widget collection for Bevy UI. bevy_core_widgets = ["bevy_internal/bevy_core_widgets"] @@ -2691,12 +2695,12 @@ category = "Input" wasm = false [[example]] -name = "text_input" -path = "examples/input/text_input.rs" +name = "ime_text_input" +path = "examples/input/ime_text_input.rs" doc-scrape-examples = true -[package.metadata.example.text_input] -name = "Text Input" +[package.metadata.example.ime_text_input] +name = "IME Text Input" description = "Simple text input with IME support" category = "Input" wasm = false @@ -3360,6 +3364,17 @@ description = "Illustrates creating and updating a button" category = "UI (User Interface)" wasm = true +[[example]] +name = "clipboard" +path = "examples/ui/clipboard.rs" +doc-scrape-examples = true + +[package.metadata.example.clipboard] +name = "Clipboard" +description = "Demonstrates accessing the clipboard to retrieve and display text" +category = "UI (User Interface)" +wasm = true + [[example]] name = "display_and_visibility" path = "examples/ui/display_and_visibility.rs" @@ -3471,6 +3486,50 @@ description = "Illustrates creating and updating text" category = "UI (User Interface)" wasm = true +[[example]] +name = "text_box" +path = "examples/ui/text_box.rs" +doc-scrape-examples = true + +[package.metadata.example.text_box] +name = "Text Box" +description = "Example demonstrating a text input box" +category = "UI (User Interface)" +wasm = true + +[[example]] +name = "text_input" +path = "examples/ui/text_input.rs" +doc-scrape-examples = true + +[package.metadata.example.text_input] +name = "Text Input" +description = "Example demonstrating multiple text inputs" +category = "UI (User Interface)" +wasm = true + +[[example]] +name = "password_input" +path = "examples/ui/password_input.rs" +doc-scrape-examples = true + +[package.metadata.example.password_input] +name = "Password Input" +description = "Example demonstrating a password input field" +category = "UI (User Interface)" +wasm = true + +[[example]] +name = "text_field" +path = "examples/ui/text_field.rs" +doc-scrape-examples = true + +[package.metadata.example.text_field] +name = "Single Line Text Input" +description = "Example demonstrating a single line text input" +category = "UI (User Interface)" +wasm = true + [[example]] name = "text_background_colors" path = "examples/ui/text_background_colors.rs" diff --git a/assets/fonts/NotoNaskhArabic-Medium.ttf b/assets/fonts/NotoNaskhArabic-Medium.ttf new file mode 100644 index 0000000000000..692aea41e876a Binary files /dev/null and b/assets/fonts/NotoNaskhArabic-Medium.ttf differ diff --git a/assets/fonts/NotoSans-Medium.ttf b/assets/fonts/NotoSans-Medium.ttf new file mode 100644 index 0000000000000..a44124bb363c8 Binary files /dev/null and b/assets/fonts/NotoSans-Medium.ttf differ diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt new file mode 100644 index 0000000000000..d4e170557fc8b --- /dev/null +++ b/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Orbitron-Medium.ttf b/assets/fonts/Orbitron-Medium.ttf new file mode 100644 index 0000000000000..ed16a64eb20f6 Binary files /dev/null and b/assets/fonts/Orbitron-Medium.ttf differ diff --git a/assets/sounds/invalid_key.ogg b/assets/sounds/invalid_key.ogg new file mode 100644 index 0000000000000..fb6c2ff86a0ca Binary files /dev/null and b/assets/sounds/invalid_key.ogg differ diff --git a/assets/sounds/key_press.ogg b/assets/sounds/key_press.ogg new file mode 100644 index 0000000000000..a80cf5f284fab Binary files /dev/null and b/assets/sounds/key_press.ogg differ diff --git a/crates/bevy_clipboard/Cargo.toml b/crates/bevy_clipboard/Cargo.toml new file mode 100644 index 0000000000000..2020be26a993e --- /dev/null +++ b/crates/bevy_clipboard/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "bevy_clipboard" +version = "0.17.0-dev" +edition = "2024" +description = "Provides clipboard support for Bevy Engine" +homepage = "https://bevyengine.org" +repository = "https://github.com/bevyengine/bevy" +license = "MIT OR Apache-2.0" +keywords = ["bevy", "clipboard"] + +[dependencies] +# bevy +bevy_app = { path = "../bevy_app", version = "0.17.0-dev", default-features = false } +bevy_ecs = { path = "../bevy_ecs", version = "0.17.0-dev", default-features = false } +bevy_platform = { path = "../bevy_platform", version = "0.17.0-dev", default-features = false } + +[target.'cfg(any(windows, unix))'.dependencies] +arboard = { version = "3.5.0", default-features = false } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = { version = "0.2" } +web-sys = { version = "0.3", features = ["Navigator", "Clipboard"] } +wasm-bindgen-futures = "0.4" + +[lints] +workspace = true + +[package.metadata.docs.rs] +rustdoc-args = ["-Zunstable-options", "--generate-link-to-definition"] +all-features = true diff --git a/crates/bevy_clipboard/LICENSE-APACHE b/crates/bevy_clipboard/LICENSE-APACHE new file mode 100644 index 0000000000000..d9a10c0d8e868 --- /dev/null +++ b/crates/bevy_clipboard/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/crates/bevy_clipboard/LICENSE-MIT b/crates/bevy_clipboard/LICENSE-MIT new file mode 100644 index 0000000000000..9cf106272ac3b --- /dev/null +++ b/crates/bevy_clipboard/LICENSE-MIT @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/bevy_clipboard/README.md b/crates/bevy_clipboard/README.md new file mode 100644 index 0000000000000..d96116fb9d4d8 --- /dev/null +++ b/crates/bevy_clipboard/README.md @@ -0,0 +1,7 @@ +# Bevy Clipboard + +[![License](https://img.shields.io/badge/license-MIT%2FApache-blue.svg)](https://github.com/bevyengine/bevy#license) +[![Crates.io](https://img.shields.io/crates/v/bevy_clipboard.svg)](https://crates.io/crates/bevy_clipboard) +[![Downloads](https://img.shields.io/crates/d/bevy_clipboard.svg)](https://crates.io/crates/bevy_clipboard) +[![Docs](https://docs.rs/bevy_clipboard/badge.svg)](https://docs.rs/bevy_clipboard/latest/bevy_clipboard/) +[![Discord](https://img.shields.io/discord/691052431525675048.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/bevy) diff --git a/crates/bevy_clipboard/src/lib.rs b/crates/bevy_clipboard/src/lib.rs new file mode 100644 index 0000000000000..9585319975323 --- /dev/null +++ b/crates/bevy_clipboard/src/lib.rs @@ -0,0 +1,251 @@ +//! This crate provides a platform-agnostic interface for accessing the clipboard + +extern crate alloc; + +use bevy_ecs::resource::Resource; + +#[cfg(target_arch = "wasm32")] +use {alloc::sync::Arc, bevy_platform::sync::Mutex, wasm_bindgen_futures::JsFuture}; + +/// The clipboard prelude +pub mod prelude { + pub use crate::{Clipboard, ClipboardRead}; +} + +/// Clipboard plugin +#[derive(Default)] +pub struct ClipboardPlugin; + +impl bevy_app::Plugin for ClipboardPlugin { + fn build(&self, app: &mut bevy_app::App) { + app.init_resource::(); + } +} + +/// Represents an attempt to read from the clipboard. +/// +/// On desktop targets the result is available immediately. +/// On wasm32 the result is fetched asynchronously. +#[derive(Debug)] +pub enum ClipboardRead { + /// The clipboard contents are ready to be accessed. + Ready(Result), + #[cfg(target_arch = "wasm32")] + /// The clipboard contents are being fetched asynchronously. + Pending(Arc>>>), +} + +impl ClipboardRead { + /// The result of an attempt to read from the clipboard, once ready. + /// If the result is still pending, returns `None`. + pub fn poll_result(&mut self) -> Option> { + match self { + #[cfg(target_arch = "wasm32")] + Self::Pending(shared) => { + if let Some(contents) = shared.lock().ok().and_then(|mut inner| inner.take()) { + *self = Self::Ready(Err(ClipboardError::ContentTaken)); + Some(contents) + } else { + None + } + } + Self::Ready(inner) => { + Some(core::mem::replace(inner, Err(ClipboardError::ContentTaken))) + } + } + } +} + +/// Resource providing access to the clipboard +#[cfg(unix)] +#[derive(Resource)] +pub struct Clipboard(Option); + +#[cfg(unix)] +impl Default for Clipboard { + fn default() -> Self { + { + Self(arboard::Clipboard::new().ok()) + } + } +} + +/// Resource providing access to the clipboard +#[cfg(not(unix))] +#[derive(Resource, Default)] +pub struct Clipboard; + +impl Clipboard { + /// Fetches UTF-8 text from the clipboard and returns it via a `ClipboardRead`. + /// + /// On Windows and Unix `ClipboardRead`s are completed instantly, on wasm32 the result is fetched asynchronously. + pub fn fetch_text(&mut self) -> ClipboardRead { + #[cfg(unix)] + { + ClipboardRead::Ready(if let Some(clipboard) = self.0.as_mut() { + clipboard.get_text().map_err(ClipboardError::from) + } else { + Err(ClipboardError::ClipboardNotSupported) + }) + } + + #[cfg(windows)] + { + ClipboardRead::Ready( + arboard::Clipboard::new() + .and_then(|mut clipboard| clipboard.get_text()) + .map_err(ClipboardError::from), + ) + } + + #[cfg(target_arch = "wasm32")] + { + if let Some(clipboard) = web_sys::window().map(|w| w.navigator().clipboard()) { + let shared = Arc::new(Mutex::new(None)); + let shared_clone = shared.clone(); + wasm_bindgen_futures::spawn_local(async move { + let text = JsFuture::from(clipboard.read_text()).await; + let text = match text { + Ok(text) => text.as_string().ok_or(ClipboardError::ConversionFailure), + Err(_) => Err(ClipboardError::ContentNotAvailable), + }; + shared.lock().unwrap().replace(text); + }); + ClipboardRead::Pending(shared_clone) + } else { + ClipboardRead::Ready(Err(ClipboardError::ClipboardNotSupported)) + } + } + + #[cfg(not(any(unix, windows, target_arch = "wasm32")))] + { + ClipboardRead::Ready(Err(ClipboardError::ClipboardNotSupported)) + } + } + + /// Asynchronously retrieves UTF-8 text from the system clipboard. + pub async fn fetch_text_async(&mut self) -> Result { + #[cfg(unix)] + { + if let Some(clipboard) = self.0.as_mut() { + clipboard.get_text().map_err(ClipboardError::from) + } else { + Err(ClipboardError::ClipboardNotSupported) + } + } + + #[cfg(windows)] + { + arboard::Clipboard::new() + .and_then(|mut clipboard| clipboard.get_text()) + .map_err(ClipboardError::from) + } + + #[cfg(target_arch = "wasm32")] + { + use wasm_bindgen::JsCast; + use wasm_bindgen_futures::JsFuture; + + let clipboard = web_sys::window() + .and_then(|w| w.navigator().clipboard()) + .ok_or(ClipboardError::ClipboardNotSupported)?; + + let result = JsFuture::from(clipboard.read_text()).await; + match result { + Ok(val) => val.as_string().ok_or(ClipboardError::ConversionFailure), + Err(_) => Err(ClipboardError::ContentNotAvailable), + } + } + + #[cfg(not(any(unix, windows, target_arch = "wasm32")))] + { + Err(ClipboardError::ClipboardNotSupported) + } + } + + /// Places the text onto the clipboard. Any valid UTF-8 string is accepted. + /// + /// # Errors + /// + /// Returns error if `text` failed to be stored on the clipboard. + pub fn set_text<'a, T: Into>>( + &mut self, + text: T, + ) -> Result<(), ClipboardError> { + #[cfg(unix)] + { + if let Some(clipboard) = self.0.as_mut() { + clipboard.set_text(text).map_err(ClipboardError::from) + } else { + Err(ClipboardError::ClipboardNotSupported) + } + } + + #[cfg(windows)] + { + arboard::Clipboard::new() + .and_then(|mut clipboard| clipboard.set_text(text)) + .map_err(ClipboardError::from) + } + + #[cfg(target_arch = "wasm32")] + { + if let Some(clipboard) = web_sys::window().map(|w| w.navigator().clipboard()) { + let text = text.into().to_string(); + wasm_bindgen_futures::spawn_local(async move { + let _ = JsFuture::from(clipboard.write_text(&text)).await; + }); + Ok(()) + } else { + Err(ClipboardError::ClipboardNotSupported) + } + } + + #[cfg(not(any(unix, windows, target_arch = "wasm32")))] + { + Err(ClipboardError::ClipboardNotSupported) + } + } +} + +/// An error that might happen during a clipboard operation. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub enum ClipboardError { + /// Clipboard contents were unavailable or not in the expected format. + ContentNotAvailable, + + /// No suitable clipboard backend was available + ClipboardNotSupported, + + /// Clipboard access is temporarily locked by another process or thread. + ClipboardOccupied, + + /// The data could not be converted to or from the required format. + ConversionFailure, + + /// The clipboard content was already taken from the `ClipboardRead`. + ContentTaken, + + /// An unknown error + Unknown { + /// String describing the error + description: String, + }, +} + +#[cfg(any(windows, unix))] +impl From for ClipboardError { + fn from(value: arboard::Error) -> Self { + match value { + arboard::Error::ContentNotAvailable => ClipboardError::ContentNotAvailable, + arboard::Error::ClipboardNotSupported => ClipboardError::ClipboardNotSupported, + arboard::Error::ClipboardOccupied => ClipboardError::ClipboardOccupied, + arboard::Error::ConversionFailure => ClipboardError::ConversionFailure, + arboard::Error::Unknown { description } => ClipboardError::Unknown { description }, + _ => ClipboardError::Unknown { + description: "Unknown arboard error variant".to_owned(), + }, + } + } +} diff --git a/crates/bevy_internal/Cargo.toml b/crates/bevy_internal/Cargo.toml index 6caaa09198f53..ba1a7d51ecd15 100644 --- a/crates/bevy_internal/Cargo.toml +++ b/crates/bevy_internal/Cargo.toml @@ -375,6 +375,9 @@ web = [ "bevy_tasks/web", ] +# Clipboard support +bevy_clipboard = ["dep:bevy_clipboard"] + hotpatching = ["bevy_app/hotpatching", "bevy_ecs/hotpatching"] gltf_convert_coordinates_default = [ @@ -465,6 +468,7 @@ bevy_window = { path = "../bevy_window", optional = true, version = "0.17.0-dev" "bevy_reflect", ] } bevy_winit = { path = "../bevy_winit", optional = true, version = "0.17.0-dev", default-features = false } +bevy_clipboard = { path = "../bevy_clipboard", optional = true, version = "0.17.0-dev" } [lints] workspace = true diff --git a/crates/bevy_internal/src/default_plugins.rs b/crates/bevy_internal/src/default_plugins.rs index cdb59921dcc74..622582e4c1fc5 100644 --- a/crates/bevy_internal/src/default_plugins.rs +++ b/crates/bevy_internal/src/default_plugins.rs @@ -68,6 +68,8 @@ plugin_group! { bevy_dev_tools:::DevToolsPlugin, #[cfg(feature = "bevy_ci_testing")] bevy_dev_tools::ci_testing:::CiTestingPlugin, + #[cfg(feature = "bevy_clipboard")] + bevy_clipboard:::ClipboardPlugin, #[cfg(feature = "hotpatching")] bevy_app::hotpatch:::HotPatchPlugin, #[plugin_group] diff --git a/crates/bevy_internal/src/lib.rs b/crates/bevy_internal/src/lib.rs index 61879abdc9086..9c8f33dad1fa7 100644 --- a/crates/bevy_internal/src/lib.rs +++ b/crates/bevy_internal/src/lib.rs @@ -27,6 +27,8 @@ pub use bevy_asset as asset; pub use bevy_audio as audio; #[cfg(feature = "bevy_camera")] pub use bevy_camera as camera; +#[cfg(feature = "bevy_clipboard")] +pub use bevy_clipboard as clipboard; #[cfg(feature = "bevy_color")] pub use bevy_color as color; #[cfg(feature = "bevy_core_pipeline")] diff --git a/crates/bevy_internal/src/prelude.rs b/crates/bevy_internal/src/prelude.rs index c8ba27ea82c1e..8aed1aa1ddc05 100644 --- a/crates/bevy_internal/src/prelude.rs +++ b/crates/bevy_internal/src/prelude.rs @@ -86,3 +86,7 @@ pub use crate::gltf::prelude::*; #[doc(hidden)] #[cfg(feature = "bevy_picking")] pub use crate::picking::prelude::*; + +#[doc(hidden)] +#[cfg(feature = "bevy_clipboard")] +pub use crate::clipboard::prelude::*; diff --git a/crates/bevy_text/Cargo.toml b/crates/bevy_text/Cargo.toml index 58bcfb1c5b7b2..6dfd325217fb3 100644 --- a/crates/bevy_text/Cargo.toml +++ b/crates/bevy_text/Cargo.toml @@ -16,6 +16,7 @@ default_font = [] bevy_app = { path = "../bevy_app", version = "0.17.0-dev" } bevy_asset = { path = "../bevy_asset", version = "0.17.0-dev" } bevy_color = { path = "../bevy_color", version = "0.17.0-dev" } +bevy_clipboard = { path = "../bevy_clipboard", version = "0.17.0-dev" } bevy_derive = { path = "../bevy_derive", version = "0.17.0-dev" } bevy_ecs = { path = "../bevy_ecs", version = "0.17.0-dev" } bevy_image = { path = "../bevy_image", version = "0.17.0-dev" } @@ -34,6 +35,7 @@ bevy_platform = { path = "../bevy_platform", version = "0.17.0-dev", default-fea # other cosmic-text = { version = "0.14", features = ["shape-run-cache"] } +cosmic_undo_2 = { version = "0.2.0" } thiserror = { version = "2", default-features = false } serde = { version = "1", features = ["derive"] } smallvec = { version = "1", default-features = false } diff --git a/crates/bevy_text/src/input.rs b/crates/bevy_text/src/input.rs new file mode 100644 index 0000000000000..f69c2ec23413c --- /dev/null +++ b/crates/bevy_text/src/input.rs @@ -0,0 +1,1368 @@ +use crate::buffer_dimensions; +use crate::load_font_to_fontdb; +use crate::CosmicFontSystem; +use crate::Font; +use crate::FontAtlasSets; +use crate::FontSmoothing; +use crate::Justify; +use crate::LineBreak; +use crate::LineHeight; +use crate::PositionedGlyph; +use crate::TextBounds; +use crate::TextError; +use crate::TextFont; +use crate::TextLayoutInfo; +use crate::TextPipeline; +use alloc::collections::VecDeque; +use bevy_asset::Assets; +use bevy_asset::Handle; +use bevy_clipboard::Clipboard; +use bevy_clipboard::ClipboardRead; +use bevy_derive::Deref; +use bevy_derive::DerefMut; +use bevy_ecs::change_detection::DetectChanges; +use bevy_ecs::change_detection::DetectChangesMut; +use bevy_ecs::component::Component; +use bevy_ecs::entity::Entity; +use bevy_ecs::event::EntityEvent; +use bevy_ecs::hierarchy::ChildOf; +use bevy_ecs::lifecycle::HookContext; +use bevy_ecs::prelude::ReflectComponent; +use bevy_ecs::query::Changed; +use bevy_ecs::query::Or; +use bevy_ecs::schedule::SystemSet; +use bevy_ecs::system::Commands; +use bevy_ecs::system::Query; +use bevy_ecs::system::Res; +use bevy_ecs::system::ResMut; +use bevy_ecs::world::DeferredWorld; +use bevy_ecs::world::Ref; +use bevy_image::Image; +use bevy_image::TextureAtlasLayout; +use bevy_math::IVec2; +use bevy_math::Rect; +use bevy_math::UVec2; +use bevy_math::Vec2; +use bevy_reflect::prelude::ReflectDefault; +use bevy_reflect::Reflect; +use cosmic_text::Action; +use cosmic_text::BorrowedWithFontSystem; +use cosmic_text::Buffer; +use cosmic_text::BufferLine; +use cosmic_text::Edit; +use cosmic_text::Editor; +use cosmic_text::Metrics; +pub use cosmic_text::Motion; +use cosmic_text::Selection; +/// Systems handling text input update and layout +#[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)] +pub struct TextInputSystems; + +/// Get the text from a cosmic text buffer +fn get_cosmic_text_buffer_contents(buffer: &Buffer) -> String { + buffer + .lines + .iter() + .map(BufferLine::text) + .fold(String::new(), |mut out, line| { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(line); + out + }) +} + +/// The text input buffer. +/// Primary component that contains the text layout. +/// +/// To determine if the `TextLayoutInfo` needs to be updated check the `redraw` method on the `editor` buffer. +/// Change detection is not reliable as the editor needs to be borrowed mutably during updates. +#[derive(Component, Debug)] +#[require(TextInputAttributes, TextInputTarget, TextInputActions, TextLayoutInfo)] +pub struct TextInputBuffer { + /// The cosmic text editor buffer + pub editor: Editor<'static>, + /// Space advance width for the current font + pub space_advance: f32, +} + +impl Default for TextInputBuffer { + fn default() -> Self { + Self { + editor: Editor::new(Buffer::new_empty(Metrics::new(20.0, 20.0))), + space_advance: 20., + } + } +} + +impl TextInputBuffer { + /// Use the cosmic text buffer mutably + pub fn with_buffer_mut(&mut self, f: F) -> T + where + F: FnOnce(&mut Buffer) -> T, + { + self.editor.with_buffer_mut(f) + } + + /// Use the cosmic text buffer + pub fn with_buffer(&self, f: F) -> T + where + F: FnOnce(&Buffer) -> T, + { + self.editor.with_buffer(f) + } + + /// True if the buffer is empty + pub fn is_empty(&self) -> bool { + self.with_buffer(|buffer| { + buffer.lines.len() == 0 + || (buffer.lines.len() == 1 && buffer.lines[0].text().is_empty()) + }) + } + + /// Get the text contained in the text buffer + pub fn get_text(&self) -> String { + self.editor.with_buffer(get_cosmic_text_buffer_contents) + } +} + +/// Component containing the change history for a text input. +/// Text input entities without this component will ignore undo and redo actions. +#[derive(Component, Debug, Default)] +pub struct TextInputUndoHistory { + /// The commands to undo and undo + pub changes: cosmic_undo_2::Commands, +} + +impl TextInputUndoHistory { + /// Clear the history for the text input + pub fn clear(&mut self) { + self.changes.clear(); + } +} + +/// Details of the target the text input will be rendered to +#[derive(Component, PartialEq, Debug, Default)] +pub struct TextInputTarget { + /// size of the target + pub size: Vec2, + /// scale factor of the target + pub scale_factor: f32, +} + +impl TextInputTarget { + /// Returns true if the target has zero or negative size. + pub fn is_empty(&self) -> bool { + (self.scale_factor * self.size).cmple(Vec2::ZERO).all() + } +} + +/// Contains the current text in the text input buffer +/// If inserted, replaces the current text in the text buffer +#[derive(Component, PartialEq, Debug, Default, Deref)] +#[component( + on_insert = on_insert_text_input_value, +)] +pub struct TextInputValue(String); + +impl TextInputValue { + /// New text, when inserted replaces the current text in the text buffer + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// Get the current text + pub fn get(&self) -> &str { + &self.0 + } +} + +/// Set the text input with the text from the `TextInputValue` when inserted. +fn on_insert_text_input_value(mut world: DeferredWorld, context: HookContext) { + if let Some(value) = world.get::(context.entity) { + let value = value.0.clone(); + if let Some(mut actions) = world + .entity_mut(context.entity) + .get_mut::() + { + actions.queue(TextInputAction::SetText(value)); + } + } +} + +/// Common text input properties set by the user that +/// require a layout recomputation or font update on changes. +#[derive(Component, Debug, PartialEq)] +pub struct TextInputAttributes { + /// The text input's font, also used for any prompt or password mask. + /// A text input's glyphs must all be from the same font. + pub font: Handle, + /// The size of the font. + /// A text input's glyphs must all be the same size. + pub font_size: f32, + /// The height of each line. + /// A text input's lines must all be the same height. + pub line_height: LineHeight, + /// Determines how lines will be broken + pub line_break: LineBreak, + /// The horizontal alignment for all the text in the text input buffer. + pub justify: Justify, + /// Controls text antialiasing + pub font_smoothing: FontSmoothing, + /// Maximum number of glyphs the text input buffer can contain. + /// Any edits that extend the length above `max_chars` are ignored. + /// If set on a buffer longer than `max_chars` the buffer will be truncated. + pub max_chars: Option, + /// The number of lines the buffer will display at once. + /// Limited by the size of the target. + /// If None or equal or less than 0, will fill the target space. + pub lines: Option, + /// Clear on submit + pub clear_on_submit: bool, +} + +impl Default for TextInputAttributes { + fn default() -> Self { + Self { + font: Default::default(), + font_size: 20., + line_height: LineHeight::RelativeToFont(1.2), + font_smoothing: Default::default(), + justify: Default::default(), + line_break: Default::default(), + max_chars: None, + lines: None, + clear_on_submit: false, + } + } +} + +/// Any actions that modify a text input's text so that it fails +/// to pass the filter are not applied. +#[derive(Component)] +pub enum TextInputFilter { + /// Positive integer input + /// accepts only digits + PositiveInteger, + /// Integer input + /// accepts only digits and a leading sign + Integer, + /// Decimal input + /// accepts only digits, a decimal point and a leading sign + Decimal, + /// Hexadecimal input + /// accepts only `0-9`, `a-f` and `A-F` + Hex, + /// Alphanumeric input + /// accepts only `0-9`, `a-z` and `A-Z` + Alphanumeric, + /// Custom filter + Custom(Box bool + Send + Sync>), +} + +impl TextInputFilter { + /// Returns true if the text passes the filter + pub fn is_match(&self, text: &str) -> bool { + // Always passes if the input is empty unless using a custom filter + if text.is_empty() && !matches!(self, Self::Custom(_)) { + return true; + } + + match self { + TextInputFilter::PositiveInteger => text.chars().all(|c| c.is_ascii_digit()), + TextInputFilter::Integer => text + .strip_prefix('-') + .unwrap_or(text) + .chars() + .all(|c| c.is_ascii_digit()), + TextInputFilter::Decimal => text + .strip_prefix('-') + .unwrap_or(text) + .chars() + .try_fold(true, |is_int, c| match c { + '.' if is_int => Ok(false), + c if c.is_ascii_digit() => Ok(is_int), + _ => Err(()), + }) + .is_ok(), + TextInputFilter::Hex => text.chars().all(|c| c.is_ascii_hexdigit()), + TextInputFilter::Alphanumeric => text.chars().all(|c| c.is_ascii_alphanumeric()), + TextInputFilter::Custom(is_match) => is_match(text), + } + } + + /// Create a custom filter + pub fn custom(filter_fn: impl Fn(&str) -> bool + Send + Sync + 'static) -> Self { + Self::Custom(Box::new(filter_fn)) + } +} + +/// Add this component to hide the text input buffer contents +/// by replacing the characters with `mask_char`. +/// +/// Should only be used with monospaced fonts. +/// With variable width fonts mouse picking and horizontal scrolling +/// may not work correctly. +#[derive(Component)] +pub struct TextInputPasswordMask { + /// If true the password will not be hidden + pub show_password: bool, + /// Char that will replace the masked input characters, by default `*` + pub mask_char: char, + /// Buffer mirroring the actual text input buffer but only containing `mask_char`s + editor: Editor<'static>, +} + +impl Default for TextInputPasswordMask { + fn default() -> Self { + Self { + show_password: false, + mask_char: '*', + editor: Editor::new(Buffer::new_empty(Metrics::new(20.0, 20.0))), + } + } +} + +/// Text input commands queue +#[derive(Component, Default)] +pub struct TextInputActions { + /// Commands to be applied before the text input is updated + pub queue: VecDeque, +} + +impl TextInputActions { + /// queue an action + pub fn queue(&mut self, command: TextInputAction) { + self.queue.push_back(command); + } +} + +/// Deferred text input edit and navigation actions applied by the `apply_text_input_actions` system. +#[derive(Debug)] +pub enum TextInputAction { + /// Copy the selected text into the clipboard. Does nothing if no text selected. + Copy, + /// Copy the selected text into the clipboard, then delete the selected text. Does nothing if no text selected. + Cut, + /// Insert the contents of the clipboard at the current cursor position. Does nothing if the clipboard is empty. + Paste, + InsertString(String), + PasteDeferred(ClipboardRead), + /// Move the cursor with some motion. + Motion { + /// The motion to perform. + motion: Motion, + /// Select the text from the initial cursor position to the end of the motion. + with_select: bool, + }, + /// Insert a character at the cursor. If there is a selection, replaces the selection with the character instead. + Insert(char), + /// Set the character at the cursor, overwriting the previous character. Inserts if cursor is at the end of a line. + /// If there is a selection, replaces the selection with the character instead. + Overwrite(char), + /// Start a new line. + NewLine, + /// Delete the character behind the cursor. + /// If there is a selection, deletes the selection instead. + Backspace, + /// Delete the character a the cursor. + /// If there is a selection, deletes the selection instead. + Delete, + /// Indent at the cursor. + Indent, + /// Unindent at the cursor. + Unindent, + /// Moves the cursor to the character at the given position. + Click(IVec2), + /// Selects the word at the given position. + DoubleClick(IVec2), + /// Selects the line at the given position. + TripleClick(IVec2), + /// Select the text up to the given position + Drag(IVec2), + /// Scroll vertically by the given number of lines. + /// Negative values scroll upwards towards the start of the text, positive downwards to the end of the text. + Scroll { + /// Number of lines to scroll. + /// Negative values scroll upwards towards the start of the text, positive downwards to the end of the text. + lines: i32, + }, + /// Undo the previous action. + Undo, + /// Redo an undone action. Must directly follow an Undo. + Redo, + /// Select the entire contents of the text input buffer. + SelectAll, + /// Select the line at the cursor. + SelectLine, + /// Clear any selection. + Escape, + /// Clear the text input buffer. + Clear, + /// Set the contents of the text input buffer. The existing contents is discarded. + SetText(String), + /// Submit the contents of the text input buffer + Submit, +} + +impl TextInputAction { + /// An action that moves the cursor. + /// If `with_select` is true, it selects as it moves + pub fn motion(motion: Motion, with_select: bool) -> Self { + Self::Motion { + motion, + with_select, + } + } +} + +/// apply a motion action to the editor buffer +pub fn apply_motion<'a>( + editor: &mut BorrowedWithFontSystem>, + shift_pressed: bool, + motion: Motion, +) { + if shift_pressed { + if editor.selection() == Selection::None { + let cursor = editor.cursor(); + editor.set_selection(Selection::Normal(cursor)); + } + } else { + editor.action(Action::Escape); + } + editor.action(Action::Motion(motion)); +} + +/// Returns true if the cursor is at the end of a line +pub fn is_cursor_at_end_of_line(editor: &mut BorrowedWithFontSystem>) -> bool { + let cursor = editor.cursor(); + editor.with_buffer(|buffer| { + buffer + .lines + .get(cursor.line) + .map(|line| cursor.index == line.text().len()) + .unwrap_or(false) + }) +} + +/// apply an action from the undo history to the text input buffer +fn apply_action<'a>( + editor: &mut BorrowedWithFontSystem>, + action: cosmic_undo_2::Action<&cosmic_text::Change>, +) { + match action { + cosmic_undo_2::Action::Do(change) => { + editor.apply_change(change); + } + cosmic_undo_2::Action::Undo(change) => { + let mut reversed = change.clone(); + reversed.reverse(); + editor.apply_change(&reversed); + } + } + editor.set_redraw(true); +} + +/// Apply the queued actions for each text input, with special case for submit actions. +/// Then update [`TextInputValue`]s +pub fn apply_text_input_actions( + mut commands: Commands, + mut font_system: ResMut, + mut text_input_query: Query<( + Entity, + &mut TextInputBuffer, + &mut TextInputActions, + &TextInputAttributes, + Option<&TextInputFilter>, + Option<&mut TextInputUndoHistory>, + Option<&mut TextInputValue>, + )>, + mut clipboard: Option>, +) { + for ( + entity, + mut buffer, + mut text_input_actions, + attribs, + maybe_filter, + mut maybe_history, + maybe_value, + ) in text_input_query.iter_mut() + { + while let Some(action) = text_input_actions.queue.pop_front() { + match action { + TextInputAction::Paste => { + if let Some(clipboard) = clipboard.as_mut() { + text_input_actions + .queue + .push_front(TextInputAction::PasteDeferred(clipboard.fetch_text())); + } + } + TextInputAction::PasteDeferred(mut clipboard_read) => { + if let Some(text) = clipboard_read.poll_result() { + if let Ok(text) = text { + let _ = apply_text_input_action( + buffer.editor.borrow_with(&mut font_system), + maybe_history.as_mut().map(AsMut::as_mut), + maybe_filter, + attribs.max_chars, + clipboard.as_mut(), + TextInputAction::InsertString(text), + ); + } + } else { + text_input_actions + .queue + .push_front(TextInputAction::PasteDeferred(clipboard_read)); + } + } + TextInputAction::Submit => { + commands.trigger_targets( + TextInputEvent::Submission { + text: buffer.get_text(), + text_input: entity, + }, + entity, + ); + + if attribs.clear_on_submit { + apply_text_input_action( + buffer.editor.borrow_with(&mut font_system), + maybe_history.as_mut().map(AsMut::as_mut), + maybe_filter, + attribs.max_chars, + clipboard.as_mut(), + TextInputAction::Clear, + ); + + if let Some(history) = maybe_history.as_mut() { + history.clear(); + } + } + } + action => { + if !apply_text_input_action( + buffer.editor.borrow_with(&mut font_system), + maybe_history.as_mut().map(AsMut::as_mut), + maybe_filter, + attribs.max_chars, + clipboard.as_mut(), + action, + ) { + commands.trigger_targets( + TextInputEvent::InvalidInput { text_input: entity }, + entity, + ); + } + } + } + } + + let contents = buffer.get_text(); + if let Some(mut value) = maybe_value { + if value.0 != contents { + value.0 = contents; + commands + .trigger_targets(TextInputEvent::ValueChanged { text_input: entity }, entity); + } + } + } +} + +/// update the text input buffer when a non-text edit change happens like +/// the font or line height changing and the buffer's metrics and attributes need +/// to be regenerated +pub fn update_text_input_buffers( + mut text_input_query: Query<( + &mut TextInputBuffer, + Ref, + Ref, + )>, + mut font_system: ResMut, + mut text_pipeline: ResMut, + fonts: Res>, +) { + let font_system = &mut font_system.0; + let font_id_map = &mut text_pipeline.map_handle_to_font_id; + for (mut input_buffer, target, attributes) in text_input_query.iter_mut() { + let TextInputBuffer { + editor, + space_advance, + .. + } = input_buffer.as_mut(); + + let _ = editor.with_buffer_mut(|buffer| { + if target.is_changed() || attributes.is_changed() { + let line_height = attributes.line_height.eval(attributes.font_size); + let metrics = + Metrics::new(attributes.font_size, line_height).scale(target.scale_factor); + buffer.set_metrics(font_system, metrics); + + buffer.set_wrap(font_system, attributes.line_break.into()); + + if !fonts.contains(attributes.font.id()) { + return Err(TextError::NoSuchFont); + } + + let face_info = + load_font_to_fontdb(attributes.font.clone(), font_system, font_id_map, &fonts); + + let attrs = cosmic_text::Attrs::new() + .metadata(0) + .family(cosmic_text::Family::Name(&face_info.family_name)) + .stretch(face_info.stretch) + .style(face_info.style) + .weight(face_info.weight); + + let mut text = buffer.lines.iter().map(BufferLine::text).fold( + String::new(), + |mut out, line| { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(line); + out + }, + ); + + if let Some(max_chars) = attributes.max_chars { + text.truncate(max_chars); + } + + buffer.set_text(font_system, &text, &attrs, cosmic_text::Shaping::Advanced); + let align = Some(attributes.justify.into()); + for buffer_line in buffer.lines.iter_mut() { + buffer_line.set_align(align); + } + + *space_advance = font_id_map + .get(&attributes.font.id()) + .and_then(|(id, ..)| font_system.get_font(*id)) + .and_then(|font| { + let face = font.rustybuzz(); + face.glyph_index(' ') + .and_then(|gid| face.glyph_hor_advance(gid)) + .map(|advance| advance as f32 / face.units_per_em() as f32) + }) + .unwrap_or(0.0) + * buffer.metrics().font_size; + + let height = if let Some(lines) = attributes.lines.filter(|lines| 0. < *lines) { + (metrics.line_height * lines).max(target.size.y) + } else { + target.size.y + }; + + buffer.set_size( + font_system, + Some(target.size.x - *space_advance), + Some(height), + ); + + buffer.set_redraw(true); + } + + Ok(()) + }); + } +} + +/// Update password masks to mirror the underlying `TextInputBuffer`. +/// +/// With variable sized fonts the glyph geometry of the password mask editor buffer may not match the +/// underlying editor buffer, possibly resulting in incorrect scrolling and mouse interactions. +pub fn update_password_masks( + mut text_input_query: Query<(&mut TextInputBuffer, &mut TextInputPasswordMask)>, + mut cosmic_font_system: ResMut, +) { + let font_system = &mut cosmic_font_system.0; + for (mut buffer, mut mask) in text_input_query.iter_mut() { + if buffer.editor.redraw() || mask.is_changed() { + buffer.editor.shape_as_needed(font_system, false); + let mask_text: String = buffer.get_text().chars().map(|_| mask.mask_char).collect(); + let mask_editor = &mut mask.bypass_change_detection().editor; + *mask_editor = buffer.editor.clone(); + let mut editor = mask_editor.borrow_with(font_system); + let selection = editor.selection(); + let cursor = editor.cursor(); + editor.action(Action::Motion(Motion::BufferStart)); + let start = editor.cursor(); + editor.set_selection(Selection::Normal(start)); + editor.action(Action::Motion(Motion::BufferEnd)); + editor.action(Action::Delete); + editor.insert_string(&mask_text, None); + editor.set_selection(selection); + editor.set_cursor(cursor); + editor.set_redraw(true); + } + } +} + +/// Based on `LayoutRunIter` from cosmic-text but doesn't crop the +/// bottom line when scrolling up. +#[derive(Debug)] +pub struct ScrollingLayoutRunIter<'b> { + /// Cosmic text buffer + buffer: &'b Buffer, + /// Index of the current `BufferLine` (The paragraphs of text before line-breaking) + paragraph_index: usize, + /// Index of the current `LayoutLine`, a horizontal line of glyphs from the current `BufferLine` (The individual lines of a paragraph after line-breaking) + broken_line_index: usize, + /// Total height of the lines iterated so far + total_height: f32, + /// The y-coordinate of the top of the current `LayoutLine`. + line_top: f32, +} + +impl<'b> ScrollingLayoutRunIter<'b> { + /// Returns a new iterator that iterates the visible lines of the `buffer`. + pub fn new(buffer: &'b Buffer) -> Self { + Self { + buffer, + paragraph_index: buffer.scroll().line, + broken_line_index: 0, + total_height: 0.0, + line_top: 0.0, + } + } +} + +impl<'b> Iterator for ScrollingLayoutRunIter<'b> { + type Item = cosmic_text::LayoutRun<'b>; + + fn next(&mut self) -> Option { + // Iterate paragraphs + while let Some(line) = self.buffer.lines.get(self.paragraph_index) { + let shape = line.shape_opt()?; + let layout = line.layout_opt()?; + + // Iterate the paragraph's lines after line-breaking + while let Some(layout_line) = layout.get(self.broken_line_index) { + self.broken_line_index += 1; + + let line_height = layout_line + .line_height_opt + .unwrap_or(self.buffer.metrics().line_height); + self.total_height += line_height; + + let line_top = self.line_top - self.buffer.scroll().vertical; + let glyph_height = layout_line.max_ascent + layout_line.max_descent; + let centering_offset = (line_height - glyph_height) / 2.0; + let line_bottom = line_top + centering_offset + layout_line.max_ascent; + if let Some(height) = self.buffer.size().1 { + if height + line_height < line_bottom { + // The line is below the target bound's bottom edge. + // No more lines are visible, return `None` to end the iteration. + return None; + } + } + self.line_top += line_height; + if line_bottom < 0.0 { + // The bottom of the line is above the target's bounds top edge and not visible. Skip it. + continue; + } + + return Some(cosmic_text::LayoutRun { + line_i: self.paragraph_index, + text: line.text(), + rtl: shape.rtl, + glyphs: &layout_line.glyphs, + line_y: line_bottom, + line_top, + line_height, + line_w: layout_line.w, + }); + } + self.paragraph_index += 1; + self.broken_line_index = 0; + } + + None + } +} + +/// Updates the `TextLayoutInfo` for each text input for rendering. +pub fn update_text_input_layouts( + mut textures: ResMut>, + mut texture_atlases: ResMut>, + mut text_query: Query<( + &mut TextLayoutInfo, + &mut TextInputBuffer, + &TextInputAttributes, + Option<&mut TextInputPasswordMask>, + )>, + mut font_system: ResMut, + mut swash_cache: ResMut, + mut font_atlas_sets: ResMut, +) { + let font_system = &mut font_system.0; + for (mut layout_info, mut buffer, attributes, mut maybe_password_mask) in text_query.iter_mut() + { + // Force a redraw when a password is revealed or hidden + let force_redraw = maybe_password_mask + .as_mut() + .map(|mask| mask.is_changed() && mask.show_password) + .unwrap_or(false); + + let space_advance = buffer.space_advance; + let editor = if let Some(password_mask) = maybe_password_mask + .as_mut() + .filter(|mask| !mask.show_password) + { + // The underlying buffer isn't visible, but set redraw to false as though it has been to avoid unnecessary reupdates. + buffer.editor.set_redraw(false); + &mut password_mask.bypass_change_detection().editor + } else { + &mut buffer.editor + }; + editor.shape_as_needed(font_system, false); + + if editor.redraw() || force_redraw { + layout_info.glyphs.clear(); + layout_info.section_rects.clear(); + layout_info.selection_rects.clear(); + layout_info.cursor_index = None; + layout_info.cursor = None; + + let selection = editor.selection_bounds(); + let cursor_position = editor.cursor_position(); + let cursor = editor.cursor(); + + let result = editor.with_buffer_mut(|buffer| { + let box_size = buffer_dimensions(buffer); + let line_height = buffer.metrics().line_height; + if let Some((x, y)) = cursor_position { + let size = Vec2::new(space_advance, line_height); + layout_info.cursor = Some(( + IVec2::new(x, y).as_vec2() + 0.5 * size, + size, + cursor.affinity.after(), + )); + } + let result = ScrollingLayoutRunIter::new(buffer).try_for_each(|run| { + if let Some(selection) = selection { + if let Some((x0, w)) = run.highlight(selection.0, selection.1) { + let y0 = run.line_top; + let y1 = y0 + run.line_height; + let x1 = x0 + w; + let r = Rect::new(x0, y0, x1, y1); + layout_info.selection_rects.push(r); + } + } + + run.glyphs + .iter() + .map(move |layout_glyph| (layout_glyph, run.line_y, run.line_i)) + .try_for_each(|(layout_glyph, line_y, line_i)| { + let mut temp_glyph; + let span_index = layout_glyph.metadata; + let font_id = attributes.font.id(); + let font_smoothing = attributes.font_smoothing; + + let layout_glyph = if font_smoothing == FontSmoothing::None { + // If font smoothing is disabled, round the glyph positions and sizes, + // effectively discarding all subpixel layout. + temp_glyph = layout_glyph.clone(); + temp_glyph.x = temp_glyph.x.round(); + temp_glyph.y = temp_glyph.y.round(); + temp_glyph.w = temp_glyph.w.round(); + temp_glyph.x_offset = temp_glyph.x_offset.round(); + temp_glyph.y_offset = temp_glyph.y_offset.round(); + temp_glyph.line_height_opt = + temp_glyph.line_height_opt.map(f32::round); + + &temp_glyph + } else { + layout_glyph + }; + + let font_atlas_set = font_atlas_sets.sets.entry(font_id).or_default(); + + let physical_glyph = layout_glyph.physical((0., 0.), 1.); + + let atlas_info = font_atlas_set + .get_glyph_atlas_info(physical_glyph.cache_key, font_smoothing) + .map(Ok) + .unwrap_or_else(|| { + font_atlas_set.add_glyph_to_atlas( + &mut texture_atlases, + &mut textures, + font_system, + &mut swash_cache.0, + layout_glyph, + font_smoothing, + ) + })?; + + let texture_atlas = + texture_atlases.get(atlas_info.texture_atlas).unwrap(); + let location = atlas_info.location; + let glyph_rect = texture_atlas.textures[location.glyph_index]; + let left = location.offset.x as f32; + let top = location.offset.y as f32; + let glyph_size = UVec2::new(glyph_rect.width(), glyph_rect.height()); + + // offset by half the size because the origin is center + let x = glyph_size.x as f32 / 2.0 + left + physical_glyph.x as f32; + let y = line_y.round() + physical_glyph.y as f32 - top + + glyph_size.y as f32 / 2.0; + + let position = Vec2::new(x, y); + + let pos_glyph = PositionedGlyph { + position, + size: glyph_size.as_vec2(), + atlas_info, + span_index, + byte_index: layout_glyph.start, + byte_length: layout_glyph.end - layout_glyph.start, + line_index: line_i, + }; + layout_info.glyphs.push(pos_glyph); + if cursor.line == line_i && cursor.index == layout_glyph.start { + layout_info.cursor_index = Some(layout_info.glyphs.len() - 1); + if let Some((ref mut position, ref mut size, ..)) = + layout_info.cursor + { + size.x = layout_glyph.w; + if let Some(cursor_position) = cursor_position { + *position = + IVec2::from(cursor_position).as_vec2() + 0.5 * *size; + } + } + } + + Ok(()) + }) + }); + + // Check result. + result?; + layout_info.size = box_size; + Ok(()) + }); + + match result { + Err(TextError::NoSuchFont) => { + // There was an error processing the text layout, try again next frame + } + Err(e @ (TextError::FailedToAddGlyph(_) | TextError::FailedToGetGlyphImage(_))) => { + panic!("Fatal error when processing text: {e}."); + } + Ok(()) => { + layout_info.scroll = + editor.with_buffer(|buffer| Vec2::new(buffer.scroll().horizontal, 0.)); + + editor.set_redraw(false); + } + } + } + } +} + +/// Apply a text input action to a text input +fn apply_text_input_action( + mut editor: BorrowedWithFontSystem<'_, Editor<'static>>, + mut maybe_history: Option<&mut TextInputUndoHistory>, + maybe_filter: Option<&TextInputFilter>, + max_chars: Option, + clipboard: Option<&mut ResMut>, + action: TextInputAction, +) -> bool { + editor.start_change(); + + match action { + TextInputAction::Copy => { + if let Some(text) = editor.copy_selection() { + if let Some(clipboard) = clipboard { + clipboard.set_text(text); + } + } + } + TextInputAction::Cut => { + if let Some(text) = editor.copy_selection() { + if let Some(clipboard) = clipboard { + clipboard.set_text(text); + } + editor.delete_selection(); + } + } + TextInputAction::InsertString(text) => { + editor.insert_string(&text, None); + } + TextInputAction::Motion { + motion, + with_select, + } => { + apply_motion(&mut editor, with_select, motion); + } + TextInputAction::Insert(ch) => { + editor.action(Action::Insert(ch)); + } + TextInputAction::Overwrite(ch) => match editor.selection() { + Selection::None => { + if is_cursor_at_end_of_line(&mut editor) { + editor.action(Action::Insert(ch)); + } else { + editor.action(Action::Delete); + editor.action(Action::Insert(ch)); + } + } + _ => editor.action(Action::Insert(ch)), + }, + TextInputAction::NewLine => { + editor.action(Action::Enter); + } + TextInputAction::Backspace => { + if !editor.delete_selection() { + editor.action(Action::Backspace); + } + } + TextInputAction::Delete => { + if !editor.delete_selection() { + editor.action(Action::Delete); + } + } + TextInputAction::Indent => { + editor.action(Action::Indent); + } + TextInputAction::Unindent => { + editor.action(Action::Unindent); + } + TextInputAction::Click(point) => { + editor.action(Action::Click { + x: point.x, + y: point.y, + }); + } + TextInputAction::DoubleClick(point) => { + editor.action(Action::DoubleClick { + x: point.x, + y: point.y, + }); + } + TextInputAction::TripleClick(point) => { + editor.action(Action::TripleClick { + x: point.x, + y: point.y, + }); + } + TextInputAction::Drag(point) => { + editor.action(Action::Drag { + x: point.x, + y: point.y, + }); + } + TextInputAction::Scroll { lines } => { + editor.action(Action::Scroll { lines }); + } + TextInputAction::Undo => { + if let Some(history) = maybe_history.as_mut() { + for action in history.changes.undo() { + apply_action(&mut editor, action); + } + } + } + TextInputAction::Redo => { + if let Some(history) = maybe_history.as_mut() { + for action in history.changes.redo() { + apply_action(&mut editor, action); + } + } + } + TextInputAction::SelectAll => { + editor.action(Action::Motion(Motion::BufferStart)); + let cursor = editor.cursor(); + editor.set_selection(Selection::Normal(cursor)); + editor.action(Action::Motion(Motion::BufferEnd)); + } + TextInputAction::SelectLine => { + editor.action(Action::Motion(Motion::Home)); + let cursor = editor.cursor(); + editor.set_selection(Selection::Normal(cursor)); + editor.action(Action::Motion(Motion::End)); + } + TextInputAction::Escape => { + editor.set_selection(Selection::None); + } + TextInputAction::Clear => { + editor.action(Action::Motion(Motion::BufferStart)); + let cursor = editor.cursor(); + editor.set_selection(Selection::Normal(cursor)); + editor.action(Action::Motion(Motion::BufferEnd)); + editor.action(Action::Delete); + } + TextInputAction::SetText(text) => { + editor.action(Action::Motion(Motion::Home)); + let cursor = editor.cursor(); + editor.set_selection(Selection::Normal(cursor)); + editor.action(Action::Motion(Motion::End)); + editor.insert_string(&text, None); + } + _ => {} + } + + let Some(mut change) = editor.finish_change() else { + return true; + }; + + if change.items.is_empty() { + return true; + } + + if maybe_filter.is_some() || max_chars.is_some() { + let text = editor.with_buffer(get_cosmic_text_buffer_contents); + if maybe_filter.is_some_and(|filter| !filter.is_match(&text)) + || max_chars.is_some_and(|max_chars| max_chars <= text.chars().count()) + { + change.reverse(); + editor.apply_change(&change); + return false; + } + } + + if let Some(history) = maybe_history.as_mut() { + history.changes.push(change); + } + + // Set redraw manually, sometimes the editor doesn't set it automatically. + editor.set_redraw(true); + + true +} + +/// Event dispatched when a text input receives the [`TextInputAction::Submit`] action. +/// Contains a copy of the buffer contents at the time when when the action was applied. +#[derive(EntityEvent, Clone, Debug, Component, Reflect)] +#[entity_event(traversal = &'static ChildOf, auto_propagate)] +#[reflect(Component, Clone)] +pub enum TextInputEvent { + /// The input received an invalid input that was filtered + InvalidInput { + /// The source text input entity + text_input: Entity, + }, + /// Text from the input was submitted + Submission { + /// The submitted text + text: String, + /// The source text input entity + text_input: Entity, + }, + /// The contents of the text input changed due to an edit action. + /// Dispatched if a text input entity has a [`TextInputValue`] component. + ValueChanged { + /// The source text input entity + text_input: Entity, + }, +} + +/// Prompt displayed when the input is empty (including whitespace). +/// Optional component. +#[derive(Default, Component, Clone, Debug, Reflect, Deref, DerefMut)] +#[reflect(Component, Default, Debug)] +#[require(PromptLayout)] +pub struct Prompt(pub String); + +impl Prompt { + /// A new prompt. + pub fn new(prompt: impl Into) -> Self { + Self(prompt.into()) + } +} + +/// Layout for the prompt text +#[derive(Component)] +pub struct PromptLayout { + /// Prompt's cosmic-text buffer (not an Editor as isn't editable) + buffer: Buffer, + /// Prompt's text layout, displayed when the text input is empty. + /// Doesn't reuse the editor's `TextLayoutInfo` as otherwise the prompt would need a relayout + /// everytime it was displayed. + layout: TextLayoutInfo, +} + +impl PromptLayout { + /// Get the text layout + pub fn layout(&self) -> &TextLayoutInfo { + &self.layout + } +} + +impl Default for PromptLayout { + fn default() -> Self { + Self { + buffer: Buffer::new_empty(Metrics::new(20.0, 20.0)), + layout: Default::default(), + } + } +} + +/// Generates a new text prompt layout when a prompt's text or its target's geometry has changed. +pub fn update_text_input_prompt_layouts( + mut textures: ResMut>, + fonts: Res>, + mut font_system: ResMut, + mut texture_atlases: ResMut>, + mut text_pipeline: ResMut, + mut swash_cache: ResMut, + mut font_atlas_sets: ResMut, + mut text_query: Query< + ( + &Prompt, + &TextInputAttributes, + &TextInputTarget, + &TextFont, + &mut PromptLayout, + ), + Or<( + Changed, + Changed, + Changed, + Changed, + )>, + >, +) { + for (prompt, style, target, text_font, mut prompt_layout) in text_query.iter_mut() { + let PromptLayout { buffer, layout } = prompt_layout.as_mut(); + + layout.clear(); + + if prompt.0.is_empty() || target.is_empty() { + continue; + } + + if !fonts.contains(text_font.font.id()) { + continue; + } + + let line_height = text_font.line_height.eval(text_font.font_size); + + let metrics = Metrics::new(text_font.font_size, line_height).scale(target.scale_factor); + + if metrics.font_size <= 0. || metrics.line_height <= 0. { + continue; + } + + let bounds: TextBounds = target.size.into(); + let face_info = load_font_to_fontdb( + text_font.font.clone(), + font_system.as_mut(), + &mut text_pipeline.map_handle_to_font_id, + &fonts, + ); + + buffer.set_size(font_system.as_mut(), bounds.width, bounds.height); + + buffer.set_wrap(&mut font_system, style.line_break.into()); + + let attrs = cosmic_text::Attrs::new() + .metadata(0) + .family(cosmic_text::Family::Name(&face_info.family_name)) + .stretch(face_info.stretch) + .style(face_info.style) + .weight(face_info.weight) + .metrics(metrics); + + buffer.set_text( + &mut font_system, + &prompt.0, + &attrs, + cosmic_text::Shaping::Advanced, + ); + + let align = Some(style.justify.into()); + for buffer_line in buffer.lines.iter_mut() { + buffer_line.set_align(align); + } + + buffer.shape_until_scroll(&mut font_system, false); + + let box_size = buffer_dimensions(buffer); + let result = buffer.layout_runs().try_for_each(|run| { + run.glyphs + .iter() + .map(move |layout_glyph| (layout_glyph, run.line_y, run.line_i)) + .try_for_each(|(layout_glyph, line_y, line_i)| { + let mut temp_glyph; + let span_index = layout_glyph.metadata; + let font_id = text_font.font.id(); + let font_smoothing = text_font.font_smoothing; + + let layout_glyph = if font_smoothing == FontSmoothing::None { + // If font smoothing is disabled, round the glyph positions and sizes, + // effectively discarding all subpixel layout. + temp_glyph = layout_glyph.clone(); + temp_glyph.x = temp_glyph.x.round(); + temp_glyph.y = temp_glyph.y.round(); + temp_glyph.w = temp_glyph.w.round(); + temp_glyph.x_offset = temp_glyph.x_offset.round(); + temp_glyph.y_offset = temp_glyph.y_offset.round(); + temp_glyph.line_height_opt = temp_glyph.line_height_opt.map(f32::round); + + &temp_glyph + } else { + layout_glyph + }; + + let font_atlas_set = font_atlas_sets.sets.entry(font_id).or_default(); + + let physical_glyph = layout_glyph.physical((0., 0.), 1.); + + let atlas_info = font_atlas_set + .get_glyph_atlas_info(physical_glyph.cache_key, font_smoothing) + .map(Ok) + .unwrap_or_else(|| { + font_atlas_set.add_glyph_to_atlas( + &mut texture_atlases, + &mut textures, + &mut font_system, + &mut swash_cache.0, + layout_glyph, + font_smoothing, + ) + })?; + + let texture_atlas = texture_atlases.get(atlas_info.texture_atlas).unwrap(); + let location = atlas_info.location; + let glyph_rect = texture_atlas.textures[location.glyph_index]; + let left = location.offset.x as f32; + let top = location.offset.y as f32; + let glyph_size = UVec2::new(glyph_rect.width(), glyph_rect.height()); + + // offset by half the size because the origin is center + let x = glyph_size.x as f32 / 2.0 + left + physical_glyph.x as f32; + let y = + line_y.round() + physical_glyph.y as f32 - top + glyph_size.y as f32 / 2.0; + + let position = Vec2::new(x, y); + + let pos_glyph = PositionedGlyph { + position, + size: glyph_size.as_vec2(), + atlas_info, + span_index, + byte_index: layout_glyph.start, + byte_length: layout_glyph.end - layout_glyph.start, + line_index: line_i, + }; + layout.glyphs.push(pos_glyph); + Ok(()) + }) + }); + + prompt_layout.layout.size = target.scale_factor.recip() * box_size; + + match result { + Err(TextError::NoSuchFont) => { + // There was an error processing the text layout, try again next frame + prompt_layout.layout.clear(); + } + Err(e @ (TextError::FailedToAddGlyph(_) | TextError::FailedToGetGlyphImage(_))) => { + panic!("Fatal error when processing text: {e}."); + } + Ok(()) => {} + } + } +} diff --git a/crates/bevy_text/src/lib.rs b/crates/bevy_text/src/lib.rs index b36f5fa2bb0d7..fab9afd7693b5 100644 --- a/crates/bevy_text/src/lib.rs +++ b/crates/bevy_text/src/lib.rs @@ -38,6 +38,7 @@ mod font_atlas; mod font_atlas_set; mod font_loader; mod glyph; +mod input; mod pipeline; mod text; mod text2d; @@ -50,6 +51,7 @@ pub use font_atlas::*; pub use font_atlas_set::*; pub use font_loader::*; pub use glyph::*; +pub use input::*; pub use pipeline::*; pub use text::*; pub use text2d::*; @@ -133,6 +135,20 @@ impl Plugin for TextPlugin { ) .add_systems(Last, trim_cosmic_cache); + app.add_systems( + PostUpdate, + ( + update_text_input_buffers, + apply_text_input_actions, + update_password_masks, + update_text_input_layouts, + update_text_input_prompt_layouts, + ) + .chain() + .in_set(TextInputSystems) + .ambiguous_with(Text2dUpdateSystems), + ); + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { render_app.add_systems( ExtractSchedule, diff --git a/crates/bevy_text/src/pipeline.rs b/crates/bevy_text/src/pipeline.rs index 8c1136c0636d4..7fb89a4758421 100644 --- a/crates/bevy_text/src/pipeline.rs +++ b/crates/bevy_text/src/pipeline.rs @@ -1,6 +1,6 @@ use alloc::sync::Arc; -use bevy_asset::{AssetId, Assets}; +use bevy_asset::{AssetId, Assets, Handle}; use bevy_color::Color; use bevy_derive::{Deref, DerefMut}; use bevy_ecs::{ @@ -136,7 +136,7 @@ impl TextPipeline { // Load Bevy fonts into cosmic-text's font system. let face_info = load_font_to_fontdb( - text_font, + text_font.font.clone(), font_system, &mut self.map_handle_to_font_id, fonts, @@ -452,10 +452,31 @@ pub struct TextLayoutInfo { /// Scaled and positioned glyphs in screenspace pub glyphs: Vec, /// Rects bounding the text block's text sections. - /// A text section spanning more than one line will have multiple bounding rects. + /// A text section spanning more than one line will have multiple bounding rects pub section_rects: Vec<(Entity, Rect)>, + /// Rects bounding the selected text + pub selection_rects: Vec, /// The glyphs resulting size pub size: Vec2, + /// Cursor position and size + pub cursor: Option<(Vec2, Vec2, bool)>, + /// Index of glyph under the cursor + pub cursor_index: Option, + /// Offset for scrolled text + pub scroll: Vec2, +} + +impl TextLayoutInfo { + /// Clear the text layout + pub fn clear(&mut self) { + self.glyphs.clear(); + self.section_rects.clear(); + self.selection_rects.clear(); + self.size = Vec2::ZERO; + self.cursor = None; + self.cursor_index = None; + self.scroll = Vec2::ZERO; + } } /// Size information for a corresponding [`ComputedTextBlock`] component. @@ -490,12 +511,11 @@ impl TextMeasureInfo { /// Add the font to the cosmic text's `FontSystem`'s in-memory font database pub fn load_font_to_fontdb( - text_font: &TextFont, + font_handle: Handle, font_system: &mut cosmic_text::FontSystem, map_handle_to_font_id: &mut HashMap, (cosmic_text::fontdb::ID, Arc)>, fonts: &Assets, ) -> FontFaceInfo { - let font_handle = text_font.font.clone(); let (face_id, family_name) = map_handle_to_font_id .entry(font_handle.id()) .or_insert_with(|| { @@ -549,7 +569,7 @@ fn get_attrs<'a>( } /// Calculate the size of the text area for the given buffer. -fn buffer_dimensions(buffer: &Buffer) -> Vec2 { +pub(crate) fn buffer_dimensions(buffer: &Buffer) -> Vec2 { let (width, height) = buffer .layout_runs() .map(|run| (run.line_w, run.line_height)) diff --git a/crates/bevy_text/src/text.rs b/crates/bevy_text/src/text.rs index 330f0d977a279..208f573541334 100644 --- a/crates/bevy_text/src/text.rs +++ b/crates/bevy_text/src/text.rs @@ -5,7 +5,7 @@ use bevy_derive::{Deref, DerefMut}; use bevy_ecs::{prelude::*, reflect::ReflectComponent}; use bevy_reflect::prelude::*; use bevy_utils::once; -use cosmic_text::{Buffer, Metrics}; +use cosmic_text::{Buffer, Metrics, Wrap}; use serde::{Deserialize, Serialize}; use smallvec::SmallVec; use tracing::warn; @@ -40,7 +40,7 @@ pub struct TextEntity { #[derive(Component, Debug, Clone, Reflect)] #[reflect(Component, Debug, Default, Clone)] pub struct ComputedTextBlock { - /// Buffer for managing text layout and creating [`TextLayoutInfo`]. + /// Buffer for managing text layout and creating [`TextLayoutInfo`](crate::pipeline::TextLayoutInfo). /// /// This is private because buffer contents are always refreshed from ECS state when writing glyphs to /// `TextLayoutInfo`. If you want to control the buffer contents manually or use the `cosmic-text` @@ -446,6 +446,17 @@ pub enum LineBreak { NoWrap, } +impl From for Wrap { + fn from(value: LineBreak) -> Self { + match value { + LineBreak::WordBoundary => Wrap::Word, + LineBreak::AnyCharacter => Wrap::Glyph, + LineBreak::WordOrCharacter => Wrap::WordOrGlyph, + LineBreak::NoWrap => Wrap::None, + } + } +} + /// Determines which antialiasing method to use when rendering text. By default, text is /// rendered with grayscale antialiasing, but this can be changed to achieve a pixelated look. /// diff --git a/crates/bevy_text/src/text2d.rs b/crates/bevy_text/src/text2d.rs index edda540a7fd4b..e0de383cd18cf 100644 --- a/crates/bevy_text/src/text2d.rs +++ b/crates/bevy_text/src/text2d.rs @@ -92,7 +92,9 @@ use bevy_window::{PrimaryWindow, Window}; Anchor, Visibility, VisibilityClass, - Transform + Transform, + ComputedTextBlock, + TextLayoutInfo )] #[component(on_add = view::add_visibility_class::)] pub struct Text2d(pub String); diff --git a/crates/bevy_ui/Cargo.toml b/crates/bevy_ui/Cargo.toml index a26821a20c4be..c0b0613dff184 100644 --- a/crates/bevy_ui/Cargo.toml +++ b/crates/bevy_ui/Cargo.toml @@ -31,6 +31,9 @@ bevy_utils = { path = "../bevy_utils", version = "0.17.0-dev" } bevy_platform = { path = "../bevy_platform", version = "0.17.0-dev", default-features = false, features = [ "std", ] } +bevy_input_focus = { path = "../bevy_input_focus", version = "0.17.0-dev" } +bevy_time = { path = "../bevy_time", version = "0.17.0-dev" } +bevy_log = { path = "../bevy_log", version = "0.17.0-dev" } # other taffy = { version = "0.7" } diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index d3f9cbd279016..10c5129250190 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -34,6 +34,7 @@ mod layout; mod stack; mod ui_node; +use bevy_text::{update_password_masks, TextInputSystems}; pub use focus::*; pub use geometry::*; pub use gradients::*; @@ -196,7 +197,8 @@ impl Plugin for UiPlugin { let ui_layout_system_config = ui_layout_system .in_set(UiSystems::Layout) - .before(TransformSystems::Propagate); + .before(TransformSystems::Propagate) + .before(TextInputSystems); let ui_layout_system_config = ui_layout_system_config // Text and Text2D operate on disjoint sets of entities @@ -257,6 +259,7 @@ fn build_text_interop(app: &mut App) { ) .chain() .in_set(UiSystems::Content) + .ambiguous_with(update_password_masks) // Text and Text2d are independent. .ambiguous_with(bevy_text::detect_text_needs_rerender::) // Potential conflict: `Assets` diff --git a/crates/bevy_ui/src/widget/mod.rs b/crates/bevy_ui/src/widget/mod.rs index bbd319e986f09..3d37a6d57bbb9 100644 --- a/crates/bevy_ui/src/widget/mod.rs +++ b/crates/bevy_ui/src/widget/mod.rs @@ -4,10 +4,14 @@ mod button; mod image; mod label; mod text; +mod text_box; +mod text_field; mod viewport; pub use button::*; pub use image::*; pub use label::*; pub use text::*; +pub use text_box::*; +pub use text_field::*; pub use viewport::*; diff --git a/crates/bevy_ui/src/widget/text.rs b/crates/bevy_ui/src/widget/text.rs index 1544a5ff7a976..1ad4bde9dd62f 100644 --- a/crates/bevy_ui/src/widget/text.rs +++ b/crates/bevy_ui/src/widget/text.rs @@ -95,7 +95,16 @@ impl Default for TextNodeFlags { /// ``` #[derive(Component, Debug, Default, Clone, Deref, DerefMut, Reflect, PartialEq)] #[reflect(Component, Default, Debug, PartialEq, Clone)] -#[require(Node, TextLayout, TextFont, TextColor, TextNodeFlags, ContentSize)] +#[require( + Node, + TextLayout, + TextFont, + TextColor, + TextNodeFlags, + ContentSize, + ComputedTextBlock, + TextLayoutInfo +)] pub struct Text(pub String); impl Text { diff --git a/crates/bevy_ui/src/widget/text_box.rs b/crates/bevy_ui/src/widget/text_box.rs new file mode 100644 index 0000000000000..9c6e9832a4c38 --- /dev/null +++ b/crates/bevy_ui/src/widget/text_box.rs @@ -0,0 +1,613 @@ +use crate::widget::measure_lines; +use crate::widget::update_text_field_attributes; +use crate::ComputedNode; +use crate::Node; +use crate::UiGlobalTransform; +use crate::UiScale; +use crate::UiSystems; +use bevy_app::Plugin; +use bevy_app::PostUpdate; +use bevy_color::palettes::tailwind::BLUE_900; +use bevy_color::palettes::tailwind::GRAY_300; +use bevy_color::palettes::tailwind::GRAY_400; +use bevy_color::palettes::tailwind::GRAY_950; +use bevy_color::palettes::tailwind::SKY_300; +use bevy_color::Color; +use bevy_derive::Deref; +use bevy_derive::DerefMut; +use bevy_ecs::change_detection::DetectChangesMut; +use bevy_ecs::component::Component; +use bevy_ecs::entity::Entity; +use bevy_ecs::event::EventReader; +use bevy_ecs::lifecycle::HookContext; +use bevy_ecs::observer::Observer; +use bevy_ecs::observer::On; +use bevy_ecs::query::Has; +use bevy_ecs::resource::Resource; +use bevy_ecs::schedule::IntoScheduleConfigs; +use bevy_ecs::system::Commands; +use bevy_ecs::system::Query; +use bevy_ecs::system::Res; +use bevy_ecs::system::ResMut; +use bevy_ecs::world::DeferredWorld; +use bevy_input::keyboard::Key; +use bevy_input::keyboard::KeyboardInput; +use bevy_input::mouse::MouseScrollUnit; +use bevy_input::mouse::MouseWheel; +use bevy_input::ButtonInput; +use bevy_input_focus::tab_navigation::NavAction; +use bevy_input_focus::FocusedInput; +use bevy_input_focus::InputFocus; +use bevy_math::IVec2; +use bevy_math::Rect; +use bevy_math::Vec2; +use bevy_picking::events::Click; +use bevy_picking::events::Drag; +use bevy_picking::events::Move; +use bevy_picking::events::Pointer; +use bevy_picking::events::Press; +use bevy_picking::hover::HoverMap; +use bevy_picking::pointer::PointerButton; +use bevy_text::Justify; +use bevy_text::LineBreak; +use bevy_text::Motion; +use bevy_text::TextFont; +use bevy_text::TextInputAction; +use bevy_text::TextInputActions; +use bevy_text::TextInputAttributes; +use bevy_text::TextInputBuffer; +use bevy_text::TextInputSystems; +use bevy_text::TextInputTarget; +use bevy_text::TextInputUndoHistory; +use bevy_time::Time; +use core::time::Duration; + +pub struct TextInputPlugin; + +impl Plugin for TextInputPlugin { + fn build(&self, app: &mut bevy_app::App) { + app.init_resource::() + .init_resource::() + .add_systems( + PostUpdate, + ( + update_text_box_attributes, + update_text_field_attributes, + measure_lines, + mouse_wheel_scroll, + ) + .chain() + .in_set(UiSystems::Content), + ) + .add_systems( + PostUpdate, + (update_targets, update_cursor_visibility) + .in_set(UiSystems::PostLayout) + .before(TextInputSystems), + ); + } +} + +/// Controls how long until the button has to be pressed again to register a multi-click. +#[derive(Resource, Deref, DerefMut)] +pub struct TextInputMultiClickPeriod(pub Duration); + +impl Default for TextInputMultiClickPeriod { + fn default() -> Self { + Self(Duration::from_secs_f32(0.5)) + } +} + +fn update_targets(mut text_input_node_query: Query<(&ComputedNode, &mut TextInputTarget)>) { + for (node, mut target) in text_input_node_query.iter_mut() { + let new_target = TextInputTarget { + size: node.size(), + scale_factor: node.inverse_scale_factor.recip(), + }; + target.set_if_neq(new_target); + } +} + +fn update_text_box_attributes( + mut text_input_node_query: Query<(&TextBox, &TextFont, &mut TextInputAttributes)>, +) { + for (text_box, font, mut attributes) in text_input_node_query.iter_mut() { + attributes.set_if_neq(TextInputAttributes { + font: font.font.clone(), + font_size: font.font_size, + font_smoothing: font.font_smoothing, + justify: text_box.justify, + line_break: text_box.line_break, + line_height: font.line_height, + max_chars: None, + lines: text_box.lines, + clear_on_submit: text_box.clear_on_submit, + }); + } +} + +/// Single line input. +/// No vertical scrolling, tabs or newlines. +/// Enter submits. +/// Todo: Up and down walk submission history +#[derive(Default, Component)] +pub struct SingleLineInputField; + +#[derive(Component, Debug)] +pub struct TextUnderCursorColor(pub Color); + +impl Default for TextUnderCursorColor { + fn default() -> Self { + Self(Color::BLACK) + } +} + +/// Main text input component +#[derive(Component, Debug, Default)] +#[require( + Node, + TextFont, + TextInputStyle, + TextInputMultiClickCounter, + TextInputBuffer, + TextCursorBlinkTimer, + TextInputUndoHistory +)] +#[component( + on_add = on_add_text_input_node, + on_remove = on_remove_input_focus, +)] +pub struct TextBox { + /// maximum number of chars + pub max_chars: Option, + /// justification + pub justify: Justify, + /// line break + pub line_break: LineBreak, + /// Number of visible lines + pub lines: Option, + /// Clear text input contents and history on submit + pub clear_on_submit: bool, +} + +fn on_add_text_input_node(mut world: DeferredWorld, context: HookContext) { + for mut observer in [ + Observer::new(on_text_input_dragged), + Observer::new(on_text_input_pressed), + Observer::new(on_multi_click_set_selection), + Observer::new(on_move_clear_multi_click), + Observer::new(on_focused_keyboard_input), + ] { + observer.watch_entity(context.entity); + world.commands().spawn(observer); + } +} + +fn on_remove_input_focus(mut world: DeferredWorld, context: HookContext) { + let mut input_focus = world.resource_mut::(); + if input_focus.0 == Some(context.entity) { + input_focus.0 = None; + } +} + +/// Visual styling for a text input widget. +#[derive(Component, Clone)] +pub struct TextInputStyle { + /// Text color + pub text_color: Color, + /// Color of text under an overwrite cursor + pub overwrite_text_color: Color, + /// Color of input prompt (if set) + pub prompt_color: Color, + /// Color of the cursor. + pub cursor_color: Color, + /// Size of the insert cursor relative to the space advance width and line height. + pub cursor_size: Vec2, + /// How long the cursor blinks for. + pub cursor_blink_interval: Duration, + /// Color of selection blocks + pub selection_color: Color, +} + +impl Default for TextInputStyle { + fn default() -> Self { + Self { + text_color: GRAY_300.into(), + overwrite_text_color: GRAY_950.into(), + prompt_color: SKY_300.into(), + cursor_color: GRAY_400.into(), + cursor_size: Vec2::new(0.2, 1.), + cursor_blink_interval: Duration::from_secs_f32(0.5), + selection_color: BLUE_900.into(), + } + } +} + +/// Controls cursor blinking. +/// If the value is none or greater than the `blink_interval` in `TextCursorStyle` then the cursor +/// is not displayed. +#[derive(Component, Debug, Default)] +pub struct TextCursorBlinkTimer(pub Option); + +pub fn update_cursor_visibility( + time: Res