From 49d4befe6b6198f5adcb7e2e6b413fa1f8de288c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jochen=20G=C3=B6rtler?= Date: Mon, 3 Aug 2026 17:07:37 +0200 Subject: [PATCH] Store `web_sys::File` inside of `DroppedFile` (#8354) * Closes #4654 * Related #4667 * [x] I have followed the instructions in the PR template This PR avoids materializing the contents of a file that was dragged into an egui application on the web. It does so by storing the `web_sys::File` handle directly on WASM. This breaks the existing API of `DroppedFile` on the web, because there is no way to retrieve the bytes synchronously form a `DroppedFile` anymore, forcing handling call sites to become asynchronous. The native API remains the same. --------- Co-authored-by: Emil Ernerfeldt --- Cargo.lock | 2 +- crates/eframe/Cargo.toml | 1 - crates/eframe/src/web/dropped_file.rs | 48 ++++++++++++++++ crates/eframe/src/web/events.rs | 65 +++++----------------- crates/eframe/src/web/mod.rs | 11 ++-- crates/egui-winit/src/dropped_file.rs | 22 ++++++++ crates/egui-winit/src/lib.rs | 10 ++-- crates/egui/Cargo.toml | 7 +++ crates/egui/src/data/input/dropped_file.rs | 60 +++++++++++++++----- crates/egui/src/data/input/mod.rs | 2 +- crates/egui/src/data/input/raw_input.rs | 17 +++++- crates/egui_demo_app/src/wrap_app.rs | 36 ++++++------ examples/file_dialog/src/main.rs | 36 ++++++------ 13 files changed, 197 insertions(+), 120 deletions(-) create mode 100644 crates/eframe/src/web/dropped_file.rs create mode 100644 crates/egui-winit/src/dropped_file.rs diff --git a/Cargo.lock b/Cargo.lock index 4afcaf461..adf431072 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1284,7 +1284,6 @@ dependencies = [ "serde", "static_assertions", "wasm-bindgen", - "wasm-bindgen-futures", "web-sys", "web-time", "wgpu", @@ -1311,6 +1310,7 @@ dependencies = [ "serde", "smallvec", "unicode-segmentation", + "web-sys", ] [[package]] diff --git a/crates/eframe/Cargo.toml b/crates/eframe/Cargo.toml index 65e3150a8..4b30ad18d 100644 --- a/crates/eframe/Cargo.toml +++ b/crates/eframe/Cargo.toml @@ -209,7 +209,6 @@ image = { workspace = true, features = ["png"] } # For copying images js-sys.workspace = true percent-encoding.workspace = true wasm-bindgen.workspace = true -wasm-bindgen-futures.workspace = true web-sys = { workspace = true, features = [ "AddEventListenerOptions", "BinaryType", diff --git a/crates/eframe/src/web/dropped_file.rs b/crates/eframe/src/web/dropped_file.rs new file mode 100644 index 000000000..45e454cc5 --- /dev/null +++ b/crates/eframe/src/web/dropped_file.rs @@ -0,0 +1,48 @@ +use std::{ + future::Future, + path::{Path, PathBuf}, + pin::Pin, +}; + +#[derive(Debug)] +pub(crate) struct WebFile { + file: web_sys::File, + // We store a `PathBuf` here so that we can hand out `Path`s + // without allocating each time. + path: PathBuf, +} + +impl From for WebFile { + fn from(file: web_sys::File) -> Self { + let path = file.name().into(); + Self { file, path } + } +} + +impl egui::DroppedFile for WebFile { + fn path(&self) -> &Path { + &self.path + } + + fn bytes_async(&self) -> Pin, String>> + '_>> { + let file = self.file.clone(); + Box::pin(async move { + if file.size() > f64::from(u32::MAX) { + return Err(format!( + "File is too large: browser file reads are limited to {} bytes", + u32::MAX + )); + } + + let array_buffer = file + .array_buffer() + .await + .map_err(|err| crate::web::string_from_js_value(&err))?; + Ok(js_sys::Uint8Array::new(&array_buffer).to_vec()) + }) + } + + fn web_file(&self) -> Option<&web_sys::File> { + Some(&self.file) + } +} diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs index f9d992c2f..0303f0cdf 100644 --- a/crates/eframe/src/web/events.rs +++ b/crates/eframe/src/web/events.rs @@ -973,62 +973,25 @@ fn install_drag_and_drop(runner_ref: &WebRunner, target: &EventTarget) -> Result event.prevent_default(); })?; - runner_ref.add_event_listener(target, "drop", { - let runner_ref = runner_ref.clone(); + runner_ref.add_event_listener(target, "drop", |event: web_sys::DragEvent, runner| { + if let Some(data_transfer) = event.data_transfer() { + // TODO(https://github.com/emilk/egui/issues/3702): support dropping folders + runner.input.raw.hovered_files.clear(); + runner.needs_repaint.repaint_asap(); - move |event: web_sys::DragEvent, runner| { - if let Some(data_transfer) = event.data_transfer() { - // TODO(https://github.com/emilk/egui/issues/3702): support dropping folders - runner.input.raw.hovered_files.clear(); - runner.needs_repaint.repaint_asap(); + if let Some(files) = data_transfer.files() { + for i in 0..files.length() { + if let Some(file) = files.get(i) { + log::debug!("Dropped {:?} ({} bytes)", file.name(), file.size()); - 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 mime = file.type_(); - let last_modified = std::time::UNIX_EPOCH - + std::time::Duration::from_millis(file.last_modified() as u64); - - log::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(); - log::debug!("Loaded {:?} ({} bytes).", name, bytes.len()); - - if let Some(mut runner_lock) = runner_ref.try_lock() { - runner_lock.input.raw.dropped_files.push( - egui::DroppedFile { - name, - mime, - last_modified: Some(last_modified), - bytes: Some(bytes.into()), - ..Default::default() - }, - ); - runner_lock.needs_repaint.repaint_asap(); - } - } - Err(err) => { - log::error!( - "Failed to read file: {}", - string_from_js_value(&err) - ); - } - } - }; - wasm_bindgen_futures::spawn_local(future); - } + runner.input.raw.dropped_files.push(std::sync::Arc::new( + super::dropped_file::WebFile::from(file), + )); } } - event.stop_propagation(); - event.prevent_default(); } + event.stop_propagation(); + event.prevent_default(); } })?; diff --git a/crates/eframe/src/web/mod.rs b/crates/eframe/src/web/mod.rs index 67923987b..bf851d4ad 100644 --- a/crates/eframe/src/web/mod.rs +++ b/crates/eframe/src/web/mod.rs @@ -5,6 +5,7 @@ mod app_runner; mod backend; +mod dropped_file; mod events; mod input; mod panic_handler; @@ -207,13 +208,12 @@ fn set_clipboard_text(s: &str) { return; } let promise = window.navigator().clipboard().write_text(s); - let future = wasm_bindgen_futures::JsFuture::from(promise); let future = async move { - if let Err(err) = future.await { + if let Err(err) = promise.await { log::error!("Copy/cut action failed: {}", string_from_js_value(&err)); } }; - wasm_bindgen_futures::spawn_local(future); + js_sys::futures::spawn_local(future); } } @@ -248,16 +248,15 @@ fn set_clipboard_image(image: &egui::ColorImage) { }; let items = js_sys::Array::of1(&item); let promise = window.navigator().clipboard().write(&items); - let future = wasm_bindgen_futures::JsFuture::from(promise); let future = async move { - if let Err(err) = future.await { + if let Err(err) = promise.await { log::error!( "Copy/cut image action failed: {}", string_from_js_value(&err) ); } }; - wasm_bindgen_futures::spawn_local(future); + js_sys::futures::spawn_local(future); } } diff --git a/crates/egui-winit/src/dropped_file.rs b/crates/egui-winit/src/dropped_file.rs new file mode 100644 index 000000000..43f8960fc --- /dev/null +++ b/crates/egui-winit/src/dropped_file.rs @@ -0,0 +1,22 @@ +use std::path::{Path, PathBuf}; + +#[derive(Debug)] +pub(crate) struct NativeFile { + path: PathBuf, +} + +impl From for NativeFile { + fn from(path: PathBuf) -> Self { + Self { path } + } +} + +impl egui::DroppedFile for NativeFile { + fn path(&self) -> &Path { + &self.path + } + + fn bytes(&self) -> Result, String> { + std::fs::read(&self.path).map_err(|err| err.to_string()) + } +} diff --git a/crates/egui-winit/src/lib.rs b/crates/egui-winit/src/lib.rs index 85b22a997..0029a47b7 100644 --- a/crates/egui-winit/src/lib.rs +++ b/crates/egui-winit/src/lib.rs @@ -21,6 +21,7 @@ use egui::{Pos2, Rect, Theme, Vec2, ViewportBuilder, ViewportCommand, ViewportId pub use winit; pub mod clipboard; +mod dropped_file; mod safe_area; mod window_settings; @@ -28,6 +29,8 @@ pub use window_settings::WindowSettings; use raw_window_handle::HasDisplayHandle; +use dropped_file::NativeFile; + use winit::{ dpi::{PhysicalPosition, PhysicalSize}, event::ElementState, @@ -470,10 +473,9 @@ impl State { } WindowEvent::DroppedFile(path) => { self.egui_input.hovered_files.clear(); - self.egui_input.dropped_files.push(egui::DroppedFile { - path: Some(path.clone()), - ..Default::default() - }); + self.egui_input + .dropped_files + .push(std::sync::Arc::new(NativeFile::from(path.clone()))); EventResponse { repaint: true, consumed: false, diff --git a/crates/egui/Cargo.toml b/crates/egui/Cargo.toml index fadc27c6c..4aee66384 100644 --- a/crates/egui/Cargo.toml +++ b/crates/egui/Cargo.toml @@ -19,6 +19,7 @@ workspace = true [package.metadata.docs.rs] all-features = true rustdoc-args = ["--generate-link-to-definition"] +targets = ["x86_64-unknown-linux-gnu", "wasm32-unknown-unknown"] [lib] @@ -90,3 +91,9 @@ document-features = { workspace = true, optional = true } ron = { workspace = true, optional = true } serde = { workspace = true, optional = true, features = ["derive", "rc"] } + + +# web: +[target.'cfg(target_arch = "wasm32")'.dependencies] +# For `DroppedFile`, which hands web apps a file handle instead of its contents. +web-sys = { workspace = true, features = ["File"] } diff --git a/crates/egui/src/data/input/dropped_file.rs b/crates/egui/src/data/input/dropped_file.rs index 39faceba8..0ca617fa0 100644 --- a/crates/egui/src/data/input/dropped_file.rs +++ b/crates/egui/src/data/input/dropped_file.rs @@ -1,19 +1,51 @@ +use std::{path::Path, sync::Arc}; + +#[cfg(target_arch = "wasm32")] +use std::{future::Future, pin::Pin}; + /// A file dropped into egui. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] -pub struct DroppedFile { - /// Set by the `egui-winit` backend. - pub path: Option, +/// +/// The integration owns the concrete file handle, letting egui remain independent of windowing +/// backends and file APIs. +pub trait DroppedFile: std::fmt::Debug { + /// The path of the dropped file. + /// + /// This is an absolute path on native platforms. On the web, it is a relative path containing + /// only the file name because browsers do not expose the file's local path. + fn path(&self) -> &Path; - /// Name of the file. Set by the `eframe` web backend. - pub name: String, + /// Read the file contents. + /// + /// This is asynchronous because browsers can only read files asynchronously. + /// + /// # Errors + /// + /// Returns an error if the browser cannot read the file. + #[cfg(target_arch = "wasm32")] + fn bytes_async(&self) -> Pin, String>> + '_>>; - /// With the `eframe` web backend, this is set to the mime-type of the file (if available). - pub mime: String, + /// Read the file contents. + /// + /// # Errors + /// + /// Returns an error if the file cannot be read. + #[cfg(not(target_arch = "wasm32"))] + fn bytes(&self) -> Result, String>; - /// Set by the `eframe` web backend. - pub last_modified: Option, - - /// Set by the `eframe` web backend. - pub bytes: Option>, + /// The browser file handle, if this file was dropped on the web. + #[cfg(target_arch = "wasm32")] + fn web_file(&self) -> Option<&web_sys::File> { + None + } } + +/// A shared reference to a dropped file. +#[cfg(not(all(target_arch = "wasm32", target_feature = "atomics")))] +pub type DroppedFileHandle = Arc; + +/// A shared reference to a dropped file. +/// +/// This is not necessarily `Send + Sync` when wasm threads are enabled, because +/// [`web_sys::File`] is not thread-safe in that configuration. +#[cfg(all(target_arch = "wasm32", target_feature = "atomics"))] +pub type DroppedFileHandle = Arc; diff --git a/crates/egui/src/data/input/mod.rs b/crates/egui/src/data/input/mod.rs index b4e739471..3f2060b06 100644 --- a/crates/egui/src/data/input/mod.rs +++ b/crates/egui/src/data/input/mod.rs @@ -16,7 +16,7 @@ mod touch; mod viewport_info; pub use self::{ - dropped_file::DroppedFile, + dropped_file::{DroppedFile, DroppedFileHandle}, event::Event, event_filter::EventFilter, hovered_file::HoveredFile, diff --git a/crates/egui/src/data/input/raw_input.rs b/crates/egui/src/data/input/raw_input.rs index b9fc6e66a..7135e90e0 100644 --- a/crates/egui/src/data/input/raw_input.rs +++ b/crates/egui/src/data/input/raw_input.rs @@ -1,6 +1,6 @@ use crate::{OrderedViewportIdMap, Theme, ViewportId, ViewportIdMap, emath::Rect}; -use super::{DroppedFile, Event, HoveredFile, SafeAreaInsets, ViewportInfo}; +use super::{DroppedFileHandle, Event, HoveredFile, SafeAreaInsets, ViewportInfo}; /// What the integrations provides to egui at the start of each frame. /// @@ -13,7 +13,7 @@ use super::{DroppedFile, Event, HoveredFile, SafeAreaInsets, ViewportInfo}; /// /// Ii "points" can be calculated from native physical pixels /// using `pixels_per_point` = [`crate::Context::zoom_factor`] * `native_pixels_per_point`; -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct RawInput { /// The id of the active viewport. @@ -65,9 +65,20 @@ pub struct RawInput { /// Dragged files dropped into egui. /// + /// egui never reads the file contents. + #[cfg_attr( + not(target_arch = "wasm32"), + doc = "Call [`crate::DroppedFile::bytes`] to read a dropped file." + )] + #[cfg_attr( + target_arch = "wasm32", + doc = "Call [`crate::DroppedFile::bytes_async`] to read a dropped file." + )] + /// /// Note: when using `eframe` on Windows, this will always be empty if drag-and-drop support has /// been disabled in [`crate::viewport::ViewportBuilder`]. - pub dropped_files: Vec, + #[cfg_attr(feature = "serde", serde(skip))] + pub dropped_files: Vec, /// The native window has the keyboard focus (i.e. is receiving key presses). /// diff --git a/crates/egui_demo_app/src/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index 8ed3086d1..540f05ca8 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -183,7 +183,7 @@ pub struct WrapApp { #[cfg(any(feature = "glow", feature = "wgpu"))] custom3d: Option, - dropped_files: Vec, + dropped_files: Vec, } impl WrapApp { @@ -519,25 +519,23 @@ impl WrapApp { .open(&mut open) .show(ctx, |ui| { for file in &self.dropped_files { - let mut info = if let Some(path) = &file.path { - path.display().to_string() - } else if file.name.is_empty() { - "???".to_owned() - } else { - file.name.clone() - }; + #[cfg(not(target_arch = "wasm32"))] + let info = file.path().display().to_string(); - let mut additional_info = vec![]; - if !file.mime.is_empty() { - additional_info.push(format!("type: {}", file.mime)); - } - if let Some(bytes) = &file.bytes { - additional_info.push(format!("{} bytes", bytes.len())); - } - if !additional_info.is_empty() { - use std::fmt::Write as _; - write!(info, " ({})", additional_info.join(", ")).ok(); - } + // The size and mime-type are free to read; the contents are not, + // so we never touch them here. + #[cfg(target_arch = "wasm32")] + let info = { + let Some(web_file) = file.web_file() else { + continue; + }; + let (name, mime) = (web_file.name(), web_file.type_()); + if mime.is_empty() { + format!("{name} ({} bytes)", web_file.size()) + } else { + format!("{name} ({} bytes, type: {mime})", web_file.size()) + } + }; ui.label(info); } diff --git a/examples/file_dialog/src/main.rs b/examples/file_dialog/src/main.rs index 914bd3423..d42da9b82 100644 --- a/examples/file_dialog/src/main.rs +++ b/examples/file_dialog/src/main.rs @@ -20,7 +20,7 @@ fn main() -> eframe::Result { #[derive(Default)] struct MyApp { - dropped_files: Vec, + dropped_files: Vec, picked_path: Option, } @@ -48,27 +48,23 @@ impl eframe::App for MyApp { ui.label("Dropped files:"); for file in &self.dropped_files { - let mut info = if let Some(path) = &file.path { - path.display().to_string() - } else if file.name.is_empty() { - "???".to_owned() - } else { - file.name.clone() - }; + #[cfg(not(target_arch = "wasm32"))] + ui.label(file.path().display().to_string()); - let mut additional_info = vec![]; - if !file.mime.is_empty() { - additional_info.push(format!("type: {}", file.mime)); + #[cfg(target_arch = "wasm32")] + { + let Some(web_file) = file.web_file() else { + continue; + }; + let name = web_file.name(); + let mime = web_file.type_(); + let size = web_file.size(); + if mime.is_empty() { + ui.label(format!("{name} ({size} bytes)")); + } else { + ui.label(format!("{name} (type: {mime}, {size} bytes)")); + } } - if let Some(bytes) = &file.bytes { - additional_info.push(format!("{} bytes", bytes.len())); - } - if !additional_info.is_empty() { - use std::fmt::Write as _; - write!(info, " ({})", additional_info.join(", ")).ok(); - } - - ui.label(info); } }); }