diff --git a/Cargo.toml b/Cargo.toml index efc44322159cc..48f4670c36379 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -133,6 +133,7 @@ default = [ "animation", "bevy_asset", "bevy_audio", + "bevy_clipboard", "bevy_color", "bevy_core_pipeline", "bevy_post_process", @@ -295,6 +296,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"] @@ -870,6 +874,17 @@ description = "Renders text to multiple windows with different scale factors usi category = "2D Rendering" wasm = true +[[example]] +name = "text_input_2d" +path = "examples/2d/text_input_2d.rs" +doc-scrape-examples = true + +[package.metadata.example.text_input_2d] +name = "Text Input 2D" +description = "Text input in 2D" +category = "2D Rendering" +wasm = true + [[example]] name = "texture_atlas" path = "examples/2d/texture_atlas.rs" @@ -2697,12 +2712,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 @@ -3397,6 +3412,17 @@ description = "Demonstrates dragging and dropping UI nodes" 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" @@ -3508,6 +3534,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..46295f4b6b357 --- /dev/null +++ b/crates/bevy_clipboard/src/lib.rs @@ -0,0 +1,273 @@ +//! 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, Clone)] +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 +#[derive(Resource)] +pub struct Clipboard { + /// Use arboard to access host clipboard + #[cfg(unix)] + host_clipboard: Option, + + /// Fallback basic clipboard implementation that only works within the bevy app. + local_clipboard: Option, +} + +impl Default for Clipboard { + fn default() -> Self { + Clipboard { + #[cfg(unix)] + host_clipboard: arboard::Clipboard::new().ok(), + local_clipboard: None, + } + } +} + +// TODO: not entirely consistent with `local_clipboard` usage, fix this +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.host_clipboard.as_mut() { + clipboard.get_text().map_err(ClipboardError::from) + } else { + self.local_clipboard + .clone() + .ok_or(ClipboardError::ContentNotAvailable) + }) + } + + #[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( + self.local_clipboard + .clone() + .ok_or(ClipboardError::ContentNotAvailable), + ) + } + } + + #[cfg(not(any(unix, windows, target_arch = "wasm32")))] + { + ClipboardRead::Ready( + self.local_clipboard + .clone() + .ok_or(ClipboardError::ContentNotAvailable), + ) + } + } + + /// 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.host_clipboard.as_mut() { + clipboard.get_text().map_err(ClipboardError::from) + } else { + self.local_clipboard + .clone() + .ok_or(ClipboardError::ContentNotAvailable) + } + } + + #[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| Some(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")))] + { + self.local_clipboard + .clone() + .ok_or(ClipboardError::ContentNotAvailable) + } + } + + /// 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.host_clipboard.as_mut() { + clipboard.set_text(text).map_err(ClipboardError::from) + } else { + let k: alloc::borrow::Cow<'a, str> = text.into(); + let j: String = k.into(); + self.local_clipboard = Some(j); + Ok(()) + } + } + + #[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 { + let k: alloc::borrow::Cow<'a, str> = text.into(); + let j: String = k.into(); + self.local_clipboard = Some(j); + Ok(()) + } + } + + #[cfg(not(any(unix, windows, target_arch = "wasm32")))] + { + let k: alloc::borrow::Cow<'a, str> = text.into(); + let j: String = k.into(); + self.local_clipboard = Some(j); + Ok(()) + } + } +} + +/// 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, + + /// 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::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 babb95a4dac1c..b161284c2cf5b 100644 --- a/crates/bevy_internal/Cargo.toml +++ b/crates/bevy_internal/Cargo.toml @@ -422,6 +422,9 @@ async_executor = [ # Note this is currently only applicable on `wasm32` architectures. web = ["bevy_app/web", "bevy_platform/web", "bevy_reflect/web"] +# Clipboard support +bevy_clipboard = ["dep:bevy_clipboard"] + hotpatching = ["bevy_app/hotpatching", "bevy_ecs/hotpatching"] debug = ["bevy_utils/debug"] @@ -510,6 +513,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" } [target.'cfg(target_os = "android")'.dependencies] bevy_android = { path = "../bevy_android", version = "0.17.0-dev", default-features = false } diff --git a/crates/bevy_internal/src/default_plugins.rs b/crates/bevy_internal/src/default_plugins.rs index 4467da12f4f11..79167a63c0943 100644 --- a/crates/bevy_internal/src/default_plugins.rs +++ b/crates/bevy_internal/src/default_plugins.rs @@ -84,6 +84,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 78c8ba2b221f5..f6b0a30838f0a 100644 --- a/crates/bevy_internal/src/lib.rs +++ b/crates/bevy_internal/src/lib.rs @@ -29,6 +29,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 ea4a306d0a6d6..98408b14adeb1 100644 --- a/crates/bevy_internal/src/prelude.rs +++ b/crates/bevy_internal/src/prelude.rs @@ -102,3 +102,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_sprite/src/lib.rs b/crates/bevy_sprite/src/lib.rs index 81874f37af24f..cf68b9a1077a1 100644 --- a/crates/bevy_sprite/src/lib.rs +++ b/crates/bevy_sprite/src/lib.rs @@ -87,7 +87,8 @@ impl Plugin for SpritePlugin { bevy_text::detect_text_needs_rerender::, update_text2d_layout .after(bevy_camera::CameraUpdateSystems) - .after(bevy_text::remove_dropped_font_atlas_sets), + .after(bevy_text::remove_dropped_font_atlas_sets) + .ambiguous_with(bevy_text::update_placeholder_layouts), calculate_bounds_text2d.in_set(VisibilitySystems::CalculateBounds), ) .chain() diff --git a/crates/bevy_sprite/src/sprite.rs b/crates/bevy_sprite/src/sprite.rs index c4e4469dc2178..4c8e423e67750 100644 --- a/crates/bevy_sprite/src/sprite.rs +++ b/crates/bevy_sprite/src/sprite.rs @@ -1,10 +1,13 @@ use bevy_asset::{AsAssetId, AssetId, Assets, Handle}; -use bevy_camera::visibility::{self, Visibility, VisibilityClass}; +use bevy_camera::{ + primitives::Aabb, + visibility::{self, Visibility, VisibilityClass}, +}; use bevy_color::Color; use bevy_derive::{Deref, DerefMut}; use bevy_ecs::{component::Component, reflect::ReflectComponent}; use bevy_image::{Image, TextureAtlas, TextureAtlasLayout}; -use bevy_math::{Rect, UVec2, Vec2}; +use bevy_math::{Rect, UVec2, Vec2, Vec3}; use bevy_reflect::{std_traits::ReflectDefault, Reflect}; use bevy_transform::components::Transform; @@ -268,6 +271,16 @@ impl Anchor { pub fn as_vec(&self) -> Vec2 { self.0 } + + /// Determine the bounds at the anchor + pub fn calculate_bounds(&self, size: Vec2) -> Aabb { + let x1 = (Anchor::TOP_LEFT.0.x - self.as_vec().x) * size.x; + let x2 = (Anchor::TOP_LEFT.0.x - self.as_vec().x + 1.) * size.x; + let y1 = (Anchor::TOP_LEFT.0.y - self.as_vec().y - 1.) * size.y; + let y2 = (Anchor::TOP_LEFT.0.y - self.as_vec().y) * size.y; + + Aabb::from_min_max(Vec3::new(x1, y1, 0.), Vec3::new(x2, y2, 0.)) + } } impl Default for Anchor { diff --git a/crates/bevy_sprite/src/text2d.rs b/crates/bevy_sprite/src/text2d.rs index 3c0e5fa56564f..1c5a9402235e3 100644 --- a/crates/bevy_sprite/src/text2d.rs +++ b/crates/bevy_sprite/src/text2d.rs @@ -87,7 +87,9 @@ use core::any::TypeId; Anchor, Visibility, VisibilityClass, - Transform + Transform, + ComputedTextBlock, + TextLayoutInfo )] #[component(on_add = visibility::add_visibility_class::)] pub struct Text2d(pub String); diff --git a/crates/bevy_text/Cargo.toml b/crates/bevy_text/Cargo.toml index 1d46bf56511d5..ac8c19bfcc345 100644 --- a/crates/bevy_text/Cargo.toml +++ b/crates/bevy_text/Cargo.toml @@ -16,12 +16,14 @@ 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" } bevy_log = { path = "../bevy_log", version = "0.17.0-dev" } bevy_math = { path = "../bevy_math", version = "0.17.0-dev" } bevy_reflect = { path = "../bevy_reflect", version = "0.17.0-dev" } +bevy_time = { path = "../bevy_time", version = "0.17.0-dev" } 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", diff --git a/crates/bevy_text/src/input/buffer.rs b/crates/bevy_text/src/input/buffer.rs new file mode 100644 index 0000000000000..08803591849bf --- /dev/null +++ b/crates/bevy_text/src/input/buffer.rs @@ -0,0 +1,306 @@ +use bevy_asset::{AssetEvent, Assets, Handle}; +use bevy_derive::Deref; +use bevy_ecs::{ + change_detection::DetectChanges, + component::Component, + event::EventReader, + lifecycle::HookContext, + system::{Query, Res, ResMut}, + world::{DeferredWorld, Ref}, +}; +use bevy_time::Time; +use cosmic_text::{Buffer, BufferLine, Edit, Editor, Metrics}; + +use crate::{ + load_font_to_fontdb, CosmicFontSystem, CursorBlink, Font, FontSmoothing, Justify, LineBreak, + LineHeight, TextCursorBlinkInterval, TextEdit, TextEdits, TextError, TextInputTarget, + TextLayoutInfo, TextPipeline, +}; + +/// Common text input properties set by the user. +/// On changes, the text input systems will automatically update the buffer, layout and fonts as required. +#[derive(Component, Debug, PartialEq)] +pub struct TextInputAttributes { + /// The text input's font, which also applies to any [`crate::Placeholder`] text 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 maximum number of lines the buffer will display without scrolling. + /// * Clamped between zero and target height divided by line height. + /// * If None or equal or less than 0, will fill the target space. + /// * Only restricts the maximum number of visible lines, places no constraint on the text buffer's length. + /// * Supports fractional values, `visible_lines: Some(2.5)` will display two and a half lines of text. + pub visible_lines: Option, +} + +/// Default font size +pub const DEFAULT_FONT_SIZE: f32 = 20.; +/// Default line height factor (relative to font size) +/// +/// `1.2` corresponds to `normal` in `` +pub const DEFAULT_LINE_HEIGHT_FACTOR: f32 = 1.2; +/// Default line height +pub const DEFAULT_LINE_HEIGHT: f32 = DEFAULT_FONT_SIZE * DEFAULT_LINE_HEIGHT_FACTOR; +/// Default space advance +pub const DEFAULT_SPACE_ADVANCE: f32 = 20.; + +impl Default for TextInputAttributes { + fn default() -> Self { + Self { + font: Default::default(), + font_size: DEFAULT_FONT_SIZE, + line_height: LineHeight::RelativeToFont(DEFAULT_LINE_HEIGHT_FACTOR), + font_smoothing: Default::default(), + justify: Default::default(), + line_break: Default::default(), + max_chars: None, + visible_lines: None, + } + } +} + +/// Contains the current text in the text input buffer. +/// Automatically synchronized with the buffer by [`crate::apply_text_edits`] after any edits are applied. +/// On insertion, 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(pub 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(TextEdit::SetText(value)); + } + } +} + +/// Get the text from a cosmic text buffer +pub 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. +/// +/// The `needs_redraw` method can be used to check if the buffer's contents have changed and need redrawing. +/// Component change detection is not reliable as the editor buffer needs to be borrowed mutably during updates. +#[derive(Component, Debug)] +#[require(TextInputAttributes, TextInputTarget, TextEdits, TextLayoutInfo)] +pub struct TextInputBuffer { + /// The cosmic text editor buffer. + pub editor: Editor<'static>, + /// Space advance width for the current font, used to determine the width of the cursor when it is at the end of a line + /// or when the buffer is empty. + pub space_advance: f32, +} + +impl Default for TextInputBuffer { + fn default() -> Self { + Self { + editor: Editor::new(Buffer::new_empty(Metrics::new( + DEFAULT_FONT_SIZE, + DEFAULT_LINE_HEIGHT, + ))), + space_advance: DEFAULT_SPACE_ADVANCE, + } + } +} + +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.is_empty() + || (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) + } + + /// Returns true if the buffer's contents have changed and need to be redrawn. + pub fn needs_redraw(&self) -> bool { + self.editor.redraw() + } +} + +/// Updates the text input buffer in response to changes +/// that require regeneration of the the buffer's +/// metrics and attributes. +pub fn update_text_input_buffers( + mut text_input_query: Query<( + &mut TextInputBuffer, + Ref, + &TextEdits, + Ref, + Option<&mut CursorBlink>, + )>, + time: Res