diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 789a0696..bc3226aa 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -460,6 +460,10 @@ export class Editor implements PlayHost { const onTick = (ticker: Ticker) => this.tick(ticker.deltaMS / 1000); app.ticker.add(onTick); this.cleanups.push(() => app.ticker.remove(onTick)); + // In the game view Tab is a game key (an inventory, a target switch) – like every other key there. + const focus = ui().focus; + focus.tabBelongsElsewhere = () => this.tab === 'game'; + this.cleanups.push(() => (focus.tabBelongsElsewhere = () => false)); // A node, a frame and a template object exist only in their own document, so selecting one // brings it up. Selecting an asset never does: that is what opening it (double-click, Enter, // creating it) is for – see the `open` event below. diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 50adb89e..0fdbc27b 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -676,6 +676,10 @@ class KeyCaptureButton extends Button { if (!this.capturing) this.setText(keyLabel(key)); } + override get takesTab(): boolean { + return this.capturing; + } + override onKeyDown(event: KeyboardEvent): boolean { if (!this.capturing) return super.onKeyDown(event); event.preventDefault(); diff --git a/src/ui/FocusManager.ts b/src/ui/FocusManager.ts index 01f7ac8f..cc36798c 100644 --- a/src/ui/FocusManager.ts +++ b/src/ui/FocusManager.ts @@ -11,6 +11,8 @@ export class FocusManager { 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)[] = []; + /** With nothing focused, whether Tab is someone else's key (a running game's) rather than a focus move. */ + tabBelongsElsewhere: () => boolean = () => false; private focusedWidget: Widget | null = null; private readonly onKeyDown = (event: KeyboardEvent) => this.handleKeyDown(event); @@ -90,7 +92,8 @@ export class FocusManager { if ((event.target as HTMLElement | null)?.tagName === 'INPUT') return; if (event.key === 'Tab') { event.preventDefault(); - this.cycle(event.shiftKey); + if (this.focusedWidget?.takesTab) this.focusedWidget.onKeyDown(event); + else if (this.focusedWidget || !this.tabBelongsElsewhere()) this.cycle(event.shiftKey); return; } this.validate(); diff --git a/src/ui/Widget.ts b/src/ui/Widget.ts index 4379d424..59e41da4 100644 --- a/src/ui/Widget.ts +++ b/src/ui/Widget.ts @@ -21,6 +21,10 @@ export abstract class Widget extends Container { /** Whether the widget takes part in Tab cycling. */ focusable = false; focused = false; + /** While true, a Tab goes to `onKeyDown` instead of moving the focus on. */ + get takesTab(): boolean { + return false; + } enabled = true; /** Maintained by `trackHover()`; read in `redraw()` to paint the hover state. */ protected hovered = false; diff --git a/tools/e2e/20-key-repeat.mjs b/tools/e2e/20-key-repeat.mjs index 70313db2..4028cc59 100644 --- a/tools/e2e/20-key-repeat.mjs +++ b/tools/e2e/20-key-repeat.mjs @@ -8,6 +8,7 @@ import { LANES, scaffoldBehavior } from './lib/scaffold.mjs'; const PLAIN = 'plain hit'; const REPEATING = 'repeating hit'; +const TAB = 'tab hit'; const DELAY = 300; const INTERVAL = 50; @@ -21,15 +22,18 @@ const repeatGraph = { repeatInterval: INTERVAL, }), node('r-repeat-log', 'Log', 340, 220, { message: REPEATING }), + node('r-tab', 'OnKeyDown', 0, 440, { key: 'Tab' }), + node('r-tab-log', 'Log', 340, 440, { message: TAB }), ], edges: [ edge('r-e1', 'r-plain', 'exec', 'r-plain-log', 'exec'), edge('r-e2', 'r-repeat', 'exec', 'r-repeat-log', 'exec'), + edge('r-e3', 'r-tab', 'exec', 'r-tab-log', 'exec'), ], }; export async function keyRepeat(t) { - const { check, sleep, json, shot, click, holdKey, world, stop, select, typeText } = t; + const { check, sleep, json, shot, click, press, holdKey, world, stop, select, typeText } = t; /** Both counts out of one read of the world, so the two are always of the same moment. */ const hits = async () => { const log = (await world()).log; @@ -125,6 +129,36 @@ export async function keyRepeat(t) { ); await stop(); + // 20f. Tab is a game key like any other while the game has the keyboard. The editor used to move + // its focus on it, so the next key went to a widget and no longer reached the game. + await t.play(); + const beforeTab = await hits(); + const tabsBefore = (await world()).log.filter((line) => line.includes(TAB)).length; + await press('Tab'); + await holdKey('ArrowRight', 120); + await sleep(200); + const afterTab = await hits(); + const tabsAfter = (await world()).log.filter((line) => line.includes(TAB)).length; + const focusAfterTab = await json('__gm.focusedRect()'); + check( + 'Tab reaches the game and leaves the keyboard with it', + tabsAfter === tabsBefore + 1 && afterTab.plain === beforeTab.plain + 1 && focusAfterTab === null, + JSON.stringify({ tabs: tabsAfter - tabsBefore, plain: afterTab.plain - beforeTab.plain, focusAfterTab }), + ); + await stop(); + + // 20g. The key field takes Tab too, or a graph could never be set to it by hand. + await select({ kind: 'node', assetId: 'repeater', nodeId: 'r-plain' }); + const keyField = await json(`__gm.inspectorField('Key')`); + await click(keyField.x, keyField.y); + await press('Tab'); + const pickedKey = await json( + `__gm.state.project.assets.find((a) => a.id === 'repeater').graph.nodes.find((n) => n.id === 'r-plain').params.key`, + ); + check('the key field captures Tab', pickedKey === 'Tab', JSON.stringify({ pickedKey })); + // Selecting the node brought its graph to the front; the steps after this one start in the scene. + await t.showTab('scene'); + const left = await rig.remove(); check( 'the step leaves nothing of its own behind', diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 2acadb89..ea595b73 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -38,6 +38,7 @@ export function colourDistance(a, b) { const KEYS = { Enter: { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, Escape: { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }, + Tab: { key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }, Space: { key: ' ', code: 'Space', windowsVirtualKeyCode: 32 }, ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 }, ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', windowsVirtualKeyCode: 38 },