Source: ui/TextInputField.js

// src/engine/ui/TextInputField.js
import BaseUIElement from './BaseUIElement.js'

/**
 * @class TextInputField
 * @extends BaseUIElement
 * @description An interactive UI element for text input from the keyboard.
 */
class TextInputField extends BaseUIElement {
  /**
   * Creates an instance of TextInputField.
   * @param {object} options - Configuration options.
   * @param {number} [options.x=0] - The x-coordinate.
   * @param {number} [options.y=0] - The y-coordinate.
   * @param {number} [options.width=200] - The width of the input field.
   * @param {number} [options.height=30] - The height of the input field.
   * @param {string} [options.initialText=''] - Initial text content.
   * @param {string} [options.placeholderText=''] - Text to display when the field is empty and not focused.
   * @param {number} [options.maxLength=null] - Maximum number of characters. Null for no limit.
   * @param {string} [options.font='16px sans-serif'] - Font for the text.
   * @param {string} [options.textColor='black'] - Color of the text.
   * @param {string} [options.placeholderColor='gray'] - Color of the placeholder text.
   * @param {string} [options.backgroundColor='white'] - Background color of the field.
   * @param {string} [options.borderColor='gray'] - Border color.
   * @param {string} [options.focusBorderColor='dodgerblue'] - Border color when focused.
   * @param {number} [options.borderWidth=1] - Width of the border.
   * @param {number} [options.padding=5] - Inner padding for text.
   * @param {function(string):void} [options.onEnterPressed] - Callback when Enter is pressed. Receives current text.
   * @param {function(string):void} [options.onTextChanged] - Callback when text changes. Receives new text.
   * @param {function():void} [options.onFocus] - Callback when the field gains focus.
   * @param {function():void} [options.onBlur] - Callback when the field loses focus.
   */
  constructor(options = {}) {
    super(options) // Handles x, y, width, height, visible, enabled, id

    this.text = options.initialText || ''
    this.placeholderText = options.placeholderText || ''
    this.maxLength = options.maxLength || null

    this.font = options.font || '16px sans-serif'
    this.textColor = options.textColor || 'black'
    this.placeholderColor = options.placeholderColor || 'gray'
    this.backgroundColor = options.backgroundColor || 'white'
    this.borderColor = options.borderColor || 'gray'
    this.focusBorderColor = options.focusBorderColor || 'dodgerblue'
    this.borderWidth = options.borderWidth !== undefined ? options.borderWidth : 1
    this.padding = options.padding !== undefined ? options.padding : 5

    this.isFocused = false
    this.cursorVisible = false
    this.cursorBlinkRate = 500 // milliseconds
    this._cursorBlinkTimer = 0
    this._cursorPosition = this.text.length // For now, cursor is always at the end

    this.onEnterPressed = options.onEnterPressed || null
    this.onTextChanged = options.onTextChanged || null
    this.onFocusCallback = options.onFocus || null
    this.onBlurCallback = options.onBlur || null

    // Bind methods that will be used as event handlers
    this._boundHandleKeyDown = this._handleKeyDown.bind(this)
    this._boundHandleGlobalClick = this._handleGlobalClick.bind(this)
  }

  focus() {
    if (!this.enabled || this.isFocused) return
    this.isFocused = true
    this.cursorVisible = true
    this._cursorBlinkTimer = 0
    window.addEventListener('keydown', this._boundHandleKeyDown)
    document.addEventListener('mousedown', this._boundHandleGlobalClick, true) // Capture phase for global click
    if (this.onFocusCallback) this.onFocusCallback()
    // console.log(`TextInput (${this.id || 'ID_not_set'}): Focused`);
  }

  blur() {
    if (!this.isFocused) return
    this.isFocused = false
    this.cursorVisible = false
    window.removeEventListener('keydown', this._boundHandleKeyDown)
    document.removeEventListener('mousedown', this._boundHandleGlobalClick, true)
    if (this.onBlurCallback) this.onBlurCallback()
    // console.log(`TextInput (${this.id || 'ID_not_set'}): Blurred`);
  }

  setText(newText, triggerCallback = true) {
    const oldText = this.text
    if (this.maxLength !== null) {
      this.text = newText.substring(0, this.maxLength)
    } else {
      this.text = newText
    }
    this._cursorPosition = this.text.length // Keep cursor at end for simplicity

    if (this.onTextChanged && triggerCallback && oldText !== this.text) {
      this.onTextChanged(this.text)
    }
  }

  _handleGlobalClick(event) {
    // If click is outside this element, blur it.
    if (this.isFocused) {
      const canvas = this.engine ? this.engine.canvas : null
      if (canvas && event.target !== canvas) {
        // Click was outside canvas entirely
        this.blur()
        return
      }
      // If click was on canvas, check if it's outside this element's bounds
      if (canvas) {
        const rect = canvas.getBoundingClientRect()
        const mouseX = event.clientX - rect.left
        const mouseY = event.clientY - rect.top
        // TODO: Adjust mouseX, mouseY for canvas scaling if necessary
        if (!this.containsPoint(mouseX, mouseY)) {
          this.blur()
        }
      }
    }
  }

  _handleKeyDown(event) {
    if (!this.isFocused || !this.enabled) return

    // Prevent default browser action for keys we handle,
    // to avoid page scroll on space, or form submission on enter etc.
    if (
      event.key.length === 1 ||
      event.key === 'Backspace' ||
      event.key === 'Enter' ||
      event.key === 'Delete'
    ) {
      event.preventDefault()
    }

    let newText = this.text
    if (event.key.length === 1) {
      // Printable character
      if (this.maxLength === null || this.text.length < this.maxLength) {
        newText += event.key
      }
    } else if (event.key === 'Backspace') {
      newText = this.text.substring(0, this.text.length - 1)
    } else if (event.key === 'Enter') {
      if (this.onEnterPressed) {
        this.onEnterPressed(this.text)
      }
      // Optionally blur on enter: this.blur();
      return // Don't update text or call onTextChanged for Enter itself
    } else if (event.key === 'Delete') {
      // Basic delete: if cursor was at end, same as backspace.
      // Advanced: would delete char at cursor position.
      newText = this.text.substring(0, this.text.length - 1)
    } else {
      return // Ignore other special keys for now (arrows, home, end, etc.)
    }

    if (newText !== this.text) {
      this.setText(newText) // This will call onTextChanged
    }
    this.cursorVisible = true // Make cursor visible on key press
    this._cursorBlinkTimer = 0 // Reset blink timer
  }

  update(deltaTime, engine, mousePos) {
    super.update(deltaTime, engine, mousePos)
    if (!this.visible) return // Enabled check is handled in focus/blur/keydown

    // Handle click to focus
    const inputManager = engine.inputManager
    if (
      this.enabled &&
      inputManager.isMouseButtonJustPressed(inputManager.constructor.MOUSE_BUTTON_LEFT)
    ) {
      if (this.containsPoint(mousePos.x, mousePos.y)) {
        if (!this.isFocused) {
          this.focus()
        }
      }
      // Blurring by clicking outside is handled by _handleGlobalClick
    }

    // Cursor blinking
    if (this.isFocused) {
      this._cursorBlinkTimer += deltaTime * 1000 // deltaTime is in seconds
      if (this._cursorBlinkTimer >= this.cursorBlinkRate) {
        this.cursorVisible = !this.cursorVisible
        this._cursorBlinkTimer = 0
      }
    } else {
      this.cursorVisible = false
    }
  }

  _drawSelf(context, engine) {
    // Background
    context.fillStyle = this.backgroundColor
    context.fillRect(this.x, this.y, this.width, this.height)

    // Border
    if (this.borderWidth > 0) {
      context.strokeStyle =
        this.isFocused && this.enabled ? this.focusBorderColor : this.borderColor
      context.lineWidth = this.borderWidth
      context.strokeRect(this.x, this.y, this.width, this.height)
    }

    // Text or Placeholder
    context.font = this.font
    context.textAlign = 'left'
    context.textBaseline = 'middle' // Vertically center text

    const textX = this.x + this.padding
    const textY = this.y + this.height / 2

    if (this.text.length > 0) {
      context.fillStyle = this.enabled ? this.textColor : 'darkgray'
      context.fillText(this.text, textX, textY)
    } else if (!this.isFocused && this.placeholderText) {
      context.fillStyle = this.enabled ? this.placeholderColor : 'darkgray'
      context.fillText(this.placeholderText, textX, textY)
    }

    // Cursor
    if (this.isFocused && this.cursorVisible && this.enabled) {
      // Measure text to position cursor at the end
      // For simplicity, cursor is always at the end.
      // A more complex implementation would track cursor position within the text.
      const textWidth = context.measureText(this.text).width
      const cursorX = textX + textWidth
      const cursorYStart = this.y + this.padding
      const cursorYEnd = this.y + this.height - this.padding

      context.strokeStyle = this.enabled ? this.textColor : 'darkgray'
      context.lineWidth = 1
      context.beginPath()
      context.moveTo(cursorX, cursorYStart)
      context.lineTo(cursorX, cursorYEnd)
      context.stroke()
    }
  }

  destroy() {
    super.destroy() // Call if BaseUIElement has a destroy method
    this.blur() // Ensure listeners are removed
    // console.log(`TextInput (${this.id || 'ID_not_set'}): Destroyed`);
  }
}

export default TextInputField