Web: Fix incorrect scale when moving to screen with new DPI (#5631)
* Closes https://github.com/emilk/egui/issues/5246 Tested on * [x] Chromium * [x] Firefox * [x] Safari On Chromium and Firefox we get one annoying frame with the wrong size, which can mess up the layout of egui apps, but this PR is still a huge improvement, and I don't want to spend more time on this right now.
This commit is contained in:
parent
304c6518e3
commit
6680e9c079
|
|
@ -211,6 +211,7 @@ percent-encoding = "2.1"
|
|||
wasm-bindgen.workspace = true
|
||||
wasm-bindgen-futures.workspace = true
|
||||
web-sys = { workspace = true, features = [
|
||||
"AddEventListenerOptions",
|
||||
"BinaryType",
|
||||
"Blob",
|
||||
"BlobPropertyBag",
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ impl AppRunner {
|
|||
|
||||
egui_ctx.options_mut(|o| {
|
||||
// On web by default egui follows the zoom factor of the browser,
|
||||
// and lets the browser handle the zoom shortscuts.
|
||||
// and lets the browser handle the zoom shortcuts.
|
||||
// A user can still zoom egui separately by calling [`egui::Context::set_zoom_factor`].
|
||||
o.zoom_with_keyboard = false;
|
||||
o.zoom_factor = 1.0;
|
||||
|
|
@ -216,6 +216,18 @@ impl AppRunner {
|
|||
let canvas_size = super::canvas_size_in_points(self.canvas(), self.egui_ctx());
|
||||
let mut raw_input = self.input.new_frame(canvas_size);
|
||||
|
||||
if super::DEBUG_RESIZE {
|
||||
log::info!(
|
||||
"egui running at canvas size: {}x{}, DPR: {}, zoom_factor: {}. egui size: {}x{} points",
|
||||
self.canvas().width(),
|
||||
self.canvas().height(),
|
||||
super::native_pixels_per_point(),
|
||||
self.egui_ctx.zoom_factor(),
|
||||
canvas_size.x,
|
||||
canvas_size.y,
|
||||
);
|
||||
}
|
||||
|
||||
self.app.raw_input_hook(&self.egui_ctx, &mut raw_input);
|
||||
|
||||
let full_output = self.egui_ctx.run(raw_input, |egui_ctx| {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
use web_sys::EventTarget;
|
||||
|
||||
use crate::web::string_from_js_value;
|
||||
|
||||
use super::{
|
||||
button_from_mouse_event, location_hash, modifiers_from_kb_event, modifiers_from_mouse_event,
|
||||
modifiers_from_wheel_event, pos_from_mouse_event, prefers_color_scheme_dark, primary_touch_pos,
|
||||
push_touches, text_from_keyboard_event, theme_from_dark_mode, translate_key, AppRunner,
|
||||
Closure, JsCast, JsValue, WebRunner,
|
||||
modifiers_from_wheel_event, native_pixels_per_point, pos_from_mouse_event,
|
||||
prefers_color_scheme_dark, primary_touch_pos, push_touches, text_from_keyboard_event,
|
||||
theme_from_dark_mode, translate_key, AppRunner, Closure, JsCast, JsValue, WebRunner,
|
||||
DEBUG_RESIZE,
|
||||
};
|
||||
use web_sys::EventTarget;
|
||||
|
||||
// TODO(emilk): there are more calls to `prevent_default` and `stop_propagation`
|
||||
// than what is probably needed.
|
||||
|
|
@ -363,10 +367,17 @@ fn install_window_events(runner_ref: &WebRunner, window: &EventTarget) -> Result
|
|||
runner.save();
|
||||
})?;
|
||||
|
||||
// NOTE: resize is handled by `ResizeObserver` below
|
||||
// We want to handle the case of dragging the browser from one monitor to another,
|
||||
// which can cause the DPR to change without any resize event (e.g. Safari).
|
||||
install_dpr_change_event(runner_ref)?;
|
||||
|
||||
// No need to subscribe to "resize": we already subscribe to the canvas
|
||||
// size using a ResizeObserver, and we also subscribe to DPR changes of the monitor.
|
||||
for event_name in &["load", "pagehide", "pageshow"] {
|
||||
runner_ref.add_event_listener(window, event_name, move |_: web_sys::Event, runner| {
|
||||
// log::debug!("{event_name:?}");
|
||||
if DEBUG_RESIZE {
|
||||
log::debug!("{event_name:?}");
|
||||
}
|
||||
runner.needs_repaint.repaint_asap();
|
||||
})?;
|
||||
}
|
||||
|
|
@ -380,6 +391,48 @@ fn install_window_events(runner_ref: &WebRunner, window: &EventTarget) -> Result
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn install_dpr_change_event(web_runner: &WebRunner) -> Result<(), JsValue> {
|
||||
let original_dpr = native_pixels_per_point();
|
||||
|
||||
let window = web_sys::window().unwrap();
|
||||
let Some(media_query_list) =
|
||||
window.match_media(&format!("(resolution: {original_dpr}dppx)"))?
|
||||
else {
|
||||
log::error!(
|
||||
"Failed to create MediaQueryList: eframe won't be able to detect changes in DPR"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let closure = move |_: web_sys::Event, app_runner: &mut AppRunner, web_runner: &WebRunner| {
|
||||
let new_dpr = native_pixels_per_point();
|
||||
log::debug!("Device Pixel Ratio changed from {original_dpr} to {new_dpr}");
|
||||
|
||||
if true {
|
||||
// Explicitly resize canvas to match the new DPR.
|
||||
// This is a bit ugly, but I haven't found a better way to do it.
|
||||
let canvas = app_runner.canvas();
|
||||
canvas.set_width((canvas.width() as f32 * new_dpr / original_dpr).round() as _);
|
||||
canvas.set_height((canvas.height() as f32 * new_dpr / original_dpr).round() as _);
|
||||
log::debug!("Resized canvas to {}x{}", canvas.width(), canvas.height());
|
||||
}
|
||||
|
||||
// It may be tempting to call `resize_observer.observe(&canvas)` here,
|
||||
// but unfortunately this has no effect.
|
||||
|
||||
if let Err(err) = install_dpr_change_event(web_runner) {
|
||||
log::error!(
|
||||
"Failed to install DPR change event: {}",
|
||||
string_from_js_value(&err)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let options = web_sys::AddEventListenerOptions::default();
|
||||
options.set_once(true);
|
||||
web_runner.add_event_listener_ex(&media_query_list, "change", &options, closure)
|
||||
}
|
||||
|
||||
fn install_color_scheme_change_event(
|
||||
runner_ref: &WebRunner,
|
||||
window: &web_sys::Window,
|
||||
|
|
@ -813,53 +866,79 @@ fn install_drag_and_drop(runner_ref: &WebRunner, target: &EventTarget) -> Result
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Install a `ResizeObserver` to observe changes to the size of the canvas.
|
||||
///
|
||||
/// This is the only way to ensure a canvas size change without an associated window `resize` event
|
||||
/// actually results in a resize of the canvas.
|
||||
/// A `ResizeObserver` is used to observe changes to the size of the canvas.
|
||||
///
|
||||
/// The resize observer is called the by the browser at `observe` time, instead of just on the first actual resize.
|
||||
/// We use that to trigger the first `request_animation_frame` _after_ updating the size of the canvas to the correct dimensions,
|
||||
/// to avoid [#4622](https://github.com/emilk/egui/issues/4622).
|
||||
pub(crate) fn install_resize_observer(runner_ref: &WebRunner) -> Result<(), JsValue> {
|
||||
let closure = Closure::wrap(Box::new({
|
||||
let runner_ref = runner_ref.clone();
|
||||
move |entries: js_sys::Array| {
|
||||
// Only call the wrapped closure if the egui code has not panicked
|
||||
if let Some(mut runner_lock) = runner_ref.try_lock() {
|
||||
let canvas = runner_lock.canvas();
|
||||
let (width, height) = match get_display_size(&entries) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
log::error!("{}", super::string_from_js_value(&err));
|
||||
return;
|
||||
pub struct ResizeObserverContext {
|
||||
observer: web_sys::ResizeObserver,
|
||||
|
||||
// Kept so it is not dropped until we are done with it.
|
||||
_closure: Closure<dyn FnMut(js_sys::Array)>,
|
||||
}
|
||||
|
||||
impl Drop for ResizeObserverContext {
|
||||
fn drop(&mut self) {
|
||||
self.observer.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
impl ResizeObserverContext {
|
||||
pub fn new(runner_ref: &WebRunner) -> Result<Self, JsValue> {
|
||||
let closure = Closure::wrap(Box::new({
|
||||
let runner_ref = runner_ref.clone();
|
||||
move |entries: js_sys::Array| {
|
||||
if DEBUG_RESIZE {
|
||||
// log::info!("ResizeObserverContext callback");
|
||||
}
|
||||
// Only call the wrapped closure if the egui code has not panicked
|
||||
if let Some(mut runner_lock) = runner_ref.try_lock() {
|
||||
let canvas = runner_lock.canvas();
|
||||
let (width, height) = match get_display_size(&entries) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
log::error!("{}", super::string_from_js_value(&err));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if DEBUG_RESIZE {
|
||||
log::info!(
|
||||
"ResizeObserver: new canvas size: {width}x{height}, DPR: {}",
|
||||
web_sys::window().unwrap().device_pixel_ratio()
|
||||
);
|
||||
}
|
||||
};
|
||||
canvas.set_width(width);
|
||||
canvas.set_height(height);
|
||||
canvas.set_width(width);
|
||||
canvas.set_height(height);
|
||||
|
||||
// force an immediate repaint
|
||||
runner_lock.needs_repaint.repaint_asap();
|
||||
paint_if_needed(&mut runner_lock);
|
||||
drop(runner_lock);
|
||||
// we rely on the resize observer to trigger the first `request_animation_frame`:
|
||||
if let Err(err) = runner_ref.request_animation_frame() {
|
||||
log::error!("{}", super::string_from_js_value(&err));
|
||||
};
|
||||
// force an immediate repaint
|
||||
runner_lock.needs_repaint.repaint_asap();
|
||||
paint_if_needed(&mut runner_lock);
|
||||
drop(runner_lock);
|
||||
// we rely on the resize observer to trigger the first `request_animation_frame`:
|
||||
if let Err(err) = runner_ref.request_animation_frame() {
|
||||
log::error!("{}", super::string_from_js_value(&err));
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}) as Box<dyn FnMut(js_sys::Array)>);
|
||||
}) as Box<dyn FnMut(js_sys::Array)>);
|
||||
|
||||
let observer = web_sys::ResizeObserver::new(closure.as_ref().unchecked_ref())?;
|
||||
let options = web_sys::ResizeObserverOptions::new();
|
||||
options.set_box(web_sys::ResizeObserverBoxOptions::ContentBox);
|
||||
if let Some(runner_lock) = runner_ref.try_lock() {
|
||||
observer.observe_with_options(runner_lock.canvas(), &options);
|
||||
drop(runner_lock);
|
||||
runner_ref.set_resize_observer(observer, closure);
|
||||
let observer = web_sys::ResizeObserver::new(closure.as_ref().unchecked_ref())?;
|
||||
|
||||
Ok(Self {
|
||||
observer,
|
||||
_closure: closure,
|
||||
})
|
||||
}
|
||||
|
||||
Ok(())
|
||||
pub fn observe(&self, canvas: &web_sys::HtmlCanvasElement) {
|
||||
if DEBUG_RESIZE {
|
||||
log::info!("Calling observe on canvas…");
|
||||
}
|
||||
let options = web_sys::ResizeObserverOptions::new();
|
||||
options.set_box(web_sys::ResizeObserverBoxOptions::ContentBox);
|
||||
self.observer.observe_with_options(canvas, &options);
|
||||
}
|
||||
}
|
||||
|
||||
// Code ported to Rust from:
|
||||
|
|
@ -878,6 +957,10 @@ fn get_display_size(resize_observer_entries: &js_sys::Array) -> Result<(u32, u32
|
|||
width = size.inline_size();
|
||||
height = size.block_size();
|
||||
dpr = 1.0; // no need to apply
|
||||
|
||||
if DEBUG_RESIZE {
|
||||
// log::info!("devicePixelContentBoxSize {width}x{height}");
|
||||
}
|
||||
} else if JsValue::from_str("contentBoxSize").js_in(entry.as_ref()) {
|
||||
let content_box_size = entry.content_box_size();
|
||||
let idx0 = content_box_size.at(0);
|
||||
|
|
@ -892,6 +975,9 @@ fn get_display_size(resize_observer_entries: &js_sys::Array) -> Result<(u32, u32
|
|||
width = size.inline_size();
|
||||
height = size.block_size();
|
||||
}
|
||||
if DEBUG_RESIZE {
|
||||
log::info!("contentBoxSize {width}x{height}");
|
||||
}
|
||||
} else {
|
||||
// legacy
|
||||
let content_rect = entry.content_rect();
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ use input::{
|
|||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Debug browser resizing?
|
||||
const DEBUG_RESIZE: bool = false;
|
||||
|
||||
pub(crate) fn string_from_js_value(value: &JsValue) -> String {
|
||||
value.as_string().unwrap_or_else(|| format!("{value:#?}"))
|
||||
}
|
||||
|
|
@ -152,7 +155,10 @@ fn canvas_content_rect(canvas: &web_sys::HtmlCanvasElement) -> egui::Rect {
|
|||
}
|
||||
|
||||
fn canvas_size_in_points(canvas: &web_sys::HtmlCanvasElement, ctx: &egui::Context) -> egui::Vec2 {
|
||||
let pixels_per_point = ctx.pixels_per_point();
|
||||
// ctx.pixels_per_point can be outdated
|
||||
|
||||
let pixels_per_point = ctx.zoom_factor() * native_pixels_per_point();
|
||||
|
||||
egui::vec2(
|
||||
canvas.width() as f32 / pixels_per_point,
|
||||
canvas.height() as f32 / pixels_per_point,
|
||||
|
|
@ -352,3 +358,8 @@ pub fn percent_decode(s: &str) -> String {
|
|||
.decode_utf8_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Are we running inside the Safari browser?
|
||||
pub fn is_safari_browser() -> bool {
|
||||
web_sys::window().is_some_and(|window| window.has_own_property(&JsValue::from("safari")))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ use wasm_bindgen::prelude::*;
|
|||
|
||||
use crate::{epi, App};
|
||||
|
||||
use super::{events, text_agent::TextAgent, AppRunner, PanicHandler};
|
||||
use super::{
|
||||
events::{self, ResizeObserverContext},
|
||||
text_agent::TextAgent,
|
||||
AppRunner, PanicHandler,
|
||||
};
|
||||
|
||||
/// This is how `eframe` runs your web application
|
||||
///
|
||||
|
|
@ -18,7 +22,7 @@ pub struct WebRunner {
|
|||
|
||||
/// If we ever panic during running, this `RefCell` is poisoned.
|
||||
/// So before we use it, we need to check [`Self::panic_handler`].
|
||||
runner: Rc<RefCell<Option<AppRunner>>>,
|
||||
app_runner: Rc<RefCell<Option<AppRunner>>>,
|
||||
|
||||
/// In case of a panic, unsubscribe these.
|
||||
/// They have to be in a separate `Rc` so that we don't need to pass them to
|
||||
|
|
@ -39,7 +43,7 @@ impl WebRunner {
|
|||
|
||||
Self {
|
||||
panic_handler,
|
||||
runner: Rc::new(RefCell::new(None)),
|
||||
app_runner: Rc::new(RefCell::new(None)),
|
||||
events_to_unsubscribe: Rc::new(RefCell::new(Default::default())),
|
||||
frame: Default::default(),
|
||||
resize_observer: Default::default(),
|
||||
|
|
@ -58,28 +62,33 @@ impl WebRunner {
|
|||
) -> Result<(), JsValue> {
|
||||
self.destroy();
|
||||
|
||||
let text_agent = TextAgent::attach(self)?;
|
||||
|
||||
let runner = AppRunner::new(canvas, web_options, app_creator, text_agent).await?;
|
||||
|
||||
{
|
||||
// Make sure the canvas can be given focus.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex
|
||||
runner.canvas().set_tab_index(0);
|
||||
canvas.set_tab_index(0);
|
||||
|
||||
// Don't outline the canvas when it has focus:
|
||||
runner.canvas().style().set_property("outline", "none")?;
|
||||
canvas.style().set_property("outline", "none")?;
|
||||
}
|
||||
|
||||
self.runner.replace(Some(runner));
|
||||
let text_agent = TextAgent::attach(self)?;
|
||||
|
||||
{
|
||||
events::install_event_handlers(self)?;
|
||||
let resize_observer = events::ResizeObserverContext::new(self)?;
|
||||
|
||||
// The resize observer handles calling `request_animation_frame` to start the render loop.
|
||||
events::install_resize_observer(self)?;
|
||||
// This will (eventually) result in a `request_animation_frame` to start the render loop.
|
||||
resize_observer.observe(&canvas);
|
||||
|
||||
self.resize_observer.replace(Some(resize_observer));
|
||||
}
|
||||
|
||||
{
|
||||
let app_runner = AppRunner::new(canvas, web_options, app_creator, text_agent).await?;
|
||||
self.app_runner.replace(Some(app_runner));
|
||||
}
|
||||
|
||||
events::install_event_handlers(self)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -109,10 +118,7 @@ impl WebRunner {
|
|||
}
|
||||
}
|
||||
|
||||
if let Some(context) = self.resize_observer.take() {
|
||||
context.resize_observer.disconnect();
|
||||
drop(context.closure);
|
||||
}
|
||||
self.resize_observer.replace(None);
|
||||
}
|
||||
|
||||
/// Shut down eframe and clean up resources.
|
||||
|
|
@ -124,7 +130,7 @@ impl WebRunner {
|
|||
window.cancel_animation_frame(frame.id).ok();
|
||||
}
|
||||
|
||||
if let Some(runner) = self.runner.replace(None) {
|
||||
if let Some(runner) = self.app_runner.replace(None) {
|
||||
runner.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -138,7 +144,7 @@ impl WebRunner {
|
|||
self.unsubscribe_from_all_events();
|
||||
None
|
||||
} else {
|
||||
let lock = self.runner.try_borrow_mut().ok()?;
|
||||
let lock = self.app_runner.try_borrow_mut().ok()?;
|
||||
std::cell::RefMut::filter_map(lock, |lock| -> Option<&mut AppRunner> { lock.as_mut() })
|
||||
.ok()
|
||||
}
|
||||
|
|
@ -166,20 +172,45 @@ impl WebRunner {
|
|||
event_name: &'static str,
|
||||
mut closure: impl FnMut(E, &mut AppRunner) + 'static,
|
||||
) -> Result<(), wasm_bindgen::JsValue> {
|
||||
let runner_ref = self.clone();
|
||||
let options = web_sys::AddEventListenerOptions::default();
|
||||
self.add_event_listener_ex(
|
||||
target,
|
||||
event_name,
|
||||
&options,
|
||||
move |event, app_runner, _web_runner| closure(event, app_runner),
|
||||
)
|
||||
}
|
||||
|
||||
/// Convenience function to reduce boilerplate and ensure that all event handlers
|
||||
/// are dealt with in the same way.
|
||||
///
|
||||
/// All events added with this method will automatically be unsubscribed on panic,
|
||||
/// or when [`Self::destroy`] is called.
|
||||
pub fn add_event_listener_ex<E: wasm_bindgen::JsCast>(
|
||||
&self,
|
||||
target: &web_sys::EventTarget,
|
||||
event_name: &'static str,
|
||||
options: &web_sys::AddEventListenerOptions,
|
||||
mut closure: impl FnMut(E, &mut AppRunner, &Self) + 'static,
|
||||
) -> Result<(), wasm_bindgen::JsValue> {
|
||||
let web_runner = self.clone();
|
||||
|
||||
// Create a JS closure based on the FnMut provided
|
||||
let closure = Closure::wrap(Box::new(move |event: web_sys::Event| {
|
||||
// Only call the wrapped closure if the egui code has not panicked
|
||||
if let Some(mut runner_lock) = runner_ref.try_lock() {
|
||||
if let Some(mut runner_lock) = web_runner.try_lock() {
|
||||
// Cast the event to the expected event type
|
||||
let event = event.unchecked_into::<E>();
|
||||
closure(event, &mut runner_lock);
|
||||
closure(event, &mut runner_lock, &web_runner);
|
||||
}
|
||||
}) as Box<dyn FnMut(web_sys::Event)>);
|
||||
|
||||
// Add the event listener to the target
|
||||
target.add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref())?;
|
||||
target.add_event_listener_with_callback_and_add_event_listener_options(
|
||||
event_name,
|
||||
closure.as_ref().unchecked_ref(),
|
||||
options,
|
||||
)?;
|
||||
|
||||
let handle = TargetEvent {
|
||||
target: target.clone(),
|
||||
|
|
@ -208,13 +239,13 @@ impl WebRunner {
|
|||
|
||||
let window = web_sys::window().unwrap();
|
||||
let closure = Closure::once({
|
||||
let runner_ref = self.clone();
|
||||
let web_runner = self.clone();
|
||||
move || {
|
||||
// We can paint now, so clear the animation frame.
|
||||
// This drops the `closure` and allows another
|
||||
// animation frame to be scheduled
|
||||
let _ = runner_ref.frame.take();
|
||||
events::paint_and_schedule(&runner_ref)
|
||||
let _ = web_runner.frame.take();
|
||||
events::paint_and_schedule(&web_runner)
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -226,19 +257,6 @@ impl WebRunner {
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn set_resize_observer(
|
||||
&self,
|
||||
resize_observer: web_sys::ResizeObserver,
|
||||
closure: Closure<dyn FnMut(js_sys::Array)>,
|
||||
) {
|
||||
self.resize_observer
|
||||
.borrow_mut()
|
||||
.replace(ResizeObserverContext {
|
||||
resize_observer,
|
||||
closure,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
|
@ -253,11 +271,6 @@ struct AnimationFrameRequest {
|
|||
_closure: Closure<dyn FnMut() -> Result<(), JsValue>>,
|
||||
}
|
||||
|
||||
struct ResizeObserverContext {
|
||||
resize_observer: web_sys::ResizeObserver,
|
||||
closure: Closure<dyn FnMut(js_sys::Array)>,
|
||||
}
|
||||
|
||||
struct TargetEvent {
|
||||
target: web_sys::EventTarget,
|
||||
event_name: String,
|
||||
|
|
|
|||
Loading…
Reference in New Issue