---@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.(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 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. = 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