From c60a1055f5f869bcd78ce83b7ee48e17d1264745 Mon Sep 17 00:00:00 2001 From: Vladyslav Batyrenko Date: Sat, 4 Jan 2025 19:24:05 +0200 Subject: [PATCH] Rework input handling (#345) * Refactor input systems (closes #173) * Add color_test example * Add support for redirecting events to non-window contexts * Actually respect EguiGlobalSettings * Remove web_sys_unstable_apis, fix docs and tests * Fix windows build * Disable the headless test (broken after a Bevy update) --- .cargo/config.toml | 4 - .github/workflows/check.yml | 2 - Cargo.toml | 16 +- README.md | 4 +- build.rs | 3 - examples/color_test.rs | 939 +++++++++++++++++++++++++ examples/render_egui_to_image.rs | 149 +++- examples/simple.rs | 2 +- src/helpers.rs | 252 +++++++ src/input.rs | 690 ++++++++++++++++++ src/lib.rs | 646 +++++++++-------- src/output.rs | 117 +++ src/systems.rs | 880 ----------------------- src/text_agent.rs | 118 ++-- src/web_clipboard.rs | 44 +- static/error_web_sys_unstable_apis.txt | 6 - 16 files changed, 2609 insertions(+), 1263 deletions(-) delete mode 100644 build.rs create mode 100644 examples/color_test.rs create mode 100644 src/helpers.rs create mode 100644 src/input.rs create mode 100644 src/output.rs delete mode 100644 src/systems.rs delete mode 100644 static/error_web_sys_unstable_apis.txt diff --git a/.cargo/config.toml b/.cargo/config.toml index 37505ddfb..a9f12423e 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,2 @@ [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"] diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 42e65a0f1..6f096d0ad 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -69,8 +69,6 @@ jobs: "render", "manage_clipboard,open_url,render", ] - env: - RUSTFLAGS: --cfg=web_sys_unstable_apis steps: - uses: actions/checkout@v3 - uses: dtolnay/rust-toolchain@master diff --git a/Cargo.toml b/Cargo.toml index a06aa5b3f..7e1161229 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,9 @@ serde = ["egui/serde"] # The enabled logs will print with the info log level, to make it less cumbersome to debug in browsers. log_input_events = [] +[[example]] +name = "color_test" +required-features = ["render"] [[example]] name = "paint_callback" required-features = ["render"] @@ -58,7 +61,7 @@ name = "ui" required-features = ["render"] [[example]] name = "render_egui_to_image" -required-features = ["render"] +required-features = ["render", "bevy/bevy_gizmos"] [dependencies] egui = { version = "0.30", default-features = false } @@ -94,16 +97,17 @@ thread_local = { version = "1.1.0", optional = true } [dev-dependencies] version-sync = "0.9.4" bevy = { version = "0.15.0", default-features = false, features = [ - "x11", - "png", - "bevy_pbr", - "bevy_core_pipeline", "bevy_asset", + "bevy_core_pipeline", + "bevy_sprite", + "bevy_pbr", "bevy_window", "bevy_winit", + "android-game-activity", + "png", "tonemapping_luts", "webgl2", - "android-game-activity", + "x11", ] } egui = { version = "0.30", default-features = false, features = ["bytemuck"] } diff --git a/README.md b/README.md index bf3788df2..6cd265c74 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ fn main() { App::new() .add_plugins(DefaultPlugins) .add_plugins(EguiPlugin) - // Systems that create Egui widgets should be run during the `CoreSet::Update` set, - // or after the `EguiSet::BeginPass` system (which belongs to the `CoreSet::PreUpdate` set). + // Systems that create Egui widgets should be run during the `Update` Bevy schedule, + // or after the `EguiPreUpdateSet::BeginPass` system (which belongs to the `PreUpdate` Bevy schedule). .add_systems(Update, ui_example_system) .run(); } diff --git a/build.rs b/build.rs deleted file mode 100644 index 5e6da5fb3..000000000 --- a/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("cargo::rustc-check-cfg=cfg(web_sys_unstable_apis)"); -} diff --git a/examples/color_test.rs b/examples/color_test.rs new file mode 100644 index 000000000..4d44e7589 --- /dev/null +++ b/examples/color_test.rs @@ -0,0 +1,939 @@ +use bevy::{ + prelude::{MeshMaterial2d, *}, + render::render_resource::LoadOp, + window::PrimaryWindow, +}; +use bevy_egui::{ + helpers::vec2_into_egui_pos2, + input::{EguiContextPointerPosition, HoveredNonWindowEguiContext}, + EguiContext, EguiContexts, EguiInputSet, EguiPlugin, EguiRenderToImage, EguiSettings, +}; + +fn main() { + App::new() + .insert_resource(ClearColor(Color::WHITE)) + .add_plugins(DefaultPlugins) + .add_plugins(EguiPlugin) + .init_resource::() + .add_systems(Startup, setup_system) + .add_systems( + PreUpdate, + update_egui_hovered_context.in_set(EguiInputSet::InitReading), + ) + .add_systems(Update, (ui_system, update_image_size.after(ui_system))) + .run(); +} + +#[derive(Eq, PartialEq)] +enum DisplayedUi { + Regular, + MeshImage, + EguiTextureImage, +} + +#[derive(Resource)] +struct AppState { + displayed_ui: DisplayedUi, + color_test: ColorTest, + top_panel_height: u32, + mesh_image_entity: Entity, + egui_texture_image_entity: Entity, + egui_texture_image_handle: Handle, + egui_texture_image_id: egui::TextureId, +} + +impl Default for AppState { + fn default() -> Self { + Self { + displayed_ui: DisplayedUi::Regular, + color_test: Default::default(), + top_panel_height: 0, + mesh_image_entity: Entity::PLACEHOLDER, + egui_texture_image_entity: Entity::PLACEHOLDER, + egui_texture_image_handle: Handle::default(), + egui_texture_image_id: egui::TextureId::User(0), + } + } +} + +fn setup_system( + mut commands: Commands, + mut egui_contexts: EguiContexts, + mut app_state: ResMut, + mut meshes: ResMut>, + mut materials: ResMut>, + mut images: ResMut>, +) { + let size = Extent3d { + width: 256, + height: 256, + depth_or_array_layers: 1, + }; + let mut image = bevy::image::Image { + // You should use `0` so that the pixels are transparent. + data: vec![0; (size.width * size.height * 4) as usize], + ..default() + }; + image.texture_descriptor.usage |= TextureUsages::RENDER_ATTACHMENT; + image.texture_descriptor.size = size; + + let mesh_image_handle = images.add(image.clone()); + let egui_texture_image_handle = images.add(image); + + app_state.mesh_image_entity = commands + .spawn(( + Mesh2d(meshes.add(Rectangle::new(256.0, 256.0))), + MeshMaterial2d(materials.add(mesh_image_handle.clone())), + EguiRenderToImage { + handle: mesh_image_handle, + load_op: LoadOp::Clear(Color::srgb_u8(43, 44, 47).to_linear().into()), + }, + )) + .id(); + + app_state.egui_texture_image_entity = commands + .spawn(EguiRenderToImage { + handle: egui_texture_image_handle.clone(), + load_op: LoadOp::Clear(Color::srgb_u8(43, 44, 47).to_linear().into()), + }) + .id(); + app_state.egui_texture_image_handle = egui_texture_image_handle.clone_weak(); + app_state.egui_texture_image_id = + egui_contexts.add_image(egui_texture_image_handle.clone_weak()); + + commands.spawn(Camera2d); +} + +fn update_image_size( + mut prev_top_panel_height: Local, + mut prev_window_size: Local, + window: Single<&Window, With>, + app_state: Res, + mut images: ResMut>, + mut meshes: ResMut>, + mut egui_render_to_image_query: Query<( + &EguiRenderToImage, + Option<&Mesh2d>, + Option<&mut Transform>, + )>, +) { + if *prev_window_size == window.physical_size() + && *prev_top_panel_height == app_state.top_panel_height + { + return; + } + + *prev_window_size = window.physical_size(); + *prev_top_panel_height = app_state.top_panel_height; + + let new_height = window.physical_height() - app_state.top_panel_height; + + for (egui_render_to_image, mesh_handle, transform) in egui_render_to_image_query.iter_mut() { + let image = images + .get_mut(&egui_render_to_image.handle) + .expect("Expected a created image"); + image + .data + .resize((window.physical_width() * new_height * 4) as usize, 0); + image.texture_descriptor.size.width = window.physical_width(); + image.texture_descriptor.size.height = new_height; + + if let Some((mesh_handle, mut transform)) = mesh_handle.zip(transform) { + *meshes + .get_mut(mesh_handle) + .expect("Expected a created mesh") = + Rectangle::new(window.physical_width() as f32, new_height as f32).into(); + transform.translation.y = *prev_top_panel_height as f32 / -2.0; + } + } +} + +#[allow(clippy::type_complexity)] +fn update_egui_hovered_context( + mut commands: Commands, + app_state: Res, + mut cursor_moved_reader: EventReader, + mut egui_contexts: Query< + ( + Entity, + &mut EguiContextPointerPosition, + &EguiSettings, + Option<&Mesh2d>, + ), + (With, Without), + >, +) { + for (entity, mut context_pointer_position, settings, mesh) in egui_contexts.iter_mut() { + if !matches!( + (&app_state.displayed_ui, mesh), + (DisplayedUi::MeshImage, Some(_)) | (DisplayedUi::EguiTextureImage, None) + ) { + continue; + } + + // We expect to reach this code only once since we can have only 1 active context matching the conditions. + for event in cursor_moved_reader.read() { + let scale_factor = settings.scale_factor; + let pointer_position = vec2_into_egui_pos2(event.position / scale_factor) + - Vec2::new(0.0, app_state.top_panel_height as f32); + if pointer_position.y < 0.0 { + commands.remove_resource::(); + continue; + } + + context_pointer_position.position = pointer_position; + commands.insert_resource(HoveredNonWindowEguiContext(entity)); + } + } +} + +fn ui_system( + mut app_state: ResMut, + mut contexts: EguiContexts, + images: Res>, +) { + let ctx = contexts.ctx_mut(); + app_state.top_panel_height = egui::TopBottomPanel::top("top_panel") + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.selectable_value( + &mut app_state.displayed_ui, + DisplayedUi::Regular, + "Regular UI", + ); + ui.selectable_value( + &mut app_state.displayed_ui, + DisplayedUi::MeshImage, + "Render to image (mesh)", + ); + ui.selectable_value( + &mut app_state.displayed_ui, + DisplayedUi::EguiTextureImage, + "Render to image (Egui user texture)", + ); + }); + }) + .response + .rect + .height() as u32; + + match app_state.displayed_ui { + DisplayedUi::Regular => { + egui::CentralPanel::default().show(ctx, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + app_state.color_test.ui(ui); + }); + }); + } + DisplayedUi::MeshImage => { + let mesh_image_ctx = contexts.ctx_for_entity_mut(app_state.mesh_image_entity); + egui::CentralPanel::default().show(mesh_image_ctx, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + app_state.color_test.ui(ui); + }); + }); + } + DisplayedUi::EguiTextureImage => { + let egui_texture_image = images + .get(&app_state.egui_texture_image_handle) + .expect("Expected a created image"); + egui::CentralPanel::default() + .frame(egui::Frame::none()) + .show(ctx, |ui| { + ui.image(egui::load::SizedTexture::new( + app_state.egui_texture_image_id, + [ + egui_texture_image.texture_descriptor.size.width as f32, + egui_texture_image.texture_descriptor.size.height as f32, + ], + )); + }); + + let egui_texture_image_entity_ctx = + contexts.ctx_for_entity_mut(app_state.egui_texture_image_entity); + egui::CentralPanel::default().show(egui_texture_image_entity_ctx, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + app_state.color_test.ui(ui); + }); + }); + } + } +} + +// +// Copy-pasted from https://github.com/emilk/egui/blob/0.30.0/crates/egui_demo_lib/src/rendering_test.rs. +// + +use egui::{ + epaint, lerp, pos2, vec2, widgets::color_picker::show_color, Align2, Color32, FontId, Image, + Mesh, Pos2, Rect, Response, Rgba, RichText, Sense, Shape, Stroke, TextureHandle, + TextureOptions, Ui, Vec2, +}; +use std::collections::HashMap; +use wgpu_types::{Extent3d, TextureUsages}; + +const GRADIENT_SIZE: Vec2 = vec2(256.0, 18.0); + +const BLACK: Color32 = Color32::BLACK; +const GREEN: Color32 = Color32::GREEN; +const RED: Color32 = Color32::RED; +const TRANSPARENT: Color32 = Color32::TRANSPARENT; +const WHITE: Color32 = Color32::WHITE; + +/// A test for sanity-checking and diagnosing egui rendering backends. +pub struct ColorTest { + tex_mngr: TextureManager, + vertex_gradients: bool, + texture_gradients: bool, +} + +impl Default for ColorTest { + fn default() -> Self { + Self { + tex_mngr: Default::default(), + vertex_gradients: true, + texture_gradients: true, + } + } +} + +impl ColorTest { + pub fn ui(&mut self, ui: &mut Ui) { + ui.horizontal_wrapped(|ui| { + ui.label("This is made to test that the egui rendering backend is set up correctly."); + ui.add(egui::Label::new("❓").sense(egui::Sense::click())) + .on_hover_text("The texture sampling should be sRGB-aware, and every other color operation should be done in gamma-space (sRGB). All colors should use pre-multiplied alpha"); + }); + + ui.separator(); + + pixel_test(ui); + + ui.separator(); + + ui.collapsing("Color test", |ui| { + self.color_test(ui); + }); + + ui.separator(); + + ui.heading("Text rendering"); + + text_on_bg(ui, Color32::from_gray(200), Color32::from_gray(230)); // gray on gray + text_on_bg(ui, Color32::from_gray(140), Color32::from_gray(28)); // dark mode normal text + + // Matches Mac Font book (useful for testing): + text_on_bg(ui, Color32::from_gray(39), Color32::from_gray(255)); + text_on_bg(ui, Color32::from_gray(220), Color32::from_gray(30)); + + ui.separator(); + + blending_and_feathering_test(ui); + } + + fn color_test(&mut self, ui: &mut Ui) { + ui.label("If the rendering is done right, all groups of gradients will look uniform."); + + ui.horizontal(|ui| { + ui.checkbox(&mut self.vertex_gradients, "Vertex gradients"); + ui.checkbox(&mut self.texture_gradients, "Texture gradients"); + }); + + ui.heading("sRGB color test"); + ui.label("Use a color picker to ensure this color is (255, 165, 0) / #ffa500"); + ui.scope(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; // No spacing between gradients + let g = Gradient::one_color(Color32::from_rgb(255, 165, 0)); + self.vertex_gradient(ui, "orange rgb(255, 165, 0) - vertex", WHITE, &g); + self.tex_gradient(ui, "orange rgb(255, 165, 0) - texture", WHITE, &g); + }); + + ui.separator(); + + ui.label("Test that vertex color times texture color is done in gamma space:"); + ui.scope(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; // No spacing between gradients + + let tex_color = Color32::from_rgb(64, 128, 255); + let vertex_color = Color32::from_rgb(128, 196, 196); + let ground_truth = mul_color_gamma(tex_color, vertex_color); + + ui.horizontal(|ui| { + let color_size = ui.spacing().interact_size; + ui.label("texture"); + show_color(ui, tex_color, color_size); + ui.label(" * "); + show_color(ui, vertex_color, color_size); + ui.label(" vertex color ="); + }); + { + let g = Gradient::one_color(ground_truth); + self.vertex_gradient(ui, "Ground truth (vertices)", WHITE, &g); + self.tex_gradient(ui, "Ground truth (texture)", WHITE, &g); + } + + ui.horizontal(|ui| { + let g = Gradient::one_color(tex_color); + let tex = self.tex_mngr.get(ui.ctx(), &g); + let texel_offset = 0.5 / (g.0.len() as f32); + let uv = Rect::from_min_max(pos2(texel_offset, 0.0), pos2(1.0 - texel_offset, 1.0)); + ui.add( + Image::from_texture((tex.id(), GRADIENT_SIZE)) + .tint(vertex_color) + .uv(uv), + ) + .on_hover_text(format!("A texture that is {} texels wide", g.0.len())); + ui.label("GPU result"); + }); + }); + + ui.separator(); + + // TODO(emilk): test color multiplication (image tint), + // to make sure vertex and texture color multiplication is done in linear space. + + ui.label("Gamma interpolation:"); + self.show_gradients(ui, WHITE, (RED, GREEN), Interpolation::Gamma); + + ui.separator(); + + self.show_gradients(ui, RED, (TRANSPARENT, GREEN), Interpolation::Gamma); + + ui.separator(); + + self.show_gradients(ui, WHITE, (TRANSPARENT, GREEN), Interpolation::Gamma); + + ui.separator(); + + self.show_gradients(ui, BLACK, (BLACK, WHITE), Interpolation::Gamma); + ui.separator(); + self.show_gradients(ui, WHITE, (BLACK, TRANSPARENT), Interpolation::Gamma); + ui.separator(); + self.show_gradients(ui, BLACK, (TRANSPARENT, WHITE), Interpolation::Gamma); + ui.separator(); + + ui.label("Additive blending: add more and more blue to the red background:"); + self.show_gradients( + ui, + RED, + (TRANSPARENT, Color32::from_rgb_additive(0, 0, 255)), + Interpolation::Gamma, + ); + + ui.separator(); + + ui.label("Linear interpolation (texture sampling):"); + self.show_gradients(ui, WHITE, (RED, GREEN), Interpolation::Linear); + } + + fn show_gradients( + &mut self, + ui: &mut Ui, + bg_fill: Color32, + (left, right): (Color32, Color32), + interpolation: Interpolation, + ) { + let is_opaque = left.is_opaque() && right.is_opaque(); + + ui.horizontal(|ui| { + let color_size = ui.spacing().interact_size; + if !is_opaque { + ui.label("Background:"); + show_color(ui, bg_fill, color_size); + } + ui.label("gradient"); + show_color(ui, left, color_size); + ui.label("-"); + show_color(ui, right, color_size); + }); + + ui.scope(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; // No spacing between gradients + if is_opaque { + let g = Gradient::ground_truth_gradient(left, right, interpolation); + self.vertex_gradient(ui, "Ground Truth (CPU gradient) - vertices", bg_fill, &g); + self.tex_gradient(ui, "Ground Truth (CPU gradient) - texture", bg_fill, &g); + } else { + let g = Gradient::ground_truth_gradient(left, right, interpolation) + .with_bg_fill(bg_fill); + self.vertex_gradient( + ui, + "Ground Truth (CPU gradient, CPU blending) - vertices", + bg_fill, + &g, + ); + self.tex_gradient( + ui, + "Ground Truth (CPU gradient, CPU blending) - texture", + bg_fill, + &g, + ); + let g = Gradient::ground_truth_gradient(left, right, interpolation); + self.vertex_gradient(ui, "CPU gradient, GPU blending - vertices", bg_fill, &g); + self.tex_gradient(ui, "CPU gradient, GPU blending - texture", bg_fill, &g); + } + + let g = Gradient::endpoints(left, right); + + match interpolation { + Interpolation::Linear => { + // texture sampler is sRGBA aware, and should therefore be linear + self.tex_gradient(ui, "Texture of width 2 (test texture sampler)", bg_fill, &g); + } + Interpolation::Gamma => { + // vertex shader uses gamma + self.vertex_gradient( + ui, + "Triangle mesh of width 2 (test vertex decode and interpolation)", + bg_fill, + &g, + ); + } + } + }); + } + + fn tex_gradient(&mut self, ui: &mut Ui, label: &str, bg_fill: Color32, gradient: &Gradient) { + if !self.texture_gradients { + return; + } + ui.horizontal(|ui| { + let tex = self.tex_mngr.get(ui.ctx(), gradient); + let texel_offset = 0.5 / (gradient.0.len() as f32); + let uv = Rect::from_min_max(pos2(texel_offset, 0.0), pos2(1.0 - texel_offset, 1.0)); + ui.add( + Image::from_texture((tex.id(), GRADIENT_SIZE)) + .bg_fill(bg_fill) + .uv(uv), + ) + .on_hover_text(format!( + "A texture that is {} texels wide", + gradient.0.len() + )); + ui.label(label); + }); + } + + fn vertex_gradient(&self, ui: &mut Ui, label: &str, bg_fill: Color32, gradient: &Gradient) { + if !self.vertex_gradients { + return; + } + ui.horizontal(|ui| { + vertex_gradient(ui, bg_fill, gradient).on_hover_text(format!( + "A triangle mesh that is {} vertices wide", + gradient.0.len() + )); + ui.label(label); + }); + } +} + +fn vertex_gradient(ui: &mut Ui, bg_fill: Color32, gradient: &Gradient) -> Response { + let (rect, response) = ui.allocate_at_least(GRADIENT_SIZE, Sense::hover()); + if bg_fill != Default::default() { + let mut mesh = Mesh::default(); + mesh.add_colored_rect(rect, bg_fill); + ui.painter().add(Shape::mesh(mesh)); + } + { + let n = gradient.0.len(); + assert!(n >= 2); + let mut mesh = Mesh::default(); + for (i, &color) in gradient.0.iter().enumerate() { + let t = i as f32 / (n as f32 - 1.0); + let x = lerp(rect.x_range(), t); + mesh.colored_vertex(pos2(x, rect.top()), color); + mesh.colored_vertex(pos2(x, rect.bottom()), color); + if i < n - 1 { + let i = i as u32; + mesh.add_triangle(2 * i, 2 * i + 1, 2 * i + 2); + mesh.add_triangle(2 * i + 1, 2 * i + 2, 2 * i + 3); + } + } + ui.painter().add(Shape::mesh(mesh)); + } + response +} + +#[derive(Clone, Copy)] +enum Interpolation { + Linear, + Gamma, +} + +#[derive(Clone, Hash, PartialEq, Eq)] +struct Gradient(pub Vec); + +impl Gradient { + pub fn one_color(srgba: Color32) -> Self { + Self(vec![srgba, srgba]) + } + + pub fn endpoints(left: Color32, right: Color32) -> Self { + Self(vec![left, right]) + } + + pub fn ground_truth_gradient( + left: Color32, + right: Color32, + interpolation: Interpolation, + ) -> Self { + match interpolation { + Interpolation::Linear => Self::ground_truth_linear_gradient(left, right), + Interpolation::Gamma => Self::ground_truth_gamma_gradient(left, right), + } + } + + pub fn ground_truth_linear_gradient(left: Color32, right: Color32) -> Self { + let left = Rgba::from(left); + let right = Rgba::from(right); + + let n = 255; + Self( + (0..=n) + .map(|i| { + let t = i as f32 / n as f32; + Color32::from(lerp(left..=right, t)) + }) + .collect(), + ) + } + + pub fn ground_truth_gamma_gradient(left: Color32, right: Color32) -> Self { + let n = 255; + Self( + (0..=n) + .map(|i| { + let t = i as f32 / n as f32; + left.lerp_to_gamma(right, t) + }) + .collect(), + ) + } + + /// Do premultiplied alpha-aware blending of the gradient on top of the fill color + /// in gamma-space. + pub fn with_bg_fill(self, bg: Color32) -> Self { + Self( + self.0 + .into_iter() + .map(|fg| { + let a = fg.a() as f32 / 255.0; + Color32::from_rgba_premultiplied( + (bg[0] as f32 * (1.0 - a) + fg[0] as f32).round() as u8, + (bg[1] as f32 * (1.0 - a) + fg[1] as f32).round() as u8, + (bg[2] as f32 * (1.0 - a) + fg[2] as f32).round() as u8, + (bg[3] as f32 * (1.0 - a) + fg[3] as f32).round() as u8, + ) + }) + .collect(), + ) + } + + pub fn to_pixel_row(&self) -> Vec { + self.0.clone() + } +} + +#[derive(Default)] +struct TextureManager(HashMap); + +impl TextureManager { + fn get(&mut self, ctx: &egui::Context, gradient: &Gradient) -> &TextureHandle { + self.0.entry(gradient.clone()).or_insert_with(|| { + let pixels = gradient.to_pixel_row(); + let width = pixels.len(); + let height = 1; + ctx.load_texture( + "color_test_gradient", + epaint::ColorImage { + size: [width, height], + pixels, + }, + TextureOptions::LINEAR, + ) + }) + } +} + +/// A visual test that the rendering is correctly aligned on the physical pixel grid. +/// +/// Requires eyes and a magnifying glass to verify. +pub fn pixel_test(ui: &mut Ui) { + ui.heading("Pixel alignment test"); + ui.label("If anything is blurry, then everything will be blurry, including text."); + ui.label("You might need a magnifying glass to check this test."); + + if cfg!(target_arch = "wasm32") { + ui.label("Make sure these test pass even when you zoom in/out and resize the browser."); + } + + ui.add_space(4.0); + + pixel_test_lines(ui); + + ui.add_space(4.0); + + pixel_test_squares(ui); + + ui.add_space(4.0); + + pixel_test_strokes(ui); +} + +fn pixel_test_strokes(ui: &mut Ui) { + ui.label("The strokes should align to the physical pixel grid."); + let color = if ui.style().visuals.dark_mode { + egui::Color32::WHITE + } else { + egui::Color32::BLACK + }; + + let pixels_per_point = ui.ctx().pixels_per_point(); + + for thickness_pixels in 1..=3 { + let thickness_pixels = thickness_pixels as f32; + let thickness_points = thickness_pixels / pixels_per_point; + let num_squares = (pixels_per_point * 10.0).round().max(10.0) as u32; + let size_pixels = vec2(ui.min_size().x, num_squares as f32 + thickness_pixels * 2.0); + let size_points = size_pixels / pixels_per_point + Vec2::splat(2.0); + let (response, painter) = ui.allocate_painter(size_points, Sense::hover()); + + let mut cursor_pixel = Pos2::new( + response.rect.min.x * pixels_per_point + thickness_pixels, + response.rect.min.y * pixels_per_point + thickness_pixels, + ) + .ceil(); + + let stroke = Stroke::new(thickness_points, color); + for size in 1..=num_squares { + let rect_points = Rect::from_min_size( + Pos2::new(cursor_pixel.x, cursor_pixel.y), + Vec2::splat(size as f32), + ); + painter.rect_stroke(rect_points / pixels_per_point, 0.0, stroke); + cursor_pixel.x += (1 + size) as f32 + thickness_pixels * 2.0; + } + } +} + +fn pixel_test_squares(ui: &mut Ui) { + ui.label("The first square should be exactly one physical pixel big."); + ui.label("They should be exactly one physical pixel apart."); + ui.label("Each subsequent square should be one physical pixel larger than the previous."); + ui.label("They should be perfectly aligned to the physical pixel grid."); + + let color = if ui.style().visuals.dark_mode { + egui::Color32::WHITE + } else { + egui::Color32::BLACK + }; + + let pixels_per_point = ui.ctx().pixels_per_point(); + + let num_squares = (pixels_per_point * 10.0).round().max(10.0) as u32; + let size_pixels = vec2( + ((num_squares + 1) * (num_squares + 2) / 2) as f32, + num_squares as f32, + ); + let size_points = size_pixels / pixels_per_point + Vec2::splat(2.0); + let (response, painter) = ui.allocate_painter(size_points, Sense::hover()); + + let mut cursor_pixel = Pos2::new( + response.rect.min.x * pixels_per_point, + response.rect.min.y * pixels_per_point, + ) + .ceil(); + for size in 1..=num_squares { + let rect_points = Rect::from_min_size( + Pos2::new(cursor_pixel.x, cursor_pixel.y), + Vec2::splat(size as f32), + ); + painter.rect_filled(rect_points / pixels_per_point, 0.0, color); + cursor_pixel.x += (1 + size) as f32; + } +} + +fn pixel_test_lines(ui: &mut Ui) { + let pixels_per_point = ui.ctx().pixels_per_point(); + let n = (96.0 * pixels_per_point) as usize; + + ui.label("The lines should be exactly one physical pixel wide, one physical pixel apart."); + ui.label("They should be perfectly white and black."); + + let hspace_px = pixels_per_point * 4.0; + + let size_px = Vec2::new(2.0 * n as f32 + hspace_px, n as f32); + let size_points = size_px / pixels_per_point + Vec2::splat(2.0); + let (response, painter) = ui.allocate_painter(size_points, Sense::hover()); + + let mut cursor_px = Pos2::new( + response.rect.min.x * pixels_per_point, + response.rect.min.y * pixels_per_point, + ) + .ceil(); + + // Vertical stripes: + for x in 0..n / 2 { + let rect_px = Rect::from_min_size( + Pos2::new(cursor_px.x + 2.0 * x as f32, cursor_px.y), + Vec2::new(1.0, n as f32), + ); + painter.rect_filled(rect_px / pixels_per_point, 0.0, egui::Color32::WHITE); + let rect_px = rect_px.translate(vec2(1.0, 0.0)); + painter.rect_filled(rect_px / pixels_per_point, 0.0, egui::Color32::BLACK); + } + + cursor_px.x += n as f32 + hspace_px; + + // Horizontal stripes: + for y in 0..n / 2 { + let rect_px = Rect::from_min_size( + Pos2::new(cursor_px.x, cursor_px.y + 2.0 * y as f32), + Vec2::new(n as f32, 1.0), + ); + painter.rect_filled(rect_px / pixels_per_point, 0.0, egui::Color32::WHITE); + let rect_px = rect_px.translate(vec2(0.0, 1.0)); + painter.rect_filled(rect_px / pixels_per_point, 0.0, egui::Color32::BLACK); + } +} + +fn blending_and_feathering_test(ui: &mut Ui) { + ui.label("The left side shows how lines of different widths look."); + ui.label("The right side tests text rendering at different opacities and sizes."); + ui.label("The top and bottom images should look symmetrical in their intensities."); + + let size = vec2(512.0, 512.0); + let (response, painter) = ui.allocate_painter(size, Sense::hover()); + let rect = response.rect; + + let mut top_half = rect; + top_half.set_bottom(top_half.center().y); + painter.rect_filled(top_half, 0.0, Color32::BLACK); + paint_fine_lines_and_text(&painter, top_half, Color32::WHITE); + + let mut bottom_half = rect; + bottom_half.set_top(bottom_half.center().y); + painter.rect_filled(bottom_half, 0.0, Color32::WHITE); + paint_fine_lines_and_text(&painter, bottom_half, Color32::BLACK); +} + +fn text_on_bg(ui: &mut egui::Ui, fg: Color32, bg: Color32) { + assert!(fg.is_opaque()); + assert!(bg.is_opaque()); + + ui.horizontal(|ui| { + ui.label( + RichText::from("▣ The quick brown fox jumps over the lazy dog and runs away.") + .background_color(bg) + .color(fg), + ); + ui.label(format!( + "({} {} {}) on ({} {} {})", + fg.r(), + fg.g(), + fg.b(), + bg.r(), + bg.g(), + bg.b(), + )); + }); +} + +fn paint_fine_lines_and_text(painter: &egui::Painter, mut rect: Rect, color: Color32) { + { + let mut y = 0.0; + for opacity in [1.00, 0.50, 0.25, 0.10, 0.05, 0.02, 0.01, 0.00] { + painter.text( + rect.center_top() + vec2(0.0, y), + Align2::LEFT_TOP, + format!("{:.0}% white", 100.0 * opacity), + FontId::proportional(14.0), + Color32::WHITE.gamma_multiply(opacity), + ); + painter.text( + rect.center_top() + vec2(80.0, y), + Align2::LEFT_TOP, + format!("{:.0}% gray", 100.0 * opacity), + FontId::proportional(14.0), + Color32::GRAY.gamma_multiply(opacity), + ); + painter.text( + rect.center_top() + vec2(160.0, y), + Align2::LEFT_TOP, + format!("{:.0}% black", 100.0 * opacity), + FontId::proportional(14.0), + Color32::BLACK.gamma_multiply(opacity), + ); + y += 20.0; + } + + for font_size in [6.0, 7.0, 8.0, 9.0, 10.0, 12.0, 14.0] { + painter.text( + rect.center_top() + vec2(0.0, y), + Align2::LEFT_TOP, + format!( + "{font_size}px - The quick brown fox jumps over the lazy dog and runs away." + ), + FontId::proportional(font_size), + color, + ); + y += font_size + 1.0; + } + } + + rect.max.x = rect.center().x; + + rect = rect.shrink(16.0); + for width in [0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 4.0] { + painter.text( + rect.left_top(), + Align2::CENTER_CENTER, + width.to_string(), + FontId::monospace(12.0), + color, + ); + + painter.add(egui::epaint::CubicBezierShape::from_points_stroke( + [ + rect.left_top() + vec2(16.0, 0.0), + rect.right_top(), + rect.right_center(), + rect.right_bottom(), + ], + false, + Color32::TRANSPARENT, + Stroke::new(width, color), + )); + + rect.min.y += 24.0; + rect.max.x -= 24.0; + } + + rect.min.y += 16.0; + painter.text( + rect.left_top(), + Align2::LEFT_CENTER, + "transparent --> opaque", + FontId::monospace(10.0), + color, + ); + rect.min.y += 12.0; + let mut mesh = Mesh::default(); + mesh.colored_vertex(rect.left_bottom(), Color32::TRANSPARENT); + mesh.colored_vertex(rect.left_top(), Color32::TRANSPARENT); + mesh.colored_vertex(rect.right_bottom(), color); + mesh.colored_vertex(rect.right_top(), color); + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(1, 2, 3); + painter.add(mesh); +} + +fn mul_color_gamma(left: Color32, right: Color32) -> Color32 { + Color32::from_rgba_premultiplied( + (left.r() as f32 * right.r() as f32 / 255.0).round() as u8, + (left.g() as f32 * right.g() as f32 / 255.0).round() as u8, + (left.b() as f32 * right.b() as f32 / 255.0).round() as u8, + (left.a() as f32 * right.a() as f32 / 255.0).round() as u8, + ) +} diff --git a/examples/render_egui_to_image.rs b/examples/render_egui_to_image.rs index a3e17ad0c..62cc86a59 100644 --- a/examples/render_egui_to_image.rs +++ b/examples/render_egui_to_image.rs @@ -1,4 +1,6 @@ -use bevy::prelude::*; +use bevy::{ + input::mouse::MouseMotion, prelude::*, render::render_resource::LoadOp, window::PrimaryWindow, +}; use bevy_egui::{EguiContexts, EguiPlugin, EguiRenderToImage}; use wgpu_types::{Extent3d, TextureUsages}; @@ -7,22 +9,53 @@ fn main() { app.add_plugins(DefaultPlugins); app.add_plugins(EguiPlugin); app.add_systems(Startup, setup_worldspace); - app.add_systems(Update, (update_screenspace, update_worldspace)); + app.add_systems( + Update, + ( + update_screenspace, + update_worldspace, + handle_dragging, + draw_gizmos.after(handle_dragging), + ), + ); app.run(); } -fn update_screenspace(mut contexts: EguiContexts) { +struct Name(String); + +impl Default for Name { + fn default() -> Self { + Self("%username%".to_string()) + } +} + +fn update_screenspace(mut name: Local, mut contexts: EguiContexts) { egui::Window::new("Screenspace UI").show(contexts.ctx_mut(), |ui| { - ui.label("I'm rendering to screenspace!"); + ui.horizontal(|ui| { + ui.label("Your name:"); + ui.text_edit_singleline(&mut name.0); + }); + ui.label(format!( + "Hi {}, I'm rendering to an image in screenspace!", + name.0 + )); }); } -fn update_worldspace(mut contexts: Query<&mut bevy_egui::EguiContext, With>) { - for mut ctx in contexts.iter_mut() { - egui::Window::new("Worldspace UI").show(ctx.get_mut(), |ui| { - ui.label("I'm rendering to an image in worldspace!"); +fn update_worldspace( + mut name: Local, + mut ctx: Single<&mut bevy_egui::EguiContext, With>, +) { + egui::Window::new("Worldspace UI").show(ctx.get_mut(), |ui| { + ui.horizontal(|ui| { + ui.label("Your name:"); + ui.text_edit_singleline(&mut name.0); }); - } + ui.label(format!( + "Hi {}, I'm rendering to an image in worldspace!", + name.0 + )); + }); } fn setup_worldspace( @@ -30,7 +63,12 @@ fn setup_worldspace( mut meshes: ResMut>, mut materials: ResMut>, mut commands: Commands, + mut config_store: ResMut, ) { + for (_, config, _) in config_store.iter_mut() { + config.depth_bias = -1.0; + } + let image = images.add({ let size = Extent3d { width: 256, @@ -47,20 +85,85 @@ fn setup_worldspace( image }); + commands + .spawn(( + Mesh3d(meshes.add(Plane3d::new(Vec3::Z, Vec2::splat(0.5)).mesh())), + MeshMaterial3d(materials.add(StandardMaterial { + base_color: Color::WHITE, + base_color_texture: Some(Handle::clone(&image)), + alpha_mode: AlphaMode::Blend, + // Remove this if you want it to use the world's lighting. + unlit: true, + ..default() + })), + EguiRenderToImage { + handle: image, + load_op: LoadOp::Clear(Color::srgb_u8(43, 44, 47).to_linear().into()), + }, + )) + .with_child(( + Mesh3d(meshes.add(Cuboid::new(1.1, 1.1, 0.1))), + MeshMaterial3d(materials.add(StandardMaterial { + base_color: Color::linear_rgb(0.4, 0.4, 0.4), + ..default() + })), + Transform::from_xyz(0.0, 0.0, -0.051), + )); + commands.spawn(( - Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0).mesh())), - MeshMaterial3d(materials.add(StandardMaterial { - base_color: Color::WHITE, - base_color_texture: Some(Handle::clone(&image)), - alpha_mode: AlphaMode::Blend, - // Remove this if you want it to use the world's lighting. - unlit: true, - ..default() - })), - )); - commands.spawn(EguiRenderToImage::new(image)); - commands.spawn(( - Camera3d::default(), - Transform::from_xyz(1.5, 1.5, 1.5).looking_at(Vec3::new(0., 0., 0.), Vec3::Y), + PointLight::default(), + Transform::from_translation(Vec3::new(5.0, 3.0, 10.0)), )); + + let camera_transform = Transform::from_xyz(1.0, 1.5, 2.5).looking_at(Vec3::ZERO, Vec3::Y); + commands.spawn((Camera3d::default(), camera_transform)); +} + +fn draw_gizmos(mut gizmos: Gizmos, egui_mesh_query: Query<&Transform, With>) { + let egui_mesh_transform = egui_mesh_query.single(); + gizmos.axes(*egui_mesh_transform, 0.1); +} + +fn handle_dragging( + mouse_button_input: Res>, + mut mouse_motion_events: EventReader, + window_query: Query<&Window, With>, + mut egui_mesh_query: Query<&mut Transform, With>, + // Need to specify `Without` for `camera_query` and `egui_mesh_query` to be disjoint. + camera_query: Query<&Transform, (With, Without)>, + mut local_state: Local<(Quat, Quat)>, +) { + if !mouse_button_input.pressed(MouseButton::Left) { + mouse_motion_events.clear(); + return; + } + + let window = window_query.single(); + let camera_transform = camera_query.single(); + + let mut egui_mesh_transform = egui_mesh_query.single_mut(); + + let (initial_rotation, delta) = &mut *local_state; + + if mouse_button_input.just_pressed(MouseButton::Left) { + *initial_rotation = egui_mesh_transform.rotation; + *delta = Quat::IDENTITY; + } + + for ev in mouse_motion_events.read() { + let angle = Vec2::new( + ev.delta.x / window.physical_width() as f32, + ev.delta.y / window.physical_height() as f32, + ) + .length() + * std::f32::consts::PI + * 2.0; + let frame_delta = + Quat::from_axis_angle(Vec3::new(ev.delta.y, ev.delta.x, 0.0).normalize(), angle); + *delta = frame_delta * *delta; + + let camera_rotation = camera_transform.rotation; + egui_mesh_transform.rotation = + camera_rotation * *delta * camera_rotation.conjugate() * *initial_rotation; + } } diff --git a/examples/simple.rs b/examples/simple.rs index 8a518a4ce..86bddd021 100644 --- a/examples/simple.rs +++ b/examples/simple.rs @@ -6,7 +6,7 @@ fn main() { .add_plugins(DefaultPlugins) .add_plugins(EguiPlugin) // Systems that create Egui widgets should be run during the `CoreSet::Update` set, - // or after the `EguiSet::BeginPass` system (which belongs to the `CoreSet::PreUpdate` set). + // or after the `EguiPreUpdateSet::BeginPass` system (which belongs to the `CoreSet::PreUpdate` set). .add_systems(Update, ui_example_system) .run(); } diff --git a/src/helpers.rs b/src/helpers.rs new file mode 100644 index 000000000..e365bdb07 --- /dev/null +++ b/src/helpers.rs @@ -0,0 +1,252 @@ +use bevy_ecs::{ + entity::Entity, + query::{QueryData, QueryEntityError, QueryFilter, QueryItem, ROQueryItem}, + system::Query, +}; +use bevy_input::keyboard::{Key, KeyCode}; + +/// Translates [`egui::CursorIcon`] into [`bevy_window::SystemCursorIcon`]. +pub fn egui_to_winit_cursor_icon( + cursor_icon: egui::CursorIcon, +) -> Option { + match cursor_icon { + egui::CursorIcon::Default => Some(bevy_window::SystemCursorIcon::Default), + egui::CursorIcon::PointingHand => Some(bevy_window::SystemCursorIcon::Pointer), + egui::CursorIcon::ResizeHorizontal => Some(bevy_window::SystemCursorIcon::EwResize), + egui::CursorIcon::ResizeNeSw => Some(bevy_window::SystemCursorIcon::NeswResize), + egui::CursorIcon::ResizeNwSe => Some(bevy_window::SystemCursorIcon::NwseResize), + egui::CursorIcon::ResizeVertical => Some(bevy_window::SystemCursorIcon::NsResize), + egui::CursorIcon::Text => Some(bevy_window::SystemCursorIcon::Text), + egui::CursorIcon::Grab => Some(bevy_window::SystemCursorIcon::Grab), + egui::CursorIcon::Grabbing => Some(bevy_window::SystemCursorIcon::Grabbing), + egui::CursorIcon::ContextMenu => Some(bevy_window::SystemCursorIcon::ContextMenu), + egui::CursorIcon::Help => Some(bevy_window::SystemCursorIcon::Help), + egui::CursorIcon::Progress => Some(bevy_window::SystemCursorIcon::Progress), + egui::CursorIcon::Wait => Some(bevy_window::SystemCursorIcon::Wait), + egui::CursorIcon::Cell => Some(bevy_window::SystemCursorIcon::Cell), + egui::CursorIcon::Crosshair => Some(bevy_window::SystemCursorIcon::Crosshair), + egui::CursorIcon::VerticalText => Some(bevy_window::SystemCursorIcon::VerticalText), + egui::CursorIcon::Alias => Some(bevy_window::SystemCursorIcon::Alias), + egui::CursorIcon::Copy => Some(bevy_window::SystemCursorIcon::Copy), + egui::CursorIcon::Move => Some(bevy_window::SystemCursorIcon::Move), + egui::CursorIcon::NoDrop => Some(bevy_window::SystemCursorIcon::NoDrop), + egui::CursorIcon::NotAllowed => Some(bevy_window::SystemCursorIcon::NotAllowed), + egui::CursorIcon::AllScroll => Some(bevy_window::SystemCursorIcon::AllScroll), + egui::CursorIcon::ZoomIn => Some(bevy_window::SystemCursorIcon::ZoomIn), + egui::CursorIcon::ZoomOut => Some(bevy_window::SystemCursorIcon::ZoomOut), + egui::CursorIcon::ResizeEast => Some(bevy_window::SystemCursorIcon::EResize), + egui::CursorIcon::ResizeSouthEast => Some(bevy_window::SystemCursorIcon::SeResize), + egui::CursorIcon::ResizeSouth => Some(bevy_window::SystemCursorIcon::SResize), + egui::CursorIcon::ResizeSouthWest => Some(bevy_window::SystemCursorIcon::SwResize), + egui::CursorIcon::ResizeWest => Some(bevy_window::SystemCursorIcon::WResize), + egui::CursorIcon::ResizeNorthWest => Some(bevy_window::SystemCursorIcon::NwResize), + egui::CursorIcon::ResizeNorth => Some(bevy_window::SystemCursorIcon::NResize), + egui::CursorIcon::ResizeNorthEast => Some(bevy_window::SystemCursorIcon::NeResize), + egui::CursorIcon::ResizeColumn => Some(bevy_window::SystemCursorIcon::ColResize), + egui::CursorIcon::ResizeRow => Some(bevy_window::SystemCursorIcon::RowResize), + egui::CursorIcon::None => None, + } +} + +/// Matches the implementation of . +pub fn bevy_to_egui_key(key: &Key) -> Option { + let key = match key { + Key::Character(str) => return egui::Key::from_name(str.as_str()), + Key::Unidentified(_) | Key::Dead(_) => return None, + + Key::Enter => egui::Key::Enter, + Key::Tab => egui::Key::Tab, + Key::Space => egui::Key::Space, + Key::ArrowDown => egui::Key::ArrowDown, + Key::ArrowLeft => egui::Key::ArrowLeft, + Key::ArrowRight => egui::Key::ArrowRight, + Key::ArrowUp => egui::Key::ArrowUp, + Key::End => egui::Key::End, + Key::Home => egui::Key::Home, + Key::PageDown => egui::Key::PageDown, + Key::PageUp => egui::Key::PageUp, + Key::Backspace => egui::Key::Backspace, + Key::Delete => egui::Key::Delete, + Key::Insert => egui::Key::Insert, + Key::Escape => egui::Key::Escape, + Key::F1 => egui::Key::F1, + Key::F2 => egui::Key::F2, + Key::F3 => egui::Key::F3, + Key::F4 => egui::Key::F4, + Key::F5 => egui::Key::F5, + Key::F6 => egui::Key::F6, + Key::F7 => egui::Key::F7, + Key::F8 => egui::Key::F8, + Key::F9 => egui::Key::F9, + Key::F10 => egui::Key::F10, + Key::F11 => egui::Key::F11, + Key::F12 => egui::Key::F12, + Key::F13 => egui::Key::F13, + Key::F14 => egui::Key::F14, + Key::F15 => egui::Key::F15, + Key::F16 => egui::Key::F16, + Key::F17 => egui::Key::F17, + Key::F18 => egui::Key::F18, + Key::F19 => egui::Key::F19, + Key::F20 => egui::Key::F20, + + _ => return None, + }; + Some(key) +} + +/// Matches the implementation of . +pub fn bevy_to_egui_physical_key(key: &KeyCode) -> Option { + let key = match key { + KeyCode::ArrowDown => egui::Key::ArrowDown, + KeyCode::ArrowLeft => egui::Key::ArrowLeft, + KeyCode::ArrowRight => egui::Key::ArrowRight, + KeyCode::ArrowUp => egui::Key::ArrowUp, + + KeyCode::Escape => egui::Key::Escape, + KeyCode::Tab => egui::Key::Tab, + KeyCode::Backspace => egui::Key::Backspace, + KeyCode::Enter | KeyCode::NumpadEnter => egui::Key::Enter, + + KeyCode::Insert => egui::Key::Insert, + KeyCode::Delete => egui::Key::Delete, + KeyCode::Home => egui::Key::Home, + KeyCode::End => egui::Key::End, + KeyCode::PageUp => egui::Key::PageUp, + KeyCode::PageDown => egui::Key::PageDown, + + // Punctuation + KeyCode::Space => egui::Key::Space, + KeyCode::Comma => egui::Key::Comma, + KeyCode::Period => egui::Key::Period, + // KeyCode::Colon => egui::Key::Colon, // NOTE: there is no physical colon key on an american keyboard + KeyCode::Semicolon => egui::Key::Semicolon, + KeyCode::Backslash => egui::Key::Backslash, + KeyCode::Slash | KeyCode::NumpadDivide => egui::Key::Slash, + KeyCode::BracketLeft => egui::Key::OpenBracket, + KeyCode::BracketRight => egui::Key::CloseBracket, + KeyCode::Backquote => egui::Key::Backtick, + + KeyCode::Cut => egui::Key::Cut, + KeyCode::Copy => egui::Key::Copy, + KeyCode::Paste => egui::Key::Paste, + KeyCode::Minus | KeyCode::NumpadSubtract => egui::Key::Minus, + KeyCode::NumpadAdd => egui::Key::Plus, + KeyCode::Equal => egui::Key::Equals, + + KeyCode::Digit0 | KeyCode::Numpad0 => egui::Key::Num0, + KeyCode::Digit1 | KeyCode::Numpad1 => egui::Key::Num1, + KeyCode::Digit2 | KeyCode::Numpad2 => egui::Key::Num2, + KeyCode::Digit3 | KeyCode::Numpad3 => egui::Key::Num3, + KeyCode::Digit4 | KeyCode::Numpad4 => egui::Key::Num4, + KeyCode::Digit5 | KeyCode::Numpad5 => egui::Key::Num5, + KeyCode::Digit6 | KeyCode::Numpad6 => egui::Key::Num6, + KeyCode::Digit7 | KeyCode::Numpad7 => egui::Key::Num7, + KeyCode::Digit8 | KeyCode::Numpad8 => egui::Key::Num8, + KeyCode::Digit9 | KeyCode::Numpad9 => egui::Key::Num9, + + KeyCode::KeyA => egui::Key::A, + KeyCode::KeyB => egui::Key::B, + KeyCode::KeyC => egui::Key::C, + KeyCode::KeyD => egui::Key::D, + KeyCode::KeyE => egui::Key::E, + KeyCode::KeyF => egui::Key::F, + KeyCode::KeyG => egui::Key::G, + KeyCode::KeyH => egui::Key::H, + KeyCode::KeyI => egui::Key::I, + KeyCode::KeyJ => egui::Key::J, + KeyCode::KeyK => egui::Key::K, + KeyCode::KeyL => egui::Key::L, + KeyCode::KeyM => egui::Key::M, + KeyCode::KeyN => egui::Key::N, + KeyCode::KeyO => egui::Key::O, + KeyCode::KeyP => egui::Key::P, + KeyCode::KeyQ => egui::Key::Q, + KeyCode::KeyR => egui::Key::R, + KeyCode::KeyS => egui::Key::S, + KeyCode::KeyT => egui::Key::T, + KeyCode::KeyU => egui::Key::U, + KeyCode::KeyV => egui::Key::V, + KeyCode::KeyW => egui::Key::W, + KeyCode::KeyX => egui::Key::X, + KeyCode::KeyY => egui::Key::Y, + KeyCode::KeyZ => egui::Key::Z, + + KeyCode::F1 => egui::Key::F1, + KeyCode::F2 => egui::Key::F2, + KeyCode::F3 => egui::Key::F3, + KeyCode::F4 => egui::Key::F4, + KeyCode::F5 => egui::Key::F5, + KeyCode::F6 => egui::Key::F6, + KeyCode::F7 => egui::Key::F7, + KeyCode::F8 => egui::Key::F8, + KeyCode::F9 => egui::Key::F9, + KeyCode::F10 => egui::Key::F10, + KeyCode::F11 => egui::Key::F11, + KeyCode::F12 => egui::Key::F12, + KeyCode::F13 => egui::Key::F13, + KeyCode::F14 => egui::Key::F14, + KeyCode::F15 => egui::Key::F15, + KeyCode::F16 => egui::Key::F16, + KeyCode::F17 => egui::Key::F17, + KeyCode::F18 => egui::Key::F18, + KeyCode::F19 => egui::Key::F19, + KeyCode::F20 => egui::Key::F20, + _ => return None, + }; + Some(key) +} + +/// Converts [`bevy_math::Vec2`] into [`egui::Pos2`]. +pub fn vec2_into_egui_pos2(vec: bevy_math::Vec2) -> egui::Pos2 { + egui::Pos2::new(vec.x, vec.y) +} + +/// Converts [`bevy_math::Vec2`] into [`egui::Vec2`]. +pub fn vec2_into_egui_vec2(vec: bevy_math::Vec2) -> egui::Vec2 { + egui::Vec2::new(vec.x, vec.y) +} + +/// Converts [`egui::Pos2`] into [`bevy_math::Vec2`]. +pub fn egui_pos2_into_vec2(pos: egui::Pos2) -> bevy_math::Vec2 { + bevy_math::Vec2::new(pos.x, pos.y) +} + +/// Converts [`egui::Vec2`] into [`bevy_math::Vec2`]. +pub fn egui_vec2_into_vec2(pos: egui::Vec2) -> bevy_math::Vec2 { + bevy_math::Vec2::new(pos.x, pos.y) +} + +pub(crate) trait QueryHelper<'w> { + type QueryData: bevy_ecs::query::QueryData; + + fn get_some(&self, entity: Entity) -> Option>; + + fn get_some_mut(&mut self, entity: Entity) -> Option>; +} + +impl<'w, D: QueryData, F: QueryFilter> QueryHelper<'w> for Query<'w, '_, D, F> { + type QueryData = D; + + fn get_some(&self, entity: Entity) -> Option> { + match self.get(entity) { + Ok(item) => Some(item), + Err(QueryEntityError::NoSuchEntity(_)) => None, + err => { + err.unwrap(); + unreachable!() + } + } + } + + fn get_some_mut(&mut self, entity: Entity) -> Option> { + match self.get_mut(entity) { + Ok(item) => Some(item), + Err(QueryEntityError::NoSuchEntity(_)) => None, + err => { + err.unwrap(); + unreachable!() + } + } + } +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 000000000..3953b70c3 --- /dev/null +++ b/src/input.rs @@ -0,0 +1,690 @@ +#[cfg(target_arch = "wasm32")] +use crate::text_agent::{is_mobile_safari, update_text_agent}; +use crate::{ + helpers::{vec2_into_egui_pos2, QueryHelper}, + EguiContext, EguiGlobalSettings, EguiInput, EguiOutput, EguiSettings, +}; +use bevy_ecs::prelude::*; +use bevy_input::{ + keyboard::{Key, KeyboardFocusLost, KeyboardInput}, + mouse::{MouseButton, MouseButtonInput, MouseScrollUnit, MouseWheel}, + touch::TouchInput, + ButtonState, +}; +use bevy_time::{Real, Time}; +use bevy_window::{CursorMoved, Ime, Window}; +use egui::Modifiers; + +/// Cached pointer position, used to populate [`egui::Event::PointerButton`] events. +#[derive(Component, Default)] +pub struct EguiContextPointerPosition { + /// Pointer position. + pub position: egui::Pos2, +} + +/// Stores an active touch id. +#[derive(Component, Default)] +pub struct EguiContextPointerTouchId { + /// Active touch id. + pub pointer_touch_id: Option, +} + +/// Indicates whether [IME](https://en.wikipedia.org/wiki/Input_method) is enabled or disabled to avoid sending event duplicates. +#[derive(Component, Default)] +pub struct EguiContextImeState { + /// Indicates whether IME is enabled. + pub has_sent_ime_enabled: bool, +} + +#[derive(Event)] +/// Wraps Egui events emitted by [`crate::EguiInputSet`] systems. +pub struct EguiInputEvent { + /// Context to pass an event to. + pub context: Entity, + /// Wrapped event. + pub event: egui::Event, +} + +#[derive(Resource)] +/// Insert this resource when a pointer hovers over a non-window (e.g. world-space) [`EguiContext`] entity. +/// Also, make sure to update an [`EguiContextPointerPosition`] component of a hovered entity. +/// Both updates should happen during [`crate::EguiInputSet::InitReading`]. +/// +/// To learn how `bevy_egui` uses this resource, see the [`FocusedNonWindowEguiContext`] documentation. +pub struct HoveredNonWindowEguiContext(pub Entity); + +/// Stores an entity of a focused non-window context (to push keyboard events to). +/// +/// The resource won't exist if no context is focused, [`Option>`] must be used to read from it. +/// If the [`HoveredNonWindowEguiContext`] resource exists, the [`FocusedNonWindowEguiContext`] +/// resource will get inserted on mouse button press or touch start event +/// (and removed if no hovered non-window context exists respectively). +/// +/// Atm, it's up to users to update [`HoveredNonWindowEguiContext`] and [`EguiContextPointerPosition`]. +/// We might be able to add proper `bevy_picking` support for world space UI once [`bevy_picking::backend::HitData`] +/// starts exposing triangle index or UV. +/// +/// Updating focused contexts happens during [`crate::EguiInputSet::FocusContext`], +/// see [`write_pointer_button_events_system`] and [`write_window_touch_events_system`]. +#[derive(Resource)] +pub struct FocusedNonWindowEguiContext(pub Entity); + +/// Stores "pressed" state of modifier keys. +#[derive(Resource, Clone, Copy, Debug)] +pub struct ModifierKeysState { + /// Indicates whether the [`Key::Shift`] key is pressed. + pub shift: bool, + /// Indicates whether the [`Key::Control`] key is pressed. + pub ctrl: bool, + /// Indicates whether the [`Key::Alt`] key is pressed. + pub alt: bool, + /// Indicates whether the [`Key::Super`] (or [`Key::Meta`]) key is pressed. + pub win: bool, + is_macos: bool, +} + +impl Default for ModifierKeysState { + fn default() -> Self { + let mut state = Self { + shift: false, + ctrl: false, + alt: false, + win: false, + is_macos: false, + }; + + #[cfg(not(target_arch = "wasm32"))] + { + state.is_macos = cfg!(target_os = "macos"); + } + + #[cfg(target_arch = "wasm32")] + 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") { + state.is_macos = true; + } + } + } + + state + } +} + +impl ModifierKeysState { + /// Converts the struct to [`egui::Modifiers`]. + pub fn to_egui_modifiers(&self) -> egui::Modifiers { + egui::Modifiers { + alt: self.alt, + ctrl: self.ctrl, + shift: self.shift, + mac_cmd: if self.is_macos { self.win } else { false }, + command: if self.is_macos { self.win } else { self.ctrl }, + } + } + + /// Returns `true` if modifiers shouldn't prevent text input (we don't want to put characters on pressing Ctrl+A, etc). + pub fn text_input_is_allowed(&self) -> bool { + // Ctrl + Alt enables AltGr which is used to print special characters. + !self.win && !self.ctrl || !self.is_macos && self.ctrl && self.alt + } + + fn reset(&mut self) { + self.shift = false; + self.ctrl = false; + self.alt = false; + self.win = false; + } +} + +/// Reads [`KeyboardInput`] events to update the [`ModifierKeysState`] resource. +pub fn write_modifiers_keys_state_system( + mut ev_keyboard_input: EventReader, + mut ev_focus: EventReader, + mut modifier_keys_state: ResMut, +) { + // If window focus is lost, clear all modifiers to avoid stuck keys. + if !ev_focus.is_empty() { + ev_focus.clear(); + modifier_keys_state.reset(); + } + + for event in ev_keyboard_input.read() { + let KeyboardInput { + logical_key, state, .. + } = event; + match logical_key { + Key::Shift => { + modifier_keys_state.shift = state.is_pressed(); + } + Key::Control => { + modifier_keys_state.ctrl = state.is_pressed(); + } + Key::Alt => { + modifier_keys_state.alt = state.is_pressed(); + } + Key::Super | Key::Meta => { + modifier_keys_state.win = state.is_pressed(); + } + _ => {} + }; + } +} + +/// Reads [`MouseButtonInput`] events and wraps them into [`EguiInputEvent`] (only for window contexts). +pub fn write_window_pointer_moved_events_system( + mut cursor_moved_reader: EventReader, + mut egui_input_event_writer: EventWriter, + mut egui_contexts: Query< + (&EguiSettings, &mut EguiContextPointerPosition), + (With, With), + >, +) { + for event in cursor_moved_reader.read() { + let Some((context_settings, mut context_pointer_position)) = + egui_contexts.get_some_mut(event.window) + else { + continue; + }; + + let scale_factor = context_settings.scale_factor; + let pointer_position = vec2_into_egui_pos2(event.position / scale_factor); + context_pointer_position.position = pointer_position; + egui_input_event_writer.send(EguiInputEvent { + context: event.window, + event: egui::Event::PointerMoved(pointer_position), + }); + } +} + +/// Reads [`MouseButtonInput`] events and wraps them into [`EguiInputEvent`], can redirect events to [`HoveredNonWindowEguiContext`], +/// inserts, updates or removes the [`FocusedNonWindowEguiContext`] resource based on a hovered context. +pub fn write_pointer_button_events_system( + egui_global_settings: Res, + mut commands: Commands, + hovered_non_window_egui_context: Option>, + modifier_keys_state: Res, + mut mouse_button_input_reader: EventReader, + mut egui_input_event_writer: EventWriter, + egui_contexts: Query<&EguiContextPointerPosition, With>, +) { + let modifiers = modifier_keys_state.to_egui_modifiers(); + for event in mouse_button_input_reader.read() { + let hovered_context = hovered_non_window_egui_context + .as_deref() + .map_or(event.window, |hovered| hovered.0); + + let Some(context_pointer_position) = egui_contexts.get_some(hovered_context) else { + continue; + }; + + let button = match event.button { + MouseButton::Left => Some(egui::PointerButton::Primary), + MouseButton::Right => Some(egui::PointerButton::Secondary), + MouseButton::Middle => Some(egui::PointerButton::Middle), + MouseButton::Back => Some(egui::PointerButton::Extra1), + MouseButton::Forward => Some(egui::PointerButton::Extra2), + _ => None, + }; + let Some(button) = button else { + continue; + }; + let pressed = match event.state { + ButtonState::Pressed => true, + ButtonState::Released => false, + }; + egui_input_event_writer.send(EguiInputEvent { + context: hovered_context, + event: egui::Event::PointerButton { + pos: context_pointer_position.position, + button, + pressed, + modifiers, + }, + }); + + // If we are hovering over some UI in world space, we want to mark it as focused on mouse click. + if egui_global_settings.enable_focused_non_window_context_updates && pressed { + if let Some(hovered_non_window_egui_context) = &hovered_non_window_egui_context { + commands.insert_resource(FocusedNonWindowEguiContext( + hovered_non_window_egui_context.0, + )); + } else { + commands.remove_resource::(); + } + } + } +} + +/// Reads [`CursorMoved`] events and wraps them into [`EguiInputEvent`] for a [`HoveredNonWindowEguiContext`] context (if one exists). +pub fn write_non_window_pointer_moved_events_system( + hovered_non_window_egui_context: Option>, + mut cursor_moved_reader: EventReader, + mut egui_input_event_writer: EventWriter, + egui_contexts: Query<&EguiContextPointerPosition, With>, +) { + if cursor_moved_reader.is_empty() { + return; + } + + cursor_moved_reader.clear(); + let Some(HoveredNonWindowEguiContext(hovered_non_window_egui_context)) = + hovered_non_window_egui_context.as_deref() + else { + return; + }; + + let Some(context_pointer_position) = egui_contexts.get_some(*hovered_non_window_egui_context) + else { + return; + }; + + egui_input_event_writer.send(EguiInputEvent { + context: *hovered_non_window_egui_context, + event: egui::Event::PointerMoved(context_pointer_position.position), + }); +} + +/// Reads [`MouseWheel`] events and wraps them into [`EguiInputEvent`], can redirect events to [`HoveredNonWindowEguiContext`]. +pub fn write_mouse_wheel_events_system( + modifier_keys_state: Res, + hovered_non_window_egui_context: Option>, + mut mouse_wheel_reader: EventReader, + mut egui_input_event_writer: EventWriter, +) { + let modifiers = modifier_keys_state.to_egui_modifiers(); + for event in mouse_wheel_reader.read() { + let delta = egui::vec2(event.x, event.y); + let unit = match event.unit { + MouseScrollUnit::Line => egui::MouseWheelUnit::Line, + MouseScrollUnit::Pixel => egui::MouseWheelUnit::Point, + }; + + egui_input_event_writer.send(EguiInputEvent { + context: hovered_non_window_egui_context + .as_deref() + .map_or(event.window, |hovered| hovered.0), + event: egui::Event::MouseWheel { + unit, + delta, + modifiers, + }, + }); + } +} + +/// Reads [`KeyboardInput`] events and wraps them into [`EguiInputEvent`], can redirect events to [`FocusedNonWindowEguiContext`]. +pub fn write_keyboard_input_events_system( + modifier_keys_state: Res, + focused_non_window_egui_context: Option>, + #[cfg(all( + feature = "manage_clipboard", + not(target_os = "android"), + not(target_arch = "wasm32") + ))] + mut egui_clipboard: ResMut, + mut keyboard_input_reader: EventReader, + mut egui_input_event_writer: EventWriter, +) { + let modifiers = modifier_keys_state.to_egui_modifiers(); + for event in keyboard_input_reader.read() { + let context = focused_non_window_egui_context + .as_deref() + .map_or(event.window, |context| context.0); + + if modifier_keys_state.text_input_is_allowed() && event.state.is_pressed() { + match &event.logical_key { + Key::Character(char) if char.matches(char::is_control).count() == 0 => { + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::Text(char.to_string()), + }); + } + Key::Space => { + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::Text(" ".to_string()), + }); + } + _ => (), + } + } + + let (Some(key), physical_key) = ( + crate::helpers::bevy_to_egui_key(&event.logical_key), + crate::helpers::bevy_to_egui_physical_key(&event.key_code), + ) else { + continue; + }; + + let egui_event = egui::Event::Key { + key, + pressed: event.state.is_pressed(), + repeat: false, + modifiers, + physical_key, + }; + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui_event, + }); + + // We also check that it's a `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"), + not(target_arch = "wasm32") + ))] + if modifiers.command && event.state.is_pressed() { + match key { + egui::Key::C => { + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::Copy, + }); + } + egui::Key::X => { + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::Cut, + }); + } + egui::Key::V => { + if let Some(contents) = egui_clipboard.get_contents() { + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::Text(contents), + }); + } + } + _ => {} + } + } + } +} + +/// Reads [`Ime`] events and wraps them into [`EguiInputEvent`], can redirect events to [`FocusedNonWindowEguiContext`]. +pub fn write_ime_events_system( + focused_non_window_egui_context: Option>, + mut ime_reader: EventReader, + mut egui_input_event_writer: EventWriter, + mut egui_contexts: Query<(Entity, &mut EguiContextImeState, &EguiOutput), With>, +) { + for event in ime_reader.read() { + let window = match &event { + Ime::Preedit { window, .. } + | Ime::Commit { window, .. } + | Ime::Disabled { window } + | Ime::Enabled { window } => *window, + }; + let context = focused_non_window_egui_context + .as_deref() + .map_or(window, |context| context.0); + + let Some((_entity, mut ime_state, _egui_output)) = egui_contexts.get_some_mut(context) + else { + continue; + }; + + let ime_event_enable = + |ime_state: &mut EguiContextImeState, + egui_input_event_writer: &mut EventWriter| { + if !ime_state.has_sent_ime_enabled { + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::Ime(egui::ImeEvent::Enabled), + }); + ime_state.has_sent_ime_enabled = true; + } + }; + + let ime_event_disable = + |ime_state: &mut EguiContextImeState, + egui_input_event_writer: &mut EventWriter| { + if !ime_state.has_sent_ime_enabled { + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::Ime(egui::ImeEvent::Disabled), + }); + ime_state.has_sent_ime_enabled = false; + } + }; + + // Aligned with the egui-winit implementation: https://github.com/emilk/egui/blob/0f2b427ff4c0a8c68f6622ec7d0afb7ba7e71bba/crates/egui-winit/src/lib.rs#L348 + match event { + Ime::Enabled { window: _ } => { + ime_event_enable(&mut ime_state, &mut egui_input_event_writer); + } + Ime::Preedit { + value, + window: _, + cursor: _, + } => { + ime_event_enable(&mut ime_state, &mut egui_input_event_writer); + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::Ime(egui::ImeEvent::Preedit(value.clone())), + }); + } + Ime::Commit { value, window: _ } => { + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::Ime(egui::ImeEvent::Commit(value.clone())), + }); + ime_event_disable(&mut ime_state, &mut egui_input_event_writer); + } + Ime::Disabled { window: _ } => { + ime_event_disable(&mut ime_state, &mut egui_input_event_writer); + } + } + } +} + +/// Reads [`TouchInput`] events and wraps them into [`EguiInputEvent`]. +pub fn write_window_touch_events_system( + mut commands: Commands, + egui_global_settings: Res, + hovered_non_window_egui_context: Option>, + modifier_keys_state: Res, + mut touch_input_reader: EventReader, + mut egui_input_event_writer: EventWriter, + mut egui_contexts: Query< + ( + &EguiSettings, + &mut EguiContextPointerPosition, + &mut EguiContextPointerTouchId, + &EguiOutput, + ), + (With, With), + >, +) { + #[cfg(target_arch = "wasm32")] + let mut editing_text = false; + #[cfg(target_arch = "wasm32")] + for (_, _, _, egui_output) in egui_contexts.iter() { + let platform_output = &egui_output.platform_output; + if platform_output.ime.is_some() || platform_output.mutable_text_under_cursor { + editing_text = true; + break; + } + } + + let modifiers = modifier_keys_state.to_egui_modifiers(); + for event in touch_input_reader.read() { + let Some((context_settings, mut context_pointer_position, mut context_pointer_touch_id, _)) = + egui_contexts.get_some_mut(event.window) + else { + continue; + }; + + if let Some(hovered_non_window_egui_context) = hovered_non_window_egui_context.as_deref() { + #[cfg(target_arch = "wasm32")] + if context_pointer_touch_id.pointer_touch_id.is_none() + || context_pointer_touch_id.pointer_touch_id.unwrap() == event.id + { + if let bevy_input::touch::TouchPhase::Ended = event.phase { + if !is_mobile_safari() { + update_text_agent(editing_text); + } + } + } + + if egui_global_settings.enable_focused_non_window_context_updates { + if let bevy_input::touch::TouchPhase::Started = event.phase { + commands.insert_resource(FocusedNonWindowEguiContext( + hovered_non_window_egui_context.0, + )); + } + } + + continue; + } + if egui_global_settings.enable_focused_non_window_context_updates { + if let bevy_input::touch::TouchPhase::Started = event.phase { + commands.remove_resource::(); + } + } + + let scale_factor = context_settings.scale_factor; + let touch_position = vec2_into_egui_pos2(event.position / scale_factor); + context_pointer_position.position = touch_position; + write_touch_event( + &mut egui_input_event_writer, + event, + event.window, + touch_position, + modifiers, + &mut context_pointer_touch_id, + ); + } +} + +fn write_touch_event( + egui_input_event_writer: &mut EventWriter, + event: &TouchInput, + context: Entity, + pointer_position: egui::Pos2, + modifiers: Modifiers, + context_pointer_touch_id: &mut EguiContextPointerTouchId, +) { + let touch_id = egui::TouchId::from(event.id); + + // Emit touch event + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::Touch { + device_id: egui::TouchDeviceId(event.window.to_bits()), + id: touch_id, + phase: match event.phase { + bevy_input::touch::TouchPhase::Started => egui::TouchPhase::Start, + bevy_input::touch::TouchPhase::Moved => egui::TouchPhase::Move, + bevy_input::touch::TouchPhase::Ended => egui::TouchPhase::End, + bevy_input::touch::TouchPhase::Canceled => egui::TouchPhase::Cancel, + }, + pos: pointer_position, + force: match event.force { + Some(bevy_input::touch::ForceTouch::Normalized(force)) => Some(force as f32), + Some(bevy_input::touch::ForceTouch::Calibrated { + force, + max_possible_force, + .. + }) => Some((force / max_possible_force) as f32), + None => None, + }, + }, + }); + + // If we're not yet translating a touch, or we're translating this very + // touch, … + if context_pointer_touch_id.pointer_touch_id.is_none() + || context_pointer_touch_id.pointer_touch_id.unwrap() == event.id + { + // … emit PointerButton resp. PointerMoved events to emulate mouse. + match event.phase { + bevy_input::touch::TouchPhase::Started => { + context_pointer_touch_id.pointer_touch_id = Some(event.id); + // First move the pointer to the right location. + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::PointerMoved(pointer_position), + }); + // Then do mouse button input. + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::PointerButton { + pos: pointer_position, + button: egui::PointerButton::Primary, + pressed: true, + modifiers, + }, + }); + } + bevy_input::touch::TouchPhase::Moved => { + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::PointerMoved(pointer_position), + }); + } + bevy_input::touch::TouchPhase::Ended => { + context_pointer_touch_id.pointer_touch_id = None; + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::PointerButton { + pos: pointer_position, + button: egui::PointerButton::Primary, + pressed: false, + modifiers, + }, + }); + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::PointerGone, + }); + } + bevy_input::touch::TouchPhase::Canceled => { + context_pointer_touch_id.pointer_touch_id = None; + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::PointerGone, + }); + } + } + } +} + +/// Reads [`EguiInputEvent`] events and feeds them to Egui. +pub fn write_egui_input_system( + focused_non_window_egui_context: Option>, + modifier_keys_state: Res, + mut egui_input_event_reader: EventReader, + mut egui_contexts: Query<(Entity, &mut EguiInput, Option<&Window>)>, + time: Res>, +) { + for EguiInputEvent { context, event } in egui_input_event_reader.read() { + #[cfg(feature = "log_input_events")] + bevy_log::info!("{context:?}: {event:?}"); + + let (_, mut egui_input, _) = match egui_contexts.get_mut(*context) { + Ok(egui_input) => egui_input, + Err(err) => { + bevy_log::error!( + "Failed to get an Egui context ({context:?}) for an event ({event:?}): {err:?}" + ); + continue; + } + }; + + egui_input.events.push(event.clone()); + } + + for (entity, mut egui_input, window) in egui_contexts.iter_mut() { + egui_input.focused = focused_non_window_egui_context.as_deref().map_or_else( + || window.is_some_and(|window| window.focused), + |context| context.0 == entity, + ); + egui_input.modifiers = modifier_keys_state.to_egui_modifiers(); + egui_input.time = Some(time.elapsed_secs_f64()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 2e9ce44ea..2c626c72a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ #![warn(missing_docs)] +#![allow(clippy::type_complexity)] //! This crate provides an [Egui](https://github.com/emilk/egui) integration for the [Bevy](https://github.com/bevyengine/bevy) game engine. //! @@ -34,8 +35,8 @@ //! App::new() //! .add_plugins(DefaultPlugins) //! .add_plugins(EguiPlugin) -//! // Systems that create Egui widgets should be run during the `CoreSet::Update` set, -//! // or after the `EguiSet::BeginPass` system (which belongs to the `CoreSet::PreUpdate` set). +//! // Systems that create Egui widgets should be run during the `Update` Bevy schedule, +//! // or after the `EguiPreUpdateSet::BeginPass` system (which belongs to the `PreUpdate` Bevy schedule). //! .add_systems(Update, ui_example_system) //! .run(); //! } @@ -57,40 +58,33 @@ //! //! - [`bevy-inspector-egui`](https://github.com/jakobhellermann/bevy-inspector-egui) -#[cfg(all( - feature = "manage_clipboard", - target_arch = "wasm32", - not(web_sys_unstable_apis) -))] -compile_error!(include_str!("../static/error_web_sys_unstable_apis.txt")); - /// Egui render node. #[cfg(feature = "render")] pub mod egui_node; -/// Egui render node for rendering to a texture. +/// Helpers for converting Bevy types into Egui ones and vice versa. +pub mod helpers; +/// Systems for translating Bevy input events into Egui input. +pub mod input; +/// Systems for handling Egui output. +pub mod output; /// Plugin systems for the render app. #[cfg(feature = "render")] pub mod render_systems; -/// Plugin systems. -pub mod systems; -/// Mobile web keyboard hacky input support +/// Mobile web keyboard input support. #[cfg(target_arch = "wasm32")] -mod text_agent; -/// Clipboard management for web -#[cfg(all( - feature = "manage_clipboard", - target_arch = "wasm32", - web_sys_unstable_apis -))] +pub mod text_agent; +/// Clipboard management for web. +#[cfg(all(feature = "manage_clipboard", target_arch = "wasm32",))] pub mod web_clipboard; pub use egui; -use crate::systems::*; +use crate::input::*; #[cfg(target_arch = "wasm32")] use crate::text_agent::{ - install_text_agent, is_mobile_safari, process_safari_virtual_keyboard, propagate_text, - SafariVirtualKeyboardHack, TextAgentChannel, VirtualTouchInfo, + install_text_agent_system, is_mobile_safari, process_safari_virtual_keyboard_system, + write_text_agent_channel_events_system, SafariVirtualKeyboardTouchState, TextAgentChannel, + VirtualTouchInfo, }; #[cfg(feature = "render")] use crate::{ @@ -129,8 +123,9 @@ use bevy_render::{ render_resource::{LoadOp, SpecializedRenderPipelines}, ExtractSchedule, Render, RenderApp, RenderSet, }; -use bevy_window::{PrimaryWindow, SystemCursorIcon, Window}; +use bevy_window::{PrimaryWindow, Window}; use bevy_winit::cursor::CursorIcon; +use output::process_output_system; #[cfg(all( feature = "manage_clipboard", not(any(target_arch = "wasm32", target_os = "android")) @@ -142,6 +137,24 @@ use wasm_bindgen::prelude::*; /// Adds all Egui resources and render graph nodes. pub struct EguiPlugin; +/// A resource for storing global plugin settings. +#[derive(Clone, Debug, Resource, Reflect)] +pub struct EguiGlobalSettings { + /// Set this to `false` if you want to disable updating focused contexts by the plugin's systems + /// (enabled by default). + /// + /// For more info, see the [`FocusedNonWindowEguiContext`] documentation. + pub enable_focused_non_window_context_updates: bool, +} + +impl Default for EguiGlobalSettings { + fn default() -> Self { + Self { + enable_focused_non_window_context_updates: true, + } + } +} + /// A component for storing Egui context settings. #[derive(Clone, Debug, Component, Reflect)] #[cfg_attr(feature = "render", derive(ExtractComponent))] @@ -168,7 +181,7 @@ pub struct EguiSettings { /// If not specified, `_self` will be used. Only matters in a web browser. #[cfg(feature = "open_url")] pub default_open_url_target: Option, - /// Controls if Egui should capture pointer input when using [`bevy_picking`]. + /// Controls if Egui should capture pointer input when using [`bevy_picking`] (i.e. suppress `bevy_picking` events when a pointer is over an Egui window). #[cfg(feature = "render")] pub capture_pointer_input: bool, } @@ -199,11 +212,11 @@ impl Default for EguiSettings { /// Is used for storing Egui context input. /// -/// It gets reset during the [`EguiSet::ProcessInput`] system. +/// It gets reset during the [`crate::EguiInputSet::WriteEguiEvents`] system set. #[derive(Component, Clone, Debug, Default, Deref, DerefMut)] pub struct EguiInput(pub egui::RawInput); -/// Is used to store Egui context output. +/// Intermediate output buffer generated on an Egui pass end and consumed by the [`process_output_system`] system. #[derive(Component, Clone, Default, Deref, DerefMut)] pub struct EguiFullOutput(pub Option); @@ -215,102 +228,17 @@ pub struct EguiFullOutput(pub Option); pub struct EguiClipboard { #[cfg(not(target_arch = "wasm32"))] clipboard: thread_local::ThreadLocal>>, - #[cfg(all(target_arch = "wasm32", web_sys_unstable_apis))] + #[cfg(target_arch = "wasm32")] clipboard: web_clipboard::WebClipboard, } -#[cfg(all( - feature = "manage_clipboard", - not(target_os = "android"), - not(all(target_arch = "wasm32", not(web_sys_unstable_apis))) -))] -impl EguiClipboard { - /// Sets clipboard contents. - pub fn set_contents(&mut self, contents: &str) { - 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(all(target_arch = "wasm32", web_sys_unstable_apis))] - 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 { - self.get_contents_impl() - } - - /// Gets clipboard contents. Returns [`None`] if clipboard provider is unavailable or returns an error. - #[must_use] - #[cfg(all(target_arch = "wasm32", web_sys_unstable_apis))] - pub fn get_contents(&mut self) -> Option { - self.get_contents_impl() - } - - /// Receives a clipboard event sent by the `copy`/`cut`/`paste` listeners. - #[cfg(all(target_arch = "wasm32", web_sys_unstable_apis))] - pub fn try_receive_clipboard_event(&self) -> Option { - self.clipboard.try_receive_clipboard_event() - } - - #[cfg(not(target_arch = "wasm32"))] - 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()) { - bevy_log::error!("Failed to set clipboard contents: {:?}", err); - } - } - } - - #[cfg(all(target_arch = "wasm32", web_sys_unstable_apis))] - fn set_contents_impl(&mut self, contents: &str) { - self.clipboard.set_contents(contents); - } - - #[cfg(not(target_arch = "wasm32"))] - fn get_contents_impl(&mut self) -> Option { - if let Some(mut clipboard) = self.get() { - match clipboard.get_text() { - Ok(contents) => return Some(contents), - Err(err) => bevy_log::error!("Failed to get clipboard contents: {:?}", err), - } - }; - None - } - - #[cfg(all(target_arch = "wasm32", web_sys_unstable_apis))] - #[allow(clippy::unnecessary_wraps)] - fn get_contents_impl(&mut self) -> Option { - self.clipboard.get_contents() - } - - #[cfg(not(target_arch = "wasm32"))] - fn get(&self) -> Option> { - self.clipboard - .get_or(|| { - Clipboard::new() - .map(RefCell::new) - .map_err(|err| { - bevy_log::error!("Failed to initialize clipboard: {:?}", err); - }) - .ok() - }) - .as_ref() - .map(|cell| cell.borrow_mut()) - } -} - /// Is used for storing Egui shapes and textures delta. #[derive(Component, Clone, Default, Debug)] #[cfg_attr(feature = "render", derive(ExtractComponent))] pub struct EguiRenderOutput { /// Pairs of rectangles and paint commands. /// - /// The field gets populated during the [`EguiSet::ProcessOutput`] system (belonging to bevy's [`PostUpdate`]) and reset during `EguiNode::update`. + /// The field gets populated during the [`EguiPostUpdateSet::ProcessOutput`] system (belonging to bevy's [`PostUpdate`]) and reset during [`egui_node::EguiNode`]'s `update`. pub paint_jobs: Vec, /// The change in egui textures since last frame. @@ -318,27 +246,36 @@ pub struct EguiRenderOutput { } impl EguiRenderOutput { - /// Returns `true` if the output has no Egui shapes and no textures delta + /// Returns `true` if the output has no Egui shapes and no textures delta. pub fn is_empty(&self) -> bool { self.paint_jobs.is_empty() && self.textures_delta.is_empty() } } -/// Is used for storing Egui output. +/// Stores last Egui output. #[derive(Component, Clone, Default)] pub struct EguiOutput { - /// The field gets updated during the [`EguiSet::ProcessOutput`] system (belonging to [`PostUpdate`]). + /// The field gets updated during the [`EguiPostUpdateSet::ProcessOutput`] system (belonging to [`PostUpdate`]). pub platform_output: egui::PlatformOutput, } /// A component for storing `bevy_egui` context. #[derive(Clone, Component, Default)] #[cfg_attr(feature = "render", derive(ExtractComponent))] +#[require( + EguiSettings, + EguiInput, + EguiContextPointerPosition, + EguiContextPointerTouchId, + EguiContextImeState, + EguiFullOutput, + EguiRenderOutput, + EguiOutput, + RenderTargetSize, + CursorIcon +)] pub struct EguiContext { ctx: egui::Context, - mouse_position: egui::Pos2, - pointer_touch_id: Option, - has_sent_ime_enabled: bool, } impl EguiContext { @@ -401,7 +338,7 @@ impl EguiContexts<'_, '_> { #[must_use] pub fn ctx_mut(&mut self) -> &mut egui::Context { self.try_ctx_mut() - .expect("`EguiContexts::ctx_mut` was called for an uninitialized context (primary window), make sure your system is run after [`EguiSet::InitContexts`] (or [`EguiStartupSet::InitContexts`] for startup systems)") + .expect("`EguiContexts::ctx_mut` was called for an uninitialized context (primary window), make sure your system is run after [`EguiPreUpdateSet::InitContexts`] (or [`EguiStartupSet::InitContexts`] for startup systems)") } /// Fallible variant of [`EguiContexts::ctx_mut`]. @@ -422,7 +359,7 @@ impl EguiContexts<'_, '_> { #[must_use] pub fn ctx_for_entity_mut(&mut self, entity: Entity) -> &mut egui::Context { self.try_ctx_for_entity_mut(entity) - .unwrap_or_else(|| panic!("`EguiContexts::ctx_for_window_mut` was called for an uninitialized context (entity {entity:?}), make sure your system is run after [`EguiSet::InitContexts`] (or [`EguiStartupSet::InitContexts`] for startup systems)")) + .unwrap_or_else(|| panic!("`EguiContexts::ctx_for_window_mut` was called for an uninitialized context (entity {entity:?}), make sure your system is run after [`EguiPreUpdateSet::InitContexts`] (or [`EguiStartupSet::InitContexts`] for startup systems)")) } /// Fallible variant of [`EguiContexts::ctx_for_entity_mut`]. @@ -465,7 +402,7 @@ impl EguiContexts<'_, '_> { #[must_use] pub fn ctx(&self) -> &egui::Context { self.try_ctx() - .expect("`EguiContexts::ctx` was called for an uninitialized context (primary window), make sure your system is run after [`EguiSet::InitContexts`] (or [`EguiStartupSet::InitContexts`] for startup systems)") + .expect("`EguiContexts::ctx` was called for an uninitialized context (primary window), make sure your system is run after [`EguiPreUpdateSet::InitContexts`] (or [`EguiStartupSet::InitContexts`] for startup systems)") } /// Fallible variant of [`EguiContexts::ctx`]. @@ -504,7 +441,7 @@ impl EguiContexts<'_, '_> { #[cfg(feature = "immutable_ctx")] pub fn ctx_for_entity(&self, entity: Entity) -> &egui::Context { self.try_ctx_for_entity(entity) - .unwrap_or_else(|| panic!("`EguiContexts::ctx_for_entity` was called for an uninitialized context (entity {entity:?}), make sure your system is run after [`EguiSet::InitContexts`] (or [`EguiStartupSet::InitContexts`] for startup systems)")) + .unwrap_or_else(|| panic!("`EguiContexts::ctx_for_entity` was called for an uninitialized context (entity {entity:?}), make sure your system is run after [`EguiPreUpdateSet::InitContexts`] (or [`EguiStartupSet::InitContexts`] for startup systems)")) } /// Fallible variant of [`EguiContexts::ctx_for_entity`]. @@ -566,6 +503,7 @@ impl EguiContexts<'_, '_> { /// automatically. #[cfg(feature = "render")] #[derive(Component, Clone, Debug, ExtractComponent)] +#[require(EguiContext)] pub struct EguiRenderToImage { /// A handle of an image to render to. pub handle: Handle, @@ -696,26 +634,54 @@ pub enum EguiStartupSet { InitContexts, } -/// The `bevy_egui` plugin system sets. +/// System sets that run during the [`PreUpdate`] schedule. #[derive(SystemSet, Clone, Hash, Debug, Eq, PartialEq)] -pub enum EguiSet { +pub enum EguiPreUpdateSet { /// Initializes Egui contexts for newly created render targets. InitContexts, /// Reads Egui inputs (keyboard, mouse, etc) and writes them into the [`EguiInput`] resource. /// /// To modify the input, you can hook your system like this: /// - /// `system.after(EguiSet::ProcessInput).before(EguiSet::BeginPass)`. + /// `system.after(EguiPreUpdateSet::ProcessInput).before(EguiSet::BeginPass)`. ProcessInput, /// Begins the `egui` pass. BeginPass, - /// Processes the [`EguiOutput`] resource. +} + +/// Subsets of the [`EguiPreUpdateSet::ProcessInput`] set. +#[derive(SystemSet, Clone, Hash, Debug, Eq, PartialEq)] +pub enum EguiInputSet { + /// Reads key modifiers state and pointer positions. + /// + /// This is where [`HoveredNonWindowEguiContext`] should get inserted or removed. + InitReading, + /// Processes window mouse button click and touch events, updates [`FocusedNonWindowEguiContext`] based on [`HoveredNonWindowEguiContext`]. + FocusContext, + /// Processes rest of the events for both window and non-window contexts. + ReadBevyEvents, + /// Feeds all the events into [`EguiInput`]. + WriteEguiEvents, +} + +/// System sets that run during the [`PostUpdate`] schedule. +#[derive(SystemSet, Clone, Hash, Debug, Eq, PartialEq)] +pub enum EguiPostUpdateSet { + /// Ends Egui pass. + EndPass, + /// Processes Egui output, reads paint jobs for the renderer. ProcessOutput, + /// Post-processing of Egui output (updates textures, browser virtual keyboard state, etc). + PostProcessOutput, } impl Plugin for EguiPlugin { fn build(&self, app: &mut App) { + app.register_type::(); app.register_type::(); + app.init_resource::(); + app.init_resource::(); + app.add_event::(); #[cfg(feature = "render")] { @@ -736,47 +702,93 @@ impl Plugin for EguiPlugin { #[cfg(all(feature = "manage_clipboard", not(target_os = "android")))] app.init_resource::(); - #[cfg(all( - feature = "manage_clipboard", - target_arch = "wasm32", - web_sys_unstable_apis - ))] + app.configure_sets( + PreUpdate, + ( + EguiPreUpdateSet::InitContexts, + EguiPreUpdateSet::ProcessInput.after(InputSystem), + EguiPreUpdateSet::BeginPass, + ) + .chain(), + ); + app.configure_sets( + PreUpdate, + ( + EguiInputSet::InitReading, + EguiInputSet::FocusContext, + EguiInputSet::ReadBevyEvents, + EguiInputSet::WriteEguiEvents, + ) + .chain(), + ); + app.configure_sets( + PostUpdate, + ( + EguiPostUpdateSet::EndPass, + EguiPostUpdateSet::ProcessOutput, + EguiPostUpdateSet::PostProcessOutput, + ) + .chain(), + ); + + // Startup systems. + #[cfg(all(feature = "manage_clipboard", target_arch = "wasm32",))] { - app.add_systems(PreStartup, web_clipboard::startup_setup_web_events); + app.add_systems(PreStartup, web_clipboard::startup_setup_web_events_system); } - app.add_systems( PreStartup, ( setup_new_windows_system, - #[cfg(feature = "render")] - setup_render_to_image_handles_system, apply_deferred, - update_contexts_system, + update_ui_size_and_scale_system, ) .chain() .in_set(EguiStartupSet::InitContexts), ); + // PreUpdate systems. app.add_systems( PreUpdate, ( setup_new_windows_system, - #[cfg(feature = "render")] - setup_render_to_image_handles_system, apply_deferred, - update_contexts_system, + update_ui_size_and_scale_system, ) .chain() - .in_set(EguiSet::InitContexts), + .in_set(EguiPreUpdateSet::InitContexts), ); app.add_systems( PreUpdate, - process_input_system - .in_set(EguiSet::ProcessInput) - .after(InputSystem) - .after(EguiSet::InitContexts), + ( + ( + write_modifiers_keys_state_system, + write_window_pointer_moved_events_system, + ) + .in_set(EguiInputSet::InitReading), + ( + write_pointer_button_events_system, + write_window_touch_events_system, + ) + .in_set(EguiInputSet::FocusContext), + ( + write_non_window_pointer_moved_events_system, + write_mouse_wheel_events_system, + write_keyboard_input_events_system, + write_ime_events_system, + ) + .in_set(EguiInputSet::ReadBevyEvents), + write_egui_input_system.in_set(EguiInputSet::WriteEguiEvents), + ) + .chain() + .in_set(EguiPreUpdateSet::ProcessInput), + ); + app.add_systems( + PreUpdate, + begin_pass_system.in_set(EguiPreUpdateSet::BeginPass), ); + + // Web-specific resources and systems. #[cfg(target_arch = "wasm32")] { use std::sync::{LazyLock, Mutex}; @@ -797,7 +809,7 @@ impl Plugin for EguiPlugin { static TOUCH_INFO: LazyLock> = LazyLock::new(|| Mutex::new(VirtualTouchInfo::default())); - app.insert_resource(SafariVirtualKeyboardHack { + app.insert_resource(SafariVirtualKeyboardTouchState { sender, receiver, touch_info: &TOUCH_INFO, @@ -805,49 +817,50 @@ impl Plugin for EguiPlugin { app.add_systems( PreStartup, - install_text_agent - .in_set(EguiSet::ProcessInput) - .after(process_input_system) - .after(InputSystem) - .after(EguiSet::InitContexts), + install_text_agent_system.in_set(EguiStartupSet::InitContexts), + ); + + app.add_systems( + PreUpdate, + write_text_agent_channel_events_system + .in_set(EguiPreUpdateSet::ProcessInput) + .in_set(EguiInputSet::ReadBevyEvents), ); + #[cfg(feature = "manage_clipboard")] app.add_systems( PreUpdate, - propagate_text - .in_set(EguiSet::ProcessInput) - .after(process_input_system) - .after(InputSystem) - .after(EguiSet::InitContexts), + web_clipboard::write_web_clipboard_events_system + .in_set(EguiPreUpdateSet::ProcessInput) + .in_set(EguiInputSet::ReadBevyEvents), ); if is_mobile_safari() { app.add_systems( PostUpdate, - process_safari_virtual_keyboard.after(process_output_system), + process_safari_virtual_keyboard_system + .in_set(EguiPostUpdateSet::PostProcessOutput), ); } } } + + // PostUpdate systems. app.add_systems( - PreUpdate, - begin_pass_system - .in_set(EguiSet::BeginPass) - .after(EguiSet::ProcessInput), + PostUpdate, + end_pass_system.in_set(EguiPostUpdateSet::EndPass), ); - - app.add_systems(PostUpdate, end_pass_system.before(EguiSet::ProcessOutput)); app.add_systems( PostUpdate, - process_output_system.in_set(EguiSet::ProcessOutput), + process_output_system.in_set(EguiPostUpdateSet::ProcessOutput), ); #[cfg(feature = "render")] - app.add_systems(PostUpdate, capture_pointer_input); + app.add_systems(PostUpdate, capture_pointer_input_system); #[cfg(feature = "render")] app.add_systems( PostUpdate, - update_egui_textures_system.after(EguiSet::ProcessOutput), + update_egui_textures_system.in_set(EguiPostUpdateSet::PostProcessOutput), ) .add_systems( Render, @@ -906,56 +919,6 @@ impl Plugin for EguiPlugin { } } -/// Queries all the Egui related components. -#[derive(QueryData)] -#[query_data(mutable)] -#[non_exhaustive] -pub struct EguiContextQuery { - /// Window entity. - pub render_target: Entity, - /// Egui context associated with the render target. - pub ctx: &'static mut EguiContext, - /// Settings associated with the context. - pub egui_settings: &'static mut EguiSettings, - /// Encapsulates [`egui::RawInput`]. - pub egui_input: &'static mut EguiInput, - /// Encapsulates [`egui::FullOutput`]. - pub egui_full_output: &'static mut EguiFullOutput, - /// Egui shapes and textures delta. - pub render_output: &'static mut EguiRenderOutput, - /// Encapsulates [`egui::PlatformOutput`]. - pub egui_output: &'static mut EguiOutput, - /// Stores physical size of the window and its scale factor. - pub render_target_size: &'static mut RenderTargetSize, - /// [`Window`] component, when rendering to a window. - pub window: Option<&'static mut Window>, - /// [`CursorIcon`] component. - pub cursor: Option<&'static mut CursorIcon>, - /// [`EguiRenderToImage`] component, when rendering to a texture. - #[cfg(feature = "render")] - pub render_to_image: Option<&'static mut EguiRenderToImage>, -} - -impl EguiContextQueryItem<'_> { - fn ime_event_enable(&mut self) { - if !self.ctx.has_sent_ime_enabled { - self.egui_input - .events - .push(egui::Event::Ime(egui::ImeEvent::Enabled)); - self.ctx.has_sent_ime_enabled = true; - } - } - - fn ime_event_disable(&mut self) { - if self.ctx.has_sent_ime_enabled { - self.egui_input - .events - .push(egui::Event::Ime(egui::ImeEvent::Disabled)); - self.ctx.has_sent_ime_enabled = false; - } - } -} - /// Contains textures allocated and painted by Egui. #[cfg(feature = "render")] #[derive(bevy_ecs::system::Resource, Deref, DerefMut, Default)] @@ -976,35 +939,111 @@ pub fn setup_new_windows_system( new_windows: Query, Without)>, ) { for window in new_windows.iter() { - commands.entity(window).insert(( - EguiContext::default(), - EguiSettings::default(), - EguiRenderOutput::default(), - EguiInput::default(), - EguiFullOutput::default(), - EguiOutput::default(), - RenderTargetSize::default(), - CursorIcon::System(SystemCursorIcon::Default), - )); + // See the list of required components to check the full list of components we add. + commands.entity(window).insert(EguiContext::default()); + } +} + +#[cfg(all(feature = "manage_clipboard", not(target_os = "android"),))] +impl EguiClipboard { + /// Sets clipboard contents. + pub fn set_contents(&mut self, contents: &str) { + 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 { + self.get_contents_impl() + } + + /// Gets clipboard contents. Returns [`None`] if clipboard provider is unavailable or returns an error. + #[must_use] + #[cfg(target_arch = "wasm32")] + pub fn get_contents(&mut self) -> Option { + 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 { + self.clipboard.try_receive_clipboard_event() + } + + #[cfg(not(target_arch = "wasm32"))] + 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()) { + bevy_log::error!("Failed to set clipboard contents: {:?}", err); + } + } + } + + #[cfg(target_arch = "wasm32")] + fn set_contents_impl(&mut self, contents: &str) { + self.clipboard.set_contents(contents); + } + + #[cfg(not(target_arch = "wasm32"))] + fn get_contents_impl(&mut self) -> Option { + if let Some(mut clipboard) = self.get() { + match clipboard.get_text() { + Ok(contents) => return Some(contents), + Err(err) => bevy_log::error!("Failed to get clipboard contents: {:?}", err), + } + }; + None + } + + #[cfg(target_arch = "wasm32")] + #[allow(clippy::unnecessary_wraps)] + fn get_contents_impl(&mut self) -> Option { + self.clipboard.get_contents() + } + + #[cfg(not(target_arch = "wasm32"))] + fn get(&self) -> Option> { + self.clipboard + .get_or(|| { + Clipboard::new() + .map(RefCell::new) + .map_err(|err| { + bevy_log::error!("Failed to initialize clipboard: {:?}", err); + }) + .ok() + }) + .as_ref() + .map(|cell| cell.borrow_mut()) } } -/// The ordering value used for bevy_picking. +/// The ordering value used for [`bevy_picking`]. #[cfg(feature = "render")] pub const PICKING_ORDER: f32 = 1_000_000.0; -/// Captures pointers on egui windows for bevy_picking. + +/// Captures pointers on egui windows for [`bevy_picking`]. #[cfg(feature = "render")] -pub fn capture_pointer_input( +pub fn capture_pointer_input_system( pointers: Query<(&PointerId, &PointerLocation)>, - mut egui_context: Query<(Entity, &mut EguiContext, &EguiSettings)>, + mut egui_context: Query<(Entity, &mut EguiContext, &EguiSettings), With>, mut output: EventWriter, ) { + use helpers::QueryHelper; + for (pointer, location) in pointers .iter() .filter_map(|(i, p)| p.location.as_ref().map(|l| (i, l))) { if let NormalizedRenderTarget::Window(id) = location.target { - if let Ok((entity, mut ctx, settings)) = egui_context.get_mut(id.entity()) { + if let Some((entity, mut ctx, settings)) = egui_context.get_some_mut(id.entity()) { if settings.capture_pointer_input && ctx.get_mut().wants_pointer_input() { let entry = (entity, HitData::new(entity, 0.0, None, None)); output.send(PointerHits::new( @@ -1018,28 +1057,8 @@ pub fn capture_pointer_input( } } -/// Adds bevy_egui components to newly created windows. -#[cfg(feature = "render")] -pub fn setup_render_to_image_handles_system( - mut commands: Commands, - new_render_to_image_targets: Query, Without)>, -) { - for render_to_image_target in new_render_to_image_targets.iter() { - commands.entity(render_to_image_target).insert(( - EguiContext::default(), - EguiSettings::default(), - EguiRenderOutput::default(), - EguiInput::default(), - EguiFullOutput::default(), - EguiOutput::default(), - RenderTargetSize::default(), - )); - } -} - /// Updates textures painted by Egui. #[cfg(feature = "render")] -#[allow(clippy::type_complexity)] pub fn update_egui_textures_system( mut egui_render_output: Query< (Entity, &mut EguiRenderOutput), @@ -1103,7 +1122,6 @@ pub fn update_egui_textures_system( /// If you add textures via [`EguiContexts::add_image`] or [`EguiUserTextures::add_image`] by passing a weak handle, /// the systems ensures that corresponding Egui textures are cleaned up as well. #[cfg(feature = "render")] -#[allow(clippy::type_complexity)] pub fn free_egui_textures_system( mut egui_user_textures: ResMut, mut egui_render_output: Query< @@ -1150,7 +1168,7 @@ struct EventClosure { #[cfg(target_arch = "wasm32")] #[derive(Default)] pub struct SubscribedEvents { - #[cfg(all(feature = "manage_clipboard", web_sys_unstable_apis))] + #[cfg(feature = "manage_clipboard")] clipboard_event_closures: Vec>, composition_event_closures: Vec>, keyboard_event_closures: Vec>, @@ -1163,7 +1181,7 @@ impl SubscribedEvents { /// Use this method to unsubscribe from all stored events, this can be useful /// for gracefully destroying a Bevy instance in a page. pub fn unsubscribe_from_all_events(&mut self) { - #[cfg(all(feature = "manage_clipboard", web_sys_unstable_apis))] + #[cfg(feature = "manage_clipboard")] Self::unsubscribe_from_events(&mut self.clipboard_event_closures); Self::unsubscribe_from_events(&mut self.composition_event_closures); Self::unsubscribe_from_events(&mut self.keyboard_event_closures); @@ -1190,39 +1208,99 @@ impl SubscribedEvents { } } +#[derive(QueryData)] +#[query_data(mutable)] +#[allow(missing_docs)] +pub struct UpdateUiSizeAndScaleQuery { + ctx: &'static mut EguiContext, + egui_input: &'static mut EguiInput, + render_target_size: &'static mut RenderTargetSize, + egui_settings: &'static EguiSettings, + window: Option<&'static Window>, + #[cfg(feature = "render")] + render_to_image: Option<&'static EguiRenderToImage>, +} + +/// Updates UI [`egui::RawInput::screen_rect`] and calls [`egui::Context::set_pixels_per_point`]. +pub fn update_ui_size_and_scale_system( + mut contexts: Query, + #[cfg(feature = "render")] images: Res>, +) { + for mut context in contexts.iter_mut() { + let mut render_target_size = None; + if let Some(window) = context.window { + render_target_size = Some(RenderTargetSize::new( + window.physical_width() as f32, + window.physical_height() as f32, + window.scale_factor(), + )); + } + #[cfg(feature = "render")] + if let Some(EguiRenderToImage { handle, .. }) = context.render_to_image { + if let Some(image) = images.get(handle) { + let size = image.size_f32(); + render_target_size = Some(RenderTargetSize { + physical_width: size.x, + physical_height: size.y, + scale_factor: 1.0, + }) + } else { + bevy_log::warn!("Invalid EguiRenderToImage handle: {handle:?}"); + } + } + + let Some(new_render_target_size) = render_target_size else { + bevy_log::error!("bevy_egui context without window or render to texture!"); + continue; + }; + let width = new_render_target_size.physical_width + / new_render_target_size.scale_factor + / context.egui_settings.scale_factor; + let height = new_render_target_size.physical_height + / new_render_target_size.scale_factor + / context.egui_settings.scale_factor; + + if width < 1.0 || height < 1.0 { + continue; + } + + context.egui_input.screen_rect = Some(egui::Rect::from_min_max( + egui::pos2(0.0, 0.0), + egui::pos2(width, height), + )); + + context.ctx.get_mut().set_pixels_per_point( + new_render_target_size.scale_factor * context.egui_settings.scale_factor, + ); + + *context.render_target_size = new_render_target_size; + } +} + +/// Marks a pass start for Egui. +pub fn begin_pass_system(mut contexts: Query<(&mut EguiContext, &EguiSettings, &mut EguiInput)>) { + for (mut ctx, egui_settings, mut egui_input) in contexts.iter_mut() { + if !egui_settings.run_manually { + ctx.get_mut().begin_pass(egui_input.take()); + } + } +} + +/// Marks a pass end for Egui. +pub fn end_pass_system( + mut contexts: Query<(&mut EguiContext, &EguiSettings, &mut EguiFullOutput)>, +) { + for (mut ctx, egui_settings, mut full_output) in contexts.iter_mut() { + if !egui_settings.run_manually { + **full_output = Some(ctx.get_mut().end_pass()); + } + } +} + #[cfg(test)] mod tests { - use super::*; - use bevy::{ - app::PluginGroup, - render::{settings::WgpuSettings, RenderPlugin}, - winit::WinitPlugin, - DefaultPlugins, - }; - #[test] fn test_readme_deps() { version_sync::assert_markdown_deps_updated!("README.md"); } - - #[test] - fn test_headless_mode() { - App::new() - .add_plugins( - DefaultPlugins - .set(RenderPlugin { - render_creation: bevy::render::settings::RenderCreation::Automatic( - WgpuSettings { - backends: None, - ..Default::default() - }, - ), - ..Default::default() - }) - .build() - .disable::(), - ) - .add_plugins(EguiPlugin) - .update(); - } } diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 000000000..40a803305 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,117 @@ +use crate::{helpers, EguiContext, EguiFullOutput, EguiRenderOutput, EguiSettings}; +#[cfg(windows)] +use bevy_ecs::system::Local; +use bevy_ecs::{ + entity::Entity, + event::EventWriter, + system::{NonSend, Query}, +}; +use bevy_window::RequestRedraw; +use bevy_winit::{cursor::CursorIcon, EventLoopProxy, WakeUp}; +use std::time::Duration; + +/// Reads Egui output. +pub fn process_output_system( + mut contexts: Query<( + Entity, + &mut EguiContext, + &mut EguiFullOutput, + &mut EguiRenderOutput, + Option<&mut CursorIcon>, + &EguiSettings, + )>, + #[cfg(all(feature = "manage_clipboard", not(target_os = "android")))] + mut egui_clipboard: bevy_ecs::system::ResMut, + mut event: EventWriter, + #[cfg(windows)] mut last_cursor_icon: Local>, + event_loop_proxy: Option>>, +) { + let mut should_request_redraw = false; + + for (_entity, mut context, mut full_output, mut render_output, cursor_icon, _settings) in + contexts.iter_mut() + { + let ctx = context.get_mut(); + let Some(full_output) = full_output.0.take() else { + bevy_log::error!("bevy_egui pass output has not been prepared (if EguiSettings::run_manually is set to true, make sure to call egui::Context::run or egui::Context::begin_pass and egui::Context::end_pass)"); + continue; + }; + let egui::FullOutput { + platform_output, + shapes, + textures_delta, + pixels_per_point, + viewport_output: _, + } = full_output; + let paint_jobs = ctx.tessellate(shapes, pixels_per_point); + + render_output.paint_jobs = paint_jobs; + render_output.textures_delta.append(textures_delta); + + #[cfg(all(feature = "manage_clipboard", not(target_os = "android"),))] + if !platform_output.copied_text.is_empty() { + egui_clipboard.set_contents(&platform_output.copied_text); + } + + if let Some(mut cursor) = cursor_icon { + let mut set_icon = || { + *cursor = CursorIcon::System( + helpers::egui_to_winit_cursor_icon(platform_output.cursor_icon) + .unwrap_or(bevy_window::SystemCursorIcon::Default), + ); + }; + + #[cfg(windows)] + { + let last_cursor_icon = last_cursor_icon.entry(_entity).or_default(); + if *last_cursor_icon != platform_output.cursor_icon { + set_icon(); + *last_cursor_icon = platform_output.cursor_icon; + } + } + #[cfg(not(windows))] + set_icon(); + } + + let needs_repaint = !render_output.is_empty(); + should_request_redraw |= ctx.has_requested_repaint() && needs_repaint; + + // The resource doesn't exist in the headless mode. + if let Some(event_loop_proxy) = &event_loop_proxy { + // A zero duration indicates that it's an outstanding redraw request, which gives Egui an + // opportunity to settle the effects of interactions with widgets. Such repaint requests + // are processed not immediately but on a next frame. In this case, we need to indicate to + // winit, that it needs to wake up next frame as well even if there are no inputs. + // + // TLDR: this solves repaint corner cases of `WinitSettings::desktop_app()`. + if let Some(Duration::ZERO) = + ctx.viewport(|viewport| viewport.input.wants_repaint_after()) + { + let _ = event_loop_proxy.send_event(WakeUp); + } + } + + #[cfg(feature = "open_url")] + if let Some(egui::output::OpenUrl { url, new_tab }) = platform_output.open_url { + let target = if new_tab { + "_blank" + } else { + _settings + .default_open_url_target + .as_deref() + .unwrap_or("_self") + }; + if let Err(err) = webbrowser::open_browser_with_options( + webbrowser::Browser::Default, + &url, + webbrowser::BrowserOptions::new().with_target_hint(target), + ) { + bevy_log::error!("Failed to open '{}': {:?}", url, err); + } + } + } + + if should_request_redraw { + event.send(RequestRedraw); + } +} diff --git a/src/systems.rs b/src/systems.rs deleted file mode 100644 index 916011054..000000000 --- a/src/systems.rs +++ /dev/null @@ -1,880 +0,0 @@ -#[cfg(target_arch = "wasm32")] -use crate::text_agent::{is_mobile_safari, update_text_agent}; -#[cfg(feature = "render")] -use crate::EguiRenderToImage; -use crate::{ - EguiContext, EguiContextQuery, EguiContextQueryItem, EguiFullOutput, EguiInput, EguiSettings, - RenderTargetSize, -}; -#[cfg(feature = "render")] -use bevy_asset::Assets; -use bevy_ecs::{ - event::EventWriter, - prelude::*, - query::QueryEntityError, - system::{Local, Res, SystemParam}, -}; -#[cfg(feature = "render")] -use bevy_image::Image; -use bevy_input::{ - keyboard::{Key, KeyCode, KeyboardFocusLost, KeyboardInput}, - mouse::{MouseButton, MouseButtonInput, MouseScrollUnit, MouseWheel}, - touch::TouchInput, - ButtonState, -}; -use bevy_log::{self, error}; -use bevy_time::{Real, Time}; -use bevy_window::{CursorMoved, Ime, RequestRedraw}; -use bevy_winit::{EventLoopProxy, WakeUp}; -use std::{marker::PhantomData, time::Duration}; - -#[allow(missing_docs)] -#[derive(SystemParam)] -// IMPORTANT: remember to add the logic to clear event readers to the `clear` method. -pub struct InputEvents<'w, 's> { - pub ev_cursor: EventReader<'w, 's, CursorMoved>, - pub ev_mouse_button_input: EventReader<'w, 's, MouseButtonInput>, - pub ev_mouse_wheel: EventReader<'w, 's, MouseWheel>, - pub ev_keyboard_input: EventReader<'w, 's, KeyboardInput>, - pub ev_touch: EventReader<'w, 's, TouchInput>, - pub ev_focus: EventReader<'w, 's, KeyboardFocusLost>, - pub ev_ime_input: EventReader<'w, 's, Ime>, -} - -impl InputEvents<'_, '_> { - /// Consumes all the events. - pub fn clear(&mut self) { - self.ev_cursor.clear(); - self.ev_mouse_button_input.clear(); - self.ev_mouse_wheel.clear(); - self.ev_keyboard_input.clear(); - self.ev_touch.clear(); - self.ev_focus.clear(); - self.ev_ime_input.clear(); - } -} - -/// Stores "pressed" state of modifier keys. -/// Will be removed if Bevy adds support for `ButtonInput` (logical keys). -#[derive(Resource, Default, Clone, Copy, Debug)] -pub struct ModifierKeysState { - shift: bool, - ctrl: bool, - alt: bool, - win: bool, -} - -#[allow(missing_docs)] -#[derive(SystemParam)] -pub struct InputResources<'w, 's> { - #[cfg(all( - feature = "manage_clipboard", - not(target_os = "android"), - not(all(target_arch = "wasm32", not(web_sys_unstable_apis))) - ))] - pub egui_clipboard: bevy_ecs::system::ResMut<'w, crate::EguiClipboard>, - pub modifier_keys_state: Local<'s, ModifierKeysState>, - #[system_param(ignore)] - _marker: PhantomData<&'w ()>, -} - -#[allow(missing_docs)] -#[derive(SystemParam)] -pub struct ContextSystemParams<'w, 's> { - pub contexts: Query<'w, 's, EguiContextQuery>, - pub is_macos: Local<'s, bool>, - #[system_param(ignore)] - _marker: PhantomData<&'s ()>, -} - -impl ContextSystemParams<'_, '_> { - fn window_context(&mut self, window: Entity) -> Option { - match self.contexts.get_mut(window) { - Ok(context) => Some(context), - Err(err @ QueryEntityError::AliasedMutability(_)) => { - panic!("Failed to get an Egui context for a window ({window:?}): {err:?}"); - } - Err( - err @ QueryEntityError::NoSuchEntity(_) - | err @ QueryEntityError::QueryDoesNotMatch(_, _), - ) => { - bevy_log::error!( - "Failed to get an Egui context for a window ({window:?}): {err:?}", - ); - None - } - } - } -} - -/// Processes Bevy input and feeds it to Egui. -pub fn process_input_system( - mut input_events: InputEvents, - mut input_resources: InputResources, - mut context_params: ContextSystemParams, - time: Res>, -) { - // Test whether it's macOS or OS X. - use std::sync::Once; - static START: Once = Once::new(); - START.call_once(|| { - // The default for WASM is `false` since the `target_os` is `unknown`. - *context_params.is_macos = cfg!(target_os = "macos"); - - #[cfg(target_arch = "wasm32")] - 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") { - *context_params.is_macos = true; - } - } - } - }); - - 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. - keyboard_input_events.push(event.clone()); - #[cfg(feature = "log_input_events")] - bevy_log::info!("{event:?}"); - - let KeyboardInput { - logical_key, state, .. - } = event; - match logical_key { - Key::Shift => { - input_resources.modifier_keys_state.shift = state.is_pressed(); - } - Key::Control => { - input_resources.modifier_keys_state.ctrl = state.is_pressed(); - } - Key::Alt => { - input_resources.modifier_keys_state.alt = state.is_pressed(); - } - Key::Super | Key::Meta => { - input_resources.modifier_keys_state.win = state.is_pressed(); - } - _ => {} - }; - } - - // If window focus is lost, clear all modifiers to avoid stuck keys. - if !input_events.ev_focus.is_empty() { - input_events.ev_focus.clear(); - *input_resources.modifier_keys_state = Default::default(); - } - - let ModifierKeysState { - shift, - ctrl, - alt, - win, - } = *input_resources.modifier_keys_state; - let mac_cmd = if *context_params.is_macos { win } else { false }; - let command = if *context_params.is_macos { win } else { ctrl }; - - let modifiers = egui::Modifiers { - alt, - ctrl, - shift, - mac_cmd, - command, - }; - - for event in input_events.ev_cursor.read() { - let Some(mut window_context) = context_params.window_context(event.window) else { - continue; - }; - - let scale_factor = window_context.egui_settings.scale_factor; - let (x, y): (f32, f32) = (event.position / scale_factor).into(); - let mouse_position = egui::pos2(x, y); - window_context.ctx.mouse_position = mouse_position; - window_context - .egui_input - .events - .push(egui::Event::PointerMoved(mouse_position)); - } - - for event in input_events.ev_mouse_button_input.read() { - let Some(mut window_context) = context_params.window_context(event.window) else { - continue; - }; - #[cfg(feature = "log_input_events")] - bevy_log::info!("{event:?}"); - - let button = match event.button { - MouseButton::Left => Some(egui::PointerButton::Primary), - MouseButton::Right => Some(egui::PointerButton::Secondary), - MouseButton::Middle => Some(egui::PointerButton::Middle), - _ => None, - }; - let pressed = match event.state { - ButtonState::Pressed => true, - ButtonState::Released => false, - }; - if let Some(button) = button { - window_context - .egui_input - .events - .push(egui::Event::PointerButton { - pos: window_context.ctx.mouse_position, - button, - pressed, - modifiers, - }); - } - } - - for event in input_events.ev_mouse_wheel.read() { - let Some(mut window_context) = context_params.window_context(event.window) else { - continue; - }; - #[cfg(feature = "log_input_events")] - bevy_log::info!("{event:?}"); - - let delta = egui::vec2(event.x, event.y); - - let unit = match event.unit { - MouseScrollUnit::Line => egui::MouseWheelUnit::Line, - MouseScrollUnit::Pixel => egui::MouseWheelUnit::Point, - }; - - window_context - .egui_input - .events - .push(egui::Event::MouseWheel { - unit, - delta, - modifiers, - }); - } - - #[cfg(target_arch = "wasm32")] - let mut editing_text = false; - #[cfg(target_arch = "wasm32")] - for context in context_params.contexts.iter() { - let platform_output = &context.egui_output.platform_output; - if platform_output.ime.is_some() || platform_output.mutable_text_under_cursor { - editing_text = true; - break; - } - } - - for event in input_events.ev_ime_input.read() { - let window = match &event { - Ime::Preedit { window, .. } - | Ime::Commit { window, .. } - | Ime::Disabled { window } - | Ime::Enabled { window } => *window, - }; - - let Some(mut window_context) = context_params.window_context(window) else { - continue; - }; - #[cfg(feature = "log_input_events")] - bevy_log::info!("{event:?}"); - - // Aligned with the egui-winit implementation: https://github.com/emilk/egui/blob/0f2b427ff4c0a8c68f6622ec7d0afb7ba7e71bba/crates/egui-winit/src/lib.rs#L348 - match event { - Ime::Enabled { window: _ } => { - window_context.ime_event_enable(); - } - Ime::Preedit { - value, - window: _, - cursor: _, - } => { - window_context.ime_event_enable(); - window_context - .egui_input - .events - .push(egui::Event::Ime(egui::ImeEvent::Preedit(value.clone()))); - } - Ime::Commit { value, window: _ } => { - window_context - .egui_input - .events - .push(egui::Event::Ime(egui::ImeEvent::Commit(value.clone()))); - window_context.ime_event_disable(); - } - Ime::Disabled { window: _ } => { - window_context.ime_event_disable(); - } - } - } - - for event in keyboard_input_events { - let text_event_allowed = !command && !win || !*context_params.is_macos && ctrl && alt; - let Some(mut window_context) = context_params.window_context(event.window) else { - continue; - }; - #[cfg(feature = "log_input_events")] - bevy_log::info!("{event:?}"); - - if text_event_allowed && event.state.is_pressed() { - match &event.logical_key { - Key::Character(char) if char.matches(char::is_control).count() == 0 => { - (window_context.egui_input.events).push(egui::Event::Text(char.to_string())); - } - Key::Space => { - (window_context.egui_input.events).push(egui::Event::Text(" ".into())); - } - _ => (), - } - } - - let (Some(key), physical_key) = ( - bevy_to_egui_key(&event.logical_key), - bevy_to_egui_physical_key(&event.key_code), - ) else { - continue; - }; - - let egui_event = egui::Event::Key { - key, - pressed: event.state.is_pressed(), - repeat: false, - modifiers, - physical_key, - }; - window_context.egui_input.events.push(egui_event); - - // 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"), - not(target_arch = "wasm32") - ))] - if command && event.state.is_pressed() { - match key { - egui::Key::C => { - window_context.egui_input.events.push(egui::Event::Copy); - } - egui::Key::X => { - window_context.egui_input.events.push(egui::Event::Cut); - } - egui::Key::V => { - if let Some(contents) = input_resources.egui_clipboard.get_contents() { - window_context - .egui_input - .events - .push(egui::Event::Text(contents)) - } - } - _ => {} - } - } - } - - #[cfg(all( - feature = "manage_clipboard", - target_arch = "wasm32", - web_sys_unstable_apis - ))] - while let Some(event) = input_resources.egui_clipboard.try_receive_clipboard_event() { - // In web, we assume that we have only 1 window per app. - let mut window_context = context_params.contexts.single_mut(); - #[cfg(feature = "log_input_events")] - bevy_log::info!("{event:?}"); - - match event { - crate::web_clipboard::WebClipboardEvent::Copy => { - window_context.egui_input.events.push(egui::Event::Copy); - } - crate::web_clipboard::WebClipboardEvent::Cut => { - window_context.egui_input.events.push(egui::Event::Cut); - } - crate::web_clipboard::WebClipboardEvent::Paste(contents) => { - input_resources - .egui_clipboard - .set_contents_internal(&contents); - window_context - .egui_input - .events - .push(egui::Event::Text(contents)) - } - } - } - - for event in input_events.ev_touch.read() { - let Some(mut window_context) = context_params.window_context(event.window) else { - continue; - }; - #[cfg(feature = "log_input_events")] - bevy_log::info!("{event:?}"); - - let touch_id = egui::TouchId::from(event.id); - let scale_factor = window_context.egui_settings.scale_factor; - let touch_position: (f32, f32) = (event.position / scale_factor).into(); - - // Emit touch event - window_context.egui_input.events.push(egui::Event::Touch { - device_id: egui::TouchDeviceId(event.window.to_bits()), - id: touch_id, - phase: match event.phase { - bevy_input::touch::TouchPhase::Started => egui::TouchPhase::Start, - bevy_input::touch::TouchPhase::Moved => egui::TouchPhase::Move, - bevy_input::touch::TouchPhase::Ended => egui::TouchPhase::End, - bevy_input::touch::TouchPhase::Canceled => egui::TouchPhase::Cancel, - }, - pos: egui::pos2(touch_position.0, touch_position.1), - force: match event.force { - Some(bevy_input::touch::ForceTouch::Normalized(force)) => Some(force as f32), - Some(bevy_input::touch::ForceTouch::Calibrated { - force, - max_possible_force, - .. - }) => Some((force / max_possible_force) as f32), - None => None, - }, - }); - - // If we're not yet translating a touch, or we're translating this very - // touch, … - if window_context.ctx.pointer_touch_id.is_none() - || window_context.ctx.pointer_touch_id.unwrap() == event.id - { - // … emit PointerButton resp. PointerMoved events to emulate mouse. - match event.phase { - bevy_input::touch::TouchPhase::Started => { - window_context.ctx.pointer_touch_id = Some(event.id); - // First move the pointer to the right location. - window_context - .egui_input - .events - .push(egui::Event::PointerMoved(egui::pos2( - touch_position.0, - touch_position.1, - ))); - // Then do mouse button input. - window_context - .egui_input - .events - .push(egui::Event::PointerButton { - pos: egui::pos2(touch_position.0, touch_position.1), - button: egui::PointerButton::Primary, - pressed: true, - modifiers, - }); - } - bevy_input::touch::TouchPhase::Moved => { - window_context - .egui_input - .events - .push(egui::Event::PointerMoved(egui::pos2( - touch_position.0, - touch_position.1, - ))); - } - bevy_input::touch::TouchPhase::Ended => { - window_context.ctx.pointer_touch_id = None; - window_context - .egui_input - .events - .push(egui::Event::PointerButton { - pos: egui::pos2(touch_position.0, touch_position.1), - button: egui::PointerButton::Primary, - pressed: false, - modifiers, - }); - window_context - .egui_input - .events - .push(egui::Event::PointerGone); - - #[cfg(target_arch = "wasm32")] - if !is_mobile_safari() { - update_text_agent(editing_text); - } - } - bevy_input::touch::TouchPhase::Canceled => { - window_context.ctx.pointer_touch_id = None; - window_context - .egui_input - .events - .push(egui::Event::PointerGone); - } - } - } - } - - for mut context in context_params.contexts.iter_mut() { - context.egui_input.modifiers = modifiers; - context.egui_input.time = Some(time.elapsed_secs_f64()); - } - - // In some cases, we may skip certain events. For example, we ignore `ReceivedCharacter` events - // when alt or ctrl button is pressed. We still want to clear event buffer. - input_events.clear(); -} - -/// Initialises Egui contexts (for multiple windows). -pub fn update_contexts_system( - mut context_params: ContextSystemParams, - #[cfg(feature = "render")] images: Res>, -) { - for mut context in context_params.contexts.iter_mut() { - let mut render_target_size = None; - if let Some(window) = context.window { - render_target_size = Some(RenderTargetSize::new( - window.physical_width() as f32, - window.physical_height() as f32, - window.scale_factor(), - )); - } - #[cfg(feature = "render")] - if let Some(EguiRenderToImage { handle, .. }) = context.render_to_image.as_deref() { - let image = images.get(handle).expect("rtt handle should be valid"); - let size = image.size_f32(); - render_target_size = Some(RenderTargetSize { - physical_width: size.x, - physical_height: size.y, - scale_factor: 1.0, - }) - } - - let Some(new_render_target_size) = render_target_size else { - error!("bevy_egui context without window or render to texture!"); - continue; - }; - let width = new_render_target_size.physical_width - / new_render_target_size.scale_factor - / context.egui_settings.scale_factor; - let height = new_render_target_size.physical_height - / new_render_target_size.scale_factor - / context.egui_settings.scale_factor; - - if width < 1.0 || height < 1.0 { - continue; - } - - context.egui_input.screen_rect = Some(egui::Rect::from_min_max( - egui::pos2(0.0, 0.0), - egui::pos2(width, height), - )); - - context.ctx.get_mut().set_pixels_per_point( - new_render_target_size.scale_factor * context.egui_settings.scale_factor, - ); - - *context.render_target_size = new_render_target_size; - } -} - -/// Marks a pass start for Egui. -pub fn begin_pass_system(mut contexts: Query<(&mut EguiContext, &EguiSettings, &mut EguiInput)>) { - for (mut ctx, egui_settings, mut egui_input) in contexts.iter_mut() { - if !egui_settings.run_manually { - ctx.get_mut().begin_pass(egui_input.take()); - } - } -} - -/// Marks a pass end for Egui. -pub fn end_pass_system( - mut contexts: Query<(&mut EguiContext, &EguiSettings, &mut EguiFullOutput)>, -) { - for (mut ctx, egui_settings, mut full_output) in contexts.iter_mut() { - if !egui_settings.run_manually { - **full_output = Some(ctx.get_mut().end_pass()); - } - } -} - -/// Reads Egui output. -pub fn process_output_system( - mut contexts: Query, - #[cfg(all(feature = "manage_clipboard", not(target_os = "android")))] - mut egui_clipboard: bevy_ecs::system::ResMut, - mut event: EventWriter, - #[cfg(windows)] mut last_cursor_icon: Local>, - event_loop_proxy: Option>>, -) { - let mut should_request_redraw = false; - - for mut context in contexts.iter_mut() { - let ctx = context.ctx.get_mut(); - let Some(full_output) = context.egui_full_output.0.take() else { - bevy_log::error!("bevy_egui pass output has not been prepared (if EguiSettings::run_manually is set to true, make sure to call egui::Context::run or egui::Context::begin_pass and egui::Context::end_pass)"); - continue; - }; - let egui::FullOutput { - platform_output, - shapes, - textures_delta, - pixels_per_point, - viewport_output: _, - } = full_output; - let paint_jobs = ctx.tessellate(shapes, pixels_per_point); - - context.render_output.paint_jobs = paint_jobs; - context.render_output.textures_delta.append(textures_delta); - - context.egui_output.platform_output = platform_output.clone(); - - #[cfg(all( - feature = "manage_clipboard", - not(target_os = "android"), - not(all(target_arch = "wasm32", not(web_sys_unstable_apis))) - ))] - if !platform_output.copied_text.is_empty() { - egui_clipboard.set_contents(&platform_output.copied_text); - } - - if let Some(mut cursor) = context.cursor { - let mut set_icon = || { - *cursor = bevy_winit::cursor::CursorIcon::System( - egui_to_winit_cursor_icon(platform_output.cursor_icon) - .unwrap_or(bevy_window::SystemCursorIcon::Default), - ); - }; - - #[cfg(windows)] - { - let last_cursor_icon = last_cursor_icon.entry(context.render_target).or_default(); - if *last_cursor_icon != platform_output.cursor_icon { - set_icon(); - *last_cursor_icon = platform_output.cursor_icon; - } - } - #[cfg(not(windows))] - set_icon(); - } - - let needs_repaint = !context.render_output.is_empty(); - should_request_redraw |= ctx.has_requested_repaint() && needs_repaint; - - // The resource doesn't exist in the headless mode. - if let Some(event_loop_proxy) = &event_loop_proxy { - // A zero duration indicates that it's an outstanding redraw request, which gives Egui an - // opportunity to settle the effects of interactions with widgets. Such repaint requests - // are processed not immediately but on a next frame. In this case, we need to indicate to - // winit, that it needs to wake up next frame as well even if there are no inputs. - // - // TLDR: this solves repaint corner cases of `WinitSettings::desktop_app()`. - if let Some(Duration::ZERO) = - ctx.viewport(|viewport| viewport.input.wants_repaint_after()) - { - let _ = event_loop_proxy.send_event(WakeUp); - } - } - - #[cfg(feature = "open_url")] - if let Some(egui::output::OpenUrl { url, new_tab }) = platform_output.open_url { - let target = if new_tab { - "_blank" - } else { - context - .egui_settings - .default_open_url_target - .as_deref() - .unwrap_or("_self") - }; - if let Err(err) = webbrowser::open_browser_with_options( - webbrowser::Browser::Default, - &url, - webbrowser::BrowserOptions::new().with_target_hint(target), - ) { - bevy_log::error!("Failed to open '{}': {:?}", url, err); - } - } - } - - if should_request_redraw { - event.send(RequestRedraw); - } -} - -fn egui_to_winit_cursor_icon( - cursor_icon: egui::CursorIcon, -) -> Option { - match cursor_icon { - egui::CursorIcon::Default => Some(bevy_window::SystemCursorIcon::Default), - egui::CursorIcon::PointingHand => Some(bevy_window::SystemCursorIcon::Pointer), - egui::CursorIcon::ResizeHorizontal => Some(bevy_window::SystemCursorIcon::EwResize), - egui::CursorIcon::ResizeNeSw => Some(bevy_window::SystemCursorIcon::NeswResize), - egui::CursorIcon::ResizeNwSe => Some(bevy_window::SystemCursorIcon::NwseResize), - egui::CursorIcon::ResizeVertical => Some(bevy_window::SystemCursorIcon::NsResize), - egui::CursorIcon::Text => Some(bevy_window::SystemCursorIcon::Text), - egui::CursorIcon::Grab => Some(bevy_window::SystemCursorIcon::Grab), - egui::CursorIcon::Grabbing => Some(bevy_window::SystemCursorIcon::Grabbing), - egui::CursorIcon::ContextMenu => Some(bevy_window::SystemCursorIcon::ContextMenu), - egui::CursorIcon::Help => Some(bevy_window::SystemCursorIcon::Help), - egui::CursorIcon::Progress => Some(bevy_window::SystemCursorIcon::Progress), - egui::CursorIcon::Wait => Some(bevy_window::SystemCursorIcon::Wait), - egui::CursorIcon::Cell => Some(bevy_window::SystemCursorIcon::Cell), - egui::CursorIcon::Crosshair => Some(bevy_window::SystemCursorIcon::Crosshair), - egui::CursorIcon::VerticalText => Some(bevy_window::SystemCursorIcon::VerticalText), - egui::CursorIcon::Alias => Some(bevy_window::SystemCursorIcon::Alias), - egui::CursorIcon::Copy => Some(bevy_window::SystemCursorIcon::Copy), - egui::CursorIcon::Move => Some(bevy_window::SystemCursorIcon::Move), - egui::CursorIcon::NoDrop => Some(bevy_window::SystemCursorIcon::NoDrop), - egui::CursorIcon::NotAllowed => Some(bevy_window::SystemCursorIcon::NotAllowed), - egui::CursorIcon::AllScroll => Some(bevy_window::SystemCursorIcon::AllScroll), - egui::CursorIcon::ZoomIn => Some(bevy_window::SystemCursorIcon::ZoomIn), - egui::CursorIcon::ZoomOut => Some(bevy_window::SystemCursorIcon::ZoomOut), - egui::CursorIcon::ResizeEast => Some(bevy_window::SystemCursorIcon::EResize), - egui::CursorIcon::ResizeSouthEast => Some(bevy_window::SystemCursorIcon::SeResize), - egui::CursorIcon::ResizeSouth => Some(bevy_window::SystemCursorIcon::SResize), - egui::CursorIcon::ResizeSouthWest => Some(bevy_window::SystemCursorIcon::SwResize), - egui::CursorIcon::ResizeWest => Some(bevy_window::SystemCursorIcon::WResize), - egui::CursorIcon::ResizeNorthWest => Some(bevy_window::SystemCursorIcon::NwResize), - egui::CursorIcon::ResizeNorth => Some(bevy_window::SystemCursorIcon::NResize), - egui::CursorIcon::ResizeNorthEast => Some(bevy_window::SystemCursorIcon::NeResize), - egui::CursorIcon::ResizeColumn => Some(bevy_window::SystemCursorIcon::ColResize), - egui::CursorIcon::ResizeRow => Some(bevy_window::SystemCursorIcon::RowResize), - egui::CursorIcon::None => None, - } -} - -/// Matches the implementation of . -pub fn bevy_to_egui_key(key: &Key) -> Option { - let key = match key { - Key::Character(str) => return egui::Key::from_name(str.as_str()), - Key::Unidentified(_) | Key::Dead(_) => return None, - - Key::Enter => egui::Key::Enter, - Key::Tab => egui::Key::Tab, - Key::Space => egui::Key::Space, - Key::ArrowDown => egui::Key::ArrowDown, - Key::ArrowLeft => egui::Key::ArrowLeft, - Key::ArrowRight => egui::Key::ArrowRight, - Key::ArrowUp => egui::Key::ArrowUp, - Key::End => egui::Key::End, - Key::Home => egui::Key::Home, - Key::PageDown => egui::Key::PageDown, - Key::PageUp => egui::Key::PageUp, - Key::Backspace => egui::Key::Backspace, - Key::Delete => egui::Key::Delete, - Key::Insert => egui::Key::Insert, - Key::Escape => egui::Key::Escape, - Key::F1 => egui::Key::F1, - Key::F2 => egui::Key::F2, - Key::F3 => egui::Key::F3, - Key::F4 => egui::Key::F4, - Key::F5 => egui::Key::F5, - Key::F6 => egui::Key::F6, - Key::F7 => egui::Key::F7, - Key::F8 => egui::Key::F8, - Key::F9 => egui::Key::F9, - Key::F10 => egui::Key::F10, - Key::F11 => egui::Key::F11, - Key::F12 => egui::Key::F12, - Key::F13 => egui::Key::F13, - Key::F14 => egui::Key::F14, - Key::F15 => egui::Key::F15, - Key::F16 => egui::Key::F16, - Key::F17 => egui::Key::F17, - Key::F18 => egui::Key::F18, - Key::F19 => egui::Key::F19, - Key::F20 => egui::Key::F20, - - _ => return None, - }; - Some(key) -} - -/// Matches the implementation of . -pub fn bevy_to_egui_physical_key(key: &KeyCode) -> Option { - let key = match key { - KeyCode::ArrowDown => egui::Key::ArrowDown, - KeyCode::ArrowLeft => egui::Key::ArrowLeft, - KeyCode::ArrowRight => egui::Key::ArrowRight, - KeyCode::ArrowUp => egui::Key::ArrowUp, - - KeyCode::Escape => egui::Key::Escape, - KeyCode::Tab => egui::Key::Tab, - KeyCode::Backspace => egui::Key::Backspace, - KeyCode::Enter | KeyCode::NumpadEnter => egui::Key::Enter, - - KeyCode::Insert => egui::Key::Insert, - KeyCode::Delete => egui::Key::Delete, - KeyCode::Home => egui::Key::Home, - KeyCode::End => egui::Key::End, - KeyCode::PageUp => egui::Key::PageUp, - KeyCode::PageDown => egui::Key::PageDown, - - // Punctuation - KeyCode::Space => egui::Key::Space, - KeyCode::Comma => egui::Key::Comma, - KeyCode::Period => egui::Key::Period, - // KeyCode::Colon => egui::Key::Colon, // NOTE: there is no physical colon key on an american keyboard - KeyCode::Semicolon => egui::Key::Semicolon, - KeyCode::Backslash => egui::Key::Backslash, - KeyCode::Slash | KeyCode::NumpadDivide => egui::Key::Slash, - KeyCode::BracketLeft => egui::Key::OpenBracket, - KeyCode::BracketRight => egui::Key::CloseBracket, - KeyCode::Backquote => egui::Key::Backtick, - - KeyCode::Cut => egui::Key::Cut, - KeyCode::Copy => egui::Key::Copy, - KeyCode::Paste => egui::Key::Paste, - KeyCode::Minus | KeyCode::NumpadSubtract => egui::Key::Minus, - KeyCode::NumpadAdd => egui::Key::Plus, - KeyCode::Equal => egui::Key::Equals, - - KeyCode::Digit0 | KeyCode::Numpad0 => egui::Key::Num0, - KeyCode::Digit1 | KeyCode::Numpad1 => egui::Key::Num1, - KeyCode::Digit2 | KeyCode::Numpad2 => egui::Key::Num2, - KeyCode::Digit3 | KeyCode::Numpad3 => egui::Key::Num3, - KeyCode::Digit4 | KeyCode::Numpad4 => egui::Key::Num4, - KeyCode::Digit5 | KeyCode::Numpad5 => egui::Key::Num5, - KeyCode::Digit6 | KeyCode::Numpad6 => egui::Key::Num6, - KeyCode::Digit7 | KeyCode::Numpad7 => egui::Key::Num7, - KeyCode::Digit8 | KeyCode::Numpad8 => egui::Key::Num8, - KeyCode::Digit9 | KeyCode::Numpad9 => egui::Key::Num9, - - KeyCode::KeyA => egui::Key::A, - KeyCode::KeyB => egui::Key::B, - KeyCode::KeyC => egui::Key::C, - KeyCode::KeyD => egui::Key::D, - KeyCode::KeyE => egui::Key::E, - KeyCode::KeyF => egui::Key::F, - KeyCode::KeyG => egui::Key::G, - KeyCode::KeyH => egui::Key::H, - KeyCode::KeyI => egui::Key::I, - KeyCode::KeyJ => egui::Key::J, - KeyCode::KeyK => egui::Key::K, - KeyCode::KeyL => egui::Key::L, - KeyCode::KeyM => egui::Key::M, - KeyCode::KeyN => egui::Key::N, - KeyCode::KeyO => egui::Key::O, - KeyCode::KeyP => egui::Key::P, - KeyCode::KeyQ => egui::Key::Q, - KeyCode::KeyR => egui::Key::R, - KeyCode::KeyS => egui::Key::S, - KeyCode::KeyT => egui::Key::T, - KeyCode::KeyU => egui::Key::U, - KeyCode::KeyV => egui::Key::V, - KeyCode::KeyW => egui::Key::W, - KeyCode::KeyX => egui::Key::X, - KeyCode::KeyY => egui::Key::Y, - KeyCode::KeyZ => egui::Key::Z, - - KeyCode::F1 => egui::Key::F1, - KeyCode::F2 => egui::Key::F2, - KeyCode::F3 => egui::Key::F3, - KeyCode::F4 => egui::Key::F4, - KeyCode::F5 => egui::Key::F5, - KeyCode::F6 => egui::Key::F6, - KeyCode::F7 => egui::Key::F7, - KeyCode::F8 => egui::Key::F8, - KeyCode::F9 => egui::Key::F9, - KeyCode::F10 => egui::Key::F10, - KeyCode::F11 => egui::Key::F11, - KeyCode::F12 => egui::Key::F12, - KeyCode::F13 => egui::Key::F13, - KeyCode::F14 => egui::Key::F14, - KeyCode::F15 => egui::Key::F15, - KeyCode::F16 => egui::Key::F16, - KeyCode::F17 => egui::Key::F17, - KeyCode::F18 => egui::Key::F18, - KeyCode::F19 => egui::Key::F19, - KeyCode::F20 => egui::Key::F20, - _ => return None, - }; - Some(key) -} diff --git a/src/text_agent.rs b/src/text_agent.rs index 9e46f7a68..941deedc2 100644 --- a/src/text_agent.rs +++ b/src/text_agent.rs @@ -1,25 +1,29 @@ //! The text agent is an `` element used to trigger //! mobile keyboard and IME input. -use crate::{systems::ContextSystemParams, EventClosure, SubscribedEvents}; +use crate::{ + input::{EguiInputEvent, FocusedNonWindowEguiContext}, + EguiContext, EguiInput, EguiOutput, EventClosure, SubscribedEvents, +}; use bevy_ecs::prelude::*; -use bevy_window::RequestRedraw; +use bevy_window::{PrimaryWindow, RequestRedraw}; use crossbeam_channel::{unbounded, Receiver, Sender}; use std::sync::{LazyLock, Mutex}; use wasm_bindgen::prelude::*; static AGENT_ID: &str = "egui_text_agent"; -#[allow(missing_docs)] +// Stores if we are editing text, to react on touch events as a workaround for Safari. #[derive(Clone, Copy, Debug, Default)] -pub struct VirtualTouchInfo { - pub editing_text: bool, +pub(crate) struct VirtualTouchInfo { + editing_text: bool, } +/// Channel for receiving events from a text agent. #[derive(Resource)] pub struct TextAgentChannel { - pub sender: Sender, - pub receiver: Receiver, + sender: Sender, + receiver: Receiver, } impl Default for TextAgentChannel { @@ -29,62 +33,76 @@ impl Default for TextAgentChannel { } } +/// Wraps [`VirtualTouchInfo`] and channels that notify when we need to update it. #[derive(Resource)] -pub struct SafariVirtualKeyboardHack { - pub sender: Sender, - pub receiver: Receiver, - pub touch_info: &'static LazyLock>, +pub struct SafariVirtualKeyboardTouchState { + pub(crate) sender: Sender<()>, + pub(crate) receiver: Receiver<()>, + pub(crate) touch_info: &'static LazyLock>, } -pub fn process_safari_virtual_keyboard( - context_params: ContextSystemParams, - safari_virtual_keyboard_hack: Res, +/// Listens to the [`SafariVirtualKeyboardTouchState`] channel and updates the wrapped [`VirtualTouchInfo`]. +pub fn process_safari_virtual_keyboard_system( + egui_contexts: Query<(&EguiInput, &EguiOutput)>, + safari_virtual_keyboard_touch_state: Res, ) { - for contexts in context_params.contexts.iter() { - while let Ok(true) = safari_virtual_keyboard_hack.receiver.try_recv() { - let platform_output = &contexts.egui_output.platform_output; - let mut editing_text = false; + let mut received = false; + while let Ok(()) = safari_virtual_keyboard_touch_state.receiver.try_recv() { + received = true; + } + if !received { + return; + } - if platform_output.ime.is_some() || platform_output.mutable_text_under_cursor { - editing_text = true; - } - match safari_virtual_keyboard_hack.touch_info.lock() { - Ok(mut touch_info) => { - touch_info.editing_text = editing_text; - } - Err(poisoned) => { - let _unused = poisoned.into_inner(); - } - }; + let mut editing_text = false; + for (egui_input, egui_output) in egui_contexts.iter() { + if !egui_input.focused { + continue; + } + let platform_output = &egui_output.platform_output; + if platform_output.ime.is_some() || platform_output.mutable_text_under_cursor { + editing_text = true; + break; } } + + match safari_virtual_keyboard_touch_state.touch_info.lock() { + Ok(mut touch_info) => { + touch_info.editing_text = editing_text; + } + Err(poisoned) => { + let _unused = poisoned.into_inner(); + } + }; } -pub fn propagate_text( +/// Listens to the [`TextAgentChannel`] channel and wraps messages into [`EguiInputEvent`] events. +pub fn write_text_agent_channel_events_system( channel: Res, - mut context_params: ContextSystemParams, + focused_non_window_egui_context: Option>, + // We can safely assume that we have only 1 window in WASM. + primary_context: Single, With)>, + mut egui_input_event_writer: EventWriter, mut redraw_event: EventWriter, ) { - for mut contexts in context_params.contexts.iter_mut() { - if contexts.egui_input.focused { - let mut redraw = false; - while let Ok(r) = channel.receiver.try_recv() { - redraw = true; - contexts.egui_input.events.push(r); - } - if redraw { - redraw_event.send(RequestRedraw); - } - break; - } + let mut redraw = false; + let context = focused_non_window_egui_context + .as_deref() + .map_or(*primary_context, |context| context.0); + while let Ok(event) = channel.receiver.try_recv() { + redraw = true; + egui_input_event_writer.send(EguiInputEvent { context, event }); + } + if redraw { + redraw_event.send(RequestRedraw); } } -/// Text event handler, -pub fn install_text_agent( +/// Installs a text agent on startup. +pub fn install_text_agent_system( mut subscribed_events: NonSendMut, text_agent_channel: Res, - safari_virtual_keyboard_hack: Res, + safari_virtual_keyboard_touch_state: Res, ) { let window = web_sys::window().unwrap(); let document = window.document().unwrap(); @@ -246,11 +264,11 @@ pub fn install_text_agent( // Mobile safari doesn't let you set input focus outside of an event handler. if is_mobile_safari() { - let safari_sender = safari_virtual_keyboard_hack.sender.clone(); + let safari_sender = safari_virtual_keyboard_touch_state.sender.clone(); let closure = Closure::wrap(Box::new(move |_event: web_sys::TouchEvent| { #[cfg(feature = "log_input_events")] log::info!("Touch start: {:?}", _event); - let _ = safari_sender.send(true); + let _ = safari_sender.send(()); }) as Box); document .add_event_listener_with_callback("touchstart", closure.as_ref().unchecked_ref()) @@ -264,7 +282,7 @@ pub fn install_text_agent( closure, }); - let safari_touch_info_lock = safari_virtual_keyboard_hack.touch_info; + let safari_touch_info_lock = safari_virtual_keyboard_touch_state.touch_info; let closure = Closure::wrap(Box::new(move |_event: web_sys::TouchEvent| { #[cfg(feature = "log_input_events")] log::info!("Touch end: {:?}", _event); @@ -406,7 +424,7 @@ pub fn update_text_agent(editing_text: bool) { } } -pub fn is_mobile_safari() -> bool { +pub(crate) fn is_mobile_safari() -> bool { (|| { let user_agent = web_sys::window()?.navigator().user_agent().ok()?; let is_ios = user_agent.contains("iPhone") diff --git a/src/web_clipboard.rs b/src/web_clipboard.rs index 73ae8da35..8a44fd0d8 100644 --- a/src/web_clipboard.rs +++ b/src/web_clipboard.rs @@ -1,12 +1,16 @@ -use crate::{string_from_js_value, EguiClipboard, EventClosure, SubscribedEvents}; +use crate::{ + input::{EguiInputEvent, FocusedNonWindowEguiContext}, + string_from_js_value, EguiClipboard, EguiContext, EventClosure, SubscribedEvents, +}; use bevy_ecs::prelude::*; use bevy_log as log; +use bevy_window::PrimaryWindow; use crossbeam_channel::{Receiver, Sender}; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::spawn_local; /// Startup system to initialize web clipboard events. -pub fn startup_setup_web_events( +pub fn startup_setup_web_events_system( mut egui_clipboard: ResMut, mut subscribed_events: NonSendMut, ) { @@ -17,6 +21,42 @@ pub fn startup_setup_web_events( setup_clipboard_paste(&mut subscribed_events, tx); } +/// Receives web clipboard events and wraps them as [`EguiInputEvent`] events. +pub fn write_web_clipboard_events_system( + focused_non_window_egui_context: Option>, + // We can safely assume that we have only 1 window in WASM. + primary_context: Single, With)>, + mut egui_clipboard: ResMut, + mut egui_input_event_writer: EventWriter, +) { + let context = focused_non_window_egui_context + .as_deref() + .map_or(*primary_context, |context| context.0); + while let Some(event) = egui_clipboard.try_receive_clipboard_event() { + match event { + crate::web_clipboard::WebClipboardEvent::Copy => { + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::Copy, + }); + } + crate::web_clipboard::WebClipboardEvent::Cut => { + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::Cut, + }); + } + crate::web_clipboard::WebClipboardEvent::Paste(contents) => { + egui_clipboard.set_contents_internal(&contents); + egui_input_event_writer.send(EguiInputEvent { + context, + event: egui::Event::Text(contents), + }); + } + } + } +} + /// Internal implementation of `[crate::EguiClipboard]` for web. #[derive(Default)] pub struct WebClipboard { diff --git a/static/error_web_sys_unstable_apis.txt b/static/error_web_sys_unstable_apis.txt deleted file mode 100644 index 7b07db435..000000000 --- a/static/error_web_sys_unstable_apis.txt +++ /dev/null @@ -1,6 +0,0 @@ -bevy_egui uses unstable APIs to support clipboard on web. - -Please add `--cfg=web_sys_unstable_apis` to your rustflags or disable the `bevy_egui::manage_clipboard` feature. - -More Info: https://rustwasm.github.io/wasm-bindgen/web-sys/unstable-apis.html - \ No newline at end of file