diff --git a/crates/eframe/src/web/app_runner.rs b/crates/eframe/src/web/app_runner.rs index a9de9e1f..894b7dca 100644 --- a/crates/eframe/src/web/app_runner.rs +++ b/crates/eframe/src/web/app_runner.rs @@ -187,6 +187,10 @@ impl AppRunner { /// /// The result can be painted later with a call to [`Self::run_and_paint`] or [`Self::paint`]. pub fn logic(&mut self) { + // We sometimes miss blur/focus events due to the text agent, so let's just poll each frame: + self.input + .set_focus(super::has_focus(self.canvas()) || self.text_agent.has_focus()); + let canvas_size = super::canvas_size_in_points(self.canvas(), self.egui_ctx()); let mut raw_input = self.input.new_frame(canvas_size); diff --git a/crates/eframe/src/web/backend.rs b/crates/eframe/src/web/backend.rs index 74853abe..fa87bf3b 100644 --- a/crates/eframe/src/web/backend.rs +++ b/crates/eframe/src/web/backend.rs @@ -36,8 +36,12 @@ impl WebInput { raw_input } - /// On alt-tab and similar. - pub fn on_web_page_focus_change(&mut self, focused: bool) { + /// On alt-tab, or user clicking another HTML element. + pub fn set_focus(&mut self, focused: bool) { + if self.raw.focused == focused { + return; + } + // log::debug!("on_web_page_focus_change: {focused}"); self.raw.modifiers = egui::Modifiers::default(); // Avoid sticky modifier keys on alt-tab: self.raw.focused = focused; diff --git a/crates/eframe/src/web/events.rs b/crates/eframe/src/web/events.rs index a226af50..ba895f60 100644 --- a/crates/eframe/src/web/events.rs +++ b/crates/eframe/src/web/events.rs @@ -2,6 +2,9 @@ use web_sys::EventTarget; use super::*; +// TODO(emilk): there are more calls to `prevent_default` and `stop_propagaton` +// than what is probably needed. + // ------------------------------------------------------------------------ /// Calls `request_animation_frame` to schedule repaint. @@ -54,10 +57,9 @@ pub(crate) fn install_event_handlers(runner_ref: &WebRunner) -> Result<(), JsVal let document = window.document().unwrap(); let canvas = runner_ref.try_lock().unwrap().canvas().clone(); - install_blur_focus(runner_ref, &document)?; - install_blur_focus(runner_ref, &window)?; + install_blur_focus(runner_ref, &canvas)?; - prevent_default( + prevent_default_and_stop_propagation( runner_ref, &canvas, &[ @@ -69,8 +71,11 @@ pub(crate) fn install_event_handlers(runner_ref: &WebRunner) -> Result<(), JsVal ], )?; - install_keydown(runner_ref, &document)?; - install_keyup(runner_ref, &document)?; + install_keydown(runner_ref, &canvas)?; + install_keyup(runner_ref, &canvas)?; + + // It seems copy/cut/paste events only work on the document, + // so we check if we have focus inside of the handler. install_copy_cut_paste(runner_ref, &document)?; install_mousedown(runner_ref, &canvas)?; @@ -94,9 +99,12 @@ pub(crate) fn install_event_handlers(runner_ref: &WebRunner) -> Result<(), JsVal } fn install_blur_focus(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { + // NOTE: because of the text agent we sometime miss 'blur' events, + // so we also poll the focus state each frame in `AppRunner::logic`. for event_name in ["blur", "focus"] { let closure = move |_event: web_sys::MouseEvent, runner: &mut AppRunner| { // log::debug!("{event_name:?}"); + let has_focus = event_name == "focus"; if !has_focus { @@ -104,7 +112,7 @@ fn install_blur_focus(runner_ref: &WebRunner, target: &EventTarget) -> Result<() runner.save(); } - runner.input.on_web_page_focus_change(has_focus); + runner.input.set_focus(has_focus); runner.egui_ctx().request_repaint(); }; @@ -118,94 +126,140 @@ fn install_keydown(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), J target, "keydown", |event: web_sys::KeyboardEvent, runner| { - if event.is_composing() || event.key_code() == 229 { - // https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/ + if !runner.input.raw.focused { return; } let modifiers = modifiers_from_kb_event(&event); - runner.input.raw.modifiers = modifiers; - - let key = event.key(); - let egui_key = translate_key(&key); - - if let Some(key) = egui_key { - runner.input.raw.events.push(egui::Event::Key { - key, - physical_key: None, // TODO(fornwall) - pressed: true, - repeat: false, // egui will fill this in for us! - modifiers, - }); - } if !modifiers.ctrl && !modifiers.command - && !should_ignore_key(&key) // When text agent is focused, it is responsible for handling input events && !runner.text_agent.has_focus() { - runner.input.raw.events.push(egui::Event::Text(key)); - } - runner.needs_repaint.repaint_asap(); + if let Some(text) = text_from_keyboard_event(&event) { + runner.input.raw.events.push(egui::Event::Text(text)); + runner.needs_repaint.repaint_asap(); - let egui_wants_keyboard = runner.egui_ctx().wants_keyboard_input(); + // If this is indeed text, then prevent any other action. + event.prevent_default(); - #[allow(clippy::if_same_then_else)] - let prevent_default = if egui_key == Some(egui::Key::Tab) { - // Always prevent moving cursor to url bar. - // egui wants to use tab to move to the next text field. - true - } else if egui_key == Some(egui::Key::P) { - #[allow(clippy::needless_bool)] - if modifiers.ctrl || modifiers.command || modifiers.mac_cmd { - true // Prevent ctrl-P opening the print dialog. Users may want to use it for a command palette. - } else { - false // let normal P:s through + // Assume egui uses all key events, and don't let them propagate to parent elements. + event.stop_propagation(); } - } 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 - }; - - // log::debug!( - // "On key-down {:?}, egui_wants_keyboard: {}, prevent_default: {}", - // event.key().as_str(), - // egui_wants_keyboard, - // prevent_default - // ); - - if prevent_default { - event.prevent_default(); - // event.stop_propagation(); } + + on_keydown(event, runner); }, ) } -fn install_keyup(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { - runner_ref.add_event_listener(target, "keyup", |event: web_sys::KeyboardEvent, runner| { - let modifiers = modifiers_from_kb_event(&event); - runner.input.raw.modifiers = modifiers; - if let Some(key) = translate_key(&event.key()) { - runner.input.raw.events.push(egui::Event::Key { - key, - physical_key: None, // TODO(fornwall) - pressed: false, - repeat: false, - modifiers, - }); - } +#[allow(clippy::needless_pass_by_value)] // So that we can pass it directly to `add_event_listener` +pub(crate) fn on_keydown(event: web_sys::KeyboardEvent, runner: &mut AppRunner) { + let has_focus = runner.input.raw.focused; + if !has_focus { + return; + } + + if event.is_composing() || event.key_code() == 229 { + // https://web.archive.org/web/20200526195704/https://www.fxsitecompat.dev/en-CA/docs/2018/keydown-and-keyup-events-are-now-fired-during-ime-composition/ + return; + } + + let modifiers = modifiers_from_kb_event(&event); + runner.input.raw.modifiers = modifiers; + + let key = event.key(); + let egui_key = translate_key(&key); + + if let Some(egui_key) = egui_key { + runner.input.raw.events.push(egui::Event::Key { + key: egui_key, + physical_key: None, // TODO(fornwall) + pressed: true, + repeat: false, // egui will fill this in for us! + modifiers, + }); runner.needs_repaint.repaint_asap(); - }) + + let prevent_default = should_prevent_default_for_key(runner, &modifiers, egui_key); + + // log::debug!( + // "On keydown {:?} {egui_key:?}, has_focus: {has_focus}, egui_wants_keyboard: {}, prevent_default: {prevent_default}", + // event.key().as_str(), + // runner.egui_ctx().wants_keyboard_input() + // ); + + if prevent_default { + event.prevent_default(); + } + + // Assume egui uses all key events, and don't let them propagate to parent elements. + event.stop_propagation(); + } +} + +/// If the canvas (or text agent) has focus: +/// should we prevent the default browser event action when the user presses this key? +fn should_prevent_default_for_key( + runner: &AppRunner, + modifiers: &egui::Modifiers, + egui_key: egui::Key, +) -> bool { + // NOTE: We never want to prevent: + // * F5 / cmd-R (refresh) + // * cmd-shift-C (debug tools) + // * cmd/ctrl-c/v/x (lest we prevent copy/paste/cut events) + + // Prevent ctrl-P from opening the print dialog. Users may want to use it for a command palette. + if egui_key == egui::Key::P && (modifiers.ctrl || modifiers.command || modifiers.mac_cmd) { + return true; + } + + if egui_key == egui::Key::Space && !runner.text_agent.has_focus() { + // Space scrolls the web page, but we don't want that while canvas has focus + // However, don't prevent it if text agent has focus, or we can't type space! + return true; + } + + matches!( + egui_key, + // Prevent browser from focusing the next HTML element. + // egui uses Tab to move focus within the egui app. + egui::Key::Tab + + // So we don't go back to previous page while canvas has focus + | egui::Key::Backspace + + // Don't scroll web page while canvas has focus. + // Also, cmd-left is "back" on Mac (https://github.com/emilk/egui/issues/58) + | egui::Key::ArrowDown | egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp + ) +} + +fn install_keyup(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { + runner_ref.add_event_listener(target, "keyup", on_keyup) +} + +#[allow(clippy::needless_pass_by_value)] // So that we can pass it directly to `add_event_listener` +pub(crate) fn on_keyup(event: web_sys::KeyboardEvent, runner: &mut AppRunner) { + let modifiers = modifiers_from_kb_event(&event); + runner.input.raw.modifiers = modifiers; + if let Some(key) = translate_key(&event.key()) { + runner.input.raw.events.push(egui::Event::Key { + key, + physical_key: None, // TODO(fornwall) + pressed: false, + repeat: false, + modifiers, + }); + } + runner.needs_repaint.repaint_asap(); + + let has_focus = runner.input.raw.focused; + if has_focus { + // Assume egui uses all key events, and don't let them propagate to parent elements. + event.stop_propagation(); + } } fn install_copy_cut_paste(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { @@ -214,7 +268,7 @@ fn install_copy_cut_paste(runner_ref: &WebRunner, target: &EventTarget) -> Resul 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() { + if !text.is_empty() && runner.input.raw.focused { runner.input.raw.events.push(egui::Event::Paste(text)); runner.needs_repaint.repaint_asap(); } @@ -226,14 +280,16 @@ fn install_copy_cut_paste(runner_ref: &WebRunner, target: &EventTarget) -> Resul #[cfg(web_sys_unstable_apis)] runner_ref.add_event_listener(target, "cut", |event: web_sys::ClipboardEvent, runner| { - runner.input.raw.events.push(egui::Event::Cut); + if runner.input.raw.focused { + runner.input.raw.events.push(egui::Event::Cut); - // In Safari we are only allowed to write to the clipboard during the - // event callback, which is why we run the app logic here and now: - runner.logic(); + // In Safari we are only allowed to write to the clipboard during the + // event callback, which is why we run the app logic here and now: + runner.logic(); - // Make sure we paint the output of the above logic call asap: - runner.needs_repaint.repaint_asap(); + // Make sure we paint the output of the above logic call asap: + runner.needs_repaint.repaint_asap(); + } event.stop_propagation(); event.prevent_default(); @@ -241,14 +297,16 @@ fn install_copy_cut_paste(runner_ref: &WebRunner, target: &EventTarget) -> Resul #[cfg(web_sys_unstable_apis)] runner_ref.add_event_listener(target, "copy", |event: web_sys::ClipboardEvent, runner| { - runner.input.raw.events.push(egui::Event::Copy); + if runner.input.raw.focused { + runner.input.raw.events.push(egui::Event::Copy); - // In Safari we are only allowed to write to the clipboard during the - // event callback, which is why we run the app logic here and now: - runner.logic(); + // In Safari we are only allowed to write to the clipboard during the + // event callback, which is why we run the app logic here and now: + runner.logic(); - // Make sure we paint the output of the above logic call asap: - runner.needs_repaint.repaint_asap(); + // Make sure we paint the output of the above logic call asap: + runner.needs_repaint.repaint_asap(); + } event.stop_propagation(); event.prevent_default(); @@ -299,7 +357,7 @@ pub(crate) fn install_color_scheme_change_event(runner_ref: &WebRunner) -> Resul Ok(()) } -fn prevent_default( +fn prevent_default_and_stop_propagation( runner_ref: &WebRunner, target: &EventTarget, event_names: &[&'static str], @@ -307,7 +365,7 @@ fn prevent_default( for event_name in event_names { let closure = move |event: web_sys::MouseEvent, _runner: &mut AppRunner| { event.prevent_default(); - // event.stop_propagation(); + event.stop_propagation(); // log::debug!("Preventing event {event_name:?}"); }; @@ -348,9 +406,6 @@ fn install_mousedown(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), } fn install_mousemove(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { - // NOTE: we register "mousemove" on `document` instead of just the canvas - // in order to track a dragged mouse outside the canvas. - // See https://github.com/emilk/egui/issues/3157 runner_ref.add_event_listener(target, "mousemove", |event: web_sys::MouseEvent, runner| { let modifiers = modifiers_from_mouse_event(&event); runner.input.raw.modifiers = modifiers; @@ -363,11 +418,10 @@ fn install_mousemove(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), } fn install_mouseup(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { - // Use `document` here to notice if the user releases a drag outside of the canvas. - // See https://github.com/emilk/egui/issues/3157 runner_ref.add_event_listener(target, "mouseup", |event: web_sys::MouseEvent, runner| { let modifiers = modifiers_from_mouse_event(&event); runner.input.raw.modifiers = modifiers; + if let Some(button) = button_from_mouse_event(&event) { let pos = pos_from_mouse_event(runner.canvas(), &event, runner.egui_ctx()); let modifiers = runner.input.raw.modifiers; @@ -458,8 +512,6 @@ fn install_touchmove(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), } fn install_touchend(runner_ref: &WebRunner, target: &EventTarget) -> Result<(), JsValue> { - // Use `document` here to notice if the user releases a drag outside of the canvas. - // See https://github.com/emilk/egui/issues/3157 runner_ref.add_event_listener(target, "touchend", |event: web_sys::TouchEvent, runner| { if let Some(pos) = runner.input.latest_touch_pos { let modifiers = runner.input.raw.modifiers; diff --git a/crates/eframe/src/web/input.rs b/crates/eframe/src/web/input.rs index 0f632453..c3ddf348 100644 --- a/crates/eframe/src/web/input.rs +++ b/crates/eframe/src/web/input.rs @@ -83,40 +83,50 @@ pub fn push_touches(runner: &mut AppRunner, phase: egui::TouchPhase, event: &web } } -/// 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 { +/// The text input from a keyboard event (e.g. `X` when pressing the `X` key). +pub fn text_from_keyboard_event(event: &web_sys::KeyboardEvent) -> Option { + let key = event.key(); + 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" - ) + if is_function_key { + return None; + } + + let is_control_key = matches!( + key.as_str(), + "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" + ); + + if is_control_key { + return None; + } + + Some(key) } /// Web sends all keys as strings, so it is up to us to figure out if it is diff --git a/crates/eframe/src/web/text_agent.rs b/crates/eframe/src/web/text_agent.rs index 0cd25db1..876a50e0 100644 --- a/crates/eframe/src/web/text_agent.rs +++ b/crates/eframe/src/web/text_agent.rs @@ -88,6 +88,11 @@ impl TextAgent { runner_ref.add_event_listener(&input, "compositionupdate", on_composition_update)?; runner_ref.add_event_listener(&input, "compositionend", on_composition_end)?; + // The canvas doesn't get keydown/keyup events when the text agent is focused, + // so we need to forward them to the runner: + runner_ref.add_event_listener(&input, "keydown", super::events::on_keydown)?; + runner_ref.add_event_listener(&input, "keyup", super::events::on_keyup)?; + Ok(Self { input, prev_ime_output: Default::default(), diff --git a/crates/eframe/src/web/web_runner.rs b/crates/eframe/src/web/web_runner.rs index 5005fb2f..6f6cc930 100644 --- a/crates/eframe/src/web/web_runner.rs +++ b/crates/eframe/src/web/web_runner.rs @@ -68,6 +68,16 @@ impl WebRunner { let text_agent = TextAgent::attach(self)?; let runner = AppRunner::new(canvas_id, 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); + + // Don't outline the canvas when it has focus: + runner.canvas().style().set_property("outline", "none")?; + } + self.runner.replace(Some(runner)); { diff --git a/web_demo/index.html b/web_demo/index.html index 79d59e83..76866d36 100644 --- a/web_demo/index.html +++ b/web_demo/index.html @@ -96,8 +96,8 @@ - +

Loading… @@ -175,6 +175,9 @@ console.debug("App started."); document.getElementById("center_text").innerHTML = ''; + + // Make sure the canvas is focused so it can receive keyboard events right away: + document.getElementById("the_canvas_id").focus(); } function on_error(error) { diff --git a/web_demo/multiple_apps.html b/web_demo/multiple_apps.html index 096dd2d1..6b1903cf 100644 --- a/web_demo/multiple_apps.html +++ b/web_demo/multiple_apps.html @@ -38,7 +38,7 @@ margin: 8px; padding: 8px; width: 45%; - height: 98%; + height: 110%; } .centered { @@ -94,7 +94,6 @@ Stop one app -