big ui moment

This commit is contained in:
bryanthaboi
2026-08-03 11:50:49 -04:00
parent 8e5501a23b
commit f0f5bc7551
78 changed files with 34435 additions and 3519 deletions
@@ -0,0 +1,245 @@
-- modules/behaviors/Animated.lua
--
-- Concrete behavior: animation update, interpolation application, chaining
-- resolution, and transition wiring.
--
-- Task 06 of the behavior-mode-unification refactor. Moves the entire
-- animation-update block out of Element:update (lines ~2761-2800) into
-- `Animated.onUpdate(element, dt)`, and the `_ColorModule`/`_TransformModule`
-- init-time wiring into `Animated.onAttach(element)`.
--
-- This behavior is UNIQUE among the behavior set because it can attach
-- AFTER element creation. Animation is opt-in: a plain Element created without
-- `transitions` and without an `animation` field never attaches Animated.
-- The moment something creates an animation on the element — either directly
-- (`element.animation = Animation.new(...)`, `element:fadeIn(...)`) or via a
-- transition firing in `setProperty` — `Animated.ensureAttached(element)`
-- attaches this behavior on demand so subsequent `Element:update` frames
-- dispatch to `Animated.onUpdate`.
--
-- Attachment rule (shouldAttach): true when `props.transitions` is set OR an
-- `element.animation` already exists at runtime. The runtime arm covers the
-- late-attach case (animateTo / fadeIn / direct animation assignment).
--
-- State ownership (per the locked Behavior contract):
-- * Per-element runtime state lives ON THE ELEMENT (`element.animation`).
-- * The behavior instance itself is stateless and shared across elements.
-- * Element-class-level dependencies (Element._Animation, Element._Color,
-- Element._Transform) are resolved from the owning element's metatable,
-- exactly like Clickable does — keeping the behavior stateless without
-- expanding the 6-hook signature.
--
-- saveState/restoreState are no-ops: animations are ephemeral (an in-flight
-- animation is not part of immediate-mode persisted state — the next frame
-- re-evaluates transitions / re-applies animations fresh). Persisted scalar
-- props (`opacity`, `x`, ...) survive via Element.saveState's `_props` block,
-- not via the animation.
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Resolve the Element class from an element instance.
-- Element instances are created via `setmetatable({}, Element)` in _construct,
-- so their metatable IS the Element class — giving us Element._Animation,
-- Element._Color, Element._Transform, etc. without threading deps through the
-- behavior hook signature.
local function ElementClass(element)
return getmetatable(element)
end
-- ----------------------------------------------------------------------------
-- ensureAnimationModuleWiring — set Element._Animation._ColorModule /
-- _TransformModule. Idempotent; called from both onAttach and onUpdate so it
-- works even when an animation was assigned by a caller that bypassed
-- onAttach (direct `element.animation = Animation.new(...)`).
-- ----------------------------------------------------------------------------
local function ensureAnimationModuleWiring(element)
local Element = ElementClass(element)
local Animation = Element._Animation
if not Animation then
return
end
-- Ensure animation has Color module reference for color interpolation
if not Animation._ColorModule and Element._Color then
Animation._ColorModule = Element._Color
end
-- Ensure animation has Transform module reference for transform interpolation
if not Animation._TransformModule and Element._Transform then
Animation._TransformModule = Element._Transform
end
end
-- ----------------------------------------------------------------------------
-- shouldAttach (class-level predicate, no element required)
-- ----------------------------------------------------------------------------
-- True when the element declares transitions up front OR already has an
-- animation attached. The `animation` arm is consulted by ensureAttached at
-- runtime (after creation); the `transitions` arm lets Animated auto-attach
-- during Element.new for elements that pre-declare transitions.
local function shouldAttach(props)
if not props then
return false
end
if props.transitions ~= nil then
return true
end
-- Late-attach case: an animation was assigned after creation. When ensure
-- Attached passes the element instance as `props`, this arm catches it.
if type(props) == "table" and props.animation ~= nil then
return true
end
return false
end
-- ----------------------------------------------------------------------------
-- ensureAttached — dynamic late-attach entry point
-- ----------------------------------------------------------------------------
-- Idempotently attach the Animated behavior to an element that just gained an
-- animation (via animateTo / fadeIn / direct assignment / a firing transition
-- in setProperty). Called from Element.setProperty when a transition fires and
-- from the transition helper methods on Element. Safe to call when already
-- attached (no-op / returns false).
--
-- `animatedBehavior` is the shared behavior instance resolved lazily by
-- Element (see Element._resolveAnimatedBehavior). The behavior is looked up
-- from the registry once and cached on the class.
--
-- Returns true if the behavior was attached this call, false otherwise.
local function ensureAttached(element, animatedBehavior)
if not element or not animatedBehavior then
return false
end
-- Already attached? Avoid duplicate entries within one element lifetime
-- (a behavior may legitimately be re-added across immediate-mode frames
-- since Element is recreated each frame, but within one lifetime at most
-- once).
local behaviors = element.behaviors
if behaviors then
for i = 1, #behaviors do
if behaviors[i] == animatedBehavior then
return false
end
end
end
table.insert(element.behaviors, animatedBehavior)
animatedBehavior.onAttach(element)
return true
end
-- ----------------------------------------------------------------------------
-- onAttach — initialize Animation module references (formerly the
-- Element._Animation._ColorModule / _TransformModule wiring in Element:update
-- lines ~2772-2778).
-- ----------------------------------------------------------------------------
local function onAttach(element)
ensureAnimationModuleWiring(element)
end
-- ----------------------------------------------------------------------------
-- onUpdate — the animation update + interpolation + chain-resolution block
-- (formerly Element:update lines ~2761-2800).
-- ----------------------------------------------------------------------------
local function onUpdate(element, dt)
local animation = element.animation
if not animation then
return
end
-- (Re)ensure module wiring is present in case the Animation instance was
-- created by a caller that bypassed onAttach (e.g. direct
-- `element.animation = Animation.new(...)`). Cheap idempotent writes.
ensureAnimationModuleWiring(element)
local finished = animation:update(dt, element)
if finished then
-- Animation:update() already called onComplete callback.
-- Check for chained animation.
if animation._next then
element.animation = animation._next
elseif animation._nextFactory and type(animation._nextFactory) == "function" then
local success, nextAnim = pcall(animation._nextFactory, element)
if success and nextAnim then
element.animation = nextAnim
else
element.animation = nil
end
else
element.animation = nil
end
else
-- Apply animation interpolation during update.
animation:applyInterpolation(element)
end
end
-- ----------------------------------------------------------------------------
-- saveState / restoreState — no-ops (animations are ephemeral).
-- ----------------------------------------------------------------------------
-- Animations are not persisted across immediate-mode frames — they are
-- re-derived each frame from transitions / direct calls. The element's scalar
-- props (opacity, x, ...) are persisted by Element.saveState's _props block,
-- so a completed animation's final visual state still survives recreation.
-- While an animation is mid-flight in immediate mode, the element is recreated
-- and the animation is NOT carried over (intentional — animating in immediate
-- mode requires setting up the animation each frame).
local function saveState()
return nil
end
local function restoreState()
return nil
end
-- ----------------------------------------------------------------------------
-- Build the (stateless, shared) behavior instance.
-- ----------------------------------------------------------------------------
-- onDetach/onDraw omitted: they default to no-ops (the behavior allocates no
-- behavior-local state and animations have no draw pass). Animation state lives
-- on the element (`element.animation`); nothing to tear down on detach.
--
-- We build the immutable behavior via Behavior.new (for validation + freeze +
-- isBehavior parity with Clickable), then expose the late-attach helper on a
-- thin module table since the frozen instance cannot accept new keys. The
-- module table passes the behavior to the registry while making
-- `Animated.ensureAttached` callable from Element.setProperty / the transition
-- helpers — exactly as the task spec requires.
local behavior = Behavior.new({
onAttach = onAttach,
onUpdate = onUpdate,
saveState = saveState,
restoreState = restoreState,
shouldAttach = shouldAttach,
})
-- Thin module table: exposes the behavior instance (for the registry) plus the
-- late-attach helper (for Element.setProperty). 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 Animated = {
behavior = behavior,
ensureAttached = ensureAttached,
shouldAttach = shouldAttach,
onAttach = onAttach,
onUpdate = onUpdate,
}
-- 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.
setmetatable(Animated, {
__index = behavior,
__tostring = function()
return "Animated"
end,
})
return Animated
@@ -0,0 +1,344 @@
-- modules/behaviors/Clickable.lua
--
-- Concrete behavior: mouse/touch event handling, pressed-state tracking,
-- hit-testing, and theme-state sync.
--
-- This is the largest behavior in the behavior-mode-unification refactor
-- (~200 LOC moved out of Element:update / _initSubSystems / saveState).
-- Task 02 extracts the entire `if self.onEvent or self.themeComponent or
-- self.editable or self._selectState or self.selectOption then ... end` block
-- from Element:update (hit-testing, mouse/touch event processing, immediate-
-- mode state save, theme-state update) plus EventHandler creation (formerly the
-- first half of Element:_initSubSystems) plus pressed-state drawing (formerly a
-- render layer in Renderer) plus EventHandler save/restore.
--
-- Attachment rule (shouldAttach): the same predicate that previously guarded
-- mouse-event processing in Element:update. An element owns the EventHandler /
-- gets press feedback exactly when it is interactive: when it declares an
-- `onEvent` callback, a `themeComponent`, is `editable`, or participates in a
-- Select group (selectParent / selectOption). A plain passive element never
-- attaches Clickable and therefore never allocates an EventHandler.
--
-- Element retains only the `self._eventHandler` field; Clickable owns it on
-- attach. All other Element paths that touched the EventHandler (handleTouchEvent,
-- handleGesture, getTouches) already nil-guard `self._eventHandler`, so they keep
-- working unchanged for non-clickable elements.
--
-- State ownership (per the locked Behavior contract):
-- * Per-element runtime state lives ON THE ELEMENT (self._eventHandler etc.).
-- * The behavior instance itself is stateless and shared across elements.
-- * Element-class-level dependencies (EventHandler factory, StateManager,
-- Context) are resolved from the owning element's metatable (the Element
-- class set by Element:_construct). This keeps the behavior stateless while
-- avoiding a dependency-injection parameter that would violate the locked
-- 6-hook signature `(element, ...)`.
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Resolve the Element class from an element instance.
-- Element instances are created via `setmetatable({}, Element)` in _construct,
-- so their metatable IS the Element class — giving us Element._EventHandler,
-- Element._eventHandlerDeps, Element._StateManager, Element._Context, etc.
-- without threading deps through the behavior hook signature.
local function ElementClass(element)
return getmetatable(element)
end
-- ----------------------------------------------------------------------------
-- shouldAttach (class-level predicate, no element required)
-- ----------------------------------------------------------------------------
-- Mirrors the cases that previously caused Element to allocate + use an
-- EventHandler. MUST cover every element that touches the EventHandler at
-- runtime: click (onEvent), theme press-feedback (themeComponent), text mouse
-- interaction (editable), Select groups (selectParent / selectOption), touch
-- callbacks (onTouchEvent), and gesture callbacks (onGesture). selectParent /
-- selectOption are the props that produce _selectState during _initSubSystems;
-- checking the props (rather than the runtime _selectState) lets shouldAttach
-- run before the Select subsystem is initialized.
local function shouldAttach(props)
props = props or {}
return props.onEvent ~= nil
or props.themeComponent ~= nil
or props.editable == true
or props.onTouchEvent ~= nil
or props.onGesture ~= nil
or props.selectOption ~= nil
or props.selectParent ~= nil
end
-- ----------------------------------------------------------------------------
-- onAttach — create the EventHandler (formerly Element:_initSubSystems
-- lines ~640-690) and restore immediate-mode EventHandler state.
-- ----------------------------------------------------------------------------
local function onAttach(element)
local Element = ElementClass(element)
local eventHandlerConfig = {
-- element.onEvent is source of truth; not cached on handler
onEventDeferred = element.onEventDeferred,
-- element.onTouchEvent is source of truth; not cached on handler
onTouchEventDeferred = element.onTouchEventDeferred,
-- element.onGesture is source of truth; not cached on handler
onGestureDeferred = element.onGestureDeferred,
touchEnabled = element.touchEnabled,
multiTouchEnabled = element.multiTouchEnabled,
}
-- In immediate mode, restore EventHandler state from StateManager so pressed
-- / hovered / click-count survive the per-frame element recreation cycle.
-- Mode-aware via Context.isImmediateMode (behavior-mode-unification task 11):
-- in retained mode the eventHandler persists, so nothing to restore.
if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
local state = Element._StateManager.getState(element._stateId)
if state then
-- Restore EventHandler state from StateManager (sparse storage — provide defaults)
eventHandlerConfig._pressed = state._pressed or {}
eventHandlerConfig._lastClickTime = state._lastClickTime
eventHandlerConfig._lastClickButton = state._lastClickButton
eventHandlerConfig._clickCount = state._clickCount or 0
eventHandlerConfig._dragStartX = state._dragStartX or {}
eventHandlerConfig._dragStartY = state._dragStartY or {}
eventHandlerConfig._lastMouseX = state._lastMouseX or {}
eventHandlerConfig._lastMouseY = state._lastMouseY or {}
eventHandlerConfig._hovered = state._hovered
end
end
element._eventHandler = Element._EventHandler.new(eventHandlerConfig, Element._eventHandlerDeps)
end
local function onDetach(element)
-- Clear focus callbacks read by KeyboardNavigation / TextEditor:focus so the
-- element's closure references can be collected in immediate mode (formerly
-- part of Element:_cleanup). The EventHandler instance itself is INTENTIONALLY
-- kept: Element:_cleanup preserves element structure for inspection (the
-- stale-element refs are released when the element is GC'd). onEvent,
-- onTouchEvent, onGesture are also left intact — the Renderer/EventHandler
-- read those directly from the element (not the cache), so clearing them
-- would break retained mode.
element.onFocus = nil
element.onBlur = nil
end
-- ----------------------------------------------------------------------------
-- onUpdate — the mouse hit-testing + event-processing + theme-state +
-- immediate-mode save block (formerly Element:update lines ~2813-2960).
-- ----------------------------------------------------------------------------
local function onUpdate(element, dt)
local Element = ElementClass(element)
local eventHandler = element._eventHandler
if not eventHandler then
return
end
local mx, my = love.mouse.getPosition()
-- Clickable area is the border box (x, y already includes padding)
-- BORDER-BOX MODEL: Use stored border-box dimensions for hit detection
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
-- Account for scroll offsets from parent containers
-- Walk up the parent chain and accumulate scroll offsets. This stays in
-- Clickable because it's an interaction concern (hit-testing), not layout.
local scrollOffsetX = 0
local scrollOffsetY = 0
local current = element.parent
while current do
local overflowX = current.overflowX or current.overflow
local overflowY = current.overflowY or current.overflow
local hasScrollableOverflow = (
overflowX == "scroll"
or overflowX == "auto"
or overflowY == "scroll"
or overflowY == "auto"
or overflowX == "hidden"
or overflowY == "hidden"
)
if hasScrollableOverflow then
scrollOffsetX = scrollOffsetX + (current._scrollX or 0)
scrollOffsetY = scrollOffsetY + (current._scrollY or 0)
end
current = current.parent
end
-- Adjust mouse position by accumulated scroll offset for hit testing
local adjustedMx = mx + scrollOffsetX
local adjustedMy = my + scrollOffsetY
local isHovering = adjustedMx >= bx and adjustedMx <= bx + bw and adjustedMy >= by and adjustedMy <= by + bh
-- Check if this is the topmost interactive element at the mouse position
-- (z-index ordering). This prevents blocked/occluded elements from
-- receiving interactions or visual feedback. A single mode-agnostic lookup
-- via `Context.findInteractiveAtPosition` (unified-event-routing task 05)
-- replaces the previous immediate/retained-mode split that used
-- `getTopElementAt` in immediate mode and `_activeEventElement` in retained
-- mode. `findInteractiveAtPosition` routes every hit test through
-- `pointHitsElement` (the single canonical `display == false` guard) and
-- resolves occlusion by z-index in both modes, so the active element is the
-- same one that would receive a hit under the cursor.
local topElement = Element._Context.findInteractiveAtPosition(mx, my)
local isActiveElement = (topElement == element or topElement == nil)
-- Reset scrollbar press flag at start of each frame
eventHandler:resetScrollbarPressFlag()
-- Process mouse events through EventHandler FIRST
-- This ensures pressed states are updated before theme state is calculated
eventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement)
-- In immediate mode, save EventHandler state to StateManager after
-- processing events so it survives the per-frame recreation.
if element._stateId and Element._Context.isImmediateMode() and element._stateId ~= "" then
local eventHandlerState = eventHandler:getState()
Element._StateManager.updateState(element._stateId, {
_pressed = eventHandlerState._pressed,
_lastClickTime = eventHandlerState._lastClickTime,
_lastClickButton = eventHandlerState._lastClickButton,
_clickCount = eventHandlerState._clickCount,
_dragStartX = eventHandlerState._dragStartX,
_dragStartY = eventHandlerState._dragStartY,
_lastMouseX = eventHandlerState._lastMouseX,
_lastMouseY = eventHandlerState._lastMouseY,
_hovered = eventHandlerState._hovered,
})
end
-- Update theme state based on interaction. themeComponent state update
-- lives in Clickable because it is driven by hover/press state; the actual
-- theme RENDERING is the Themed behavior (task 07).
if element.themeComponent then
-- Check if any button is pressed via EventHandler
local anyPressed = eventHandler:isAnyButtonPressed()
-- Update theme state via ThemeManager
local isFocused = Element._Context.getFocused() == element
local newThemeState =
element._themeManager:updateState(isHovering and isActiveElement, anyPressed, isFocused, element.disabled)
if element._stateId and Element._Context.isImmediateMode() then
local hover = newThemeState == "hover"
local pressed = newThemeState == "pressed"
local focused = isFocused
Element._StateManager.updateState(element._stateId, {
hover = hover,
pressed = pressed,
focused = focused,
disabled = element.disabled,
active = element.active,
})
end
if element._renderer then
element._renderer:setThemeState(newThemeState)
end
end
-- Process touch events through EventHandler
eventHandler:processTouchEvents(element)
end
-- ----------------------------------------------------------------------------
-- onDraw — pressed-state visual feedback (formerly Renderer Layer 5).
-- ----------------------------------------------------------------------------
-- Draws the grey pressed overlay when any mouse button is currently pressed on
-- the element. Delegates the actual pixels to Renderer:drawPressedState (which
-- owns the RoundedRect + opacity math) but drives the DECISION + transform
-- context here, so the renderer no longer needs the `if element.onEvent ...`
-- behavioral branch. Honors disableHighlight (themes handle their own visual
-- feedback) exactly as the old render layer did.
local function onDraw(element)
if element.disableHighlight then
return
end
local eventHandler = element._eventHandler
if not eventHandler then
return
end
local anyPressed = false
local pressedState = eventHandler:getState()._pressed or {}
for _, pressed in pairs(pressedState) do
if pressed then
anyPressed = true
break
end
end
if not anyPressed then
return
end
local renderer = element._renderer
if not renderer then
return
end
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
-- Apply the element transform around the overlay, mirroring how the
-- Renderer wrapped its whole command buffer (pressed state was a render
-- layer subject to the same transform).
local Element = ElementClass(element)
local Transform = Element._Transform
local hasTransform = element.transform ~= nil and Transform ~= nil and not Transform.isIdentity(element.transform)
if hasTransform then
Transform.apply(element.transform, element.x, element.y, element.width, element.height)
end
renderer:drawPressedState(element.x, element.y, bw, bh, element.opacity, element.cornerRadius)
if hasTransform then
Transform.unapply()
end
end
-- ----------------------------------------------------------------------------
-- saveState / restoreState — EventHandler state (formerly the eventHandler
-- branches of Element:saveState / Element:restoreState).
-- ----------------------------------------------------------------------------
local function saveState(element)
if element._eventHandler then
return { eventHandler = element._eventHandler:getState() }
end
return nil
end
local function restoreState(element, state)
if not state then
return nil
end
if element._eventHandler and state.eventHandler then
element._eventHandler:setState(state.eventHandler)
end
return nil
end
-- ----------------------------------------------------------------------------
-- Build the (stateless, shared, immutable) behavior instance.
-- ----------------------------------------------------------------------------
local Clickable = Behavior.new({
onAttach = onAttach,
onDetach = onDetach,
onUpdate = onUpdate,
onDraw = onDraw,
saveState = saveState,
restoreState = restoreState,
shouldAttach = shouldAttach,
})
-- Expose the predicate at module level so callers/tests can reference it
-- directly without an element instance (mirrors Behavior.shouldAttach).
Clickable.shouldAttach = shouldAttach
return Clickable
@@ -0,0 +1,282 @@
-- modules/behaviors/Imageable.lua
--
-- Concrete behavior: image loading + image rendering config.
--
-- Imageable owns the image side of the Renderer: it runs the deferred image-
-- load pipeline (cache check → defer → load → fire onImageLoad/onImageError
-- callbacks), populates the resolved `_loadedImage` cache on both the element
-- and the shared renderer, and persists that cache across immediate-mode
-- recreation. It is the behavior-mode-unification replacement for the image-
-- loading half of Element:_initImageAndRenderer and the deferred
-- Element:_loadImage method (behavior-mode-unification task 07).
--
-- Image value props (imagePath/image/objectFit/objectPosition/imageOpacity/
-- imageRepeat/imageTint) are bound on the ELEMENT by Element:_applyProps and read
-- from the element at draw time (Renderer._executeDrawCommand image branch) —
-- Imageable does NOT mirror them onto the renderer, so bare writes and
-- setProperty(...) are immediately consistent. Only the resolved _loadedImage
-- cache (the love.Image produced by the load pipeline) is renderer-mirrored,
-- because Renderer:draw reads `self._loadedImage`.
--
-- Runtime reload: setProperty("imagePath", ...) / setProperty("image", ...) and
-- the bare-write-equivalent setImage* flows route through element._reloadImage
-- (installed below) which re-runs the load pipeline. See
-- TestRetainedPropertyConsistency (image props) and TestImageableIntegration.
--
-- Attachment rule (shouldAttach): an element owns image concern exactly when it
-- declares an `imagePath` (load-from-path) or a direct `image` (already-loaded
-- love.Image). Mirrors the old `if self.imagePath / if self.image` init branches.
--
-- Pairing with Themed: Themed.onAttach creates the Renderer with theme/blur
-- config; Imageable.onAttach enriches the SAME renderer instance with image
-- config + kicks off loading. They share `element._renderer`. In the registry
-- Imageable runs after Themed, so the renderer already exists; the create-or-
-- reuse guard below covers the defensive case where Imageable attaches first.
--
-- onDraw: the image LAYER is rendered by the integrated `Renderer:draw` call
-- (owned by the Themed behavior) which executes the renderer's `image` draw
-- command using the config Imageable.onAttach wired. Imageable.onDraw is
-- therefore a no-op for the draw call itself — there is no separate
-- `_renderer:_drawImage` entry point; pixel emission lives in the integrated
-- Renderer:draw command buffer. Splitting it out would require Renderer surgery
-- with no behavioral gain (Renderer:draw already conditionally skips the image
-- layer when no image is loaded).
--
-- State ownership (per the locked Behavior contract):
-- * Per-element runtime state lives ON THE ELEMENT (`element._loadedImage`,
-- `element._renderer._loadedImage`). The behavior instance is stateless.
-- * saveState/restoreState persist `_loadedImage` across immediate-mode frames
-- so the image renders even if the ImageCache is cleared between frames and
-- so the renderer's loaded-image cache survives element recreation.
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Lua 5.4 removed the global `unpack`; mirror Element's alias.
local unpack = table.unpack or unpack
-- Resolve the Element class from an element instance (mirrors Clickable/Themed).
local function ElementClass(element)
return getmetatable(element)
end
-- ----------------------------------------------------------------------------
-- shouldAttach (class-level predicate, no element required)
-- ----------------------------------------------------------------------------
local function shouldAttach(props)
props = props or {}
return props.imagePath ~= nil or props.image ~= nil
end
-- ----------------------------------------------------------------------------
-- Image callback helper (moved from Element._fireImageCallback).
-- Fires a user-supplied image callback (onImageLoad/onImageError) under pcall,
-- honoring the onXDeferred flag when `honorDeferred` is true, and emits a single
-- EVT_002 warn on failure. The direct-`image` sync init path passes
-- honorDeferred=false to preserve immediate firing (image is already loaded).
-- ----------------------------------------------------------------------------
local function fireImageCallback(element, callbackField, honorDeferred, ...)
local cb = element[callbackField]
if type(cb) ~= "function" then
return
end
local Element = ElementClass(element)
local argc = select("#", ...)
local args = { ... }
local function invoke()
local ok, err = pcall(cb, element, unpack(args, 1, argc))
if not ok then
Element._ErrorHandler:warn("Element", "EVT_002", {
callback = callbackField,
error = tostring(err),
})
end
end
if honorDeferred and element[callbackField .. "Deferred"] then
Element._Context.deferCallback(invoke)
else
invoke()
end
end
-- ----------------------------------------------------------------------------
-- Deferred image loader (replaces Element:_loadImage).
--
-- Invoked by Element's deferred-method dispatcher via the instance closure that
-- onAttach installs on `element._loadImage`. Loads the image from cache or disk
-- (I/O), updates BOTH the element and renderer `_loadedImage` caches so the
-- image draws after an async load, and fires the load/error callback (deferred,
-- honoring onImageLoadDeferred / onImageErrorDeferred).
-- ----------------------------------------------------------------------------
local function loadImage(element)
if not element.imagePath or element.image then
return
end
local Element = ElementClass(element)
local loadedImage, err = Element._ImageCache.load(element.imagePath)
if loadedImage then
element._loadedImage = loadedImage
if element._renderer then
element._renderer._loadedImage = loadedImage
end
fireImageCallback(element, "onImageLoad", true, loadedImage)
else
fireImageCallback(element, "onImageError", true, err or "Unknown error")
end
end
-- ----------------------------------------------------------------------------
-- reloadImage — recompute the loaded-image cache from the current image/imagePath.
--
-- This is the single entry point for (re)loading after either initial attach or
-- a runtime property change (see Element._specialSetHandlers.imagePath/image,
-- which call element:_reloadImage()). Precedence matches onAttach: a direct
-- `image` wins over `imagePath`; `nil` for both clears the cache.
--
-- * direct image → set _loadedImage immediately, fire onImageLoad SYNC (the
-- image is already loaded; honorDeferred=false preserves the
-- original synchronous init contract).
-- * imagePath → cache CHECK only (no I/O) so a cached image can draw this
-- frame, then defer the loader (_loadImage) for the actual
-- I/O + deferred callbacks. load bails if `image` is later set.
-- * neither → clear _loadedImage on both element + renderer.
--
-- Image value props (objectFit/imageOpacity/imageRepeat/imageTint/objectPosition)
-- and imagePath/image themselves live on the ELEMENT as source of truth; the
-- renderer reads them at draw time, so reloadImage does NOT mirror them onto the
-- renderer — only the resolved _loadedImage cache is pushed.
-- ----------------------------------------------------------------------------
local function reloadImage(element)
local Element = ElementClass(element)
local renderer = element._renderer
if element.image then
element._loadedImage = element.image
if renderer then
renderer._loadedImage = element.image
end
fireImageCallback(element, "onImageLoad", false, element.image)
elseif element.imagePath then
-- Cache check (no I/O). Populate both caches immediately if cached so the
-- image can draw this frame without waiting for the deferred load.
local cached = Element._ImageCache.get(element.imagePath)
element._loadedImage = cached
if renderer then
renderer._loadedImage = cached
end
-- Kick off the deferred I/O load + callbacks (idempotent: loadImage bails
-- if image is set or imagePath is nil by the time it runs).
if element._loadImage then
element:_deferMethod("_loadImage")
end
else
element._loadedImage = nil
if renderer then
renderer._loadedImage = nil
end
end
end
-- ----------------------------------------------------------------------------
-- onAttach — enrich the shared renderer with image config + kick off loading
-- (formerly the image block of Element:_initImageAndRenderer).
-- ----------------------------------------------------------------------------
local function onAttach(element)
local Element = ElementClass(element)
-- Ensure the renderer exists (Thamed normally creates it; this create-or-reuse
-- guard is defensive for the Imageable-attaches-first ordering).
if not element._renderer then
element._renderer = Element._Renderer.new({
theme = element.theme,
scaleCorners = element.scaleCorners,
scalingAlgorithm = element.scalingAlgorithm,
contentBlur = element.contentBlur,
backdropBlur = element.backdropBlur,
}, Element._rendererDeps)
end
-- Install the (re)load hooks as instance methods so Element's
-- deferred-method dispatcher / setProperty special handlers can trigger a
-- reload without Element needing a behavior reference. This keeps Element
-- decoupled from the Imageable behavior (mirrors the stateless-behavior +
-- element-owned-state contract). Image value props and imagePath/image live
-- on the element as source of truth (read at draw time); only the resolved
-- _loadedImage cache is mirrored onto the renderer by reloadImage.
element._loadImage = function(el)
loadImage(el)
end
element._reloadImage = function(el)
reloadImage(el)
end
-- Initial load: compute _loadedImage + defer the I/O load.
reloadImage(element)
end
-- ----------------------------------------------------------------------------
-- onDraw — no-op (see file header: the image layer is rendered by the integrated
-- Renderer:draw call owned by the Themed behavior, using the config wired here).
-- ----------------------------------------------------------------------------
-- ----------------------------------------------------------------------------
-- saveState / restoreState — `_loadedImage` cache (for immediate-mode).
-- ----------------------------------------------------------------------------
local function saveState(element)
if element._loadedImage ~= nil then
return { _loadedImage = element._loadedImage }
end
return nil
end
local function restoreState(element, state)
if not state or state._loadedImage == nil then
return nil
end
local loadedImage = state._loadedImage
element._loadedImage = loadedImage
if element._renderer then
element._renderer._loadedImage = loadedImage
end
return nil
end
-- ----------------------------------------------------------------------------
-- onDetach — release image-load callback closures so the element can be GC'd
-- cleanly in immediate mode (formerly part of Element:_cleanup). The cached
-- `_loadedImage` is reproduced on the next attach via the Imageable saveState
-- -> restoreState cycle, so dropping the live references is always safe.
-- ----------------------------------------------------------------------------
local function onDetach(element)
element.onImageLoad = nil
element.onImageError = nil
end
-- ----------------------------------------------------------------------------
-- Build the (stateless, shared, immutable) behavior instance.
-- ----------------------------------------------------------------------------
local Imageable = Behavior.new({
onAttach = onAttach,
onDetach = onDetach,
onUpdate = function() end,
onDraw = function() end,
saveState = saveState,
restoreState = restoreState,
shouldAttach = shouldAttach,
})
-- Expose the predicate at module level so callers/tests can reference it
-- directly without an element instance (mirrors Behavior.shouldAttach /
-- Clickable.shouldAttach). `loadImage` is NOT exposed on the (frozen) behavior
-- instance; it is captured as a module-local upvalue by the onAttach closure that
-- installs `element._loadImage`.
Imageable.shouldAttach = shouldAttach
return Imageable
@@ -0,0 +1,132 @@
-- modules/behaviors/Persistable.lua
--
-- Concrete behavior: generic public-property persistence across the immediate-
-- mode recreation cycle (behavior-mode-unification task 12).
--
-- Owns the ONE piece of Element save/restore state that is NOT subsystem state:
-- the snapshot of an element's own public scalar fields (`text`, `display`,
-- `opacity`, `x`, `width`, ...). Event-driven mutations to these fields (a
-- release callback changing `text`, a toggle hiding a panel via `display =
-- false`) must survive the per-frame Element recreation that defines immediate
-- mode. Persistable captures them in `saveState` and reapplies them in
-- `restoreState`, so the caller never branches on mode.
--
-- This behavior is the final home for the former `Element:saveState` `_props`
-- block and the former `Element:restoreState` `_props` block (~20 LOC moved out
-- of Element.lua). With it in place, `Element:saveState` / `Element:restoreState`
-- collapse to a pure behavior-dispatch loop and Element owns zero property-
-- extraction logic — every persisted slice is owned by exactly one behavior.
--
-- Attachment rule (shouldAttach): every element. Persistable attaches
-- unconditionally (mirrors the pre-refactor invariant that every element's
-- public scalar props were scanned). The actual snapshot is mode-gated inside
-- `saveState` (immediate-mode-only, matching the legacy contract); in retained
-- mode `saveState` returns nil and `restoreState` is a no-op unless a snapshot
-- is explicitly passed.
--
-- Registry ordering: Persistable is intentionally placed LAST in the behavior
-- registry. `restoreState` applies `_props` AFTER every other behavior has
-- hydrated its subsystem state, so a persisted public-prop mutation (e.g.
-- `text = "mutated"`) overrides the freshly-restored TextEditor/Select state —
-- preserving the legacy restore ordering (behaviors first, `_props` tail).
--
-- State ownership (per the locked Behavior contract):
-- * The persisted props live ON the element (they ARE the element's public
-- fields). The behavior instance is stateless + immutable and shared.
-- * The snapshot is returned under the `_props` key (prefixed with `_` so
-- the public-prop scan itself skips it — avoiding self-recursion).
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Resolve the Element class from an element instance (mirrors Clickable /
-- Themed). Element instances are created via `setmetatable({}, Element)`, so
-- their metatable IS the Element class — giving access to Element._StateManager
-- without threading deps through the hook signature.
local function ElementClass(element)
return getmetatable(element)
end
-- ============================================================================
-- shouldAttach (class-level predicate, no element required)
-- ============================================================================
-- Every element's public scalar props are persistable, so this behavior
-- attaches unconditionally. The mode gate lives inside saveState (it needs the
-- runtime mode, which is only available with an element via StateManager).
local function shouldAttach()
return true
end
-- ============================================================================
-- saveState — snapshot public scalar fields (immediate-mode-only).
-- ============================================================================
-- Mirrors the former `Element:saveState` `_props` block exactly:
-- * Only string keys NOT prefixed with `_` (so internal fields like
-- `_renderer`, `_themeState`, `_initProps` are excluded).
-- * Only scalar values (numbers, strings, booleans); tables and functions
-- are excluded (children, padding, onEvent, ...).
-- Returns `{ _props = {...} }` when there is at least one persistable prop and
-- the element is in immediate mode; nil otherwise (retained mode no-op —
-- state lives on the element directly there, so nothing to snapshot).
local function saveState(element)
local Element = ElementClass(element)
if not Element._StateManager.isImmediateMode() then
return nil
end
local props = {}
for k, v in pairs(element) do
if type(k) == "string" and k:sub(1, 1) ~= "_" and type(v) ~= "table" and type(v) ~= "function" then
props[k] = v
end
end
if next(props) then
return { _props = props }
end
return nil
end
-- ============================================================================
-- restoreState — reapply the persisted public-prop snapshot onto a fresh
-- element (mode-agnostic; only fires when a `_props` slice is present).
-- ============================================================================
-- Applies persisted mutations on top of whatever the constructor + other
-- behaviors already set, so event-driven changes from the previous frame
-- override the declarative props of the recreated element. Runs last in the
-- behavior dispatch (Persistable is the registry tail) to preserve the legacy
-- restore ordering (subsystem restore first, `_props` override last).
local function restoreState(element, state)
if not state or not state._props then
return
end
for k, v in pairs(state._props) do
element[k] = v
end
end
-- ============================================================================
-- onAttach / onUpdate / onDraw / onDetach — no-ops.
-- ============================================================================
-- Persistable owns no subsystem and allocates no per-element state (the
-- "state" it persists IS the element's own fields). The lifecycle is purely
-- save/restore.
-- ============================================================================
-- Build the (stateless, shared, immutable) behavior instance.
-- ============================================================================
local Persistable = Behavior.new({
saveState = saveState,
restoreState = restoreState,
shouldAttach = shouldAttach,
})
-- Expose the predicate at module level so callers/tests can reference it
-- directly without an element instance (mirrors Behavior.shouldAttach /
-- Clickable.shouldAttach).
Persistable.shouldAttach = shouldAttach
return Persistable
@@ -0,0 +1,264 @@
-- modules/behaviors/Scrollable.lua
--
-- Concrete behavior: ScrollManager lifecycle (creation + immediate-mode
-- scrollbar interaction-state restore).
--
-- Scrollable owns the per-element ScrollManager instance — the subsystem that
-- manages overflow detection, scrollbar geometry, scroll position, and scrollbar
-- drag/hover interaction. It is the behavior-mode-unification replacement for
-- the former `Element:_initScrollManager` phase (~84 LOC) of Element.new
-- (behavior-mode-unification task 03 / landed as part of the task 08 capstone).
--
-- Attachment rule (shouldAttach): an element owns a ScrollManager exactly when
-- it declares an `overflow`, `overflowX`, or `overflowY` prop — mirroring the
-- legacy `if props.overflow or props.overflowX or props.overflowY then` guard
-- in `Element:_initScrollManager`. The ScrollManager is created and its
-- normalized fields are exposed back onto the element (so the Renderer /
-- ScrollManager delegates read `element.overflow` / `element.scrollbarWidth`
-- etc.) exactly as the legacy inline phase did.
--
-- Why onAttach reads `element._initProps` (not element fields): the scrollbar
-- configuration props (scrollbarWidth / scrollbarColor / scrollSpeed /
-- scrollbarPlacement / scrollbarBalance / invertScroll / smoothScrollEnabled /
-- scrollBarStyle / scrollbarKnobOffset / hideScrollbars / scrollbarRadius /
-- scrollbarPadding / scrollbarTrackColor / _scrollX / _scrollY) are listed in
-- SPECIAL_PROPS and therefore NOT bound onto the element by the schema-driven
-- `_applyProps` loop — they are consumed only by the ScrollManager constructor.
-- The locked behavior hook signature is `(element, ...)` with no props arg, so
-- the original construction props are stashed on the element as `_initProps` by
-- `Element:_construct` and read back here. (`overflow` / `overflowX` /
-- `overflowY` ARE bound onto the element by `_applyProps` so that
-- `Element:addChild`'s scroll-container auto-size guard sees them during
-- declarative-children processing in `_finalizeConstruction`, which runs BEFORE
-- this onAttach; onAttach then overwrites them with the ScrollManager's
-- normalized values, matching the legacy field-exposure order.)
--
-- onUpdate / onDraw / saveState / restoreState are deferred to the
-- behavior-driven update/draw tasks (09 / 12): the ScrollManager update,
-- interaction, scrollbar drawing, and state save/restore currently stay inline
-- in `Element:update` / `Element:draw` / `Element:saveState` /
-- `Element:restoreState` (delegated through the ScrollManager API bound in
-- `Element.init`). Those inline call sites are NOT behavioral `if` branches —
-- they are unconditional 1-line delegates — so leaving them in Element does not
-- regress the behavior-dispatch goals of tasks 09/12; task 09 will fold them
-- into Scrollable hooks.
--
-- State ownership (per the locked Behavior contract):
-- * Per-element runtime state lives ON THE ELEMENT (`element._scrollManager`,
-- `element.overflow`, `element._scrollX`, `element._scrollbarDragging`, ...).
-- * The behavior instance is stateless + immutable and shared across elements.
-- * Element-class-level dependencies (`Element._ScrollManager`,
-- `Element._scrollManagerDeps`, `Element._Context`, `Element._StateManager`)
-- are resolved from the owning element's metatable (the Element class set by
-- `Element:_construct`).
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Resolve the Element class from an element instance.
-- `setmetatable({}, Element)` in `_construct` makes the instance metatable BE
-- the Element class, so this yields Element._ScrollManager,
-- Element._scrollManagerDeps, Element._Context, Element._StateManager without
-- threading deps through the hook signature.
local function ElementClass(element)
return getmetatable(element)
end
-- ----------------------------------------------------------------------------
-- shouldAttach (class-level predicate, no element required)
-- ----------------------------------------------------------------------------
-- Mirrors the legacy `if props.overflow or props.overflowX or props.overflowY`
-- guard. Uses `~= nil` (rather than truthiness) so that an explicit
-- `overflow = false` / `overflow = ""` does not spuriously attach — though in
-- practice overflow values are always strings or unset, matching the predicate
-- semantics of the other behaviors (Clickable / TextEditable / Selectable).
local function shouldAttach(props)
props = props or {}
return props.overflow ~= nil or props.overflowX ~= nil or props.overflowY ~= nil
end
-- ----------------------------------------------------------------------------
-- onAttach — create the ScrollManager + expose its fields + restore immediate-
-- mode scrollbar interaction state (formerly Element:_initScrollManager).
-- ----------------------------------------------------------------------------
local function onAttach(element)
local Element = ElementClass(element)
-- Construction props are stashed on the element by _construct (the scrollbar
-- config props are SPECIAL_PROPS and not bound as element fields).
local props = element._initProps or {}
element._scrollManager = Element._ScrollManager.new({
overflow = props.overflow,
overflowX = props.overflowX,
overflowY = props.overflowY,
scrollbarWidth = props.scrollbarWidth,
scrollbarColor = props.scrollbarColor,
scrollbarTrackColor = props.scrollbarTrackColor,
scrollbarRadius = props.scrollbarRadius,
scrollbarPadding = props.scrollbarPadding,
scrollSpeed = props.scrollSpeed,
invertScroll = props.invertScroll,
smoothScrollEnabled = props.smoothScrollEnabled,
scrollBarStyle = props.scrollBarStyle,
scrollbarKnobOffset = props.scrollbarKnobOffset,
hideScrollbars = props.hideScrollbars,
scrollbarPlacement = props.scrollbarPlacement,
scrollbarBalance = props.scrollbarBalance,
_scrollX = props._scrollX,
_scrollY = props._scrollY,
}, Element._scrollManagerDeps)
-- Expose ScrollManager properties for backward compatibility (Renderer access).
local sm = element._scrollManager
element.overflow = sm.overflow
element.overflowX = sm.overflowX
element.overflowY = sm.overflowY
element.scrollbarWidth = sm.scrollbarWidth
element.scrollbarColor = sm.scrollbarColor
element.scrollbarTrackColor = sm.scrollbarTrackColor
element.scrollbarRadius = sm.scrollbarRadius
element.scrollbarPadding = sm.scrollbarPadding
element.scrollSpeed = sm.scrollSpeed
element.invertScroll = sm.invertScroll
element.scrollBarStyle = sm.scrollBarStyle
element.scrollbarKnobOffset = sm.scrollbarKnobOffset
element.hideScrollbars = sm.hideScrollbars
element.scrollbarPlacement = sm.scrollbarPlacement
element.scrollbarBalance = sm.scrollbarBalance
-- Initialize state properties (will be synced from ScrollManager).
element._overflowX = false
element._overflowY = false
element._contentWidth = 0
element._contentHeight = 0
element._scrollX = 0
element._scrollY = 0
element._maxScrollX = 0
element._maxScrollY = 0
element._scrollbarHoveredVertical = false
element._scrollbarHoveredHorizontal = false
element._scrollbarDragging = false
element._hoveredScrollbar = nil
element._scrollbarDragOffset = 0
-- Restore scrollbar state from StateManager in immediate mode (must happen
-- before layout). Mirrors the legacy _initScrollManager restore block.
-- 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.scrollManager then
element._scrollbarHoveredVertical = state.scrollManager._scrollbarHoveredVertical or false
element._scrollbarHoveredHorizontal = state.scrollManager._scrollbarHoveredHorizontal or false
element._scrollbarDragging = state.scrollManager._scrollbarDragging or false
element._hoveredScrollbar = state.scrollManager._hoveredScrollbar
element._scrollbarDragOffset = state.scrollManager._scrollbarDragOffset or 0
-- Apply to ScrollManager immediately.
sm._scrollbarHoveredVertical = element._scrollbarHoveredVertical
sm._scrollbarHoveredHorizontal = element._scrollbarHoveredHorizontal
sm._scrollbarDragging = element._scrollbarDragging
sm._hoveredScrollbar = element._hoveredScrollbar
sm._scrollbarDragOffset = element._scrollbarDragOffset
-- Restore drag start positions for relative movement tracking.
sm._dragStartMouseX = state.scrollManager._dragStartMouseX or 0
sm._dragStartMouseY = state.scrollManager._dragStartMouseY or 0
sm._dragStartScrollX = state.scrollManager._dragStartScrollX or 0
sm._dragStartScrollY = state.scrollManager._dragStartScrollY or 0
end
end
end
-- --------------------------------------------------------------------------
-- onUpdate — scroll-position momentum + scrollbar hover/drag/press interaction
-- (formerly the inline ScrollManager blocks in Element:update).
-- Runs BEFORE Clickable.onUpdate in the registry so the scrollbar press flag
-- is set before Clickable's EventHandler processes mouse events.
-- --------------------------------------------------------------------------
local function onUpdate(element, dt)
local Element = ElementClass(element)
local sm = element._scrollManager
if not sm then
return
end
-- Restore scrollbar interaction state from StateManager in immediate mode
-- (no-op outside immediate mode / when no state is stored).
Element._ScrollManager.restoreImmediateState(element)
-- Smooth-scroll / momentum interpolation.
sm:update(dt)
element:_syncScrollManagerState()
-- Scrollbar hover / drag / press interaction. Captures the mouse here so the
-- interaction state is consistent across the rest of the frame's behaviors.
local mx, my = love.mouse.getPosition()
Element._ScrollManager.updateInteraction(element, mx, my)
end
-- --------------------------------------------------------------------------
-- onDraw — scrollbar rendering (post-children overlay). Marked
-- `drawLayer = "overlay"` so Element:draw dispatches it AFTER children, so
-- scrollbars paint on top of clipped child content and without parent clipping.
-- --------------------------------------------------------------------------
local function onDraw(element, _ctx)
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if overflowX ~= "scroll" and overflowX ~= "auto" and overflowY ~= "scroll" and overflowY ~= "auto" then
return
end
local scrollbarDims = element:_calculateScrollbarDimensions()
if not (scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible) then
return
end
-- Clear any parent scissor clipping before drawing scrollbars so they render
-- fully visible (scrollbars must not be clipped by ancestor overflow).
love.graphics.setScissor()
element._renderer:drawScrollbars(element, element.x, element.y, element.width, element.height, scrollbarDims)
end
-- --------------------------------------------------------------------------
-- saveState / restoreState — ScrollManager state snapshot for immediate-mode
-- recreation (formerly the inline blocks in Element:saveState/
-- Element:restoreState). Returns a table merged under the `scrollManager` key
-- by Element:saveState's behavior loop, mirroring the legacy contract.
-- --------------------------------------------------------------------------
local function saveState(element)
local sm = element._scrollManager
if not sm then
return nil
end
return { scrollManager = sm:getState() }
end
local function restoreState(element, state)
if not state then
return
end
local sm = element._scrollManager
local smState = state.scrollManager
if sm and smState then
sm:setState(smState)
end
end
local Scrollable = Behavior.new({
onAttach = onAttach,
onDetach = function() end,
onUpdate = onUpdate,
onDraw = onDraw,
saveState = saveState,
restoreState = restoreState,
drawLayer = "overlay",
})
-- Expose the predicate at module level so callers/tests can reference it
-- directly without an element instance (mirrors Clickable.shouldAttach /
-- Selectable.shouldAttach).
Scrollable.shouldAttach = shouldAttach
return Scrollable
@@ -0,0 +1,206 @@
-- modules/behaviors/Selectable.lua
--
-- Concrete behavior: Select state-machine lifecycle for dropdown-style
-- select groups. Owns the per-element Select subsystem initialization, the
-- managed-frame layout sync each frame, and select save/restore across the
-- immediate-mode recreation cycle.
--
-- This behavior consolidates the legacy `if self._selectState` / `if
-- self.selectOption` branches that previously lived inside Element.lua:
--
-- * Select subsystem init (formerly Element:_initSubSystems lines ~810-825 —
-- `Select.initSelectParent` / `Select.initSelectOption`).
-- * Managed-frame adoption (formerly Element:_initPositioning lines ~1700-
-- 1702 — `Select.adoptSelectFrame`).
-- * Per-frame frame-state sync (formerly Element:update line ~2747 —
-- `Select.ensureFrameState`).
-- * Save/restore of select open/value/label (formerly the `select` branch of
-- Element:saveState / Element:restoreState).
--
-- Element retains `self._selectState` and `self.selectOption` for backward-
-- compat field access; runtime state lives ON THE ELEMENT. The behavior itself
-- is stateless + immutable (a single shared instance attaches to every
-- selectable element).
--
-- The 20 Element select-API delegate methods (openSelect, closeSelect,
-- toggleSelect, isSelectOpen, getSelectValue, setSelectValue, ...) stay as
-- 1-line forwarders into the Select module — the behavior owns the
-- *lifecycle* (attach / update / save / restore / detach), not the API
-- surface (per task 05 spec notes).
--
-- State ownership (per the locked Behavior contract):
-- * Per-element runtime state lives ON THE ELEMENT (self._selectState,
-- self.selectOption, self._selectParentElement, ...).
-- * The behavior instance is stateless + immutable and shared across 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.
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Resolve the Element class from an element instance.
-- `setmetatable({}, Element)` in `_construct` makes the instance metatable BE
-- the Element class, so this yields Element._Select, 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 cases that previously caused Element to initialize a Select
-- subsystem. An element owns select state exactly when it declares a
-- `selectParent` config (the dropdown trigger) or a `selectOption` config (an
-- option inside a dropdown). Checking the props (rather than the runtime
-- `_selectState`) lets shouldAttach run before onAttach initializes the
-- subsystem, matching the auto-attach contract established by Clickable /
-- TextEditable.
local function shouldAttach(props)
props = props or {}
return type(props.selectParent) == "table" or type(props.selectOption) == "table"
end
-- ============================================================================
-- onAttach — initialize the Select subsystem (formerly Element:_initSubSystems
-- lines ~810-825) and adopt the managed frame (formerly Element:_initPositioning
-- lines ~1700-1702).
-- ============================================================================
local function onAttach(element)
local Element = ElementClass(element)
-- Initialize the appropriate select role. Mirrors the legacy _initSubSystems
-- block exactly: selectParent → initSelectParent (sets _selectState +
-- immediate-mode restore from StateManager); selectOption → initSelectOption
-- (sets the option value/label/disabled).
if type(element.selectParent) == "table" then
Element._Select.initSelectParent(element, element.selectParent)
end
if type(element.selectOption) == "table" then
Element._Select.initSelectOption(element, element.selectOption)
end
-- Adopt the managed dropdown frame. This was formerly the tail of
-- _initPositioning (after the select parent's own addChild). It creates the
-- select anchor, reparents the frame under it, and syncs visibility. Moving
-- it here is safe because onAttach runs after _initPositioning: the parent's
-- own positioning is finalized, so the anchor's geometry can be computed.
if element._selectState and type(element.selectParent) == "table" and element.selectParent.selectFrame ~= nil then
Element._Select.adoptSelectFrame(element, element.selectParent.selectFrame)
end
-- Backfill option registration for children added BEFORE this behavior
-- attached. The auto-attach pass runs at the very end of Element.new
-- (after _finalizeConstruction, which processes declarative `children`).
-- Declarative select-option children are addChild'd to this element during
-- _finalizeConstruction — at that point _selectState did not yet exist (this
-- onAttach had not run), so their registerWithSelectParent call walked the
-- parent chain, found no _selectState, and returned early. Re-scan now that
-- _selectState is initialized so these options are registered + reparented
-- into the managed frame exactly like runtime-added options.
-- (registerWithSelectParent is idempotent — it skips options already
-- registered — so this is a no-op for children added after _selectState was
-- set, e.g. the common `FlexLove.new({ parent = sp, selectOption = {...} })`
-- pattern.)
if element._selectState then
for _, child in ipairs(element.children) do
if child.selectOption then
Element._Select.registerWithSelectParent(child)
Element._Select.attachOptionToManagedFrame(child)
end
end
end
end
local function onDetach(element)
-- Clear select-managed fields so the element can be GC'd cleanly in immediate
-- mode (formerly part of Element:_cleanup). This mirrors the select-clearing
-- block that lived in Element:_cleanup; Element:destroy separately routes
-- through Select.cleanupDestroy for full teardown (idempotent with this).
if element.selectParent then
element.selectParent.onChange = nil
end
element._selectState = nil
element._managedSelectOwner = nil
element._managedSelectFrame = nil
element._managedSelectAnchor = nil
element._managedSelectBaseOpacity = nil
element._managedSelectBaseVisibility = nil
element._managedSelectBaseDisabled = nil
end
-- ============================================================================
-- onUpdate — per-frame managed-frame layout sync (formerly Element:update
-- line ~2747 — `Select.ensureFrameState`).
-- ============================================================================
local function onUpdate(element, dt)
local Element = ElementClass(element)
Element._Select.ensureFrameState(element)
end
-- ============================================================================
-- onDraw — no-op.
-- ============================================================================
-- Select rendering is driven by the managed frame / anchor elements themselves
-- (visibility synced by Select.syncManagedFrameVisibility), not by the select
-- parent's draw path. The parent's own pixels are the theme/renderer's job.
local function onDraw() end
-- ============================================================================
-- saveState / restoreState — select open/value/label (formerly the `select`
-- branch of Element:saveState / Element:restoreState).
-- ============================================================================
-- Returns a snapshot under the `select` key to match the legacy immediate-mode
-- restoreState contract (Element:restoreState looked up state.select). The
-- behavior-dispatch loop merges behavior snapshots into the top-level state
-- table, so returning { select = ... } slots in identically to the old inline
-- `state.select = selectState` assignment.
local function saveState(element)
local Element = ElementClass(element)
local selectState = Element._Select.saveState(element)
if selectState then
return { select = selectState }
end
return nil
end
-- Consumes the previously-saved snapshot keyed under `select`. The behavior-
-- dispatch loop passes the FULL top-level state table; this hook reads only
-- its own `state.select` slice, mirroring the legacy `if state.select then`
-- guard in Element:restoreState.
local function restoreState(element, state)
if not state then
return
end
local Element = ElementClass(element)
if state.select then
Element._Select.restoreState(element, state.select)
end
end
-- ============================================================================
-- Build the (stateless, shared, immutable) behavior instance.
-- ============================================================================
local Selectable = Behavior.new({
onAttach = onAttach,
onDetach = onDetach,
onUpdate = onUpdate,
onDraw = onDraw,
saveState = saveState,
restoreState = restoreState,
shouldAttach = shouldAttach,
})
-- Expose the predicate at module level so callers/tests can reference it
-- directly without an element instance (mirrors Behavior.shouldAttach).
Selectable.shouldAttach = shouldAttach
return Selectable
@@ -0,0 +1,576 @@
-- 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
+178
View File
@@ -0,0 +1,178 @@
-- modules/behaviors/Themed.lua
--
-- Concrete behavior: Renderer ownership + theme-state rendering.
--
-- Themed owns the per-element Renderer instance and the single
-- `Renderer:draw` call that paints the core visual layers (background, image,
-- theme 9-patch, borders, text, customDraw). It is the behavior-mode-unification
-- replacement for the former `_initImageAndRenderer` Renderer creation block and
-- the former first `self._renderer:draw(self, backdropCanvas)` call in
-- Element:draw (behavior-mode-unification task 07).
--
-- Attachment rule (shouldAttach): every renderable Element. The pre-refactor
-- code unconditionally created a Renderer for every Element and unconditionally
-- called `Renderer:draw` in Element:draw; Themed mirrors that invariant so the
-- Renderer is always available to subsystems that depend on it (TextEditor font
-- / wrap delegation, ScrollManager scrollbar drawing) AND so visual rendering of
-- background / border / theme / image layers is preserved for every element.
-- Restricting attachment to `themeComponent`-only elements would break editable
-- text fields and scrollable containers (which need a Renderer for subsystem
-- delegation even when they have no theme component). The 9-patch theme-state
-- rendering within `Renderer:draw` is a no-op for elements without a
-- `themeComponent`, so always-attaching carries no rendering cost.
--
-- Themed and Imageable are paired (both configure the same `element._renderer`):
-- Themed.onAttach creates the Renderer with the theme/blur config; Imageable
-- (attached for imagePath/image elements) enriches the SAME renderer instance with
-- image config + deferred image loading. They share `element._renderer`.
--
-- onUpdate is a no-op: theme-state transitions are DRIVEN by the Clickable
-- behavior (whose onUpdate recomputes hover/press/focus and calls
-- `renderer:setThemeState`). Themed only READS that state for rendering, so it has
-- no per-frame update work.
--
-- saveState owns the blur-region snapshot (`state.blur`): the per-frame blur
-- geometry + radius/quality used by the Blur cache for invalidation (formerly
-- the inline `if self.backdropBlur or self.contentBlur` block of
-- Element:saveState — behavior-mode-unification task 12). restoreState is a
-- no-op: blur cache data is used for invalidation, not restoration (the Blur
-- cache is keyed by element id and cleared via `Blur.clearElementCache` from
-- FlexLove.endFrame, not replayed through restoreState).
--
-- State ownership (per the locked Behavior contract):
-- * Per-element runtime state lives ON THE ELEMENT (`element._renderer`,
-- `element._themeState`, `element.backdropBlur`, `element.contentBlur`).
-- The behavior instance is stateless and shared.
-- * `element._renderer` is recreated on attach; onDetach is a no-op — the
-- reference is released when the element is GC'd (Element:_cleanup keeps
-- element structure for inspection).
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
local Behavior = require(_pkg .. "Behavior")
-- Resolve the Element class from an element instance (mirrors Clickable).
-- Element instances are created via `setmetatable({}, Element)`, so their
-- metatable IS the Element class — giving access to Element._Renderer,
-- Element._rendererDeps, etc. without threading deps through the hook signature.
local function ElementClass(element)
return getmetatable(element)
end
-- ----------------------------------------------------------------------------
-- shouldAttach (class-level predicate, no element required)
-- ----------------------------------------------------------------------------
-- Returns true for every renderable Element. See file header for the rationale:
-- the pre-refactor invariant was "every Element has a Renderer; Element:draw
-- always calls Renderer:draw", and Thamed is the behavior-system embodiment of
-- that invariant. Returns true for `themeComponent`-bearing props (the spec's
-- headline case) and for every other element so subsystems/rendering stay intact.
local function shouldAttach(props)
return true
end
-- ----------------------------------------------------------------------------
-- onAttach — create the Renderer with theme/blur config (formerly the
-- Renderer.new block of Element:_initImageAndRenderer).
-- ----------------------------------------------------------------------------
local function onAttach(element)
local Element = ElementClass(element)
-- Create-or-reuse the Renderer. Thamed is the first render behavior in the
-- registry, so it normally creates the instance; Imageable (if attached) will
-- reuse this same instance for image config. Guarded so Imageable-onAttach-
-- first (defensive) does not clobber an existing renderer.
if element._renderer then
return
end
-- NOTE: backgroundColor/borderColor/opacity/cornerRadius/themeComponent are
-- intentionally NOT passed here. Renderer:draw() reads them from the element
-- as the single source of truth (see Renderer.lua draw()). Only renderer-owned
-- state (theme, blur) is cached on the renderer; image config is added by the
-- Imageable behavior. border is element-sourced too.
element._renderer = Element._Renderer.new({
theme = element.theme,
scaleCorners = element.scaleCorners,
scalingAlgorithm = element.scalingAlgorithm,
contentBlur = element.contentBlur,
backdropBlur = element.backdropBlur,
}, Element._rendererDeps)
end
-- ----------------------------------------------------------------------------
-- onDraw — the single Renderer:draw call (formerly the first call in
-- Element:draw). Paints all core visual layers for this element.
-- ----------------------------------------------------------------------------
local function onDraw(element, ctx)
local renderer = element._renderer
if not renderer then
return
end
renderer:draw(element, ctx and ctx.backdropCanvas)
end
-- ----------------------------------------------------------------------------
-- onDetach — no-op. Element:_cleanup preserves element structure for
-- inspection (the original invariant), so the Renderer reference is released
-- when the element is GC'd rather than torn down here. Present as an explicit
-- hook so the behavior conforms to the full lifecycle contract.
-- ----------------------------------------------------------------------------
local function onDetach() end
-- ----------------------------------------------------------------------------
-- saveState — blur-region snapshot (formerly the `blur` branch of
-- Element:saveState). Returns `{ blur = {...} }` when the element configures a
-- backdrop or content blur, so the Blur cache can invalidate by element id;
-- nil otherwise. Mode-agnostic to match the legacy contract (the snapshot is
-- only read back by the cache-invalidation path, which itself is
-- immediate-mode-only via FlexLove.endFrame).
-- ----------------------------------------------------------------------------
local function saveState(element)
if not (element.backdropBlur or element.contentBlur) then
return nil
end
local blur = {
_blurX = element.x,
_blurY = element.y,
_blurWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right),
_blurHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom),
}
if element.backdropBlur then
blur._backdropBlurRadius = element.backdropBlur.radius
blur._backdropBlurQuality = element.backdropBlur.quality or 5
end
if element.contentBlur then
blur._contentBlurRadius = element.contentBlur.radius
blur._contentBlurQuality = element.contentBlur.quality or 5
end
return { blur = blur }
end
-- restoreState — no-op: blur cache data is used for invalidation, not
-- restoration (see file header). Present so the behavior conforms to the
-- lifecycle contract without replaying geometry that the cache recomputes.
-- ----------------------------------------------------------------------------
-- Build the (stateless, shared, immutable) behavior instance.
-- ----------------------------------------------------------------------------
local Themed = Behavior.new({
onAttach = onAttach,
onDetach = onDetach,
onUpdate = function() end,
onDraw = onDraw,
saveState = saveState,
restoreState = function() end,
})
-- Expose the predicate at module level so callers/tests can reference it
-- directly without an element instance (mirrors Behavior.shouldAttach /
-- Clickable.shouldAttach).
Themed.shouldAttach = shouldAttach
return Themed