import { CanvasTextMetrics, type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; import type { TextEditTarget } from '../KeyboardBridge'; import { Theme, textStyle, type TextStyleToken } from '../Theme'; import { ui } from '../UIContext'; import { type Size, Widget } from '../Widget'; export interface TextFieldOptions { placeholder?: string; style?: TextStyleToken; width?: number; /** Fires while typing. */ onInput?: (value: string) => void; /** Fires on Enter / blur when the value changed since editing started. */ onChange?: (value: string) => void; } /** * Single-line text input drawn in the canvas. Keyboard handling is delegated to the * KeyboardBridge while the field is focused; this class only mirrors value, selection * and caret and handles pointer interaction (caret placement, drag selection). */ export class TextField extends Widget implements TextEditTarget { protected readonly bg = new Graphics(); protected readonly selectionShape = new Graphics(); protected readonly caret = new Graphics(); protected readonly textNode: Text; protected readonly placeholderNode: Text; protected valueText = ''; protected editing = false; protected selStart = 0; protected selEnd = 0; private originalValue = ''; private scrollX = 0; private hovered = false; private caretVisible = true; private blinkTimer = 0; private dragSelecting = false; private readonly innerPad = 6; placeholder: string; onInput?: (value: string) => void; onChange?: (value: string) => void; constructor(value = '', options: TextFieldOptions = {}) { super(); this.valueText = value; this.placeholder = options.placeholder ?? ''; this.onInput = options.onInput; this.onChange = options.onChange; this.focusable = true; this.cursor = 'text'; this.minHeight = Theme.controlHeight; this.minWidth = options.width ?? 80; this.textNode = new Text({ text: value, style: textStyle(options.style ?? 'body') }); this.placeholderNode = new Text({ text: this.placeholder, style: textStyle('muted') }); const clip = new Graphics(); this.addChild(this.bg, this.selectionShape, this.placeholderNode, this.textNode, this.caret, clip); this.mask = clip; this.clipShape = clip; this.on('pointerover', () => { this.hovered = true; this.invalidate(); }); this.on('pointerout', () => { this.hovered = false; this.invalidate(); }); this.on('pointerdown', (e: FederatedPointerEvent) => this.handlePointerDown(e)); this.on('globalpointermove', (e: FederatedPointerEvent) => { if (!this.dragSelecting) return; const index = this.indexAt(this.toLocal(e.global).x); this.setSelection(this.selStart, index, true); }); const stop = () => { this.dragSelecting = false; }; this.on('pointerup', stop); this.on('pointerupoutside', stop); } private readonly clipShape: Graphics; get value(): string { return this.valueText; } /** Programmatic update (from the model). Ignored while the user is editing. */ setValue(value: string): void { if (this.editing || value === this.valueText) return; this.valueText = value; this.invalidate(); } get isEditing(): boolean { return this.editing; } protected handlePointerDown(e: FederatedPointerEvent): void { if (e.button !== 0) return; e.stopPropagation(); const index = this.indexAt(this.toLocal(e.global).x); if (!this.editing) { ui().focus.focus(this); if (e.detail >= 2) this.selectAll(); else this.setSelection(index, index, true); } else if (e.detail >= 2) { this.selectAll(); } else { this.setSelection(index, index, true); } this.dragSelecting = true; } // --- focus --------------------------------------------------------------------------- override onFocus(): void { super.onFocus(); this.startEditing(); } override onBlur(): void { super.onBlur(); this.stopEditing(true); } protected startEditing(): void { if (this.editing) return; this.editing = true; this.originalValue = this.valueText; this.selStart = 0; this.selEnd = this.valueText.length; const global = this.getGlobalPosition(); ui().keyboard.moveTo(global.x, global.y); ui().keyboard.attach(this, this.valueText, this.selStart, this.selEnd); this.startBlink(); this.invalidate(); } protected stopEditing(commit: boolean): void { if (!this.editing) return; this.editing = false; this.stopBlink(); ui().keyboard.detach(this); if (commit) { if (this.valueText !== this.originalValue) this.onChange?.(this.valueText); } else if (this.valueText !== this.originalValue) { this.valueText = this.originalValue; this.onInput?.(this.valueText); } this.scrollX = 0; this.invalidate(); } selectAll(): void { this.setSelection(0, this.valueText.length, true); } private setSelection(start: number, end: number, pushToBridge: boolean): void { this.selStart = Math.max(0, Math.min(this.valueText.length, start)); this.selEnd = Math.max(0, Math.min(this.valueText.length, end)); if (pushToBridge && this.editing) { const lo = Math.min(this.selStart, this.selEnd); const hi = Math.max(this.selStart, this.selEnd); ui().keyboard.setValue(this.valueText, lo, hi); // Keep our anchor/focus order; the DOM only knows lo/hi. } this.caretVisible = true; this.invalidate(); } // --- TextEditTarget -------------------------------------------------------------------- onBridgeInput(value: string, selectionStart: number, selectionEnd: number): void { const changed = value !== this.valueText; this.valueText = value; this.selStart = selectionStart; this.selEnd = selectionEnd; this.caretVisible = true; this.invalidate(); if (changed) this.onInput?.(value); } onBridgeKey(event: KeyboardEvent): boolean { if (event.key === 'Enter') { ui().focus.blur(); // triggers onBlur → commit return true; } if (event.key === 'Escape') { this.stopEditing(false); ui().focus.blur(); return true; } if (event.key === 'Tab') { ui().focus.cycle(event.shiftKey); return true; } return false; } onBridgeBlur(): void { // DOM focus went elsewhere (browser UI, another tab). Commit and release focus. this.stopEditing(true); if (ui().focus.focused === this) ui().focus.blur(); } // --- geometry -------------------------------------------------------------------------- private textWidth(text: string): number { return CanvasTextMetrics.measureText(text, this.textNode.style).width; } /** Character index closest to a local x coordinate. */ protected indexAt(localX: number): number { const x = localX - this.innerPad + this.scrollX; let best = 0; let bestDist = Infinity; for (let i = 0; i <= this.valueText.length; i++) { const dist = Math.abs(this.textWidth(this.valueText.slice(0, i)) - x); if (dist < bestDist) { bestDist = dist; best = i; } } return best; } private startBlink(): void { this.stopBlink(); this.caretVisible = true; this.blinkTimer = window.setInterval(() => { this.caretVisible = !this.caretVisible; this.invalidate(); }, 530); } private stopBlink(): void { if (this.blinkTimer) window.clearInterval(this.blinkTimer); this.blinkTimer = 0; } override measure(): Size { return { w: this.minWidth, h: Math.max(this.minHeight, Theme.controlHeight) }; } override redraw(): void { const c = Theme.colors; const innerW = this.w - this.innerPad * 2; this.bg.clear(); this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(this.hovered && !this.editing ? c.controlHover : c.control); this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: this.focused ? c.accent : c.border }); this.clipShape.clear().rect(1, 1, this.w - 2, this.h - 2).fill(0xffffff); this.textNode.text = this.valueText; this.placeholderNode.text = this.valueText ? '' : this.placeholder; // Horizontal scroll so the caret stays visible. const caretX = this.textWidth(this.valueText.slice(0, this.selEnd)); if (!this.editing) this.scrollX = 0; else if (caretX - this.scrollX > innerW) this.scrollX = caretX - innerW; else if (caretX - this.scrollX < 0) this.scrollX = caretX; const textX = this.innerPad - this.scrollX; const textY = Math.round((this.h - this.textNode.height) / 2); this.textNode.position.set(Math.round(textX), textY); this.placeholderNode.position.set(this.innerPad, textY); this.selectionShape.clear(); this.caret.clear(); if (!this.editing) return; const lo = Math.min(this.selStart, this.selEnd); const hi = Math.max(this.selStart, this.selEnd); if (hi > lo) { const x1 = textX + this.textWidth(this.valueText.slice(0, lo)); const x2 = textX + this.textWidth(this.valueText.slice(0, hi)); this.selectionShape.rect(x1, textY, x2 - x1, this.textNode.height).fill({ color: c.accent, alpha: 0.45 }); } if (this.caretVisible) { this.caret.rect(Math.round(textX + caretX), textY, 1, this.textNode.height).fill(c.text); } } override destroy(options?: Parameters[0]): void { this.stopEditing(false); super.destroy(options); } }