Skip to content

Commit

Permalink
Web clipboard handling (#267)
Browse files Browse the repository at this point in the history
* wip clipboard example

* wip read clipboard, fail :(

* wip attempt to use web_sys paste event

* progress, paste with bugs implemented :)

* integrate clipboard code into bevy_egui

* apply review; remove bad comment

* unified mut api for clipboard

* use crossbeam-channel to avoid explicit mutex

* Update .cargo/config.toml

Co-authored-by: Vladyslav Batyrenko <[email protected]>

* add copy and cut events

* Update src/web_clipboard.rs

* comments and naming improvements

* store new resource to detect if we're on a mac

* res IsMac now a local, init via Once; removed comments, use user-agent

* remove info logs ; fix user agent detection

* fmt

* Refactor, fix edge-cases, improve error handling

---------

Co-authored-by: Thierry Berger <[email protected]>
  • Loading branch information
vladbat00 and Vrixyz authored Mar 18, 2024
1 parent a1f01a9 commit e01b613
Show file tree
Hide file tree
Showing 13 changed files with 376 additions and 39 deletions.
5 changes: 5 additions & 0 deletions .cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[alias]
run-wasm = ["run", "--release", "--package", "run-wasm", "--"]

[workspace]
"run-wasm"
6 changes: 6 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[alias]
run-wasm = ["run", "--release", "--package", "run-wasm", "--"]

# Using unstable APIs is required for writing to clipboard
[target.wasm32-unknown-unknown]
rustflags = ["--cfg=web_sys_unstable_apis"]
29 changes: 23 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,14 @@ name = "ui"
required-features = ["render"]

[dependencies]
bevy = { version = "0.13", default-features = false, features = [
"bevy_asset",
] }
bevy = { version = "0.13", default-features = false, features = ["bevy_asset"] }
egui = { version = "0.26", default-features = false, features = ["bytemuck"] }
webbrowser = { version = "0.8.2", optional = true }

[target.'cfg(not(any(target_arch = "wasm32", target_os = "android")))'.dependencies]
arboard = { version = "3.2.0", optional = true }
thread_local = { version = "1.1.0", optional = true }

[target.'cfg(target_arch = "wasm32")'.dependencies]
web-sys = { version = "0.3.63", features = ["Navigator"] }

[dev-dependencies]
version-sync = "0.9.4"
bevy = { version = "0.13", default-features = false, features = [
Expand All @@ -60,4 +55,26 @@ bevy = { version = "0.13", default-features = false, features = [
"bevy_pbr",
"bevy_core_pipeline",
"tonemapping_luts",
"webgl2",
] }

[target.'cfg(target_arch = "wasm32")'.dependencies]
winit = "0.29"
web-sys = { version = "0.3.63", features = [
"Clipboard",
"ClipboardEvent",
"DataTransfer",
'Document',
'EventTarget',
"Window",
"Navigator",
] }
js-sys = "0.3.63"
wasm-bindgen = "0.2.84"
wasm-bindgen-futures = "0.4.36"
console_log = "1.0.0"
log = "0.4"
crossbeam-channel = "0.5.8"

[workspace]
members = ["run-wasm"]
5 changes: 2 additions & 3 deletions examples/render_to_image_widget.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
use bevy::{
prelude::*,
render::{
camera::{ClearColorConfig, RenderTarget},
camera::RenderTarget,
render_resource::{
Extent3d, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages,
},
view::RenderLayers,
},
};
use bevy_egui::{egui, EguiContexts, EguiPlugin, EguiUserTextures};
use egui::Widget;
use bevy_egui::{egui::Widget, EguiContexts, EguiPlugin, EguiUserTextures};

fn main() {
App::new()
Expand Down
4 changes: 2 additions & 2 deletions examples/side_panel.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use bevy::{prelude::*, render::camera::Projection, window::PrimaryWindow};
use bevy_egui::{egui, EguiContexts, EguiPlugin};
use bevy::{prelude::*, window::PrimaryWindow};
use bevy_egui::{EguiContexts, EguiPlugin};

#[derive(Default, Resource)]
struct OccupiedScreenSpace {
Expand Down
2 changes: 1 addition & 1 deletion examples/simple.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts, EguiPlugin};

This comment has been minimized.

Copy link
@xeuc

xeuc Mar 31, 2024

Could you tell me why you removed 'egui'?
I had to re-add it to make the sample work.

use bevy_egui::{EguiContexts, EguiPlugin};

fn main() {
App::new()
Expand Down
10 changes: 8 additions & 2 deletions examples/ui.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use bevy::{prelude::*, window::PrimaryWindow};
use bevy_egui::{egui, EguiContexts, EguiPlugin, EguiSettings};
use bevy_egui::{EguiContexts, EguiPlugin, EguiSettings};

struct Images {
bevy_icon: Handle<Image>,
Expand All @@ -25,7 +25,13 @@ fn main() {
.insert_resource(ClearColor(Color::rgb(0.0, 0.0, 0.0)))
.insert_resource(Msaa::Sample4)
.init_resource::<UiState>()
.add_plugins(DefaultPlugins)
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
prevent_default_event_handling: false,
..default()
}),
..default()
}))
.add_plugins(EguiPlugin)
.add_systems(Startup, configure_visuals_system)
.add_systems(Startup, configure_ui_state_system)
Expand Down
9 changes: 9 additions & 0 deletions run-wasm/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[package]
name = "run-wasm"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
cargo-run-wasm = "0.2.0"
3 changes: 3 additions & 0 deletions run-wasm/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fn main() {
cargo_run_wasm::run_wasm_with_css("body { margin: 0px; }");
}
60 changes: 41 additions & 19 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,17 @@
//!
//! - [`bevy-inspector-egui`](https://github.com/jakobhellermann/bevy-inspector-egui)
/// Egui render node.
#[cfg(feature = "render")]
pub mod egui_node;
/// Plugin systems for the render app.
#[cfg(feature = "render")]
pub mod render_systems;
/// Plugin systems.
pub mod systems;

/// Egui render node.
#[cfg(feature = "render")]
pub mod egui_node;
/// Clipboard management for web
#[cfg(all(feature = "manage_clipboard", target_arch = "wasm32"))]
pub mod web_clipboard;

pub use egui;

Expand Down Expand Up @@ -111,11 +113,6 @@ use std::borrow::Cow;
not(any(target_arch = "wasm32", target_os = "android"))
))]
use std::cell::{RefCell, RefMut};
#[cfg(all(
feature = "manage_clipboard",
not(any(target_arch = "wasm32", target_os = "android"))
))]
use thread_local::ThreadLocal;

/// Adds all Egui resources and render graph nodes.
pub struct EguiPlugin;
Expand Down Expand Up @@ -178,9 +175,9 @@ pub struct EguiInput(pub egui::RawInput);
#[derive(Default, Resource)]
pub struct EguiClipboard {
#[cfg(not(target_arch = "wasm32"))]
clipboard: ThreadLocal<Option<RefCell<Clipboard>>>,
clipboard: thread_local::ThreadLocal<Option<RefCell<Clipboard>>>,
#[cfg(target_arch = "wasm32")]
clipboard: String,
clipboard: web_clipboard::WebClipboard,
}

#[cfg(all(feature = "manage_clipboard", not(target_os = "android")))]
Expand All @@ -190,14 +187,35 @@ impl EguiClipboard {
self.set_contents_impl(contents);
}

/// Sets the internal buffer of clipboard contents.
/// This buffer is used to remember the contents of the last "Paste" event.
#[cfg(target_arch = "wasm32")]
pub fn set_contents_internal(&mut self, contents: &str) {
self.clipboard.set_contents_internal(contents);
}

/// Gets clipboard contents. Returns [`None`] if clipboard provider is unavailable or returns an error.
#[must_use]
#[cfg(not(target_arch = "wasm32"))]
pub fn get_contents(&mut self) -> Option<String> {
self.get_contents_impl()
}

/// Gets clipboard contents. Returns [`None`] if clipboard provider is unavailable or returns an error.
#[must_use]
pub fn get_contents(&self) -> Option<String> {
#[cfg(target_arch = "wasm32")]
pub fn get_contents(&mut self) -> Option<String> {
self.get_contents_impl()
}

/// Receives a clipboard event sent by the `copy`/`cut`/`paste` listeners.
#[cfg(target_arch = "wasm32")]
pub fn try_receive_clipboard_event(&self) -> Option<web_clipboard::WebClipboardEvent> {
self.clipboard.try_receive_clipboard_event()
}

#[cfg(not(target_arch = "wasm32"))]
fn set_contents_impl(&self, contents: &str) {
fn set_contents_impl(&mut self, contents: &str) {
if let Some(mut clipboard) = self.get() {
if let Err(err) = clipboard.set_text(contents.to_owned()) {
log::error!("Failed to set clipboard contents: {:?}", err);
Expand All @@ -207,24 +225,24 @@ impl EguiClipboard {

#[cfg(target_arch = "wasm32")]
fn set_contents_impl(&mut self, contents: &str) {
self.clipboard = contents.to_owned();
self.clipboard.set_contents(contents);
}

#[cfg(not(target_arch = "wasm32"))]
fn get_contents_impl(&self) -> Option<String> {
fn get_contents_impl(&mut self) -> Option<String> {
if let Some(mut clipboard) = self.get() {
match clipboard.get_text() {
Ok(contents) => return Some(contents),
Err(err) => log::info!("Failed to get clipboard contents: {:?}", err),
Err(err) => log::error!("Failed to get clipboard contents: {:?}", err),
}
};
None
}

#[cfg(target_arch = "wasm32")]
#[allow(clippy::unnecessary_wraps)]
fn get_contents_impl(&self) -> Option<String> {
Some(self.clipboard.clone())
fn get_contents_impl(&mut self) -> Option<String> {
self.clipboard.get_contents()
}

#[cfg(not(target_arch = "wasm32"))]
Expand All @@ -234,7 +252,7 @@ impl EguiClipboard {
Clipboard::new()
.map(RefCell::new)
.map_err(|err| {
log::info!("Failed to initialize clipboard: {:?}", err);
log::error!("Failed to initialize clipboard: {:?}", err);
})
.ok()
})
Expand Down Expand Up @@ -580,6 +598,8 @@ impl Plugin for EguiPlugin {
world.init_resource::<EguiManagedTextures>();
#[cfg(all(feature = "manage_clipboard", not(target_os = "android")))]
world.init_resource::<EguiClipboard>();
#[cfg(all(feature = "manage_clipboard", target_arch = "wasm32"))]
world.init_non_send_resource::<web_clipboard::SubscribedEvents>();
#[cfg(feature = "render")]
world.init_resource::<EguiUserTextures>();
world.init_resource::<EguiMousePosition>();
Expand All @@ -597,6 +617,8 @@ impl Plugin for EguiPlugin {
#[cfg(feature = "render")]
app.add_plugins(ExtractComponentPlugin::<EguiRenderOutput>::default());

#[cfg(all(feature = "manage_clipboard", target_arch = "wasm32"))]
app.add_systems(PreStartup, web_clipboard::startup_setup_web_events);
app.add_systems(
PreStartup,
(
Expand Down
1 change: 0 additions & 1 deletion src/render_systems.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ use bevy::{
DynamicUniformBuffer, PipelineCache, ShaderType, SpecializedRenderPipelines,
},
renderer::{RenderDevice, RenderQueue},
texture::Image,
view::ExtractedWindows,
Extract,
},
Expand Down
31 changes: 26 additions & 5 deletions src/systems.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ pub struct ModifierKeysState {
#[derive(SystemParam)]
pub struct InputResources<'w, 's> {
#[cfg(all(feature = "manage_clipboard", not(target_os = "android")))]
pub egui_clipboard: Res<'w, crate::EguiClipboard>,
pub egui_clipboard: ResMut<'w, crate::EguiClipboard>,
pub modifier_keys_state: Local<'s, ModifierKeysState>,
#[system_param(ignore)]
_marker: PhantomData<&'w ()>,
Expand Down Expand Up @@ -110,7 +110,7 @@ pub fn process_input_system(
if let Some(window) = web_sys::window() {
let nav = window.navigator();
if let Ok(user_agent) = nav.user_agent() {
if user_agent.to_ascii_lowercase().contains("Mac") {
if user_agent.to_ascii_lowercase().contains("mac") {
*context_params.is_macos = true;
}
}
Expand All @@ -130,7 +130,6 @@ pub fn process_input_system(
None
};
}

let mut keyboard_input_events = Vec::new();
for event in input_events.ev_keyboard_input.read() {
// Copy the events as we might want to pass them to an Egui context later.
Expand All @@ -149,7 +148,7 @@ pub fn process_input_system(
Key::Alt => {
input_resources.modifier_keys_state.alt = state.is_pressed();
}
Key::Super => {
Key::Super | Key::Meta => {
input_resources.modifier_keys_state.win = state.is_pressed();
}
_ => {}
Expand Down Expand Up @@ -305,7 +304,11 @@ pub fn process_input_system(

// We also check that it's an `ButtonState::Pressed` event, as we don't want to
// copy, cut or paste on the key release.
#[cfg(all(feature = "manage_clipboard", not(target_os = "android")))]
#[cfg(all(
feature = "manage_clipboard",
not(target_os = "android"),
not(target_arch = "wasm32")
))]
if command && ev.state.is_pressed() {
match key {
egui::Key::C => {
Expand All @@ -325,6 +328,24 @@ pub fn process_input_system(
}
}

#[cfg(all(feature = "manage_clipboard", target_arch = "wasm32"))]
while let Some(event) = input_resources.egui_clipboard.try_receive_clipboard_event() {
match event {
crate::web_clipboard::WebClipboardEvent::Copy => {
focused_input.events.push(egui::Event::Copy);
}
crate::web_clipboard::WebClipboardEvent::Cut => {
focused_input.events.push(egui::Event::Cut);
}
crate::web_clipboard::WebClipboardEvent::Paste(contents) => {
input_resources
.egui_clipboard
.set_contents_internal(&contents);
focused_input.events.push(egui::Event::Text(contents))
}
}
}

for touch in input_events.ev_touch.read() {
let scale_factor = egui_settings.scale_factor;
let touch_position: (f32, f32) = (touch.position / scale_factor).into();
Expand Down
Loading

0 comments on commit e01b613

Please sign in to comment.