import type { FederatedPointerEvent } from 'pixi.js'; import { ui } from '../UIContext'; import { TextField, type TextFieldOptions } from './TextField'; export interface NumberFieldOptions extends Omit { min?: number; max?: number; /** Increment per pixel while scrubbing and for arrow keys. */ step?: number; /** Fires continuously while scrubbing or typing a valid number. */ onInput?: (value: number) => void; /** Fires once when a scrub ends or an edit is committed. */ onChange?: (value: number) => void; } /** * Numeric input: click to type, drag horizontally to scrub (Shift = ×10, Alt = ×0.1), * arrow keys step. Values are clamped to min/max and formatted with the step's precision. */ export class NumberField extends TextField { private numeric: number; private readonly min: number; private readonly max: number; private readonly step: number; private readonly decimals: number; private scrub: { startX: number; startValue: number; moved: boolean; event: FederatedPointerEvent } | null = null; onNumberInput?: (value: number) => void; onNumberChange?: (value: number) => void; constructor(value = 0, options: NumberFieldOptions = {}) { super('', { placeholder: options.placeholder, style: options.style ?? 'mono', width: options.width }); this.min = options.min ?? -Infinity; this.max = options.max ?? Infinity; this.step = options.step ?? 1; this.decimals = Math.max(0, Math.min(6, Math.ceil(-Math.log10(this.step)))); this.onNumberInput = options.onInput; this.onNumberChange = options.onChange; this.numeric = this.clamp(value); this.valueText = this.format(this.numeric); this.cursor = 'ew-resize'; this.onInput = (text) => { const n = Number(text.trim()); if (text.trim() !== '' && Number.isFinite(n)) { this.numeric = this.clamp(n); this.onNumberInput?.(this.numeric); } }; this.onChange = (text) => { const n = Number(text.trim()); this.numeric = this.clamp(Number.isFinite(n) && text.trim() !== '' ? n : this.numeric); this.valueText = this.format(this.numeric); this.invalidate(); this.onNumberChange?.(this.numeric); }; this.on('globalpointermove', (e: FederatedPointerEvent) => { if (!this.scrub) return; const dx = e.global.x - this.scrub.startX; if (!this.scrub.moved && Math.abs(dx) < 3) return; this.scrub.moved = true; const factor = e.shiftKey ? 10 : e.altKey ? 0.1 : 1; this.setNumber(this.scrub.startValue + dx * this.step * factor); this.onNumberInput?.(this.numeric); }); const finish = () => { const scrub = this.scrub; this.scrub = null; if (!scrub) return; if (scrub.moved) { this.onNumberChange?.(this.numeric); } else { // A plain click: enter text editing with everything selected. ui().focus.focus(this); this.selectAll(); } }; this.on('pointerup', finish); this.on('pointerupoutside', finish); } get number(): number { return this.numeric; } setNumber(value: number): void { this.numeric = this.clamp(value); if (!this.isEditing) { this.valueText = this.format(this.numeric); this.invalidate(); } } protected override handlePointerDown(e: FederatedPointerEvent): void { if (e.button !== 0) return; if (this.isEditing) { super.handlePointerDown(e); return; } e.stopPropagation(); this.scrub = { startX: e.global.x, startValue: this.numeric, moved: false, event: e }; } override onBridgeKey(event: KeyboardEvent): boolean { if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { const dir = event.key === 'ArrowUp' ? 1 : -1; const factor = event.shiftKey ? 10 : event.altKey ? 0.1 : 1; this.numeric = this.clamp(this.numeric + dir * this.step * factor); this.valueText = this.format(this.numeric); ui().keyboard.setValue(this.valueText, 0, this.valueText.length); this.selectAll(); this.onNumberInput?.(this.numeric); return true; } return super.onBridgeKey(event); } private clamp(value: number): number { if (!Number.isFinite(value)) return this.numeric ?? 0; return Math.max(this.min, Math.min(this.max, value)); } private format(value: number): string { const rounded = Number(value.toFixed(this.decimals)); return String(rounded); } }