mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 20:20:19 +02:00
big ui moment
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user