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>
This commit is contained in:
Mateusz Sych
2026-10-23 13:42:27 +00:00
committed by GitHub
parent 867df396ea
commit 94279fe059
4 changed files with 61 additions and 1 deletions
+1 -1
View File
@@ -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",
] }
+37
View File
@@ -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::<dyn FnMut(web_sys::KeyboardEvent)>::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<std::cell::RefCell<Option<Result<Vec<u8>, 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::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
@@ -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));
}
}
+1
View File
@@ -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.
+22
View File
@@ -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); };