Files
feuerwerk/src/ui/FocusManager.ts
T
rootandClaude Opus 5 495756b500 Remove exports and methods nothing uses any more
A reference search over every export and method in src/ found code that
only tests still called, or that was never called at all:

- RemoveSceneObjectCommand: the product only removes through the
  multi-object command; the tests now use that one too.
- NodeRegistry.requireNodeDef: a second function of the same name in
  commands/require.ts raises a CommandError instead of throwing, and is
  the one the product uses. The twin invited the wrong import.
- isTextureAsset, listContainers, AssetTabEditor.show,
  FocusManager.destroy, KeyboardBridge.selectAll and the getters
  ColorField.value, ListView.selected, NumberField.number.

Also: Theme.controlActive now matches the resting control colour, and a
new note asks what editing the graph during play would take.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 20:15:49 +02:00

105 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Container } from 'pixi.js';
import { collectFocusables, type Widget } from './Widget';
/**
* Keyboard focus lives in the canvas, not in the DOM: one widget is focused at a time and keys
* reach it from a `window` listener. The keyboard bridge input is the only DOM element that ever
* holds real focus, and its keys are routed separately.
*/
export class FocusManager {
/** Global handlers for keys nobody consumed (Delete, Ctrl+Z, …). */
readonly globalHandlers: ((event: KeyboardEvent) => boolean)[] = [];
/** Called after focus moved, so a panel can catch up on work it postponed while being typed in. */
readonly changeHandlers: (() => void)[] = [];
private focusedWidget: Widget | null = null;
private readonly onKeyDown = (event: KeyboardEvent) => this.handleKeyDown(event);
constructor(private readonly root: Container) {
window.addEventListener('keydown', this.onKeyDown);
}
get focused(): Widget | null {
return this.focusedWidget;
}
focus(widget: Widget | null): void {
if (widget === this.focusedWidget) return;
const previous = this.focusedWidget;
this.focusedWidget = widget;
previous?.onBlur();
widget?.onFocus();
for (const handler of this.changeHandlers) handler();
}
/** Whether the focused widget is `node` or sits inside it. */
isWithin(node: Container): boolean {
for (let current: Container | null = this.focusedWidget; current; current = current.parent) {
if (current === node) return true;
}
return false;
}
blur(): void {
this.focus(null);
}
/**
* Blurs before the press is handled, so a text field commits its edit before whatever the
* click triggers – a selection change, an inspector rebuild – can destroy it.
*/
blurIfOutside(target: Container | null): void {
const w = this.focusedWidget;
if (!w) return;
for (let node = target; node; node = node.parent) {
if (node === w) return;
}
this.focus(null);
}
/** Drop focus if the focused widget was removed from the tree or hidden. */
validate(): void {
const w = this.focusedWidget;
if (w && (w.destroyed || !isAttached(w, this.root) || !w.enabled)) this.focus(null);
}
/** Focus the next (or previous) focusable widget in tree order, wrapping around. */
cycle(backwards: boolean): void {
const list = collectFocusables(this.root);
if (list.length === 0) return this.focus(null);
const index = this.focusedWidget ? list.indexOf(this.focusedWidget) : -1;
const next = backwards ? (index <= 0 ? list.length : index) - 1 : (index + 1) % list.length;
this.focus(list[next] ?? null);
}
private handleKeyDown(event: KeyboardEvent): void {
// Keys typed into the keyboard bridge input are forwarded by the bridge itself.
if ((event.target as HTMLElement | null)?.tagName === 'INPUT') return;
if (event.key === 'Tab') {
event.preventDefault();
this.cycle(event.shiftKey);
return;
}
this.validate();
if (this.focusedWidget?.onKeyDown(event)) {
event.preventDefault();
return;
}
for (const handler of this.globalHandlers) {
if (handler(event)) {
event.preventDefault();
return;
}
}
}
}
function isAttached(node: Container, root: Container): boolean {
let current: Container | null = node;
while (current) {
if (current === root) return true;
if (!current.visible) return false;
current = current.parent;
}
return false;
}