From 94279fe05912eefc197c1ed37eb02b2fd0aa765f Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:42:27 +0000 Subject: [PATCH] Web: Ctrl+Tab and Ctrl+Shift+Tab switch browser tabs again (#5227) (#5350) * claim: #5227 * Web: leave Ctrl+Tab and Ctrl+Shift+Tab to the browser (#5227) eframe cancels the default action of every Tab keydown on its canvas (egui moves focus with Tab), so browsers that let a page cancel their shortcuts, such as Vivaldi, could not switch tabs while SolidCraft had the keyboard. A capture listener on the window now stops Ctrl+Tab and Ctrl+Shift+Tab before they reach the canvas, without cancelling them, so the browser acts on them as usual. Plain Tab and Shift+Tab still move between the app's fields. Desktop builds are unchanged. web-api-test checks it in headless Chrome: Ctrl+Tab and Ctrl+Shift+Tab keydowns are not cancelled, Tab still is. --------- Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com> --- apps/solidcraft-web/Cargo.toml | 2 +- apps/solidcraft-web/src/main.rs | 37 +++++++++++++++++++++++++++++++++ docs/browser-api.md | 1 + xtask/src/web_offline_test.rs | 22 ++++++++++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/apps/solidcraft-web/Cargo.toml b/apps/solidcraft-web/Cargo.toml index 6bfebbb565..98d692fcb8 100644 --- a/apps/solidcraft-web/Cargo.toml +++ b/apps/solidcraft-web/Cargo.toml @@ -30,5 +30,5 @@ web-sys = { version = "0.3", features = [ "FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream", "WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", "IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode", - "BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", "Gamepad", "Performance", + "BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "MediaQueryList", "Gamepad", "Performance", ] } diff --git a/apps/solidcraft-web/src/main.rs b/apps/solidcraft-web/src/main.rs index 7722a20bc7..a6e9be2056 100644 --- a/apps/solidcraft-web/src/main.rs +++ b/apps/solidcraft-web/src/main.rs @@ -17,6 +17,14 @@ mod install; #[cfg(any(target_arch = "wasm32", test))] mod space_mouse; +/// The browser switches tabs with Ctrl+Tab and Ctrl+Shift+Tab, so the page leaves those keys to +/// it (#5227); plain Tab and Shift+Tab still move between the app's fields. (Ctrl+PageUp and +/// Ctrl+PageDown already reach the browser.) +#[cfg(any(target_arch = "wasm32", test))] +fn browser_switches_tabs(key: &str, ctrl: bool) -> bool { + ctrl && key == "Tab" +} + #[cfg(target_arch = "wasm32")] mod web { use std::rc::Rc; @@ -84,6 +92,20 @@ mod web { on_unload.forget(); } + /// eframe cancels the browser's action for every Tab it gets, so the browser couldn't switch + /// tabs while the app has the keyboard. Its tab-switching keys stop here, on the way down to + /// the canvas, before eframe sees them; the browser then acts on them as usual. + fn leave_tab_switching_to_browser(window: &web_sys::Window) { + let on_key = wasm_bindgen::closure::Closure::::new(|e: web_sys::KeyboardEvent| { + if crate::browser_switches_tabs(&e.key(), e.ctrl_key()) { + e.stop_propagation(); + } + }); + let _ = window.add_event_listener_with_callback_and_bool("keydown", on_key.as_ref().unchecked_ref(), true); + // The listener lives as long as the page. + on_key.forget(); + } + const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf"; type FontLoadResult = Rc, String>>>>; @@ -553,6 +575,7 @@ mod web { wasm_bindgen_futures::spawn_local(async { let Some(window) = web_sys::window() else { return }; ask_before_unload(&window); + leave_tab_switching_to_browser(&window); let Some(document) = window.document() else { return }; let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::().ok()) else { log::error!("missing "); @@ -675,3 +698,17 @@ fn main() { fn main() { eprintln!("solidcraft-web only runs in the browser: build it with `trunk build --release` in apps/solidcraft-web"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_ctrl_tab_is_left_to_the_browser() { + // Ctrl+Tab and Ctrl+Shift+Tab (the event's key is "Tab" either way) switch browser tabs. + assert!(browser_switches_tabs("Tab", true)); + // Tab and Shift+Tab move between the app's fields; Ctrl with other keys stays the app's. + assert!(!browser_switches_tabs("Tab", false)); + assert!(!browser_switches_tabs("s", true)); + } +} diff --git a/docs/browser-api.md b/docs/browser-api.md index 33ded3683f..0065b3a632 100644 --- a/docs/browser-api.md +++ b/docs/browser-api.md @@ -47,4 +47,5 @@ cargo xtask web-api-test --site dist/web The test starts a local server and headless Chrome, discovers commands, creates a box in the live document, checks undo and redo, and verifies error handling and the pending-request limit. +It also checks that Ctrl+Tab and Ctrl+Shift+Tab are left to the browser, which switches tabs with them. Set `CHROME` to a Chrome or Chromium executable if it is not installed in a usual location. diff --git a/xtask/src/web_offline_test.rs b/xtask/src/web_offline_test.rs index 525344a31b..98c1e3d5e3 100644 --- a/xtask/src/web_offline_test.rs +++ b/xtask/src/web_offline_test.rs @@ -87,13 +87,35 @@ pub fn run_api(site: &str) -> Result<(), String> { let browser = Chrome::launch(&chrome, &work.0.join("profile"))?; let mut cdp = browser.connect()?; cdp.call("Emulation.setDeviceMetricsOverride", json!({"width": 1280, "height": 900, "deviceScaleFactor": 1, "mobile": false}))?; + // The page has the keyboard, as a tab the user is looking at does (for KEYS_TEST). + cdp.call("Emulation.setFocusEmulationEnabled", json!({"enabled": true}))?; cdp.call("Page.navigate", json!({"url": format!("http://127.0.0.1:{}{PREFIX}?webgl", server.port)}))?; wait_for(&mut cdp, "!!window.solidcraft && !document.getElementById('solidcraft_loading')", "the browser API is ready", |v| v == true)?; let result = cdp.eval(API_TEST)?; println!("web-api-test: {result}"); + let keys = cdp.eval(KEYS_TEST)?; + println!("web-api-test: {keys}"); Ok(()) } +/// The browser's own tab switching keys reach it while the app has the keyboard (#5227): their +/// default action isn't cancelled, while Tab's is (the app moves between its fields with it). +const KEYS_TEST: &str = r#"(async () => { + const check = (condition, message) => { if (!condition) throw new Error(message); }; + const canvas = document.getElementById('solidcraft_canvas'); + canvas.focus(); + canvas.dispatchEvent(new FocusEvent('focus')); + const cancelled = init => { + const e = new KeyboardEvent('keydown', {key: 'Tab', code: 'Tab', bubbles: true, cancelable: true, ...init}); + canvas.dispatchEvent(e); + return e.defaultPrevented; + }; + check(!cancelled({ctrlKey: true}), 'Ctrl+Tab is left to the browser'); + check(!cancelled({ctrlKey: true, shiftKey: true}), 'Ctrl+Shift+Tab is left to the browser'); + check(cancelled({}), 'the app takes Tab (it has the keyboard)'); + return {ctrl_tab: 'browser', ctrl_shift_tab: 'browser', tab: 'app'}; +})()"#; + const API_TEST: &str = r#"(async () => { const app = window.solidcraft; const check = (condition, message) => { if (!condition) throw new Error(message); };