- Add customDraw property to Element.new() for custom rendering callbacks - Add getComputedBox() method to access element's content area position/size - Call customDraw in Element:draw() between text and children (Layer 4.5) - Graphics state isolated with push/pop and color reset - Enables rendering custom content (e.g. game objects) within UI elements
3833 lines
146 KiB
Lua
3833 lines
146 KiB
Lua
---@class Element
|
|
---@field id string
|
|
---@field autosizing {width:boolean, height:boolean} -- Whether the element should automatically size to fit its children
|
|
---@field x number|string -- X coordinate of the element
|
|
---@field y number|string -- Y coordinate of the element
|
|
---@field z number -- Z-index for layering (default: 0)
|
|
---@field width number|string -- Width of the element
|
|
---@field height number|string -- Height of the element
|
|
---@field top number? -- Offset from top edge (CSS-style positioning)
|
|
---@field right number? -- Offset from right edge (CSS-style positioning)
|
|
---@field bottom number? -- Offset from bottom edge (CSS-style positioning)
|
|
---@field left number? -- Offset from left edge (CSS-style positioning)
|
|
---@field children table<integer, Element> -- Children of this element
|
|
---@field parent Element? -- Parent element (nil if top-level)
|
|
---@field border Border -- Border configuration for the element
|
|
---@field opacity number
|
|
---@field borderColor Color -- Color of the border
|
|
---@field backgroundColor Color -- Background color of the element
|
|
---@field cornerRadius number|{topLeft:number?, topRight:number?, bottomLeft:number?, bottomRight:number?}? -- Corner radius for rounded corners (default: 0)
|
|
---@field prevGameSize {width:number, height:number} -- Previous game size for resize calculations
|
|
---@field text string? -- Text content to display in the element
|
|
---@field textColor Color -- Color of the text content
|
|
---@field textAlign TextAlign -- Alignment of the text content
|
|
---@field gap number|string -- Space between children elements (default: 10)
|
|
---@field padding {top?:number, right?:number, bottom?:number, left?:number}? -- Padding around children (default: {top=0, right=0, bottom=0, left=0})
|
|
---@field margin {top?:number, right?:number, bottom?:number, left?:number} -- Margin around children (default: {top=0, right=0, bottom=0, left=0})
|
|
---@field positioning Positioning -- Layout positioning mode (default: RELATIVE)
|
|
---@field flexDirection FlexDirection -- Direction of flex layout (default: HORIZONTAL)
|
|
---@field justifyContent JustifyContent -- Alignment of items along main axis (default: FLEX_START)
|
|
---@field alignItems AlignItems -- Alignment of items along cross axis (default: STRETCH)
|
|
---@field alignContent AlignContent -- Alignment of lines in multi-line flex containers (default: STRETCH)
|
|
---@field flexWrap FlexWrap -- Whether children wrap to multiple lines (default: NOWRAP)
|
|
---@field justifySelf JustifySelf -- Alignment of the item itself along main axis (default: AUTO)
|
|
---@field alignSelf AlignSelf -- Alignment of the item itself along cross axis (default: AUTO)
|
|
---@field textSize number? -- Resolved font size for text content in pixels
|
|
---@field minTextSize number?
|
|
---@field maxTextSize number?
|
|
---@field fontFamily string? -- Font family name from theme or path to font file
|
|
---@field autoScaleText boolean -- Whether text should auto-scale with window size (default: true)
|
|
---@field transform TransformProps -- Transform properties for animations and styling
|
|
---@field transition TransitionProps -- Transition settings for animations
|
|
---@field onEvent fun(element:Element, event:InputEvent)? -- Callback function for interaction events
|
|
---@field onEventDeferred boolean? -- Whether onEvent callback should be deferred until after canvases are released (default: false)
|
|
---@field onFocus fun(element:Element)? -- Callback function when element receives focus
|
|
---@field onFocusDeferred boolean? -- Whether onFocus callback should be deferred (default: false)
|
|
---@field onBlur fun(element:Element)? -- Callback function when element loses focus
|
|
---@field onBlurDeferred boolean? -- Whether onBlur callback should be deferred (default: false)
|
|
---@field onTextInput fun(element:Element, text:string)? -- Callback function for text input
|
|
---@field onTextInputDeferred boolean? -- Whether onTextInput callback should be deferred (default: false)
|
|
---@field onTextChange fun(element:Element, text:string)? -- Callback function when text changes
|
|
---@field onTextChangeDeferred boolean? -- Whether onTextChange callback should be deferred (default: false)
|
|
---@field onEnter fun(element:Element)? -- Callback function when Enter key is pressed
|
|
---@field onEnterDeferred boolean? -- Whether onEnter callback should be deferred (default: false)
|
|
---@field units table -- Original unit specifications for responsive behavior
|
|
---@field _eventHandler EventHandler -- Event handler instance for input processing
|
|
---@field _explicitlyAbsolute boolean?
|
|
---@field _originalPositioning Positioning? -- Original positioning value set by user
|
|
---@field gridRows number? -- Number of rows in the grid
|
|
---@field gridColumns number? -- Number of columns in the grid
|
|
---@field columnGap number|string? -- Gap between grid columns
|
|
---@field rowGap number|string? -- Gap between grid rows
|
|
---@field theme string? -- Theme component to use for rendering
|
|
---@field themeComponent string?
|
|
---@field _themeState string? -- Current theme state (normal, hover, pressed, active, disabled)
|
|
---@field _themeManager ThemeManager -- Internal: theme manager instance
|
|
---@field _stateId string? -- State manager ID for this element
|
|
---@field _elementMode "immediate"|"retained" -- Lifecycle mode for this element (resolved from props.mode or global mode)
|
|
---@field disabled boolean? -- Whether the element is disabled (default: false)
|
|
---@field active boolean? -- Whether the element is active/focused (for inputs, default: false)
|
|
---@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false)
|
|
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions
|
|
---@field scaleCorners number? -- Scale multiplier for 9-patch corners/edges. E.g., 2 = 2x size (overrides theme setting)
|
|
---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-patch corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting)
|
|
---@field contentBlur {radius:number, quality:number?}? -- Blur the element's content including children (radius: pixels, quality: 1-10, default: 5)
|
|
---@field backdropBlur {radius:number, quality:number?}? -- Blur content behind the element (radius: pixels, quality: 1-10, default: 5)
|
|
---@field _blurInstance table? -- Internal: cached blur effect instance
|
|
---@field editable boolean -- Whether the element is editable (default: false)
|
|
---@field multiline boolean -- Whether the element supports multiple lines (default: false)
|
|
---@field textWrap boolean|"word"|"char" -- Text wrapping mode (default: false for single-line, "word" for multi-line)
|
|
---@field maxLines number? -- Maximum number of lines (default: nil)
|
|
---@field maxLength number? -- Maximum text length in characters (default: nil)
|
|
---@field placeholder string? -- Placeholder text when empty (default: nil)
|
|
---@field passwordMode boolean -- Whether to display text as password (default: false)
|
|
---@field inputType "text"|"number"|"email"|"url" -- Input type for validation (default: "text")
|
|
---@field textOverflow "clip"|"ellipsis"|"scroll" -- Text overflow behavior (default: "clip")
|
|
---@field scrollable boolean -- Whether text is scrollable (default: false for single-line, true for multi-line)
|
|
---@field autoGrow boolean -- Whether element auto-grows with text (default: false)
|
|
---@field selectOnFocus boolean -- Whether to select all text on focus (default: false)
|
|
---@field cursorColor Color? -- Cursor color (default: nil, uses textColor)
|
|
---@field selectionColor Color? -- Selection background color (default: nil, uses theme or default)
|
|
---@field cursorBlinkRate number -- Cursor blink rate in seconds (default: 0.5)
|
|
---@field _cursorPosition number? -- Internal: cursor character position (0-based)
|
|
---@field _cursorLine number? -- Internal: cursor line number (1-based)
|
|
---@field _cursorColumn number? -- Internal: cursor column within line
|
|
---@field _cursorBlinkTimer number? -- Internal: cursor blink timer
|
|
---@field _cursorVisible boolean? -- Internal: cursor visibility state
|
|
---@field _cursorBlinkPaused boolean? -- Internal: whether cursor blink is paused (e.g., while typing)
|
|
---@field _cursorBlinkPauseTimer number? -- Internal: timer for how long cursor blink has been paused
|
|
---@field _selectionStart number? -- Internal: selection start position
|
|
---@field _selectionEnd number? -- Internal: selection end position
|
|
---@field _selectionAnchor number? -- Internal: selection anchor point
|
|
---@field _focused boolean? -- Internal: focus state
|
|
---@field _textBuffer string? -- Internal: text buffer for editable elements
|
|
---@field _lines table? -- Internal: split lines for multi-line text
|
|
---@field _wrappedLines table? -- Internal: wrapped line data
|
|
---@field _textDirty boolean? -- Internal: flag to recalculate lines/wrapping
|
|
---@field _textEditor TextEditor? -- Internal: TextEditor instance for editable elements
|
|
---@field imagePath string? -- Path to image file (auto-loads via ImageCache)
|
|
---@field image love.Image? -- Image object to display
|
|
---@field objectFit "fill"|"contain"|"cover"|"scale-down"|"none"? -- Image fit mode (default: "fill")
|
|
---@field objectPosition string? -- Image position like "center center", "top left", "50% 50%" (default: "center center")
|
|
---@field imageOpacity number? -- Image opacity 0-1 (default: 1, combines with element opacity)
|
|
---@field imageRepeat "no-repeat"|"repeat"|"repeat-x"|"repeat-y"|"space"|"round"? -- Image repeat/tiling mode (default: "no-repeat")
|
|
---@field imageTint Color? -- Color to tint the image (default: nil/white, no tint)
|
|
---@field onImageLoad fun(element:Element, image:love.Image)? -- Callback when image loads successfully
|
|
---@field onImageLoadDeferred boolean? -- Whether onImageLoad callback should be deferred (default: false)
|
|
---@field onImageError fun(element:Element, error:string)? -- Callback when image fails to load
|
|
---@field onImageErrorDeferred boolean? -- Whether onImageError callback should be deferred (default: false)
|
|
---@field _loadedImage love.Image? -- Internal: cached loaded image
|
|
---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control)
|
|
---@field userdata table?
|
|
---@field _renderer Renderer -- Internal: Renderer instance for visual rendering
|
|
---@field _layoutEngine LayoutEngine -- Internal: LayoutEngine instance for layout calculations
|
|
---@field _scrollManager ScrollManager? -- Internal: ScrollManager instance for scroll handling
|
|
---@field _borderBoxWidth number? -- Internal: cached border-box width
|
|
---@field _borderBoxHeight number? -- Internal: cached border-box height
|
|
---@field overflow string? -- Overflow behavior for both axes
|
|
---@field overflowX string? -- Overflow behavior for horizontal axis
|
|
---@field overflowY string? -- Overflow behavior for vertical axis
|
|
---@field scrollbarWidth number? -- Scrollbar width in pixels
|
|
---@field scrollbarColor Color? -- Scrollbar thumb color
|
|
---@field scrollbarTrackColor Color? -- Scrollbar track color
|
|
---@field scrollbarRadius number? -- Scrollbar corner radius
|
|
---@field scrollbarPadding number? -- Scrollbar padding from edges
|
|
---@field scrollSpeed number? -- Scroll speed multiplier
|
|
---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars)
|
|
---@field scrollbarKnobOffset number|table? -- Scrollbar knob/handle offset (number or {x, y} or {horizontal, vertical})
|
|
---@field _overflowX boolean? -- Internal: whether content overflows horizontally
|
|
---@field _overflowY boolean? -- Internal: whether content overflows vertically
|
|
---@field _contentWidth number? -- Internal: total content width
|
|
---@field _contentHeight number? -- Internal: total content height
|
|
---@field _scrollX number? -- Internal: horizontal scroll position
|
|
---@field _scrollY number? -- Internal: vertical scroll position
|
|
---@field _maxScrollX number? -- Internal: maximum horizontal scroll
|
|
---@field _maxScrollY number? -- Internal: maximum vertical scroll
|
|
---@field _scrollbarHoveredVertical boolean? -- Internal: vertical scrollbar hover state
|
|
---@field _scrollbarHoveredHorizontal boolean? -- Internal: horizontal scrollbar hover state
|
|
---@field _scrollbarDragging boolean? -- Internal: scrollbar dragging state
|
|
---@field _hoveredScrollbar table? -- Internal: currently hovered scrollbar info
|
|
---@field _scrollbarDragOffset number? -- Internal: scrollbar drag offset
|
|
---@field _scrollbarPressHandled boolean? -- Internal: scrollbar press handled flag
|
|
---@field _pressed table? -- Internal: button press state tracking
|
|
---@field _mouseDownPosition number? -- Internal: mouse down position for drag tracking
|
|
---@field _textDragOccurred boolean? -- Internal: whether text drag occurred
|
|
---@field animation table? -- Animation instance for this element
|
|
local Element = {}
|
|
Element.__index = Element
|
|
|
|
---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
|
|
end
|
|
|
|
---@param props ElementProps
|
|
---@return Element
|
|
function Element.new(props)
|
|
-- Early check: If this is a retained-mode element in an immediate-mode context,
|
|
-- check if it already exists (was restored to parent) to prevent duplicates
|
|
local elementMode = props.mode
|
|
if elementMode == nil then
|
|
elementMode = Element._Context._immediateMode and "immediate" or "retained"
|
|
end
|
|
|
|
-- If retained mode and has an ID, check if element already exists in parent's children
|
|
if elementMode == "retained" and props.id and props.id ~= "" and props.parent then
|
|
-- Check if this element already exists in parent's restored children
|
|
for _, child in ipairs(props.parent.children) do
|
|
if child.id == props.id and child._elementMode == "retained" then
|
|
-- Element already exists (was restored), return existing instance
|
|
return child
|
|
end
|
|
end
|
|
end
|
|
|
|
local self = setmetatable({}, Element)
|
|
|
|
-- Create dependency subsets for sub-modules (defined once, used throughout)
|
|
local eventHandlerDeps = {
|
|
InputEvent = Element._InputEvent,
|
|
Context = Element._Context,
|
|
utils = Element._utils,
|
|
}
|
|
|
|
local 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,
|
|
}
|
|
|
|
local layoutEngineDeps = {
|
|
utils = Element._utils,
|
|
Grid = Element._Grid,
|
|
Units = Element._Units,
|
|
Context = Element._Context,
|
|
ErrorHandler = Element._ErrorHandler,
|
|
}
|
|
|
|
local textEditorDeps = {
|
|
Context = Element._Context,
|
|
StateManager = Element._StateManager,
|
|
Color = Element._Color,
|
|
utils = Element._utils,
|
|
}
|
|
|
|
local scrollManagerDeps = {
|
|
utils = Element._utils,
|
|
Color = Element._Color,
|
|
}
|
|
|
|
-- Normalize flexDirection: convert "row"→"horizontal", "column"→"vertical"
|
|
if props.flexDirection == "row" then
|
|
props.flexDirection = "horizontal"
|
|
elseif props.flexDirection == "column" then
|
|
props.flexDirection = "vertical"
|
|
end
|
|
|
|
-- Normalize padding: convert single value to table with all sides
|
|
if props.padding ~= nil and type(props.padding) ~= "table" then
|
|
local singleValue = props.padding
|
|
props.padding = {
|
|
top = singleValue,
|
|
right = singleValue,
|
|
bottom = singleValue,
|
|
left = singleValue,
|
|
}
|
|
end
|
|
|
|
-- Normalize margin: convert single value to table with all sides
|
|
if props.margin ~= nil and type(props.margin) ~= "table" then
|
|
local singleValue = props.margin
|
|
props.margin = {
|
|
top = singleValue,
|
|
right = singleValue,
|
|
bottom = singleValue,
|
|
left = singleValue,
|
|
}
|
|
end
|
|
|
|
-- Resolve element mode: props.mode takes precedence over global mode
|
|
-- This must happen BEFORE ID generation and state management
|
|
if props.mode == "immediate" then
|
|
self._elementMode = "immediate"
|
|
elseif props.mode == "retained" then
|
|
self._elementMode = "retained"
|
|
else
|
|
-- nil or invalid: use global mode
|
|
self._elementMode = Element._Context._immediateMode and "immediate" or "retained"
|
|
end
|
|
|
|
self.children = {}
|
|
self.onEvent = props.onEvent
|
|
|
|
-- 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
|
|
self.id = Element._StateManager.generateID(props, props.parent)
|
|
else
|
|
self.id = props.id
|
|
end
|
|
|
|
-- AFTER ID is determined, check for duplicate top-level OR child retained elements
|
|
-- ONLY for auto-generated IDs (same call site recreating same element)
|
|
-- If user provides explicit ID, they control uniqueness
|
|
if self._elementMode == "retained" and idWasAutoGenerated and self.id and self.id ~= "" then
|
|
if not props.parent then
|
|
-- Top-level element: check in topElements
|
|
for _, existingElement in ipairs(Element._Context.topElements) do
|
|
if existingElement.id == self.id and existingElement._elementMode == "retained" then
|
|
-- Element already exists (was preserved from previous frame), return existing instance
|
|
-- CRITICAL: Clear children array to prevent accumulation
|
|
-- Children will be re-declared this frame (retained children will be found via duplicate check)
|
|
existingElement.children = {}
|
|
return existingElement
|
|
end
|
|
end
|
|
else
|
|
-- Child element: check in parent's children
|
|
for _, existingChild in ipairs(props.parent.children) do
|
|
if existingChild.id == self.id and existingChild._elementMode == "retained" then
|
|
-- Element already exists (was restored to parent), return existing instance
|
|
-- CRITICAL: Clear children array to prevent accumulation
|
|
-- Children will be re-declared this frame (retained children will be found via duplicate check)
|
|
existingChild.children = {}
|
|
return existingChild
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- In immediate mode, restore retained children from StateManager
|
|
-- This allows retained-mode children to persist when immediate-mode parents recreate
|
|
if self._elementMode == "immediate" and self.id and self.id ~= "" then
|
|
local retainedChildren = Element._StateManager.getRetainedChildren(self.id)
|
|
if retainedChildren and #retainedChildren > 0 then
|
|
-- Restore retained children and update their parent references
|
|
for _, child in ipairs(retainedChildren) do
|
|
child.parent = self
|
|
table.insert(self.children, child)
|
|
end
|
|
end
|
|
end
|
|
|
|
self.userdata = props.userdata
|
|
|
|
self.onFocus = props.onFocus
|
|
self.onFocusDeferred = props.onFocusDeferred or false
|
|
self.onBlur = props.onBlur
|
|
self.onBlurDeferred = props.onBlurDeferred or false
|
|
self.onTextInput = props.onTextInput
|
|
self.onTextInputDeferred = props.onTextInputDeferred or false
|
|
self.onTextChange = props.onTextChange
|
|
self.onTextChangeDeferred = props.onTextChangeDeferred or false
|
|
self.onEnter = props.onEnter
|
|
self.onEnterDeferred = props.onEnterDeferred or false
|
|
|
|
self.customDraw = props.customDraw -- Custom rendering callback
|
|
|
|
-- Initialize state manager ID for immediate mode (use self.id which may be auto-generated)
|
|
self._stateId = self.id
|
|
|
|
-- In immediate mode, restore EventHandler state from StateManager
|
|
local eventHandlerConfig = {
|
|
onEvent = self.onEvent,
|
|
onEventDeferred = props.onEventDeferred,
|
|
}
|
|
if self._elementMode == "immediate" and self._stateId and self._stateId ~= "" then
|
|
local state = Element._StateManager.getState(self._stateId)
|
|
if state then
|
|
-- Restore EventHandler state from StateManager (sparse storage - provide defaults)
|
|
eventHandlerConfig._pressed = state._pressed or {}
|
|
eventHandlerConfig._lastClickTime = state._lastClickTime
|
|
eventHandlerConfig._lastClickButton = state._lastClickButton
|
|
eventHandlerConfig._clickCount = state._clickCount or 0
|
|
eventHandlerConfig._dragStartX = state._dragStartX or {}
|
|
eventHandlerConfig._dragStartY = state._dragStartY or {}
|
|
eventHandlerConfig._lastMouseX = state._lastMouseX or {}
|
|
eventHandlerConfig._lastMouseY = state._lastMouseY or {}
|
|
eventHandlerConfig._hovered = state._hovered
|
|
end
|
|
end
|
|
|
|
self._eventHandler = Element._EventHandler.new(eventHandlerConfig, eventHandlerDeps)
|
|
|
|
self._themeManager = Element._Theme.Manager.new({
|
|
theme = props.theme or Element._Context.defaultTheme,
|
|
themeComponent = props.themeComponent or nil,
|
|
disabled = props.disabled or false,
|
|
active = props.active or false,
|
|
disableHighlight = props.disableHighlight,
|
|
themeStateLock = props.themeStateLock or false,
|
|
scaleCorners = props.scaleCorners,
|
|
scalingAlgorithm = props.scalingAlgorithm,
|
|
})
|
|
|
|
-- 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.contentBlur = props.contentBlur
|
|
self.backdropBlur = props.backdropBlur
|
|
self._blurInstance = nil
|
|
|
|
self.editable = props.editable or false
|
|
self.multiline = props.multiline or false
|
|
self.passwordMode = props.passwordMode or false
|
|
|
|
-- Validate property combinations: passwordMode disables multiline
|
|
if self.passwordMode and props.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.maxLines = props.maxLines
|
|
self.maxLength = props.maxLength
|
|
self.placeholder = props.placeholder
|
|
self.inputType = props.inputType or "text"
|
|
|
|
self.textOverflow = props.textOverflow or "clip"
|
|
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.selectOnFocus = props.selectOnFocus or false
|
|
|
|
self.cursorColor = props.cursorColor
|
|
self.selectionColor = props.selectionColor
|
|
self.cursorBlinkRate = props.cursorBlinkRate or 0.5
|
|
|
|
if self.editable then
|
|
self._textEditor = Element._TextEditor.new({
|
|
editable = self.editable,
|
|
multiline = self.multiline,
|
|
passwordMode = self.passwordMode,
|
|
textWrap = self.textWrap,
|
|
maxLines = self.maxLines,
|
|
maxLength = self.maxLength,
|
|
placeholder = self.placeholder,
|
|
inputType = self.inputType,
|
|
textOverflow = self.textOverflow,
|
|
scrollable = self.scrollable,
|
|
autoGrow = self.autoGrow,
|
|
selectOnFocus = self.selectOnFocus,
|
|
cursorColor = self.cursorColor,
|
|
selectionColor = self.selectionColor,
|
|
cursorBlinkRate = self.cursorBlinkRate,
|
|
text = props.text or "",
|
|
onFocus = props.onFocus,
|
|
onBlur = props.onBlur,
|
|
onTextInput = props.onTextInput,
|
|
onTextChange = props.onTextChange,
|
|
onEnter = props.onEnter,
|
|
}, textEditorDeps)
|
|
|
|
-- Restore TextEditor state from StateManager in immediate mode
|
|
if self._elementMode == "immediate" and self._stateId and self._stateId ~= "" then
|
|
local state = Element._StateManager.getState(self._stateId)
|
|
if state and state.textEditor then
|
|
-- Restore from nested textEditor state (saved via saveState())
|
|
self._textEditor:setState(state.textEditor, self)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Set parent first so it's available for size calculations
|
|
self.parent = props.parent
|
|
|
|
------ add non-hereditary ------
|
|
--- self drawing---
|
|
-- OPTIMIZATION: Handle border - only create table if border exists
|
|
-- This saves ~80 bytes per element without borders
|
|
if type(props.border) == "table" then
|
|
-- Check if any border side is truthy
|
|
local hasAnyBorder = props.border.top or props.border.right or props.border.bottom or props.border.left
|
|
if hasAnyBorder then
|
|
-- Normalize border values: boolean true → 1, number → value, false/nil → false
|
|
local function normalizeBorderValue(value)
|
|
if value == true then
|
|
return 1
|
|
elseif type(value) == "number" then
|
|
return value
|
|
else
|
|
return false
|
|
end
|
|
end
|
|
|
|
self.border = {
|
|
top = normalizeBorderValue(props.border.top),
|
|
right = normalizeBorderValue(props.border.right),
|
|
bottom = normalizeBorderValue(props.border.bottom),
|
|
left = normalizeBorderValue(props.border.left),
|
|
}
|
|
else
|
|
self.border = nil
|
|
end
|
|
elseif props.border then
|
|
-- If border is a number or truthy value, keep it as-is
|
|
self.border = props.border
|
|
else
|
|
-- No border specified - use nil instead of table with all false
|
|
self.border = nil
|
|
end
|
|
self.borderColor = props.borderColor or Element._Color.new(0, 0, 0, 1)
|
|
self.backgroundColor = props.backgroundColor or Element._Color.new(0, 0, 0, 0)
|
|
|
|
-- Validate and set opacity
|
|
if props.opacity ~= nil then
|
|
Element._utils.validateRange(props.opacity, 0, 1, "opacity")
|
|
end
|
|
self.opacity = props.opacity or 1
|
|
|
|
-- Set visibility property (default: "visible")
|
|
self.visibility = props.visibility or "visible"
|
|
|
|
-- Set transform property (optional)
|
|
self.transform = props.transform or nil
|
|
|
|
-- OPTIMIZATION: Handle cornerRadius - store as number or table, nil if all zeros
|
|
-- This saves ~80 bytes per element without rounded corners
|
|
if props.cornerRadius then
|
|
if type(props.cornerRadius) == "number" then
|
|
-- Store as number for uniform radius (compact)
|
|
if props.cornerRadius ~= 0 then
|
|
self.cornerRadius = props.cornerRadius
|
|
else
|
|
self.cornerRadius = nil
|
|
end
|
|
else
|
|
-- Store as table only if non-zero values exist
|
|
local hasNonZero = props.cornerRadius.topLeft or props.cornerRadius.topRight or props.cornerRadius.bottomLeft or props.cornerRadius.bottomRight
|
|
if hasNonZero then
|
|
self.cornerRadius = {
|
|
topLeft = props.cornerRadius.topLeft or 0,
|
|
topRight = props.cornerRadius.topRight or 0,
|
|
bottomLeft = props.cornerRadius.bottomLeft or 0,
|
|
bottomRight = props.cornerRadius.bottomRight or 0,
|
|
}
|
|
else
|
|
self.cornerRadius = nil
|
|
end
|
|
end
|
|
else
|
|
-- No cornerRadius specified - use nil instead of table with all zeros
|
|
self.cornerRadius = nil
|
|
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
|
|
|
|
-- Sync self.text with restored _textBuffer for editable elements in immediate mode
|
|
if self.editable and Element._Context._immediateMode and self._textBuffer then
|
|
self.text = self._textBuffer
|
|
end
|
|
|
|
-- Validate and set textAlign
|
|
if props.textAlign then
|
|
Element._utils.validateEnum(props.textAlign, Element._utils.enums.TextAlign, "textAlign")
|
|
end
|
|
self.textAlign = props.textAlign or Element._utils.enums.TextAlign.START
|
|
|
|
-- Image properties
|
|
self.imagePath = props.imagePath
|
|
self.image = props.image
|
|
|
|
-- Validate objectFit
|
|
if props.objectFit then
|
|
local validObjectFit = { fill = "fill", contain = "contain", cover = "cover", ["scale-down"] = "scale-down", none = "none" }
|
|
Element._utils.validateEnum(props.objectFit, validObjectFit, "objectFit")
|
|
end
|
|
self.objectFit = props.objectFit or "fill"
|
|
self.objectPosition = props.objectPosition or "center center"
|
|
|
|
-- Validate and set imageOpacity
|
|
if props.imageOpacity ~= nil then
|
|
Element._utils.validateRange(props.imageOpacity, 0, 1, "imageOpacity")
|
|
end
|
|
self.imageOpacity = props.imageOpacity or 1
|
|
|
|
-- Validate and set imageRepeat
|
|
if props.imageRepeat then
|
|
local validImageRepeat = {
|
|
["no-repeat"] = "no-repeat",
|
|
["repeat"] = "repeat",
|
|
["repeat-x"] = "repeat-x",
|
|
["repeat-y"] = "repeat-y",
|
|
space = "space",
|
|
round = "round",
|
|
}
|
|
Element._utils.validateEnum(props.imageRepeat, validImageRepeat, "imageRepeat")
|
|
end
|
|
self.imageRepeat = props.imageRepeat or "no-repeat"
|
|
|
|
-- Set imageTint
|
|
self.imageTint = props.imageTint
|
|
|
|
-- Image callbacks
|
|
self.onImageLoad = props.onImageLoad
|
|
self.onImageLoadDeferred = props.onImageLoadDeferred or false
|
|
self.onImageError = props.onImageError
|
|
self.onImageErrorDeferred = props.onImageErrorDeferred or false
|
|
|
|
-- Auto-load image if imagePath is provided
|
|
if self.imagePath and not self.image then
|
|
local loadedImage, err = Element._ImageCache.load(self.imagePath)
|
|
if loadedImage then
|
|
self._loadedImage = loadedImage
|
|
-- Call onImageLoad callback if provided
|
|
if self.onImageLoad and type(self.onImageLoad) == "function" then
|
|
if self.onImageLoadDeferred then
|
|
Element._Context.deferCallback(function()
|
|
local success, callbackErr = pcall(self.onImageLoad, self, loadedImage)
|
|
if not success then
|
|
Element._ErrorHandler:warn("Element", "EVT_002", {
|
|
callback = "onImageLoad",
|
|
error = tostring(callbackErr),
|
|
})
|
|
end
|
|
end)
|
|
else
|
|
local success, callbackErr = pcall(self.onImageLoad, self, loadedImage)
|
|
if not success then
|
|
Element._ErrorHandler:warn("Element", "EVT_002", {
|
|
callback = "onImageLoad",
|
|
error = tostring(callbackErr),
|
|
})
|
|
end
|
|
end
|
|
end
|
|
else
|
|
-- Image failed to load
|
|
self._loadedImage = nil
|
|
-- Call onImageError callback if provided
|
|
if self.onImageError and type(self.onImageError) == "function" then
|
|
if self.onImageErrorDeferred then
|
|
Element._Context.deferCallback(function()
|
|
local success, callbackErr = pcall(self.onImageError, self, err or "Unknown error")
|
|
if not success then
|
|
Element._ErrorHandler:warn("Element", "EVT_002", {
|
|
callback = "onImageError",
|
|
error = tostring(callbackErr),
|
|
})
|
|
end
|
|
end)
|
|
else
|
|
local success, callbackErr = pcall(self.onImageError, self, err or "Unknown error")
|
|
if not success then
|
|
Element._ErrorHandler:warn("Element", "EVT_002", {
|
|
callback = "onImageError",
|
|
error = tostring(callbackErr),
|
|
})
|
|
end
|
|
end
|
|
end
|
|
end
|
|
elseif self.image then
|
|
self._loadedImage = self.image
|
|
-- Call onImageLoad for directly provided images
|
|
if self.onImageLoad and type(self.onImageLoad) == "function" then
|
|
if self.onImageLoadDeferred then
|
|
Element._Context.deferCallback(function()
|
|
local success, callbackErr = pcall(self.onImageLoad, self, self.image)
|
|
if not success then
|
|
Element._ErrorHandler:warn("Element", "EVT_002", {
|
|
callback = "onImageLoad",
|
|
error = tostring(callbackErr),
|
|
})
|
|
end
|
|
end)
|
|
else
|
|
local success, callbackErr = pcall(self.onImageLoad, self, self.image)
|
|
if not success then
|
|
Element._ErrorHandler:warn("Element", "EVT_002", {
|
|
callback = "onImageLoad",
|
|
error = tostring(callbackErr),
|
|
})
|
|
end
|
|
end
|
|
end
|
|
else
|
|
self._loadedImage = nil
|
|
end
|
|
|
|
-- Initialize Renderer module for visual rendering
|
|
self._renderer = Element._Renderer.new({
|
|
backgroundColor = self.backgroundColor,
|
|
borderColor = self.borderColor,
|
|
opacity = self.opacity,
|
|
border = self.border,
|
|
cornerRadius = self.cornerRadius,
|
|
theme = self.theme,
|
|
themeComponent = self.themeComponent,
|
|
scaleCorners = self.scaleCorners,
|
|
scalingAlgorithm = self.scalingAlgorithm,
|
|
imagePath = self.imagePath,
|
|
image = self.image,
|
|
_loadedImage = self._loadedImage,
|
|
objectFit = self.objectFit,
|
|
objectPosition = self.objectPosition,
|
|
imageOpacity = self.imageOpacity,
|
|
imageRepeat = self.imageRepeat,
|
|
imageTint = self.imageTint,
|
|
contentBlur = self.contentBlur,
|
|
backdropBlur = self.backdropBlur,
|
|
}, rendererDeps)
|
|
|
|
--- 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,
|
|
}, 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" },
|
|
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 scaleX, scaleY = Element._Context.getScaleFactors()
|
|
|
|
self.minTextSize = props.minTextSize
|
|
self.maxTextSize = props.maxTextSize
|
|
|
|
-- Set autoScaleText BEFORE textSize processing (needed for correct initialization)
|
|
if props.autoScaleText == nil then
|
|
self.autoScaleText = true
|
|
else
|
|
self.autoScaleText = props.autoScaleText
|
|
end
|
|
|
|
-- 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 == "ew" then
|
|
-- ew is relative to element width (use viewport width as fallback during initialization)
|
|
-- Will be re-resolved after width is set
|
|
self.textSize = (value / 100) * viewportWidth
|
|
elseif unit == "eh" then
|
|
-- eh is relative to element height (use viewport height as fallback during initialization)
|
|
-- Will be re-resolved after height is set
|
|
self.textSize = (value / 100) * viewportHeight
|
|
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
|
|
|
|
-- Handle width (both w and width properties, prefer w if both exist)
|
|
local widthProp = props.width
|
|
local tempWidth = 0 -- Temporary width for padding resolution
|
|
if widthProp then
|
|
-- Check if it's a CalcObject (table with _isCalc marker)
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(widthProp)
|
|
if type(widthProp) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(widthProp)
|
|
self.units.width = { value = value, unit = unit }
|
|
local parentWidth = self.parent and self.parent.width or viewportWidth
|
|
tempWidth = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth)
|
|
-- Defensive check: ensure tempWidth is a number after resolution
|
|
if type(tempWidth) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "width resolution returned non-number value",
|
|
type = type(tempWidth),
|
|
value = tostring(tempWidth),
|
|
})
|
|
tempWidth = 0
|
|
end
|
|
else
|
|
tempWidth = Element._Context.baseScale and (widthProp * scaleX) or widthProp
|
|
self.units.width = { value = widthProp, unit = "px" }
|
|
end
|
|
self.width = tempWidth
|
|
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)
|
|
local heightProp = props.height
|
|
local tempHeight = 0 -- Temporary height for padding resolution
|
|
if heightProp then
|
|
-- Check if it's a CalcObject (table with _isCalc marker)
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(heightProp)
|
|
if type(heightProp) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(heightProp)
|
|
self.units.height = { value = value, unit = unit }
|
|
local parentHeight = self.parent and self.parent.height or viewportHeight
|
|
tempHeight = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight)
|
|
-- Defensive check: ensure tempHeight is a number after resolution
|
|
if type(tempHeight) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "height resolution returned non-number value",
|
|
type = type(tempHeight),
|
|
value = tostring(tempHeight),
|
|
})
|
|
tempHeight = 0
|
|
end
|
|
else
|
|
-- Apply base scaling to pixel values
|
|
tempHeight = Element._Context.baseScale and (heightProp * scaleY) or heightProp
|
|
self.units.height = { value = heightProp, unit = "px" }
|
|
end
|
|
self.height = tempHeight
|
|
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
|
|
|
|
--- child positioning ---
|
|
if props.gap then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.gap)
|
|
if type(props.gap) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.gap)
|
|
self.units.gap = { value = value, unit = unit }
|
|
-- Gap percentages should be relative to the element's own size, not parent
|
|
-- For horizontal flex, gap is based on width; for vertical flex, based on height
|
|
local flexDir = props.flexDirection or Element._utils.enums.FlexDirection.HORIZONTAL
|
|
local containerSize = (flexDir == Element._utils.enums.FlexDirection.HORIZONTAL) and self.width or self.height
|
|
local resolvedGap = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, containerSize)
|
|
-- Defensive check: ensure gap is a number after resolution
|
|
if type(resolvedGap) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "gap resolution returned non-number value",
|
|
type = type(resolvedGap),
|
|
value = tostring(resolvedGap),
|
|
})
|
|
resolvedGap = 0
|
|
end
|
|
self.gap = resolvedGap
|
|
else
|
|
self.gap = props.gap
|
|
self.units.gap = { value = props.gap, unit = "px" }
|
|
end
|
|
else
|
|
self.gap = 0
|
|
self.units.gap = { value = 0, unit = "px" }
|
|
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
|
|
-- Ensure tempWidth and tempHeight are numbers (not CalcObjects)
|
|
-- This should already be true after Units.resolve(), but add defensive check
|
|
if type(tempWidth) ~= "number" then
|
|
if Element._ErrorHandler then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "tempWidth is not a number after resolution",
|
|
type = type(tempWidth),
|
|
})
|
|
end
|
|
tempWidth = 0
|
|
end
|
|
if type(tempHeight) ~= "number" then
|
|
if Element._ErrorHandler then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "tempHeight is not a number after resolution",
|
|
type = type(tempHeight),
|
|
})
|
|
end
|
|
tempHeight = 0
|
|
end
|
|
|
|
-- 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 ew/eh textSize units now that width/height are set
|
|
if props.textSize and type(props.textSize) == "string" then
|
|
-- Check if it's a preset first (presets don't need re-resolution)
|
|
local presetValue, presetUnit = Element._utils.resolveTextSizePreset(props.textSize)
|
|
if not presetValue then
|
|
-- Not a preset, parse and check for ew/eh units
|
|
local value, unit = Element._Units.parse(props.textSize)
|
|
if unit == "ew" then
|
|
-- Element width relative (now that width is set)
|
|
self.textSize = (value / 100) * self.width
|
|
elseif unit == "eh" then
|
|
-- Element height relative (now that height is set)
|
|
self.textSize = (value / 100) * self.height
|
|
end
|
|
end
|
|
end
|
|
|
|
-- 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 shorthand properties first (horizontal/vertical)
|
|
if props.padding then
|
|
if props.padding.horizontal then
|
|
if type(props.padding.horizontal) == "string" then
|
|
local value, unit = Element._Units.parse(props.padding.horizontal)
|
|
self.units.padding.horizontal = { value = value, unit = unit }
|
|
else
|
|
self.units.padding.horizontal = { value = props.padding.horizontal, unit = "px" }
|
|
end
|
|
end
|
|
if props.padding.vertical then
|
|
if type(props.padding.vertical) == "string" then
|
|
local value, unit = Element._Units.parse(props.padding.vertical)
|
|
self.units.padding.vertical = { value = value, unit = unit }
|
|
else
|
|
self.units.padding.vertical = { value = props.padding.vertical, unit = "px" }
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Initialize all padding sides
|
|
for _, side in ipairs({ "top", "right", "bottom", "left" }) do
|
|
if props.padding and props.padding[side] then
|
|
if type(props.padding[side]) == "string" then
|
|
local value, unit = Element._Units.parse(props.padding[side])
|
|
self.units.padding[side] = { value = value, unit = unit, explicit = true }
|
|
else
|
|
self.units.padding[side] = { value = props.padding[side], unit = "px", explicit = true }
|
|
end
|
|
else
|
|
-- Mark as derived from shorthand (will use shorthand during resize if available)
|
|
self.units.padding[side] = { value = self.padding[side], unit = "px", explicit = false }
|
|
end
|
|
end
|
|
|
|
-- Store margin shorthand properties
|
|
if props.margin then
|
|
if props.margin.horizontal then
|
|
if type(props.margin.horizontal) == "string" then
|
|
local value, unit = Element._Units.parse(props.margin.horizontal)
|
|
self.units.margin.horizontal = { value = value, unit = unit }
|
|
else
|
|
self.units.margin.horizontal = { value = props.margin.horizontal, unit = "px" }
|
|
end
|
|
end
|
|
if props.margin.vertical then
|
|
if type(props.margin.vertical) == "string" then
|
|
local value, unit = Element._Units.parse(props.margin.vertical)
|
|
self.units.margin.vertical = { value = value, unit = unit }
|
|
else
|
|
self.units.margin.vertical = { value = props.margin.vertical, unit = "px" }
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Initialize all margin sides
|
|
for _, side in ipairs({ "top", "right", "bottom", "left" }) do
|
|
if props.margin and props.margin[side] then
|
|
if type(props.margin[side]) == "string" then
|
|
local value, unit = Element._Units.parse(props.margin[side])
|
|
self.units.margin[side] = { value = value, unit = unit, explicit = true }
|
|
else
|
|
self.units.margin[side] = { value = props.margin[side], unit = "px", explicit = true }
|
|
end
|
|
else
|
|
-- Mark as derived from shorthand (will use shorthand during resize if available)
|
|
self.units.margin[side] = { value = self.margin[side], unit = "px", explicit = false }
|
|
end
|
|
end
|
|
|
|
-- Grid properties are set later in the constructor
|
|
|
|
------ add hereditary ------
|
|
if props.parent == nil then
|
|
table.insert(Element._Context.topElements, self)
|
|
|
|
-- Handle x position with units
|
|
if props.x then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.x)
|
|
if type(props.x) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.x)
|
|
self.units.x = { value = value, unit = unit }
|
|
local resolvedX = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth)
|
|
if type(resolvedX) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "x resolution returned non-number value",
|
|
type = type(resolvedX),
|
|
})
|
|
resolvedX = 0
|
|
end
|
|
self.x = resolvedX
|
|
else
|
|
-- Apply base scaling to pixel positions
|
|
self.x = Element._Context.baseScale and (props.x * scaleX) or props.x
|
|
self.units.x = { value = props.x, unit = "px" }
|
|
end
|
|
else
|
|
self.x = 0
|
|
self.units.x = { value = 0, unit = "px" }
|
|
end
|
|
|
|
-- Handle y position with units
|
|
if props.y then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.y)
|
|
if type(props.y) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.y)
|
|
self.units.y = { value = value, unit = unit }
|
|
local resolvedY = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight)
|
|
if type(resolvedY) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "y resolution returned non-number value",
|
|
type = type(resolvedY),
|
|
})
|
|
resolvedY = 0
|
|
end
|
|
self.y = resolvedY
|
|
else
|
|
-- Apply base scaling to pixel positions
|
|
self.y = Element._Context.baseScale and (props.y * scaleY) or props.y
|
|
self.units.y = { value = props.y, unit = "px" }
|
|
end
|
|
else
|
|
self.y = 0
|
|
self.units.y = { value = 0, unit = "px" }
|
|
end
|
|
|
|
self.z = props.z or 0
|
|
|
|
-- 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 used without absolute positioning
|
|
if (props.top or props.bottom or props.left or props.right) and not self._explicitlyAbsolute then
|
|
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
|
|
|
|
-- Handle top positioning with units
|
|
if props.top then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.top)
|
|
if type(props.top) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.top)
|
|
self.units.top = { value = value, unit = unit }
|
|
local resolvedTop = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight)
|
|
if type(resolvedTop) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "top resolution returned non-number value",
|
|
type = type(resolvedTop),
|
|
})
|
|
resolvedTop = 0
|
|
end
|
|
self.top = resolvedTop
|
|
else
|
|
self.top = props.top
|
|
self.units.top = { value = props.top, unit = "px" }
|
|
end
|
|
end
|
|
|
|
-- Handle right positioning with units
|
|
if props.right then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.right)
|
|
if type(props.right) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.right)
|
|
self.units.right = { value = value, unit = unit }
|
|
local resolvedRight = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth)
|
|
if type(resolvedRight) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "right resolution returned non-number value",
|
|
type = type(resolvedRight),
|
|
})
|
|
resolvedRight = 0
|
|
end
|
|
self.right = resolvedRight
|
|
else
|
|
self.right = props.right
|
|
self.units.right = { value = props.right, unit = "px" }
|
|
end
|
|
end
|
|
|
|
-- Handle bottom positioning with units
|
|
if props.bottom then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.bottom)
|
|
if type(props.bottom) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.bottom)
|
|
self.units.bottom = { value = value, unit = unit }
|
|
local resolvedBottom = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight)
|
|
if type(resolvedBottom) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "bottom resolution returned non-number value",
|
|
type = type(resolvedBottom),
|
|
})
|
|
resolvedBottom = 0
|
|
end
|
|
self.bottom = resolvedBottom
|
|
else
|
|
self.bottom = props.bottom
|
|
self.units.bottom = { value = props.bottom, unit = "px" }
|
|
end
|
|
end
|
|
|
|
-- Handle left positioning with units
|
|
if props.left then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.left)
|
|
if type(props.left) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.left)
|
|
self.units.left = { value = value, unit = unit }
|
|
local resolvedLeft = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth)
|
|
if type(resolvedLeft) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "left resolution returned non-number value",
|
|
type = type(resolvedLeft),
|
|
})
|
|
resolvedLeft = 0
|
|
end
|
|
self.left = resolvedLeft
|
|
else
|
|
self.left = props.left
|
|
self.units.left = { value = props.left, unit = "px" }
|
|
end
|
|
end
|
|
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
|
|
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 + self.parent.padding.left
|
|
local baseY = self.parent.y + self.parent.padding.top
|
|
|
|
-- Handle x position with units
|
|
if props.x then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.x)
|
|
if type(props.x) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.x)
|
|
self.units.x = { value = value, unit = unit }
|
|
local parentWidth = self.parent.width
|
|
local offsetX = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth)
|
|
if type(offsetX) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "x resolution returned non-number value",
|
|
type = type(offsetX),
|
|
})
|
|
offsetX = 0
|
|
end
|
|
self.x = baseX + offsetX
|
|
else
|
|
-- Apply base scaling to pixel positions
|
|
local scaledOffset = Element._Context.baseScale and (props.x * scaleX) or props.x
|
|
self.x = baseX + scaledOffset
|
|
self.units.x = { value = props.x, unit = "px" }
|
|
end
|
|
else
|
|
self.x = baseX
|
|
self.units.x = { value = 0, unit = "px" }
|
|
end
|
|
|
|
-- Handle y position with units
|
|
if props.y then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.y)
|
|
if type(props.y) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.y)
|
|
self.units.y = { value = value, unit = unit }
|
|
local parentHeight = self.parent.height
|
|
local offsetY = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight)
|
|
if type(offsetY) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "y resolution returned non-number value",
|
|
type = type(offsetY),
|
|
})
|
|
offsetY = 0
|
|
end
|
|
self.y = baseY + offsetY
|
|
else
|
|
-- Apply base scaling to pixel positions
|
|
local scaledOffset = Element._Context.baseScale and (props.y * scaleY) or props.y
|
|
self.y = baseY + scaledOffset
|
|
self.units.y = { value = props.y, unit = "px" }
|
|
end
|
|
else
|
|
self.y = baseY
|
|
self.units.y = { value = 0, unit = "px" }
|
|
end
|
|
|
|
self.z = props.z or 0
|
|
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 + self.parent.padding.left
|
|
local baseY = self.parent.y + self.parent.padding.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
|
|
|
|
if props.x then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.x)
|
|
if type(props.x) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.x)
|
|
self.units.x = { value = value, unit = unit }
|
|
local parentWidth = self.parent.width
|
|
local offsetX = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth)
|
|
if type(offsetX) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "x resolution returned non-number value",
|
|
type = type(offsetX),
|
|
})
|
|
offsetX = 0
|
|
end
|
|
self.x = baseX + offsetX
|
|
else
|
|
-- Apply base scaling to pixel offsets
|
|
local scaledOffset = Element._Context.baseScale and (props.x * scaleX) or props.x
|
|
self.x = baseX + scaledOffset
|
|
self.units.x = { value = props.x, unit = "px" }
|
|
end
|
|
else
|
|
self.x = baseX
|
|
self.units.x = { value = 0, unit = "px" }
|
|
end
|
|
|
|
if props.y then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.y)
|
|
if type(props.y) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.y)
|
|
self.units.y = { value = value, unit = unit }
|
|
parentHeight = self.parent.height
|
|
local offsetY = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight)
|
|
if type(offsetY) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "y resolution returned non-number value",
|
|
type = type(offsetY),
|
|
})
|
|
offsetY = 0
|
|
end
|
|
self.y = baseY + offsetY
|
|
else
|
|
-- Apply base scaling to pixel offsets
|
|
local scaledOffset = Element._Context.baseScale and (props.y * scaleY) or props.y
|
|
self.y = baseY + scaledOffset
|
|
self.units.y = { value = props.y, unit = "px" }
|
|
end
|
|
else
|
|
self.y = baseY
|
|
self.units.y = { value = 0, unit = "px" }
|
|
end
|
|
|
|
self.z = props.z or self.parent.z or 0
|
|
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 used without absolute positioning
|
|
if (props.top or props.bottom or props.left or props.right) and not self._explicitlyAbsolute then
|
|
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
|
|
|
|
-- Handle top positioning with units
|
|
if props.top then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.top)
|
|
if type(props.top) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.top)
|
|
self.units.top = { value = value, unit = unit }
|
|
local resolvedTop = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight)
|
|
if type(resolvedTop) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "top resolution returned non-number value",
|
|
type = type(resolvedTop),
|
|
})
|
|
resolvedTop = 0
|
|
end
|
|
self.top = resolvedTop
|
|
else
|
|
self.top = props.top
|
|
self.units.top = { value = props.top, unit = "px" }
|
|
end
|
|
end
|
|
|
|
-- Handle right positioning with units
|
|
if props.right then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.right)
|
|
if type(props.right) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.right)
|
|
self.units.right = { value = value, unit = unit }
|
|
local resolvedRight = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth)
|
|
if type(resolvedRight) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "right resolution returned non-number value",
|
|
type = type(resolvedRight),
|
|
})
|
|
resolvedRight = 0
|
|
end
|
|
self.right = resolvedRight
|
|
else
|
|
self.right = props.right
|
|
self.units.right = { value = props.right, unit = "px" }
|
|
end
|
|
end
|
|
|
|
-- Handle bottom positioning with units
|
|
if props.bottom then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.bottom)
|
|
if type(props.bottom) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.bottom)
|
|
self.units.bottom = { value = value, unit = unit }
|
|
local resolvedBottom = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight)
|
|
if type(resolvedBottom) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "bottom resolution returned non-number value",
|
|
type = type(resolvedBottom),
|
|
})
|
|
resolvedBottom = 0
|
|
end
|
|
self.bottom = resolvedBottom
|
|
else
|
|
self.bottom = props.bottom
|
|
self.units.bottom = { value = props.bottom, unit = "px" }
|
|
end
|
|
end
|
|
|
|
-- Handle left positioning with units
|
|
if props.left then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.left)
|
|
if type(props.left) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.left)
|
|
self.units.left = { value = value, unit = unit }
|
|
local resolvedLeft = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth)
|
|
if type(resolvedLeft) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "left resolution returned non-number value",
|
|
type = type(resolvedLeft),
|
|
})
|
|
resolvedLeft = 0
|
|
end
|
|
self.left = resolvedLeft
|
|
else
|
|
self.left = props.left
|
|
self.units.left = { value = props.left, unit = "px" }
|
|
end
|
|
end
|
|
|
|
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 or props.gridTemplateRows or props.gridTemplateColumns then
|
|
Element._ErrorHandler:warn("Element", "LAY_010", {
|
|
element = self.id or "unnamed",
|
|
positioning = "flex",
|
|
properties = "gridRows/gridColumns/gridTemplate*",
|
|
})
|
|
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 or 1
|
|
self.gridColumns = props.gridColumns or 1
|
|
self.alignItems = props.alignItems or Element._utils.enums.AlignItems.STRETCH
|
|
|
|
-- Handle columnGap and rowGap
|
|
if props.columnGap then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.columnGap)
|
|
if type(props.columnGap) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.columnGap)
|
|
local resolvedColumnGap = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, self.width)
|
|
if type(resolvedColumnGap) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "columnGap resolution returned non-number value",
|
|
type = type(resolvedColumnGap),
|
|
})
|
|
resolvedColumnGap = 0
|
|
end
|
|
self.columnGap = resolvedColumnGap
|
|
else
|
|
self.columnGap = props.columnGap
|
|
end
|
|
else
|
|
self.columnGap = 0
|
|
end
|
|
|
|
if props.rowGap then
|
|
local isCalc = Element._Calc and Element._Calc.isCalc(props.rowGap)
|
|
if type(props.rowGap) == "string" or isCalc then
|
|
local value, unit = Element._Units.parse(props.rowGap)
|
|
local resolvedRowGap = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, self.height)
|
|
if type(resolvedRowGap) ~= "number" then
|
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
|
issue = "rowGap resolution returned non-number value",
|
|
type = type(resolvedRowGap),
|
|
})
|
|
resolvedRowGap = 0
|
|
end
|
|
self.rowGap = resolvedRowGap
|
|
else
|
|
self.rowGap = props.rowGap
|
|
end
|
|
else
|
|
self.rowGap = 0
|
|
end
|
|
end
|
|
|
|
self.alignSelf = props.alignSelf or Element._utils.enums.AlignSelf.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 already set at line 424 (props.transform or nil)
|
|
-- Don't overwrite it here
|
|
self.transition = props.transition or {}
|
|
|
|
if props.overflow or props.overflowX or props.overflowY then
|
|
self._scrollManager = Element._ScrollManager.new({
|
|
overflow = props.overflow,
|
|
overflowX = props.overflowX,
|
|
overflowY = props.overflowY,
|
|
scrollbarWidth = props.scrollbarWidth,
|
|
scrollbarColor = props.scrollbarColor,
|
|
scrollbarTrackColor = props.scrollbarTrackColor,
|
|
scrollbarRadius = props.scrollbarRadius,
|
|
scrollbarPadding = props.scrollbarPadding,
|
|
scrollSpeed = props.scrollSpeed,
|
|
smoothScrollEnabled = props.smoothScrollEnabled,
|
|
scrollBarStyle = props.scrollBarStyle,
|
|
scrollbarKnobOffset = props.scrollbarKnobOffset,
|
|
hideScrollbars = props.hideScrollbars,
|
|
_scrollX = props._scrollX,
|
|
_scrollY = props._scrollY,
|
|
}, scrollManagerDeps)
|
|
|
|
-- Expose ScrollManager properties for backward compatibility (Renderer access)
|
|
self.overflow = self._scrollManager.overflow
|
|
self.overflowX = self._scrollManager.overflowX
|
|
self.overflowY = self._scrollManager.overflowY
|
|
self.scrollbarWidth = self._scrollManager.scrollbarWidth
|
|
self.scrollbarColor = self._scrollManager.scrollbarColor
|
|
self.scrollbarTrackColor = self._scrollManager.scrollbarTrackColor
|
|
self.scrollbarRadius = self._scrollManager.scrollbarRadius
|
|
self.scrollbarPadding = self._scrollManager.scrollbarPadding
|
|
self.scrollSpeed = self._scrollManager.scrollSpeed
|
|
self.scrollBarStyle = self._scrollManager.scrollBarStyle
|
|
self.scrollbarKnobOffset = self._scrollManager.scrollbarKnobOffset
|
|
self.hideScrollbars = self._scrollManager.hideScrollbars
|
|
|
|
-- Initialize state properties (will be synced from ScrollManager)
|
|
self._overflowX = false
|
|
self._overflowY = false
|
|
self._contentWidth = 0
|
|
self._contentHeight = 0
|
|
self._scrollX = 0
|
|
self._scrollY = 0
|
|
self._maxScrollX = 0
|
|
self._maxScrollY = 0
|
|
self._scrollbarHoveredVertical = false
|
|
self._scrollbarHoveredHorizontal = false
|
|
self._scrollbarDragging = false
|
|
self._hoveredScrollbar = nil
|
|
self._scrollbarDragOffset = 0
|
|
|
|
-- Restore scrollbar state from StateManager in immediate mode (must happen before layout)
|
|
if self._elementMode == "immediate" and self._stateId and self._stateId ~= "" then
|
|
local state = Element._StateManager.getState(self._stateId)
|
|
if state and state.scrollManager then
|
|
-- Restore from nested scrollManager state (saved via saveState())
|
|
self._scrollbarHoveredVertical = state.scrollManager._scrollbarHoveredVertical or false
|
|
self._scrollbarHoveredHorizontal = state.scrollManager._scrollbarHoveredHorizontal or false
|
|
self._scrollbarDragging = state.scrollManager._scrollbarDragging or false
|
|
self._hoveredScrollbar = state.scrollManager._hoveredScrollbar
|
|
self._scrollbarDragOffset = state.scrollManager._scrollbarDragOffset or 0
|
|
|
|
-- Apply to ScrollManager immediately
|
|
self._scrollManager._scrollbarHoveredVertical = self._scrollbarHoveredVertical
|
|
self._scrollManager._scrollbarHoveredHorizontal = self._scrollbarHoveredHorizontal
|
|
self._scrollManager._scrollbarDragging = self._scrollbarDragging
|
|
self._scrollManager._hoveredScrollbar = self._hoveredScrollbar
|
|
self._scrollManager._scrollbarDragOffset = self._scrollbarDragOffset
|
|
|
|
-- Restore drag start positions for relative movement tracking
|
|
self._scrollManager._dragStartMouseX = state.scrollManager._dragStartMouseX or 0
|
|
self._scrollManager._dragStartMouseY = state.scrollManager._dragStartMouseY or 0
|
|
self._scrollManager._dragStartScrollX = state.scrollManager._dragStartScrollX or 0
|
|
self._scrollManager._dragStartScrollY = state.scrollManager._dragStartScrollY or 0
|
|
end
|
|
end
|
|
else
|
|
self._scrollManager = nil
|
|
end
|
|
|
|
-- Register element in z-index tracking for immediate mode
|
|
if self._elementMode == "immediate" then
|
|
Element._Context.registerElement(self)
|
|
end
|
|
|
|
-- 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
|
|
|
|
return self
|
|
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
|
|
|
|
--- Sync ScrollManager state to Element properties for backward compatibility
|
|
--- This ensures Renderer and StateManager can access scroll state from Element
|
|
function Element:_syncScrollManagerState()
|
|
if not self._scrollManager then
|
|
return
|
|
end
|
|
|
|
-- Sync state properties from ScrollManager
|
|
self._overflowX = self._scrollManager._overflowX
|
|
self._overflowY = self._scrollManager._overflowY
|
|
self._contentWidth = self._scrollManager._contentWidth
|
|
self._contentHeight = self._scrollManager._contentHeight
|
|
self._scrollX = self._scrollManager._scrollX
|
|
self._scrollY = self._scrollManager._scrollY
|
|
self._maxScrollX = self._scrollManager._maxScrollX
|
|
self._maxScrollY = self._scrollManager._maxScrollY
|
|
self._scrollbarHoveredVertical = self._scrollManager._scrollbarHoveredVertical
|
|
self._scrollbarHoveredHorizontal = self._scrollManager._scrollbarHoveredHorizontal
|
|
self._scrollbarDragging = self._scrollManager._scrollbarDragging
|
|
self._hoveredScrollbar = self._scrollManager._hoveredScrollbar
|
|
self._scrollbarDragOffset = self._scrollManager._scrollbarDragOffset
|
|
end
|
|
|
|
--- Detect if content overflows container bounds (delegates to ScrollManager)
|
|
function Element:_detectOverflow()
|
|
if self._scrollManager then
|
|
self._scrollManager:detectOverflow(self)
|
|
self:_syncScrollManagerState()
|
|
end
|
|
end
|
|
|
|
--- Programmatically scroll content to any position for implementing "scroll to top" buttons or navigation anchors
|
|
--- Use this to create custom scrolling controls or jump to specific content sections
|
|
---@param x number? -- X scroll position (nil to keep current)
|
|
---@param y number? -- Y scroll position (nil to keep current)
|
|
function Element:setScrollPosition(x, y)
|
|
if self._scrollManager then
|
|
self._scrollManager:setScroll(x, y)
|
|
self:_syncScrollManagerState()
|
|
end
|
|
end
|
|
|
|
--- Calculate scrollbar dimensions and positions (delegates to ScrollManager)
|
|
---@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}}
|
|
function Element:_calculateScrollbarDimensions()
|
|
if self._scrollManager then
|
|
return self._scrollManager:calculateScrollbarDimensions(self)
|
|
end
|
|
-- Return empty result if no ScrollManager
|
|
return {
|
|
vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 },
|
|
horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 },
|
|
}
|
|
end
|
|
|
|
--- Draw scrollbars
|
|
|
|
--- Get scrollbar at mouse position (delegates to ScrollManager)
|
|
---@param mouseX number
|
|
---@param mouseY number
|
|
---@return table|nil -- {component: "vertical"|"horizontal", region: "thumb"|"track"}
|
|
function Element:_getScrollbarAtPosition(mouseX, mouseY)
|
|
if self._scrollManager then
|
|
return self._scrollManager:getScrollbarAtPosition(self, mouseX, mouseY)
|
|
end
|
|
return nil
|
|
end
|
|
|
|
--- Handle scrollbar mouse press
|
|
---@param mouseX number
|
|
---@param mouseY number
|
|
---@param button number
|
|
---@return boolean -- True if event was consumed
|
|
function Element:_handleScrollbarPress(mouseX, mouseY, button)
|
|
if self._scrollManager then
|
|
local consumed = self._scrollManager:handleMousePress(self, mouseX, mouseY, button)
|
|
self:_syncScrollManagerState()
|
|
return consumed
|
|
end
|
|
return false
|
|
end
|
|
|
|
--- Handle scrollbar drag (delegates to ScrollManager)
|
|
---@param mouseX number
|
|
---@param mouseY number
|
|
---@return boolean -- True if event was consumed
|
|
function Element:_handleScrollbarDrag(mouseX, mouseY)
|
|
if self._scrollManager then
|
|
local consumed = self._scrollManager:handleMouseMove(self, mouseX, mouseY)
|
|
self:_syncScrollManagerState()
|
|
return consumed
|
|
end
|
|
return false
|
|
end
|
|
|
|
--- Handle scrollbar release (delegates to ScrollManager)
|
|
---@param button number
|
|
---@return boolean -- True if event was consumed
|
|
function Element:_handleScrollbarRelease(button)
|
|
if self._scrollManager then
|
|
local consumed = self._scrollManager:handleMouseRelease(button)
|
|
self:_syncScrollManagerState()
|
|
return consumed
|
|
end
|
|
return false
|
|
end
|
|
|
|
--- Handle mouse wheel scrolling (delegates to ScrollManager)
|
|
---@param x number -- Horizontal scroll amount
|
|
---@param y number -- Vertical scroll amount
|
|
---@return boolean -- True if scroll was handled
|
|
function Element:_handleWheelScroll(x, y)
|
|
if self._scrollManager then
|
|
local consumed = self._scrollManager:handleWheel(x, y)
|
|
self:_syncScrollManagerState()
|
|
return consumed
|
|
end
|
|
return false
|
|
end
|
|
|
|
--- Query how far content is scrolled to implement scroll-aware UI like "back to top" buttons
|
|
--- Use this to create scroll position indicators or trigger lazy-loading
|
|
---@return number scrollX, number scrollY
|
|
function Element:getScrollPosition()
|
|
if self._scrollManager then
|
|
return self._scrollManager:getScroll()
|
|
end
|
|
return 0, 0
|
|
end
|
|
|
|
--- Find the scroll limits for validation and scroll position clamping
|
|
--- Use this to determine if content is fully scrolled or calculate remaining scroll distance
|
|
---@return number maxScrollX, number maxScrollY
|
|
function Element:getMaxScroll()
|
|
if self._scrollManager then
|
|
return self._scrollManager:getMaxScroll()
|
|
end
|
|
return 0, 0
|
|
end
|
|
|
|
--- Get normalized scroll progress for scroll-based animations or position indicators
|
|
--- Use this to drive progress bars or parallax effects based on scroll position
|
|
---@return number percentX, number percentY
|
|
function Element:getScrollPercentage()
|
|
if self._scrollManager then
|
|
return self._scrollManager:getScrollPercentage()
|
|
end
|
|
return 0, 0
|
|
end
|
|
|
|
--- Determine if content extends beyond visible bounds to conditionally show scrollbars or overflow indicators
|
|
--- Use this to decide whether to display scroll hints or enable scroll interactions
|
|
---@return boolean hasOverflowX, boolean hasOverflowY
|
|
function Element:hasOverflow()
|
|
if self._scrollManager then
|
|
return self._scrollManager:hasOverflow()
|
|
end
|
|
return false, false
|
|
end
|
|
|
|
--- Measure total content size including overflowed areas for scroll calculations
|
|
--- Use this to understand how much content exists beyond the visible viewport
|
|
---@return number contentWidth, number contentHeight
|
|
function Element:getContentSize()
|
|
if self._scrollManager then
|
|
return self._scrollManager:getContentSize()
|
|
end
|
|
return 0, 0
|
|
end
|
|
|
|
--- Scroll content by a relative amount for smooth scrolling animations or gesture-based scrolling
|
|
--- Use this to implement custom scroll controls or smooth scroll transitions
|
|
---@param dx number? -- X delta (nil for no change)
|
|
---@param dy number? -- Y delta (nil for no change)
|
|
function Element:scrollBy(dx, dy)
|
|
if self._scrollManager then
|
|
self._scrollManager:scrollBy(dx, dy)
|
|
self:_syncScrollManagerState()
|
|
end
|
|
end
|
|
|
|
--- Jump to the beginning of scrollable content instantly
|
|
--- Use this for "back to top" buttons or resetting scroll position
|
|
function Element:scrollToTop()
|
|
self:setScrollPosition(nil, 0)
|
|
end
|
|
|
|
--- Scroll to bottom
|
|
function Element:scrollToBottom()
|
|
if self._scrollManager then
|
|
local _, maxScrollY = self._scrollManager:getMaxScroll()
|
|
self:setScrollPosition(nil, maxScrollY)
|
|
end
|
|
end
|
|
|
|
--- Scroll to left
|
|
function Element:scrollToLeft()
|
|
self:setScrollPosition(0, nil)
|
|
end
|
|
|
|
--- Jump to the rightmost position of horizontally scrollable content
|
|
--- Use this to navigate to the end of horizontal lists or carousels
|
|
function Element:scrollToRight()
|
|
if self._scrollManager then
|
|
local maxScrollX, _ = self._scrollManager:getMaxScroll()
|
|
self:setScrollPosition(maxScrollX, nil)
|
|
end
|
|
end
|
|
|
|
--- 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 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
|
|
|
|
--- 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)
|
|
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)
|
|
|
|
-- 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
|
|
|
|
if self.autosizing.height 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 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
|
|
|
|
-- In immediate mode, defer layout until endFrame() when all elements are created
|
|
-- This prevents premature overflow detection with incomplete children
|
|
if not Element._Context._immediateMode then
|
|
self:layoutChildren()
|
|
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
|
|
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
|
|
if not Element._Context._immediateMode then
|
|
self:layoutChildren()
|
|
end
|
|
|
|
break
|
|
end
|
|
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
|
|
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)
|
|
if not Element._Context._immediateMode 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
|
|
|
|
-- Delegate layout to LayoutEngine
|
|
self._layoutEngine:layoutChildren()
|
|
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
|
|
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 retained children from StateManager (if this is an immediate-mode element)
|
|
if self._elementMode == "immediate" and self.id and self.id ~= "" then
|
|
Element._StateManager.clearRetainedChildren(self.id)
|
|
end
|
|
|
|
-- 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
|
|
end
|
|
|
|
--- Draw element and its children
|
|
function Element:draw(backdropCanvas)
|
|
-- Early exit if element is invisible (optimization)
|
|
if self.opacity <= 0 then
|
|
return
|
|
end
|
|
|
|
-- Handle opacity during animation
|
|
local drawBackgroundColor = self.backgroundColor
|
|
if self.animation then
|
|
local anim = self.animation:interpolate()
|
|
if anim.opacity then
|
|
drawBackgroundColor = Element._Color.new(self.backgroundColor.r, self.backgroundColor.g, self.backgroundColor.b, anim.opacity)
|
|
end
|
|
end
|
|
|
|
-- Cache border box dimensions for this draw call (optimization)
|
|
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)
|
|
|
|
-- LAYERS 0.5-3: Delegate visual rendering (backdrop blur, background, image, theme, borders) to Renderer module
|
|
self._renderer:draw(self, backdropCanvas)
|
|
|
|
-- LAYER 4: Delegate text rendering (text, cursor, selection, placeholder, password masking) to Renderer module
|
|
self._renderer:drawText(self)
|
|
|
|
-- LAYER 4.5: Custom draw callback (if provided)
|
|
if self.customDraw then
|
|
love.graphics.push()
|
|
love.graphics.setColor(1, 1, 1, 1) -- Reset color to white
|
|
self.customDraw(self)
|
|
love.graphics.pop()
|
|
end
|
|
|
|
-- Draw visual feedback when element is pressed (if it has an onEvent handler and highlight is not disabled)
|
|
if self.onEvent and not self.disableHighlight and self._eventHandler then
|
|
-- Check if any button is pressed
|
|
local anyPressed = false
|
|
local pressedState = self._eventHandler:getState()._pressed or {}
|
|
for _, pressed in pairs(pressedState) do
|
|
if pressed then
|
|
anyPressed = true
|
|
break
|
|
end
|
|
end
|
|
if anyPressed then
|
|
-- BORDER-BOX MODEL: Use stored border-box dimensions for drawing
|
|
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)
|
|
self._renderer:drawPressedState(self.x, self.y, borderBoxWidth, borderBoxHeight)
|
|
end
|
|
end
|
|
|
|
-- Sort children by z-index before drawing
|
|
local sortedChildren = {}
|
|
for _, child in ipairs(self.children) do
|
|
table.insert(sortedChildren, child)
|
|
end
|
|
table.sort(sortedChildren, function(a, b)
|
|
return a.z < b.z
|
|
end)
|
|
|
|
-- 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
|
|
|
|
-- Helper function to draw children (with or without clipping)
|
|
local function drawChildren()
|
|
-- Determine overflow behavior per axis (matches HTML/CSS behavior)
|
|
-- Priority: axis-specific (overflowX/Y) > general (overflow) > default (hidden)
|
|
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 offset if overflow is not visible
|
|
local hasScrollOffset = needsOverflowClipping and (self._scrollX ~= 0 or self._scrollY ~= 0)
|
|
|
|
if hasRoundedCorners and #sortedChildren > 0 then
|
|
-- Use stencil to clip children to rounded rectangle
|
|
-- BORDER-BOX MODEL: Use stored border-box dimensions for clipping
|
|
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 stencilFunc = Element._RoundedRect.stencilFunction(self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
|
|
|
|
-- Temporarily disable canvas for stencil operation (LÖVE 11.5 workaround)
|
|
local currentCanvas = love.graphics.getCanvas()
|
|
love.graphics.setCanvas()
|
|
love.graphics.stencil(stencilFunc, "replace", 1)
|
|
love.graphics.setCanvas(currentCanvas)
|
|
|
|
love.graphics.setStencilTest("greater", 0)
|
|
|
|
-- Apply scroll offset AFTER clipping is set
|
|
if hasScrollOffset then
|
|
love.graphics.push()
|
|
love.graphics.translate(-self._scrollX, -self._scrollY)
|
|
end
|
|
|
|
for _, child in ipairs(sortedChildren) do
|
|
child:draw(backdropCanvas)
|
|
end
|
|
|
|
if hasScrollOffset then
|
|
love.graphics.pop()
|
|
end
|
|
|
|
love.graphics.setStencilTest()
|
|
elseif needsOverflowClipping and #sortedChildren > 0 then
|
|
-- Clip content for overflow hidden/scroll/auto without rounded corners
|
|
local contentX = self.x + self.padding.left
|
|
local contentY = self.y + self.padding.top
|
|
local contentWidth = self.width
|
|
local contentHeight = self.height
|
|
|
|
love.graphics.setScissor(contentX, contentY, contentWidth, contentHeight)
|
|
|
|
-- Apply scroll offset AFTER clipping is set
|
|
if hasScrollOffset then
|
|
love.graphics.push()
|
|
love.graphics.translate(-self._scrollX, -self._scrollY)
|
|
end
|
|
|
|
for _, child in ipairs(sortedChildren) do
|
|
child:draw(backdropCanvas)
|
|
end
|
|
|
|
if hasScrollOffset then
|
|
love.graphics.pop()
|
|
end
|
|
|
|
love.graphics.setScissor()
|
|
else
|
|
-- No clipping needed
|
|
for _, child in ipairs(sortedChildren) do
|
|
child:draw(backdropCanvas)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Apply content blur if configured
|
|
if self.contentBlur and self.contentBlur.radius > 0 and #sortedChildren > 0 then
|
|
local blurInstance = self:getBlurInstance()
|
|
if blurInstance then
|
|
Element._Blur.applyToRegion(blurInstance, self.contentBlur.radius, self.x, self.y, borderBoxWidth, borderBoxHeight, drawChildren)
|
|
else
|
|
drawChildren()
|
|
end
|
|
else
|
|
drawChildren()
|
|
end
|
|
|
|
-- Draw scrollbars if overflow is scroll or auto
|
|
-- IMPORTANT: Scrollbars must be drawn without parent clipping
|
|
local overflowX = self.overflowX or self.overflow
|
|
local overflowY = self.overflowY or self.overflow
|
|
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then
|
|
local scrollbarDims = self:_calculateScrollbarDimensions()
|
|
if scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible then
|
|
-- Clear any parent scissor clipping before drawing scrollbars
|
|
love.graphics.setScissor()
|
|
-- Delegate scrollbar rendering to Renderer module
|
|
self._renderer:drawScrollbars(self, self.x, self.y, self.width, self.height, scrollbarDims)
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Update element (propagate to children)
|
|
---@param dt number
|
|
function Element:update(dt)
|
|
-- Track active animations for performance warnings (only on root elements)
|
|
if not self.parent then
|
|
self:_trackActiveAnimations()
|
|
end
|
|
|
|
-- Restore scrollbar state from StateManager in immediate mode
|
|
if self._stateId and self._elementMode == "immediate" then
|
|
local state = Element._StateManager.getState(self._stateId)
|
|
if state and state.scrollManager then
|
|
-- Restore from nested scrollManager state (saved via saveState())
|
|
self._scrollbarHoveredVertical = state.scrollManager._scrollbarHoveredVertical or false
|
|
self._scrollbarHoveredHorizontal = state.scrollManager._scrollbarHoveredHorizontal or false
|
|
self._scrollbarDragging = state.scrollManager._scrollbarDragging or false
|
|
self._hoveredScrollbar = state.scrollManager._hoveredScrollbar
|
|
self._scrollbarDragOffset = state.scrollManager._scrollbarDragOffset or 0
|
|
|
|
if self._scrollManager then
|
|
self._scrollManager._scrollbarHoveredVertical = self._scrollbarHoveredVertical
|
|
self._scrollManager._scrollbarHoveredHorizontal = self._scrollbarHoveredHorizontal
|
|
self._scrollManager._scrollbarDragging = self._scrollbarDragging
|
|
self._scrollManager._hoveredScrollbar = self._hoveredScrollbar
|
|
self._scrollManager._scrollbarDragOffset = self._scrollbarDragOffset
|
|
|
|
-- Restore drag start positions for relative movement tracking
|
|
self._scrollManager._dragStartMouseX = state.scrollManager._dragStartMouseX or 0
|
|
self._scrollManager._dragStartMouseY = state.scrollManager._dragStartMouseY or 0
|
|
self._scrollManager._dragStartScrollX = state.scrollManager._dragStartScrollX or 0
|
|
self._scrollManager._dragStartScrollY = state.scrollManager._dragStartScrollY or 0
|
|
end
|
|
end
|
|
end
|
|
|
|
for _, child in ipairs(self.children) do
|
|
child:update(dt)
|
|
end
|
|
|
|
-- Update text editor cursor blink
|
|
if self._textEditor then
|
|
self._textEditor:update(self, dt)
|
|
end
|
|
|
|
-- Update scroll manager for smooth scrolling and momentum
|
|
if self._scrollManager then
|
|
self._scrollManager:update(dt)
|
|
self:_syncScrollManagerState()
|
|
end
|
|
|
|
-- Update animation if exists
|
|
if self.animation then
|
|
-- Ensure animation has Color module reference for color interpolation
|
|
if Element._Animation and not Element._Animation._ColorModule and Element._Color then
|
|
Element._Animation._ColorModule = Element._Color
|
|
end
|
|
|
|
-- Ensure animation has Transform module reference for transform interpolation
|
|
if Element._Animation and not Element._Animation._TransformModule and Element._Transform then
|
|
Element._Animation._TransformModule = Element._Transform
|
|
end
|
|
|
|
local finished = self.animation:update(dt, self)
|
|
if finished then
|
|
-- Animation:update() already called onComplete callback
|
|
-- Check for chained animation
|
|
if self.animation._next then
|
|
self.animation = self.animation._next
|
|
elseif self.animation._nextFactory and type(self.animation._nextFactory) == "function" then
|
|
local success, nextAnim = pcall(self.animation._nextFactory, self)
|
|
if success and nextAnim then
|
|
self.animation = nextAnim
|
|
else
|
|
self.animation = nil
|
|
end
|
|
else
|
|
self.animation = nil
|
|
end
|
|
else
|
|
-- Apply animation interpolation during update
|
|
local anim = self.animation:interpolate()
|
|
|
|
-- Apply numeric properties
|
|
self.width = anim.width or self.width
|
|
self.height = anim.height or self.height
|
|
self.opacity = anim.opacity or self.opacity
|
|
self.x = anim.x or self.x
|
|
self.y = anim.y or self.y
|
|
self.gap = anim.gap or self.gap
|
|
self.imageOpacity = anim.imageOpacity or self.imageOpacity
|
|
self.scrollbarWidth = anim.scrollbarWidth or self.scrollbarWidth
|
|
self.borderWidth = anim.borderWidth or self.borderWidth
|
|
self.fontSize = anim.fontSize or self.fontSize
|
|
self.lineHeight = anim.lineHeight or self.lineHeight
|
|
|
|
-- Apply color properties
|
|
if anim.backgroundColor then
|
|
self.backgroundColor = anim.backgroundColor
|
|
end
|
|
if anim.borderColor then
|
|
self.borderColor = anim.borderColor
|
|
end
|
|
if anim.textColor then
|
|
self.textColor = anim.textColor
|
|
end
|
|
if anim.scrollbarColor then
|
|
self.scrollbarColor = anim.scrollbarColor
|
|
end
|
|
if anim.scrollbarBackgroundColor then
|
|
self.scrollbarBackgroundColor = anim.scrollbarBackgroundColor
|
|
end
|
|
if anim.imageTint then
|
|
self.imageTint = anim.imageTint
|
|
end
|
|
|
|
-- Apply table properties
|
|
if anim.padding then
|
|
self.padding = anim.padding
|
|
end
|
|
if anim.margin then
|
|
self.margin = anim.margin
|
|
end
|
|
if anim.cornerRadius then
|
|
self.cornerRadius = anim.cornerRadius
|
|
end
|
|
|
|
-- Apply transform property
|
|
if anim.transform then
|
|
self.transform = anim.transform
|
|
end
|
|
|
|
-- Backward compatibility: Update background color with interpolated opacity
|
|
if anim.opacity and not anim.backgroundColor then
|
|
self.backgroundColor.a = anim.opacity
|
|
end
|
|
end
|
|
end
|
|
|
|
local mx, my = love.mouse.getPosition()
|
|
|
|
if self._scrollManager then
|
|
self._scrollManager:updateHoverState(self, mx, my)
|
|
self:_syncScrollManagerState()
|
|
end
|
|
|
|
-- Note: Scrollbar state is saved via saveState() -> ScrollManager:getState() at end of frame
|
|
-- This intermediate save is kept for backward compatibility with hover states
|
|
|
|
if self._scrollbarDragging and love.mouse.isDown(1) then
|
|
self:_handleScrollbarDrag(mx, my)
|
|
elseif self._scrollbarDragging then
|
|
if self._scrollManager then
|
|
self._scrollManager:handleMouseRelease(1)
|
|
self:_syncScrollManagerState()
|
|
end
|
|
|
|
if self._stateId and self._elementMode == "immediate" then
|
|
Element._StateManager.updateState(self._stateId, {
|
|
scrollbarDragging = false,
|
|
})
|
|
end
|
|
end
|
|
|
|
-- Handle scrollbar click/press (independent of onEvent)
|
|
-- Check if we should handle scrollbar press for elements with overflow
|
|
local overflowX = self.overflowX or self.overflow
|
|
local overflowY = self.overflowY or self.overflow
|
|
local hasScrollableOverflow = (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
|
|
|
|
if hasScrollableOverflow and not self._scrollbarDragging then
|
|
-- Check for scrollbar press on left mouse button
|
|
if love.mouse.isDown(1) and not self._scrollbarPressHandled then
|
|
local scrollbarPressed = self:_handleScrollbarPress(mx, my, 1)
|
|
if scrollbarPressed then
|
|
self._scrollbarPressHandled = true
|
|
end
|
|
elseif not love.mouse.isDown(1) then
|
|
-- Reset press handled flag when button is released
|
|
self._scrollbarPressHandled = false
|
|
end
|
|
end
|
|
|
|
if self.onEvent or self.themeComponent or self.editable then
|
|
-- Clickable area is the border box (x, y already includes padding)
|
|
-- BORDER-BOX MODEL: Use stored border-box dimensions for hit detection
|
|
local bx = self.x
|
|
local by = self.y
|
|
local bw = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
|
|
local bh = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
|
|
|
|
-- Account for scroll offsets from parent containers
|
|
-- Walk up the parent chain and accumulate scroll offsets
|
|
local scrollOffsetX = 0
|
|
local scrollOffsetY = 0
|
|
local current = self.parent
|
|
while current do
|
|
local overflowX = current.overflowX or current.overflow
|
|
local overflowY = current.overflowY or current.overflow
|
|
local hasScrollableOverflow = (
|
|
overflowX == "scroll"
|
|
or overflowX == "auto"
|
|
or overflowY == "scroll"
|
|
or overflowY == "auto"
|
|
or overflowX == "hidden"
|
|
or overflowY == "hidden"
|
|
)
|
|
if hasScrollableOverflow then
|
|
scrollOffsetX = scrollOffsetX + (current._scrollX or 0)
|
|
scrollOffsetY = scrollOffsetY + (current._scrollY or 0)
|
|
end
|
|
current = current.parent
|
|
end
|
|
|
|
-- Adjust mouse position by accumulated scroll offset for hit testing
|
|
local adjustedMx = mx + scrollOffsetX
|
|
local adjustedMy = my + scrollOffsetY
|
|
local isHovering = adjustedMx >= bx and adjustedMx <= bx + bw and adjustedMy >= by and adjustedMy <= by + bh
|
|
|
|
-- Check if this is the topmost element at the mouse position (z-index ordering)
|
|
-- This prevents blocked elements from receiving interactions or visual feedback
|
|
local isActiveElement
|
|
if Element._Context._immediateMode then
|
|
-- In immediate mode, use z-index occlusion detection
|
|
local topElement = Element._Context.getTopElementAt(mx, my)
|
|
isActiveElement = (topElement == self or topElement == nil)
|
|
else
|
|
-- In retained mode, use the old _activeEventElement mechanism
|
|
isActiveElement = (Element._Context._activeEventElement == nil or Element._Context._activeEventElement == self)
|
|
end
|
|
|
|
-- Reset scrollbar press flag at start of each frame
|
|
self._eventHandler:resetScrollbarPressFlag()
|
|
|
|
-- Process mouse events through EventHandler FIRST
|
|
-- This ensures pressed states are updated before theme state is calculated
|
|
self._eventHandler:processMouseEvents(self, mx, my, isHovering, isActiveElement)
|
|
|
|
-- In immediate mode, save EventHandler state to StateManager after processing events
|
|
if self._stateId and self._elementMode == "immediate" and self._stateId ~= "" then
|
|
local eventHandlerState = self._eventHandler:getState()
|
|
Element._StateManager.updateState(self._stateId, {
|
|
_pressed = eventHandlerState._pressed,
|
|
_lastClickTime = eventHandlerState._lastClickTime,
|
|
_lastClickButton = eventHandlerState._lastClickButton,
|
|
_clickCount = eventHandlerState._clickCount,
|
|
_dragStartX = eventHandlerState._dragStartX,
|
|
_dragStartY = eventHandlerState._dragStartY,
|
|
_lastMouseX = eventHandlerState._lastMouseX,
|
|
_lastMouseY = eventHandlerState._lastMouseY,
|
|
_hovered = eventHandlerState._hovered,
|
|
})
|
|
end
|
|
|
|
-- Update theme state based on interaction
|
|
if self.themeComponent then
|
|
-- Check if any button is pressed via EventHandler
|
|
local anyPressed = self._eventHandler:isAnyButtonPressed()
|
|
|
|
-- Update theme state via ThemeManager
|
|
local newThemeState = self._themeManager:updateState(isHovering and isActiveElement, anyPressed, self._focused, self.disabled)
|
|
|
|
if self._stateId and self._elementMode == "immediate" then
|
|
local hover = newThemeState == "hover"
|
|
local pressed = newThemeState == "pressed"
|
|
local focused = newThemeState == "active" or self._focused
|
|
|
|
Element._StateManager.updateState(self._stateId, {
|
|
hover = hover,
|
|
pressed = pressed,
|
|
focused = focused,
|
|
disabled = self.disabled,
|
|
active = self.active,
|
|
})
|
|
end
|
|
|
|
if self._renderer then
|
|
self._renderer:setThemeState(newThemeState)
|
|
end
|
|
end
|
|
|
|
-- Process touch events through EventHandler
|
|
self._eventHandler:processTouchEvents(self)
|
|
end
|
|
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)
|
|
|
|
-- 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.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.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 = contentWidth + self.padding.left + self.padding.right
|
|
self.width = contentWidth
|
|
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 = contentHeight + self.padding.top + self.padding.bottom
|
|
self.height = contentHeight
|
|
end
|
|
|
|
-- Re-resolve ew/eh textSize units after all dimensions are finalized
|
|
-- This ensures textSize updates based on current width/height (whether calculated or manually set)
|
|
if self.units.textSize.value then
|
|
local unit = self.units.textSize.unit
|
|
local value = self.units.textSize.value
|
|
local _, scaleY = Element._Context.getScaleFactors()
|
|
|
|
if unit == "ew" then
|
|
-- Element width relative (use current width)
|
|
self.textSize = (value / 100) * self.width
|
|
|
|
-- Apply min/max constraints
|
|
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
|
|
if self.textSize < 1 then
|
|
self.textSize = 1
|
|
end
|
|
elseif unit == "eh" then
|
|
-- Element height relative (use current height)
|
|
self.textSize = (value / 100) * self.height
|
|
|
|
-- Apply min/max constraints
|
|
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
|
|
if self.textSize < 1 then
|
|
self.textSize = 1
|
|
end
|
|
end
|
|
end
|
|
|
|
self:layoutChildren()
|
|
self.prevGameSize.width = newGameWidth
|
|
self.prevGameSize.height = newGameHeight
|
|
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 wrappedWidth, wrappedLines = font:getWrap(self.text, availableWidth)
|
|
height = height * #wrappedLines
|
|
end
|
|
end
|
|
|
|
return Element._utils.applyContentMultiplier(height, self.contentAutoSizingMultiplier, "height")
|
|
end
|
|
|
|
function Element:calculateAutoWidth()
|
|
return self._layoutEngine:calculateAutoWidth()
|
|
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 - Cursor Management
|
|
-- ====================
|
|
|
|
--- Set cursor position
|
|
---@param position number -- Character index (0-based)
|
|
function Element:setCursorPosition(position)
|
|
if self._textEditor then
|
|
self._textEditor:setCursorPosition(self, position)
|
|
end
|
|
end
|
|
|
|
--- Get cursor position
|
|
---@return number -- Character index (0-based)
|
|
function Element:getCursorPosition()
|
|
if self._textEditor then
|
|
return self._textEditor:getCursorPosition()
|
|
end
|
|
return 0
|
|
end
|
|
|
|
--- Move cursor by delta characters
|
|
---@param delta number -- Number of characters to move (positive or negative)
|
|
function Element:moveCursorBy(delta)
|
|
if self._textEditor then
|
|
self._textEditor:moveCursorBy(self, delta)
|
|
end
|
|
end
|
|
|
|
--- Move cursor to start of text
|
|
function Element:moveCursorToStart()
|
|
if self._textEditor then
|
|
self._textEditor:moveCursorToStart(self)
|
|
end
|
|
end
|
|
|
|
--- Move cursor to end of text
|
|
function Element:moveCursorToEnd()
|
|
if self._textEditor then
|
|
self._textEditor:moveCursorToEnd(self)
|
|
end
|
|
end
|
|
|
|
--- Move cursor to start of current line
|
|
function Element:moveCursorToLineStart()
|
|
if self._textEditor then
|
|
self._textEditor:moveCursorToLineStart(self)
|
|
end
|
|
end
|
|
|
|
--- Move cursor to end of current line
|
|
function Element:moveCursorToLineEnd()
|
|
if self._textEditor then
|
|
self._textEditor:moveCursorToLineEnd(self)
|
|
end
|
|
end
|
|
|
|
--- Move cursor to start of previous word
|
|
function Element:moveCursorToPreviousWord()
|
|
if self._textEditor then
|
|
self._textEditor:moveCursorToPreviousWord(self)
|
|
end
|
|
end
|
|
|
|
--- Move cursor to start of next word
|
|
function Element:moveCursorToNextWord()
|
|
if self._textEditor then
|
|
self._textEditor:moveCursorToNextWord(self)
|
|
end
|
|
end
|
|
|
|
-- ====================
|
|
-- Input Handling - Selection Management
|
|
-- ====================
|
|
|
|
--- Set selection range
|
|
---@param startPos number -- Start position (inclusive)
|
|
---@param endPos number -- End position (inclusive)
|
|
function Element:setSelection(startPos, endPos)
|
|
if self._textEditor then
|
|
self._textEditor:setSelection(self, startPos, endPos)
|
|
end
|
|
end
|
|
|
|
--- Get selection range
|
|
---@return number?, number? -- Start and end positions, or nil if no selection
|
|
function Element:getSelection()
|
|
if self._textEditor then
|
|
return self._textEditor:getSelection()
|
|
end
|
|
return nil, nil
|
|
end
|
|
|
|
--- Check if there is an active selection
|
|
---@return boolean
|
|
function Element:hasSelection()
|
|
if self._textEditor then
|
|
return self._textEditor:hasSelection()
|
|
end
|
|
return false
|
|
end
|
|
|
|
--- Clear selection
|
|
function Element:clearSelection()
|
|
if self._textEditor then
|
|
self._textEditor:clearSelection(self)
|
|
end
|
|
end
|
|
|
|
--- Select all text
|
|
function Element:selectAll()
|
|
if self._textEditor then
|
|
self._textEditor:selectAll(self)
|
|
end
|
|
end
|
|
|
|
--- Get selected text
|
|
---@return string? -- Selected text or nil if no selection
|
|
function Element:getSelectedText()
|
|
if self._textEditor then
|
|
return self._textEditor:getSelectedText()
|
|
end
|
|
return nil
|
|
end
|
|
|
|
--- Delete selected text
|
|
---@return boolean -- True if text was deleted
|
|
function Element:deleteSelection()
|
|
if self._textEditor then
|
|
local result = self._textEditor:deleteSelection(self)
|
|
if result then
|
|
self.text = self._textEditor:getText() -- Sync display text
|
|
self._textEditor:updateAutoGrowHeight(self)
|
|
end
|
|
return result
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- ====================
|
|
-- Input Handling - Focus Management
|
|
-- ====================
|
|
|
|
--- 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()
|
|
if self._textEditor then
|
|
self._textEditor:focus(self)
|
|
end
|
|
end
|
|
|
|
--- Remove keyboard focus to stop capturing input events
|
|
--- Use this when closing popups or switching focus to other elements
|
|
function Element:blur()
|
|
if self._textEditor then
|
|
self._textEditor:blur(self)
|
|
end
|
|
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()
|
|
if self._textEditor then
|
|
return self._textEditor:isFocused()
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- ====================
|
|
-- Input Handling - Text Buffer Management
|
|
-- ====================
|
|
|
|
--- 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()
|
|
if self._textEditor then
|
|
return self._textEditor:getText()
|
|
end
|
|
return self.text or ""
|
|
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)
|
|
if self._textEditor then
|
|
self._textEditor:setText(self, text)
|
|
self.text = self._textEditor:getText() -- Sync display text
|
|
self._textEditor:updateAutoGrowHeight(self)
|
|
return
|
|
end
|
|
self.text = 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)
|
|
if self._textEditor then
|
|
self._textEditor:insertText(self, text, position)
|
|
self.text = self._textEditor:getText() -- Sync display text
|
|
self._textEditor:updateAutoGrowHeight(self)
|
|
end
|
|
end
|
|
|
|
---@param startPos number -- Start position (inclusive)
|
|
---@param endPos number -- End position (inclusive)
|
|
function Element:deleteText(startPos, endPos)
|
|
if self._textEditor then
|
|
self._textEditor:deleteText(self, startPos, endPos)
|
|
self.text = self._textEditor:getText() -- Sync display text
|
|
self._textEditor:updateAutoGrowHeight(self)
|
|
end
|
|
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)
|
|
if self._textEditor then
|
|
self._textEditor:replaceText(self, startPos, endPos, newText)
|
|
self.text = self._textEditor:getText() -- Sync display text
|
|
self._textEditor:updateAutoGrowHeight(self)
|
|
end
|
|
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)
|
|
---@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)
|
|
if self._textEditor then
|
|
self._textEditor:handleTextClick(self, mouseX, mouseY, clickCount)
|
|
-- Store mouse down position on element for drag tracking
|
|
if clickCount == 1 then
|
|
self._mouseDownPosition = self._textEditor:mouseToTextPosition(self, mouseX, mouseY)
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Handle mouse drag for text selection
|
|
---@param mouseX number -- Mouse X coordinate
|
|
---@param mouseY number -- Mouse Y coordinate
|
|
function Element:_handleTextDrag(mouseX, mouseY)
|
|
if self._textEditor then
|
|
self._textEditor:handleTextDrag(self, mouseX, mouseY)
|
|
self._textDragOccurred = self._textEditor._textDragOccurred
|
|
end
|
|
end
|
|
|
|
-- ====================
|
|
-- Input Handling - Keyboard Input
|
|
-- ====================
|
|
|
|
--- Handle text input (character input)
|
|
---@param text string -- Character(s) to insert
|
|
function Element:textinput(text)
|
|
if self._textEditor then
|
|
self._textEditor:handleTextInput(self, text)
|
|
self.text = self._textEditor:getText() -- Sync display text
|
|
self._textEditor:updateAutoGrowHeight(self)
|
|
end
|
|
end
|
|
|
|
--- Handle key press (special keys)
|
|
---@param key string -- Key name
|
|
---@param scancode string -- Scancode
|
|
---@param isrepeat boolean -- Whether this is a key repeat
|
|
function Element:keypressed(key, scancode, isrepeat)
|
|
if self._textEditor then
|
|
self._textEditor:handleKeyPress(self, key, scancode, isrepeat)
|
|
self.text = self._textEditor:getText() -- Sync display text
|
|
self._textEditor:updateAutoGrowHeight(self)
|
|
end
|
|
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
|
|
if self._renderer then
|
|
self._renderer.imageTint = color
|
|
end
|
|
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
|
|
if self._renderer then
|
|
self._renderer.imageOpacity = opacity
|
|
end
|
|
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
|
|
if self._renderer then
|
|
self._renderer.imageRepeat = repeatMode
|
|
end
|
|
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
|
|
|
|
--- 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
|
|
Element._ErrorHandler:warn("Element", "ELEM_003")
|
|
config = {}
|
|
end
|
|
|
|
-- Validate config
|
|
if config.duration and (type(config.duration) ~= "number" or config.duration < 0) then
|
|
Element._ErrorHandler:warn("Element", "ELEM_004", {
|
|
value = tostring(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(groupName, config, properties)
|
|
if type(properties) ~= "table" then
|
|
Element._ErrorHandler:warn("Element", "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
|
|
|
|
--- Set property with automatic transition
|
|
---@param property string Property name
|
|
---@param value any New value
|
|
function Element:setProperty(property, value)
|
|
-- Check if transitions are enabled for this property
|
|
local shouldTransition = false
|
|
local transitionConfig = nil
|
|
|
|
if self.transitions then
|
|
transitionConfig = self.transitions[property] or self.transitions["all"]
|
|
shouldTransition = transitionConfig ~= nil
|
|
end
|
|
|
|
-- Don't transition if value is the same
|
|
if self[property] == value then
|
|
return
|
|
end
|
|
|
|
-- Properties that affect layout and require invalidation
|
|
local layoutProperties = {
|
|
width = true,
|
|
height = true,
|
|
padding = true,
|
|
margin = true,
|
|
gap = true,
|
|
flexDirection = true,
|
|
flexWrap = true,
|
|
justifyContent = true,
|
|
alignItems = true,
|
|
alignContent = true,
|
|
positioning = true,
|
|
gridRows = true,
|
|
gridColumns = true,
|
|
top = true,
|
|
right = true,
|
|
bottom = true,
|
|
left = true,
|
|
}
|
|
|
|
if shouldTransition and transitionConfig then
|
|
local currentValue = self[property]
|
|
|
|
-- Only transition if we have a valid current value
|
|
if currentValue ~= nil then
|
|
-- Create animation for the property change
|
|
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
|
|
else
|
|
self[property] = value
|
|
end
|
|
|
|
-- Invalidate layout if this property affects layout
|
|
if layoutProperties[property] then
|
|
self:invalidateLayout()
|
|
end
|
|
end
|
|
|
|
-- ====================
|
|
-- State Persistence
|
|
-- ====================
|
|
|
|
--- Save all element state for immediate mode persistence
|
|
--- Collects state from all sub-modules and returns consolidated state
|
|
---@return ElementStateData state Complete state snapshot
|
|
function Element:saveState()
|
|
local state = {}
|
|
if self._eventHandler then
|
|
state.eventHandler = self._eventHandler:getState()
|
|
end
|
|
if self._textEditor then
|
|
state.textEditor = self._textEditor:getState()
|
|
end
|
|
if self._scrollManager then
|
|
state.scrollManager = self._scrollManager:getState()
|
|
end
|
|
if self.backdropBlur or self.contentBlur then
|
|
state.blur = {
|
|
_blurX = self.x,
|
|
_blurY = self.y,
|
|
_blurWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right),
|
|
_blurHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom),
|
|
}
|
|
|
|
if self.backdropBlur then
|
|
state.blur._backdropBlurRadius = self.backdropBlur.radius
|
|
state.blur._backdropBlurQuality = self.backdropBlur.quality or 5
|
|
end
|
|
|
|
if self.contentBlur then
|
|
state.blur._contentBlurRadius = self.contentBlur.radius
|
|
state.blur._contentBlurQuality = self.contentBlur.quality or 5
|
|
end
|
|
end
|
|
|
|
-- Save drag tracking state for text selection
|
|
if self._mouseDownPosition ~= nil then
|
|
state._mouseDownPosition = self._mouseDownPosition
|
|
end
|
|
if self._textDragOccurred ~= nil then
|
|
state._textDragOccurred = self._textDragOccurred
|
|
end
|
|
|
|
-- Save retained children references (for mixed-mode trees)
|
|
-- Only save if this is an immediate-mode element with retained children
|
|
if self._elementMode == "immediate" and #self.children > 0 then
|
|
Element._StateManager.saveRetainedChildren(self.id, self.children)
|
|
end
|
|
|
|
return state
|
|
end
|
|
|
|
--- Restore all element state from StateManager
|
|
--- Distributes state to all sub-modules
|
|
---@param state ElementStateData State to restore
|
|
function Element:restoreState(state)
|
|
if not state then
|
|
return
|
|
end
|
|
|
|
-- Restore EventHandler state (if exists)
|
|
if self._eventHandler and state.eventHandler then
|
|
self._eventHandler:setState(state.eventHandler)
|
|
end
|
|
|
|
-- Restore TextEditor state (if exists)
|
|
if self._textEditor and state.textEditor then
|
|
self._textEditor:setState(state.textEditor, self)
|
|
-- Sync TextEditor's focus state to Element for theme management
|
|
self._focused = self._textEditor._focused
|
|
self._cursorPosition = self._textEditor._cursorPosition
|
|
self._selectionStart = self._textEditor._selectionStart
|
|
self._selectionEnd = self._textEditor._selectionEnd
|
|
self._textBuffer = self._textEditor._textBuffer
|
|
end
|
|
|
|
-- Restore ScrollManager state (if exists)
|
|
if self._scrollManager and state.scrollManager then
|
|
self._scrollManager:setState(state.scrollManager)
|
|
end
|
|
|
|
-- Restore drag tracking state for text selection
|
|
if state._mouseDownPosition ~= nil then
|
|
self._mouseDownPosition = state._mouseDownPosition
|
|
end
|
|
if state._textDragOccurred ~= nil then
|
|
self._textDragOccurred = state._textDragOccurred
|
|
end
|
|
|
|
-- Note: Blur cache data is used for invalidation, not restoration
|
|
end
|
|
|
|
--- Check if blur cache should be invalidated based on state changes
|
|
---@param oldState ElementStateData? Previous state
|
|
---@param newState ElementStateData Current state
|
|
---@return boolean shouldInvalidate True if blur cache should be cleared
|
|
function Element:shouldInvalidateBlurCache(oldState, newState)
|
|
if not oldState or not oldState.blur or not newState.blur then
|
|
return false
|
|
end
|
|
|
|
local old = oldState.blur
|
|
local new = newState.blur
|
|
|
|
-- Check if any blur-related property changed
|
|
return old._blurX ~= new._blurX
|
|
or old._blurY ~= new._blurY
|
|
or old._blurWidth ~= new._blurWidth
|
|
or old._blurHeight ~= new._blurHeight
|
|
or old._backdropBlurRadius ~= new._backdropBlurRadius
|
|
or old._backdropBlurQuality ~= new._backdropBlurQuality
|
|
or old._contentBlurRadius ~= new._contentBlurRadius
|
|
or old._contentBlurQuality ~= new._contentBlurQuality
|
|
end
|
|
|
|
--- Cleanup method to break circular references (for immediate mode)
|
|
--- Note: Cleans internal module state but keeps structure for inspection
|
|
function Element:_cleanup()
|
|
-- Clear event callbacks (may hold closures)
|
|
self.onEvent = nil
|
|
self.onFocus = nil
|
|
self.onBlur = nil
|
|
self.onTextInput = nil
|
|
self.onTextChange = nil
|
|
self.onEnter = nil
|
|
self.onImageLoad = nil
|
|
self.onImageError = nil
|
|
end
|
|
|
|
return Element
|