mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 22:53:14 -04:00
Remove egui_web and epi (#1545)
* Remove integration name (it is always eframe) * Remove egui_web crate * Move egui_web/CHANGELOG.md into eframe/CHANGELOG.md * Remove all mentions of egui_web * Remove epi crate and absorb into eframe * egui_glow: only use puffin on native * Remove WASM doc from CI (we don't generate it anyways!) * Remove eframe::epi and improve eframe docs
This commit is contained in:
489
eframe/src/epi.rs
Normal file
489
eframe/src/epi.rs
Normal file
@@ -0,0 +1,489 @@
|
||||
//! Platform-agnostic interface for writing apps using [`egui`] (epi = egui programming interface).
|
||||
//!
|
||||
//! `epi` provides interfaces for window management and serialization.
|
||||
//!
|
||||
//! Start by looking at the [`App`] trait, and implement [`App::update`].
|
||||
|
||||
#![warn(missing_docs)] // Let's keep `epi` well-documented.
|
||||
|
||||
/// This is how your app is created.
|
||||
///
|
||||
/// You can use the [`CreationContext`] to setup egui, restore state, setup OpenGL things, etc.
|
||||
pub type AppCreator = Box<dyn FnOnce(&CreationContext<'_>) -> Box<dyn App>>;
|
||||
|
||||
/// Data that is passed to [`AppCreator`] that can be used to setup and initialize your app.
|
||||
pub struct CreationContext<'s> {
|
||||
/// The egui Context.
|
||||
///
|
||||
/// You can use this to customize the look of egui, e.g to call [`egui::Context::set_fonts`],
|
||||
/// [`egui::Context::set_visuals`] etc.
|
||||
pub egui_ctx: egui::Context,
|
||||
|
||||
/// Information about the surrounding environment.
|
||||
pub integration_info: IntegrationInfo,
|
||||
|
||||
/// You can use the storage to restore app state(requires the "persistence" feature).
|
||||
pub storage: Option<&'s dyn Storage>,
|
||||
|
||||
/// The [`glow::Context`] allows you to initialize OpenGL resources (e.g. shaders) that
|
||||
/// you might want to use later from a [`egui::PaintCallback`].
|
||||
pub gl: std::rc::Rc<glow::Context>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Implement this trait to write apps that can be compiled for both web/wasm and desktop/native using [`eframe`](https://github.com/emilk/egui/tree/master/eframe).
|
||||
pub trait App {
|
||||
/// Called each time the UI needs repainting, which may be many times per second.
|
||||
///
|
||||
/// Put your widgets into a [`egui::SidePanel`], [`egui::TopBottomPanel`], [`egui::CentralPanel`], [`egui::Window`] or [`egui::Area`].
|
||||
///
|
||||
/// The [`egui::Context`] can be cloned and saved if you like.
|
||||
///
|
||||
/// To force a repaint, call [`egui::Context::request_repaint`] at any time (e.g. from another thread).
|
||||
fn update(&mut self, ctx: &egui::Context, frame: &mut Frame);
|
||||
|
||||
/// Called on shutdown, and perhaps at regular intervals. Allows you to save state.
|
||||
///
|
||||
/// Only called when the "persistence" feature is enabled.
|
||||
///
|
||||
/// On web the state is stored to "Local Storage".
|
||||
/// On native the path is picked using [`directories_next::ProjectDirs::data_dir`](https://docs.rs/directories-next/2.0.0/directories_next/struct.ProjectDirs.html#method.data_dir) which is:
|
||||
/// * Linux: `/home/UserName/.local/share/APPNAME`
|
||||
/// * macOS: `/Users/UserName/Library/Application Support/APPNAME`
|
||||
/// * Windows: `C:\Users\UserName\AppData\Roaming\APPNAME`
|
||||
///
|
||||
/// where `APPNAME` is what is given to `eframe::run_native`.
|
||||
fn save(&mut self, _storage: &mut dyn Storage) {}
|
||||
|
||||
/// Called before an exit that can be aborted.
|
||||
/// By returning `false` the exit will be aborted. To continue the exit return `true`.
|
||||
///
|
||||
/// A scenario where this method will be run is after pressing the close button on a native
|
||||
/// window, which allows you to ask the user whether they want to do something before exiting.
|
||||
/// See the example at <https://github.com/emilk/egui/blob/master/examples/confirm_exit/> for practical usage.
|
||||
///
|
||||
/// It will _not_ be called on the web or when the window is forcefully closed.
|
||||
fn on_exit_event(&mut self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Called once on shutdown, after [`Self::save`].
|
||||
///
|
||||
/// If you need to abort an exit use [`Self::on_exit_event`].
|
||||
fn on_exit(&mut self, _gl: &glow::Context) {}
|
||||
|
||||
// ---------
|
||||
// Settings:
|
||||
|
||||
/// Time between automatic calls to [`Self::save`]
|
||||
fn auto_save_interval(&self) -> std::time::Duration {
|
||||
std::time::Duration::from_secs(30)
|
||||
}
|
||||
|
||||
/// The size limit of the web app canvas.
|
||||
///
|
||||
/// By default the max size is [`egui::Vec2::INFINITY`], i.e. unlimited.
|
||||
///
|
||||
/// A large canvas can lead to bad frame rates on some older browsers on some platforms
|
||||
/// (see <https://bugzilla.mozilla.org/show_bug.cgi?id=1010527#c0>).
|
||||
fn max_size_points(&self) -> egui::Vec2 {
|
||||
egui::Vec2::INFINITY
|
||||
}
|
||||
|
||||
/// Background color for the app, e.g. what is sent to `gl.clearColor`.
|
||||
/// This is the background of your windows if you don't set a central panel.
|
||||
fn clear_color(&self) -> egui::Rgba {
|
||||
// NOTE: a bright gray makes the shadows of the windows look weird.
|
||||
// We use a bit of transparency so that if the user switches on the
|
||||
// `transparent()` option they get immediate results.
|
||||
egui::Color32::from_rgba_unmultiplied(12, 12, 12, 180).into()
|
||||
}
|
||||
|
||||
/// Controls whether or not the native window position and size will be
|
||||
/// persisted (only if the "persistence" feature is enabled).
|
||||
fn persist_native_window(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Controls whether or not the egui memory (window positions etc) will be
|
||||
/// persisted (only if the "persistence" feature is enabled).
|
||||
fn persist_egui_memory(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// If `true` a warm-up call to [`Self::update`] will be issued where
|
||||
/// `ctx.memory().everything_is_visible()` will be set to `true`.
|
||||
///
|
||||
/// This can help pre-caching resources loaded by different parts of the UI, preventing stutter later on.
|
||||
///
|
||||
/// In this warm-up call, all painted shapes will be ignored.
|
||||
///
|
||||
/// The default is `false`, and it is unlikely you will want to change this.
|
||||
fn warm_up_enabled(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Options controlling the behavior of a native window.
|
||||
///
|
||||
/// Only a single native window is currently supported.
|
||||
#[derive(Clone)]
|
||||
pub struct NativeOptions {
|
||||
/// Sets whether or not the window will always be on top of other windows.
|
||||
pub always_on_top: bool,
|
||||
|
||||
/// Show window in maximized mode
|
||||
pub maximized: bool,
|
||||
|
||||
/// On desktop: add window decorations (i.e. a frame around your app)?
|
||||
/// If false it will be difficult to move and resize the app.
|
||||
pub decorated: bool,
|
||||
|
||||
/// On Windows: enable drag and drop support. Drag and drop can
|
||||
/// not be disabled on other platforms.
|
||||
///
|
||||
/// See [winit's documentation][drag_and_drop] for information on why you
|
||||
/// might want to disable this on windows.
|
||||
///
|
||||
/// [drag_and_drop]: https://docs.rs/winit/latest/x86_64-pc-windows-msvc/winit/platform/windows/trait.WindowBuilderExtWindows.html#tymethod.with_drag_and_drop
|
||||
pub drag_and_drop_support: bool,
|
||||
|
||||
/// The application icon, e.g. in the Windows task bar etc.
|
||||
pub icon_data: Option<IconData>,
|
||||
|
||||
/// The initial (inner) position of the native window in points (logical pixels).
|
||||
pub initial_window_pos: Option<egui::Pos2>,
|
||||
|
||||
/// The initial inner size of the native window in points (logical pixels).
|
||||
pub initial_window_size: Option<egui::Vec2>,
|
||||
|
||||
/// The minimum inner window size
|
||||
pub min_window_size: Option<egui::Vec2>,
|
||||
|
||||
/// The maximum inner window size
|
||||
pub max_window_size: Option<egui::Vec2>,
|
||||
|
||||
/// Should the app window be resizable?
|
||||
pub resizable: bool,
|
||||
|
||||
/// On desktop: make the window transparent.
|
||||
/// You control the transparency with [`App::clear_color()`].
|
||||
/// You should avoid having a [`egui::CentralPanel`], or make sure its frame is also transparent.
|
||||
pub transparent: bool,
|
||||
|
||||
/// Turn on vertical syncing, limiting the FPS to the display refresh rate.
|
||||
///
|
||||
/// The default is `true`.
|
||||
pub vsync: bool,
|
||||
|
||||
/// Set the level of the multisampling anti-aliasing (MSAA).
|
||||
///
|
||||
/// Must be a power-of-two. Higher = more smooth 3D.
|
||||
///
|
||||
/// A value of `0` turns it off (default).
|
||||
///
|
||||
/// `egui` already performs anti-aliasing via "feathering"
|
||||
/// (controlled by [`egui::epaint::TessellationOptions`]),
|
||||
/// but if you are embedding 3D in egui you may want to turn on multisampling.
|
||||
pub multisampling: u16,
|
||||
|
||||
/// Sets the number of bits in the depth buffer.
|
||||
///
|
||||
/// `egui` doesn't need the depth buffer, so the default value is 0.
|
||||
pub depth_buffer: u8,
|
||||
|
||||
/// Sets the number of bits in the stencil buffer.
|
||||
///
|
||||
/// `egui` doesn't need the stencil buffer, so the default value is 0.
|
||||
pub stencil_buffer: u8,
|
||||
}
|
||||
|
||||
impl Default for NativeOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
always_on_top: false,
|
||||
maximized: false,
|
||||
decorated: true,
|
||||
drag_and_drop_support: true,
|
||||
icon_data: None,
|
||||
initial_window_pos: None,
|
||||
initial_window_size: None,
|
||||
min_window_size: None,
|
||||
max_window_size: None,
|
||||
resizable: true,
|
||||
transparent: false,
|
||||
vsync: true,
|
||||
multisampling: 0,
|
||||
depth_buffer: 0,
|
||||
stencil_buffer: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Image data for an application icon.
|
||||
#[derive(Clone)]
|
||||
pub struct IconData {
|
||||
/// RGBA pixels, unmultiplied.
|
||||
pub rgba: Vec<u8>,
|
||||
|
||||
/// Image width. This should be a multiple of 4.
|
||||
pub width: u32,
|
||||
|
||||
/// Image height. This should be a multiple of 4.
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Represents the surroundings of your app.
|
||||
///
|
||||
/// It provides methods to inspect the surroundings (are we on the web?),
|
||||
/// allocate textures, and change settings (e.g. window size).
|
||||
pub struct Frame {
|
||||
/// Information about the integration.
|
||||
#[doc(hidden)]
|
||||
pub info: IntegrationInfo,
|
||||
|
||||
/// Where the app can issue commands back to the integration.
|
||||
#[doc(hidden)]
|
||||
pub output: backend::AppOutput,
|
||||
|
||||
/// A place where you can store custom data in a way that persists when you restart the app.
|
||||
#[doc(hidden)]
|
||||
pub storage: Option<Box<dyn Storage>>,
|
||||
|
||||
/// A reference to the underlying [`glow`] (OpenGL) context.
|
||||
#[doc(hidden)]
|
||||
pub gl: std::rc::Rc<glow::Context>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// True if you are in a web environment.
|
||||
pub fn is_web(&self) -> bool {
|
||||
self.info.web_info.is_some()
|
||||
}
|
||||
|
||||
/// Information about the integration.
|
||||
pub fn info(&self) -> IntegrationInfo {
|
||||
self.info.clone()
|
||||
}
|
||||
|
||||
/// A place where you can store custom data in a way that persists when you restart the app.
|
||||
pub fn storage(&self) -> Option<&dyn Storage> {
|
||||
self.storage.as_deref()
|
||||
}
|
||||
|
||||
/// A place where you can store custom data in a way that persists when you restart the app.
|
||||
pub fn storage_mut(&mut self) -> Option<&mut (dyn Storage + 'static)> {
|
||||
self.storage.as_deref_mut()
|
||||
}
|
||||
|
||||
/// A reference to the underlying [`glow`] (OpenGL) context.
|
||||
///
|
||||
/// This can be used, for instance, to:
|
||||
/// * Render things to offscreen buffers.
|
||||
/// * Read the pixel buffer from the previous frame (`glow::Context::read_pixels`).
|
||||
/// * Render things behind the egui windows.
|
||||
///
|
||||
/// Note that all egui painting is deferred to after the call to [`App::update`]
|
||||
/// ([`egui`] only collects [`egui::Shape`]s and then eframe paints them all in one go later on).
|
||||
pub fn gl(&self) -> &std::rc::Rc<glow::Context> {
|
||||
&self.gl
|
||||
}
|
||||
|
||||
/// Signal the app to stop/exit/quit the app (only works for native apps, not web apps).
|
||||
/// The framework will not quit immediately, but at the end of the this frame.
|
||||
pub fn quit(&mut self) {
|
||||
self.output.quit = true;
|
||||
}
|
||||
|
||||
/// Set the desired inner size of the window (in egui points).
|
||||
pub fn set_window_size(&mut self, size: egui::Vec2) {
|
||||
self.output.window_size = Some(size);
|
||||
}
|
||||
|
||||
/// Set the desired title of the window.
|
||||
pub fn set_window_title(&mut self, title: &str) {
|
||||
self.output.window_title = Some(title.to_owned());
|
||||
}
|
||||
|
||||
/// Set whether to show window decorations (i.e. a frame around you app).
|
||||
/// If false it will be difficult to move and resize the app.
|
||||
pub fn set_decorations(&mut self, decorated: bool) {
|
||||
self.output.decorated = Some(decorated);
|
||||
}
|
||||
|
||||
/// set the position of the outer window
|
||||
pub fn set_window_pos(&mut self, pos: egui::Pos2) {
|
||||
self.output.window_pos = Some(pos);
|
||||
}
|
||||
|
||||
/// When called, the native window will follow the
|
||||
/// movement of the cursor while the primary mouse button is down.
|
||||
///
|
||||
/// Does not work on the web.
|
||||
pub fn drag_window(&mut self) {
|
||||
self.output.drag_window = true;
|
||||
}
|
||||
|
||||
/// for integrations only: call once per frame
|
||||
#[doc(hidden)]
|
||||
pub fn take_app_output(&mut self) -> backend::AppOutput {
|
||||
std::mem::take(&mut self.output)
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about the web environment (if applicable).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WebInfo {
|
||||
/// Information about the URL.
|
||||
pub location: Location,
|
||||
}
|
||||
|
||||
/// Information about the URL.
|
||||
///
|
||||
/// Everything has been percent decoded (`%20` -> ` ` etc).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Location {
|
||||
/// The full URL (`location.href`) without the hash.
|
||||
///
|
||||
/// Example: `"http://www.example.com:80/index.html?foo=bar"`.
|
||||
pub url: String,
|
||||
|
||||
/// `location.protocol`
|
||||
///
|
||||
/// Example: `"http:"`.
|
||||
pub protocol: String,
|
||||
|
||||
/// `location.host`
|
||||
///
|
||||
/// Example: `"example.com:80"`.
|
||||
pub host: String,
|
||||
|
||||
/// `location.hostname`
|
||||
///
|
||||
/// Example: `"example.com"`.
|
||||
pub hostname: String,
|
||||
|
||||
/// `location.port`
|
||||
///
|
||||
/// Example: `"80"`.
|
||||
pub port: String,
|
||||
|
||||
/// The "#fragment" part of "www.example.com/index.html?query#fragment".
|
||||
///
|
||||
/// Note that the leading `#` is included in the string.
|
||||
/// Also known as "hash-link" or "anchor".
|
||||
pub hash: String,
|
||||
|
||||
/// The "query" part of "www.example.com/index.html?query#fragment".
|
||||
///
|
||||
/// Note that the leading `?` is NOT included in the string.
|
||||
///
|
||||
/// Use [`Self::web_query_map]` to get the parsed version of it.
|
||||
pub query: String,
|
||||
|
||||
/// The parsed "query" part of "www.example.com/index.html?query#fragment".
|
||||
///
|
||||
/// "foo=42&bar%20" is parsed as `{"foo": "42", "bar ": ""}`
|
||||
pub query_map: std::collections::BTreeMap<String, String>,
|
||||
|
||||
/// `location.origin`
|
||||
///
|
||||
/// Example: `"http://www.example.com:80"`.
|
||||
pub origin: String,
|
||||
}
|
||||
|
||||
/// Information about the integration passed to the use app each frame.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct IntegrationInfo {
|
||||
/// If the app is running in a Web context, this returns information about the environment.
|
||||
pub web_info: Option<WebInfo>,
|
||||
|
||||
/// Does the system prefer dark mode (over light mode)?
|
||||
/// `None` means "don't know".
|
||||
pub prefer_dark_mode: Option<bool>,
|
||||
|
||||
/// Seconds of cpu usage (in seconds) of UI code on the previous frame.
|
||||
/// `None` if this is the first frame.
|
||||
pub cpu_usage: Option<f32>,
|
||||
|
||||
/// The OS native pixels-per-point
|
||||
pub native_pixels_per_point: Option<f32>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// A place where you can store custom data in a way that persists when you restart the app.
|
||||
///
|
||||
/// On the web this is backed by [local storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage).
|
||||
/// On desktop this is backed by the file system.
|
||||
///
|
||||
/// See [`CreationContext::storage`] and [`App::save`].
|
||||
pub trait Storage {
|
||||
/// Get the value for the given key.
|
||||
fn get_string(&self, key: &str) -> Option<String>;
|
||||
/// Set the value for the given key.
|
||||
fn set_string(&mut self, key: &str, value: String);
|
||||
|
||||
/// write-to-disk or similar
|
||||
fn flush(&mut self);
|
||||
}
|
||||
|
||||
/// Stores nothing.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct DummyStorage {}
|
||||
|
||||
impl Storage for DummyStorage {
|
||||
fn get_string(&self, _key: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn set_string(&mut self, _key: &str, _value: String) {}
|
||||
fn flush(&mut self) {}
|
||||
}
|
||||
|
||||
/// Get and deserialize the [RON](https://github.com/ron-rs/ron) stored at the given key.
|
||||
#[cfg(feature = "ron")]
|
||||
pub fn get_value<T: serde::de::DeserializeOwned>(storage: &dyn Storage, key: &str) -> Option<T> {
|
||||
storage
|
||||
.get_string(key)
|
||||
.and_then(|value| ron::from_str(&value).ok())
|
||||
}
|
||||
|
||||
/// Serialize the given value as [RON](https://github.com/ron-rs/ron) and store with the given key.
|
||||
#[cfg(feature = "ron")]
|
||||
pub fn set_value<T: serde::Serialize>(storage: &mut dyn Storage, key: &str, value: &T) {
|
||||
storage.set_string(key, ron::ser::to_string(value).unwrap());
|
||||
}
|
||||
|
||||
/// [`Storage`] key used for app
|
||||
pub const APP_KEY: &str = "app";
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// You only need to look here if you are writing a backend for `epi`.
|
||||
#[doc(hidden)]
|
||||
pub mod backend {
|
||||
/// Action that can be taken by the user app.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[must_use]
|
||||
pub struct AppOutput {
|
||||
/// Set to `true` to stop the app.
|
||||
/// This does nothing for web apps.
|
||||
pub quit: bool,
|
||||
|
||||
/// Set to some size to resize the outer window (e.g. glium window) to this size.
|
||||
pub window_size: Option<egui::Vec2>,
|
||||
|
||||
/// Set to some string to rename the outer window (e.g. glium window) to this title.
|
||||
pub window_title: Option<String>,
|
||||
|
||||
/// Set to some bool to change window decorations.
|
||||
pub decorated: Option<bool>,
|
||||
|
||||
/// Set to true to drag window while primary mouse button is down.
|
||||
pub drag_window: bool,
|
||||
|
||||
/// Set to some position to move the outer window (e.g. glium window) to this position
|
||||
pub window_pos: Option<egui::Pos2>,
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
//! eframe - the egui framework crate
|
||||
//! eframe - the [`egui`] framework crate
|
||||
//!
|
||||
//! If you are planning to write an app for web or native,
|
||||
//! and are happy with just using egui for all visuals,
|
||||
//! Then `eframe` is for you!
|
||||
//! and want to use [`egui`] for everything, then `eframe` is for you!
|
||||
//!
|
||||
//! To get started, see the [examples](https://github.com/emilk/egui/tree/master/examples).
|
||||
//! To learn how to set up `eframe` for web and native, go to <https://github.com/emilk/eframe_template/> and follow the instructions there!
|
||||
//!
|
||||
//! In short, you implement [`App`] and then
|
||||
//! In short, you implement [`App`] (especially [`App::update`]) and then
|
||||
//! call [`crate::run_native`] from your `main.rs`, and/or call `eframe::start_web` from your `lib.rs`.
|
||||
//!
|
||||
//! `eframe` is implemented using [`egui_web`](https://github.com/emilk/egui/tree/master/egui_web) for web and
|
||||
//! [`egui_glow`](https://github.com/emilk/egui/tree/master/egui_glow) for native.
|
||||
//!
|
||||
//! ## Usage, native:
|
||||
//! ``` no_run
|
||||
//! use eframe::egui;
|
||||
@@ -60,7 +56,9 @@
|
||||
#![allow(clippy::needless_doctest_main)]
|
||||
|
||||
// Re-export all useful libraries:
|
||||
pub use {egui, egui::emath, egui::epaint, epi};
|
||||
pub use {egui, egui::emath, egui::epaint, glow};
|
||||
|
||||
mod epi;
|
||||
|
||||
// Re-export everything in `epi` so `eframe` users don't have to care about what `epi` is:
|
||||
pub use epi::*;
|
||||
@@ -69,7 +67,13 @@ pub use epi::*;
|
||||
// When compiling for web
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use egui_web::wasm_bindgen;
|
||||
mod web;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use wasm_bindgen;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web_sys;
|
||||
|
||||
/// Install event listeners to register different input events
|
||||
/// and start running the given app.
|
||||
@@ -90,7 +94,7 @@ pub use egui_web::wasm_bindgen;
|
||||
/// ```
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn start_web(canvas_id: &str, app_creator: AppCreator) -> Result<(), wasm_bindgen::JsValue> {
|
||||
egui_web::start(canvas_id, app_creator)?;
|
||||
web::start(canvas_id, app_creator)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::epi;
|
||||
use egui_winit::{native_pixels_per_point, WindowSettings};
|
||||
|
||||
pub fn points_to_size(points: egui::Vec2) -> winit::dpi::LogicalSize<f64> {
|
||||
@@ -136,7 +137,7 @@ pub fn handle_app_output(
|
||||
/// For loading/saving app state and/or egui memory to disk.
|
||||
pub fn create_storage(_app_name: &str) -> Option<Box<dyn epi::Storage>> {
|
||||
#[cfg(feature = "persistence")]
|
||||
if let Some(storage) = epi::file_storage::FileStorage::from_app_name(_app_name) {
|
||||
if let Some(storage) = super::file_storage::FileStorage::from_app_name(_app_name) {
|
||||
return Some(Box::new(storage));
|
||||
}
|
||||
None
|
||||
@@ -158,7 +159,6 @@ pub struct EpiIntegration {
|
||||
|
||||
impl EpiIntegration {
|
||||
pub fn new(
|
||||
integration_name: &'static str,
|
||||
gl: std::rc::Rc<glow::Context>,
|
||||
max_texture_side: usize,
|
||||
window: &winit::window::Window,
|
||||
@@ -172,7 +172,6 @@ impl EpiIntegration {
|
||||
|
||||
let frame = epi::Frame {
|
||||
info: epi::IntegrationInfo {
|
||||
name: integration_name,
|
||||
web_info: None,
|
||||
prefer_dark_mode,
|
||||
cpu_usage: None,
|
||||
|
||||
116
eframe/src/native/file_storage.rs
Normal file
116
eframe/src/native/file_storage.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// A key-value store backed by a [RON](https://github.com/ron-rs/ron) file on disk.
|
||||
/// Used to restore egui state, glium window position/size and app state.
|
||||
pub struct FileStorage {
|
||||
ron_filepath: PathBuf,
|
||||
kv: HashMap<String, String>,
|
||||
dirty: bool,
|
||||
last_save_join_handle: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Drop for FileStorage {
|
||||
fn drop(&mut self) {
|
||||
if let Some(join_handle) = self.last_save_join_handle.take() {
|
||||
join_handle.join().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileStorage {
|
||||
/// Store the state in this .ron file.
|
||||
pub fn from_ron_filepath(ron_filepath: impl Into<PathBuf>) -> Self {
|
||||
let ron_filepath: PathBuf = ron_filepath.into();
|
||||
Self {
|
||||
kv: read_ron(&ron_filepath).unwrap_or_default(),
|
||||
ron_filepath,
|
||||
dirty: false,
|
||||
last_save_join_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a good place to put the files that the OS likes.
|
||||
pub fn from_app_name(app_name: &str) -> Option<Self> {
|
||||
if let Some(proj_dirs) = directories_next::ProjectDirs::from("", "", app_name) {
|
||||
let data_dir = proj_dirs.data_dir().to_path_buf();
|
||||
if let Err(err) = std::fs::create_dir_all(&data_dir) {
|
||||
tracing::warn!(
|
||||
"Saving disabled: Failed to create app path at {:?}: {}",
|
||||
data_dir,
|
||||
err
|
||||
);
|
||||
None
|
||||
} else {
|
||||
Some(Self::from_ron_filepath(data_dir.join("app.ron")))
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("Saving disabled: Failed to find path to data_dir.");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Storage for FileStorage {
|
||||
fn get_string(&self, key: &str) -> Option<String> {
|
||||
self.kv.get(key).cloned()
|
||||
}
|
||||
|
||||
fn set_string(&mut self, key: &str, value: String) {
|
||||
if self.kv.get(key) != Some(&value) {
|
||||
self.kv.insert(key.to_owned(), value);
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) {
|
||||
if self.dirty {
|
||||
self.dirty = false;
|
||||
|
||||
let file_path = self.ron_filepath.clone();
|
||||
let kv = self.kv.clone();
|
||||
|
||||
if let Some(join_handle) = self.last_save_join_handle.take() {
|
||||
// wait for previous save to complete.
|
||||
join_handle.join().ok();
|
||||
}
|
||||
|
||||
let join_handle = std::thread::spawn(move || {
|
||||
let file = std::fs::File::create(&file_path).unwrap();
|
||||
let config = Default::default();
|
||||
ron::ser::to_writer_pretty(file, &kv, config).unwrap();
|
||||
tracing::trace!("Persisted to {:?}", file_path);
|
||||
});
|
||||
|
||||
self.last_save_join_handle = Some(join_handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn read_ron<T>(ron_path: impl AsRef<Path>) -> Option<T>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
match std::fs::File::open(ron_path) {
|
||||
Ok(file) => {
|
||||
let reader = std::io::BufReader::new(file);
|
||||
match ron::de::from_reader(reader) {
|
||||
Ok(value) => Some(value),
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to parse RON: {}", err);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_err) => {
|
||||
// File probably doesn't exist. That's fine.
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
mod epi_integration;
|
||||
mod run;
|
||||
|
||||
/// File storage which can be used by native backends.
|
||||
#[cfg(feature = "persistence")]
|
||||
pub mod file_storage;
|
||||
|
||||
pub use run::run;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::epi_integration;
|
||||
use crate::epi;
|
||||
use egui_winit::winit;
|
||||
|
||||
struct RequestRepaintEvent;
|
||||
@@ -50,7 +51,6 @@ pub fn run(app_name: &str, native_options: &epi::NativeOptions, app_creator: epi
|
||||
.unwrap_or_else(|error| panic!("some OpenGL error occurred {}\n", error));
|
||||
|
||||
let mut integration = epi_integration::EpiIntegration::new(
|
||||
"egui_glow",
|
||||
gl.clone(),
|
||||
painter.max_texture_side(),
|
||||
gl_window.window(),
|
||||
|
||||
444
eframe/src/web/backend.rs
Normal file
444
eframe/src/web/backend.rs
Normal file
@@ -0,0 +1,444 @@
|
||||
use super::{glow_wrapping::WrappedGlowPainter, *};
|
||||
|
||||
use crate::epi;
|
||||
|
||||
use egui::TexturesDelta;
|
||||
pub use egui::{pos2, Color32};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Data gathered between frames.
|
||||
#[derive(Default)]
|
||||
pub struct WebInput {
|
||||
/// Required because we don't get a position on touched
|
||||
pub latest_touch_pos: Option<egui::Pos2>,
|
||||
|
||||
/// Required to maintain a stable touch position for multi-touch gestures.
|
||||
pub latest_touch_pos_id: Option<egui::TouchId>,
|
||||
|
||||
pub raw: egui::RawInput,
|
||||
}
|
||||
|
||||
impl WebInput {
|
||||
pub fn new_frame(&mut self, canvas_size: egui::Vec2) -> egui::RawInput {
|
||||
egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(Default::default(), canvas_size)),
|
||||
pixels_per_point: Some(native_pixels_per_point()), // We ALWAYS use the native pixels-per-point
|
||||
time: Some(now_sec()),
|
||||
..self.raw.take()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
pub struct NeedRepaint(std::sync::atomic::AtomicBool);
|
||||
|
||||
impl Default for NeedRepaint {
|
||||
fn default() -> Self {
|
||||
Self(true.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl NeedRepaint {
|
||||
pub fn fetch_and_clear(&self) -> bool {
|
||||
self.0.swap(false, SeqCst)
|
||||
}
|
||||
|
||||
pub fn set_true(&self) {
|
||||
self.0.store(true, SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn web_location() -> epi::Location {
|
||||
let location = web_sys::window().unwrap().location();
|
||||
|
||||
let hash = percent_decode(&location.hash().unwrap_or_default());
|
||||
|
||||
let query = location
|
||||
.search()
|
||||
.unwrap_or_default()
|
||||
.strip_prefix('?')
|
||||
.map(percent_decode)
|
||||
.unwrap_or_default();
|
||||
|
||||
let query_map = parse_query_map(&query)
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
|
||||
.collect();
|
||||
|
||||
epi::Location {
|
||||
url: percent_decode(&location.href().unwrap_or_default()),
|
||||
protocol: percent_decode(&location.protocol().unwrap_or_default()),
|
||||
host: percent_decode(&location.host().unwrap_or_default()),
|
||||
hostname: percent_decode(&location.hostname().unwrap_or_default()),
|
||||
port: percent_decode(&location.port().unwrap_or_default()),
|
||||
hash,
|
||||
query,
|
||||
query_map,
|
||||
origin: percent_decode(&location.origin().unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_query_map(query: &str) -> BTreeMap<&str, &str> {
|
||||
query
|
||||
.split('&')
|
||||
.filter_map(|pair| {
|
||||
if pair.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(if let Some((key, value)) = pair.split_once('=') {
|
||||
(key, value)
|
||||
} else {
|
||||
(pair, "")
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_query() {
|
||||
assert_eq!(parse_query_map(""), BTreeMap::default());
|
||||
assert_eq!(parse_query_map("foo"), BTreeMap::from_iter([("foo", "")]));
|
||||
assert_eq!(
|
||||
parse_query_map("foo=bar"),
|
||||
BTreeMap::from_iter([("foo", "bar")])
|
||||
);
|
||||
assert_eq!(
|
||||
parse_query_map("foo=bar&baz=42"),
|
||||
BTreeMap::from_iter([("foo", "bar"), ("baz", "42")])
|
||||
);
|
||||
assert_eq!(
|
||||
parse_query_map("foo&baz=42"),
|
||||
BTreeMap::from_iter([("foo", ""), ("baz", "42")])
|
||||
);
|
||||
assert_eq!(
|
||||
parse_query_map("foo&baz&&"),
|
||||
BTreeMap::from_iter([("foo", ""), ("baz", "")])
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
pub struct AppRunner {
|
||||
pub(crate) frame: epi::Frame,
|
||||
egui_ctx: egui::Context,
|
||||
painter: WrappedGlowPainter,
|
||||
pub(crate) input: WebInput,
|
||||
app: Box<dyn epi::App>,
|
||||
pub(crate) needs_repaint: std::sync::Arc<NeedRepaint>,
|
||||
last_save_time: f64,
|
||||
screen_reader: super::screen_reader::ScreenReader,
|
||||
pub(crate) text_cursor_pos: Option<egui::Pos2>,
|
||||
pub(crate) mutable_text_under_cursor: bool,
|
||||
textures_delta: TexturesDelta,
|
||||
}
|
||||
|
||||
impl AppRunner {
|
||||
pub fn new(canvas_id: &str, app_creator: epi::AppCreator) -> Result<Self, JsValue> {
|
||||
let painter = WrappedGlowPainter::new(canvas_id).map_err(JsValue::from)?;
|
||||
|
||||
let prefer_dark_mode = super::prefer_dark_mode();
|
||||
|
||||
let frame = epi::Frame {
|
||||
info: epi::IntegrationInfo {
|
||||
web_info: Some(epi::WebInfo {
|
||||
location: web_location(),
|
||||
}),
|
||||
prefer_dark_mode,
|
||||
cpu_usage: None,
|
||||
native_pixels_per_point: Some(native_pixels_per_point()),
|
||||
},
|
||||
output: Default::default(),
|
||||
storage: Some(Box::new(LocalStorage::default())),
|
||||
gl: painter.gl().clone(),
|
||||
};
|
||||
|
||||
let needs_repaint: std::sync::Arc<NeedRepaint> = Default::default();
|
||||
|
||||
let egui_ctx = egui::Context::default();
|
||||
|
||||
{
|
||||
let needs_repaint = needs_repaint.clone();
|
||||
egui_ctx.set_request_repaint_callback(move || {
|
||||
needs_repaint.0.store(true, SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
load_memory(&egui_ctx);
|
||||
if prefer_dark_mode == Some(true) {
|
||||
egui_ctx.set_visuals(egui::Visuals::dark());
|
||||
} else {
|
||||
egui_ctx.set_visuals(egui::Visuals::light());
|
||||
}
|
||||
|
||||
let app = app_creator(&epi::CreationContext {
|
||||
egui_ctx: egui_ctx.clone(),
|
||||
integration_info: frame.info(),
|
||||
storage: frame.storage(),
|
||||
gl: painter.painter.gl().clone(),
|
||||
});
|
||||
|
||||
let mut runner = Self {
|
||||
frame,
|
||||
egui_ctx,
|
||||
painter,
|
||||
input: Default::default(),
|
||||
app,
|
||||
needs_repaint,
|
||||
last_save_time: now_sec(),
|
||||
screen_reader: Default::default(),
|
||||
text_cursor_pos: None,
|
||||
mutable_text_under_cursor: false,
|
||||
textures_delta: Default::default(),
|
||||
};
|
||||
|
||||
runner.input.raw.max_texture_side = Some(runner.painter.max_texture_side());
|
||||
|
||||
Ok(runner)
|
||||
}
|
||||
|
||||
pub fn egui_ctx(&self) -> &egui::Context {
|
||||
&self.egui_ctx
|
||||
}
|
||||
|
||||
pub fn auto_save(&mut self) {
|
||||
let now = now_sec();
|
||||
let time_since_last_save = now - self.last_save_time;
|
||||
|
||||
if time_since_last_save > self.app.auto_save_interval().as_secs_f64() {
|
||||
if self.app.persist_egui_memory() {
|
||||
save_memory(&self.egui_ctx);
|
||||
}
|
||||
if let Some(storage) = self.frame.storage_mut() {
|
||||
self.app.save(storage);
|
||||
}
|
||||
self.last_save_time = now;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn canvas_id(&self) -> &str {
|
||||
self.painter.canvas_id()
|
||||
}
|
||||
|
||||
pub fn warm_up(&mut self) -> Result<(), JsValue> {
|
||||
if self.app.warm_up_enabled() {
|
||||
let saved_memory: egui::Memory = self.egui_ctx.memory().clone();
|
||||
self.egui_ctx.memory().set_everything_is_visible(true);
|
||||
self.logic()?;
|
||||
*self.egui_ctx.memory() = saved_memory; // We don't want to remember that windows were huge.
|
||||
self.egui_ctx.clear_animations();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` if egui requests a repaint.
|
||||
///
|
||||
/// Call [`Self::paint`] later to paint
|
||||
pub fn logic(&mut self) -> Result<(bool, Vec<egui::ClippedPrimitive>), JsValue> {
|
||||
let frame_start = now_sec();
|
||||
|
||||
resize_canvas_to_screen_size(self.canvas_id(), self.app.max_size_points());
|
||||
let canvas_size = canvas_size_in_points(self.canvas_id());
|
||||
let raw_input = self.input.new_frame(canvas_size);
|
||||
|
||||
let full_output = self.egui_ctx.run(raw_input, |egui_ctx| {
|
||||
self.app.update(egui_ctx, &mut self.frame);
|
||||
});
|
||||
let egui::FullOutput {
|
||||
platform_output,
|
||||
needs_repaint,
|
||||
textures_delta,
|
||||
shapes,
|
||||
} = full_output;
|
||||
|
||||
self.handle_platform_output(platform_output);
|
||||
self.textures_delta.append(textures_delta);
|
||||
let clipped_primitives = self.egui_ctx.tessellate(shapes);
|
||||
|
||||
{
|
||||
let app_output = self.frame.take_app_output();
|
||||
let epi::backend::AppOutput {
|
||||
quit: _, // Can't quit a web page
|
||||
window_size: _, // Can't resize a web page
|
||||
window_title: _, // TODO: change title of window
|
||||
decorated: _, // Can't toggle decorations
|
||||
drag_window: _, // Can't be dragged
|
||||
window_pos: _, // Can't set position of a web page
|
||||
} = app_output;
|
||||
}
|
||||
|
||||
self.frame.info.cpu_usage = Some((now_sec() - frame_start) as f32);
|
||||
Ok((needs_repaint, clipped_primitives))
|
||||
}
|
||||
|
||||
pub fn clear_color_buffer(&self) {
|
||||
self.painter.clear(self.app.clear_color());
|
||||
}
|
||||
|
||||
/// Paint the results of the last call to [`Self::logic`].
|
||||
pub fn paint(&mut self, clipped_primitives: &[egui::ClippedPrimitive]) -> Result<(), JsValue> {
|
||||
let textures_delta = std::mem::take(&mut self.textures_delta);
|
||||
|
||||
self.painter.paint_and_update_textures(
|
||||
clipped_primitives,
|
||||
self.egui_ctx.pixels_per_point(),
|
||||
&textures_delta,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_platform_output(&mut self, platform_output: egui::PlatformOutput) {
|
||||
if self.egui_ctx.options().screen_reader {
|
||||
self.screen_reader
|
||||
.speak(&platform_output.events_description());
|
||||
}
|
||||
|
||||
let egui::PlatformOutput {
|
||||
cursor_icon,
|
||||
open_url,
|
||||
copied_text,
|
||||
events: _, // already handled
|
||||
mutable_text_under_cursor,
|
||||
text_cursor_pos,
|
||||
} = platform_output;
|
||||
|
||||
set_cursor_icon(cursor_icon);
|
||||
if let Some(open) = open_url {
|
||||
super::open_url(&open.url, open.new_tab);
|
||||
}
|
||||
|
||||
#[cfg(web_sys_unstable_apis)]
|
||||
if !copied_text.is_empty() {
|
||||
set_clipboard_text(&copied_text);
|
||||
}
|
||||
|
||||
#[cfg(not(web_sys_unstable_apis))]
|
||||
let _ = copied_text;
|
||||
|
||||
self.mutable_text_under_cursor = mutable_text_under_cursor;
|
||||
|
||||
if self.text_cursor_pos != text_cursor_pos {
|
||||
text_agent::move_text_cursor(text_cursor_pos, self.canvas_id());
|
||||
self.text_cursor_pos = text_cursor_pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
pub type AppRunnerRef = Arc<Mutex<AppRunner>>;
|
||||
|
||||
pub struct AppRunnerContainer {
|
||||
pub runner: AppRunnerRef,
|
||||
/// Set to `true` if there is a panic.
|
||||
/// Used to ignore callbacks after a panic.
|
||||
pub panicked: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl AppRunnerContainer {
|
||||
/// Convenience function to reduce boilerplate and ensure that all event handlers
|
||||
/// are dealt with in the same way
|
||||
pub fn add_event_listener<E: wasm_bindgen::JsCast>(
|
||||
&self,
|
||||
target: &EventTarget,
|
||||
event_name: &'static str,
|
||||
mut closure: impl FnMut(E, MutexGuard<'_, AppRunner>) + 'static,
|
||||
) -> Result<(), JsValue> {
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
// Create a JS closure based on the FnMut provided
|
||||
let closure = Closure::wrap({
|
||||
// Clone atomics
|
||||
let runner_ref = self.runner.clone();
|
||||
let panicked = self.panicked.clone();
|
||||
|
||||
Box::new(move |event: web_sys::Event| {
|
||||
// Only call the wrapped closure if the egui code has not panicked
|
||||
if !panicked.load(Ordering::SeqCst) {
|
||||
// Cast the event to the expected event type
|
||||
let event = event.unchecked_into::<E>();
|
||||
|
||||
closure(event, runner_ref.lock());
|
||||
}
|
||||
}) as Box<dyn FnMut(_)>
|
||||
});
|
||||
|
||||
// Add the event listener to the target
|
||||
target.add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref())?;
|
||||
|
||||
// Bypass closure drop so that event handler can call the closure
|
||||
closure.forget();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Install event listeners to register different input events
|
||||
/// and start running the given app.
|
||||
pub fn start(canvas_id: &str, app_creator: epi::AppCreator) -> Result<AppRunnerRef, JsValue> {
|
||||
let mut runner = AppRunner::new(canvas_id, app_creator)?;
|
||||
runner.warm_up()?;
|
||||
start_runner(runner)
|
||||
}
|
||||
|
||||
/// Install event listeners to register different input events
|
||||
/// and starts running the given [`AppRunner`].
|
||||
fn start_runner(app_runner: AppRunner) -> Result<AppRunnerRef, JsValue> {
|
||||
let runner_container = AppRunnerContainer {
|
||||
runner: Arc::new(Mutex::new(app_runner)),
|
||||
panicked: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
|
||||
super::events::install_canvas_events(&runner_container)?;
|
||||
super::events::install_document_events(&runner_container)?;
|
||||
text_agent::install_text_agent(&runner_container)?;
|
||||
super::events::repaint_every_ms(&runner_container, 1000)?; // just in case. TODO: make it a parameter
|
||||
|
||||
super::events::paint_and_schedule(&runner_container.runner, runner_container.panicked.clone())?;
|
||||
|
||||
// Disable all event handlers on panic
|
||||
std::panic::set_hook(Box::new({
|
||||
let previous_hook = std::panic::take_hook();
|
||||
|
||||
let panicked = runner_container.panicked;
|
||||
|
||||
move |panic_info| {
|
||||
tracing::info_span!("egui_panic_handler").in_scope(|| {
|
||||
tracing::trace!("setting panicked flag");
|
||||
|
||||
panicked.store(true, SeqCst);
|
||||
|
||||
tracing::info!("egui disabled all event handlers due to panic");
|
||||
});
|
||||
|
||||
// Propagate panic info to the previously registered panic hook
|
||||
previous_hook(panic_info);
|
||||
}
|
||||
}));
|
||||
|
||||
Ok(runner_container.runner)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct LocalStorage {}
|
||||
|
||||
impl epi::Storage for LocalStorage {
|
||||
fn get_string(&self, key: &str) -> Option<String> {
|
||||
local_storage_get(key)
|
||||
}
|
||||
fn set_string(&mut self, key: &str, value: String) {
|
||||
local_storage_set(key, &value);
|
||||
}
|
||||
fn flush(&mut self) {}
|
||||
}
|
||||
544
eframe/src/web/events.rs
Normal file
544
eframe/src/web/events.rs
Normal file
@@ -0,0 +1,544 @@
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
pub fn paint_and_schedule(
|
||||
runner_ref: &AppRunnerRef,
|
||||
panicked: Arc<AtomicBool>,
|
||||
) -> Result<(), JsValue> {
|
||||
fn paint_if_needed(runner_ref: &AppRunnerRef) -> Result<(), JsValue> {
|
||||
let mut runner_lock = runner_ref.lock();
|
||||
if runner_lock.needs_repaint.fetch_and_clear() {
|
||||
runner_lock.clear_color_buffer();
|
||||
let (needs_repaint, clipped_primitives) = runner_lock.logic()?;
|
||||
runner_lock.paint(&clipped_primitives)?;
|
||||
if needs_repaint {
|
||||
runner_lock.needs_repaint.set_true();
|
||||
}
|
||||
runner_lock.auto_save();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn request_animation_frame(
|
||||
runner_ref: AppRunnerRef,
|
||||
panicked: Arc<AtomicBool>,
|
||||
) -> Result<(), JsValue> {
|
||||
use wasm_bindgen::JsCast;
|
||||
let window = web_sys::window().unwrap();
|
||||
let closure = Closure::once(move || paint_and_schedule(&runner_ref, panicked));
|
||||
window.request_animation_frame(closure.as_ref().unchecked_ref())?;
|
||||
closure.forget(); // We must forget it, or else the callback is canceled on drop
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Only paint and schedule if there has been no panic
|
||||
if !panicked.load(Ordering::SeqCst) {
|
||||
paint_if_needed(runner_ref)?;
|
||||
request_animation_frame(runner_ref.clone(), panicked)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn install_document_events(runner_container: &AppRunnerContainer) -> Result<(), JsValue> {
|
||||
let window = web_sys::window().unwrap();
|
||||
let document = window.document().unwrap();
|
||||
|
||||
runner_container.add_event_listener(
|
||||
&document,
|
||||
"keydown",
|
||||
|event: web_sys::KeyboardEvent, mut runner_lock| {
|
||||
if event.is_composing() || event.key_code() == 229 {
|
||||
// https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/
|
||||
return;
|
||||
}
|
||||
|
||||
let modifiers = modifiers_from_event(&event);
|
||||
runner_lock.input.raw.modifiers = modifiers;
|
||||
|
||||
let key = event.key();
|
||||
|
||||
if let Some(key) = translate_key(&key) {
|
||||
runner_lock.input.raw.events.push(egui::Event::Key {
|
||||
key,
|
||||
pressed: true,
|
||||
modifiers,
|
||||
});
|
||||
}
|
||||
if !modifiers.ctrl
|
||||
&& !modifiers.command
|
||||
&& !should_ignore_key(&key)
|
||||
// When text agent is shown, it sends text event instead.
|
||||
&& text_agent::text_agent().hidden()
|
||||
{
|
||||
runner_lock.input.raw.events.push(egui::Event::Text(key));
|
||||
}
|
||||
runner_lock.needs_repaint.set_true();
|
||||
|
||||
let egui_wants_keyboard = runner_lock.egui_ctx().wants_keyboard_input();
|
||||
|
||||
let prevent_default = if matches!(event.key().as_str(), "Tab") {
|
||||
// Always prevent moving cursor to url bar.
|
||||
// egui wants to use tab to move to the next text field.
|
||||
true
|
||||
} else if egui_wants_keyboard {
|
||||
matches!(
|
||||
event.key().as_str(),
|
||||
"Backspace" // so we don't go back to previous page when deleting text
|
||||
| "ArrowDown" | "ArrowLeft" | "ArrowRight" | "ArrowUp" // cmd-left is "back" on Mac (https://github.com/emilk/egui/issues/58)
|
||||
)
|
||||
} else {
|
||||
// We never want to prevent:
|
||||
// * F5 / cmd-R (refresh)
|
||||
// * cmd-shift-C (debug tools)
|
||||
// * cmd/ctrl-c/v/x (or we stop copy/past/cut events)
|
||||
false
|
||||
};
|
||||
|
||||
// tracing::debug!(
|
||||
// "On key-down {:?}, egui_wants_keyboard: {}, prevent_default: {}",
|
||||
// event.key().as_str(),
|
||||
// egui_wants_keyboard,
|
||||
// prevent_default
|
||||
// );
|
||||
|
||||
if prevent_default {
|
||||
event.prevent_default();
|
||||
}
|
||||
},
|
||||
)?;
|
||||
|
||||
runner_container.add_event_listener(
|
||||
&document,
|
||||
"keyup",
|
||||
|event: web_sys::KeyboardEvent, mut runner_lock| {
|
||||
let modifiers = modifiers_from_event(&event);
|
||||
runner_lock.input.raw.modifiers = modifiers;
|
||||
if let Some(key) = translate_key(&event.key()) {
|
||||
runner_lock.input.raw.events.push(egui::Event::Key {
|
||||
key,
|
||||
pressed: false,
|
||||
modifiers,
|
||||
});
|
||||
}
|
||||
runner_lock.needs_repaint.set_true();
|
||||
},
|
||||
)?;
|
||||
|
||||
#[cfg(web_sys_unstable_apis)]
|
||||
runner_container.add_event_listener(
|
||||
&document,
|
||||
"paste",
|
||||
|event: web_sys::ClipboardEvent, mut runner_lock| {
|
||||
if let Some(data) = event.clipboard_data() {
|
||||
if let Ok(text) = data.get_data("text") {
|
||||
let text = text.replace("\r\n", "\n");
|
||||
if !text.is_empty() {
|
||||
runner_lock.input.raw.events.push(egui::Event::Paste(text));
|
||||
runner_lock.needs_repaint.set_true();
|
||||
}
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
}
|
||||
}
|
||||
},
|
||||
)?;
|
||||
|
||||
#[cfg(web_sys_unstable_apis)]
|
||||
runner_container.add_event_listener(
|
||||
&document,
|
||||
"cut",
|
||||
|_: web_sys::ClipboardEvent, mut runner_lock| {
|
||||
runner_lock.input.raw.events.push(egui::Event::Cut);
|
||||
runner_lock.needs_repaint.set_true();
|
||||
},
|
||||
)?;
|
||||
|
||||
#[cfg(web_sys_unstable_apis)]
|
||||
runner_container.add_event_listener(
|
||||
&document,
|
||||
"copy",
|
||||
|_: web_sys::ClipboardEvent, mut runner_lock| {
|
||||
runner_lock.input.raw.events.push(egui::Event::Copy);
|
||||
runner_lock.needs_repaint.set_true();
|
||||
},
|
||||
)?;
|
||||
|
||||
for event_name in &["load", "pagehide", "pageshow", "resize"] {
|
||||
runner_container.add_event_listener(
|
||||
&window,
|
||||
event_name,
|
||||
|_: web_sys::Event, runner_lock| {
|
||||
runner_lock.needs_repaint.set_true();
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
runner_container.add_event_listener(
|
||||
&window,
|
||||
"hashchange",
|
||||
|_: web_sys::Event, mut runner_lock| {
|
||||
// `epi::Frame::info(&self)` clones `epi::IntegrationInfo`, but we need to modify the original here
|
||||
if let Some(web_info) = &mut runner_lock.frame.info.web_info {
|
||||
web_info.location.hash = location_hash();
|
||||
}
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Repaint at least every `ms` milliseconds.
|
||||
pub fn repaint_every_ms(
|
||||
runner_container: &AppRunnerContainer,
|
||||
milliseconds: i32,
|
||||
) -> Result<(), JsValue> {
|
||||
assert!(milliseconds >= 0);
|
||||
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
|
||||
let closure = Closure::wrap(Box::new({
|
||||
let runner = runner_container.runner.clone();
|
||||
let panicked = runner_container.panicked.clone();
|
||||
|
||||
move || {
|
||||
// Do not lock the runner if the code has panicked
|
||||
if !panicked.load(Ordering::SeqCst) {
|
||||
runner.lock().needs_repaint.set_true();
|
||||
}
|
||||
}
|
||||
}) as Box<dyn FnMut()>);
|
||||
|
||||
window.set_interval_with_callback_and_timeout_and_arguments_0(
|
||||
closure.as_ref().unchecked_ref(),
|
||||
milliseconds,
|
||||
)?;
|
||||
|
||||
closure.forget();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn install_canvas_events(runner_container: &AppRunnerContainer) -> Result<(), JsValue> {
|
||||
use wasm_bindgen::JsCast;
|
||||
let canvas = canvas_element(runner_container.runner.lock().canvas_id()).unwrap();
|
||||
|
||||
{
|
||||
// By default, right-clicks open a context menu.
|
||||
// We don't want to do that (right clicks is handled by egui):
|
||||
let event_name = "contextmenu";
|
||||
let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
|
||||
event.prevent_default();
|
||||
}) as Box<dyn FnMut(_)>);
|
||||
canvas.add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref())?;
|
||||
closure.forget();
|
||||
}
|
||||
|
||||
runner_container.add_event_listener(
|
||||
&canvas,
|
||||
"mousedown",
|
||||
|event: web_sys::MouseEvent, mut runner_lock| {
|
||||
if let Some(button) = button_from_mouse_event(&event) {
|
||||
let pos = pos_from_mouse_event(runner_lock.canvas_id(), &event);
|
||||
let modifiers = runner_lock.input.raw.modifiers;
|
||||
runner_lock
|
||||
.input
|
||||
.raw
|
||||
.events
|
||||
.push(egui::Event::PointerButton {
|
||||
pos,
|
||||
button,
|
||||
pressed: true,
|
||||
modifiers,
|
||||
});
|
||||
runner_lock.needs_repaint.set_true();
|
||||
}
|
||||
event.stop_propagation();
|
||||
// Note: prevent_default breaks VSCode tab focusing, hence why we don't call it here.
|
||||
},
|
||||
)?;
|
||||
|
||||
runner_container.add_event_listener(
|
||||
&canvas,
|
||||
"mousemove",
|
||||
|event: web_sys::MouseEvent, mut runner_lock| {
|
||||
let pos = pos_from_mouse_event(runner_lock.canvas_id(), &event);
|
||||
runner_lock
|
||||
.input
|
||||
.raw
|
||||
.events
|
||||
.push(egui::Event::PointerMoved(pos));
|
||||
runner_lock.needs_repaint.set_true();
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
},
|
||||
)?;
|
||||
|
||||
runner_container.add_event_listener(
|
||||
&canvas,
|
||||
"mouseup",
|
||||
|event: web_sys::MouseEvent, mut runner_lock| {
|
||||
if let Some(button) = button_from_mouse_event(&event) {
|
||||
let pos = pos_from_mouse_event(runner_lock.canvas_id(), &event);
|
||||
let modifiers = runner_lock.input.raw.modifiers;
|
||||
runner_lock
|
||||
.input
|
||||
.raw
|
||||
.events
|
||||
.push(egui::Event::PointerButton {
|
||||
pos,
|
||||
button,
|
||||
pressed: false,
|
||||
modifiers,
|
||||
});
|
||||
runner_lock.needs_repaint.set_true();
|
||||
|
||||
text_agent::update_text_agent(runner_lock);
|
||||
}
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
},
|
||||
)?;
|
||||
|
||||
runner_container.add_event_listener(
|
||||
&canvas,
|
||||
"mouseleave",
|
||||
|event: web_sys::MouseEvent, mut runner_lock| {
|
||||
runner_lock.input.raw.events.push(egui::Event::PointerGone);
|
||||
runner_lock.needs_repaint.set_true();
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
},
|
||||
)?;
|
||||
|
||||
runner_container.add_event_listener(
|
||||
&canvas,
|
||||
"touchstart",
|
||||
|event: web_sys::TouchEvent, mut runner_lock| {
|
||||
let mut latest_touch_pos_id = runner_lock.input.latest_touch_pos_id;
|
||||
let pos =
|
||||
pos_from_touch_event(runner_lock.canvas_id(), &event, &mut latest_touch_pos_id);
|
||||
runner_lock.input.latest_touch_pos_id = latest_touch_pos_id;
|
||||
runner_lock.input.latest_touch_pos = Some(pos);
|
||||
let modifiers = runner_lock.input.raw.modifiers;
|
||||
runner_lock
|
||||
.input
|
||||
.raw
|
||||
.events
|
||||
.push(egui::Event::PointerButton {
|
||||
pos,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers,
|
||||
});
|
||||
|
||||
push_touches(&mut *runner_lock, egui::TouchPhase::Start, &event);
|
||||
runner_lock.needs_repaint.set_true();
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
},
|
||||
)?;
|
||||
|
||||
runner_container.add_event_listener(
|
||||
&canvas,
|
||||
"touchmove",
|
||||
|event: web_sys::TouchEvent, mut runner_lock| {
|
||||
let mut latest_touch_pos_id = runner_lock.input.latest_touch_pos_id;
|
||||
let pos =
|
||||
pos_from_touch_event(runner_lock.canvas_id(), &event, &mut latest_touch_pos_id);
|
||||
runner_lock.input.latest_touch_pos_id = latest_touch_pos_id;
|
||||
runner_lock.input.latest_touch_pos = Some(pos);
|
||||
runner_lock
|
||||
.input
|
||||
.raw
|
||||
.events
|
||||
.push(egui::Event::PointerMoved(pos));
|
||||
|
||||
push_touches(&mut *runner_lock, egui::TouchPhase::Move, &event);
|
||||
runner_lock.needs_repaint.set_true();
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
},
|
||||
)?;
|
||||
|
||||
runner_container.add_event_listener(
|
||||
&canvas,
|
||||
"touchend",
|
||||
|event: web_sys::TouchEvent, mut runner_lock| {
|
||||
if let Some(pos) = runner_lock.input.latest_touch_pos {
|
||||
let modifiers = runner_lock.input.raw.modifiers;
|
||||
// First release mouse to click:
|
||||
runner_lock
|
||||
.input
|
||||
.raw
|
||||
.events
|
||||
.push(egui::Event::PointerButton {
|
||||
pos,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers,
|
||||
});
|
||||
// Then remove hover effect:
|
||||
runner_lock.input.raw.events.push(egui::Event::PointerGone);
|
||||
|
||||
push_touches(&mut *runner_lock, egui::TouchPhase::End, &event);
|
||||
runner_lock.needs_repaint.set_true();
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
}
|
||||
|
||||
// Finally, focus or blur text agent to toggle mobile keyboard:
|
||||
text_agent::update_text_agent(runner_lock);
|
||||
},
|
||||
)?;
|
||||
|
||||
runner_container.add_event_listener(
|
||||
&canvas,
|
||||
"touchcancel",
|
||||
|event: web_sys::TouchEvent, mut runner_lock| {
|
||||
push_touches(&mut runner_lock, egui::TouchPhase::Cancel, &event);
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
},
|
||||
)?;
|
||||
|
||||
runner_container.add_event_listener(
|
||||
&canvas,
|
||||
"wheel",
|
||||
|event: web_sys::WheelEvent, mut runner_lock| {
|
||||
let scroll_multiplier = match event.delta_mode() {
|
||||
web_sys::WheelEvent::DOM_DELTA_PAGE => {
|
||||
canvas_size_in_points(runner_lock.canvas_id()).y
|
||||
}
|
||||
web_sys::WheelEvent::DOM_DELTA_LINE => {
|
||||
#[allow(clippy::let_and_return)]
|
||||
let points_per_scroll_line = 8.0; // Note that this is intentionally different from what we use in egui_glium / winit.
|
||||
points_per_scroll_line
|
||||
}
|
||||
_ => 1.0, // DOM_DELTA_PIXEL
|
||||
};
|
||||
|
||||
let mut delta =
|
||||
-scroll_multiplier * egui::vec2(event.delta_x() as f32, event.delta_y() as f32);
|
||||
|
||||
// Report a zoom event in case CTRL (on Windows or Linux) or CMD (on Mac) is pressed.
|
||||
// This if-statement is equivalent to how `Modifiers.command` is determined in
|
||||
// `modifiers_from_event()`, but we cannot directly use that fn for a [`WheelEvent`].
|
||||
if event.ctrl_key() || event.meta_key() {
|
||||
let factor = (delta.y / 200.0).exp();
|
||||
runner_lock.input.raw.events.push(egui::Event::Zoom(factor));
|
||||
} else {
|
||||
if event.shift_key() {
|
||||
// Treat as horizontal scrolling.
|
||||
// Note: one Mac we already get horizontal scroll events when shift is down.
|
||||
delta = egui::vec2(delta.x + delta.y, 0.0);
|
||||
}
|
||||
|
||||
runner_lock
|
||||
.input
|
||||
.raw
|
||||
.events
|
||||
.push(egui::Event::Scroll(delta));
|
||||
}
|
||||
|
||||
runner_lock.needs_repaint.set_true();
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
},
|
||||
)?;
|
||||
|
||||
runner_container.add_event_listener(
|
||||
&canvas,
|
||||
"dragover",
|
||||
|event: web_sys::DragEvent, mut runner_lock| {
|
||||
if let Some(data_transfer) = event.data_transfer() {
|
||||
runner_lock.input.raw.hovered_files.clear();
|
||||
for i in 0..data_transfer.items().length() {
|
||||
if let Some(item) = data_transfer.items().get(i) {
|
||||
runner_lock.input.raw.hovered_files.push(egui::HoveredFile {
|
||||
mime: item.type_(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
runner_lock.needs_repaint.set_true();
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
}
|
||||
},
|
||||
)?;
|
||||
|
||||
runner_container.add_event_listener(
|
||||
&canvas,
|
||||
"dragleave",
|
||||
|event: web_sys::DragEvent, mut runner_lock| {
|
||||
runner_lock.input.raw.hovered_files.clear();
|
||||
runner_lock.needs_repaint.set_true();
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
},
|
||||
)?;
|
||||
|
||||
runner_container.add_event_listener(&canvas, "drop", {
|
||||
let runner_ref = runner_container.runner.clone();
|
||||
|
||||
move |event: web_sys::DragEvent, mut runner_lock| {
|
||||
if let Some(data_transfer) = event.data_transfer() {
|
||||
runner_lock.input.raw.hovered_files.clear();
|
||||
runner_lock.needs_repaint.set_true();
|
||||
// Unlock the runner so it can be locked after a future await point
|
||||
drop(runner_lock);
|
||||
|
||||
if let Some(files) = data_transfer.files() {
|
||||
for i in 0..files.length() {
|
||||
if let Some(file) = files.get(i) {
|
||||
let name = file.name();
|
||||
let last_modified = std::time::UNIX_EPOCH
|
||||
+ std::time::Duration::from_millis(file.last_modified() as u64);
|
||||
|
||||
tracing::debug!("Loading {:?} ({} bytes)…", name, file.size());
|
||||
|
||||
let future = wasm_bindgen_futures::JsFuture::from(file.array_buffer());
|
||||
|
||||
let runner_ref = runner_ref.clone();
|
||||
let future = async move {
|
||||
match future.await {
|
||||
Ok(array_buffer) => {
|
||||
let bytes = js_sys::Uint8Array::new(&array_buffer).to_vec();
|
||||
tracing::debug!(
|
||||
"Loaded {:?} ({} bytes).",
|
||||
name,
|
||||
bytes.len()
|
||||
);
|
||||
|
||||
// Re-lock the mutex on the other side of the await point
|
||||
let mut runner_lock = runner_ref.lock();
|
||||
runner_lock.input.raw.dropped_files.push(
|
||||
egui::DroppedFile {
|
||||
name,
|
||||
last_modified: Some(last_modified),
|
||||
bytes: Some(bytes.into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
runner_lock.needs_repaint.set_true();
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to read file: {:?}", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(future);
|
||||
}
|
||||
}
|
||||
}
|
||||
event.stop_propagation();
|
||||
event.prevent_default();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
158
eframe/src/web/glow_wrapping.rs
Normal file
158
eframe/src/web/glow_wrapping.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
use egui::{ClippedPrimitive, Rgba};
|
||||
use egui_glow::glow;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::JsValue;
|
||||
use web_sys::HtmlCanvasElement;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use web_sys::{WebGl2RenderingContext, WebGlRenderingContext};
|
||||
|
||||
pub(crate) struct WrappedGlowPainter {
|
||||
pub(crate) canvas: HtmlCanvasElement,
|
||||
pub(crate) canvas_id: String,
|
||||
pub(crate) painter: egui_glow::Painter,
|
||||
}
|
||||
|
||||
impl WrappedGlowPainter {
|
||||
pub fn new(canvas_id: &str) -> Result<Self, String> {
|
||||
let canvas = super::canvas_element_or_die(canvas_id);
|
||||
|
||||
let (gl, shader_prefix) = init_glow_context_from_canvas(&canvas)?;
|
||||
let gl = std::rc::Rc::new(gl);
|
||||
|
||||
let dimension = [canvas.width() as i32, canvas.height() as i32];
|
||||
let painter = egui_glow::Painter::new(gl, Some(dimension), shader_prefix)
|
||||
.map_err(|error| format!("Error starting glow painter: {}", error))?;
|
||||
|
||||
Ok(Self {
|
||||
canvas,
|
||||
canvas_id: canvas_id.to_owned(),
|
||||
painter,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl WrappedGlowPainter {
|
||||
pub fn gl(&self) -> &std::rc::Rc<glow::Context> {
|
||||
self.painter.gl()
|
||||
}
|
||||
|
||||
pub fn max_texture_side(&self) -> usize {
|
||||
self.painter.max_texture_side()
|
||||
}
|
||||
|
||||
pub fn canvas_id(&self) -> &str {
|
||||
&self.canvas_id
|
||||
}
|
||||
|
||||
pub fn set_texture(&mut self, tex_id: egui::TextureId, delta: &egui::epaint::ImageDelta) {
|
||||
self.painter.set_texture(tex_id, delta);
|
||||
}
|
||||
|
||||
pub fn free_texture(&mut self, tex_id: egui::TextureId) {
|
||||
self.painter.free_texture(tex_id);
|
||||
}
|
||||
|
||||
pub fn clear(&self, clear_color: Rgba) {
|
||||
let canvas_dimension = [self.canvas.width(), self.canvas.height()];
|
||||
egui_glow::painter::clear(self.painter.gl(), canvas_dimension, clear_color);
|
||||
}
|
||||
|
||||
pub fn paint_primitives(
|
||||
&mut self,
|
||||
clipped_primitives: &[ClippedPrimitive],
|
||||
pixels_per_point: f32,
|
||||
) -> Result<(), JsValue> {
|
||||
let canvas_dimension = [self.canvas.width(), self.canvas.height()];
|
||||
self.painter
|
||||
.paint_primitives(canvas_dimension, pixels_per_point, clipped_primitives);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn paint_and_update_textures(
|
||||
&mut self,
|
||||
clipped_primitives: &[egui::ClippedPrimitive],
|
||||
pixels_per_point: f32,
|
||||
textures_delta: &egui::TexturesDelta,
|
||||
) -> Result<(), JsValue> {
|
||||
for (id, image_delta) in &textures_delta.set {
|
||||
self.set_texture(*id, image_delta);
|
||||
}
|
||||
|
||||
self.paint_primitives(clipped_primitives, pixels_per_point)?;
|
||||
|
||||
for &id in &textures_delta.free {
|
||||
self.free_texture(id);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns glow context and shader prefix.
|
||||
fn init_glow_context_from_canvas(
|
||||
canvas: &HtmlCanvasElement,
|
||||
) -> Result<(glow::Context, &'static str), String> {
|
||||
const BEST_FIRST: bool = true;
|
||||
|
||||
let result = if BEST_FIRST {
|
||||
// Trying WebGl2 first
|
||||
init_webgl2(canvas).or_else(|| init_webgl1(canvas))
|
||||
} else {
|
||||
// Trying WebGl1 first (useful for testing).
|
||||
tracing::warn!("Looking for WebGL1 first");
|
||||
init_webgl1(canvas).or_else(|| init_webgl2(canvas))
|
||||
};
|
||||
|
||||
if let Some(result) = result {
|
||||
Ok(result)
|
||||
} else {
|
||||
Err("WebGL isn't supported".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn init_webgl1(canvas: &HtmlCanvasElement) -> Option<(glow::Context, &'static str)> {
|
||||
let gl1_ctx = canvas
|
||||
.get_context("webgl")
|
||||
.expect("Failed to query about WebGL2 context");
|
||||
|
||||
let gl1_ctx = gl1_ctx?;
|
||||
tracing::debug!("WebGL1 selected.");
|
||||
|
||||
let gl1_ctx = gl1_ctx
|
||||
.dyn_into::<web_sys::WebGlRenderingContext>()
|
||||
.unwrap();
|
||||
|
||||
let shader_prefix = if super::webgl1_requires_brightening(&gl1_ctx) {
|
||||
tracing::debug!("Enabling webkitGTK brightening workaround.");
|
||||
"#define APPLY_BRIGHTENING_GAMMA"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let gl = glow::Context::from_webgl1_context(gl1_ctx);
|
||||
|
||||
Some((gl, shader_prefix))
|
||||
}
|
||||
|
||||
fn init_webgl2(canvas: &HtmlCanvasElement) -> Option<(glow::Context, &'static str)> {
|
||||
let gl2_ctx = canvas
|
||||
.get_context("webgl2")
|
||||
.expect("Failed to query about WebGL2 context");
|
||||
|
||||
let gl2_ctx = gl2_ctx?;
|
||||
tracing::debug!("WebGL2 selected.");
|
||||
|
||||
let gl2_ctx = gl2_ctx
|
||||
.dyn_into::<web_sys::WebGl2RenderingContext>()
|
||||
.unwrap();
|
||||
let gl = glow::Context::from_webgl2_context(gl2_ctx);
|
||||
let shader_prefix = "";
|
||||
|
||||
Some((gl, shader_prefix))
|
||||
}
|
||||
|
||||
trait DummyWebGLConstructor {
|
||||
fn from_webgl1_context(context: web_sys::WebGlRenderingContext) -> Self;
|
||||
|
||||
fn from_webgl2_context(context: web_sys::WebGl2RenderingContext) -> Self;
|
||||
}
|
||||
189
eframe/src/web/input.rs
Normal file
189
eframe/src/web/input.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use super::{canvas_element, canvas_origin, AppRunner};
|
||||
|
||||
pub fn pos_from_mouse_event(canvas_id: &str, event: &web_sys::MouseEvent) -> egui::Pos2 {
|
||||
let canvas = canvas_element(canvas_id).unwrap();
|
||||
let rect = canvas.get_bounding_client_rect();
|
||||
egui::Pos2 {
|
||||
x: event.client_x() as f32 - rect.left() as f32,
|
||||
y: event.client_y() as f32 - rect.top() as f32,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn button_from_mouse_event(event: &web_sys::MouseEvent) -> Option<egui::PointerButton> {
|
||||
match event.button() {
|
||||
0 => Some(egui::PointerButton::Primary),
|
||||
1 => Some(egui::PointerButton::Middle),
|
||||
2 => Some(egui::PointerButton::Secondary),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A single touch is translated to a pointer movement. When a second touch is added, the pointer
|
||||
/// should not jump to a different position. Therefore, we do not calculate the average position
|
||||
/// of all touches, but we keep using the same touch as long as it is available.
|
||||
///
|
||||
/// `touch_id_for_pos` is the [`TouchId`](egui::TouchId) of the [`Touch`](web_sys::Touch) we previously used to determine the
|
||||
/// pointer position.
|
||||
pub fn pos_from_touch_event(
|
||||
canvas_id: &str,
|
||||
event: &web_sys::TouchEvent,
|
||||
touch_id_for_pos: &mut Option<egui::TouchId>,
|
||||
) -> egui::Pos2 {
|
||||
let touch_for_pos = if let Some(touch_id_for_pos) = touch_id_for_pos {
|
||||
// search for the touch we previously used for the position
|
||||
// (unfortunately, `event.touches()` is not a rust collection):
|
||||
(0..event.touches().length())
|
||||
.into_iter()
|
||||
.map(|i| event.touches().get(i).unwrap())
|
||||
.find(|touch| egui::TouchId::from(touch.identifier()) == *touch_id_for_pos)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Use the touch found above or pick the first, or return a default position if there is no
|
||||
// touch at all. (The latter is not expected as the current method is only called when there is
|
||||
// at least one touch.)
|
||||
touch_for_pos
|
||||
.or_else(|| event.touches().get(0))
|
||||
.map_or(Default::default(), |touch| {
|
||||
*touch_id_for_pos = Some(egui::TouchId::from(touch.identifier()));
|
||||
pos_from_touch(canvas_origin(canvas_id), &touch)
|
||||
})
|
||||
}
|
||||
|
||||
fn pos_from_touch(canvas_origin: egui::Pos2, touch: &web_sys::Touch) -> egui::Pos2 {
|
||||
egui::Pos2 {
|
||||
x: touch.page_x() as f32 - canvas_origin.x as f32,
|
||||
y: touch.page_y() as f32 - canvas_origin.y as f32,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_touches(runner: &mut AppRunner, phase: egui::TouchPhase, event: &web_sys::TouchEvent) {
|
||||
let canvas_origin = canvas_origin(runner.canvas_id());
|
||||
for touch_idx in 0..event.changed_touches().length() {
|
||||
if let Some(touch) = event.changed_touches().item(touch_idx) {
|
||||
runner.input.raw.events.push(egui::Event::Touch {
|
||||
device_id: egui::TouchDeviceId(0),
|
||||
id: egui::TouchId::from(touch.identifier()),
|
||||
phase,
|
||||
pos: pos_from_touch(canvas_origin, &touch),
|
||||
force: touch.force(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Web sends all keys as strings, so it is up to us to figure out if it is
|
||||
/// a real text input or the name of a key.
|
||||
pub fn should_ignore_key(key: &str) -> bool {
|
||||
let is_function_key = key.starts_with('F') && key.len() > 1;
|
||||
is_function_key
|
||||
|| matches!(
|
||||
key,
|
||||
"Alt"
|
||||
| "ArrowDown"
|
||||
| "ArrowLeft"
|
||||
| "ArrowRight"
|
||||
| "ArrowUp"
|
||||
| "Backspace"
|
||||
| "CapsLock"
|
||||
| "ContextMenu"
|
||||
| "Control"
|
||||
| "Delete"
|
||||
| "End"
|
||||
| "Enter"
|
||||
| "Esc"
|
||||
| "Escape"
|
||||
| "GroupNext" // https://github.com/emilk/egui/issues/510
|
||||
| "Help"
|
||||
| "Home"
|
||||
| "Insert"
|
||||
| "Meta"
|
||||
| "NumLock"
|
||||
| "PageDown"
|
||||
| "PageUp"
|
||||
| "Pause"
|
||||
| "ScrollLock"
|
||||
| "Shift"
|
||||
| "Tab"
|
||||
)
|
||||
}
|
||||
|
||||
/// Web sends all all keys as strings, so it is up to us to figure out if it is
|
||||
/// a real text input or the name of a key.
|
||||
pub fn translate_key(key: &str) -> Option<egui::Key> {
|
||||
match key {
|
||||
"ArrowDown" => Some(egui::Key::ArrowDown),
|
||||
"ArrowLeft" => Some(egui::Key::ArrowLeft),
|
||||
"ArrowRight" => Some(egui::Key::ArrowRight),
|
||||
"ArrowUp" => Some(egui::Key::ArrowUp),
|
||||
|
||||
"Esc" | "Escape" => Some(egui::Key::Escape),
|
||||
"Tab" => Some(egui::Key::Tab),
|
||||
"Backspace" => Some(egui::Key::Backspace),
|
||||
"Enter" => Some(egui::Key::Enter),
|
||||
"Space" | " " => Some(egui::Key::Space),
|
||||
|
||||
"Help" | "Insert" => Some(egui::Key::Insert),
|
||||
"Delete" => Some(egui::Key::Delete),
|
||||
"Home" => Some(egui::Key::Home),
|
||||
"End" => Some(egui::Key::End),
|
||||
"PageUp" => Some(egui::Key::PageUp),
|
||||
"PageDown" => Some(egui::Key::PageDown),
|
||||
|
||||
"0" => Some(egui::Key::Num0),
|
||||
"1" => Some(egui::Key::Num1),
|
||||
"2" => Some(egui::Key::Num2),
|
||||
"3" => Some(egui::Key::Num3),
|
||||
"4" => Some(egui::Key::Num4),
|
||||
"5" => Some(egui::Key::Num5),
|
||||
"6" => Some(egui::Key::Num6),
|
||||
"7" => Some(egui::Key::Num7),
|
||||
"8" => Some(egui::Key::Num8),
|
||||
"9" => Some(egui::Key::Num9),
|
||||
|
||||
"a" | "A" => Some(egui::Key::A),
|
||||
"b" | "B" => Some(egui::Key::B),
|
||||
"c" | "C" => Some(egui::Key::C),
|
||||
"d" | "D" => Some(egui::Key::D),
|
||||
"e" | "E" => Some(egui::Key::E),
|
||||
"f" | "F" => Some(egui::Key::F),
|
||||
"g" | "G" => Some(egui::Key::G),
|
||||
"h" | "H" => Some(egui::Key::H),
|
||||
"i" | "I" => Some(egui::Key::I),
|
||||
"j" | "J" => Some(egui::Key::J),
|
||||
"k" | "K" => Some(egui::Key::K),
|
||||
"l" | "L" => Some(egui::Key::L),
|
||||
"m" | "M" => Some(egui::Key::M),
|
||||
"n" | "N" => Some(egui::Key::N),
|
||||
"o" | "O" => Some(egui::Key::O),
|
||||
"p" | "P" => Some(egui::Key::P),
|
||||
"q" | "Q" => Some(egui::Key::Q),
|
||||
"r" | "R" => Some(egui::Key::R),
|
||||
"s" | "S" => Some(egui::Key::S),
|
||||
"t" | "T" => Some(egui::Key::T),
|
||||
"u" | "U" => Some(egui::Key::U),
|
||||
"v" | "V" => Some(egui::Key::V),
|
||||
"w" | "W" => Some(egui::Key::W),
|
||||
"x" | "X" => Some(egui::Key::X),
|
||||
"y" | "Y" => Some(egui::Key::Y),
|
||||
"z" | "Z" => Some(egui::Key::Z),
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn modifiers_from_event(event: &web_sys::KeyboardEvent) -> egui::Modifiers {
|
||||
egui::Modifiers {
|
||||
alt: event.alt_key(),
|
||||
ctrl: event.ctrl_key(),
|
||||
shift: event.shift_key(),
|
||||
|
||||
// Ideally we should know if we are running or mac or not,
|
||||
// but this works good enough for now.
|
||||
mac_cmd: event.meta_key(),
|
||||
|
||||
// Ideally we should know if we are running or mac or not,
|
||||
// but this works good enough for now.
|
||||
command: event.ctrl_key() || event.meta_key(),
|
||||
}
|
||||
}
|
||||
273
eframe/src/web/mod.rs
Normal file
273
eframe/src/web/mod.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
//! [`egui`] bindings for web apps (compiling to WASM).
|
||||
|
||||
#![allow(clippy::missing_errors_doc)] // So many `-> Result<_, JsValue>`
|
||||
|
||||
pub mod backend;
|
||||
mod events;
|
||||
mod glow_wrapping;
|
||||
mod input;
|
||||
pub mod screen_reader;
|
||||
pub mod storage;
|
||||
mod text_agent;
|
||||
|
||||
pub use backend::*;
|
||||
pub use events::*;
|
||||
pub use storage::*;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
use egui::mutex::{Mutex, MutexGuard};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use web_sys::EventTarget;
|
||||
|
||||
use input::*;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Current time in seconds (since undefined point in time)
|
||||
pub fn now_sec() -> f64 {
|
||||
web_sys::window()
|
||||
.expect("should have a Window")
|
||||
.performance()
|
||||
.expect("should have a Performance")
|
||||
.now()
|
||||
/ 1000.0
|
||||
}
|
||||
|
||||
pub fn screen_size_in_native_points() -> Option<egui::Vec2> {
|
||||
let window = web_sys::window()?;
|
||||
Some(egui::vec2(
|
||||
window.inner_width().ok()?.as_f64()? as f32,
|
||||
window.inner_height().ok()?.as_f64()? as f32,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn native_pixels_per_point() -> f32 {
|
||||
let pixels_per_point = web_sys::window().unwrap().device_pixel_ratio() as f32;
|
||||
if pixels_per_point > 0.0 && pixels_per_point.is_finite() {
|
||||
pixels_per_point
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prefer_dark_mode() -> Option<bool> {
|
||||
Some(
|
||||
web_sys::window()?
|
||||
.match_media("(prefers-color-scheme: dark)")
|
||||
.ok()??
|
||||
.matches(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn canvas_element(canvas_id: &str) -> Option<web_sys::HtmlCanvasElement> {
|
||||
use wasm_bindgen::JsCast;
|
||||
let document = web_sys::window()?.document()?;
|
||||
let canvas = document.get_element_by_id(canvas_id)?;
|
||||
canvas.dyn_into::<web_sys::HtmlCanvasElement>().ok()
|
||||
}
|
||||
|
||||
pub fn canvas_element_or_die(canvas_id: &str) -> web_sys::HtmlCanvasElement {
|
||||
canvas_element(canvas_id)
|
||||
.unwrap_or_else(|| panic!("Failed to find canvas with id '{}'", canvas_id))
|
||||
}
|
||||
|
||||
fn canvas_origin(canvas_id: &str) -> egui::Pos2 {
|
||||
let rect = canvas_element(canvas_id)
|
||||
.unwrap()
|
||||
.get_bounding_client_rect();
|
||||
egui::Pos2::new(rect.left() as f32, rect.top() as f32)
|
||||
}
|
||||
|
||||
pub fn canvas_size_in_points(canvas_id: &str) -> egui::Vec2 {
|
||||
let canvas = canvas_element(canvas_id).unwrap();
|
||||
let pixels_per_point = native_pixels_per_point();
|
||||
egui::vec2(
|
||||
canvas.width() as f32 / pixels_per_point,
|
||||
canvas.height() as f32 / pixels_per_point,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resize_canvas_to_screen_size(canvas_id: &str, max_size_points: egui::Vec2) -> Option<()> {
|
||||
let canvas = canvas_element(canvas_id)?;
|
||||
|
||||
let screen_size_points = screen_size_in_native_points()?;
|
||||
let pixels_per_point = native_pixels_per_point();
|
||||
|
||||
let max_size_pixels = pixels_per_point * max_size_points;
|
||||
|
||||
let canvas_size_pixels = pixels_per_point * screen_size_points;
|
||||
let canvas_size_pixels = canvas_size_pixels.min(max_size_pixels);
|
||||
let canvas_size_points = canvas_size_pixels / pixels_per_point;
|
||||
|
||||
// Make sure that the height and width are always even numbers.
|
||||
// otherwise, the page renders blurry on some platforms.
|
||||
// See https://github.com/emilk/egui/issues/103
|
||||
fn round_to_even(v: f32) -> f32 {
|
||||
(v / 2.0).round() * 2.0
|
||||
}
|
||||
|
||||
canvas
|
||||
.style()
|
||||
.set_property(
|
||||
"width",
|
||||
&format!("{}px", round_to_even(canvas_size_points.x)),
|
||||
)
|
||||
.ok()?;
|
||||
canvas
|
||||
.style()
|
||||
.set_property(
|
||||
"height",
|
||||
&format!("{}px", round_to_even(canvas_size_points.y)),
|
||||
)
|
||||
.ok()?;
|
||||
canvas.set_width(round_to_even(canvas_size_pixels.x) as u32);
|
||||
canvas.set_height(round_to_even(canvas_size_pixels.y) as u32);
|
||||
|
||||
Some(())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
pub fn set_cursor_icon(cursor: egui::CursorIcon) -> Option<()> {
|
||||
let document = web_sys::window()?.document()?;
|
||||
document
|
||||
.body()?
|
||||
.style()
|
||||
.set_property("cursor", cursor_web_name(cursor))
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(web_sys_unstable_apis)]
|
||||
pub fn set_clipboard_text(s: &str) {
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(clipboard) = window.navigator().clipboard() {
|
||||
let promise = clipboard.write_text(s);
|
||||
let future = wasm_bindgen_futures::JsFuture::from(promise);
|
||||
let future = async move {
|
||||
if let Err(err) = future.await {
|
||||
tracing::error!("Copy/cut action denied: {:?}", err);
|
||||
}
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(future);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_web_name(cursor: egui::CursorIcon) -> &'static str {
|
||||
match cursor {
|
||||
egui::CursorIcon::Alias => "alias",
|
||||
egui::CursorIcon::AllScroll => "all-scroll",
|
||||
egui::CursorIcon::Cell => "cell",
|
||||
egui::CursorIcon::ContextMenu => "context-menu",
|
||||
egui::CursorIcon::Copy => "copy",
|
||||
egui::CursorIcon::Crosshair => "crosshair",
|
||||
egui::CursorIcon::Default => "default",
|
||||
egui::CursorIcon::Grab => "grab",
|
||||
egui::CursorIcon::Grabbing => "grabbing",
|
||||
egui::CursorIcon::Help => "help",
|
||||
egui::CursorIcon::Move => "move",
|
||||
egui::CursorIcon::NoDrop => "no-drop",
|
||||
egui::CursorIcon::None => "none",
|
||||
egui::CursorIcon::NotAllowed => "not-allowed",
|
||||
egui::CursorIcon::PointingHand => "pointer",
|
||||
egui::CursorIcon::Progress => "progress",
|
||||
egui::CursorIcon::ResizeHorizontal => "ew-resize",
|
||||
egui::CursorIcon::ResizeNeSw => "nesw-resize",
|
||||
egui::CursorIcon::ResizeNwSe => "nwse-resize",
|
||||
egui::CursorIcon::ResizeVertical => "ns-resize",
|
||||
|
||||
egui::CursorIcon::ResizeEast => "e-resize",
|
||||
egui::CursorIcon::ResizeSouthEast => "se-resize",
|
||||
egui::CursorIcon::ResizeSouth => "s-resize",
|
||||
egui::CursorIcon::ResizeSouthWest => "sw-resize",
|
||||
egui::CursorIcon::ResizeWest => "w-resize",
|
||||
egui::CursorIcon::ResizeNorthWest => "nw-resize",
|
||||
egui::CursorIcon::ResizeNorth => "n-resize",
|
||||
egui::CursorIcon::ResizeNorthEast => "ne-resize",
|
||||
egui::CursorIcon::ResizeColumn => "col-resize",
|
||||
egui::CursorIcon::ResizeRow => "row-resize",
|
||||
|
||||
egui::CursorIcon::Text => "text",
|
||||
egui::CursorIcon::VerticalText => "vertical-text",
|
||||
egui::CursorIcon::Wait => "wait",
|
||||
egui::CursorIcon::ZoomIn => "zoom-in",
|
||||
egui::CursorIcon::ZoomOut => "zoom-out",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_url(url: &str, new_tab: bool) -> Option<()> {
|
||||
let name = if new_tab { "_blank" } else { "_self" };
|
||||
|
||||
web_sys::window()?
|
||||
.open_with_url_and_target(url, name)
|
||||
.ok()?;
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// e.g. "#fragment" part of "www.example.com/index.html#fragment",
|
||||
///
|
||||
/// Percent decoded
|
||||
pub fn location_hash() -> String {
|
||||
percent_decode(
|
||||
&web_sys::window()
|
||||
.unwrap()
|
||||
.location()
|
||||
.hash()
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn percent_decode(s: &str) -> String {
|
||||
percent_encoding::percent_decode_str(s)
|
||||
.decode_utf8_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn webgl1_requires_brightening(gl: &web_sys::WebGlRenderingContext) -> bool {
|
||||
// See https://github.com/emilk/egui/issues/794
|
||||
|
||||
// detect WebKitGTK
|
||||
|
||||
// WebKitGTK use WebKit default unmasked vendor and renderer
|
||||
// but safari use same vendor and renderer
|
||||
// so exclude "Mac OS X" user-agent.
|
||||
let user_agent = web_sys::window().unwrap().navigator().user_agent().unwrap();
|
||||
!user_agent.contains("Mac OS X") && is_safari_and_webkit_gtk(gl)
|
||||
}
|
||||
|
||||
/// detecting Safari and `webkitGTK`.
|
||||
///
|
||||
/// Safari and `webkitGTK` use unmasked renderer :Apple GPU
|
||||
///
|
||||
/// If we detect safari or `webkitGTKs` returns true.
|
||||
///
|
||||
/// This function used to avoid displaying linear color with `sRGB` supported systems.
|
||||
fn is_safari_and_webkit_gtk(gl: &web_sys::WebGlRenderingContext) -> bool {
|
||||
// This call produces a warning in Firefox ("WEBGL_debug_renderer_info is deprecated in Firefox and will be removed.")
|
||||
// but unless we call it we get errors in Chrome when we call `get_parameter` below.
|
||||
// TODO: do something smart based on user agent?
|
||||
if gl
|
||||
.get_extension("WEBGL_debug_renderer_info")
|
||||
.unwrap()
|
||||
.is_some()
|
||||
{
|
||||
if let Ok(renderer) =
|
||||
gl.get_parameter(web_sys::WebglDebugRendererInfo::UNMASKED_RENDERER_WEBGL)
|
||||
{
|
||||
if let Some(renderer) = renderer.as_string() {
|
||||
if renderer.contains("Apple") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
49
eframe/src/web/screen_reader.rs
Normal file
49
eframe/src/web/screen_reader.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
pub struct ScreenReader {
|
||||
#[cfg(feature = "screen_reader")]
|
||||
tts: Option<tts::Tts>,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "screen_reader"))]
|
||||
#[allow(clippy::derivable_impls)] // False positive
|
||||
impl Default for ScreenReader {
|
||||
fn default() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "screen_reader")]
|
||||
impl Default for ScreenReader {
|
||||
fn default() -> Self {
|
||||
let tts = match tts::Tts::default() {
|
||||
Ok(screen_reader) => {
|
||||
tracing::debug!("Initialized screen reader.");
|
||||
Some(screen_reader)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to load screen reader: {}", err);
|
||||
None
|
||||
}
|
||||
};
|
||||
Self { tts }
|
||||
}
|
||||
}
|
||||
|
||||
impl ScreenReader {
|
||||
#[cfg(not(feature = "screen_reader"))]
|
||||
#[allow(clippy::unused_self)]
|
||||
pub fn speak(&mut self, _text: &str) {}
|
||||
|
||||
#[cfg(feature = "screen_reader")]
|
||||
pub fn speak(&mut self, text: &str) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(tts) = &mut self.tts {
|
||||
tracing::debug!("Speaking: {:?}", text);
|
||||
let interrupt = true;
|
||||
if let Err(err) = tts.speak(text, interrupt) {
|
||||
tracing::warn!("Failed to read: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
eframe/src/web/storage.rs
Normal file
43
eframe/src/web/storage.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
fn local_storage() -> Option<web_sys::Storage> {
|
||||
web_sys::window()?.local_storage().ok()?
|
||||
}
|
||||
|
||||
pub fn local_storage_get(key: &str) -> Option<String> {
|
||||
local_storage().map(|storage| storage.get_item(key).ok())??
|
||||
}
|
||||
|
||||
pub fn local_storage_set(key: &str, value: &str) {
|
||||
local_storage().map(|storage| storage.set_item(key, value));
|
||||
}
|
||||
|
||||
#[cfg(feature = "persistence")]
|
||||
pub fn load_memory(ctx: &egui::Context) {
|
||||
if let Some(memory_string) = local_storage_get("egui_memory_ron") {
|
||||
match ron::from_str(&memory_string) {
|
||||
Ok(memory) => {
|
||||
*ctx.memory() = memory;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to parse memory RON: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "persistence"))]
|
||||
pub fn load_memory(_: &egui::Context) {}
|
||||
|
||||
#[cfg(feature = "persistence")]
|
||||
pub fn save_memory(ctx: &egui::Context) {
|
||||
match ron::to_string(&*ctx.memory()) {
|
||||
Ok(ron) => {
|
||||
local_storage_set("egui_memory_ron", &ron);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to serialize memory as RON: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "persistence"))]
|
||||
pub fn save_memory(_: &egui::Context) {}
|
||||
228
eframe/src/web/text_agent.rs
Normal file
228
eframe/src/web/text_agent.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
//! The text agent is an `<input>` element used to trigger
|
||||
//! mobile keyboard and IME input.
|
||||
|
||||
use super::{canvas_element, AppRunner, AppRunnerContainer};
|
||||
use egui::mutex::MutexGuard;
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
static AGENT_ID: &str = "egui_text_agent";
|
||||
|
||||
pub fn text_agent() -> web_sys::HtmlInputElement {
|
||||
use wasm_bindgen::JsCast;
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.document()
|
||||
.unwrap()
|
||||
.get_element_by_id(AGENT_ID)
|
||||
.unwrap()
|
||||
.dyn_into()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Text event handler,
|
||||
pub fn install_text_agent(runner_container: &AppRunnerContainer) -> Result<(), JsValue> {
|
||||
use wasm_bindgen::JsCast;
|
||||
let window = web_sys::window().unwrap();
|
||||
let document = window.document().unwrap();
|
||||
let body = document.body().expect("document should have a body");
|
||||
let input = document
|
||||
.create_element("input")?
|
||||
.dyn_into::<web_sys::HtmlInputElement>()?;
|
||||
let input = std::rc::Rc::new(input);
|
||||
input.set_id(AGENT_ID);
|
||||
let is_composing = Rc::new(Cell::new(false));
|
||||
{
|
||||
let style = input.style();
|
||||
// Transparent
|
||||
style.set_property("opacity", "0").unwrap();
|
||||
// Hide under canvas
|
||||
style.set_property("z-index", "-1").unwrap();
|
||||
}
|
||||
// Set size as small as possible, in case user may click on it.
|
||||
input.set_size(1);
|
||||
input.set_autofocus(true);
|
||||
input.set_hidden(true);
|
||||
|
||||
// When IME is off
|
||||
runner_container.add_event_listener(&input, "input", {
|
||||
let input_clone = input.clone();
|
||||
let is_composing = is_composing.clone();
|
||||
|
||||
move |_event: web_sys::InputEvent, mut runner_lock| {
|
||||
let text = input_clone.value();
|
||||
if !text.is_empty() && !is_composing.get() {
|
||||
input_clone.set_value("");
|
||||
runner_lock.input.raw.events.push(egui::Event::Text(text));
|
||||
runner_lock.needs_repaint.set_true();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
{
|
||||
// When IME is on, handle composition event
|
||||
runner_container.add_event_listener(&input, "compositionstart", {
|
||||
let input_clone = input.clone();
|
||||
let is_composing = is_composing.clone();
|
||||
|
||||
move |_event: web_sys::CompositionEvent, mut runner_lock: MutexGuard<'_, AppRunner>| {
|
||||
is_composing.set(true);
|
||||
input_clone.set_value("");
|
||||
|
||||
runner_lock
|
||||
.input
|
||||
.raw
|
||||
.events
|
||||
.push(egui::Event::CompositionStart);
|
||||
runner_lock.needs_repaint.set_true();
|
||||
}
|
||||
})?;
|
||||
|
||||
runner_container.add_event_listener(
|
||||
&input,
|
||||
"compositionupdate",
|
||||
move |event: web_sys::CompositionEvent, mut runner_lock: MutexGuard<'_, AppRunner>| {
|
||||
if let Some(event) = event.data().map(egui::Event::CompositionUpdate) {
|
||||
runner_lock.input.raw.events.push(event);
|
||||
runner_lock.needs_repaint.set_true();
|
||||
}
|
||||
},
|
||||
)?;
|
||||
|
||||
runner_container.add_event_listener(&input, "compositionend", {
|
||||
let input_clone = input.clone();
|
||||
|
||||
move |event: web_sys::CompositionEvent, mut runner_lock: MutexGuard<'_, AppRunner>| {
|
||||
is_composing.set(false);
|
||||
input_clone.set_value("");
|
||||
|
||||
if let Some(event) = event.data().map(egui::Event::CompositionEnd) {
|
||||
runner_lock.input.raw.events.push(event);
|
||||
runner_lock.needs_repaint.set_true();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
// When input lost focus, focus on it again.
|
||||
// It is useful when user click somewhere outside canvas.
|
||||
runner_container.add_event_listener(
|
||||
&input,
|
||||
"focusout",
|
||||
move |_event: web_sys::MouseEvent, _| {
|
||||
// Delay 10 ms, and focus again.
|
||||
let func = js_sys::Function::new_no_args(&format!(
|
||||
"document.getElementById('{}').focus()",
|
||||
AGENT_ID
|
||||
));
|
||||
window
|
||||
.set_timeout_with_callback_and_timeout_and_arguments_0(&func, 10)
|
||||
.unwrap();
|
||||
},
|
||||
)?;
|
||||
|
||||
body.append_child(&input)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Focus or blur text agent to toggle mobile keyboard.
|
||||
pub fn update_text_agent(runner: MutexGuard<'_, AppRunner>) -> Option<()> {
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::HtmlInputElement;
|
||||
let window = web_sys::window()?;
|
||||
let document = window.document()?;
|
||||
let input: HtmlInputElement = document.get_element_by_id(AGENT_ID)?.dyn_into().unwrap();
|
||||
let canvas_style = canvas_element(runner.canvas_id())?.style();
|
||||
|
||||
if runner.mutable_text_under_cursor {
|
||||
let is_already_editing = input.hidden();
|
||||
if is_already_editing {
|
||||
input.set_hidden(false);
|
||||
input.focus().ok()?;
|
||||
|
||||
// Move up canvas so that text edit is shown at ~30% of screen height.
|
||||
// Only on touch screens, when keyboard popups.
|
||||
if let Some(latest_touch_pos) = runner.input.latest_touch_pos {
|
||||
let window_height = window.inner_height().ok()?.as_f64()? as f32;
|
||||
let current_rel = latest_touch_pos.y / window_height;
|
||||
|
||||
// estimated amount of screen covered by keyboard
|
||||
let keyboard_fraction = 0.5;
|
||||
|
||||
if current_rel > keyboard_fraction {
|
||||
// below the keyboard
|
||||
|
||||
let target_rel = 0.3;
|
||||
|
||||
// Note: `delta` is negative, since we are moving the canvas UP
|
||||
let delta = target_rel - current_rel;
|
||||
|
||||
let delta = delta.max(-keyboard_fraction); // Don't move it crazy much
|
||||
|
||||
let new_pos_percent = format!("{}%", (delta * 100.0).round());
|
||||
|
||||
canvas_style.set_property("position", "absolute").ok()?;
|
||||
canvas_style.set_property("top", &new_pos_percent).ok()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Drop runner lock
|
||||
drop(runner);
|
||||
|
||||
// Holding the runner lock while calling input.blur() causes a panic.
|
||||
// This is most probably caused by the browser running the event handler
|
||||
// for the triggered blur event synchronously, meaning that the mutex
|
||||
// lock does not get dropped by the time another event handler is called.
|
||||
//
|
||||
// Why this didn't exist before #1290 is a mystery to me, but it exists now
|
||||
// and this apparently is the fix for it
|
||||
//
|
||||
// ¯\_(ツ)_/¯ - @DusterTheFirst
|
||||
input.blur().ok()?;
|
||||
|
||||
input.set_hidden(true);
|
||||
canvas_style.set_property("position", "absolute").ok()?;
|
||||
canvas_style.set_property("top", "0%").ok()?; // move back to normal position
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// If context is running under mobile device?
|
||||
fn is_mobile() -> Option<bool> {
|
||||
const MOBILE_DEVICE: [&str; 6] = ["Android", "iPhone", "iPad", "iPod", "webOS", "BlackBerry"];
|
||||
|
||||
let user_agent = web_sys::window()?.navigator().user_agent().ok()?;
|
||||
let is_mobile = MOBILE_DEVICE.iter().any(|&name| user_agent.contains(name));
|
||||
Some(is_mobile)
|
||||
}
|
||||
|
||||
// Move text agent to text cursor's position, on desktop/laptop,
|
||||
// candidate window moves following text element (agent),
|
||||
// so it appears that the IME candidate window moves with text cursor.
|
||||
// On mobile devices, there is no need to do that.
|
||||
pub fn move_text_cursor(cursor: Option<egui::Pos2>, canvas_id: &str) -> Option<()> {
|
||||
let style = text_agent().style();
|
||||
// Note: movint agent on mobile devices will lead to unpredictable scroll.
|
||||
if is_mobile() == Some(false) {
|
||||
cursor.as_ref().and_then(|&egui::Pos2 { x, y }| {
|
||||
let canvas = canvas_element(canvas_id)?;
|
||||
let bounding_rect = text_agent().get_bounding_client_rect();
|
||||
let y = (y + (canvas.scroll_top() + canvas.offset_top()) as f32)
|
||||
.min(canvas.client_height() as f32 - bounding_rect.height() as f32);
|
||||
let x = x + (canvas.scroll_left() + canvas.offset_left()) as f32;
|
||||
// Canvas is translated 50% horizontally in html.
|
||||
let x = (x - canvas.offset_width() as f32 / 2.0)
|
||||
.min(canvas.client_width() as f32 - bounding_rect.width() as f32);
|
||||
style.set_property("position", "absolute").ok()?;
|
||||
style.set_property("top", &format!("{}px", y)).ok()?;
|
||||
style.set_property("left", &format!("{}px", x)).ok()
|
||||
})
|
||||
} else {
|
||||
style.set_property("position", "absolute").ok()?;
|
||||
style.set_property("top", "0px").ok()?;
|
||||
style.set_property("left", "0px").ok()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user