diff --git a/studio-web/src/lib/canvas-provider.element.ts b/studio-web/src/lib/canvas-provider.element.ts index b584195..a1a94d4 100644 --- a/studio-web/src/lib/canvas-provider.element.ts +++ b/studio-web/src/lib/canvas-provider.element.ts @@ -74,6 +74,13 @@ export class CanvasProviderElement @on("keydown") @on("keyup") _pipeKeyboardEvents(event: KeyboardEvent): void { + if (event.repeat) return; + if (this.#canvas != null) { + // If we own the canvas, we're going to catch events emitting from the + // canvas and then repeat them, eventually causing a stack overflow + return; + } + // When the user interacts with one of the web UI elements, the Bevy // canvas will lose focus, preventing it from receiving keyboard events. // As a workaround, we can listen for any events that have bubbled up to @@ -100,6 +107,52 @@ export class CanvasProviderElement })); } + @on("window:blur") + onWindowFocusLost(): void { + // If the browser window we're running in loses focus while certain keys + // or mouse buttons are held down, it can cause the input state to become + // "stuck," e.g. manipulating the camera, because Bevy is unable to detect + // when the bound key/mouse button is released. + // + // This is a hacky workaround -- we dispatch synthetic "keyup" and + // "pointerup" events to the canvas for each key and mouse button that may + // be bound to long-running input actions. + + const canvas = document.getElementById(this.#canvasId); + if (!canvas) return; + + const eventConfig = { + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + bubbles: true, + cancelable: true, + composed: true, + }; + + const platform = sniffPlatform(); + + for (let key of ["Alt", "Control", "Shift"] as const) { + const map = KEYCODE_MAPS[key]; + for (let { code, keyCode } of map[platform]) { + canvas.dispatchEvent(new KeyboardEvent("keyup", { + key, + code, + keyCode, + ...eventConfig, + })); + } + } + + for (let button of [0, 1, 2, 3, 4]) { + canvas.dispatchEvent(new PointerEvent("pointerup", { + button, + ...eventConfig, + })); + } + } + protected override render = () => html` @@ -122,3 +175,164 @@ declare global { "sts-canvas-provider": CanvasProviderElement; } } + + +enum Platform { + Windows, + Apple, + Linux, + Android, +} + +interface KeyCodes { + code: string; + keyCode: number; +} + +/** + * Map of `KeyboardEvent.key` identifiers to their platform-specific `keyCode`s. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values + */ +const KEYCODE_MAPS: { + [K in "Alt"|"Control"|"Shift"]: { + [P in Platform]: KeyCodes[]; + } + } = { + "Alt": { + [Platform.Windows]: [{ + code: "AltLeft", + keyCode: 0x12, + }, { + code: "AltLeft", + keyCode: 0xA4, + }, { + code: "AltRight", + keyCode: 0xA5, + }], + [Platform.Apple]: [{ + code: "AltLeft", + keyCode: 0x3A, + }, { + code: "AltRight", + keyCode: 0x3D, + }], + [Platform.Linux]: [{ + code: "AltLeft", + keyCode: 0xFFE9, + }, { + code: "AltRight", + keyCode: 0xFFEA, + }, { + code: "AltLeft", + keyCode: 0x01000023, + }], + [Platform.Android]: [{ + code: "AltLeft", + keyCode: 57, + }, { + code: "AltRight", + keyCode: 58, + }], + }, + "Control": { + [Platform.Windows]: [{ + code: "ControlLeft", + keyCode: 0x11, + }, { + code: "ControlLeft", + keyCode: 0xA2, + }, { + code: "ControlRight", + keyCode: 0xA3, + }], + [Platform.Apple]: [{ + code: "ControlLeft", + keyCode: 0x3B, + }, { + code: "ControlRight", + keyCode: 0x3E, + }, { + code: "MetaLeft", + keyCode: 0x37, + }, { + code: "MetaRight", + keyCode: 0x36, + }], + [Platform.Linux]: [{ + code: "ControlLeft", + keyCode: 0xFFE3, + }, { + code: "ControlRight", + keyCode: 0xFFE4, + }, { + code: "ControlLeft", + keyCode: 0x01000021, + }], + [Platform.Android]: [{ + code: "ControlLeft", + keyCode: 113, + }, { + code: "ControlRight", + keyCode: 114, + }] + }, + "Shift": { + [Platform.Windows]: [{ + code: "ShiftLeft", + keyCode: 0x10, + }, { + code: "ShiftLeft", + keyCode: 0xA0, + }, { + code: "ShiftRight", + keyCode: 0xA1, + }], + [Platform.Apple]: [{ + code: "ShiftLeft", + keyCode: 0x38, + }, { + code: "ShiftRight", + keyCode: 0x3C, + }], + [Platform.Linux]: [{ + code: "ShiftLeft", + keyCode: 0xFFE1, + }, { + code: "ShiftRight", + keyCode: 0xFFE2, + }, { + code: "ShiftLeft", + keyCode: 0x01000020, + }], + [Platform.Android]: [{ + code: "ShiftLeft", + keyCode: 59, + }, { + code: "ShiftRight", + keyCode: 60, + }], + }, +}; + +/** + * Try to determine the current {@linkcode Platform} by sniffing the User Agent + * string. + * + * @returns `Platform.Windows` if the actual platform could not be determined. + */ +function sniffPlatform(): Platform { + const userAgent = navigator.userAgent; + const platformString = userAgent.match(/\((.+?)\)/)?.[1] ?? "Windows"; + + if (platformString.includes("Windows")) + return Platform.Windows; + if (platformString.includes("Android")) + return Platform.Android; + if (platformString.includes("Mac OS")) + return Platform.Apple; + if (platformString.includes("Linux")) + return Platform.Linux; + + return Platform.Windows; +} diff --git a/studio-web/src/lib/studio.element.ts b/studio-web/src/lib/studio.element.ts index 3562a8d..2759275 100644 --- a/studio-web/src/lib/studio.element.ts +++ b/studio-web/src/lib/studio.element.ts @@ -28,7 +28,6 @@ export class StudioElement extends LitElement { #canvas?: HTMLCanvasElement; #resizeObserver?: ResizeObserver; - #frameRequest?: number; override connectedCallback(): void { super.connectedCallback(); @@ -41,14 +40,9 @@ export class StudioElement extends LitElement { if ((this.#canvas = this._canvasProvider.takeCanvas())) this.appendChild(this.#canvas); - - this.#tick(); } override disconnectedCallback(): void { - if (this.#frameRequest != null) - cancelAnimationFrame(this.#frameRequest); - if (this.#canvas) this._canvasProvider?.returnCanvas(this.removeChild(this.#canvas)); @@ -63,6 +57,8 @@ export class StudioElement extends LitElement { } else { // TODO } + + super.updated(changes); } #onResize(): void { @@ -77,17 +73,6 @@ export class StudioElement extends LitElement { })); } - #tick(): void { - // If user focus lands on the `document.body`, neither the frontend app - // nor the Bevy app will properly handle keyboard events. Thie function - // runs every tick to ensure that user focus never becomes "stranded" in - // this way. - if (document.activeElement === document.body) - this.#canvas?.focus(); - - this.#frameRequest = requestAnimationFrame(() => this.#tick()); - } - #start(): void { if (!this.#canvas) return; if (this.mode == null) return; diff --git a/studio/src/hierarchy.rs b/studio/src/hierarchy.rs index 0131ce6..409e95e 100644 --- a/studio/src/hierarchy.rs +++ b/studio/src/hierarchy.rs @@ -35,9 +35,7 @@ pub(crate) fn nearest_common_ancestor( } // Find the lowest depth of all the entities - let Some(lowest_depth) = depths.values().copied().reduce(usize::min) else { - return None; - }; + let lowest_depth = depths.values().copied().reduce(usize::min)?; // Traverse up the tree from each entity until we find each one's ancestor // at the level of lowest_depth diff --git a/studio/src/input.rs b/studio/src/input.rs index 8e415de..332c7a8 100644 --- a/studio/src/input.rs +++ b/studio/src/input.rs @@ -39,27 +39,88 @@ impl Plugin for StudioInputPlugin { .after(bevy::input::InputSystem) .after(read_mouse_delta), ); + + app.add_systems( + PostUpdate, + (apply_deferred, log_input_actions).after(StudioInputSystem), + ); } } -#[derive(Event, Clone, Copy)] +/// Logs all `InputAction`s emitted, along with the input state at the time they +/// were fired, with `debug` verbosity. +fn log_input_actions( + ri_keyboard: Res>, + ri_mouse_buttons: Res>, + mut er_input_actions: EventReader, +) { + if er_input_actions.is_empty() { + return; + } + + let lmb = if ri_mouse_buttons.pressed(MouseButton::Left) { + 'X' + } else { + '-' + }; + let mmb = if ri_mouse_buttons.pressed(MouseButton::Middle) { + 'X' + } else { + '-' + }; + let rmb = if ri_mouse_buttons.pressed(MouseButton::Right) { + 'X' + } else { + '-' + }; + + use KeyCode::*; + let mod_keys = [AltLeft, AltRight, ShiftLeft, ControlLeft, ControlRight] + .into_iter() + .collect::>(); + let action_keys = [W, A, S, D, E, Q, Escape, Grave, Delete, F] + .into_iter() + .collect::>(); + + let keys_pressed = ri_keyboard.get_pressed().copied().collect::>(); + let keys_display = keys_pressed + .intersection(&mod_keys) + .map(|key| format!("{key:?}")) + .chain( + keys_pressed + .intersection(&action_keys) + .map(|key| format!("{key:?}")), + ) + .collect::>() + .join("+"); + + for action in er_input_actions.read() { + debug!( + "Mouse: [{lmb}{mmb}{rmb}] \ + Keyboard: [{keys_display}] \ + Action: [{action:.2?}]" + ); + } +} + +#[derive(Event, Clone, Copy, Debug)] pub struct InputAction(pub Lifecycle, pub Action); -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug)] pub enum Lifecycle { Start, Ongoing, Stop, } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug)] pub enum Action { Camera(CameraMotion), Selection(Selection), ToggleDebug, } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug)] pub enum CameraMotion { Translate(Vec3), Pan(Vec2), @@ -73,7 +134,7 @@ impl From for Action { } } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug)] pub enum Selection { /// Clear the current selection Clear, @@ -206,7 +267,7 @@ fn translate_dcc_camera_events( CameraMotion::Rotate(Vec2::ZERO).into(), )); } - // Alt Camera Rotation (designed for laptop) + // Alternative Camera Rotation (designed for laptop) else if keyboard.pressed(KeyCode::ShiftLeft) { let lifecycle = if keyboard.just_pressed(KeyCode::ShiftLeft) { Lifecycle::Start