Files
gen1recomp/libs/flexlove/modules/behaviors/TextEditable.lua
T
2026-08-03 11:50:49 -04:00

577 lines
21 KiB
Lua

-- modules/behaviors/TextEditable.lua
--
-- Concrete behavior: TextEditor subsystem ownership — text editing, cursor
-- management, text selection, text-related input handling, and text-editor
-- state save/restore.
--
-- This behavior consolidates the legacy `if self._textEditor` nil-guard
-- patterns that previously lived inside Element.lua:
--
-- * TextEditor creation + immediate-mode state restore (formerly
-- Element:_initSubSystems lines ~813-830 — the `if self.editable then
-- self._textEditor = Element._TextEditor.new {...}` block).
-- * Cursor-blink update (formerly Element:update line ~2810 —
-- `if self._textEditor then self._textEditor:update(self, dt) end`).
-- * The 27 text-editor delegate methods (formerly Element:setText /
-- getText / setCursorPosition / setSelection / focus / textinput /
-- keypressed / _handleTextClick / _handleTextDrag / ...). Each was a 3-line
-- nil-guard stub (check `_textEditor`, forward call, end). They are now
-- module-level functions on this behavior; Element retains only 1-line
-- forwarders that route through `Element._TextEditable.<fn>(self, ...)`.
-- * Text-editor state save/restore (formerly the textEditor branch of
-- Element:saveState / Element:restoreState), including the cursor/selection
-- field sync and the text-selection drag-tracking fields
-- (`_mouseDownPosition` / `_textDragOccurred`).
--
-- Element retains the `self._textEditor` field for backward-compat field
-- access (Renderer:drawText reads it directly for cursor/selection rendering);
-- runtime state lives ON THE ELEMENT. The behavior itself is stateless +
-- immutable + shared across elements.
--
-- State ownership (per the locked Behavior contract):
-- * Per-element runtime state lives ON THE ELEMENT (`self._textEditor`,
-- `self._mouseDownPosition`, `self._textDragOccurred`). The behavior
-- instance is stateless + immutable and shared across all editable
-- elements.
-- * Element-class-level dependencies are resolved via `getmetatable(element)`
-- (which IS the Element class set by Element._construct), so the hook
-- signature stays exactly `(element, ...)` with no DI parameters.
--
-- onDraw is a no-op: text/cursor/selection rendering stays in the Renderer's
-- command buffer (Layer 4 "text"), driven by the Thamed behavior's single
-- `Renderer:draw` call. The Renderer's `drawText` already reads
-- `element._textEditor` for cursor/selection, so TextEditable OWNS the
-- subsystem that drawText consumes, but the draw dispatch stays in the
-- renderer to preserve the unified transform/scissor command-buffer ordering
-- (mirrors Selectable.onDraw's no-op precedent, where rendering is owned by a
-- different layer). Hoisting drawText into this behavior's onDraw would
-- double-render text, since the Renderer command buffer already emits a "text"
-- layer for every element.
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Resolve the Element class from an element instance (mirrors Clickable /
-- Selectable). `setmetatable({}, Element)` in `_construct` makes the instance
-- metatable BE the Element class, so this yields Element._TextEditor,
-- Element._textEditorDeps, Element._Context, Element._StateManager, etc.
-- without threading deps through the hook signature.
local function ElementClass(element)
return getmetatable(element)
end
-- ============================================================================
-- shouldAttach (class-level predicate, no element required)
-- ============================================================================
-- Mirrors the spec predicate: attach when the element is text-editable OR
-- carries text content. onAttach only ALLOCATES a TextEditor when
-- `element.editable` is true (preserving the pre-refactor creation invariant
-- "TextEditor created iff editable"), so non-editable text labels attach the
-- behavior but allocate no TextEditor — their onUpdate/onDraw/saveState are
-- nil-guarded no-ops, and the Element forwarders route them through the
-- non-editable branch of each delegate function (reads/writes `element.text`
-- directly). This keeps shouldAttach faithful to the spec while preserving
-- exact pre-refactor allocation behavior.
local function shouldAttach(props)
props = props or {}
return props.editable == true or props.text ~= nil
end
-- ============================================================================
-- onAttach — create the TextEditor (formerly Element:_initSubSystems lines
-- ~813-830) and restore immediate-mode TextEditor state.
-- ============================================================================
local function onAttach(element)
local Element = ElementClass(element)
-- Only editable elements own a TextEditor. Preserves the exact pre-refactor
-- creation guard (`if self.editable then ... end`) — non-editable text
-- elements attach the behavior (so their forwarders route through a single
-- code path) but allocate no TextEditor.
if not element.editable then
return
end
-- Config is sourced from element fields (bound by _applyProps / _initVisualState
-- before _attachBehaviors runs at the tail of Element.new) — NOT from raw
-- props. The callbacks (onFocus/onBlur/onTextInput/onTextChange/onEnter) are
-- schema-bound element fields by this point, and `element.text` is set by
-- _initVisualState, so no `props` reference is needed here (the hook
-- signature is `(element)`).
element._textEditor = Element._TextEditor.new({
editable = element.editable,
multiline = element.multiline,
passwordMode = element.passwordMode,
textWrap = element.textWrap,
maxLines = element.maxLines,
maxLength = element.maxLength,
placeholder = element.placeholder,
inputType = element.inputType,
textOverflow = element.textOverflow,
scrollable = element.scrollable,
autoGrow = element.autoGrow,
selectOnFocus = element.selectOnFocus,
cursorColor = element.cursorColor,
selectionColor = element.selectionColor,
cursorBlinkRate = element.cursorBlinkRate,
text = element.text or "",
onFocus = element.onFocus,
onBlur = element.onBlur,
onTextInput = element.onTextInput,
onTextChange = element.onTextChange,
onEnter = element.onEnter,
}, Element._textEditorDeps)
-- Restore TextEditor state from StateManager in immediate mode. Mirrors the
-- legacy _initSubSystems immediate-mode restore. Safe to run here (after
-- _construct registered the element with StateManager) — the StateManager
-- lookup is sparse and returns nil for a fresh element. Mode-aware via
-- Context.isImmediateMode (behavior-mode-unification task 11).
if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
local state = Element._StateManager.getState(element._stateId)
if state and state.textEditor then
element._textEditor:setState(state.textEditor, element)
end
end
end
local function onDetach(element)
-- Clear text-input callback closures read by TextEditor / KeyboardNavigation
-- so the element's closure references can be collected in immediate mode
-- (formerly part of Element:_cleanup). The TextEditor instance itself is
-- INTENTIONALLY kept: Element:_cleanup preserves element structure for
-- inspection (released when the element is GC'd).
element.onTextInput = nil
element.onTextChange = nil
element.onEnter = nil
end
-- ============================================================================
-- onUpdate — cursor-blink animation (formerly Element:update line ~2810).
-- ============================================================================
-- Drives TextEditor:update (cursor blink + blink-pause timer). Guarded on
-- `element._textEditor` because non-editable text elements attach this
-- behavior (per shouldAttach) but own no TextEditor. Element:update contains
-- zero text-editor references — the dispatch loop calls this hook.
local function onUpdate(element, dt)
local textEditor = element._textEditor
if textEditor then
textEditor:update(element, dt)
end
end
-- ============================================================================
-- onDraw — no-op (see file header: text rendering stays in the Renderer
-- command buffer driven by the Thamed behavior's Renderer:draw call).
-- ============================================================================
local function onDraw() end
-- ============================================================================
-- saveState / restoreState — TextEditor state + text-selection drag
-- tracking (formerly the textEditor branch of Element:saveState /
-- Element:restoreState, including the _mouseDownPosition / _textDragOccurred
-- fields).
-- ============================================================================
-- Returns a snapshot under the `textEditor` key to match the legacy immediate-
-- mode restoreState contract (Element:restoreState looked up state.textEditor).
-- The behavior-dispatch loop in Element:saveState merges behavior snapshots
-- into the top-level state table, so returning { textEditor = ... } slots in
-- identically to the old inline `state.textEditor = self._textEditor:getState()`
-- assignment. The drag-tracking fields are merged at the top level too
-- (matching the legacy `state._mouseDownPosition` / `state._textDragOccurred`
-- assignments) since they are text-selection state.
local function saveState(element)
local textEditor = element._textEditor
if not textEditor then
-- Non-editable text element: still persist drag-tracking fields if set
-- (they are only ever set for editable elements, but persist defensively).
local hasDragState = element._mouseDownPosition ~= nil or element._textDragOccurred ~= nil
if not hasDragState then
return nil
end
local snapshot = {}
if element._mouseDownPosition ~= nil then
snapshot._mouseDownPosition = element._mouseDownPosition
end
if element._textDragOccurred ~= nil then
snapshot._textDragOccurred = element._textDragOccurred
end
return snapshot
end
local snapshot = { textEditor = textEditor:getState() }
if element._mouseDownPosition ~= nil then
snapshot._mouseDownPosition = element._mouseDownPosition
end
if element._textDragOccurred ~= nil then
snapshot._textDragOccurred = element._textDragOccurred
end
return snapshot
end
-- Consumes the previously-saved snapshot keyed under `textEditor` plus the
-- drag-tracking fields. The behavior-dispatch loop passes the FULL top-level
-- state table; this hook reads only its own slices, mirroring the legacy
-- `if self._textEditor and state.textEditor then ... end` guard.
local function restoreState(element, state)
if not state then
return
end
local textEditor = element._textEditor
if textEditor and state.textEditor then
textEditor:setState(state.textEditor, element)
-- Sync TextEditor's focus/cursor/selection state to Element for theme
-- management (mirrors the legacy restoreState field sync).
element._focused = textEditor._focused
element._cursorPosition = textEditor._cursorPosition
element._selectionStart = textEditor._selectionStart
element._selectionEnd = textEditor._selectionEnd
element._textBuffer = textEditor._textBuffer
end
-- Restore drag-tracking state for text selection (top-level keys).
if state._mouseDownPosition ~= nil then
element._mouseDownPosition = state._mouseDownPosition
end
if state._textDragOccurred ~= nil then
element._textDragOccurred = state._textDragOccurred
end
end
-- ============================================================================
-- Text-editor delegate functions.
--
-- These are the module-level implementations of the 27 text-editor delegate
-- methods that previously lived on Element. Each mirrors the pre-refactor
-- Element method body VERBATIM (with `self` → `element`), including the
-- `element._textEditor` nil-guard: the guard is required because (a) non-
-- editable text elements attach this behavior (per shouldAttach) but own no
-- TextEditor, and (b) Element forwards these methods BEFORE onAttach has run
-- (e.g. an `onCreate` callback firing during _finalizeConstruction, which
-- runs before _attachBehaviors). The nil-guards live in THIS file (not in
-- Element.lua), so the Element.lua `if self._textEditor` count drops to 0.
--
-- Element retains 1-line forwarders: `Element.setText = function(self, text)
-- return Element._TextEditable.setText(self, text) end` (etc.), so external
-- callers (EventHandler, KeyboardNavigation, game UI) keep working unchanged.
--
-- The TextEditor API is mixed: most methods take the element as first arg
-- (`te:method(element, ...)` — "passesSelf"); a few getters omit it
-- (`te:method()`). The delegation contract is pinned by
-- subsystem_delegation_test.lua, so this mapping must match TextEditor's
-- method signatures exactly.
-- ============================================================================
-- --- Cursor management (passesSelf = element forwarded) ------------------
local function setCursorPosition(element, position)
local textEditor = element._textEditor
if textEditor then
textEditor:setCursorPosition(element, position)
end
end
local function getCursorPosition(element)
local textEditor = element._textEditor
if textEditor then
return textEditor:getCursorPosition()
end
return 0
end
local function moveCursorBy(element, delta)
local textEditor = element._textEditor
if textEditor then
textEditor:moveCursorBy(element, delta)
end
end
local function moveCursorToStart(element)
local textEditor = element._textEditor
if textEditor then
textEditor:moveCursorToStart(element)
end
end
local function moveCursorToEnd(element)
local textEditor = element._textEditor
if textEditor then
textEditor:moveCursorToEnd(element)
end
end
local function moveCursorToLineStart(element)
local textEditor = element._textEditor
if textEditor then
textEditor:moveCursorToLineStart(element)
end
end
local function moveCursorToLineEnd(element)
local textEditor = element._textEditor
if textEditor then
textEditor:moveCursorToLineEnd(element)
end
end
local function moveCursorToPreviousWord(element)
local textEditor = element._textEditor
if textEditor then
textEditor:moveCursorToPreviousWord(element)
end
end
local function moveCursorToNextWord(element)
local textEditor = element._textEditor
if textEditor then
textEditor:moveCursorToNextWord(element)
end
end
-- --- Selection management ------------------------------------------------
local function setSelection(element, startPos, endPos)
local textEditor = element._textEditor
if textEditor then
textEditor:setSelection(element, startPos, endPos)
end
end
local function getSelection(element)
local textEditor = element._textEditor
if textEditor then
return textEditor:getSelection()
end
return nil
end
local function hasSelection(element)
local textEditor = element._textEditor
if textEditor ~= nil then
return textEditor:hasSelection()
end
return false
end
local function clearSelection(element)
local textEditor = element._textEditor
if textEditor then
textEditor:clearSelection(element)
end
end
local function selectAll(element)
local textEditor = element._textEditor
if textEditor then
textEditor:selectAll(element)
end
end
local function getSelectedText(element)
local textEditor = element._textEditor
if textEditor then
return textEditor:getSelectedText()
end
return nil
end
local function deleteSelection(element)
local textEditor = element._textEditor
if textEditor then
return textEditor:deleteSelection(element)
end
return false
end
-- --- Focus management ----------------------------------------------------
local function focus(element)
local textEditor = element._textEditor
if textEditor then
textEditor:focus(element)
end
end
local function blur(element)
local textEditor = element._textEditor
if textEditor then
textEditor:blur(element)
end
end
local function isFocused(element)
local textEditor = element._textEditor
if textEditor then
return textEditor:isFocused()
end
return false
end
-- --- Text buffer management (with post-delegation sync) ------------------
-- These methods sync `element.text` from the TextEditor result + drive
-- auto-grow, exactly as the legacy Element methods did.
local function getText(element)
local textEditor = element._textEditor
if textEditor then
return textEditor:getText()
end
return element.text or ""
end
local function setText(element, text)
local textEditor = element._textEditor
if textEditor then
textEditor:setText(element, text)
element.text = textEditor:getText() -- Sync display text
textEditor:updateAutoGrowHeight(element)
return
end
element.text = text
end
local function insertText(element, text, position)
local textEditor = element._textEditor
if textEditor then
textEditor:insertText(element, text, position)
element.text = textEditor:getText() -- Sync display text
textEditor:updateAutoGrowHeight(element)
end
end
local function deleteText(element, startPos, endPos)
local textEditor = element._textEditor
if textEditor then
textEditor:deleteText(element, startPos, endPos)
element.text = textEditor:getText() -- Sync display text
textEditor:updateAutoGrowHeight(element)
end
end
local function replaceText(element, startPos, endPos, newText)
local textEditor = element._textEditor
if textEditor then
textEditor:replaceText(element, startPos, endPos, newText)
element.text = textEditor:getText() -- Sync display text
textEditor:updateAutoGrowHeight(element)
end
end
-- --- Mouse text selection ------------------------------------------------
local function handleTextClick(element, mouseX, mouseY, clickCount)
local textEditor = element._textEditor
if textEditor then
textEditor:handleTextClick(element, mouseX, mouseY, clickCount)
-- Store mouse down position on element for drag tracking
if clickCount == 1 then
element._mouseDownPosition = textEditor:mouseToTextPosition(element, mouseX, mouseY)
end
end
end
local function handleTextDrag(element, mouseX, mouseY)
local textEditor = element._textEditor
if textEditor then
textEditor:handleTextDrag(element, mouseX, mouseY)
element._textDragOccurred = textEditor._textDragOccurred
end
end
-- --- Keyboard input ------------------------------------------------------
local function textinput(element, text)
local textEditor = element._textEditor
if textEditor then
textEditor:handleTextInput(element, text)
element.text = textEditor:getText() -- Sync display text
textEditor:updateAutoGrowHeight(element)
end
end
local function keypressed(element, key, scancode, isrepeat)
local textEditor = element._textEditor
if textEditor then
textEditor:handleKeyPress(element, key, scancode, isrepeat)
element.text = textEditor:getText() -- Sync display text
textEditor:updateAutoGrowHeight(element)
end
end
-- ============================================================================
-- Build the (stateless, shared, immutable) behavior instance + thin module
-- table exposing the delegate functions (mirrors the Animated pattern).
-- ============================================================================
local behavior = Behavior.new({
onAttach = onAttach,
onDetach = onDetach,
onUpdate = onUpdate,
onDraw = onDraw,
saveState = saveState,
restoreState = restoreState,
shouldAttach = shouldAttach,
})
-- Thin module table: exposes the frozen behavior instance (for the registry)
-- plus the text-editor delegate functions (for Element's 1-line forwarders).
-- All hooks delegate to the frozen behavior instance so dispatch sites get
-- the validated, frozen implementation. shouldAttach is also exposed at module
-- level (mirrors Clickable.shouldAttach) for tests/callers without an element.
local TextEditable = {
behavior = behavior,
shouldAttach = shouldAttach,
onAttach = onAttach,
onUpdate = onUpdate,
onDraw = onDraw,
saveState = saveState,
restoreState = restoreState,
-- Text-editor delegate functions (Element forwarders route through these):
setCursorPosition = setCursorPosition,
getCursorPosition = getCursorPosition,
moveCursorBy = moveCursorBy,
moveCursorToStart = moveCursorToStart,
moveCursorToEnd = moveCursorToEnd,
moveCursorToLineStart = moveCursorToLineStart,
moveCursorToLineEnd = moveCursorToLineEnd,
moveCursorToPreviousWord = moveCursorToPreviousWord,
moveCursorToNextWord = moveCursorToNextWord,
setSelection = setSelection,
getSelection = getSelection,
hasSelection = hasSelection,
clearSelection = clearSelection,
selectAll = selectAll,
getSelectedText = getSelectedText,
deleteSelection = deleteSelection,
focus = focus,
blur = blur,
isFocused = isFocused,
getText = getText,
setText = setText,
insertText = insertText,
deleteText = deleteText,
replaceText = replaceText,
_handleTextClick = handleTextClick,
_handleTextDrag = handleTextDrag,
textinput = textinput,
keypressed = keypressed,
}
-- Metatable so the module table itself satisfies the duck-typed registry
-- contract (iterating `Element._behaviorRegistry` calls `behavior.shouldAttach`
-- and `behavior.onAttach` / `behavior.onUpdate` directly). Falls through to the
-- frozen behavior instance for every hook / isBehavior parity.
setmetatable(TextEditable, {
__index = behavior,
__tostring = function()
return "TextEditable"
end,
})
return TextEditable