Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/controls-keyboard-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@jolly-pixel/controls": minor
---

Add `Keyboard.addGuard()` and the `KeyboardGuard` port, so another input owner can block keydown and keypress events and release held keys when it engages.
6 changes: 6 additions & 0 deletions .changeset/ui-input-layers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@jolly-pixel/ui": minor
---

Add `InputLayers` and the shared `inputLayers`: an open `jolly-dialog` or `PopoverController` popover claims keydown and keypress events.
Pass `inputLayers` to `Keyboard.addGuard()` so viewport controls ignore keys pressed inside dialogs and popovers.
29 changes: 29 additions & 0 deletions packages/controls/docs/keyboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,35 @@ function isEditableTarget(
events retargeted through shadow DOM. It falls back to `target` for synthetic
events without a composed path.

## Guards

```ts
interface KeyboardGuard {
blocks(event: KeyboardEvent): boolean;
onEngage?(listener: () => void): () => void;
}

addGuard(guard: KeyboardGuard): () => void
```

A guard lets another input owner, such as an open UI dialog, take keys away
from the keyboard. A keydown or keypress that any guard `blocks()` is ignored
the same way as an editable target: no event, no state change. Keyup is never
guarded.

`onEngage` is optional. When the guard calls its listener, held keys are
released, so the next `update()` publishes `wasJustReleased` for them.
Tracked states in `buttons` are kept.

`addGuard()` returns a disposer that removes the guard and its engage
subscription. Adding the same guard twice registers it once.

```ts
import { inputLayers } from "@jolly-pixel/ui";

const dispose = input.keyboard.addGuard(inputLayers);
```

## Events

```ts
Expand Down
56 changes: 54 additions & 2 deletions packages/controls/src/devices/keyboard/Keyboard.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ export interface KeyState {

export type InputKeyboardAction = ExtendedKeyCode | InputCustomAction;

export interface KeyboardGuard {
blocks(event: KeyboardEvent): boolean;
onEngage?(listener: () => void): () => void;
}

export interface KeyboardOptions {
documentAdapter?: DocumentAdapter;
}
Expand All @@ -127,6 +132,7 @@ export class Keyboard extends Emitter<
#wasActive = false;
#settled = true;
#enabled = true;
#guards = new Map<KeyboardGuard, (() => void) | null>();
buttons = new Map<string, KeyState>();
buttonsDown = new Set<string>();
autoRepeatedCode: string | null = null;
Expand Down Expand Up @@ -166,6 +172,52 @@ export class Keyboard extends Emitter<
}
}

addGuard(
guard: KeyboardGuard
): () => void {
if (!this.#guards.has(guard)) {
this.#guards.set(
guard,
guard.onEngage?.(this.#releaseHeldKeys) ?? null
);
}

return () => this.#removeGuard(guard);
}

#removeGuard(
guard: KeyboardGuard
): void {
if (!this.#guards.has(guard)) {
return;
}

this.#guards.get(guard)?.();
this.#guards.delete(guard);
}

#releaseHeldKeys = () => {
this.buttonsDown.clear();
this.autoRepeatedCode = null;
this.#settled = false;
};

#isBlocked(
event: KeyboardEvent
): boolean {
if (isEditableTarget(event)) {
return true;
}

for (const guard of this.#guards.keys()) {
if (guard.blocks(event)) {
return true;
}
}

return false;
}

connect() {
this.#documentAdapter.addEventListener(
"keydown",
Expand Down Expand Up @@ -272,7 +324,7 @@ export class Keyboard extends Emitter<
#onKeyDown = (event: KeyboardEvent) => {
if (
!this.#enabled ||
isEditableTarget(event)
this.#isBlocked(event)
) {
return;
}
Expand Down Expand Up @@ -306,7 +358,7 @@ export class Keyboard extends Emitter<
#onKeyPress = (event: KeyboardEvent) => {
if (
!this.#enabled ||
isEditableTarget(event)
this.#isBlocked(event)
) {
return;
}
Expand Down
9 changes: 9 additions & 0 deletions packages/controls/test/devices/keyboard/Keyboard.tst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,12 @@ test("key events are named after physical codes only", () => {
expect(keyboard.on).type.not.toBeCallableWith("A", () => void 0);
expect(keyboard.on).type.not.toBeCallableWith("a", () => void 0);
});

test("addGuard() accepts a structural guard and returns a disposer", () => {
expect(keyboard.addGuard({ blocks: () => true })).type.toBe<() => void>();
expect(keyboard.addGuard).type.toBeCallableWith({
blocks: (_event: KeyboardEvent) => false,
onEngage: (_listener: () => void) => () => undefined
});
expect(keyboard.addGuard).type.not.toBeCallableWith({});
});
160 changes: 160 additions & 0 deletions packages/controls/test/devices/keyboard/KeyboardGuards.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Import Node.js Dependencies
import {
describe,
test,
beforeEach,
afterEach
} from "node:test";
import assert from "node:assert/strict";

// Import Internal Dependencies
import type {
Keyboard,
KeyboardGuard
} from "../../../src/index.ts";
import {
createConnectedKeyboardFixture,
type KeyboardDocumentAdapter
} from "./Keyboard.fixture.ts";

class FakeGuard implements KeyboardGuard {
blocking = false;
listeners = new Set<() => void>();

blocks(): boolean {
return this.blocking;
}

onEngage(
listener: () => void
): () => void {
this.listeners.add(listener);

return () => this.listeners.delete(listener);
}

engage(): void {
this.blocking = true;
for (const listener of this.listeners) {
listener();
}
}
}

describe("Controls.Keyboard guards", () => {
let keyboard: Keyboard;
let documentAdapter: KeyboardDocumentAdapter;
let guard: FakeGuard;

beforeEach(() => {
({
keyboard,
documentAdapter
} = createConnectedKeyboardFixture());
guard = new FakeGuard();
});

afterEach(() => {
keyboard.disconnect();
});

test("a blocking guard stops keydown from emitting or tracking state", () => {
keyboard.addGuard(guard);
guard.blocking = true;
const emitted: string[] = [];
keyboard.on("Escape", () => emitted.push("Escape"));
keyboard.on("down", () => emitted.push("down"));

documentAdapter.dispatchEvent("keydown", { code: "Escape" });
keyboard.update();

assert.deepEqual(emitted, []);
assert.equal(keyboard.isDown("Escape"), false);
assert.equal(keyboard.wasJustPressed("Escape"), false);
});

test("a blocking guard stops keypress from producing characters", () => {
keyboard.addGuard(guard);
guard.blocking = true;

documentAdapter.dispatchEvent("keypress", { code: "KeyA", key: "a" });
keyboard.update();

assert.equal(keyboard.char, "");
});

test("the guard receives the dispatched event", () => {
const seen: KeyboardEvent[] = [];
keyboard.addGuard({
blocks(event) {
seen.push(event);

return false;
}
});

const event = documentAdapter.dispatchEvent("keydown", { code: "KeyA" });

assert.deepEqual(seen, [event]);
assert.equal(keyboard.buttonsDown.has("KeyA"), true);
});

test("keyup is never guarded so held keys cannot stick", () => {
keyboard.addGuard(guard);
documentAdapter.dispatchEvent("keydown", { code: "KeyW" });
keyboard.update();

guard.blocking = true;
const emitted: string[] = [];
keyboard.on("up", () => emitted.push("up"));
documentAdapter.dispatchEvent("keyup", { code: "KeyW" });
keyboard.update();

assert.deepEqual(emitted, ["up"]);
assert.equal(keyboard.wasJustReleased("KeyW"), true);
});

test("engaging releases held keys so polling consumers see a release edge", () => {
keyboard.addGuard(guard);
documentAdapter.dispatchEvent("keydown", { code: "KeyW" });
keyboard.update();
keyboard.update();
assert.equal(keyboard.isDown("KeyW"), true);

guard.engage();
keyboard.update();

assert.equal(keyboard.isDown("KeyW"), false);
assert.equal(keyboard.wasJustReleased("KeyW"), true);
});

test("the returned disposer removes the guard and its engage subscription", () => {
const dispose = keyboard.addGuard(guard);
guard.blocking = true;

dispose();
dispose();
documentAdapter.dispatchEvent("keydown", { code: "KeyA" });

assert.equal(keyboard.buttonsDown.has("KeyA"), true);
assert.equal(guard.listeners.size, 0);
});

test("adding the same guard twice subscribes once", () => {
keyboard.addGuard(guard);
keyboard.addGuard(guard);

assert.equal(guard.listeners.size, 1);
});

test("editable targets stay ignored alongside guards", () => {
keyboard.addGuard(guard);
const input = documentAdapter.dispatchEvent("keydown", {
code: "KeyA",
target: { tagName: "INPUT" }
});

assert.equal(input.defaultPrevented, false);
assert.equal(keyboard.buttonsDown.has("KeyA"), false);
});
});
5 changes: 5 additions & 0 deletions packages/editors/voxel-map/src/boot/EditorShell.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Import Third-party Dependencies
import type { Input } from "@jolly-pixel/engine";
import { inputLayers } from "@jolly-pixel/ui";

// Import Internal Dependencies
import type { EditorState } from "../app/state/index.ts";
Expand Down Expand Up @@ -37,6 +38,10 @@ export class EditorShell {
textureRoom
} = options;

this.#disposables.push(
input.keyboard.addGuard(inputLayers)
);

const activityLog = document.querySelector("jolly-log");
if (activityLog) {
activityLog.entries = state.log.entries;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export class BrushShortcuts {
return false;
}

return !event.composedPath().some(isOpenDialog);
return true;
}
}

Expand All @@ -116,10 +116,3 @@ export function nextAxis(

return BRUSH_AXES[(index + 1) % BRUSH_AXES.length];
}

function isOpenDialog(
target: EventTarget
): boolean {
return Reflect.get(target, "tagName") === "DIALOG" &&
Reflect.get(target, "open") === true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ interface KeyPress {
shiftKey?: boolean;
metaKey?: boolean;
repeat?: boolean;
path?: EventTarget[];
}

function createKeyboard() {
Expand Down Expand Up @@ -56,7 +55,6 @@ function createKeyboard() {
metaKey: false,
repeat: false,
...press,
composedPath: () => press.path ?? [],
preventDefault: () => {
prevented = true;
}
Expand Down Expand Up @@ -174,16 +172,6 @@ describe("BrushShortcuts", () => {
assert.equal(brush.axis, "xz");
});

test("does nothing while a dialog is open", () => {
const { brush, press } = setup();
const dialog = document.createElement("dialog");
dialog.setAttribute("open", "");

press({ code: "KeyR", path: [document.createElement("input"), dialog] });

assert.equal(brush.mode, "build");
});

test("nextAxis follows the toolbar order", () => {
assert.equal(nextAxis("xz"), "xy");
assert.equal(nextAxis("xyz"), "xz");
Expand Down
Loading
Loading