Skip to content

Commit 6680e9c

Browse files
authored
Web: Fix incorrect scale when moving to screen with new DPI (#5631)
* Closes #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.
1 parent 304c651 commit 6680e9c

File tree

5 files changed

+213
-90
lines changed

5 files changed

+213
-90
lines changed

crates/eframe/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ percent-encoding = "2.1"
211211
wasm-bindgen.workspace = true
212212
wasm-bindgen-futures.workspace = true
213213
web-sys = { workspace = true, features = [
214+
"AddEventListenerOptions",
214215
"BinaryType",
215216
"Blob",
216217
"BlobPropertyBag",

crates/eframe/src/web/app_runner.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ impl AppRunner {
5959

6060
egui_ctx.options_mut(|o| {
6161
// On web by default egui follows the zoom factor of the browser,
62-
// and lets the browser handle the zoom shortscuts.
62+
// and lets the browser handle the zoom shortcuts.
6363
// A user can still zoom egui separately by calling [`egui::Context::set_zoom_factor`].
6464
o.zoom_with_keyboard = false;
6565
o.zoom_factor = 1.0;
@@ -216,6 +216,18 @@ impl AppRunner {
216216
let canvas_size = super::canvas_size_in_points(self.canvas(), self.egui_ctx());
217217
let mut raw_input = self.input.new_frame(canvas_size);
218218

219+
if super::DEBUG_RESIZE {
220+
log::info!(
221+
"egui running at canvas size: {}x{}, DPR: {}, zoom_factor: {}. egui size: {}x{} points",
222+
self.canvas().width(),
223+
self.canvas().height(),
224+
super::native_pixels_per_point(),
225+
self.egui_ctx.zoom_factor(),
226+
canvas_size.x,
227+
canvas_size.y,
228+
);
229+
}
230+
219231
self.app.raw_input_hook(&self.egui_ctx, &mut raw_input);
220232

221233
let full_output = self.egui_ctx.run(raw_input, |egui_ctx| {

crates/eframe/src/web/events.rs

Lines changed: 130 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
use web_sys::EventTarget;
2+
3+
use crate::web::string_from_js_value;
4+
15
use super::{
26
button_from_mouse_event, location_hash, modifiers_from_kb_event, modifiers_from_mouse_event,
3-
modifiers_from_wheel_event, pos_from_mouse_event, prefers_color_scheme_dark, primary_touch_pos,
4-
push_touches, text_from_keyboard_event, theme_from_dark_mode, translate_key, AppRunner,
5-
Closure, JsCast, JsValue, WebRunner,
7+
modifiers_from_wheel_event, native_pixels_per_point, pos_from_mouse_event,
8+
prefers_color_scheme_dark, primary_touch_pos, push_touches, text_from_keyboard_event,
9+
theme_from_dark_mode, translate_key, AppRunner, Closure, JsCast, JsValue, WebRunner,
10+
DEBUG_RESIZE,
611
};
7-
use web_sys::EventTarget;
812

913
// TODO(emilk): there are more calls to `prevent_default` and `stop_propagation`
1014
// than what is probably needed.
@@ -363,10 +367,17 @@ fn install_window_events(runner_ref: &WebRunner, window: &EventTarget) -> Result
363367
runner.save();
364368
})?;
365369

366-
// NOTE: resize is handled by `ResizeObserver` below
370+
// We want to handle the case of dragging the browser from one monitor to another,
371+
// which can cause the DPR to change without any resize event (e.g. Safari).
372+
install_dpr_change_event(runner_ref)?;
373+
374+
// No need to subscribe to "resize": we already subscribe to the canvas
375+
// size using a ResizeObserver, and we also subscribe to DPR changes of the monitor.
367376
for event_name in &["load", "pagehide", "pageshow"] {
368377
runner_ref.add_event_listener(window, event_name, move |_: web_sys::Event, runner| {
369-
// log::debug!("{event_name:?}");
378+
if DEBUG_RESIZE {
379+
log::debug!("{event_name:?}");
380+
}
370381
runner.needs_repaint.repaint_asap();
371382
})?;
372383
}
@@ -380,6 +391,48 @@ fn install_window_events(runner_ref: &WebRunner, window: &EventTarget) -> Result
380391
Ok(())
381392
}
382393

394+
fn install_dpr_change_event(web_runner: &WebRunner) -> Result<(), JsValue> {
395+
let original_dpr = native_pixels_per_point();
396+
397+
let window = web_sys::window().unwrap();
398+
let Some(media_query_list) =
399+
window.match_media(&format!("(resolution: {original_dpr}dppx)"))?
400+
else {
401+
log::error!(
402+
"Failed to create MediaQueryList: eframe won't be able to detect changes in DPR"
403+
);
404+
return Ok(());
405+
};
406+
407+
let closure = move |_: web_sys::Event, app_runner: &mut AppRunner, web_runner: &WebRunner| {
408+
let new_dpr = native_pixels_per_point();
409+
log::debug!("Device Pixel Ratio changed from {original_dpr} to {new_dpr}");
410+
411+
if true {
412+
// Explicitly resize canvas to match the new DPR.
413+
// This is a bit ugly, but I haven't found a better way to do it.
414+
let canvas = app_runner.canvas();
415+
canvas.set_width((canvas.width() as f32 * new_dpr / original_dpr).round() as _);
416+
canvas.set_height((canvas.height() as f32 * new_dpr / original_dpr).round() as _);
417+
log::debug!("Resized canvas to {}x{}", canvas.width(), canvas.height());
418+
}
419+
420+
// It may be tempting to call `resize_observer.observe(&canvas)` here,
421+
// but unfortunately this has no effect.
422+
423+
if let Err(err) = install_dpr_change_event(web_runner) {
424+
log::error!(
425+
"Failed to install DPR change event: {}",
426+
string_from_js_value(&err)
427+
);
428+
}
429+
};
430+
431+
let options = web_sys::AddEventListenerOptions::default();
432+
options.set_once(true);
433+
web_runner.add_event_listener_ex(&media_query_list, "change", &options, closure)
434+
}
435+
383436
fn install_color_scheme_change_event(
384437
runner_ref: &WebRunner,
385438
window: &web_sys::Window,
@@ -813,53 +866,79 @@ fn install_drag_and_drop(runner_ref: &WebRunner, target: &EventTarget) -> Result
813866
Ok(())
814867
}
815868

816-
/// Install a `ResizeObserver` to observe changes to the size of the canvas.
817-
///
818-
/// This is the only way to ensure a canvas size change without an associated window `resize` event
819-
/// actually results in a resize of the canvas.
869+
/// A `ResizeObserver` is used to observe changes to the size of the canvas.
820870
///
821871
/// The resize observer is called the by the browser at `observe` time, instead of just on the first actual resize.
822872
/// We use that to trigger the first `request_animation_frame` _after_ updating the size of the canvas to the correct dimensions,
823873
/// to avoid [#4622](https://github.com/emilk/egui/issues/4622).
824-
pub(crate) fn install_resize_observer(runner_ref: &WebRunner) -> Result<(), JsValue> {
825-
let closure = Closure::wrap(Box::new({
826-
let runner_ref = runner_ref.clone();
827-
move |entries: js_sys::Array| {
828-
// Only call the wrapped closure if the egui code has not panicked
829-
if let Some(mut runner_lock) = runner_ref.try_lock() {
830-
let canvas = runner_lock.canvas();
831-
let (width, height) = match get_display_size(&entries) {
832-
Ok(v) => v,
833-
Err(err) => {
834-
log::error!("{}", super::string_from_js_value(&err));
835-
return;
874+
pub struct ResizeObserverContext {
875+
observer: web_sys::ResizeObserver,
876+
877+
// Kept so it is not dropped until we are done with it.
878+
_closure: Closure<dyn FnMut(js_sys::Array)>,
879+
}
880+
881+
impl Drop for ResizeObserverContext {
882+
fn drop(&mut self) {
883+
self.observer.disconnect();
884+
}
885+
}
886+
887+
impl ResizeObserverContext {
888+
pub fn new(runner_ref: &WebRunner) -> Result<Self, JsValue> {
889+
let closure = Closure::wrap(Box::new({
890+
let runner_ref = runner_ref.clone();
891+
move |entries: js_sys::Array| {
892+
if DEBUG_RESIZE {
893+
// log::info!("ResizeObserverContext callback");
894+
}
895+
// Only call the wrapped closure if the egui code has not panicked
896+
if let Some(mut runner_lock) = runner_ref.try_lock() {
897+
let canvas = runner_lock.canvas();
898+
let (width, height) = match get_display_size(&entries) {
899+
Ok(v) => v,
900+
Err(err) => {
901+
log::error!("{}", super::string_from_js_value(&err));
902+
return;
903+
}
904+
};
905+
if DEBUG_RESIZE {
906+
log::info!(
907+
"ResizeObserver: new canvas size: {width}x{height}, DPR: {}",
908+
web_sys::window().unwrap().device_pixel_ratio()
909+
);
836910
}
837-
};
838-
canvas.set_width(width);
839-
canvas.set_height(height);
840-
841-
// force an immediate repaint
842-
runner_lock.needs_repaint.repaint_asap();
843-
paint_if_needed(&mut runner_lock);
844-
drop(runner_lock);
845-
// we rely on the resize observer to trigger the first `request_animation_frame`:
846-
if let Err(err) = runner_ref.request_animation_frame() {
847-
log::error!("{}", super::string_from_js_value(&err));
848-
};
911+
canvas.set_width(width);
912+
canvas.set_height(height);
913+
914+
// force an immediate repaint
915+
runner_lock.needs_repaint.repaint_asap();
916+
paint_if_needed(&mut runner_lock);
917+
drop(runner_lock);
918+
// we rely on the resize observer to trigger the first `request_animation_frame`:
919+
if let Err(err) = runner_ref.request_animation_frame() {
920+
log::error!("{}", super::string_from_js_value(&err));
921+
};
922+
}
849923
}
850-
}
851-
}) as Box<dyn FnMut(js_sys::Array)>);
924+
}) as Box<dyn FnMut(js_sys::Array)>);
852925

853-
let observer = web_sys::ResizeObserver::new(closure.as_ref().unchecked_ref())?;
854-
let options = web_sys::ResizeObserverOptions::new();
855-
options.set_box(web_sys::ResizeObserverBoxOptions::ContentBox);
856-
if let Some(runner_lock) = runner_ref.try_lock() {
857-
observer.observe_with_options(runner_lock.canvas(), &options);
858-
drop(runner_lock);
859-
runner_ref.set_resize_observer(observer, closure);
926+
let observer = web_sys::ResizeObserver::new(closure.as_ref().unchecked_ref())?;
927+
928+
Ok(Self {
929+
observer,
930+
_closure: closure,
931+
})
860932
}
861933

862-
Ok(())
934+
pub fn observe(&self, canvas: &web_sys::HtmlCanvasElement) {
935+
if DEBUG_RESIZE {
936+
log::info!("Calling observe on canvas…");
937+
}
938+
let options = web_sys::ResizeObserverOptions::new();
939+
options.set_box(web_sys::ResizeObserverBoxOptions::ContentBox);
940+
self.observer.observe_with_options(canvas, &options);
941+
}
863942
}
864943

865944
// Code ported to Rust from:
@@ -878,6 +957,10 @@ fn get_display_size(resize_observer_entries: &js_sys::Array) -> Result<(u32, u32
878957
width = size.inline_size();
879958
height = size.block_size();
880959
dpr = 1.0; // no need to apply
960+
961+
if DEBUG_RESIZE {
962+
// log::info!("devicePixelContentBoxSize {width}x{height}");
963+
}
881964
} else if JsValue::from_str("contentBoxSize").js_in(entry.as_ref()) {
882965
let content_box_size = entry.content_box_size();
883966
let idx0 = content_box_size.at(0);
@@ -892,6 +975,9 @@ fn get_display_size(resize_observer_entries: &js_sys::Array) -> Result<(u32, u32
892975
width = size.inline_size();
893976
height = size.block_size();
894977
}
978+
if DEBUG_RESIZE {
979+
log::info!("contentBoxSize {width}x{height}");
980+
}
895981
} else {
896982
// legacy
897983
let content_rect = entry.content_rect();

crates/eframe/src/web/mod.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ use input::{
5151

5252
// ----------------------------------------------------------------------------
5353

54+
/// Debug browser resizing?
55+
const DEBUG_RESIZE: bool = false;
56+
5457
pub(crate) fn string_from_js_value(value: &JsValue) -> String {
5558
value.as_string().unwrap_or_else(|| format!("{value:#?}"))
5659
}
@@ -152,7 +155,10 @@ fn canvas_content_rect(canvas: &web_sys::HtmlCanvasElement) -> egui::Rect {
152155
}
153156

154157
fn canvas_size_in_points(canvas: &web_sys::HtmlCanvasElement, ctx: &egui::Context) -> egui::Vec2 {
155-
let pixels_per_point = ctx.pixels_per_point();
158+
// ctx.pixels_per_point can be outdated
159+
160+
let pixels_per_point = ctx.zoom_factor() * native_pixels_per_point();
161+
156162
egui::vec2(
157163
canvas.width() as f32 / pixels_per_point,
158164
canvas.height() as f32 / pixels_per_point,
@@ -352,3 +358,8 @@ pub fn percent_decode(s: &str) -> String {
352358
.decode_utf8_lossy()
353359
.to_string()
354360
}
361+
362+
/// Are we running inside the Safari browser?
363+
pub fn is_safari_browser() -> bool {
364+
web_sys::window().is_some_and(|window| window.has_own_property(&JsValue::from("safari")))
365+
}

0 commit comments

Comments
 (0)