UNPKG

asciitorium

Version:
305 lines (304 loc) 11.8 kB
import { Component } from '../core/Component.js'; import { State } from '../core/State.js'; import { requestRender } from '../core/RenderScheduler.js'; function isNumberState(s) { return s && typeof s.value === 'number'; } export class TextInput extends Component { constructor(options) { const height = options.height ?? options.style?.height ?? 3; // Default to 3 for backwards compatibility super({ ...options, height, border: options.border ?? options.style?.border ?? true }); this.initialHeightCalculated = false; // Track if we've done the initial height calculation this.cursorIndex = 0; this.suppressCursorSync = false; this.cursorVisible = true; this.focusable = true; this.fixedHeight = typeof height === 'number' ? height : undefined; this.numericMode = options.numeric === true || isNumberState(options.value); this.placeholder = String(options.placeholder ?? ''); this.onEnter = options.onEnter; // Wire up states if (isNumberState(options.value)) { this.valueNum = options.value; this.valueStr = new State(String(options.value.value ?? '')); // when external number changes, reflect into the visible string this.bind(this.valueNum, (n) => { if (!this.suppressCursorSync) this.cursorIndex = String(n ?? '').length; // Don't thrash on NaN; just render empty this.valueStr.value = Number.isFinite(n) ? String(n) : ''; }); } else { // string mode this.valueStr = options.value ?? new State(''); this.valueNum = undefined; this.bind(this.valueStr, (v) => { if (!this.suppressCursorSync) this.cursorIndex = v.length; this.updateHeight(); // Update height when value changes }); } // Don't do initial height calculation here - wait until first render when width is properly resolved } setString(next) { this.suppressCursorSync = true; this.valueStr.value = next; this.suppressCursorSync = false; // Update height based on content if not fixed height this.updateHeight(); // If we have a backing number, try to parse and propagate if (this.valueNum) { const parsed = Number(next.trim()); if (next.trim() === '' || Number.isNaN(parsed)) { // empty or invalid -> don't push NaN; leave numeric as-is return; } this.valueNum.value = parsed; } } updateHeight() { // Only adjust height if no explicit height was set if (this.fixedHeight !== undefined) { return; } const newHeight = this.calculateRequiredHeight(); if (newHeight !== this.height) { this.height = newHeight; // Trigger layout recalculation by calling protected method from this context this.invalidateLayout(); // Request a full re-render to update parent layouts requestRender(); } } calculateRequiredHeight() { const borderPad = this.border ? 2 : 0; const innerWidth = this.width - borderPad; const prefixLength = 2; // "> " prefix const usableWidth = Math.max(1, innerWidth - prefixLength); const content = this.valueStr.value.length > 0 ? this.valueStr.value : this.placeholder; const lines = this.wrapText(content, usableWidth); // Calculate required height: actual lines needed + border padding const requiredLines = Math.max(1, lines.length); const totalHeight = requiredLines + borderPad; // For dynamic height: start at 3 (1 line + border), grow as needed // So: 1 line = height 3, 2 lines = height 4, 3 lines = height 5, etc. return Math.max(3, totalHeight); } wrapText(text, maxWidth) { if (maxWidth <= 0) return [text]; const lines = []; let currentLine = ''; for (let i = 0; i < text.length; i++) { const char = text[i]; if (char === '\n') { lines.push(currentLine); currentLine = ''; } else if (currentLine.length >= maxWidth) { lines.push(currentLine); currentLine = char; } else { currentLine += char; } } if (currentLine.length > 0 || lines.length === 0) { lines.push(currentLine); } return lines; } allowChar(ch, current) { if (!this.numericMode) return true; // numeric guard: digits; one '.'; leading '-' if (/[0-9]/.test(ch)) return true; if (ch === '.' && !current.includes('.')) return true; if (ch === '-' && this.cursorIndex === 0 && !current.startsWith('-')) return true; return false; } startCursorBlink() { // Only start blinking if not already active if (this.blinkInterval) return; this.cursorVisible = true; this.blinkInterval = setInterval(() => { this.cursorVisible = !this.cursorVisible; requestRender(); }, 500); // Blink every 500ms } stopCursorBlink() { if (this.blinkInterval) { clearInterval(this.blinkInterval); this.blinkInterval = undefined; } this.cursorVisible = true; } resetCursorBlink() { this.stopCursorBlink(); this.startCursorBlink(); } destroy() { this.stopCursorBlink(); super.destroy(); } handleEvent(event) { // Enable capture mode on first interaction when focused if (this.hasFocus && !this.captureModeActive && event !== 'Escape') { this.captureModeActive = true; } // Reset cursor blink timer on any keypress if (this.hasFocus) { this.resetCursorBlink(); } let updated = false; const val = this.valueStr.value; // Handle Escape key - exit capture mode if (event === 'Escape') { this.captureModeActive = false; return true; } if (event.length === 1 && event >= ' ') { if (!this.allowChar(event, val)) return false; const left = val.slice(0, this.cursorIndex); const right = val.slice(this.cursorIndex); this.setString(left + event + right); this.cursorIndex++; updated = true; } else if (event === 'Backspace') { if (this.cursorIndex > 0) { const left = val.slice(0, this.cursorIndex - 1); const right = val.slice(this.cursorIndex); this.setString(left + right); this.cursorIndex--; updated = true; } } else if (event === 'Delete') { if (this.cursorIndex < val.length) { const left = val.slice(0, this.cursorIndex); const right = val.slice(this.cursorIndex + 1); this.setString(left + right); updated = true; } } else if (event === 'ArrowLeft') { this.cursorIndex = Math.max(0, this.cursorIndex - 1); updated = true; } else if (event === 'ArrowRight') { this.cursorIndex = Math.min(val.length, this.cursorIndex + 1); updated = true; } else if (event === 'Home') { this.cursorIndex = 0; updated = true; } else if (event === 'End') { this.cursorIndex = val.length; updated = true; } else if (event === 'Enter') { // Commit: in numeric mode, try to coerce one last time if (this.valueNum) { const parsed = Number(this.valueStr.value.trim()); if (Number.isFinite(parsed)) this.valueNum.value = parsed; } // Call the onEnter callback if provided if (this.onEnter) { this.onEnter(); } // Exit capture mode after committing this.captureModeActive = false; updated = true; } return updated; } draw() { // Do initial height calculation on first draw when width is properly resolved if (!this.initialHeightCalculated) { this.updateHeight(); this.initialHeightCalculated = true; } // Manage cursor blinking based on focus state if (this.hasFocus && !this.blinkInterval) { this.startCursorBlink(); } else if (!this.hasFocus && this.blinkInterval) { this.stopCursorBlink(); } const buffer = super.draw(); const prefix = this.hasFocus ? ' > ' : ' > '; const prefixLength = prefix.length; const startY = this.border ? 1 : 0; const startX = this.border ? 1 : 0; const innerWidth = this.width - (this.border ? 2 : 0); const usableWidth = Math.max(0, innerWidth - prefixLength); const raw = this.valueStr.value.length > 0 ? this.valueStr.value : this.placeholder; const lines = this.wrapText(raw, usableWidth); // Render each line for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { const y = startY + lineIndex; if (y >= this.height - (this.border ? 1 : 0)) break; // Don't draw outside buffer const line = lines[lineIndex]; // prefix (only on first line) if (lineIndex === 0) { for (let i = 0; i < prefixLength && i < innerWidth; i++) { if (y < buffer.length && startX + i < buffer[y].length) { buffer[y][startX + i] = prefix[i]; } } } // text const textStartX = lineIndex === 0 ? startX + prefixLength : startX; for (let i = 0; i < line.length; i++) { const x = textStartX + i; if (y < buffer.length && x < buffer[y].length) { buffer[y][x] = line[i]; } } } // cursor - only show when focused and visible (for blinking effect) if (this.hasFocus && this.cursorVisible && usableWidth > 0) { const { line: cursorLine, pos: cursorPos } = this.getCursorPosition(lines); const y = startY + cursorLine; const x = (cursorLine === 0 ? startX + prefixLength : startX) + cursorPos; if (y < buffer.length && x < buffer[y].length) { buffer[y][x] = '▉'; } } this.buffer = buffer; return buffer; } getCursorPosition(lines) { let charCount = 0; for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { const lineLength = lines[lineIndex].length; if (this.cursorIndex <= charCount + lineLength) { return { line: lineIndex, pos: Math.min(this.cursorIndex - charCount, lineLength) }; } charCount += lineLength; } // Cursor at end const lastLineIndex = lines.length - 1; return { line: Math.max(0, lastLineIndex), pos: lines[lastLineIndex]?.length || 0 }; } }