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

3905 lines
146 KiB
Lua

---@class Element
---@field id string
---@field children Element[]
---@field parent Element|nil
---@field userdata any|nil
---@field onEvent fun(self: Element, event: table)|nil
---@field onEventDeferred boolean|nil
---@field onFocus fun(self: Element)|nil
---@field onFocusDeferred boolean
---@field dropFocusOnSelection boolean|nil
---@field onBlur fun(self: Element)|nil
---@field onBlurDeferred boolean
---@field onTextInput fun(self: Element, text: string)|nil
---@field onTextInputDeferred boolean
---@field onTextChange fun(self: Element, text: string)|nil
---@field onTextChangeDeferred boolean
---@field onEnter fun(self: Element)|nil
---@field onEnterDeferred boolean
---@field customDraw fun(self: Element)|nil
---@field onTouchEvent fun(self: Element, event: table)|nil
---@field onTouchEventDeferred boolean
---@field onGesture fun(self: Element, gesture: table)|nil
---@field onGestureDeferred boolean
---@field touchEnabled boolean
---@field multiTouchEnabled boolean
---@field theme table|nil
---@field themeComponent string|nil
---@field disabled boolean
---@field active boolean
---@field disableHighlight boolean
---@field contentAutoSizingMultiplier number[]|nil
---@field scaleCorners boolean|nil
---@field scalingAlgorithm string|nil
---@field contentBlur {radius:number, quality?:number}|nil
---@field backdropBlur {radius:number, quality?:number}|nil
---@field editable boolean
---@field multiline boolean
---@field passwordMode boolean
---@field textWrap string|boolean
---@field maxLines number|nil
---@field maxLength number|nil
---@field placeholder string|nil
---@field inputType string
---@field textOverflow string
---@field scrollable boolean
---@field autoGrow boolean
---@field selectOnFocus boolean
---@field cursorColor Color|nil
---@field selectionColor Color|nil
---@field cursorBlinkRate number
---@field selectParent Element|nil
---@field selectOption table|nil
---@field onChange fun(self: Element, value: any, option: Element)|nil
---@field border number|table|nil
---@field borderColor Color
---@field backgroundColor Color
---@field opacity number
---@field visibility string
---@field display boolean
---@field transform table|nil
---@field cornerRadius number|{topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|nil
---@field text string|nil
---@field textAlign string|table|nil
---@field textAlignHorizontal string
---@field textAlignVertical string
---@field imagePath string|nil
---@field image table|nil
---@field objectFit string
---@field objectPosition string
---@field imageOpacity number
---@field imageRepeat string
---@field imageTint Color|nil
---@field onImageLoad fun(self: Element, image: table)|nil
---@field onImageLoadDeferred boolean
---@field onImageError fun(self: Element, err: string)|nil
---@field onImageErrorDeferred boolean
---@field prevGameSize {width:number, height:number}
---@field autosizing {width:boolean, height:boolean}
---@field units table
---@field minTextSize number|nil
---@field maxTextSize number|nil
---@field autoScaleText boolean
---@field fontFamily string|nil
---@field textSize number
---@field width number
---@field height number
---@field x number
---@field y number
---@field z number
---@field gap number
---@field flexGrow number
---@field flexShrink number
---@field flexBasis number|string
---@field padding {top:number, right:number, bottom:number, left:number}
---@field margin {top:number, right:number, bottom:number, left:number}
---@field tabIndex number|nil
---@field textColor Color
---@field positioning string
---@field top number|nil
---@field right number|nil
---@field bottom number|nil
---@field left number|nil
---@field flexDirection string|nil
---@field flexWrap string|nil
---@field justifyContent string|nil
---@field alignItems string|nil
---@field alignContent string|nil
---@field justifySelf string|nil
---@field alignSelf string
---@field gridRows number|nil
---@field gridColumns number|nil
---@field columnGap number|nil
---@field rowGap number|nil
---@field transition table
---@field transitions table|nil
---@field animation table|nil
---@field overflow string|nil
---@field overflowX string|nil
---@field overflowY string|nil
---@field scrollbarWidth number|nil
---@field scrollbarColor Color|nil
---@field scrollbarBackgroundColor Color|nil
---@field scrollbarTrackColor Color|nil
---@field scrollbarRadius number|nil
---@field scrollbarPadding number|nil
---@field scrollSpeed number|nil
---@field invertScroll boolean|nil
---@field scrollBarStyle string|nil
---@field scrollbarKnobOffset number|nil
---@field hideScrollbars boolean|nil
---@field scrollbarPlacement string|nil
---@field scrollbarBalance number|nil
---@field borderWidth number|nil
---@field fontSize number|nil
---@field lineHeight number|nil
local Element = {}
Element.__index = Element
-- Forward declarations for the special-handler binding helpers used by
-- Element:_applyProps (behavior-mode-unification task 08 capstone). These
-- absorb the former subsystem-init and visual-state phase bodies (ThemeManager
-- creation + theme-field exposure + editable/text/scroll/autoGrow/select-field
-- defaults + parent assignment, and border/cornerRadius/display/text/textAlign
-- normalization). They are now private implementation of the props-binding phase
-- rather than standalone Element methods, so there are no longer per-capability
-- init phases on Element.
local bindThemeAndFields, bindVisualState
-- NOTE: There is intentionally NO custom Element.__newindex for dimension properties.
-- Lua's __newindex fires ONLY when the key is ABSENT from the raw table, but width/
-- height/x/y are all assigned during Element.new, so they already exist post-
-- construction. A __newindex handler therefore CANNOT intercept retained-mode bare
-- writes like `element.width = "42%"` (it just rawsets the broken string).
-- Dimensions are instead validated lazily in Element:_checkDimensionTypes() at the
-- start of each reflow, and must be changed via :setProperty() for resolution +
-- layout invalidation. Keeping the metatable free of __newindex also avoids a
-- per-field-write function call on every absent-key assignment (perf).
local MAX_DEFER_RETRIES = 10
local MAX_DEFERRED_METHODS = 100
local _DEFERRED_NIL = {}
local unpack = table.unpack or unpack
---Initialize Element module with required dependencies
---@param deps table Dependency table containing all required modules
function Element.init(deps)
Element._ErrorHandler = deps.ErrorHandler
Element._Color = deps.Color
Element._Context = deps.Context
Element._Units = deps.Units
Element._Calc = deps.Calc
Element._utils = deps.utils
Element._InputEvent = deps.InputEvent
Element._EventHandler = deps.EventHandler
Element._Renderer = deps.Renderer
Element._LayoutEngine = deps.LayoutEngine
Element._TextEditor = deps.TextEditor
Element._ScrollManager = deps.ScrollManager
Element._Theme = deps.Theme
Element._RoundedRect = deps.RoundedRect
Element._NinePatch = deps.NinePatch
Element._ImageRenderer = deps.ImageRenderer
Element._ImageCache = deps.ImageCache
Element._ImageScaler = deps.ImageScaler
Element._Blur = deps.Blur
Element._Transform = deps.Transform
Element._Grid = deps.Grid
Element._StateManager = deps.StateManager
Element._GestureRecognizer = deps.GestureRecognizer
Element._Performance = deps.Performance
Element._Animation = deps.Animation
Element._ZIndex = deps.ZIndex
Element._Select = deps.Select
Element._PropertySchema = deps.PropertySchema or require("modules.PropertySchema")
Element._Select.init({
ErrorHandler = Element._ErrorHandler,
Context = Element._Context,
StateManager = Element._StateManager,
utils = Element._utils,
Element = Element,
})
Element._ScrollManager.init({
ErrorHandler = Element._ErrorHandler,
Context = Element._Context,
StateManager = Element._StateManager,
})
-- Bind Element scroll/scrollbar API directly onto ScrollManager.
-- ScrollManager owns all scroll interaction logic; Element retains only
-- 1-line delegates (no hand-written sync/nil-guard boilerplate).
local SM = Element._ScrollManager
Element._syncScrollManagerState = SM.syncToElement
Element._detectOverflow = SM._detectOverflow
Element.setScrollPosition = SM.setScrollPosition
Element._calculateScrollbarDimensions = SM._calculateScrollbarDimensions
Element._getScrollbarAtPosition = SM._getScrollbarAtPosition
Element._handleScrollbarPress = SM._handleScrollbarPress
Element._handleScrollbarDrag = SM._handleScrollbarDrag
Element._handleScrollbarRelease = SM._handleScrollbarRelease
Element._handleWheelScroll = SM._handleWheelScroll
Element.getScrollPosition = SM.getScrollPosition
Element.getMaxScroll = SM.elementGetMaxScroll
Element.getScrollPercentage = SM.elementGetScrollPercentage
Element.hasOverflow = SM.elementHasOverflow
Element.getContentSize = SM.elementGetContentSize
Element.scrollBy = SM.elementScrollBy
Element.scrollToTop = SM.scrollToTop
Element.scrollToBottom = SM.scrollToBottom
Element.scrollToLeft = SM.scrollToLeft
Element.scrollToRight = SM.scrollToRight
-- Hoist subsystem dependency tables: created once at init time, not rebuilt
-- per Element.new() call. Staged initializers reference these directly.
Element._eventHandlerDeps = {
InputEvent = Element._InputEvent,
Context = Element._Context,
utils = Element._utils,
}
-- Behavior registry (behavior-mode-unification). Concrete behaviors live in
-- modules/behaviors/ and auto-attach during Element.new when their
-- shouldAttach(props) predicate returns true. Element.update/draw/save-restore
-- dispatch over `element.behaviors` instead of branching on capability flags.
-- Registry order matters for onDraw layering: Themed (core Renderer:draw) must
-- run before Clickable (pressed overlay) so pressed feedback paints on top.
-- Imageable (image config) runs last. Animated (task 06) is late-attach-only.
-- Task 02 wires Clickable; task 05 Selectable; task 06 Animated; task 07
-- Themed + Imageable; task 04 TextEditable (cursor blink + TextEditor
-- ownership + the 27 text-delegate forwarders). Scrollable is pending.
Element._behaviorRegistry = deps.behaviors or deps.clickableBehaviors or {}
-- TextEditable module reference: Element's 1-line text-delegate forwarders
-- route through `Element._TextEditable.<fn>(self, ...)` (task 04). Resolved
-- from deps (wired by FlexLove alongside the behavior registry) so minimal
-- builds without TextEditable leave forwarders inert (guarded by their
-- callers / the behavior's nil-checks).
Element._TextEditable = deps.TextEditable
-- Cached lookup of the Animated behavior instance for late-attach. Resolved
-- lazily (behaviors are optional in minimal builds) the first time an
-- animation is created on an element.
Element._animatedBehavior = nil
Element._rendererDeps = {
Color = Element._Color,
RoundedRect = Element._RoundedRect,
NinePatch = Element._NinePatch,
ImageRenderer = Element._ImageRenderer,
ImageCache = Element._ImageCache,
Theme = Element._Theme,
Blur = Element._Blur,
Transform = Element._Transform,
utils = Element._utils,
}
Element._layoutEngineDeps = {
utils = Element._utils,
Grid = Element._Grid,
Units = Element._Units,
Context = Element._Context,
ErrorHandler = Element._ErrorHandler,
}
Element._textEditorDeps = {
Context = Element._Context,
StateManager = Element._StateManager,
Color = Element._Color,
utils = Element._utils,
}
Element._scrollManagerDeps = {
utils = Element._utils,
Color = Element._Color,
}
end
-- Module-level helper: resolve a dimensional property with CSS-like unit support (px, %, vw, vh, calc)
-- Defined once (not inside new()) to avoid per-element closure allocation.
-- Handles parsing, defensive checks, and storage in both self and self.units tables.
---@param self table Element instance
---@param raw any Raw property value (string, number, CalcObject, or nil)
---@param key string Field name on self and self.units (e.g., "width", "x")
---@param ref number Reference dimension for percentage resolution
---@param ctx {vw:number, vh:number, sx:number, sy:number} Viewport and scale context
---@param opts {offset?: number, scaleAxis?: "x"|"y", default?: number, nullable?: boolean}?
---@return number? resolved Resolved pixel value (or nil if opts.nullable and input is missing/invalid)
local function _resolveUnit(self, raw, key, ref, ctx, opts)
opts = opts or {}
if raw == nil then
if opts.nullable then
return nil
end
local default = opts.default or 0
self[key] = (opts.offset or 0) + default
self.units[key] = { value = default, unit = "px" }
return self[key]
end
local isCalc = Element._Calc and Element._Calc.isCalc(raw)
if type(raw) == "string" or isCalc then
local value, unit = Element._Units.parse(raw)
local resolved = Element._Units.resolve(value, unit, ctx.vw, ctx.vh, ref)
if type(resolved) ~= "number" then
if opts.nullable then
return nil
end
Element._ErrorHandler:warn("Element", "LAY_003", {
issue = key .. " resolution returned non-number value",
type = type(resolved),
value = tostring(resolved),
})
resolved = 0
end
self.units[key] = { value = value, unit = unit }
self[key] = (opts.offset or 0) + resolved
else
local val = raw
if opts.scaleAxis and Element._Context.baseScale then
val = raw * (opts.scaleAxis == "x" and ctx.sx or ctx.sy)
end
self[key] = (opts.offset or 0) + val
self.units[key] = { value = raw, unit = "px" }
end
return self[key]
end
-- Module-level helper: re-resolve a stored unit spec against a new viewport/parent reference.
-- Used by resize() to refresh min/max constraints declared with %/vw/vh units.
local function _refreshUnit(self, key, ref, ctx, scaleAxis)
local u = self.units[key]
if not u or u.value == nil then
return
end
if u.unit == "px" then
self[key] = Element._Context.baseScale and (u.value * (scaleAxis == "x" and ctx.sx or ctx.sy)) or u.value
return
end
local resolved = Element._Units.resolve(u.value, u.unit, ctx.vw, ctx.vh, ref)
self[key] = type(resolved) == "number" and resolved or nil
end
-- ---------------------------------------------------------------------------
-- Consolidated warn helpers (Task 11).
-- Each duplicated "expecting X, got Y" / guard instrumentation block lived at
-- its own use site; these single-reference helpers centralize the emit so call
-- sites are thin invocations. Validation semantics (warn+fallback vs. throw) are
-- preserved exactly — instrumentation is consolidated, not deleted.
-- ---------------------------------------------------------------------------
-- Emit a VAL_001 invalid-enum warn for a textAlign sub-field and return the
-- fallback. textAlign's schema entry is type "any" (string | table | compound),
-- so this IS the boundary validator for the 4 textAlign parse branches in the
-- props-phase visual-state helper.
local function _warnTextAlign(field, expected, got, fallback)
Element._ErrorHandler:warn("Element", "VAL_001", {
property = field,
expected = expected,
got = tostring(got),
})
return fallback
end
-- Emit a FLEX_00x warn for an invalid flexGrow/flexShrink/flexBasis and return
-- the fallback value. These props are SPECIAL_PROPS (warn+fallback, not throw)
-- so this is their boundary validator.
local function _warnFlexInvalid(self, code, issue, value, fallback)
Element._ErrorHandler:warn("Element", code, {
element = self.id or "unnamed",
issue = issue,
value = tostring(value),
})
return fallback
end
-- Emit an ELEM_010/011/012 warn for malformed declarative children entries.
local function _warnChildrenInvalid(self, code, issue, value)
local details = { element = self.id or "unnamed", issue = issue }
if value ~= nil then
details.value = tostring(value)
end
Element._ErrorHandler:warn("Element", code, details)
end
-- Emit LAY_011 when CSS positioning props (top/right/bottom/left) are supplied
-- without absolute positioning. Called from both the no-parent and with-parent
-- branches of _initPositioning.
local function _warnCssPositioningWithoutAbsolute(self, props)
local properties = {}
if props.top then
table.insert(properties, "top")
end
if props.bottom then
table.insert(properties, "bottom")
end
if props.left then
table.insert(properties, "left")
end
if props.right then
table.insert(properties, "right")
end
Element._ErrorHandler:warn("Element", "LAY_011", {
element = self.id or "unnamed",
positioning = self._originalPositioning or "relative",
properties = table.concat(properties, ", "),
})
end
-- Emit an ELEM_003/004/005 guard warn for the animation/transition public API
-- (deps-missing, non-table arg, invalid duration, non-table property list).
-- All warn + fall back rather than throw. `value` nil => warn carries no details.
local function _warnAnimApi(code, value)
if value ~= nil then
Element._ErrorHandler:warn("Element", code, { value = tostring(value) })
else
Element._ErrorHandler:warn("Element", code)
end
end
-- Image loading + image callback firing now live in the Imageable behavior
-- (modules/behaviors/Imageable.lua) — moved out of Element per
-- behavior-mode-unification task 07. Element is decoupled from image concern;
-- the Imageable behavior enriches `element._renderer` with image config, runs
-- the deferred load pipeline, and persists `_loadedImage` across immediate-mode
-- frames. The fire-callback helper (formerly `_fireImageCallback` here) is
-- reproduced inside Imageable as `fireImageCallback`.
-- ---------------------------------------------------------------------------
-- Data-driven prop binding (Task 03)
-- ---------------------------------------------------------------------------
-- SPECIAL_PROPS is the documented boundary of the schema-driven _applyProps
-- loop. Props listed here are bound explicitly by bindThemeAndFields /
-- bindVisualState / the staged initializers instead of the generic registry
-- loop, for one of five load-bearing reasons (none represent unfinished
-- migration — moving them into the generic loop would require extending the
-- PropertySchema DSL, which is deliberately kept small and declarative):
--
-- 1. SUBSYSTEM ORDERING — the prop needs a subsystem constructed first.
-- Theme props (theme/themeComponent/disabled/active/...) depend on the
-- ThemeManager being alive so their defaults can be read from it; the
-- generic loop runs before subsystem creation in _attachBehaviors.
--
-- 2. NON-LITERAL DEFAULTS — the default is not a static value the schema's
-- `default:` field can express. borderColor/backgroundColor/textColor
-- default to Color.new(...); text defaults to "" only when editable;
-- scrollable/autoGrow default from multiline. The schema only stores
-- literal defaults (pure-Lua constraint; see PropertySchema.lua header).
--
-- 3. UNIT RESOLUTION / VIEWPORT CONTEXT — dimension props (width/height/x/y
-- /gap/padding/...) accept unit strings ("50%", "10px") or CalcObjects that
-- resolve against parent size and viewport, which a pure-Lua schema cannot
-- see. setProperty routes these via the `isDimension` flag at runtime, but
-- construction-time binding needs the sizing context from _initSizingContext.
--
-- 4. WARN-AND-FALLBACK vs. THROW — display and the flex props validate with a
-- non-throwing warn+fallback path; the schema's `validator` field throws
-- (VAL_001) for invalid enum/range values. These need their own boundary
-- validators (_warnFlexInvalid / the display type-check).
--
-- 5. SUBSYSTEM OWNERSHIP — overflow/scrollbar* are owned by ScrollManager,
-- selectParent/selectOption by the Select subsystem, border/cornerRadius
-- use schema normalizers but bind with a special shape (all-false→nil).
-- These props' storage is owned by their subsystem, not the element core.
--
-- Adding a new SIMPLE prop requires only a PropertySchema entry; adding a prop
-- that needs any of the above additionally requires listing it here and binding
-- it in the matching special-handler phase. Props NOT listed here (e.g.
-- callbacks, editable, multiline, passwordMode, autoScaleText, cursorColor,
-- selectionColor, opacity, visibility, transform, imagePath/objectFit/...,
-- minTextSize/maxTextSize, alignSelf, transition) are bound generically by
-- _applyProps (defaults + normalizers + validators + onX/onXDeferred
-- auto-wiring).
local function _set(...)
local t = {}
for _, name in ipairs({ ... }) do
t[name] = true
end
return t
end
local SPECIAL_PROPS = _set(
-- identity / tree
"id",
"parent",
"children",
-- theme-driven (ThemeManager owns these / computes defaults)
"theme",
"themeComponent",
"disabled",
"isDisabled",
"active",
"disableHighlight",
"themeStateLock",
"themeComponentDisabledStates",
"scaleCorners",
"scalingAlgorithm",
"contentAutoSizingMultiplier",
-- color defaults that require the Color module
"borderColor",
"backgroundColor",
"textColor",
-- display: non-throwing warn+fallback (unlike throwing range/enum validators)
"display",
-- text editing (validation side-effects / computed defaults)
"textWrap",
"scrollable",
"autoGrow",
"text",
"textAlign",
"textAlignHorizontal",
"textAlignVertical",
"textSize",
"fontFamily",
-- box model / dimensions (unit resolution)
"width",
"height",
"x",
"y",
"z",
"minWidth",
"maxWidth",
"minHeight",
"maxHeight",
"gap",
"top",
"right",
"bottom",
"left",
"columnGap",
"rowGap",
"padding",
"margin",
-- layout enums (positioning-mode validation + LayoutEngine config)
"positioning",
"flexDirection",
"flexWrap",
"justifyContent",
"alignItems",
"alignContent",
"justifySelf",
"gridRows",
"gridColumns",
-- flex shorthand + validated numerics (custom FLEX_xx warnings)
"flex",
"flexGrow",
"flexShrink",
"flexBasis",
-- scroll / scrollbar (ScrollManager owns these)
"overflow",
"overflowX",
"overflowY",
"scrollbarWidth",
"scrollbarColor",
"scrollbarTrackColor",
"scrollbarRadius",
"scrollbarPadding",
"scrollSpeed",
"invertScroll",
"smoothScrollEnabled",
"scrollBarStyle",
"scrollbarKnobOffset",
"hideScrollbars",
"scrollbarPlacement",
"scrollbarBalance",
"_scrollX",
"_scrollY",
-- select (Select subsystem owns these)
"selectParent",
"selectOption",
-- border / cornerRadius use schema normalizers but are bound as special handlers
"border",
"cornerRadius",
-- misc instance-only fields derived during construction
"tabIndex"
)
--- Bind every schema-driven, side-effect-free prop onto `self` in one pass.
--- Iterates PropertySchema entries: applies defaults, normalizers, validators,
--- and auto-wires `onX` + `onXDeferred` companion pairs. Props listed in
--- SPECIAL_PROPS are skipped (they are handled explicitly in Element.new).
---@param props table Element construction props
function Element:_applyProps(props)
local schema = Element._PropertySchema
local registry = schema.all()
for name, meta in pairs(registry) do
-- Skip deferred companion entries (auto-wired by their base callback's
-- hasDeferred branch below) and SPECIAL_PROPS (handled in Element.new).
if not (name:match("Deferred$") or SPECIAL_PROPS[name]) then
local value = props[name]
if value == nil then
value = meta.default
end
if meta.normalizer then
value = meta.normalizer(value)
end
if meta.validator and value ~= nil and not meta.validator(value) then
-- Mirror the legacy throwing validateRange/validateEnum behavior: invalid
-- enum/range values error during construction (validated props: opacity,
-- imageOpacity, objectFit, imageRepeat). display is a special handler that
-- warns + falls back instead.
Element._ErrorHandler:error("Element", "VAL_001", {
property = name,
expected = meta.type,
got = tostring(value),
})
end
local key = meta.storageKey or name
self[key] = value
-- Auto-wire deferred companion for callbacks that declare hasDeferred.
if meta.hasDeferred then
local deferredName = name .. "Deferred"
local deferredValue = props[deferredName]
self[deferredName] = deferredValue ~= nil and deferredValue or false
end
end
end
-- Special-handler binding (behavior-mode-unification task 08 capstone): the
-- props below need side-effects, ordering relative to subsystems, non-literal
-- defaults, or unit resolution, so they cannot be bound by the generic schema
-- loop above. The two helpers below fold in the former subsystem-init phase
-- (ThemeManager creation + theme-field exposure + editable/multiline/
-- passwordMode validation + textWrap/scrollable/autoGrow defaults +
-- selectParent/selectOption/_selectState + parent assignment) and visual-state
-- phase (border/cornerRadius/display/text/textAlign normalization). Subsystem
-- CREATION (EventHandler / TextEditor / ScrollManager / Renderer) is owned by
-- behavior onAttach hooks dispatched in _attachBehaviors; these helpers only
-- bind FIELDS that the core sizing/box/positioning phases and the behavior
-- onAttach hooks read.
bindThemeAndFields(self, props)
bindVisualState(self, props)
end
---@param props ElementProps
---@return Element
--- Construct a new Element. Orchestrator only; real work is in the staged
--- initializers below (Task 10). No single phase exceeds ~400 LOC.
function Element.new(props)
-- Staged initializers (behavior-mode-unification task 08 capstone). The
-- orchestrator is a thin dispatcher: it runs core-data phases only
-- (construct → props → sizing → box model → positioning → finalize), then
-- attaches behaviors. The former behavioral phases (subsystem-init, visual-
-- state, image/renderer, scroll-manager) are deleted: their field-binding logic
-- folded into _applyProps and their subsystem creation logic moved into
-- behavior onAttach hooks (Clickable / TextEditable / Selectable / Themed /
-- Imageable / Scrollable). _attachBehaviors runs at the tail so
-- Selectable.onAttach can re-scan declarative children built by
-- _finalizeConstruction and so onAttach sees all element fields bound.
local self = Element:_construct(props)
self:_applyProps(props)
self:_initSizingContext(props)
self:_initBoxModel(props)
self:_initPositioning(props)
self:_finalizeConstruction(props)
self:_attachBehaviors(props)
return self
end
--- Phase 1: metatable, schema-driven prop normalization (for special-handler
--- props), default tables (children, _deferredMethods), and ID generation.
--- Schema-driven binding (_applyProps) is invoked separately by Element.new.
function Element:_construct(props)
local instance = setmetatable({}, Element)
-- Stash the construction props so behavior onAttach hooks (which receive only
-- the element per the locked `(element, ...)` signature) can read SPECIAL_PROPS
-- config that is NOT bound onto the element by the schema-driven _applyProps
-- loop (e.g. the scrollbar config consumed by Scrollable.onAttach). Prefixed
-- with `_` so the immediate-mode saveState public-prop scan skips it.
instance._initProps = props
-- Apply schema-driven shape normalizers to props for the special-handler
-- props (padding/margin/flexDirection) whose downstream unit-resolution logic
-- reads from `props` directly. Generic props are bound by _applyProps below.
local schema = Element._PropertySchema
props.flexDirection = schema.get("flexDirection").normalizer(props.flexDirection)
props.padding = schema.get("padding").normalizer(props.padding)
props.margin = schema.get("margin").normalizer(props.margin)
-- Behavior registry (Task 01 of behavior-mode-unification). Concrete
-- behaviors (Clickable, Scrollable, ...) are attached here in later tasks;
-- Element:update/draw/save-restore dispatch over this table instead of
-- branching on individual capability flags. Initially empty so existing
-- behavior is identical to pre-refactor until behaviors are wired in.
instance.children = {}
instance.behaviors = {}
instance._deferredMethods = {}
-- Track whether ID was auto-generated (before ID assignment)
local idWasAutoGenerated = not props.id or props.id == ""
-- Auto-generate ID if not provided (for all elements)
if idWasAutoGenerated then
instance.id = Element._StateManager.generateID(props, props.parent)
else
instance.id = props.id
end
-- Initialize state manager ID for immediate mode (use self.id which may be auto-generated)
instance._stateId = instance.id
-- Register with StateManager for state access (both immediate and retained modes)
if instance._stateId and instance._stateId ~= "" then
Element._StateManager.registerStateful(instance._stateId, instance)
end
return instance
end
--- Attach behaviors whose shouldAttach(props) predicate matches this element's
--- props. Runs after prop binding + subsystem init (so Deferred flags and the
--- Select subsystem are in place) and dispatches onAttach for each match. The
--- EventHandler (Clickable) is created here rather than in the former subsystem-
--- init phase so Element never needs to know what an individual behavior does —
--- it only iterates the registry (behavior-mode-unification task 02).
function Element:_attachBehaviors(props)
local registry = Element._behaviorRegistry
if registry then
for _, behavior in ipairs(registry) do
if behavior.shouldAttach(props) then
table.insert(self.behaviors, behavior)
behavior.onAttach(self)
end
end
end
end
--- Resolve the (lazily cached) Animated behavior instance from the registry.
-- task 09: since the behavior loop no longer excludes Animated, elements WITH
-- the behavior attached get the loop dispatch and `_dispatchAnimatedUpdate` no-ops
-- for them.
-- task 09 consolidation: `_ensureAnimatedAttached` / `_isAnimatedBehavior`
-- (dead code) were removed; Animated.ensureAttached remains the late-attach
-- entry point for callers that route through it.
function Element._resolveAnimatedBehavior()
local animated = Element._animatedBehavior
if animated == nil then
local registry = Element._behaviorRegistry
if registry then
for _, behavior in ipairs(registry) do
-- Animated exposes ensureAttached; Clickable/Themed/Imageable do not.
if type(behavior.ensureAttached) == "function" then
animated = behavior
break
end
end
end
Element._animatedBehavior = animated or false
end
return animated
end
--- Dispatch Animated.onUpdate for an element EARLY in Element:update (before
--- the behavior loop) so animated geometry (x/y/width/height) is current for
--- Clickable hit-testing and Scrollable interaction this frame. NO-OPS for
--- elements that already have the Animated behavior attached (the loop
--- dispatches those) to avoid a double update — animation:update(dt) is not
--- idempotent within a frame. This handles the direct-assignment path
--- (`element.animation = ...` / `anim:apply`) that bypasses
--- Animated.ensureAttached; for elements with no animation the behavior's
--- onUpdate reads `element.animation` and returns. Not a behavioral capability
--- branch — iterates `element.behaviors`. (behavior-mode-unification task 09.)
function Element._dispatchAnimatedUpdate(element, dt)
if not element then
return
end
local animated = Element._resolveAnimatedBehavior()
if not animated then
return
end
-- Already attached? The behavior loop will dispatch it; bail to avoid a
-- double update (animation:update advances twice if called twice).
local behaviors = element.behaviors
if behaviors then
for i = 1, #behaviors do
if behaviors[i] == animated then
return
end
end
end
animated.onUpdate(element, dt)
end
--- Special-handler binding helper for the props phase (behavior-mode-
--- unification task 08). Formerly the Element subsystem-init phase. Binds the
--- ThemeManager (or no-op fallback) + exposes theme fields, validates
--- editable/multiline/passwordMode combos, sets textWrap/scrollable/autoGrow
--- defaults, initializes selectParent/selectOption/_selectState fields (the
--- Select subsystem itself is initialized by Selectable.onAttach), and assigns
--- self.parent. EventHandler creation is owned by Clickable.onAttach and
--- TextEditor creation by TextEditable.onAttach — both run in _attachBehaviors at
--- the tail of Element.new, so this helper does not touch either subsystem.
bindThemeAndFields = function(self, props)
if Element._Theme then
self._themeManager = Element._Theme.Manager.new({
theme = props.theme or Element._Context.defaultTheme,
themeComponent = props.themeComponent or nil,
disabled = props.isDisabled or props.disabled or false,
active = props.active or false,
disableHighlight = props.disableHighlight,
themeStateLock = props.themeStateLock or false,
themeComponentDisabledStates = props.themeComponentDisabledStates,
scaleCorners = props.scaleCorners,
scalingAlgorithm = props.scalingAlgorithm,
})
else
-- Theme module absent (minimal build) — plain no-op ThemeManager
local noPadding = { top = 0, right = 0, bottom = 0, left = 0 }
self._themeManager = {
theme = nil,
themeComponent = props.themeComponent or nil,
disabled = props.isDisabled or props.disabled or false,
active = props.active or false,
themeComponentDisabledStates = {},
scaleCorners = props.scaleCorners,
scalingAlgorithm = props.scalingAlgorithm,
validateThemeStateLock = function() end,
getState = function()
return "normal"
end,
setState = function() end,
updateState = function()
return false
end,
hasThemeComponent = function()
return false
end,
getTheme = function()
return nil
end,
getComponent = function()
return nil
end,
getStateComponent = function()
return nil
end,
getScrollbarComponent = function()
return nil
end,
getDefaultFontFamily = function()
return nil
end,
getContentAutoSizingMultiplier = function()
return nil
end,
getScaledContentPadding = function()
return noPadding
end,
getScaledContentPaddingForState = function()
return noPadding
end,
_getScaledContentPaddingForState = function()
return noPadding
end,
getStyle = function()
return nil
end,
}
end
-- Validate themeStateLock after ThemeManager is created
if props.themeStateLock and props.themeComponent then
self._themeManager:validateThemeStateLock()
end
-- Expose theme properties for backward compatibility
self.theme = self._themeManager.theme
self.themeComponent = self._themeManager.themeComponent
self.disabled = self._themeManager.disabled
self.active = self._themeManager.active
self._themeState = self._themeManager:getState()
-- disableHighlight defaults to true when using themeComponent (themes handle their own visual feedback)
-- Can be explicitly overridden by setting props.disableHighlight
if props.disableHighlight ~= nil then
self.disableHighlight = props.disableHighlight
else
self.disableHighlight = self.themeComponent ~= nil
end
-- Initialize contentAutoSizingMultiplier after theme is set
-- Priority: element props > theme component > theme default
if props.contentAutoSizingMultiplier then
self.contentAutoSizingMultiplier = props.contentAutoSizingMultiplier
else
local multiplier = self._themeManager:getContentAutoSizingMultiplier()
self.contentAutoSizingMultiplier = multiplier or { 1, 1 }
end
-- Expose 9-patch corner scaling properties for backward compatibility
self.scaleCorners = self._themeManager.scaleCorners
self.scalingAlgorithm = self._themeManager.scalingAlgorithm
self._blurInstance = nil
-- editable/multiline/passwordMode are bound by _applyProps (default false).
-- Validate combinations: passwordMode disables multiline.
if self.passwordMode and self.multiline then
Element._ErrorHandler:warn("Element", "ELEM_006")
self.multiline = false
elseif self.passwordMode then
self.multiline = false
end
self.textWrap = props.textWrap
if self.textWrap == nil then
self.textWrap = self.multiline and "word" or false
end
self.scrollable = props.scrollable
if self.scrollable == nil then
self.scrollable = self.multiline
end
-- autoGrow defaults to true for multiline, false for single-line
if props.autoGrow ~= nil then
self.autoGrow = props.autoGrow
else
self.autoGrow = self.multiline
end
self.selectParent = nil
self.selectOption = nil
self._selectState = nil
if type(props.selectParent) == "table" then
self.selectParent = props.selectParent
end
if type(props.selectOption) == "table" then
self.selectOption = props.selectOption
end
-- TextEditor creation + immediate-mode state restore is owned by the
-- TextEditable behavior's onAttach (task 04), which runs in _attachBehaviors
-- at the tail of Element.new (after this helper has bound self.text and the
-- schema-driven callback fields). This subsystem-init helper no longer touches
-- the TextEditor.
-- Set parent first so it's available for size calculations
self.parent = props.parent
end
--- Special-handler binding helper for the props phase (behavior-mode-
--- unification task 08). Formerly the Element visual-state phase. Normalizes
--- border/cornerRadius/display/text/textAlign. The branch count comment below
--- refers to the 4 textAlign parse branches (table / simple-string / compound-
--- string / invalid).
bindVisualState = function(self, props)
local schema = Element._PropertySchema
------ add non-hereditary ------
--- self drawing ---
-- Border shape-normalization via the schema normalizer (special handler: the
-- number-vs-table-vs-nil shape and the all-false→nil collapse are intentional).
self.border = schema.get("border").normalizer(props.border)
self.borderColor = props.borderColor or Element._Color.new(0, 0, 0, 1)
self.backgroundColor = props.backgroundColor or Element._Color.new(0, 0, 0, 0)
-- cornerRadius shape-normalization via the schema normalizer (special handler:
-- number-vs-table-vs-nil and the all-zero→nil collapse are intentional).
self.cornerRadius = schema.get("cornerRadius").normalizer(props.cornerRadius)
-- display: default true; invalid (non-boolean) warns + falls back to true
-- (non-throwing, unlike range/enum validators).
if props.display ~= nil then
if type(props.display) == "boolean" then
self.display = props.display
else
self.display = true
Element._ErrorHandler:warn(
"Element",
"ELEM_010",
"display must be a boolean (true/false), got " .. type(props.display) .. ". Defaulting to true."
)
end
else
self.display = true
end
-- For editable elements, default text to empty string if not provided
if self.editable and props.text == nil then
self.text = ""
else
self.text = props.text
end
-- Validate and set textAlign (supports simple string, compound string, or
-- table format). Enum membership is checked via PropertySchema validators
-- (the valid H/V sets live there, matching the objectFit/imageRepeat pattern);
-- compound-string parsing and warn+fallback stay here because they need
-- ErrorHandler, which the pure-Lua schema cannot depend on.
local textAlignMeta = schema.get("textAlign")
local textVAlignMeta = schema.get("textAlignVertical")
local textAlignDefault = textAlignMeta.default
local vAlignDefault = textVAlignMeta.default
self.textAlign = props.textAlign or textAlignDefault
self.textAlignHorizontal = textAlignDefault
self.textAlignVertical = vAlignDefault
if props.textAlign ~= nil then
if type(props.textAlign) == "table" then
-- Table format: {horizontal = "start", vertical = "center"}
local hAlign = props.textAlign.horizontal or textAlignDefault
local vAlign = props.textAlign.vertical or vAlignDefault
if not textAlignMeta.validator(hAlign) then
hAlign = _warnTextAlign("textAlign.horizontal", "valid TextAlign value", hAlign, textAlignDefault)
end
if not textVAlignMeta.validator(vAlign) then
vAlign = _warnTextAlign("textAlign.vertical", "valid TextAlignVertical value", vAlign, vAlignDefault)
end
self.textAlignHorizontal = hAlign
self.textAlignVertical = vAlign
elseif type(props.textAlign) == "string" then
if textAlignMeta.validator(props.textAlign) then
-- Known simple TextAlign value (backward compatible)
self.textAlignHorizontal = props.textAlign
self.textAlignVertical = vAlignDefault
else
-- Treat as compound string: "top-left" through "bottom-right"
local parts = {}
for part in props.textAlign:gmatch("[^-]+") do
table.insert(parts, part)
end
if #parts == 2 then
local verticalMap = { top = "start", center = "center", bottom = "end" }
local horizontalMap = { left = "start", center = "center", right = "end" }
local vStr = parts[1]:lower()
local hStr = parts[2]:lower()
local resolvedV = verticalMap[vStr]
local resolvedH = horizontalMap[hStr]
if resolvedV and resolvedH then
self.textAlignHorizontal = resolvedH
self.textAlignVertical = resolvedV
else
_warnTextAlign(
"textAlign",
"valid compound string (e.g., 'top-left', 'center-right')",
props.textAlign,
nil
)
end
else
_warnTextAlign("textAlign", "valid TextAlign value or compound string", props.textAlign, nil)
end
end
end
end
end
--- Phase 5 (image + renderer init) is owned by the Themed and Imageable
--- behaviors (modules/behaviors/), attached in _attachBehaviors. Themed.onAttach
--- creates the Renderer (theme/blur config); Imageable.onAttach enriches it with
--- image config + deferred image loading. There is no longer a stub Element
--- phase for this — the behavior onAttach hooks ARE the phase
--- (behavior-mode-unification task 07/08).
--- Phase 6a: viewport/scale context, LayoutEngine (defaults), unit specs table,
--- fontFamily, and textSize resolution.
function Element:_initSizingContext(props)
--- self positioning ---
local viewportWidth, viewportHeight = Element._Units.getViewport()
---- Sizing ----
local gw, gh = love.window.getMode()
self.prevGameSize = { width = gw, height = gh }
self.autosizing = { width = false, height = false }
-- Initialize LayoutEngine early with default values for auto-sizing calculations
-- It will be re-configured later with actual layout properties
self._layoutEngine = Element._LayoutEngine.new({
positioning = Element._utils.enums.Positioning.RELATIVE,
flexDirection = Element._utils.enums.FlexDirection.HORIZONTAL,
flexWrap = Element._utils.enums.FlexWrap.NOWRAP,
justifyContent = Element._utils.enums.JustifyContent.FLEX_START,
alignItems = Element._utils.enums.AlignItems.STRETCH,
alignContent = Element._utils.enums.AlignContent.STRETCH,
gap = 0,
gridRows = 1,
gridColumns = 1,
columnGap = 0,
rowGap = 0,
}, Element._layoutEngineDeps)
self._layoutEngine:initialize(self)
-- Store unit specifications for responsive behavior
self.units = {
width = { value = nil, unit = "px" },
height = { value = nil, unit = "px" },
x = { value = nil, unit = "px" },
y = { value = nil, unit = "px" },
textSize = { value = nil, unit = "px" },
gap = { value = nil, unit = "px" },
flexBasis = { value = nil, unit = "auto" },
padding = {
top = { value = nil, unit = "px" },
right = { value = nil, unit = "px" },
bottom = { value = nil, unit = "px" },
left = { value = nil, unit = "px" },
horizontal = { value = nil, unit = "px" }, -- Shorthand for left/right
vertical = { value = nil, unit = "px" }, -- Shorthand for top/bottom
},
margin = {
top = { value = nil, unit = "px" },
right = { value = nil, unit = "px" },
bottom = { value = nil, unit = "px" },
left = { value = nil, unit = "px" },
horizontal = { value = nil, unit = "px" }, -- Shorthand for left/right
vertical = { value = nil, unit = "px" }, -- Shorthand for top/bottom
},
}
local _, scaleY = Element._Context.getScaleFactors()
-- minTextSize/maxTextSize/autoScaleText are bound by _applyProps (autoScaleText
-- defaults true). They are needed before textSize processing below.
-- Handle fontFamily (can be font name from theme or direct path to font file)
-- Priority: explicit props.fontFamily > parent fontFamily > theme default
if props.fontFamily then
-- Explicitly set fontFamily takes highest priority
self.fontFamily = props.fontFamily
elseif self.parent and self.parent.fontFamily then
-- Inherit from parent if parent has fontFamily set
self.fontFamily = self.parent.fontFamily
elseif props.themeComponent then
-- If using themeComponent, try to get default from theme via ThemeManager
local defaultFont = self._themeManager:getDefaultFontFamily()
self.fontFamily = defaultFont and "default" or nil
else
self.fontFamily = nil
end
-- Handle textSize BEFORE width/height calculation (needed for auto-sizing)
if props.textSize then
if type(props.textSize) == "string" then
-- Check if it's a preset first
local presetValue, presetUnit = Element._utils.resolveTextSizePreset(props.textSize)
local value, unit
if presetValue then
-- It's a preset, use the preset value and unit
value, unit = presetValue, presetUnit
self.units.textSize = { value = value, unit = unit }
else
-- Not a preset, parse normally
value, unit = Element._Units.parse(props.textSize)
self.units.textSize = { value = value, unit = unit }
end
-- Resolve textSize based on unit type
if unit == "%" or unit == "vh" then
-- Percentage and vh are relative to viewport height
self.textSize = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight)
elseif unit == "vw" then
-- vw is relative to viewport width
self.textSize = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth)
elseif unit == "px" then
-- Pixel units
self.textSize = value
else
Element._ErrorHandler:error("Element", "ELEM_002", {
unit = unit,
})
end
else
-- Validate pixel textSize value
if props.textSize <= 0 then
Element._ErrorHandler:error("Element", "ELEM_001", {
value = tostring(props.textSize),
})
end
-- Pixel textSize value
if self.autoScaleText and Element._Context.baseScale then
-- With base scaling: store original pixel value and scale relative to base resolution
self.units.textSize = { value = props.textSize, unit = "px" }
self.textSize = props.textSize * scaleY
elseif self.autoScaleText then
-- Without base scaling: convert to viewport units for auto-scaling
-- Calculate what percentage of viewport height this represents
local vhValue = (props.textSize / viewportHeight) * 100
self.units.textSize = { value = vhValue, unit = "vh" }
self.textSize = props.textSize -- Initial size is the specified pixel value
else
-- No auto-scaling: apply base scaling if set, otherwise use raw value
self.textSize = Element._Context.baseScale and (props.textSize * scaleY) or props.textSize
self.units.textSize = { value = props.textSize, unit = "px" }
end
end
else
-- No textSize specified - use auto-scaling default
if self.autoScaleText and Element._Context.baseScale then
-- With base scaling: use 12px as default and scale
self.units.textSize = { value = 12, unit = "px" }
self.textSize = 12 * scaleY
elseif self.autoScaleText then
-- Without base scaling: default to 1.5vh (1.5% of viewport height)
self.units.textSize = { value = 1.5, unit = "vh" }
self.textSize = (1.5 / 100) * viewportHeight
else
-- No auto-scaling: use 12px with optional base scaling
self.textSize = Element._Context.baseScale and (12 * scaleY) or 12
self.units.textSize = { value = nil, unit = "px" }
end
end
end
--- Phase 6b: width/height/min-max/clamp, gap, flex shorthand/grow/shrink/basis,
--- 9-patch/border-box model, and padding/margin resolution + unit storage.
function Element:_initBoxModel(props)
local viewportWidth, viewportHeight = Element._Units.getViewport()
local scaleX, scaleY = Element._Context.getScaleFactors()
local _ctx = { vw = viewportWidth, vh = viewportHeight, sx = scaleX, sy = scaleY }
-- Handle width (both w and width properties, prefer w if both exist)
-- "auto" is treated as content-sized (same as omitting the property), per CSS semantics.
local widthProp = props.width
if widthProp == "auto" then
widthProp = nil
end
local tempWidth -- Temporary width for padding resolution
if widthProp then
local parentWidth = self.parent and self.parent.width or viewportWidth
tempWidth = _resolveUnit(self, widthProp, "width", parentWidth, _ctx, { scaleAxis = "x" })
else
self.autosizing.width = true
-- Special case: if textWrap is enabled and parent exists, constrain width to parent
-- Text wrapping requires a width constraint, so use parent's content width
if props.textWrap and self.parent and self.parent.width then
tempWidth = self.parent.width
self.width = tempWidth
self.units.width = { value = 100, unit = "%" } -- Mark as parent-constrained
self.autosizing.width = false -- Not truly autosizing, constrained by parent
else
tempWidth = self:calculateAutoWidth()
self.width = tempWidth
self.units.width = { value = nil, unit = "auto" } -- Mark as auto-sized
end
end
-- Handle height (both h and height properties, prefer h if both exist)
-- "auto" is treated as content-sized (same as omitting the property), per CSS semantics.
local heightProp = props.height
if heightProp == "auto" then
heightProp = nil
end
local tempHeight -- Temporary height for padding resolution
if heightProp then
local parentHeight = self.parent and self.parent.height or viewportHeight
tempHeight = _resolveUnit(self, heightProp, "height", parentHeight, _ctx, { scaleAxis = "y" })
else
self.autosizing.height = true
-- Calculate auto-height without padding first
tempHeight = self:calculateAutoHeight()
self.height = tempHeight
self.units.height = { value = nil, unit = "auto" } -- Mark as auto-sized
end
local constraintParentW = self.parent and self.parent.width or viewportWidth
local constraintParentH = self.parent and self.parent.height or viewportHeight
_resolveUnit(self, props.minWidth, "minWidth", constraintParentW, _ctx, { scaleAxis = "x", nullable = true })
_resolveUnit(self, props.maxWidth, "maxWidth", constraintParentW, _ctx, { scaleAxis = "x", nullable = true })
_resolveUnit(self, props.minHeight, "minHeight", constraintParentH, _ctx, { scaleAxis = "y", nullable = true })
_resolveUnit(self, props.maxHeight, "maxHeight", constraintParentH, _ctx, { scaleAxis = "y", nullable = true })
if not self.autosizing.width then
self.width = Element._utils.clamp(tempWidth, self.minWidth, self.maxWidth)
tempWidth = self.width
else
self.width = Element._utils.clamp(self.width, self.minWidth, self.maxWidth)
end
if not self.autosizing.height then
self.height = Element._utils.clamp(tempHeight, self.minHeight, self.maxHeight)
tempHeight = self.height
else
self.height = Element._utils.clamp(self.height, self.minHeight, self.maxHeight)
end
--- child positioning ---
if props.gap then
local flexDir = props.flexDirection or Element._utils.enums.FlexDirection.HORIZONTAL
local isHorizontalDir = flexDir == Element._utils.enums.FlexDirection.HORIZONTAL
or flexDir == Element._utils.enums.FlexDirection.HORIZONTAL_REVERSE
local containerSize = isHorizontalDir and self.width or self.height
_resolveUnit(self, props.gap, "gap", containerSize, _ctx)
else
self.gap = 0
self.units.gap = { value = 0, unit = "px" }
end
-- Handle flex shorthand property (sets flexGrow, flexShrink, flexBasis)
if props.flex ~= nil then
local grow, shrink, basis = Element._Units.parseFlexShorthand(props.flex)
-- Only set individual properties if they weren't explicitly provided
if props.flexGrow == nil then
props.flexGrow = grow
end
if props.flexShrink == nil then
props.flexShrink = shrink
end
if props.flexBasis == nil then
props.flexBasis = basis
end
end
-- Track whether flex-shrink was explicitly provided (directly or via flex shorthand)
self._hasExplicitFlexShrink = props.flexShrink ~= nil
-- Handle flexGrow property
if props.flexGrow ~= nil then
if type(props.flexGrow) == "number" and props.flexGrow >= 0 then
self.flexGrow = props.flexGrow
else
self.flexGrow = _warnFlexInvalid(self, "FLEX_001", "flexGrow must be a non-negative number", props.flexGrow, 0)
end
else
self.flexGrow = 0
end
-- Handle flexShrink property
if props.flexShrink ~= nil then
if type(props.flexShrink) == "number" and props.flexShrink >= 0 then
self.flexShrink = props.flexShrink
else
self.flexShrink =
_warnFlexInvalid(self, "FLEX_002", "flexShrink must be a non-negative number", props.flexShrink, 1)
end
else
self.flexShrink = 1
end
-- Handle flexBasis property
if props.flexBasis ~= nil then
local isCalc = Element._Calc and Element._Calc.isCalc(props.flexBasis)
if props.flexBasis == "auto" then
self.flexBasis = "auto"
self.units.flexBasis = { value = nil, unit = "auto" }
elseif type(props.flexBasis) == "string" or isCalc then
local value, unit = Element._Units.parse(props.flexBasis)
self.units.flexBasis = { value = value, unit = unit }
-- Don't resolve yet - LayoutEngine will handle this during layout
self.flexBasis = props.flexBasis
elseif type(props.flexBasis) == "number" then
self.flexBasis = props.flexBasis
self.units.flexBasis = { value = props.flexBasis, unit = "px" }
else
self.flexBasis =
_warnFlexInvalid(self, "FLEX_003", "flexBasis must be a number, string, or 'auto'", props.flexBasis, "auto")
self.units.flexBasis = { value = nil, unit = "auto" }
end
else
self.flexBasis = "auto"
self.units.flexBasis = { value = nil, unit = "auto" }
end
-- BORDER-BOX MODEL: For auto-sizing, we need to add padding to content dimensions
-- For explicit sizing, width/height already include padding (border-box)
-- Check if we should use 9-patch content padding for auto-sizing
local use9PatchPadding = false
local ninePatchContentPadding = nil
if self._themeManager:hasThemeComponent() then
local component = self._themeManager:getComponent()
if component and component._ninePatchData and component._ninePatchData.contentPadding then
-- Only use 9-patch padding if no explicit padding was provided
if
not props.padding
or (
not props.padding.top
and not props.padding.right
and not props.padding.bottom
and not props.padding.left
and not props.padding.horizontal
and not props.padding.vertical
)
then
use9PatchPadding = true
ninePatchContentPadding = component._ninePatchData.contentPadding
end
end
end
-- First, resolve padding using temporary dimensions
-- For auto-sized elements, this is content width; for explicit sizing, this is border-box width
local tempPadding
if use9PatchPadding then
-- tempWidth/tempHeight are guaranteed numbers by _resolveUnit (which warns +
-- clamps non-numbers) and calculateAutoWidth/Height; the prior defensive
-- re-check duplicated that boundary validation (Task 11).
-- Get scaled 9-patch content padding from ThemeManager
local scaledPadding = self._themeManager:getScaledContentPadding(tempWidth, tempHeight)
if scaledPadding then
tempPadding = scaledPadding
else
-- Fallback if scaling fails
tempPadding = {
left = ninePatchContentPadding.left,
top = ninePatchContentPadding.top,
right = ninePatchContentPadding.right,
bottom = ninePatchContentPadding.bottom,
}
end
else
tempPadding = Element._Units.resolveSpacing(props.padding, self.width, self.height)
end
-- Margin percentages are relative to parent's dimensions (CSS spec)
local parentWidth = self.parent and self.parent.width or viewportWidth
local parentHeight = self.parent and self.parent.height or viewportHeight
self.margin = Element._Units.resolveSpacing(props.margin, parentWidth, parentHeight)
-- For auto-sized elements, add padding to get border-box dimensions
if self.autosizing.width then
self._borderBoxWidth = self.width + tempPadding.left + tempPadding.right
else
-- For explicit sizing, width is already border-box
self._borderBoxWidth = self.width
end
if self.autosizing.height then
self._borderBoxHeight = self.height + tempPadding.top + tempPadding.bottom
else
-- For explicit sizing, height is already border-box
self._borderBoxHeight = self.height
end
-- Set final padding
if use9PatchPadding then
-- Use 9-patch content padding
self.padding = {
left = ninePatchContentPadding.left,
top = ninePatchContentPadding.top,
right = ninePatchContentPadding.right,
bottom = ninePatchContentPadding.bottom,
}
else
-- Re-resolve padding based on final border-box dimensions (important for percentage padding)
self.padding = Element._Units.resolveSpacing(props.padding, self._borderBoxWidth, self._borderBoxHeight)
end
-- Calculate final content dimensions by subtracting padding from border-box
self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right)
self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom)
-- Re-resolve textSize presets now that width/height are set
-- (presets like "vw" need the viewport; others are resolved during constructor)
-- Apply min/max constraints (also scaled)
local minSize = self.minTextSize and (Element._Context.baseScale and (self.minTextSize * scaleY) or self.minTextSize)
local maxSize = self.maxTextSize and (Element._Context.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize)
if minSize and self.textSize < minSize then
self.textSize = minSize
end
if maxSize and self.textSize > maxSize then
self.textSize = maxSize
end
-- Protect against too-small text sizes (minimum 1px)
if self.textSize < 1 then
self.textSize = 1 -- Minimum 1px
end
-- Store original spacing values for proper resize handling
-- Store spacing unit specs (padding + margin share identical structure)
local sides = { "top", "right", "bottom", "left" }
for _, kind in ipairs({ "padding", "margin" }) do
local src = props[kind]
if src then
for _, axis in ipairs({ "horizontal", "vertical" }) do
if src[axis] then
if type(src[axis]) == "string" then
local value, unit = Element._Units.parse(src[axis])
self.units[kind][axis] = { value = value, unit = unit }
else
self.units[kind][axis] = { value = src[axis], unit = "px" }
end
end
end
end
for _, side in ipairs(sides) do
if src and src[side] then
if type(src[side]) == "string" then
local value, unit = Element._Units.parse(src[side])
self.units[kind][side] = { value = value, unit = unit, explicit = true }
else
self.units[kind][side] = { value = src[side], unit = "px", explicit = true }
end
else
self.units[kind][side] = { value = self[kind][side], unit = "px", explicit = false }
end
end
end
-- Grid properties are set later in the constructor
end
--- Phase 7: hereditary positioning (no-parent and with-parent), flex/grid
--- container properties, select-frame adopt, and LayoutEngine config update.
function Element:_initPositioning(props)
local viewportWidth, viewportHeight = Element._Units.getViewport()
local scaleX, scaleY = Element._Context.getScaleFactors()
local _ctx = { vw = viewportWidth, vh = viewportHeight, sx = scaleX, sy = scaleY }
------ add hereditary ------
if props.parent == nil then
table.insert(Element._Context.topElements, self)
-- Handle x position with units
_resolveUnit(self, props.x, "x", viewportWidth, _ctx, { scaleAxis = "x", default = 0 })
-- Handle y position with units
_resolveUnit(self, props.y, "y", viewportHeight, _ctx, { scaleAxis = "y", default = 0 })
self.z = Element._ZIndex.clamp(props.z or 0)
self.tabIndex = props.tabIndex -- nil/0 = document order, >0 = explicit order, -1 = excluded from keyboard nav
-- Set textColor with priority: props > theme text color > black
if props.textColor then
self.textColor = props.textColor
else
-- Try to get text color from theme via ThemeManager
local themeToUse = self._themeManager:getTheme()
if themeToUse and themeToUse.colors and themeToUse.colors.text then
self.textColor = themeToUse.colors.text
else
-- Fallback to black
self.textColor = Element._Color.new(0, 0, 0, 1)
end
end
-- Track if positioning was explicitly set
if props.positioning then
Element._utils.validateEnum(props.positioning, Element._utils.enums.Positioning, "positioning")
self.positioning = props.positioning
self._originalPositioning = props.positioning
self._explicitlyAbsolute = (props.positioning == Element._utils.enums.Positioning.ABSOLUTE)
else
self.positioning = Element._utils.enums.Positioning.RELATIVE
self._originalPositioning = nil -- No explicit positioning
self._explicitlyAbsolute = false
end
-- Handle positioning properties for elements without parent
-- Warn if CSS positioning properties are supplied but will be ignored.
-- Relative elements honor the offsets as visual deltas (see
-- _applyRelativeOffsets); absolute elements use applyPositioningOffsets.
-- Only flex-participating children (positioning coerced to ABSOLUTE but not
-- explicitly absolute) actually drop the offsets and warrant the warning.
if
(props.top or props.bottom or props.left or props.right)
and not self._explicitlyAbsolute
and self.positioning ~= Element._utils.enums.Positioning.RELATIVE
then
_warnCssPositioningWithoutAbsolute(self, props)
end
-- Handle top/right/bottom/left positioning with units
if props.top then
_resolveUnit(self, props.top, "top", viewportHeight, _ctx)
end
if props.right then
_resolveUnit(self, props.right, "right", viewportWidth, _ctx)
end
if props.bottom then
_resolveUnit(self, props.bottom, "bottom", viewportHeight, _ctx)
end
if props.left then
_resolveUnit(self, props.left, "left", viewportWidth, _ctx)
end
-- position: relative offsets are applied as visual deltas in
-- LayoutEngine:layoutChildren (after the flex flow places children), so
-- they survive the addChild -> layoutChildren re-entry here.
else
-- Set positioning first and track if explicitly set
self._originalPositioning = props.positioning -- Track original intent
if props.positioning == Element._utils.enums.Positioning.ABSOLUTE then
self.positioning = Element._utils.enums.Positioning.ABSOLUTE
self._explicitlyAbsolute = true -- Explicitly set to absolute by user
elseif props.positioning == Element._utils.enums.Positioning.FLEX then
self.positioning = Element._utils.enums.Positioning.FLEX
self._explicitlyAbsolute = false
elseif props.positioning == Element._utils.enums.Positioning.GRID then
self.positioning = Element._utils.enums.Positioning.GRID
self._explicitlyAbsolute = false
else
-- Default: children in flex/grid containers participate in parent's layout
-- children in relative/absolute containers default to relative
if
self.parent.positioning == Element._utils.enums.Positioning.FLEX
or self.parent.positioning == Element._utils.enums.Positioning.GRID
then
self.positioning = Element._utils.enums.Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid
self._explicitlyAbsolute = false -- Participate in parent's layout
else
self.positioning = Element._utils.enums.Positioning.RELATIVE
self._explicitlyAbsolute = false -- Default for relative/absolute containers
end
end
-- Set initial position
local parentPadding = self.parent.padding or { left = 0, top = 0 }
if self.positioning == Element._utils.enums.Positioning.ABSOLUTE then
-- Absolute positioning is relative to parent's content area (padding box)
local baseX = self.parent.x + parentPadding.left
local baseY = self.parent.y + parentPadding.top
-- Handle x/y position with units
_resolveUnit(self, props.x, "x", self.parent.width, _ctx, { scaleAxis = "x", offset = baseX, default = 0 })
_resolveUnit(self, props.y, "y", self.parent.height, _ctx, { scaleAxis = "y", offset = baseY, default = 0 })
self.z = Element._ZIndex.clamp(props.z or 0)
self.tabIndex = props.tabIndex
else
-- Children in flex containers start at parent position but will be repositioned by layoutChildren
-- Children in absolute/relative containers start at parent's content area (accounting for padding)
local baseX = self.parent.x + parentPadding.left
local baseY = self.parent.y + parentPadding.top
-- Warn if explicit x/y is set on a child that will be positioned by flex layout
-- This position will be overridden unless the child has positioning="absolute"
local parentWillUseFlex = self.parent.positioning ~= "grid"
local childIsRelative = self.positioning ~= "absolute" or not self._explicitlyAbsolute
if parentWillUseFlex and childIsRelative and (props.x or props.y) then
Element._ErrorHandler:warn("Element", "LAY_008", {
element = self.id or "unnamed",
parent = self.parent.id or "unnamed",
properties = (props.x and props.y) and "x, y" or (props.x and "x" or "y"),
})
end
_resolveUnit(self, props.x, "x", self.parent.width, _ctx, { scaleAxis = "x", offset = baseX, default = 0 })
_resolveUnit(self, props.y, "y", self.parent.height, _ctx, { scaleAxis = "y", offset = baseY, default = 0 })
self.z = Element._ZIndex.clamp(props.z or self.parent.z or 0)
self.tabIndex = props.tabIndex
end
if props.textColor then
self.textColor = props.textColor
elseif self.parent.textColor then
self.textColor = self.parent.textColor
else
local themeToUse = self._themeManager:getTheme()
if themeToUse and themeToUse.colors and themeToUse.colors.text then
self.textColor = themeToUse.colors.text
else
-- Fallback to black
self.textColor = Element._Color.new(0, 0, 0, 1)
end
end
-- Handle positioning properties BEFORE adding to parent (so they're available during layout)
-- Warn if CSS positioning properties are supplied but will be ignored.
-- Relative elements honor the offsets as visual deltas (see
-- _applyRelativeOffsets); absolute elements use applyPositioningOffsets.
-- Only flex-participating children (positioning coerced to ABSOLUTE but not
-- explicitly absolute) actually drop the offsets and warrant the warning.
if
(props.top or props.bottom or props.left or props.right)
and not self._explicitlyAbsolute
and self.positioning ~= Element._utils.enums.Positioning.RELATIVE
then
_warnCssPositioningWithoutAbsolute(self, props)
end
-- Handle top/right/bottom/left positioning with units
if props.top then
_resolveUnit(self, props.top, "top", viewportHeight, _ctx)
end
if props.right then
_resolveUnit(self, props.right, "right", viewportWidth, _ctx)
end
if props.bottom then
_resolveUnit(self, props.bottom, "bottom", viewportHeight, _ctx)
end
if props.left then
_resolveUnit(self, props.left, "left", viewportWidth, _ctx)
end
-- position: relative offsets are applied as visual deltas in
-- LayoutEngine:layoutChildren (after the flex flow places children), so
-- they survive the addChild -> layoutChildren re-entry here.
props.parent:addChild(self)
end
if self.positioning == Element._utils.enums.Positioning.FLEX then
-- Validate enum properties
if props.flexDirection then
Element._utils.validateEnum(props.flexDirection, Element._utils.enums.FlexDirection, "flexDirection")
end
if props.flexWrap then
Element._utils.validateEnum(props.flexWrap, Element._utils.enums.FlexWrap, "flexWrap")
end
if props.justifyContent then
Element._utils.validateEnum(props.justifyContent, Element._utils.enums.JustifyContent, "justifyContent")
end
if props.alignItems then
Element._utils.validateEnum(props.alignItems, Element._utils.enums.AlignItems, "alignItems")
end
if props.alignContent then
Element._utils.validateEnum(props.alignContent, Element._utils.enums.AlignContent, "alignContent")
end
if props.justifySelf then
Element._utils.validateEnum(props.justifySelf, Element._utils.enums.JustifySelf, "justifySelf")
end
-- Warn if grid properties are set with flex positioning
if props.gridRows or props.gridColumns then
Element._ErrorHandler:warn("Element", "LAY_010", {
element = self.id or "unnamed",
positioning = "flex",
properties = "gridRows/gridColumns",
})
end
self.flexDirection = props.flexDirection or Element._utils.enums.FlexDirection.HORIZONTAL
self.flexWrap = props.flexWrap or Element._utils.enums.FlexWrap.NOWRAP
self.justifyContent = props.justifyContent or Element._utils.enums.JustifyContent.FLEX_START
self.alignItems = props.alignItems or Element._utils.enums.AlignItems.STRETCH
self.alignContent = props.alignContent or Element._utils.enums.AlignContent.STRETCH
self.justifySelf = props.justifySelf or Element._utils.enums.JustifySelf.AUTO
end
-- Grid container properties
if self.positioning == Element._utils.enums.Positioning.GRID then
-- Warn if flex properties are set with grid positioning
if props.flexDirection or props.flexWrap or props.justifyContent then
Element._ErrorHandler:warn("Element", "LAY_009", {
element = self.id or "unnamed",
positioning = "grid",
properties = "flexDirection/flexWrap/justifyContent",
})
end
self.gridRows = props.gridRows
self.gridColumns = props.gridColumns
self.alignItems = props.alignItems or Element._utils.enums.AlignItems.STRETCH
-- Handle columnGap and rowGap
_resolveUnit(self, props.columnGap, "columnGap", self.width, _ctx, { default = 0 })
_resolveUnit(self, props.rowGap, "rowGap", self.height, _ctx, { default = 0 })
end
-- alignSelf is bound by _applyProps (default "auto").
-- Update the LayoutEngine with actual layout properties
-- (it was initialized early with defaults for auto-sizing calculations)
self._layoutEngine.positioning = self.positioning
if self.flexDirection then
self._layoutEngine.flexDirection = self.flexDirection
end
if self.flexWrap then
self._layoutEngine.flexWrap = self.flexWrap
end
if self.justifyContent then
self._layoutEngine.justifyContent = self.justifyContent
end
if self.alignItems then
self._layoutEngine.alignItems = self.alignItems
end
if self.alignContent then
self._layoutEngine.alignContent = self.alignContent
end
if self.gap then
self._layoutEngine.gap = self.gap
end
if self.gridRows then
self._layoutEngine.gridRows = self.gridRows
end
if self.gridColumns then
self._layoutEngine.gridColumns = self.gridColumns
end
if self.columnGap then
self._layoutEngine.columnGap = self.columnGap
end
if self.rowGap then
self._layoutEngine.rowGap = self.rowGap
end
-- transform is bound by _applyProps; transition is bound by _applyProps (default {}).
-- (Previously set inline here; both are now registry-driven.)
end
--- Phase 8 (ScrollManager instantiation + immediate-mode scrollbar restore) is
--- owned by the Scrollable behavior (modules/behaviors/Scrollable.lua), attached
--- in _attachBehaviors. There is no longer an Element phase for this — the
--- behavior onAttach hook IS the phase (behavior-mode-unification task 03/08).
--- `overflow` / `overflowX` / `overflowY` are bound onto the element as plain
--- fields by bindThemeAndFields/`_applyProps` so that `Element:addChild`'s
--- scroll-container auto-size guard sees them during declarative-children
--- processing in _finalizeConstruction (which runs before _attachBehaviors);
--- Scrollable.onAttach then overwrites them with the ScrollManager's normalized
--- values, matching the legacy field-exposure order.
--- Phase 9: immediate-mode registration, dirty flags, debug draw color,
--- declarative children tree, onCreate callback, and constructed flag.
function Element:_finalizeConstruction(props)
-- Register element in z-index tracking. registerElement is a mode-aware
-- no-op outside immediate mode, so no mode check is needed here
-- (behavior-mode-unification task 11).
Element._Context.registerElement(self)
-- Performance optimization: dirty flags for layout tracking
-- These flags help skip unnecessary layout recalculations
self._dirty = false -- Element properties have changed, needs layout
self._childrenDirty = false -- Children have changed, needs layout
-- Debug draw: assign a deterministic color for element boundary visualization
-- Uses a hash of the element ID to produce a stable hue, so colors don't flash each frame
local function hashStringToHue(str)
local hash = 5381
for i = 1, #str do
hash = ((hash * 33) + string.byte(str, i)) % 360
end
return hash
end
local hue = hashStringToHue(self.id or tostring(self))
local function hslToRgb(h)
local s, l = 0.9, 0.55
local c = (1 - math.abs(2 * l - 1)) * s
local x = c * (1 - math.abs((h / 60) % 2 - 1))
local m = l - c / 2
local r, g, b
if h < 60 then
r, g, b = c, x, 0
elseif h < 120 then
r, g, b = x, c, 0
elseif h < 180 then
r, g, b = 0, c, x
elseif h < 240 then
r, g, b = 0, x, c
elseif h < 300 then
r, g, b = x, 0, c
else
r, g, b = c, 0, x
end
return r + m, g + m, b + m
end
local dr, dg, db = hslToRgb(hue)
self._debugColor = { dr, dg, db }
-- Process declarative children prop: build child tree from property tables
-- Placed after all self properties are initialized so children can safely access parent state
if props.children then
if type(props.children) ~= "table" then
_warnChildrenInvalid(self, "ELEM_010", "children must be a table array", props.children)
else
for i = 1, #props.children do
local childProps = props.children[i]
if childProps == nil then
_warnChildrenInvalid(self, "ELEM_011", "nil entry in children array, skipping", nil)
elseif type(childProps) ~= "table" then
_warnChildrenInvalid(self, "ELEM_012", "non-table entry in children array, skipping", childProps)
else
local childCopy = {}
for k, v in pairs(childProps) do
childCopy[k] = v
end
childCopy.parent = self
local child = Element.new(childCopy)
-- Set up state management for declarative children so mutations
-- made in event callbacks persist across frames. Mode-aware via
-- StateManager.isImmediateMode (behavior-mode-unification task 11):
-- this whole block is immediate-mode-only frame bookkeeping.
if Element._StateManager.isImmediateMode() then
if not child.id or child.id == "" then
child.id = Element._StateManager.generateID(childCopy, self)
end
local childState = Element._StateManager.getState(child.id, {})
Element._StateManager.markStateUsed(child.id)
child:restoreState(childState)
child._stateId = child.id
-- Restore theme state from event handler state
if child.themeComponent then
local eventState = childState.eventHandler or {}
if child.disabled or eventState.disabled then
child._themeState = "disabled"
elseif child.active or eventState.active then
child._themeState = "active"
elseif eventState._pressed and next(eventState._pressed) then
child._themeState = "pressed"
elseif eventState._hovered then
child._themeState = "hover"
else
child._themeState = "normal"
end
end
-- Add to current frame elements for saveState tracking
if Element._Context._currentFrameElements then
table.insert(Element._Context._currentFrameElements, child)
end
end
end
end
end
end
-- Fire onCreate callback if provided
if self.onCreate then
if self.onCreateDeferred then
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
self.onCreate(self, props)
end)
else
self.onCreate(self, props)
end
else
self.onCreate(self, props)
end
end
-- Mark element as fully constructed.
-- NOTE: no longer gates an __newindex dimension warning (removed — see comment
-- at top of file). Retained lazily in case future write-interception is added.
self._constructed = true
end
--- Retrieve the element's screen-space rectangle for collision detection and positioning calculations
--- Use this for custom layout logic, tooltips, or detecting overlaps between elements
---@return { x:number, y:number, width:number, height:number }
function Element:getBounds()
return { x = self.x, y = self.y, width = self:getBorderBoxWidth(), height = self:getBorderBoxHeight() }
end
--- Test if a screen coordinate falls within the element's clickable area
--- Use this for custom hit detection or determining which element the mouse is over
--- @param x number
--- @param y number
--- @return boolean
function Element:contains(x, y)
local bounds = self:getBounds()
return bounds.x <= x and bounds.y <= y and bounds.x + bounds.width >= x and bounds.y + bounds.height >= y
end
--- Get the element's total width including padding for layout calculations
--- Use this when you need the full visual width rather than just content width
---@return number
function Element:getBorderBoxWidth()
return self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
end
--- Get the element's total height including padding for layout calculations
--- Use this when you need the full visual height rather than just content height
---@return number
function Element:getBorderBoxHeight()
return self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
end
--- Get computed box dimensions (content area position and size)
--- Returns the position and size of the content area (inside padding)
---@return {x: number, y: number, width: number, height: number}
function Element:getComputedBox()
return {
x = self.x + self.padding.left,
y = self.y + self.padding.top,
width = self.width,
height = self.height,
}
end
--- Mark this element and its ancestors as dirty, requiring layout recalculation
--- Call this when element properties change that affect layout
function Element:invalidateLayout()
self._dirty = true
-- Invalidate dimension caches
self._borderBoxWidthCache = nil
self._borderBoxHeightCache = nil
-- Mark parent as having dirty children
if self.parent then
self.parent._childrenDirty = true
-- Propagate up the tree (parents need to know their descendants changed)
local ancestor = self.parent
while ancestor do
ancestor._childrenDirty = true
ancestor = ancestor.parent
end
end
end
-- Scroll / scrollbar methods (_syncScrollManagerState, _detectOverflow, setScrollPosition,
-- _calculateScrollbarDimensions, _getScrollbarAtPosition, _handleScrollbarPress/Drag/Release,
-- _handleWheelScroll, getScrollPosition, getMaxScroll, getScrollPercentage, hasOverflow,
-- getContentSize, scrollBy, scrollToTop) are bound to ScrollManager in Element.init. ScrollManager
-- owns all scrollbar interaction logic; Element retains only 1-line delegates (see ScrollManager.lua).
--- Mark a method for deferred retry during the update phase.
--- Methods that depend on layout calculations (e.g., scroll, sizing)
--- can defer themselves when preconditions aren't met. They'll be
--- retried automatically each frame in update() until they succeed.
---@param methodName string The method name to retry
---@param ... any? Arguments to forward on retry
function Element:_deferMethod(methodName, ...)
if type(self[methodName]) ~= "function" then
Element._ErrorHandler:warn("Element", "CORE_005", {
element = self.id,
method = tostring(methodName),
})
return
end
if #self._deferredMethods >= MAX_DEFERRED_METHODS then
Element._ErrorHandler:warn("Element", "CORE_004", {
element = self.id,
method = tostring(methodName),
retryCount = MAX_DEFERRED_METHODS,
})
return
end
local argc = select("#", ...)
local args = {}
for i = 1, argc do
local val = select(i, ...)
args[i] = val == nil and _DEFERRED_NIL or val
end
table.insert(self._deferredMethods, {
methodName = methodName,
args = args,
argc = argc,
retryCount = 0,
})
end
-- Deferred image loading is owned by the Imageable behavior
-- (modules/behaviors/Imageable.lua). Imageable.onAttach installs an instance
-- closure on `element._loadImage` and defers it via _deferMethod; the deferred-
-- method dispatcher (which resolves `self[methodName]`) invokes that closure.
-- Element no longer owns the load logic itself and has zero image-branch logic.
-- (behavior-mode-unification task 07)
-- scrollToBottom / scrollToLeft / scrollToRight are bound to ScrollManager in Element.init.
--- Get the current state's scaled content padding
--- Returns the contentPadding for the current theme state, scaled to the element's size
---@return table|nil -- {left, top, right, bottom} or nil if no contentPadding
function Element:getScaledContentPadding()
local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
return self._themeManager:getScaledContentPadding(borderBoxWidth, borderBoxHeight)
end
--- Get draw-time content offset from state-specific theme padding changes
---@return number offsetX, number offsetY
function Element:getContentStateOffset()
local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
local currentPadding = self:getScaledContentPadding()
local basePadding = self._themeManager:_getScaledContentPaddingForState("normal", borderBoxWidth, borderBoxHeight)
if not currentPadding or not basePadding then
return 0, 0
end
local offsetX = currentPadding.left - basePadding.left
local offsetY = currentPadding.top - basePadding.top
if math.abs(offsetX) < 0.001 then
offsetX = 0
end
if math.abs(offsetY) < 0.001 then
offsetY = 0
end
return offsetX, offsetY
end
--- Get or create blur instance for this element
---@return table? -- Blur instance or nil if no blur configured
function Element:getBlurInstance()
-- Determine quality from contentBlur or backdropBlur
local quality = 5 -- Default quality
if self.contentBlur and self.contentBlur.quality then
quality = self.contentBlur.quality
elseif self.backdropBlur and self.backdropBlur.quality then
quality = self.backdropBlur.quality
end
-- Create blur instance if needed
if not self._blurInstance or self._blurInstance.quality ~= quality then
self._blurInstance = Element._Blur.new({ quality = quality })
end
return self._blurInstance
end
--- Get available content width for children (accounting for 9-patch content padding)
--- This is the width that children should use when calculating percentage widths
---@return number
function Element:getAvailableContentWidth()
local availableWidth = self.width
local scaledContentPadding = self:getScaledContentPadding()
if scaledContentPadding then
-- Check if the element is using the scaled 9-patch contentPadding as its padding
-- Allow small floating point differences (within 0.1 pixels)
local usingContentPaddingAsPadding = (
math.abs(self.padding.left - scaledContentPadding.left) < 0.1
and math.abs(self.padding.right - scaledContentPadding.right) < 0.1
)
if not usingContentPaddingAsPadding then
-- Element has explicit padding different from contentPadding
-- Subtract scaled contentPadding to get the area children should use
availableWidth = availableWidth - scaledContentPadding.left - scaledContentPadding.right
end
end
return math.max(0, availableWidth)
end
--- Get available content height for children (accounting for 9-patch content padding)
--- This is the height that children should use when calculating percentage heights
---@return number
function Element:getAvailableContentHeight()
local availableHeight = self.height
local scaledContentPadding = self:getScaledContentPadding()
if scaledContentPadding then
-- Check if the element is using the scaled 9-patch contentPadding as its padding
-- Allow small floating point differences (within 0.1 pixels)
local usingContentPaddingAsPadding = (
math.abs(self.padding.top - scaledContentPadding.top) < 0.1
and math.abs(self.padding.bottom - scaledContentPadding.bottom) < 0.1
)
if not usingContentPaddingAsPadding then
-- Element has explicit padding different from contentPadding
-- Subtract scaled contentPadding to get the area children should use
availableHeight = availableHeight - scaledContentPadding.top - scaledContentPadding.bottom
end
end
return math.max(0, availableHeight)
end
function Element:openSelect()
Element._Select.openSelect(self)
end
function Element:closeSelect()
Element._Select.closeSelect(self)
end
function Element:toggleSelect()
Element._Select.toggleSelect(self)
end
---@return boolean
function Element:isSelectOpen()
return Element._Select.isSelectOpen(self)
end
---@return any
function Element:getSelectValue()
return Element._Select.getSelectValue(self)
end
---@return string?
function Element:getSelectLabel()
return Element._Select.getSelectLabel(self)
end
---@return boolean
function Element:isSelectedSelectOption()
return Element._Select.isSelectedOption(self)
end
---@param value any
---@param optionElement Element?
function Element:setSelectValue(value, optionElement)
Element._Select.setSelectValue(self, value, optionElement)
end
function Element:_handleSelectRelease()
Element._Select.handleRelease(self)
end
--- Dynamically insert a child element into the hierarchy for runtime UI construction
--- Use this to build interfaces procedurally or add elements based on application state
---@param child Element
function Element:addChild(child)
if self._managedSelectFrame and child.selectOption and self._managedSelectOwner then
child._selectParentHint = self._managedSelectOwner
end
child.parent = self
-- Re-evaluate positioning now that we have a parent
-- If child was created without explicit positioning, inherit from parent
if child._originalPositioning == nil then
-- No explicit positioning was set during construction
if
self.positioning == Element._utils.enums.Positioning.FLEX
or self.positioning == Element._utils.enums.Positioning.GRID
then
child.positioning = Element._utils.enums.Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid
child._explicitlyAbsolute = false -- Participate in parent's layout
else
child.positioning = Element._utils.enums.Positioning.RELATIVE
child._explicitlyAbsolute = false -- Default for relative/absolute containers
end
end
-- If child._originalPositioning is set, it means explicit positioning was provided
-- and _explicitlyAbsolute was already set correctly during construction
table.insert(self.children, child)
Element._Select.registerWithSelectParent(child)
-- Mark parent as having dirty children to trigger layout recalculation
self._childrenDirty = true
-- Only recalculate auto-sizing if the child participates in layout
-- (CSS: absolutely positioned children don't affect parent auto-sizing)
if not child._explicitlyAbsolute then
local sizeChanged = false
local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY or self.overflow
local isScrollContainer = overflowX == "scroll"
or overflowX == "auto"
or overflowY == "scroll"
or overflowY == "auto"
if self.autosizing.height and not isScrollContainer then
local oldHeight = self.height
local contentHeight = self:calculateAutoHeight()
-- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content
self._borderBoxHeight = contentHeight + self.padding.top + self.padding.bottom
self.height = contentHeight
if oldHeight ~= self.height then
sizeChanged = true
end
end
if self.autosizing.width and not isScrollContainer then
local oldWidth = self.width
local contentWidth = self:calculateAutoWidth()
-- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content
self._borderBoxWidth = contentWidth + self.padding.left + self.padding.right
self.width = contentWidth
if oldWidth ~= self.width then
sizeChanged = true
end
end
-- Propagate size change up the tree
if sizeChanged and self.parent and (self.parent.autosizing.width or self.parent.autosizing.height) then
-- Trigger parent to recalculate its size by re-adding this child's contribution
-- This ensures grandparents are notified of size changes
if self.parent.autosizing.height then
local contentHeight = self.parent:calculateAutoHeight()
self.parent._borderBoxHeight = contentHeight + self.parent.padding.top + self.parent.padding.bottom
self.parent.height = contentHeight
end
if self.parent.autosizing.width then
local contentWidth = self.parent:calculateAutoWidth()
self.parent._borderBoxWidth = contentWidth + self.parent.padding.left + self.parent.padding.right
self.parent.width = contentWidth
end
end
end
-- Layout is deferred to FlexLove.endFrame in immediate mode (all elements
-- for the frame must exist before layout). shouldLayout() encapsulates the
-- mode check (behavior-mode-unification task 11).
if Element._StateManager.shouldLayout() then
self:layoutChildren()
end
if
self._selectState
and self._selectState.selectFrame
and child.selectOption
and child ~= self._selectState.selectFrame
then
Element._Select.attachOptionToManagedFrame(child)
end
end
--- Remove a child element from the hierarchy to dynamically update UIs
--- Use this to delete elements when they're no longer needed or respond to user actions
---@param child Element
function Element:removeChild(child)
for i, c in ipairs(self.children) do
if c == child then
Element._Select.handleChildRemoved(self, child)
Element._Select.unregisterFromSelectParent(child)
table.remove(self.children, i)
child.parent = nil
-- Recalculate auto-sizing if needed
if self.autosizing.width or self.autosizing.height then
if self.autosizing.width then
local contentWidth = self:calculateAutoWidth()
self._borderBoxWidth = contentWidth + self.padding.left + self.padding.right
self.width = contentWidth
end
if self.autosizing.height then
local contentHeight = self:calculateAutoHeight()
self._borderBoxHeight = contentHeight + self.padding.top + self.padding.bottom
self.height = contentHeight
end
end
-- Re-layout children after removal (deferred in immediate mode).
if Element._StateManager.shouldLayout() then
self:layoutChildren()
end
break
end
end
end
--- Reparent this element to a new parent, properly detaching from the current location
--- and inserting into the new parent's children hierarchy with correct layout and alignment.
--- If newParent is nil, the element becomes a top-level element.
--- Works whether the element was originally created with or without a parent.
---@param newParent Element?
function Element:setParent(newParent)
local expectedManagedSelectParent = nil
if self._managedSelectFrame and self._managedSelectOwner then
expectedManagedSelectParent = self._managedSelectOwner
if self._managedSelectOwner._selectState and self._managedSelectOwner._selectState.selectAnchor then
expectedManagedSelectParent = self._managedSelectOwner._selectState.selectAnchor
end
end
if self._managedSelectFrame and self._managedSelectOwner and newParent ~= expectedManagedSelectParent then
Element._Select.warnSelectFrame(self._managedSelectOwner, "ELEM_009", {
element = self._managedSelectOwner.id,
frame = self.id,
expectedParent = expectedManagedSelectParent and expectedManagedSelectParent.id or nil,
actualParent = newParent and newParent.id or nil,
})
end
if self.parent == newParent then
return -- Already at this parent, no-op
end
-- Remove from current location
if self.parent then
-- removeChild sets child.parent = nil and recalculates parent layout
self.parent:removeChild(self)
else
-- Remove from topElements (element was created without a parent)
for i, elem in ipairs(Element._Context.topElements) do
if elem == self then
table.remove(Element._Context.topElements, i)
break
end
end
self.parent = nil
end
if newParent then
-- addChild handles: setting self.parent, re-evaluating positioning,
-- inserting into children, marking dirty, auto-sizing, and layoutChildren
newParent:addChild(self)
else
-- Become a top-level element
self.parent = nil
self.x = self.x or 0
self.y = self.y or 0
self.z = Element._ZIndex.clamp(self.z or 0)
table.insert(Element._Context.topElements, self)
end
end
--- Delete all child elements at once for resetting containers or clearing lists
--- Use this to efficiently empty containers when rebuilding UI from scratch
function Element:clearChildren()
-- Clear parent references for all children
for _, child in ipairs(self.children) do
Element._Select.unregisterFromSelectParent(child)
child.parent = nil
end
-- Clear the children table
self.children = {}
-- Recalculate auto-sizing if needed
if self.autosizing.width or self.autosizing.height then
if self.autosizing.width then
local contentWidth = self:calculateAutoWidth()
self._borderBoxWidth = contentWidth + self.padding.left + self.padding.right
self.width = contentWidth
end
if self.autosizing.height then
local contentHeight = self:calculateAutoHeight()
self._borderBoxHeight = contentHeight + self.padding.top + self.padding.bottom
self.height = contentHeight
end
end
-- Re-layout (though there are no children now; deferred in immediate mode).
if Element._StateManager.shouldLayout() then
self:layoutChildren()
end
end
--- Get the number of children this element has
---@return number
function Element:getChildCount()
return #self.children
end
--- Apply positioning offsets (top, right, bottom, left) to an element
-- @param element The element to apply offsets to
function Element:applyPositioningOffsets(element)
-- Delegate to LayoutEngine
self._layoutEngine:applyPositioningOffsets(element)
end
function Element:layoutChildren()
-- Check performance warnings (only on root elements to avoid spam)
if not self.parent then
self:_checkPerformanceWarnings()
end
-- Catch stale bare dimension writes that bypassed setProperty (e.g.
-- `element.width = "42%"` stores a raw string). Lua __newindex cannot intercept
-- these at write time (the keys exist post-construction), so we validate lazily
-- here, once per element per property, only when a reflow is already pending.
if self._dirty then
self:_checkDimensionTypes()
end
-- Delegate layout to LayoutEngine
self._layoutEngine:layoutChildren()
end
--- Warn once per stale dimension property that holds a non-number value, which
--- indicates a bare write (e.g. `element.width = "42%"`) bypassed setProperty.
--- Bare dimension writes neither resolve unit strings nor invalidate layout;
--- the element renders with the wrong size until :setProperty() is used.
function Element:_checkDimensionTypes()
if not self._dimWarned then
self._dimWarned = {}
end
for _, prop in ipairs({ "width", "height", "x", "y" }) do
local v = self[prop]
if v ~= nil and type(v) ~= "number" then
if not self._dimWarned[prop] then
self._dimWarned[prop] = true
Element._ErrorHandler:warn("Element", "ELM_001", {
property = prop,
message = string.format(
'element.%s holds a non-number value (%s); a bare write bypassed setProperty and was not resolved to pixels. Use element:setProperty("%s", value) instead.',
prop,
type(v),
prop
),
})
end
end
end
end
--- Warn about percentage sizing with auto-sizing parent
---@param child Element
---@param axis string "width" or "height"
function Element:_warnIfPercentageWithAutoSizing(child, axis)
if self._managedSelectFrame then
return
end
Element._ErrorHandler:warn("LayoutEngine", "LAY_004", {
child = child.id or "unnamed",
issue = "percentage " .. axis .. " with parent auto-sizing",
})
end
--- Whether element needs cross-axis percentage dimension syncing
--- Managed select frames sync percentage children with container dimensions
---@return boolean
function Element:_shouldSyncPercentageDimensions()
return self._managedSelectFrame == true
end
--- Adjust cross-axis percentage width for managed select minimum
---@param child Element
---@param newBorderBoxWidth number
---@return number
function Element:_adjustCrossAxisPercentageWidth(child, newBorderBoxWidth)
if self._managedSelectFrame and self.autosizing and self.autosizing.width then
local intrinsicBorderBoxWidth = child:calculateAutoWidth() + child.padding.left + child.padding.right
return math.max(newBorderBoxWidth, intrinsicBorderBoxWidth)
end
return newBorderBoxWidth
end
--- Layout-path delegate: adjust child border-box width for a managed-select frame.
--- Owned by Select; routed through here so the layout path stays free of dropdown details.
---@param child Element
---@param childBorderBoxWidth number
---@return number
function Element:_adjustAutoWidthChildBorderBoxForManagedSelect(child, childBorderBoxWidth)
return Element._Select.adjustAutoWidthChild(self, child, childBorderBoxWidth)
end
--- Destroy element and its children
function Element:destroy()
-- Remove from global elements list
for i, win in ipairs(Element._Context.topElements) do
if win == self then
table.remove(Element._Context.topElements, i)
break
end
end
if self.parent then
for i, child in ipairs(self.parent.children) do
if child == self then
Element._Select.unregisterFromSelectParent(self)
table.remove(self.parent.children, i)
break
end
end
self.parent = nil
end
-- Destroy all children
for _, child in ipairs(self.children) do
child:destroy()
end
-- Clear children table
self.children = {}
-- Clear parent reference
if self.parent then
self.parent = nil
end
-- Clear animation reference
self.animation = nil
-- Clear onEvent to prevent closure leaks
self.onEvent = nil
-- Clear touch callbacks to prevent closure leaks
self.onTouchEvent = nil
self.onGesture = nil
Element._Select.cleanupDestroy(self)
end
--- Retry deferred methods queued via `_deferMethod` during this frame. Each
--- pending entry is invoked through pcall; failures are reported to the
--- ErrorHandler instead of aborting the frame, and entries that re-defer are
--- retried next frame with an incremented retry count up to MAX_DEFER_RETRIES.
--- Extracted from the tail of Element:update so update stays a thin
--- behavior-dispatch orchestrator (behavior-mode-unification task 09).
function Element:_processDeferredMethods()
if #self._deferredMethods == 0 then
return
end
local pending = self._deferredMethods
self._deferredMethods = {}
for _, entry in ipairs(pending) do
if entry.retryCount >= MAX_DEFER_RETRIES then
Element._ErrorHandler:warn("Element", "CORE_004", {
element = self.id,
method = tostring(entry.methodName),
retryCount = entry.retryCount,
})
else
local beforeCount = #self._deferredMethods
local callArgs = {}
for j = 1, entry.argc do
local val = entry.args[j]
if val == _DEFERRED_NIL then
callArgs[j] = nil
else
callArgs[j] = val
end
end
local success, err = pcall(function()
self[entry.methodName](self, unpack(callArgs, 1, entry.argc))
end)
if not success then
Element._ErrorHandler:warn("Element", "CORE_002", {
element = self.id,
method = tostring(entry.methodName),
error = tostring(err),
})
end
-- Propagate retry count to any new deferred entry for the same method
for i = beforeCount + 1, #self._deferredMethods do
if self._deferredMethods[i].methodName == entry.methodName then
self._deferredMethods[i].retryCount = entry.retryCount + 1
end
end
end
end
end
--- Draw element and its children
function Element:draw(backdropCanvas)
-- Early exit if element is display:none or invisible (optimization)
if self.display == false or self.opacity <= 0 or self.visibility == "hidden" then
return
end
-- Background behaviors (drawLayer ~= "overlay") render BEFORE children in
-- registry order: Themed (core Renderer:draw), Clickable (pressed overlay),
-- ... Overlay behaviors (Scrollable scrollbars) render AFTER children below.
local drawCtx = { backdropCanvas = backdropCanvas }
local behaviors = self.behaviors
for i = 1, #behaviors do
local b = behaviors[i]
if b.drawLayer ~= "overlay" then
b.onDraw(self, drawCtx)
end
end
-- Core child hierarchy rendering (clipping, sorting, scroll offset, blur).
-- Stays in Element: it is structural, not a per-capability behavior.
self:_drawChildren(backdropCanvas)
-- Overlay behaviors (drawLayer == "overlay") render AFTER children so they
-- paint on top, e.g. Scrollable's scrollbars (behavior-mode-unification 09).
for i = 1, #behaviors do
local b = behaviors[i]
if b.drawLayer == "overlay" then
b.onDraw(self, drawCtx)
end
end
end
--- Core child-drawing pipeline extracted from Element:draw so the draw entry
--- point stays a thin behavior-dispatch orchestrator (task 09). Owns z-sort,
--- rounded-corner/overflow clipping (stencil > scissor), scroll/content offset,
--- optional content-blur application, and recursive child:draw. Not a behavior
--- — this is structural hierarchy rendering shared by every element.
function Element:_drawChildren(backdropCanvas)
local sortedChildren = {}
for _, child in ipairs(self.children) do
table.insert(sortedChildren, child)
end
if #sortedChildren == 0 then
return
end
table.sort(sortedChildren, function(a, b)
return a.z < b.z
end)
local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
-- Check if we need to clip children to rounded corners
local hasRoundedCorners = false
if self.cornerRadius then
if type(self.cornerRadius) == "number" then
hasRoundedCorners = self.cornerRadius > 0
else
hasRoundedCorners = self.cornerRadius.topLeft > 0
or self.cornerRadius.topRight > 0
or self.cornerRadius.bottomLeft > 0
or self.cornerRadius.bottomRight > 0
end
end
-- Render the (possibly clipped + offset) child layer, applying content blur
-- when configured. The inner closure performs clipping/offset/draw; blur
-- wraps it in a region pass when a blur instance is available.
local function renderChildLayer()
local contentOffsetX, contentOffsetY = self:getContentStateOffset()
-- Determine overflow behavior per axis (matches HTML/CSS behavior)
local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY or self.overflow
local needsOverflowClipping = (overflowX ~= "visible" or overflowY ~= "visible")
and (overflowX ~= nil or overflowY ~= nil)
-- Apply scroll/content offset after clipping is set
local hasScrollOffset = needsOverflowClipping and (self._scrollX ~= 0 or self._scrollY ~= 0)
local hasContentOffset = contentOffsetX ~= 0 or contentOffsetY ~= 0
local hasOffset = hasScrollOffset or hasContentOffset
-- Set up clipping: rounded-corners (stencil) > overflow (scissor) > none
local clipMode = "none"
if hasRoundedCorners then
local roundedBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
local roundedBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
local stencilFunc =
Element._RoundedRect.stencilFunction(self.x, self.y, roundedBoxWidth, roundedBoxHeight, self.cornerRadius)
local currentCanvas = love.graphics.getCanvas()
love.graphics.setCanvas()
love.graphics.stencil(stencilFunc, "replace", 1)
love.graphics.setCanvas(currentCanvas)
love.graphics.setStencilTest("greater", 0)
clipMode = "stencil"
elseif needsOverflowClipping then
love.graphics.setScissor(self.x + self.padding.left, self.y + self.padding.top, self.width, self.height)
clipMode = "scissor"
end
if hasOffset then
love.graphics.push()
love.graphics.translate(
(hasScrollOffset and -self._scrollX or 0) + contentOffsetX,
(hasScrollOffset and -self._scrollY or 0) + contentOffsetY
)
end
for _, child in ipairs(sortedChildren) do
child:draw(backdropCanvas)
end
if hasOffset then
love.graphics.pop()
end
-- Restore clipping state
if clipMode == "stencil" then
love.graphics.setStencilTest()
elseif clipMode == "scissor" then
love.graphics.setScissor()
end
end
-- Apply content blur if configured
if self.contentBlur and self.contentBlur.radius > 0 then
local blurInstance = self:getBlurInstance()
if blurInstance then
Element._Blur.applyToRegion(
blurInstance,
self.contentBlur.radius,
self.x,
self.y,
borderBoxWidth,
borderBoxHeight,
renderChildLayer
)
else
renderChildLayer()
end
else
renderChildLayer()
end
end
--- Update element (propagate to children)
---@param dt number
function Element:update(dt)
if self.display == false then
return
end
if not self.parent then
self:_trackActiveAnimations()
end
for _, child in ipairs(self.children) do
child:update(dt)
end
-- Advance direct-assignment animations before the loop so geometry is current
-- for hit-testing; no-ops when the Animated behavior is already attached.
Element._dispatchAnimatedUpdate(self, dt)
for _, b in ipairs(self.behaviors) do
b.onUpdate(self, dt)
end
self:_processDeferredMethods()
end
--- Handle a touch event directly (for external touch routing)
--- Invokes both onEvent and onTouchEvent callbacks if set
---@param touchEvent InputEvent The touch event to handle
function Element:handleTouchEvent(touchEvent)
if not self.touchEnabled or self.disabled then
return
end
if self._eventHandler then
self._eventHandler:_invokeCallback(self, touchEvent)
self._eventHandler:_invokeTouchCallback(self, touchEvent)
end
end
--- Handle a gesture event (from GestureRecognizer or external routing)
---@param gesture table The gesture data (type, position, velocity, etc.)
function Element:handleGesture(gesture)
if not self.touchEnabled or self.disabled then
return
end
if self._eventHandler then
self._eventHandler:_invokeGestureCallback(self, gesture)
end
end
--- Get active touches currently tracked on this element
---@return table<string, table> Active touches keyed by touch ID
function Element:getTouches()
if self._eventHandler then
return self._eventHandler:getActiveTouches()
end
return {}
end
---@param newViewportWidth number
---@param newViewportHeight number
function Element:recalculateUnits(newViewportWidth, newViewportHeight)
self._layoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
end
--- Resize element and its children based on game window size change
---@param newGameWidth number
---@param newGameHeight number
function Element:resize(newGameWidth, newGameHeight)
self:recalculateUnits(newGameWidth, newGameHeight)
self:_refreshSizeConstraints(newGameWidth, newGameHeight)
-- For non-auto-sized elements with viewport/percentage units, update content dimensions from border-box
if not self.autosizing.width and self._borderBoxWidth and self.units.width.unit ~= "px" then
self._borderBoxWidth = Element._utils.clamp(self._borderBoxWidth, self.minWidth, self.maxWidth)
self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right)
end
if not self.autosizing.height and self._borderBoxHeight and self.units.height.unit ~= "px" then
self._borderBoxHeight = Element._utils.clamp(self._borderBoxHeight, self.minHeight, self.maxHeight)
self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom)
end
-- Update children
for _, child in ipairs(self.children) do
child:resize(newGameWidth, newGameHeight)
end
-- Recalculate auto-sized dimensions after children are resized
if self.autosizing.width then
local contentWidth = self:calculateAutoWidth()
-- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content
self._borderBoxWidth =
Element._utils.clamp(contentWidth + self.padding.left + self.padding.right, self.minWidth, self.maxWidth)
self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right)
-- CONTENT-LEVEL CLAMP: CSS min-width/max-width also bound the content width.
-- Subtracting padding from the clamped border-box can drop the content width
-- below minWidth (e.g. minWidth=200, horizontal padding=100 => content=100),
-- so re-clamp the content dimension with the shared size-clamping utility.
self.width = Element._utils.clampSize(self.width, self.minWidth, self.maxWidth)
end
if self.autosizing.height then
local contentHeight = self:calculateAutoHeight()
-- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content
self._borderBoxHeight =
Element._utils.clamp(contentHeight + self.padding.top + self.padding.bottom, self.minHeight, self.maxHeight)
self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom)
-- CONTENT-LEVEL CLAMP: CSS min-height/max-height also bound the content height.
-- Subtracting padding from the clamped border-box can drop the content height
-- below minHeight (e.g. minHeight=200, vertical padding=100 => content=100),
-- so re-clamp the content dimension with the shared size-clamping utility.
self.height = Element._utils.clampSize(self.height, self.minHeight, self.maxHeight)
end
-- Re-resolve textSize if it uses viewport-relative units after dimensions are finalized
self:layoutChildren()
self.prevGameSize.width = newGameWidth
self.prevGameSize.height = newGameHeight
end
function Element:_refreshSizeConstraints(newViewportWidth, newViewportHeight)
local scaleX, scaleY = Element._Context.getScaleFactors()
local ctx = { vw = newViewportWidth, vh = newViewportHeight, sx = scaleX, sy = scaleY }
local parentW = self.parent and self.parent.width or newViewportWidth
local parentH = self.parent and self.parent.height or newViewportHeight
_refreshUnit(self, "minWidth", parentW, ctx, "x")
_refreshUnit(self, "maxWidth", parentW, ctx, "x")
_refreshUnit(self, "minHeight", parentH, ctx, "y")
_refreshUnit(self, "maxHeight", parentH, ctx, "y")
end
--- Calculate text width for button
---@return number
function Element:calculateTextWidth()
if self.text == nil then
return 0
end
local font = Element._utils.getFont(self.textSize, self.fontFamily, self.themeComponent, self._themeManager)
local width = font:getWidth(self.text)
return Element._utils.applyContentMultiplier(width, self.contentAutoSizingMultiplier, "width")
end
---@return number
function Element:calculateTextHeight()
if self.text == nil then
return 0
end
local font = Element._utils.getFont(self.textSize, self.fontFamily, self.themeComponent, self._themeManager)
local height = font:getHeight()
if self.textWrap and (self.textWrap == "word" or self.textWrap == "char" or self.textWrap == true) then
local availableWidth = self.width
if (not availableWidth or availableWidth <= 0) and self.parent then
availableWidth = self.parent.width
end
if availableWidth and availableWidth > 0 then
local _, wrappedLines = font:getWrap(self.text, availableWidth)
height = height * #wrappedLines
end
end
return Element._utils.applyContentMultiplier(height, self.contentAutoSizingMultiplier, "height")
end
function Element:calculateAutoWidth()
local contentWidth = self._layoutEngine:calculateAutoWidth()
if self._managedSelectMinimumBorderBoxWidth then
local minimumContentWidth =
math.max(0, self._managedSelectMinimumBorderBoxWidth - self.padding.left - self.padding.right)
contentWidth = math.max(contentWidth, minimumContentWidth)
end
return contentWidth
end
--- Calculate auto height based on children
function Element:calculateAutoHeight()
return self._layoutEngine:calculateAutoHeight()
end
---@param newText string
---@param autoresize boolean? --default: false
function Element:updateText(newText, autoresize)
self.text = newText or self.text
if autoresize then
self.width = self:calculateTextWidth()
self.height = self:calculateTextHeight()
end
end
---@param newOpacity number
function Element:updateOpacity(newOpacity)
self.opacity = newOpacity
for _, child in ipairs(self.children) do
child:updateOpacity(newOpacity)
end
end
--- same as calling updateOpacity(0)
function Element:hide()
self:updateOpacity(0)
end
--- same as calling updateOpacity(1)
function Element:show()
self:updateOpacity(1)
end
-- ====================
-- Input Handling - Text Editing (behavior-delegated, task 04)
-- ====================
-- All text-editor operations are dispatched through the TextEditable behavior
-- (modules/behaviors/TextEditable.lua). Element retains only thin 1-line
-- forwarders for backward-compat with external callers (EventHandler,
-- KeyboardNavigation, Renderer, game UI). The behavior owns the TextEditor
-- subsystem (onAttach creates it, onUpdate drives cursor blink, saveState /
-- restoreState persist it) AND implements the delegate bodies (text sync,
-- auto-grow, nil-guarding element._textEditor) — so Element carries zero
-- text-editor nil-guard branches and zero text-editor logic.
--
-- `_wrapLine` / `_getFont` remain here: they are RENDERER forwarders (not
-- TextEditor delegates), and the TextEditor has its own implementations.
-- `updateText` (above) is a plain-label text setter, not a TextEditor delegate.
-- ====================
--- Set cursor position (delegates to TextEditable behavior)
---@param position number -- Character index (0-based)
function Element:setCursorPosition(position)
return Element._TextEditable.setCursorPosition(self, position)
end
--- Get cursor position (delegates to TextEditable behavior)
---@return number -- Character index (0-based)
function Element:getCursorPosition()
return Element._TextEditable.getCursorPosition(self)
end
--- Move cursor by delta characters (delegates to TextEditable behavior)
---@param delta number -- Number of characters to move (positive or negative)
function Element:moveCursorBy(delta)
return Element._TextEditable.moveCursorBy(self, delta)
end
--- Move cursor to start of text (delegates to TextEditable behavior)
function Element:moveCursorToStart()
return Element._TextEditable.moveCursorToStart(self)
end
--- Move cursor to end of text (delegates to TextEditable behavior)
function Element:moveCursorToEnd()
return Element._TextEditable.moveCursorToEnd(self)
end
--- Move cursor to start of current line (delegates to TextEditable behavior)
function Element:moveCursorToLineStart()
return Element._TextEditable.moveCursorToLineStart(self)
end
--- Move cursor to end of current line (delegates to TextEditable behavior)
function Element:moveCursorToLineEnd()
return Element._TextEditable.moveCursorToLineEnd(self)
end
--- Move cursor to start of previous word (delegates to TextEditable behavior)
function Element:moveCursorToPreviousWord()
return Element._TextEditable.moveCursorToPreviousWord(self)
end
--- Move cursor to start of next word (delegates to TextEditable behavior)
function Element:moveCursorToNextWord()
return Element._TextEditable.moveCursorToNextWord(self)
end
--- Set selection range (delegates to TextEditable behavior)
---@param startPos number -- Start position (inclusive)
---@param endPos number -- End position (inclusive)
function Element:setSelection(startPos, endPos)
return Element._TextEditable.setSelection(self, startPos, endPos)
end
--- Get selection range (delegates to TextEditable behavior)
---@return number?, number? -- Start and end positions, or nil if no selection
function Element:getSelection()
return Element._TextEditable.getSelection(self)
end
--- Check if there is an active selection (delegates to TextEditable behavior)
---@return boolean
function Element:hasSelection()
return Element._TextEditable.hasSelection(self)
end
--- Clear selection (delegates to TextEditable behavior)
function Element:clearSelection()
return Element._TextEditable.clearSelection(self)
end
--- Select all text (delegates to TextEditable behavior)
function Element:selectAll()
return Element._TextEditable.selectAll(self)
end
--- Get selected text (delegates to TextEditable behavior)
---@return string? -- Selected text or nil if no selection
function Element:getSelectedText()
return Element._TextEditable.getSelectedText(self)
end
--- Delete selected text (delegates to TextEditable behavior, which owns text sync + auto-grow)
---@return boolean -- True if text was deleted
function Element:deleteSelection()
return Element._TextEditable.deleteSelection(self)
end
--- Give this element keyboard focus to enable text input or keyboard navigation
--- Use this to automatically focus text fields when showing forms or dialogs
function Element:focus()
return Element._TextEditable.focus(self)
end
--- Remove keyboard focus to stop capturing input events
--- Use this when closing popups or switching focus to other elements
function Element:blur()
return Element._TextEditable.blur(self)
end
--- Query focus state to conditionally render focus indicators or handle keyboard input
--- Use this to style focused elements or determine which element receives keyboard events
---@return boolean
function Element:isFocused()
return Element._TextEditable.isFocused(self)
end
--- Retrieve the element's current text content for processing or validation
--- Use this to read user input from text fields or get display text
---@return string
function Element:getText()
return Element._TextEditable.getText(self)
end
--- Update the element's text content programmatically for dynamic labels or resetting inputs
--- Use this to change text without user input, like clearing fields or updating status messages
---@param text string
function Element:setText(text)
return Element._TextEditable.setText(self, text)
end
--- Programmatically insert text at any position for autocomplete or text manipulation
--- Use this to implement suggestions, templates, or text snippets
---@param text string -- Text to insert
---@param position number? -- Position to insert at (default: cursor position)
function Element:insertText(text, position)
return Element._TextEditable.insertText(self, text, position)
end
---@param startPos number -- Start position (inclusive)
---@param endPos number -- End position (inclusive)
function Element:deleteText(startPos, endPos)
return Element._TextEditable.deleteText(self, startPos, endPos)
end
--- Replace text in range
---@param startPos number -- Start position (inclusive)
---@param endPos number -- End position (inclusive)
---@param newText string -- Replacement text
function Element:replaceText(startPos, endPos, newText)
return Element._TextEditable.replaceText(self, startPos, endPos, newText)
end
--- Wrap a single line of text
---@param line string -- Line to wrap
---@param maxWidth number -- Maximum width in pixels
---@return table -- Array of wrapped line parts
function Element:_wrapLine(line, maxWidth)
return self._renderer:wrapLine(self, line, maxWidth)
end
---@return love.Font
function Element:_getFont()
return self._renderer:getFont(self)
end
-- ====================
-- Input Handling - Mouse Selection
-- ====================
--- Handle mouse click on text (set cursor position or start selection)
--- Delegates to the TextEditable behavior, which owns drag tracking.
---@param mouseX number -- Mouse X coordinate
---@param mouseY number -- Mouse Y coordinate
---@param clickCount number -- Number of clicks (1=single, 2=double, 3=triple)
function Element:_handleTextClick(mouseX, mouseY, clickCount)
return Element._TextEditable._handleTextClick(self, mouseX, mouseY, clickCount)
end
--- Handle mouse drag for text selection
--- Delegates to the TextEditable behavior, which owns drag tracking.
---@param mouseX number -- Mouse X coordinate
---@param mouseY number -- Mouse Y coordinate
function Element:_handleTextDrag(mouseX, mouseY)
return Element._TextEditable._handleTextDrag(self, mouseX, mouseY)
end
-- ====================
-- Input Handling - Keyboard Input (behavior-delegated, task 04)
-- ====================
--- Handle text input (character input) — delegates to the TextEditable behavior.
---@param text string -- Character(s) to insert
function Element:textinput(text)
return Element._TextEditable.textinput(self, text)
end
--- Handle key press (special keys) — delegates to the TextEditable behavior.
---@param key string -- Key name
---@param scancode string -- Scancode
---@param isrepeat boolean -- Whether this is a key repeat
function Element:keypressed(key, scancode, isrepeat)
return Element._TextEditable.keypressed(self, key, scancode, isrepeat)
end
-- ====================
-- Performance Monitoring
-- ====================
--- Get hierarchy depth of this element
---@return number depth Depth in the element tree (0 for root)
function Element:getHierarchyDepth()
local depth = 0
local current = self.parent
while current do
depth = depth + 1
current = current.parent
end
return depth
end
--- Count total elements in this tree
---@return number count Total number of elements including this one and all descendants
function Element:countElements()
local count = 1 -- Count self
for _, child in ipairs(self.children) do
count = count + child:countElements()
end
return count
end
function Element:_checkPerformanceWarnings()
if not Element._Performance or not Element._Performance.warningsEnabled then
return
end
-- Check hierarchy depth
local depth = self:getHierarchyDepth()
if depth >= 15 then
Element._Performance:logWarning(
string.format("hierarchy_depth_%s", self.id),
"Element",
string.format("Element hierarchy depth is %d levels for element '%s'", depth, self.id or "unnamed"),
{ depth = depth, elementId = self.id or "unnamed" },
"Deep nesting can impact performance. Consider flattening the structure or using absolute positioning"
)
end
-- Check total element count (only for root elements)
if not self.parent then
local totalElements = self:countElements()
if totalElements >= 1000 then
Element._Performance:logWarning(
"element_count_high",
"Element",
string.format("UI contains %d+ elements", totalElements),
{ elementCount = totalElements },
"Large element counts may impact performance. Consider virtualization for long lists or pagination for large datasets"
)
end
end
end
--- Count active animations in tree
---@return number count Number of active animations
function Element:_countActiveAnimations()
local count = self.animation and 1 or 0
for _, child in ipairs(self.children) do
count = count + child:_countActiveAnimations()
end
return count
end
--- Track active animations and warn if too many
function Element:_trackActiveAnimations()
-- Get Performance instance from deps if available
if not Element._Performance or not Element._Performance.warningsEnabled then
return
end
local animCount = self:_countActiveAnimations()
if animCount >= 50 then
Element._Performance:logWarning(
"animation_count_high",
"Element",
string.format("%d+ animations running simultaneously", animCount),
{ animationCount = animCount },
"High animation counts may impact frame rate. Consider reducing concurrent animations or using CSS-style transitions"
)
end
end
--- Change the tint color of an image element dynamically for hover effects or state indication
--- Use this to recolor images without replacing the asset, like highlighting selected items
---@param color Color Color to tint the image
function Element:setImageTint(color)
self.imageTint = color
end
--- Adjust image transparency independently from the element for fade effects
--- Use this to create image-specific fade animations or disabled states
---@param opacity number Opacity 0-1
function Element:setImageOpacity(opacity)
if opacity ~= nil then
Element._utils.validateRange(opacity, 0, 1, "imageOpacity")
end
self.imageOpacity = opacity
end
--- Set image repeat mode
---@param repeatMode string Repeat mode: "no-repeat", "repeat", "repeat-x", "repeat-y", "space", "round"
function Element:setImageRepeat(repeatMode)
local validImageRepeat = {
["no-repeat"] = "no-repeat",
["repeat"] = "repeat",
["repeat-x"] = "repeat-x",
["repeat-y"] = "repeat-y",
space = "space",
round = "round",
}
Element._utils.validateEnum(repeatMode, validImageRepeat, "imageRepeat")
self.imageRepeat = repeatMode
end
--- Apply rotation transform to create spinning animations or rotated layouts
--- Use this for loading spinners, compass needles, or angled UI elements
---@param angle number Angle in radians
function Element:rotate(angle)
if not self.transform then
self.transform = Element._Transform.new({})
end
self.transform.rotate = angle
end
--- Resize element visually using scale transforms for zoom effects
--- Use this for hover magnification, shrinking animations, or responsive scaling
---@param scaleX number X-axis scale
---@param scaleY number? Y-axis scale (defaults to scaleX)
function Element:scale(scaleX, scaleY)
if not self.transform then
self.transform = Element._Transform.new({})
end
self.transform.scaleX = scaleX
self.transform.scaleY = scaleY or scaleX
end
--- Offset element position using transforms for smooth movement without layout recalculation
--- Use this for parallax effects, draggable elements, or position animations
---@param x number X translation
---@param y number Y translation
function Element:translate(x, y)
if not self.transform then
self.transform = Element._Transform.new({})
end
self.transform.translateX = x
self.transform.translateY = y
end
--- Define the pivot point for rotation and scaling transforms
--- Use this to rotate around corners, edges, or custom points rather than the center
---@param originX number X origin (0-1, where 0.5 is center)
---@param originY number Y origin (0-1, where 0.5 is center)
function Element:setTransformOrigin(originX, originY)
if not self.transform then
self.transform = Element._Transform.new({})
end
self.transform.originX = originX
self.transform.originY = originY
end
--- Animate element to new property values with automatic transition
--- Captures current values as start, uses provided values as final, and applies the animation
---@param props table Target property values
---@param duration number? Animation duration in seconds (default: 0.3)
---@param easing string? Easing function name (default: "linear")
---@return Element self For method chaining
function Element:animateTo(props, duration, easing)
if not Element._Animation then
_warnAnimApi("ELEM_003")
return self
end
if type(props) ~= "table" then
_warnAnimApi("ELEM_003")
return self
end
duration = duration or 0.3
easing = easing or "linear"
-- Collect current values as start
local startValues = {}
for key, _ in pairs(props) do
startValues[key] = self[key]
end
-- Create and apply animation
local anim = Element._Animation.new({
duration = duration,
start = startValues,
final = props,
easing = easing,
})
anim:apply(self)
return self
end
--- Fade element to full opacity
---@param duration number? Duration in seconds (default: 0.3)
---@param easing string? Easing function name
---@return Element self For method chaining
function Element:fadeIn(duration, easing)
return self:animateTo({ opacity = 1 }, duration or 0.3, easing)
end
--- Fade element to zero opacity
---@param duration number? Duration in seconds (default: 0.3)
---@param easing string? Easing function name
---@return Element self For method chaining
function Element:fadeOut(duration, easing)
return self:animateTo({ opacity = 0 }, duration or 0.3, easing)
end
--- Scale element to target scale value using transforms
---@param targetScale number Target scale multiplier
---@param duration number? Duration in seconds (default: 0.3)
---@param easing string? Easing function name
---@return Element self For method chaining
function Element:scaleTo(targetScale, duration, easing)
if not Element._Animation or not Element._Transform then
_warnAnimApi("ELEM_003")
return self
end
-- Ensure element has a transform
if not self.transform then
self.transform = Element._Transform.new({})
end
local currentScaleX = self.transform.scaleX or 1
local currentScaleY = self.transform.scaleY or 1
local anim = Element._Animation.new({
duration = duration or 0.3,
start = { scaleX = currentScaleX, scaleY = currentScaleY },
final = { scaleX = targetScale, scaleY = targetScale },
easing = easing or "linear",
})
anim:apply(self)
return self
end
--- Move element to target position
---@param x number Target x position
---@param y number Target y position
---@param duration number? Duration in seconds (default: 0.3)
---@param easing string? Easing function name
---@return Element self For method chaining
function Element:moveTo(x, y, duration, easing)
return self:animateTo({ x = x, y = y }, duration or 0.3, easing)
end
--- Set transition configuration for a property
---@param property string Property name or "all" for all properties
---@param config table Transition config {duration, easing, delay, onComplete}
function Element:setTransition(property, config)
if not self.transitions then
self.transitions = {}
end
if type(config) ~= "table" then
_warnAnimApi("ELEM_003")
config = {}
end
-- Validate config
if config.duration and (type(config.duration) ~= "number" or config.duration < 0) then
_warnAnimApi("ELEM_004", config.duration)
config.duration = 0.3
end
self.transitions[property] = {
duration = config.duration or 0.3,
easing = config.easing or "easeOutQuad",
delay = config.delay or 0,
onComplete = config.onComplete,
}
end
--- Set transition configuration for multiple properties
---@param groupName string Name for this transition group
---@param config table Transition config {duration, easing, delay, onComplete}
---@param properties table Array of property names
function Element:setTransitionGroup(_, config, properties)
if type(properties) ~= "table" then
_warnAnimApi("ELEM_005")
return
end
for _, prop in ipairs(properties) do
self:setTransition(prop, config)
end
end
--- Remove transition configuration for a property
---@param property string Property name or "all" to remove all
function Element:removeTransition(property)
if not self.transitions then
return
end
if property == "all" then
self.transitions = {}
else
self.transitions[property] = nil
end
end
--- Resolve a unit-based dimension property (width/height) from a string or CalcObject
--- Parses the value, updates self.units, resolves to pixels, and updates border-box dimensions
---@param property string "width" or "height"
---@param value string|table The unit string (e.g., "50%", "10vw") or CalcObject
---@return number resolvedValue The resolved pixel value
function Element:_resolveDimensionProperty(property, value)
local viewportWidth, viewportHeight = Element._Units.getViewport()
local parsedValue, parsedUnit = Element._Units.parse(value)
self.units[property] = { value = parsedValue, unit = parsedUnit }
local parentDimension
if property == "width" then
parentDimension = self.parent and self.parent.width or viewportWidth
else
parentDimension = self.parent and self.parent.height or viewportHeight
end
local resolved = Element._Units.resolve(parsedValue, parsedUnit, viewportWidth, viewportHeight, parentDimension)
if type(resolved) ~= "number" then
Element._ErrorHandler:warn("Element", "LAY_003", {
issue = string.format("%s resolution returned non-number value", property),
type = type(resolved),
value = tostring(resolved),
})
resolved = 0
end
self[property] = resolved
if property == "width" then
if self.autosizing and self.autosizing.width then
self._borderBoxWidth = resolved + self.padding.left + self.padding.right
else
self._borderBoxWidth = resolved
end
else
if self.autosizing and self.autosizing.height then
self._borderBoxHeight = resolved + self.padding.top + self.padding.bottom
else
self._borderBoxHeight = resolved
end
end
return resolved
end
--- Resolve a dimension (width/height) prop given a unit-string/Calc value.
--- Handles the unit-sameness short-circuit, transition-on-resolved-pixel-value
--- semantics, and layout invalidation. Exits setProperty (caller returns).
local function _setDimensionWithUnit(self, property, value, transitionConfig)
-- Check if the unit specification is the same (compare against stored units)
local currentUnits = self.units[property]
local newValue, newUnit = Element._Units.parse(value)
if currentUnits and currentUnits.value == newValue and currentUnits.unit == newUnit then
return
end
if transitionConfig then
-- For transitions, resolve the target value and transition the pixel value
local currentPixelValue = self[property]
local resolvedTarget = self:_resolveDimensionProperty(property, value)
if currentPixelValue ~= nil and currentPixelValue ~= resolvedTarget then
-- Reset to current value before animating
self[property] = currentPixelValue
local Animation = require("modules.Animation")
local anim = Animation.new({
duration = transitionConfig.duration,
start = { [property] = currentPixelValue },
final = { [property] = resolvedTarget },
easing = transitionConfig.easing,
onComplete = transitionConfig.onComplete,
})
anim:apply(self)
end
else
self:_resolveDimensionProperty(property, value)
end
self:invalidateLayout()
end
--- Apply a transition animation from the current value to `value` for `property`.
--- Falls back to a direct write when there is no current value to animate from.
local function _animatePropertyTo(self, property, value, transitionConfig)
local currentValue = self[property]
if currentValue ~= nil then
local Animation = require("modules.Animation")
local anim = Animation.new({
duration = transitionConfig.duration,
start = { [property] = currentValue },
final = { [property] = value },
easing = transitionConfig.easing,
onComplete = transitionConfig.onComplete,
})
anim:apply(self)
else
self[property] = value
end
end
-- Explicit handler map for the few props with genuinely-different setProperty
-- semantics that cannot be expressed via schema flags alone. Adding a new prop
-- with ordinary behavior requires NO new entry here — it flows through the
-- generic flagged dispatch below. Handlers signal a full-handled early return.
local _specialSetHandlers = {
parent = function(self, value)
self:setParent(value)
return true
end,
themeComponent = function(self, value)
self.themeComponent = value
self:_syncThemeAndRenderer("themeComponent", value)
return true
end,
-- imagePath / image: setting these must re-run the Imageable load pipeline
-- (recompute `_loadedImage`, fire onImageLoad/onImageError, defer I/O). The
-- Imageable behavior installs `element._reloadImage` at construction; if it
-- is absent the element has no image concern (Imageable only attaches when
-- imagePath/image is declared at construction), so the field is set but no
-- load occurs — late image-concern acquisition requires re-attaching the
-- behavior, which is outside the attach-at-construction contract.
imagePath = function(self, value)
self.imagePath = value
if self._reloadImage then
self:_reloadImage()
end
return true
end,
image = function(self, value)
self.image = value
if self._reloadImage then
self:_reloadImage()
end
return true
end,
}
--- Set property with automatic transition.
--- Dispatch is registry-driven: dimension/unit props route through
--- `_setDimensionWithUnit`, the genuinely-special props (parent,
--- themeComponent, imagePath, image) route through `_specialSetHandlers`, and
--- everything else is a single generic path that consults schema flags
--- (`affectsLayout` / `syncsTheme`) for layout invalidation and theme sync. No
--- inline hardcoded property-name branches and no per-call table allocation.
---@param property string Property name
---@param value any New value
function Element:setProperty(property, value)
local transitionConfig
if self.transitions then
transitionConfig = self.transitions[property] or self.transitions["all"]
end
local schema = Element._PropertySchema
-- 1. Dimension prop with a unit string / CalcObject: resolve to pixels.
if schema.isDimension(property) and (type(value) == "string" or (Element._Calc and Element._Calc.isCalc(value))) then
_setDimensionWithUnit(self, property, value, transitionConfig)
return
end
-- 2. Genuinely-special props (parent reparenting, themeComponent sync).
local handler = _specialSetHandlers[property]
if handler then
handler(self, value)
return
end
-- 3. Generic flagged dispatch.
-- Skip write/transition/layout work for unchanged values, but still sync
-- theme state: disabled/active must reach setThemeState even when the value is
-- unchanged (renderer/theme state may have been reset out-of-band).
if self[property] ~= value then
if transitionConfig then
_animatePropertyTo(self, property, value, transitionConfig)
else
self[property] = value
end
if schema.affectsLayout(property) then
self:invalidateLayout()
end
end
if schema.syncsTheme(property) then
self:_syncThemeAndRenderer(property, value)
end
end
---Sync ThemeManager and Renderer when properties change that affect rendering
---@param property string The property name that changed
---@param value any The new value
function Element:_syncThemeAndRenderer(property, value)
-- Visual props (backgroundColor/borderColor/cornerRadius/opacity) and callbacks
-- (onEvent/onTouchEvent/onGesture) are intentionally NOT synced here: Renderer:draw
-- and EventHandler dispatch read them from the element as source of truth, so a
-- bare `element.<prop> = v` write is immediately consistent with setProperty(...).
-- Only stateful side effects (theme-state machine + themeManager component) remain.
if property == "disabled" then
if self._themeManager then
self._themeManager.disabled = value
end
if self._renderer then
self._renderer:setThemeState(value and "disabled" or "normal")
end
elseif property == "active" then
if self._themeManager then
self._themeManager.active = value
end
if self._renderer then
self._renderer:setThemeState(value and "active" or "normal")
end
elseif property == "themeComponent" then
if self._themeManager then
self._themeManager.themeComponent = value
end
end
end
-- ====================
-- State Persistence (behavior-mode-unification task 12)
-- ====================
--- Save all element state for immediate-mode persistence.
--- Each attached behavior owns its own state extraction (saveState hook) and
--- returns a snapshot (or nil) merged into the consolidated state table:
--- Clickable → `eventHandler`, Scrollable → `scrollManager`, TextEditable →
--- `textEditor` + drag tracking, Selectable → `select`, Themed → `blur`,
--- Persistable → `_props` (public scalar mutations). Element owns ZERO
--- per-subsystem extraction logic — this method is a pure dispatch loop.
---@return ElementStateData state Complete state snapshot
function Element:saveState()
local state = {}
for i = 1, #self.behaviors do
local bstate = self.behaviors[i].saveState(self)
if bstate ~= nil then
for k, v in pairs(bstate) do
state[k] = v
end
end
end
return state
end
--- Restore all element state from StateManager.
--- Each attached behavior owns its own hydration (restoreState hook) and reads
--- only its own slice from the full state table. Registry order places
--- Persistable last so `_props` overrides subsystem-hydrated state, preserving
--- the legacy restore ordering. Element owns ZERO per-subsystem hydration.
---@param state ElementStateData State to restore
function Element:restoreState(state)
if not state then
return
end
for i = 1, #self.behaviors do
self.behaviors[i].restoreState(self, state)
end
end
--- Cleanup method to break circular references (immediate-mode frame end).
--- Iterates each attached behavior's `onDetach` hook so every behavior tears
--- down what its `onAttach` created (Clickable releases the EventHandler,
--- TextEditable the TextEditor, Themed the Renderer, Selectable the select
--- fields, Imageable the image callbacks), then clears the behaviors list and
--- unregisters from StateManager. Does NOT clear onEvent / onTouchEvent /
--- onGesture — the Renderer/EventHandler read those directly from the element
--- (not the cache), so clearing them here would break retained mode.
function Element:_cleanup()
for i = 1, #self.behaviors do
self.behaviors[i].onDetach(self)
end
self.behaviors = {}
-- onCreate fires once at construction (already invoked by now); release it.
self.onCreate = nil
if self._stateId and self._stateId ~= "" then
Element._StateManager.unregisterStateful(self._stateId)
end
end
-- ====================
-- Keyboard Navigation
-- ====================
--- Check if this element can receive keyboard focus
---@return boolean
function Element:isFocusable()
if self.disabled then
return false
end
-- Capability query: an element is keyboard-focusable when it is editable, has
-- an event/text handler, participates in the Select subsystem, or is a
-- touch-interactive element with callbacks. Expressed as a single boolean
-- expression (not a dispatch branch) because focusability is a query, not a
-- per-frame behavior.
return not not (
self.editable
or type(self.onEvent) == "function"
or self._selectState
or self.selectOption
or self.onTextInput
or (self.touchEnabled and (self.onTouchEvent or self.onGesture))
)
end
--- Get all focusable children in DOM/document order (depth-first traversal)
--- Elements are collected in the order they appear in the children array,
--- with nested children collected after their parent. This matches standard
--- browser tab order behavior where elements are ordered by document position.
---@return Element[]
function Element:getFocusableChildren()
local focusable = {}
local function collectFocusable(elem)
for _, child in ipairs(elem.children) do
-- Check self first
if child:isFocusable() then
table.insert(focusable, child)
end
-- Then recurse (depth-first)
collectFocusable(child)
end
end
collectFocusable(self)
return focusable
end
--- Get next focusable element in sequence
---@param container Element The container element
---@param currentElement Element? Current focused element
---@param wrap boolean? Whether to wrap around
---@return Element?
function Element.getNextFocusable(container, currentElement, wrap)
local focusable = container:getFocusableChildren()
if #focusable == 0 then
return nil
end
-- Find current index
local currentIndex = 0
if currentElement then
for i, elem in ipairs(focusable) do
if elem == currentElement then
currentIndex = i
break
end
end
end
-- Find next
local nextIndex = currentIndex + 1
if nextIndex > #focusable then
if wrap then
nextIndex = 1
else
return nil
end
end
return focusable[nextIndex]
end
--- Get previous focusable element in sequence
---@param container Element The container element
---@param currentElement Element? Current focused element
---@param wrap boolean? Whether to wrap around
---@return Element?
function Element.getPreviousFocusable(container, currentElement, wrap)
local focusable = container:getFocusableChildren()
if #focusable == 0 then
return nil
end
-- Find current index
local currentIndex = #focusable + 1
if currentElement then
for i, elem in ipairs(focusable) do
if elem == currentElement then
currentIndex = i
break
end
end
end
-- Find previous
local prevIndex = currentIndex - 1
if prevIndex < 1 then
if wrap then
prevIndex = #focusable
else
return nil
end
end
return focusable[prevIndex]
end
return Element