import { type FederatedPointerEvent, Graphics } from 'pixi.js'; import { isList, isStruct, type PropertyValue, textOf } from '../../core/model/Properties'; import type { Project } from '../../core/model/Project'; import { layerIndexOf, layerMaskOf, layerName, maskHas, maskLabel, maskWith, } from '../../core/model/collisionLayers'; import { INHERIT_LABEL, INHERIT_LAYER, ownRenderLayer } from '../../core/model/renderLayers'; import { assetChoiceLabel, assetRefChoices, assetRefLabel, MASTER_LABEL, NO_ASSET_LABEL, } from '../../core/properties/assetRef'; import { NO_FX } from '../../core/properties/assetFx'; import { busesInto, MASTER_BUS } from '../../core/properties/buses'; import { keyName } from '../../core/keys'; import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap'; import { type ObjectChoice, objectReference, referencedId } from '../../core/model/objectRefs'; import { tilemapLayerNames } from '../../core/model/tilemap'; import { isKindWith, type ReferenceKind } from '../../core/properties/kinds'; import { emptyFieldValue, structOf, flatDef, type PropertyDef, type StructFieldDef, } from '../../core/properties/PropertySchema'; import { referencedAssetId } from '../../core/properties/references'; import { NO_TEXTURE_LABEL, pictureChoices, textureRefLabel } from '../../core/properties/textureRef'; import { Button, CHEVRON_SLOT, Checkbox, ColorField, drawChevron, Dropdown, HStack, Label, Menu, type MenuEntry, type MenuItem, NumberField, type Size, Spacer, TextField, Theme, ui, VStack, type Widget, } from '../../ui'; import { ROW_ACTION_ICON_SVG } from '../panels/markIcons'; import { InlineList, ListHead } from '../list/InlineList'; import type { ListAccess } from '../list/slot'; import type { ObjectPicker } from '../objectPick'; /** * What a field needs beyond its own value: the project, to resolve an asset reference to a name * and to list what the picker may offer. A getter rather than the project itself – loading and * entering play mode replace that object. */ export interface FieldContext { project(): Project; /** * A press on the label of a reference row that names an asset (#221). Without it the label stays * plain text – only the inspector knows what a click (ping) and a double-click (open) do. */ followReference?(assetId: string, e: FederatedPointerEvent): void; /** Items a list draws inline before "… N more" (#252); none means all of them. */ listLimit?: number; /** * What an object's place may name (#258), asked anew on every open. Only a placed object has a * scene to pick from; without it the place shows "(null)". */ objectChoices?: () => ObjectChoice[]; /** Picks an object's place in the view instead (#276); without it the dropdown stands alone. */ objectPicker?: ObjectPicker; } export interface FieldHandlers { /** Live value while dragging/typing – apply as a preview, no undo entry. */ onInput: (value: PropertyValue) => void; /** Final value – commit as a command. */ onChange: (value: PropertyValue) => void; /** * The list a `list` row draws inline (#252), re-resolved on every call; only the caller knows * where the value it edits lives. Without it the row only says how many items there are. */ list?: () => ListAccess | null; /** * Handlers for one field of a struct, `path` being the keys from the bundle down to it (a struct * field nests further, #239). Writing a field means writing the whole bundle, so only the caller – * which knows where that bundle lives – can build them. Without them a struct row can only show * what it holds. */ structFieldHandlers?: (path: readonly string[], field: StructFieldDef) => FieldHandlers; /** * What the menu of a reference row ends with, asked on every open with the id it holds – a sound * FX the song makes for that place alone (#355). Only the caller knows the place. */ moreChoices?: (current: string) => MenuEntry[]; } export interface FieldRow { row: Widget; /** Push a model value into the widget (no events fired). */ setValue(value: PropertyValue): void; /** * Colour the label as deviating from its base value (an override on scene objects, a * non-default value on assets) and re-evaluate which actions currently apply. */ setModified(modified: boolean): void; } /** * The inspector's one column grid. A variable row is mark + name, a plain row a label spanning * both, so every value starts at the same x. The mark column fits `≡Aa` (24 px measured) with air. */ export const MARK_WIDTH = 28; export const NAME_WIDTH = 90; export const FIELD_INDENT = 12; const FIELD_LABEL_WIDTH = MARK_WIDTH + Theme.gap + NAME_WIDTH; /** * A name column `depth` indent steps in, narrowed by what the indent takes so the value after it * starts on the same column at any depth. Deep enough, it stops shrinking and the values move right. */ export function nameWidth(depth: number): number { return Math.max(MIN_NAME_WIDTH, NAME_WIDTH - depth * (FIELD_INDENT + Theme.gap)); } const MIN_NAME_WIDTH = 40; /** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */ export interface FieldAction { label: string; onSelect: () => void; } /** * The actions available *right now* – asked again on every refresh and on every click. * Anything that would not change the value is left out, so a row never offers a dead entry. */ export type FieldActions = () => FieldAction[]; export const actionIf = (offered: unknown, label: string, onSelect: () => void): FieldAction[] => offered ? [{ label, onSelect }] : []; /** One trailing button of a row: its picture (an SVG file), and what it currently offers. */ export interface FieldActionGroup { icon: string; /** What the button says while it offers several actions; with one, it says that action. */ tooltip: string; available: FieldActions; } export interface RowActions { reset?: FieldActions; apply?: FieldActions; /** Row-specific buttons, drawn left of ↑/↺ – they act on the value rather than on its origin. */ extra?: readonly FieldActionGroup[]; } /** A row starting with the fixed-width, muted label column. */ export function labelledRow(text: string): { row: HStack; label: Label } { const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; const label = new Label(text, { style: 'muted' }); label.minWidth = FIELD_LABEL_WIDTH; row.add(label); return { row, label }; } /** Adds the trailing reset/apply/extra buttons and returns their refresh; `buildVariable` uses it for its first row too. */ export function attachRowActions(row: HStack, rowActions: RowActions): () => void { const actions: { button: RowActionButton; available: FieldActions }[] = []; const addActions = (icon: string, tooltip: string, available: FieldActions): void => { const button = new RowActionButton(icon); button.tooltip = () => rowActionTip(tooltip, available()); // Asked again on click: the menu must not act on a state that has moved on since the // last refresh (an undo between refreshes, a value edited in the viewport). button.onClick = () => { const items = available(); if (items.length === 0) return; if (items.length === 1) return items[0]!.onSelect(); new Menu(items.map((a) => ({ label: a.label, onSelect: a.onSelect }))).openBelow(button); }; button.visible = false; actions.push({ button, available }); row.add(button); }; for (const group of rowActions.extra ?? []) addActions(group.icon, group.tooltip, group.available); if (rowActions.apply) addActions(ROW_ACTION_ICON_SVG.apply, 'Apply', rowActions.apply); if (rowActions.reset) addActions(ROW_ACTION_ICON_SVG.reset, 'Reset', rowActions.reset); const refreshActions = (): void => { let changed = false; for (const { button, available } of actions) { const count = available().length; const visible = count > 0; if (button.visible !== visible) { button.visible = visible; changed = true; } if (visible && button.setMenu(count > 1)) changed = true; } if (changed) row.relayout(); }; refreshActions(); return refreshActions; } /** The one thing a click would do, by its own name; the group's name where a click opens a menu. */ export function rowActionTip(group: string, offered: readonly FieldAction[]): string { return offered.length === 1 ? offered[0]!.label : group; } /** A row action: its picture alone, or with a chevron once it offers more than one thing. */ class RowActionButton extends Button { private readonly chevron = new Graphics(); private menu = false; constructor(icon: string) { super('', { variant: 'ghost', minWidth: Theme.controlHeight, icon }); this.addChild(this.chevron); } /** Whether the menu form changed – the row has to be laid out again then. */ setMenu(menu: boolean): boolean { if (menu === this.menu) return false; this.menu = menu; this.invalidate(); return true; } override get opensMenu(): boolean { return this.menu; } override measure(): Size { const size = super.measure(); return this.menu ? { w: size.w + CHEVRON_SLOT, h: size.h } : size; } protected override trailingRoom(): number { return this.menu ? CHEVRON_SLOT : 0; } override redraw(): void { super.redraw(); if (this.menu) drawChevron(this.chevron, this.w, this.h, Theme.colors.textMuted); else this.chevron.clear(); } } /** * Maps one schema entry to a labelled row. Action groups are asked again on each refresh: none means * no button, one a button, several a menu. */ export function createFieldRow( ctx: FieldContext, def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, rowActions: RowActions = {}, ): FieldRow { const { row, label } = labelledRow(def.label); const field = createField(ctx, def, value, handlers); addValue(row, field); const refreshActions = attachRowActions(row, rowActions); const paintLabel = linkLabel(ctx, label); let modified = false; let target: string | null = null; const retarget = (v: PropertyValue): void => { target = referencedAssetId(ctx.project(), def, v); paintLabel(target, modified); }; retarget(value); return { row: withBelow(row, field, 1), setValue: (v) => { field.setValue(v); retarget(v); }, setModified(isModified) { modified = isModified; paintLabel(target, modified); refreshActions(); }, }; } /** * Turns a row label into a link to an asset (#221): blue and underlined while it names one, a * click pings it, a double-click opens it. Returns the repaint, to be called whenever the target * or the modified state changes. Without `followReference` the label stays plain text. */ export function linkLabel( ctx: FieldContext, label: Label, ): (target: string | null, modified?: boolean) => void { const follow = ctx.followReference?.bind(ctx); let target: string | null = null; if (follow) label.on('pointerdown', (e: FederatedPointerEvent) => { if (e.button === 0 && target) follow(target, e); }); return (next, modified = false) => { target = follow ? next : null; // An override keeps its colour: that it deviates says more than that it can be followed. label.setStyle(modified ? 'override' : target ? 'link' : 'muted'); label.setUnderline(target !== null); label.eventMode = target ? 'static' : 'none'; }; } export interface Field { widget: Widget; /** Whether the widget should take the remaining row width. */ grow: boolean; setValue(value: PropertyValue): void; /** The per-field widgets of a struct, so a caller can address one of them. */ parts?: ReadonlyMap; /** What goes under the row rather than into it: a list's items (#252). */ below?: Widget; } /** * The row alone, or – for a field with something below it – the row with that under it, `depth` * indent steps in. */ function withBelow(row: Widget, field: Field, depth: number): Widget { if (!field.below) return row; const stack = new VStack({ gap: Theme.gap }); stack.add(row); stack.add(indentedBy(field.below, depth)); return stack; } /** A widget `depth` indent steps in, as far as a struct field of that depth. */ export function indentedBy(widget: Widget, depth: number): HStack { const line = new HStack({ gap: 0 }); if (depth > 0) line.add(new Spacer(depth * (FIELD_INDENT + Theme.gap))); line.add(widget, 1); return line; } /** A widget that does not grow gets a spacer after it, so whatever follows sits at the row's end. */ export function addValue(row: HStack, field: Field): (value: PropertyValue) => void { row.add(field.widget, field.grow ? 1 : 0); if (!field.grow) row.add(new Spacer(), 1); return field.setValue; } export function removeButton(onClick: () => void): Button { return new Button('×', { variant: 'ghost', width: Theme.controlHeight, tooltip: 'Remove', onClick }); } export const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} }; /** * A struct field as a property definition – a list field becomes the same row a list property has, * a struct field the same block a struct property has. */ export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef { if (field.kind === 'list') return { key: field.key, label, kind: 'list', default: [] }; if (field.kind === 'struct') return { key: field.key, label, kind: 'struct', fields: field.fields, default: structOf(field.fields, undefined), }; return flatDef(field.key, field.kind, { label }); } /** The handlers of a struct field that is a struct again: its own fields answer one step deeper. */ function nestedStructHandlers(handlers: FieldHandlers, key: string): FieldHandlers { const per = handlers.structFieldHandlers; return per ? { ...READ_ONLY, structFieldHandlers: (path, field) => per([key, ...path], field) } : READ_ONLY; } /** What a reference kind names in the project: how an id reads, and what its picker may offer. */ interface Reference { none: string; label(project: Project | null, id: string): string; choices(project: Project, def: PropertyDef): { id: string; label: string }[]; } const REFERENCES: Record = { asset: { none: NO_ASSET_LABEL, label: assetRefLabel, choices: (project, def) => { const extras = def.kind === 'asset' ? def : undefined; // Asked as the menu opens: another bus may have been sent into this one meanwhile. const left = new Set(extras?.intoFor ? busesInto(project, extras.intoFor) : []); return [ ...(extras?.master ? [{ id: MASTER_BUS, label: MASTER_LABEL }] : []), ...(extras?.noFx ? [{ id: NO_FX, label: NO_ASSET_LABEL }] : []), ...assetRefChoices(project, extras?.assetTypes) .filter((asset) => !left.has(asset.id)) .map((asset) => ({ id: asset.id, label: assetChoiceLabel(asset) })), ]; }, }, texture: { none: NO_TEXTURE_LABEL, label: textureRefLabel, choices: (project, def) => pictureChoices(project).filter( (choice) => !(def.kind === 'texture' && def.uploadedOnly) || choice.asset.type === 'texture', ), }, statemachine: { none: NO_ASSET_LABEL, label: assetRefLabel, choices: (project) => assetRefChoices(project, ['statemachine']).map((a) => ({ id: a.id, label: a.name })), }, sound: { none: NO_ASSET_LABEL, label: assetRefLabel, choices: (project) => assetRefChoices(project, ['sound']).map((a) => ({ id: a.id, label: a.name })), }, scene: { none: NO_ASSET_LABEL, label: assetRefLabel, choices: (project) => assetRefChoices(project, ['scene']).map((a) => ({ id: a.id, label: a.name })), }, animation: { none: NO_ASSET_LABEL, label: assetRefLabel, choices: (project) => assetRefChoices(project, ['animation']).map((a) => ({ id: a.id, label: a.name })), }, }; /** An id in the words of its reference kind; nothing for a kind that names nothing. */ export function referenceLabel(def: PropertyDef, project: Project | null, id: string): string | undefined { if (id === '' && def.kind === 'asset' && def.none) return def.none; return isKindWith('reference', def.kind) ? REFERENCES[def.kind].label(project, id) : undefined; } function referenceField( ctx: FieldContext, def: PropertyDef, ref: Reference, value: PropertyValue, handlers: FieldHandlers, ): Field { // The current id is kept here rather than read back off the label: the label is a name, two // assets may share one (a frame's label repeats its sheet's), and a dangling reference has none. let current = textOf(value); const none = def.kind === 'asset' && def.none ? def.none : ref.none; const labelOf = (id: string) => (id === '' ? none : ref.label(ctx.project(), id)); const button = new Dropdown(labelOf(current)); button.onClick = () => { const items: MenuEntry[] = [ { label: none, current: current === '', onSelect: () => handlers.onChange('') }, // Built on click, not once per row: assets are created, renamed and deleted while the // inspector stands. Searchable: one sliced sheet alone contributes an entry per frame. ...ref.choices(ctx.project(), def).map((choice) => ({ label: choice.label, current: choice.id === current, onSelect: () => handlers.onChange(choice.id), })), ...(handlers.moreChoices?.(current) ?? []), ]; new Menu(items, { searchable: true }).openBelow(button); }; return { widget: button, grow: true, setValue: (v) => { current = textOf(v); button.setText(labelOf(current)); }, }; } /** * "Target: ▾" – another object of the same scene, or none (#258). Where there is no scene – * on an asset – nothing can be picked, and a graph is what fills the place while the game runs. * Beside the dropdown the eyedropper picks it in the view (#276); `name` is what its strip calls it. */ function objectField(ctx: FieldContext, name: string, value: PropertyValue, handlers: FieldHandlers): Field { const choices = ctx.objectChoices; if (!choices) return { widget: new Label('(null)', { style: 'muted' }), grow: true, setValue: () => {} }; // A list item arrives unwrapped, as the id alone. const idOf = (v: PropertyValue) => (typeof v === 'string' ? v : referencedId(v)); let current = idOf(value); const labelOf = () => { if (!current) return 'None'; return choices().find((c) => c.ref === current)?.label ?? '(missing)'; }; const button = new Dropdown(labelOf()); button.onClick = () => { const pick = (ref: string) => () => handlers.onChange(objectReference(ref)); const items: MenuItem[] = [{ label: 'None', current: !current, onSelect: pick('') }]; for (const choice of choices()) items.push({ label: `${' '.repeat(choice.depth)}${choice.label}`, current: choice.ref === current, onSelect: pick(choice.ref), }); // Searchable like the parent picker: it lists every object of the scene. new Menu(items, { searchable: true }).openBelow(button); }; const setValue = (v: PropertyValue) => { current = idOf(v); button.setText(labelOf()); }; const picker = ctx.objectPicker; if (!picker) return { widget: button, grow: true, setValue }; const eyedropper = new Button('', { variant: 'ghost', minWidth: Theme.controlHeight, icon: ROW_ACTION_ICON_SVG.eyedropper, toggle: true, tooltip: 'Pick an object in the scene', }); eyedropper.onClick = () => { picker.toggle(eyedropper, { field: name, current, take: (ref) => handlers.onChange(objectReference(ref)), ended: () => { if (!eyedropper.destroyed) eyedropper.setToggled(false); }, }); const picking = picker.picking(eyedropper); eyedropper.setToggled(picking); // Enter is the pick's now, not a press of the button that has the focus – this one, say. if (picking) ui().focus.blur(); }; const line = new HStack({ gap: Theme.gap, align: 'center' }); line.add(button, 1); line.add(eyedropper); return { widget: line, grow: true, setValue }; } /** One field out of a bundle; a field the value does not carry reads as the empty one of its kind. */ export function structFieldValue(value: PropertyValue, field: StructFieldDef): PropertyValue { return (isStruct(value) ? value[field.key] : undefined) ?? emptyFieldValue(field); } /** * The bare widget for a property definition (no label, no reset). `name` is what the value is * called where it sits – a struct field's is its bundle's and its own key. */ export function createField( ctx: FieldContext, def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, name = def.label, ): Field { switch (def.kind) { case 'number': { const field = new NumberField(Number(value), { min: def.min, max: def.max, step: def.step ?? 1, decimals: def.decimals, onInput: handlers.onInput, onChange: handlers.onChange, }); return { widget: field, grow: true, setValue: (v) => field.setNumber(Number(v)) }; } case 'text': { const field = new TextField(textOf(value), { onInput: handlers.onInput, onChange: handlers.onChange }); return { widget: field, grow: true, setValue: (v) => field.setValue(textOf(v)) }; } case 'color': { const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange }); return { widget: field, grow: true, setValue: (v) => field.setValue(Number(v)) }; } case 'list': { const count = (v: PropertyValue) => (isList(v) ? v : []).length; const source = handlers.list; if (!source) { const label = new Label(`${count(value)} items`, { style: 'muted', padding: Theme.fieldPadding }); return { widget: label, grow: true, setValue: (v) => label.setText(`${count(v)} items`) }; } // The items go under the row: a board is hundreds of entries, so the inspector draws the // first few and the head opens the rest in a tab of its own. const list = new InlineList(ctx, source, { limit: ctx.listLimit }); const head = new ListHead(source, (kind) => list.addItem(kind)); list.head = head; return { widget: head, grow: true, setValue: (v) => { head.setCount(count(v)); list.refresh(); }, below: list, }; } case 'struct': { // A bundle has few fields, so unlike a list they fit under each other right here. Editing // one writes the whole bundle, which is why the handlers come per field from the caller. const per = handlers.structFieldHandlers ?? (() => READ_ONLY); const stack = new VStack({ gap: Theme.gap }); const fields = new Map(); for (const field of def.fields) { const { row } = labelledRow(field.key); const own = field.kind === 'struct' ? nestedStructHandlers(handlers, field.key) : per([field.key], field); const sub = createField( ctx, fieldDef(field), structFieldValue(value, field), own, `${name} · ${field.key}`, ); addValue(row, sub); fields.set(field.key, sub); stack.add(withBelow(row, sub, 1)); } return { widget: stack, grow: true, setValue: (v) => { for (const field of def.fields) fields.get(field.key)?.setValue(structFieldValue(v, field)); }, parts: new Map([...fields].map(([key, sub]) => [key, sub.widget])), }; } case 'object': return objectField(ctx, name, value, handlers); case 'boolean': { const field = new Checkbox(Boolean(value)); field.onChange = handlers.onChange; return { widget: field, grow: false, setValue: (v) => field.setChecked(Boolean(v)) }; } case 'enum': { const labelOf = (v: PropertyValue) => def.options.find((o) => o.value === textOf(v))?.label ?? (textOf(v) || '—'); const button = new Dropdown(labelOf(value)); button.onClick = () => { new Menu( def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) })), ).openBelow(button); }; return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) }; } case 'layer': { // The names live in the project, not in the row: renaming a layer renames it everywhere. const render = def.renderLayers === true; const names = () => (render ? ctx.project().renderLayers : ctx.project().collisionLayers); const labelOf = (v: PropertyValue) => { if (!render) return layerName(names(), layerIndexOf(v)); const own = ownRenderLayer(v); return own === INHERIT_LAYER ? INHERIT_LABEL : layerName(names(), own); }; const button = new Dropdown(labelOf(value)); button.onClick = () => { const layers = names().map((name, i) => ({ label: name, onSelect: () => handlers.onChange(i) })); const inherit = { label: INHERIT_LABEL, onSelect: () => handlers.onChange(INHERIT_LAYER) }; new Menu(render ? [inherit, ...layers] : layers).openBelow(button); }; return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) }; } case 'layers': { const names = () => (def.renderLayers ? ctx.project().renderLayers : ctx.project().collisionLayers); const labelOf = (v: PropertyValue) => maskLabel(layerMaskOf(v), names()); let mask = layerMaskOf(value); const button = new Dropdown(labelOf(value)); // A tick per layer, and the menu stays open: picking several is the normal case here. button.onClick = () => { new Menu( names().map((name, i) => ({ label: name, checked: () => maskHas(mask, i), keepOpen: true, onSelect: () => { mask = maskWith(mask, i, !maskHas(mask, i)); handlers.onChange(mask); }, })), ).openBelow(button); }; return { widget: button, grow: true, setValue: (v) => { mask = layerMaskOf(v); button.setText(labelOf(v)); }, }; } case 'tileLayer': { const labelOf = (v: PropertyValue) => textOf(v) || FIRST_TILE_LAYER; const button = new Dropdown(labelOf(value)); button.onClick = () => { const names = ['', ...tilemapLayerNames(ctx.project())]; new Menu( names.map((name) => ({ label: name || FIRST_TILE_LAYER, onSelect: () => handlers.onChange(name) })), { searchable: true }, ).openBelow(button); }; return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) }; } case 'key': { const button = new KeyCaptureButton(textOf(value), (key) => handlers.onChange(key)); return { widget: button, grow: true, setValue: (v) => button.setKey(textOf(v)) }; } case 'asset': case 'texture': case 'statemachine': case 'sound': case 'scene': case 'animation': return referenceField(ctx, def, REFERENCES[def.kind], value, handlers); } } /** Shows a key name; click, then press a key to change it (Escape cancels). */ class KeyCaptureButton extends Button { private key: string; private capturing = false; constructor( key: string, private readonly onCapture: (key: string) => void, ) { super(keyLabel(key)); this.key = key; this.onClick = () => { this.capturing = true; this.setText('Press a key…'); ui().focus.focus(this); }; } setKey(key: string): void { this.key = key; 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(); this.capturing = false; if (event.key === 'Escape') { this.setText(keyLabel(this.key)); return true; } if (['Shift', 'Control', 'Alt', 'Meta'].includes(event.key)) { this.capturing = true; return true; } this.key = keyName(event.key); // Shift + A is "a", the name the game hears it by this.setText(keyLabel(this.key)); this.onCapture(this.key); return true; } override onBlur(): void { super.onBlur(); if (this.capturing) { this.capturing = false; this.setText(keyLabel(this.key)); } } } /** Display name of a `KeyboardEvent.key` value: "Space", "Left arrow", "—" for none. */ export function keyLabel(key: string): string { if (key === ' ') return 'Space'; if (key === '') return '—'; return key.replace(/^Arrow/, '') + (key.startsWith('Arrow') ? ' arrow' : ''); }