Browse Source

fix(devtools): harden overlay lifecycle and event observation

Made-with: Cursor
master
npmrun 2 weeks ago
parent
commit
a5e8fd1f22
  1. 77
      src/devtools/CommandPalette.ts
  2. 58
      src/devtools/overlay/DebugOverlay.ts
  3. 25
      src/devtools/overlay/widgets/EventWidget.ts
  4. 18
      src/kernel/RuntimeEvents.ts
  5. 14
      src/main.ts
  6. 16
      tests/kernel/runtime-events.test.ts

77
src/devtools/CommandPalette.ts

@ -18,6 +18,34 @@ export class CommandPalette {
private filtered: RuntimeCommand[] = []; private filtered: RuntimeCommand[] = [];
private selectedIndex = 0; private selectedIndex = 0;
private isOpen = false; private isOpen = false;
private readonly onInput = () => this.refreshFiltered();
private readonly onInputKeydown = (event: KeyboardEvent) => {
if (event.key === "ArrowDown") {
event.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filtered.length - 1);
this.renderList();
} else if (event.key === "ArrowUp") {
event.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.renderList();
} else if (event.key === "Enter") {
event.preventDefault();
this.executeSelected();
} else if (event.key === "Escape") {
this.hide();
}
};
private readonly onWindowKeydown = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) {
return;
}
const isToggle = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k";
if (!isToggle) {
return;
}
event.preventDefault();
this.toggle();
};
constructor() { constructor() {
this.element = this.root.element; this.element = this.root.element;
@ -40,23 +68,8 @@ export class CommandPalette {
this.input.style.padding = "6px"; this.input.style.padding = "6px";
this.input.style.borderRadius = "4px"; this.input.style.borderRadius = "4px";
this.input.addEventListener("input", () => this.refreshFiltered()); this.input.addEventListener("input", this.onInput);
this.input.addEventListener("keydown", (event) => { this.input.addEventListener("keydown", this.onInputKeydown);
if (event.key === "ArrowDown") {
event.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filtered.length - 1);
this.renderList();
} else if (event.key === "ArrowUp") {
event.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.renderList();
} else if (event.key === "Enter") {
event.preventDefault();
this.executeSelected();
} else if (event.key === "Escape") {
this.hide();
}
});
this.root.append(this.hintText.element, this.input, this.list.element); this.root.append(this.hintText.element, this.input, this.list.element);
document.body.appendChild(this.element); document.body.appendChild(this.element);
@ -91,14 +104,7 @@ export class CommandPalette {
} }
private bindKeyboard(): void { private bindKeyboard(): void {
window.addEventListener("keydown", (event) => { window.addEventListener("keydown", this.onWindowKeydown);
const isToggle = (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k";
if (!isToggle) {
return;
}
event.preventDefault();
this.toggle();
});
} }
private refreshFiltered(): void { private refreshFiltered(): void {
@ -125,4 +131,25 @@ export class CommandPalette {
command.run(); command.run();
this.hide(); this.hide();
} }
dispose(): void {
window.removeEventListener("keydown", this.onWindowKeydown);
this.input.removeEventListener("input", this.onInput);
this.input.removeEventListener("keydown", this.onInputKeydown);
this.element.remove();
this.commands.clear();
this.filtered = [];
this.isOpen = false;
}
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
if (target.isContentEditable) {
return true;
}
const tagName = target.tagName.toLowerCase();
return tagName === "input" || tagName === "textarea";
} }

58
src/devtools/overlay/DebugOverlay.ts

@ -20,6 +20,17 @@ export class DebugOverlay {
private readonly eventWidget: EventWidget; private readonly eventWidget: EventWidget;
private readonly commandPalette = new CommandPalette(); private readonly commandPalette = new CommandPalette();
private visible = false; private visible = false;
private readonly offCallbacks: Array<() => void> = [];
private readonly onWindowKeydown = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) {
return;
}
if (event.key !== "`" && event.code !== "Backquote") {
return;
}
event.preventDefault();
this.toggle();
};
constructor(private readonly deps: DebugOverlayDeps) { constructor(private readonly deps: DebugOverlayDeps) {
this.root = document.createElement("div"); this.root = document.createElement("div");
@ -57,24 +68,19 @@ export class DebugOverlay {
} }
private bindKeys(): void { private bindKeys(): void {
window.addEventListener("keydown", (event) => { window.addEventListener("keydown", this.onWindowKeydown);
if (event.key !== "`" && event.code !== "Backquote") {
return;
}
event.preventDefault();
this.toggle();
});
} }
private bindEvents(): void { private bindEvents(): void {
this.deps.sceneManager.onStageChange(() => this.refresh()); this.offCallbacks.push(
this.deps.events.on("scene:changed", () => this.refresh()); this.deps.events.on("scene:changed", () => this.refresh()),
this.deps.events.on("runtime:ready", () => this.refresh()); this.deps.events.on("runtime:ready", () => this.refresh()),
this.deps.events.on("game:frame-rendered", () => { this.deps.events.on("game:frame-rendered", () => {
if (this.visible) { if (this.visible) {
this.eventWidget.render(); this.eventWidget.render();
} }
}); })
);
} }
private registerCommands(): void { private registerCommands(): void {
@ -100,9 +106,31 @@ export class DebugOverlay {
}); });
} }
dispose(): void {
window.removeEventListener("keydown", this.onWindowKeydown);
for (const off of this.offCallbacks) {
off();
}
this.offCallbacks.length = 0;
this.eventWidget.dispose();
this.commandPalette.dispose();
this.root.remove();
}
private refresh(): void { private refresh(): void {
this.sceneWidget.render(); this.sceneWidget.render();
this.assetWidget.render(); this.assetWidget.render();
this.eventWidget.render(); this.eventWidget.render();
} }
} }
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
if (target.isContentEditable) {
return true;
}
const tagName = target.tagName.toLowerCase();
return tagName === "input" || tagName === "textarea";
}

25
src/devtools/overlay/widgets/EventWidget.ts

@ -2,34 +2,25 @@ import type { RuntimeEvents } from "@/kernel/RuntimeEvents";
import { UiList } from "@/ui-core/UiList"; import { UiList } from "@/ui-core/UiList";
import { UiPanel } from "@/ui-core/UiPanel"; import { UiPanel } from "@/ui-core/UiPanel";
type RuntimeEventsWithEmit = RuntimeEvents & {
emit: (event: string, ...args: unknown[]) => void;
};
export class EventWidget { export class EventWidget {
readonly element: HTMLDivElement; readonly element: HTMLDivElement;
private readonly list = new UiList(); private readonly list = new UiList();
private readonly eventCounts = new Map<string, number>(); private readonly eventCounts = new Map<string, number>();
private readonly maxEvents = 6; private readonly maxEvents = 6;
private readonly runtimeEvents: RuntimeEventsWithEmit; private readonly runtimeEvents: RuntimeEvents;
private readonly disposeOnAny: () => void;
constructor(runtimeEvents: RuntimeEvents) { constructor(runtimeEvents: RuntimeEvents) {
this.runtimeEvents = runtimeEvents as RuntimeEventsWithEmit; this.runtimeEvents = runtimeEvents;
const panel = new UiPanel("Events"); const panel = new UiPanel("Events");
panel.append(this.list.element); panel.append(this.list.element);
this.element = panel.element; this.element = panel.element;
this.hookEmit(); this.disposeOnAny = this.runtimeEvents.onAny((event) => {
this.render();
}
private hookEmit(): void {
const originalEmit = this.runtimeEvents.emit.bind(this.runtimeEvents);
this.runtimeEvents.emit = (event: string, ...args: unknown[]) => {
const count = this.eventCounts.get(event) ?? 0; const count = this.eventCounts.get(event) ?? 0;
this.eventCounts.set(event, count + 1); this.eventCounts.set(event, count + 1);
this.render(); this.render();
originalEmit(event, ...args); });
}; this.render();
} }
render(): void { render(): void {
@ -43,4 +34,8 @@ export class EventWidget {
} }
this.list.setItems(items); this.list.setItems(items);
} }
dispose(): void {
this.disposeOnAny();
}
} }

18
src/kernel/RuntimeEvents.ts

@ -1,4 +1,5 @@
type EventCallback = (...args: unknown[]) => void; type EventCallback = (...args: unknown[]) => void;
type AnyEventCallback = (event: string, ...args: unknown[]) => void;
export interface RuntimeEventMap { export interface RuntimeEventMap {
"runtime:ready": { startedAt: number }; "runtime:ready": { startedAt: number };
@ -9,6 +10,7 @@ export interface RuntimeEventMap {
export class RuntimeEvents { export class RuntimeEvents {
private events = new Map<string, Set<EventCallback>>(); private events = new Map<string, Set<EventCallback>>();
private anyEventCallbacks = new Set<AnyEventCallback>();
on(event: string, callback: EventCallback): () => void { on(event: string, callback: EventCallback): () => void {
if (!this.events.has(event)) { if (!this.events.has(event)) {
@ -43,6 +45,14 @@ export class RuntimeEvents {
} }
emit(event: string, ...args: unknown[]): void { emit(event: string, ...args: unknown[]): void {
this.anyEventCallbacks.forEach((cb) => {
try {
cb(event, ...args);
} catch (error) {
console.error(`RuntimeEvents: tap listener failed for "${event}"`, error);
}
});
const callbacks = this.events.get(event); const callbacks = this.events.get(event);
if (!callbacks) { if (!callbacks) {
return; return;
@ -59,9 +69,17 @@ export class RuntimeEvents {
clear(): void { clear(): void {
this.events.clear(); this.events.clear();
this.anyEventCallbacks.clear();
} }
getEventCount(): number { getEventCount(): number {
return this.events.size; return this.events.size;
} }
onAny(callback: AnyEventCallback): () => void {
this.anyEventCallbacks.add(callback);
return () => {
this.anyEventCallbacks.delete(callback);
};
}
} }

14
src/main.ts

@ -3,6 +3,10 @@ import { initApp, game, sceneManager, assetManager } from "./init";
import { appRuntime } from "./kernel/AppRuntime"; import { appRuntime } from "./kernel/AppRuntime";
import { DebugOverlay } from "./devtools/overlay/DebugOverlay"; import { DebugOverlay } from "./devtools/overlay/DebugOverlay";
type DevtoolsWindow = Window & {
__PIXIDEMO_DEBUG_OVERLAY__?: DebugOverlay;
};
void (async () => { void (async () => {
try { try {
await initApp(); await initApp();
@ -13,12 +17,20 @@ void (async () => {
})(); })();
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
const devWindow = window as DevtoolsWindow;
devWindow.__PIXIDEMO_DEBUG_OVERLAY__?.dispose();
(window as unknown as { game: typeof game; sceneManager: typeof sceneManager }).game = game; (window as unknown as { game: typeof game; sceneManager: typeof sceneManager }).game = game;
(window as unknown as { game: typeof game; sceneManager: typeof sceneManager }).sceneManager = (window as unknown as { game: typeof game; sceneManager: typeof sceneManager }).sceneManager =
sceneManager; sceneManager;
new DebugOverlay({ devWindow.__PIXIDEMO_DEBUG_OVERLAY__ = new DebugOverlay({
sceneManager: appRuntime.sceneManager, sceneManager: appRuntime.sceneManager,
assetManager, assetManager,
events: appRuntime.events, events: appRuntime.events,
}); });
import.meta.hot?.dispose(() => {
devWindow.__PIXIDEMO_DEBUG_OVERLAY__?.dispose();
delete devWindow.__PIXIDEMO_DEBUG_OVERLAY__;
});
} }

16
tests/kernel/runtime-events.test.ts

@ -91,6 +91,22 @@ describe("RuntimeEvents", () => {
expect(throwing).toHaveBeenCalledTimes(1); expect(throwing).toHaveBeenCalledTimes(1);
expect(after).toHaveBeenCalledTimes(2); expect(after).toHaveBeenCalledTimes(2);
}); });
it("supports onAny tap without affecting event listeners", () => {
const events = new RuntimeEvents();
const tap = vi.fn();
const handler = vi.fn();
const offTap = events.onAny(tap);
events.on("scene:changed", handler);
events.emit("scene:changed", { current: "a", previous: undefined });
offTap();
events.emit("scene:changed", { current: "b", previous: "a" });
expect(tap).toHaveBeenCalledTimes(1);
expect(tap).toHaveBeenCalledWith("scene:changed", { current: "a", previous: undefined });
expect(handler).toHaveBeenCalledTimes(2);
});
}); });
describe("AppRuntime plugin lifecycle", () => { describe("AppRuntime plugin lifecycle", () => {

Loading…
Cancel
Save