This commit is contained in:
Michael Freno
2025-11-12 17:53:22 -05:00
parent 7faa8b4ec6
commit 1293ca81a4
7 changed files with 3868 additions and 4611 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,863 +0,0 @@
-- ====================
-- EventHandler Module
-- ====================
-- Extracted event handling functionality from Element.lua
-- Handles all mouse, keyboard, touch, and drag events for interactive elements
-- Setup module path for relative requires
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Module dependencies
local GuiState = req("GuiState")
local InputEvent = req("InputEvent")
local StateManager = req("StateManager")
local utils = req("utils")
-- Extract utilities
local getModifiers = utils.getModifiers
-- Reference to Gui (via GuiState)
local Gui = GuiState
---@class EventHandler
---@field onEvent fun(element:Element, event:InputEvent)?
---@field _pressed table<number, boolean> -- Track pressed state per mouse button
---@field _lastClickTime number? -- Timestamp of last click for double-click detection
---@field _lastClickButton number? -- Button of last click
---@field _clickCount number -- Current click count for multi-click detection
---@field _touchPressed table<any, boolean> -- Track touch pressed state
---@field _dragStartX table<number, number>? -- Track drag start X position per mouse button
---@field _dragStartY table<number, number>? -- Track drag start Y position per mouse button
---@field _lastMouseX table<number, number>? -- Last known mouse X position per button for drag tracking
---@field _lastMouseY table<number, number>? -- Last known mouse Y position per button for drag tracking
---@field _scrollbarPressHandled boolean? -- Track if scrollbar press was handled
---@field _element Element? -- Reference to parent element
local EventHandler = {}
EventHandler.__index = EventHandler
--- Create a new EventHandler instance
---@param config table Configuration options
---@return EventHandler
function EventHandler.new(config)
local self = setmetatable({}, EventHandler)
-- Configuration
self.onEvent = config.onEvent
-- Initialize click tracking for event system
self._pressed = {} -- Track pressed state per mouse button
self._lastClickTime = nil
self._lastClickButton = nil
self._clickCount = 0
self._touchPressed = {}
-- Initialize drag tracking for event system
self._dragStartX = {} -- Track drag start X position per mouse button
self._dragStartY = {} -- Track drag start Y position per mouse button
self._lastMouseX = {} -- Track last mouse X position per button
self._lastMouseY = {} -- Track last mouse Y position per button
-- Scrollbar press tracking
self._scrollbarPressHandled = false
-- Element reference (set via initialize)
self._element = nil
return self
end
--- Initialize with parent element reference
---@param element Element The parent element
function EventHandler:initialize(element)
self._element = element
-- Restore state from StateManager in immediate mode
if Gui._immediateMode and element._stateId then
local state = StateManager.getState(element._stateId)
if state then
-- Restore pressed state
if state._pressed then
self._pressed = state._pressed
end
-- Restore click tracking
if state._lastClickTime then
self._lastClickTime = state._lastClickTime
end
if state._lastClickButton then
self._lastClickButton = state._lastClickButton
end
if state._clickCount then
self._clickCount = state._clickCount
end
end
end
end
--- Update event handler state (called every frame)
---@param dt number Delta time
function EventHandler:update(dt)
if not self._element then
return
end
local element = self._element
local mx, my = love.mouse.getPosition()
-- Only process events if element has event handler, theme component, or is editable
if not (element.onEvent or element.themeComponent or element.editable) then
return
end
-- Get element bounds (border box)
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
-- Account for scroll offsets from parent containers
local scrollOffsetX = 0
local scrollOffsetY = 0
local current = element.parent
while current do
local overflowX = current.overflowX or current.overflow
local overflowY = current.overflowY or current.overflow
local hasScrollableOverflow = (
overflowX == "scroll"
or overflowX == "auto"
or overflowY == "scroll"
or overflowY == "auto"
or overflowX == "hidden"
or overflowY == "hidden"
)
if hasScrollableOverflow then
scrollOffsetX = scrollOffsetX + (current._scrollX or 0)
scrollOffsetY = scrollOffsetY + (current._scrollY or 0)
end
current = current.parent
end
-- Adjust mouse position by accumulated scroll offset for hit testing
local adjustedMx = mx + scrollOffsetX
local adjustedMy = my + scrollOffsetY
local isHovering = adjustedMx >= bx and adjustedMx <= bx + bw and adjustedMy >= by and adjustedMy <= by + bh
-- Check if this is the topmost element at the mouse position (z-index ordering)
local isActiveElement
if Gui._immediateMode then
-- In immediate mode, use z-index occlusion detection
local topElement = GuiState.getTopElementAt(mx, my)
isActiveElement = (topElement == element or topElement == nil)
else
-- In retained mode, use the old _activeEventElement mechanism
isActiveElement = (Gui._activeEventElement == nil or Gui._activeEventElement == element)
end
-- Update theme state based on interaction
if element.themeComponent then
local newThemeState = "normal"
-- Disabled state takes priority
if element.disabled then
newThemeState = "disabled"
-- Active state (for inputs when focused/typing)
elseif element.active then
newThemeState = "active"
-- Only show hover/pressed states if this element is active (not blocked)
elseif isHovering and isActiveElement then
-- Check if any button is pressed
local anyPressed = false
for _, pressed in pairs(self._pressed) do
if pressed then
anyPressed = true
break
end
end
if anyPressed then
newThemeState = "pressed"
else
newThemeState = "hover"
end
end
-- Update state (in StateManager if in immediate mode, otherwise locally)
if element._stateId and Gui._immediateMode then
-- Update in StateManager for immediate mode
local hover = newThemeState == "hover"
local pressed = newThemeState == "pressed"
local focused = newThemeState == "active" or element._focused
StateManager.updateState(element._stateId, {
hover = hover,
pressed = pressed,
focused = focused,
disabled = element.disabled,
active = element.active,
})
end
-- Always update local state for backward compatibility
element._themeState = newThemeState
end
-- Only process button events if onEvent handler exists, element is not disabled,
-- and this is the topmost element at the mouse position (z-index ordering)
-- Exception: Allow drag continuation even if occluded (once drag starts, it continues)
local isDragging = false
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] and love.mouse.isDown(button) then
isDragging = true
break
end
end
local canProcessEvents = (element.onEvent or element.editable) and not element.disabled and (isActiveElement or isDragging)
if canProcessEvents then
-- Check all three mouse buttons
local buttons = { 1, 2, 3 } -- left, right, middle
for _, button in ipairs(buttons) do
if isHovering or isDragging then
if love.mouse.isDown(button) then
-- Button is pressed down
if not self._pressed[button] then
-- Check if press is on scrollbar first (skip if already handled)
if button == 1 and not self._scrollbarPressHandled and element._handleScrollbarPress and element:_handleScrollbarPress(mx, my, button) then
-- Scrollbar consumed the event, mark as pressed to prevent onEvent
self._pressed[button] = true
self._scrollbarPressHandled = true
else
-- Just pressed - fire press event and record drag start position
local modifiers = getModifiers()
if element.onEvent then
local pressEvent = InputEvent.new({
type = "press",
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 1,
})
element.onEvent(element, pressEvent)
end
self._pressed[button] = true
-- Set mouse down position for text selection on left click
if button == 1 and element.editable then
element._mouseDownPosition = element:_mouseToTextPosition(mx, my)
element._textDragOccurred = false -- Reset drag flag on press
end
end
-- Record drag start position per button
self._dragStartX[button] = mx
self._dragStartY[button] = my
self._lastMouseX[button] = mx
self._lastMouseY[button] = my
else
-- Button is still pressed - check for mouse movement (drag)
local lastX = self._lastMouseX[button] or mx
local lastY = self._lastMouseY[button] or my
if lastX ~= mx or lastY ~= my then
-- Mouse has moved - fire drag event only if still hovering
if element.onEvent and isHovering then
local modifiers = getModifiers()
local dx = mx - self._dragStartX[button]
local dy = my - self._dragStartY[button]
local dragEvent = InputEvent.new({
type = "drag",
button = button,
x = mx,
y = my,
dx = dx,
dy = dy,
modifiers = modifiers,
clickCount = 1,
})
element.onEvent(element, dragEvent)
end
-- Handle text selection drag for editable elements
if button == 1 and element.editable and element._focused then
element:_handleTextDrag(mx, my)
end
-- Update last known position for this button
self._lastMouseX[button] = mx
self._lastMouseY[button] = my
end
end
elseif self._pressed[button] then
-- Button was just released - fire click event
local currentTime = love.timer.getTime()
local modifiers = getModifiers()
-- Determine click count (double-click detection)
local clickCount = 1
local doubleClickThreshold = 0.3 -- 300ms for double-click
if self._lastClickTime and self._lastClickButton == button and (currentTime - self._lastClickTime) < doubleClickThreshold then
clickCount = self._clickCount + 1
else
clickCount = 1
end
self._clickCount = clickCount
self._lastClickTime = currentTime
self._lastClickButton = button
-- Determine event type based on button
local eventType = "click"
if button == 2 then
eventType = "rightclick"
elseif button == 3 then
eventType = "middleclick"
end
if element.onEvent then
local clickEvent = InputEvent.new({
type = eventType,
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = clickCount,
})
element.onEvent(element, clickEvent)
end
self._pressed[button] = false
-- Clean up drag tracking
self._dragStartX[button] = nil
self._dragStartY[button] = nil
-- Clean up text selection drag tracking
if button == 1 then
element._mouseDownPosition = nil
end
-- Focus editable elements on left click
if button == 1 and element.editable then
-- Only focus if not already focused (to avoid moving cursor to end)
local wasFocused = element:isFocused()
if not wasFocused then
element:focus()
end
-- Handle text click for cursor positioning and word selection
-- Only process click if no text drag occurred (to preserve drag selection)
if not element._textDragOccurred then
element:_handleTextClick(mx, my, clickCount)
end
-- Reset drag flag after release
element._textDragOccurred = false
end
-- Fire release event
if element.onEvent then
local releaseEvent = InputEvent.new({
type = "release",
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = clickCount,
})
element.onEvent(element, releaseEvent)
end
end
else
-- Mouse left the element - reset pressed state and drag tracking
if self._pressed[button] then
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
end
end
end
end
-- Handle touch events (maintain backward compatibility)
if element.onEvent then
local touches = love.touch.getTouches()
for _, id in ipairs(touches) do
local tx, ty = love.touch.getPosition(id)
if tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh then
self._touchPressed[id] = true
elseif self._touchPressed[id] then
-- Create touch event (treat as left click)
local touchEvent = InputEvent.new({
type = "click",
button = 1,
x = tx,
y = ty,
modifiers = getModifiers(),
clickCount = 1,
})
element.onEvent(element, touchEvent)
self._touchPressed[id] = false
end
end
end
-- Save state to StateManager in immediate mode
if element._stateId and Gui._immediateMode then
StateManager.updateState(element._stateId, {
_pressed = self._pressed,
_lastClickTime = self._lastClickTime,
_lastClickButton = self._lastClickButton,
_clickCount = self._clickCount,
})
end
end
--- Handle mouse press event
---@param x number Mouse X position
---@param y number Mouse Y position
---@param button number Mouse button (1=left, 2=right, 3=middle)
---@return boolean True if event was consumed
function EventHandler:handleMousePress(x, y, button)
if not self._element then
return false
end
local element = self._element
-- Check if element is disabled
if element.disabled then
return false
end
-- Check if press is within bounds
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
if x < bx or x > bx + bw or y < by or y > by + bh then
return false
end
-- Fire press event
if element.onEvent then
local modifiers = getModifiers()
local pressEvent = InputEvent.new({
type = "press",
button = button,
x = x,
y = y,
modifiers = modifiers,
clickCount = 1,
})
element.onEvent(element, pressEvent)
end
-- Mark as pressed
self._pressed[button] = true
-- Record drag start position
self._dragStartX[button] = x
self._dragStartY[button] = y
self._lastMouseX[button] = x
self._lastMouseY[button] = y
return true
end
--- Handle mouse release event
---@param x number Mouse X position
---@param y number Mouse Y position
---@param button number Mouse button (1=left, 2=right, 3=middle)
---@return boolean True if event was consumed
function EventHandler:handleMouseRelease(x, y, button)
if not self._element then
return false
end
local element = self._element
-- Only handle if button was pressed
if not self._pressed[button] then
return false
end
-- Fire click event
local currentTime = love.timer.getTime()
local modifiers = getModifiers()
-- Determine click count (double-click detection)
local clickCount = 1
local doubleClickThreshold = 0.3 -- 300ms for double-click
if self._lastClickTime and self._lastClickButton == button and (currentTime - self._lastClickTime) < doubleClickThreshold then
clickCount = self._clickCount + 1
else
clickCount = 1
end
self._clickCount = clickCount
self._lastClickTime = currentTime
self._lastClickButton = button
-- Determine event type based on button
local eventType = "click"
if button == 2 then
eventType = "rightclick"
elseif button == 3 then
eventType = "middleclick"
end
if element.onEvent then
local clickEvent = InputEvent.new({
type = eventType,
button = button,
x = x,
y = y,
modifiers = modifiers,
clickCount = clickCount,
})
element.onEvent(element, clickEvent)
end
-- Mark as released
self._pressed[button] = false
-- Clean up drag tracking
self._dragStartX[button] = nil
self._dragStartY[button] = nil
-- Fire release event
if element.onEvent then
local releaseEvent = InputEvent.new({
type = "release",
button = button,
x = x,
y = y,
modifiers = modifiers,
clickCount = clickCount,
})
element.onEvent(element, releaseEvent)
end
return true
end
--- Handle mouse move event
---@param x number Mouse X position
---@param y number Mouse Y position
---@param dx number Delta X
---@param dy number Delta Y
---@return boolean True if event was consumed
function EventHandler:handleMouseMove(x, y, dx, dy)
if not self._element then
return false
end
local element = self._element
-- Check if any button is pressed (drag)
for button, pressed in pairs(self._pressed) do
if pressed then
-- Fire drag event
if element.onEvent then
local modifiers = getModifiers()
local dragDx = x - self._dragStartX[button]
local dragDy = y - self._dragStartY[button]
local dragEvent = InputEvent.new({
type = "drag",
button = button,
x = x,
y = y,
dx = dragDx,
dy = dragDy,
modifiers = modifiers,
clickCount = 1,
})
element.onEvent(element, dragEvent)
end
-- Update last mouse position
self._lastMouseX[button] = x
self._lastMouseY[button] = y
return true
end
end
return false
end
--- Handle key press event
---@param key string Key name
---@param scancode string Scancode
---@param isrepeat boolean Whether this is a key repeat
---@return boolean True if event was consumed
function EventHandler:handleKeyPress(key, scancode, isrepeat)
if not self._element then
return false
end
local element = self._element
-- Only handle if element is focused (for editable elements)
if element.editable and not element._focused then
return false
end
-- Key events are handled by TextEditor for editable elements
-- This is just a passthrough for custom key handling
if element.onEvent then
local modifiers = getModifiers()
local keyEvent = InputEvent.new({
type = "keypress",
key = key,
scancode = scancode,
isrepeat = isrepeat,
modifiers = modifiers,
x = 0,
y = 0,
button = 0,
})
element.onEvent(element, keyEvent)
return true
end
return false
end
--- Handle text input event
---@param text string Input text
---@return boolean True if event was consumed
function EventHandler:handleTextInput(text)
if not self._element then
return false
end
local element = self._element
-- Only handle if element is focused (for editable elements)
if element.editable and not element._focused then
return false
end
-- Text input is handled by TextEditor for editable elements
-- This is just a passthrough for custom text handling
if element.onEvent then
local modifiers = getModifiers()
local textEvent = InputEvent.new({
type = "textinput",
text = text,
modifiers = modifiers,
x = 0,
y = 0,
button = 0,
})
element.onEvent(element, textEvent)
return true
end
return false
end
--- Handle mouse wheel event
---@param x number Horizontal scroll amount
---@param y number Vertical scroll amount
---@return boolean True if event was consumed
function EventHandler:handleWheel(x, y)
if not self._element then
return false
end
local element = self._element
-- Fire wheel event
if element.onEvent then
local mx, my = love.mouse.getPosition()
local modifiers = getModifiers()
local wheelEvent = InputEvent.new({
type = "wheel",
x = mx,
y = my,
dx = x,
dy = y,
modifiers = modifiers,
button = 0,
})
element.onEvent(element, wheelEvent)
return true
end
return false
end
--- Handle touch press event
---@param id any Touch ID
---@param x number Touch X position
---@param y number Touch Y position
---@return boolean True if event was consumed
function EventHandler:handleTouchPress(id, x, y)
if not self._element then
return false
end
local element = self._element
-- Check if touch is within bounds
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
if x < bx or x > bx + bw or y < by or y > by + bh then
return false
end
-- Mark touch as pressed
self._touchPressed[id] = true
-- Fire touch press event (treat as left click)
if element.onEvent then
local modifiers = getModifiers()
local touchEvent = InputEvent.new({
type = "press",
button = 1,
x = x,
y = y,
modifiers = modifiers,
clickCount = 1,
})
element.onEvent(element, touchEvent)
end
return true
end
--- Handle touch release event
---@param id any Touch ID
---@param x number Touch X position
---@param y number Touch Y position
---@return boolean True if event was consumed
function EventHandler:handleTouchRelease(id, x, y)
if not self._element then
return false
end
local element = self._element
-- Only handle if touch was pressed
if not self._touchPressed[id] then
return false
end
-- Fire touch release event (treat as left click)
if element.onEvent then
local modifiers = getModifiers()
local touchEvent = InputEvent.new({
type = "click",
button = 1,
x = x,
y = y,
modifiers = modifiers,
clickCount = 1,
})
element.onEvent(element, touchEvent)
end
-- Mark touch as released
self._touchPressed[id] = false
return true
end
--- Update hover state based on mouse position
---@param mouseX number Mouse X position
---@param mouseY number Mouse Y position
function EventHandler:updateHoverState(mouseX, mouseY)
if not self._element then
return
end
local element = self._element
-- Check if mouse is hovering over element
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
local isHovering = mouseX >= bx and mouseX <= bx + bw and mouseY >= by and mouseY <= by + bh
-- Update hover state in element
if element.themeComponent then
if isHovering then
element._themeState = "hover"
else
element._themeState = "normal"
end
end
end
--- Dispatch a custom event
---@param event InputEvent The event to dispatch
function EventHandler:dispatchEvent(event)
if not self._element then
return
end
local element = self._element
if element.onEvent then
element.onEvent(element, event)
end
end
--- Check if a mouse button is currently pressed
---@param button number Mouse button (1=left, 2=right, 3=middle)
---@return boolean True if button is pressed
function EventHandler:isButtonPressed(button)
return self._pressed[button] or false
end
--- Check if element is being dragged
---@return boolean True if element is being dragged
function EventHandler:isDragging()
for _, pressed in pairs(self._pressed) do
if pressed then
return true
end
end
return false
end
--- Get the current click count
---@return number Click count
function EventHandler:getClickCount()
return self._clickCount
end
--- Reset all event state
function EventHandler:reset()
self._pressed = {}
self._lastClickTime = nil
self._lastClickButton = nil
self._clickCount = 0
self._touchPressed = {}
self._dragStartX = {}
self._dragStartY = {}
self._lastMouseX = {}
self._lastMouseY = {}
self._scrollbarPressHandled = false
end
return EventHandler

View File

@@ -1,634 +0,0 @@
-- ====================
-- LayoutEngine Module
-- ====================
-- Extracted layout calculation functionality from Element.lua
-- Handles flexbox, grid, absolute/relative positioning, and auto-sizing
-- Setup module path for relative requires
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Module dependencies
local Grid = req("Grid")
local utils = req("utils")
-- Extract enum values
local enums = utils.enums
local Positioning = enums.Positioning
local FlexDirection = enums.FlexDirection
local JustifyContent = enums.JustifyContent
local AlignContent = enums.AlignContent
local AlignItems = enums.AlignItems
local AlignSelf = enums.AlignSelf
local FlexWrap = enums.FlexWrap
---@class LayoutEngine
---@field element Element -- Reference to parent element
---@field positioning Positioning -- Layout positioning mode
---@field flexDirection FlexDirection -- Direction of flex layout
---@field justifyContent JustifyContent -- Alignment of items along main axis
---@field alignItems AlignItems -- Alignment of items along cross axis
---@field alignContent AlignContent -- Alignment of lines in multi-line flex containers
---@field flexWrap FlexWrap -- Whether children wrap to multiple lines
---@field gridRows number? -- Number of rows in the grid
---@field gridColumns number? -- Number of columns in the grid
---@field columnGap number? -- Gap between grid columns
---@field rowGap number? -- Gap between grid rows
local LayoutEngine = {}
LayoutEngine.__index = LayoutEngine
--- Create a new LayoutEngine instance
---@param config table -- Configuration options
---@return LayoutEngine
function LayoutEngine.new(config)
local self = setmetatable({}, LayoutEngine)
-- Store layout configuration
self.positioning = config.positioning or Positioning.RELATIVE
self.flexDirection = config.flexDirection or FlexDirection.HORIZONTAL
self.justifyContent = config.justifyContent or JustifyContent.FLEX_START
self.alignItems = config.alignItems or AlignItems.STRETCH
self.alignContent = config.alignContent or AlignContent.STRETCH
self.flexWrap = config.flexWrap or FlexWrap.NOWRAP
self.gridRows = config.gridRows
self.gridColumns = config.gridColumns
self.columnGap = config.columnGap
self.rowGap = config.rowGap
-- Element reference (set via initialize)
self.element = nil
return self
end
--- Initialize the layout engine with a reference to the parent element
---@param element Element -- The element this layout engine belongs to
function LayoutEngine:initialize(element)
self.element = element
end
--- Apply positioning offsets (top, right, bottom, left) to an element
---@param child Element -- The child element to apply offsets to
function LayoutEngine:applyPositioningOffsets(child)
if not child or not self.element then
return
end
local parent = self.element
-- Only apply offsets to explicitly absolute children or children in relative/absolute containers
-- Flex/grid children ignore positioning offsets as they participate in layout
local isFlexChild = child.positioning == Positioning.FLEX
or child.positioning == Positioning.GRID
or (child.positioning == Positioning.ABSOLUTE and not child._explicitlyAbsolute)
if not isFlexChild then
-- Apply absolute positioning for explicitly absolute children
-- Apply top offset (distance from parent's content box top edge)
if child.top then
child.y = parent.y + parent.padding.top + child.top
end
-- Apply bottom offset (distance from parent's content box bottom edge)
-- BORDER-BOX MODEL: Use border-box dimensions for positioning
if child.bottom then
local childBorderBoxHeight = child:getBorderBoxHeight()
child.y = parent.y + parent.padding.top + parent.height - child.bottom - childBorderBoxHeight
end
-- Apply left offset (distance from parent's content box left edge)
if child.left then
child.x = parent.x + parent.padding.left + child.left
end
-- Apply right offset (distance from parent's content box right edge)
-- BORDER-BOX MODEL: Use border-box dimensions for positioning
if child.right then
local childBorderBoxWidth = child:getBorderBoxWidth()
child.x = parent.x + parent.padding.left + parent.width - child.right - childBorderBoxWidth
end
end
end
--- Calculate auto-width based on children and text content
---@return number -- Calculated content width
function LayoutEngine:calculateAutoWidth()
if not self.element then
return 0
end
-- BORDER-BOX MODEL: Calculate content width, caller will add padding to get border-box
local contentWidth = self.element:calculateTextWidth()
if not self.element.children or #self.element.children == 0 then
return contentWidth
end
-- For HORIZONTAL flex: sum children widths + gaps
-- For VERTICAL flex: max of children widths
local isHorizontal = self.flexDirection == FlexDirection.HORIZONTAL
local totalWidth = contentWidth
local maxWidth = contentWidth
local participatingChildren = 0
for _, child in ipairs(self.element.children) do
-- Skip explicitly absolute positioned children as they don't affect parent auto-sizing
if not child._explicitlyAbsolute then
-- BORDER-BOX MODEL: Use border-box width for auto-sizing calculations
local childBorderBoxWidth = child:getBorderBoxWidth()
if isHorizontal then
totalWidth = totalWidth + childBorderBoxWidth
else
maxWidth = math.max(maxWidth, childBorderBoxWidth)
end
participatingChildren = participatingChildren + 1
end
end
if isHorizontal then
-- Add gaps between children (n-1 gaps for n children)
local gapCount = math.max(0, participatingChildren - 1)
return totalWidth + (self.element.gap * gapCount)
else
return maxWidth
end
end
--- Calculate auto-height based on children and text content
---@return number -- Calculated content height
function LayoutEngine:calculateAutoHeight()
if not self.element then
return 0
end
local height = self.element:calculateTextHeight()
if not self.element.children or #self.element.children == 0 then
return height
end
-- For VERTICAL flex: sum children heights + gaps
-- For HORIZONTAL flex: max of children heights
local isVertical = self.flexDirection == FlexDirection.VERTICAL
local totalHeight = height
local maxHeight = height
local participatingChildren = 0
for _, child in ipairs(self.element.children) do
-- Skip explicitly absolute positioned children as they don't affect parent auto-sizing
if not child._explicitlyAbsolute then
-- BORDER-BOX MODEL: Use border-box height for auto-sizing calculations
local childBorderBoxHeight = child:getBorderBoxHeight()
if isVertical then
totalHeight = totalHeight + childBorderBoxHeight
else
maxHeight = math.max(maxHeight, childBorderBoxHeight)
end
participatingChildren = participatingChildren + 1
end
end
if isVertical then
-- Add gaps between children (n-1 gaps for n children)
local gapCount = math.max(0, participatingChildren - 1)
return totalHeight + (self.element.gap * gapCount)
else
return maxHeight
end
end
--- Main layout calculation - positions all children according to layout mode
function LayoutEngine:layoutChildren()
if not self.element then
return
end
-- Handle different positioning modes
if self.positioning == Positioning.ABSOLUTE or self.positioning == Positioning.RELATIVE then
-- Absolute/Relative positioned containers don't layout their children according to flex rules,
-- but they should still apply CSS positioning offsets to their children
for _, child in ipairs(self.element.children) do
if child.top or child.right or child.bottom or child.left then
self:applyPositioningOffsets(child)
end
end
return
end
-- Handle grid layout
if self.positioning == Positioning.GRID then
self:calculateGridLayout()
return
end
-- Handle flex layout
self:calculateFlexLayout()
end
--- Calculate grid layout for children
function LayoutEngine:calculateGridLayout()
if not self.element then
return
end
-- Delegate to Grid module
Grid.layoutGridItems(self.element)
end
--- Calculate flexbox layout for children
function LayoutEngine:calculateFlexLayout()
if not self.element then
return
end
local childCount = #self.element.children
if childCount == 0 then
return
end
-- Get flex children (children that participate in flex layout)
local flexChildren = {}
for _, child in ipairs(self.element.children) do
local isFlexChild = not (child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute)
if isFlexChild then
table.insert(flexChildren, child)
end
end
if #flexChildren == 0 then
return
end
-- Calculate space reserved by absolutely positioned siblings with explicit positioning
local reservedMainStart = 0 -- Space reserved at the start of main axis (left for horizontal, top for vertical)
local reservedMainEnd = 0 -- Space reserved at the end of main axis (right for horizontal, bottom for vertical)
local reservedCrossStart = 0 -- Space reserved at the start of cross axis (top for horizontal, left for vertical)
local reservedCrossEnd = 0 -- Space reserved at the end of cross axis (bottom for horizontal, right for vertical)
for _, child in ipairs(self.element.children) do
-- Only consider absolutely positioned children with explicit positioning
if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute then
-- BORDER-BOX MODEL: Use border-box dimensions for space calculations
local childBorderBoxWidth = child:getBorderBoxWidth()
local childBorderBoxHeight = child:getBorderBoxHeight()
if self.flexDirection == FlexDirection.HORIZONTAL then
-- Horizontal layout: main axis is X, cross axis is Y
-- Check for left positioning (reserves space at main axis start)
if child.left then
local spaceNeeded = child.left + childBorderBoxWidth
reservedMainStart = math.max(reservedMainStart, spaceNeeded)
end
-- Check for right positioning (reserves space at main axis end)
if child.right then
local spaceNeeded = child.right + childBorderBoxWidth
reservedMainEnd = math.max(reservedMainEnd, spaceNeeded)
end
-- Check for top positioning (reserves space at cross axis start)
if child.top then
local spaceNeeded = child.top + childBorderBoxHeight
reservedCrossStart = math.max(reservedCrossStart, spaceNeeded)
end
-- Check for bottom positioning (reserves space at cross axis end)
if child.bottom then
local spaceNeeded = child.bottom + childBorderBoxHeight
reservedCrossEnd = math.max(reservedCrossEnd, spaceNeeded)
end
else
-- Vertical layout: main axis is Y, cross axis is X
-- Check for top positioning (reserves space at main axis start)
if child.top then
local spaceNeeded = child.top + childBorderBoxHeight
reservedMainStart = math.max(reservedMainStart, spaceNeeded)
end
-- Check for bottom positioning (reserves space at main axis end)
if child.bottom then
local spaceNeeded = child.bottom + childBorderBoxHeight
reservedMainEnd = math.max(reservedMainEnd, spaceNeeded)
end
-- Check for left positioning (reserves space at cross axis start)
if child.left then
local spaceNeeded = child.left + childBorderBoxWidth
reservedCrossStart = math.max(reservedCrossStart, spaceNeeded)
end
-- Check for right positioning (reserves space at cross axis end)
if child.right then
local spaceNeeded = child.right + childBorderBoxWidth
reservedCrossEnd = math.max(reservedCrossEnd, spaceNeeded)
end
end
end
end
-- Calculate available space (accounting for padding and reserved space)
-- BORDER-BOX MODEL: self.element.width and self.element.height are already content dimensions (padding subtracted)
local availableMainSize = 0
local availableCrossSize = 0
if self.flexDirection == FlexDirection.HORIZONTAL then
availableMainSize = self.element.width - reservedMainStart - reservedMainEnd
availableCrossSize = self.element.height - reservedCrossStart - reservedCrossEnd
else
availableMainSize = self.element.height - reservedMainStart - reservedMainEnd
availableCrossSize = self.element.width - reservedCrossStart - reservedCrossEnd
end
-- Handle flex wrap: create lines of children
local lines = {}
if self.flexWrap == FlexWrap.NOWRAP then
-- All children go on one line
lines[1] = flexChildren
else
-- Wrap children into multiple lines
local currentLine = {}
local currentLineSize = 0
for _, child in ipairs(flexChildren) do
-- BORDER-BOX MODEL: Use border-box dimensions for layout calculations
-- Include margins in size calculations
local childMainSize = 0
local childMainMargin = 0
if self.flexDirection == FlexDirection.HORIZONTAL then
childMainSize = child:getBorderBoxWidth()
childMainMargin = child.margin.left + child.margin.right
else
childMainSize = child:getBorderBoxHeight()
childMainMargin = child.margin.top + child.margin.bottom
end
local childTotalMainSize = childMainSize + childMainMargin
-- Check if adding this child would exceed the available space
local lineSpacing = #currentLine > 0 and self.element.gap or 0
if #currentLine > 0 and currentLineSize + lineSpacing + childTotalMainSize > availableMainSize then
-- Start a new line
if #currentLine > 0 then
table.insert(lines, currentLine)
end
currentLine = { child }
currentLineSize = childTotalMainSize
else
-- Add to current line
table.insert(currentLine, child)
currentLineSize = currentLineSize + lineSpacing + childTotalMainSize
end
end
-- Add the last line if it has children
if #currentLine > 0 then
table.insert(lines, currentLine)
end
-- Handle wrap-reverse: reverse the order of lines
if self.flexWrap == FlexWrap.WRAP_REVERSE then
local reversedLines = {}
for i = #lines, 1, -1 do
table.insert(reversedLines, lines[i])
end
lines = reversedLines
end
end
-- Calculate line positions and heights (including child padding)
local lineHeights = {}
local totalLinesHeight = 0
for lineIndex, line in ipairs(lines) do
local maxCrossSize = 0
for _, child in ipairs(line) do
-- BORDER-BOX MODEL: Use border-box dimensions for layout calculations
-- Include margins in cross-axis size calculations
local childCrossSize = 0
local childCrossMargin = 0
if self.flexDirection == FlexDirection.HORIZONTAL then
childCrossSize = child:getBorderBoxHeight()
childCrossMargin = child.margin.top + child.margin.bottom
else
childCrossSize = child:getBorderBoxWidth()
childCrossMargin = child.margin.left + child.margin.right
end
local childTotalCrossSize = childCrossSize + childCrossMargin
maxCrossSize = math.max(maxCrossSize, childTotalCrossSize)
end
lineHeights[lineIndex] = maxCrossSize
totalLinesHeight = totalLinesHeight + maxCrossSize
end
-- Account for gaps between lines
local lineGaps = math.max(0, #lines - 1) * self.element.gap
totalLinesHeight = totalLinesHeight + lineGaps
-- For single line layouts, CENTER, FLEX_END and STRETCH should use full cross size
if #lines == 1 then
if self.alignItems == AlignItems.STRETCH or self.alignItems == AlignItems.CENTER or self.alignItems == AlignItems.FLEX_END then
-- STRETCH, CENTER, and FLEX_END should use full available cross size
lineHeights[1] = availableCrossSize
totalLinesHeight = availableCrossSize
end
-- CENTER and FLEX_END should preserve natural child dimensions
-- and only affect positioning within the available space
end
-- Calculate starting position for lines based on alignContent
local lineStartPos = 0
local lineSpacing = self.element.gap
local freeLineSpace = availableCrossSize - totalLinesHeight
-- Apply AlignContent logic for both single and multiple lines
if self.alignContent == AlignContent.FLEX_START then
lineStartPos = 0
elseif self.alignContent == AlignContent.CENTER then
lineStartPos = freeLineSpace / 2
elseif self.alignContent == AlignContent.FLEX_END then
lineStartPos = freeLineSpace
elseif self.alignContent == AlignContent.SPACE_BETWEEN then
lineStartPos = 0
if #lines > 1 then
lineSpacing = self.element.gap + (freeLineSpace / (#lines - 1))
end
elseif self.alignContent == AlignContent.SPACE_AROUND then
local spaceAroundEach = freeLineSpace / #lines
lineStartPos = spaceAroundEach / 2
lineSpacing = self.element.gap + spaceAroundEach
elseif self.alignContent == AlignContent.STRETCH then
lineStartPos = 0
if #lines > 1 and freeLineSpace > 0 then
lineSpacing = self.element.gap + (freeLineSpace / #lines)
-- Distribute extra space to line heights (only if positive)
local extraPerLine = freeLineSpace / #lines
for i = 1, #lineHeights do
lineHeights[i] = lineHeights[i] + extraPerLine
end
end
end
-- Position children within each line
local currentCrossPos = lineStartPos
for lineIndex, line in ipairs(lines) do
local lineHeight = lineHeights[lineIndex]
-- Calculate total size of children in this line (including padding and margins)
-- BORDER-BOX MODEL: Use border-box dimensions for layout calculations
local totalChildrenSize = 0
for _, child in ipairs(line) do
if self.flexDirection == FlexDirection.HORIZONTAL then
totalChildrenSize = totalChildrenSize + child:getBorderBoxWidth() + child.margin.left + child.margin.right
else
totalChildrenSize = totalChildrenSize + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom
end
end
local totalGapSize = math.max(0, #line - 1) * self.element.gap
local totalContentSize = totalChildrenSize + totalGapSize
local freeSpace = availableMainSize - totalContentSize
-- Calculate initial position and spacing based on justifyContent
local startPos = 0
local itemSpacing = self.element.gap
if self.justifyContent == JustifyContent.FLEX_START then
startPos = 0
elseif self.justifyContent == JustifyContent.CENTER then
startPos = freeSpace / 2
elseif self.justifyContent == JustifyContent.FLEX_END then
startPos = freeSpace
elseif self.justifyContent == JustifyContent.SPACE_BETWEEN then
startPos = 0
if #line > 1 then
itemSpacing = self.element.gap + (freeSpace / (#line - 1))
end
elseif self.justifyContent == JustifyContent.SPACE_AROUND then
local spaceAroundEach = freeSpace / #line
startPos = spaceAroundEach / 2
itemSpacing = self.element.gap + spaceAroundEach
elseif self.justifyContent == JustifyContent.SPACE_EVENLY then
local spaceBetween = freeSpace / (#line + 1)
startPos = spaceBetween
itemSpacing = self.element.gap + spaceBetween
end
-- Position children in this line
local currentMainPos = startPos
for _, child in ipairs(line) do
-- Determine effective cross-axis alignment
local effectiveAlign = child.alignSelf
if effectiveAlign == nil or effectiveAlign == AlignSelf.AUTO then
effectiveAlign = self.alignItems
end
if self.flexDirection == FlexDirection.HORIZONTAL then
-- Horizontal layout: main axis is X, cross axis is Y
-- Position child at border box (x, y represents top-left including padding)
-- Add reservedMainStart and left margin to account for absolutely positioned siblings and margins
child.x = self.element.x + self.element.padding.left + reservedMainStart + currentMainPos + child.margin.left
-- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations
local childBorderBoxHeight = child:getBorderBoxHeight()
local childTotalCrossSize = childBorderBoxHeight + child.margin.top + child.margin.bottom
if effectiveAlign == AlignItems.FLEX_START then
child.y = self.element.y + self.element.padding.top + reservedCrossStart + currentCrossPos + child.margin.top
elseif effectiveAlign == AlignItems.CENTER then
child.y = self.element.y + self.element.padding.top + reservedCrossStart + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + child.margin.top
elseif effectiveAlign == AlignItems.FLEX_END then
child.y = self.element.y + self.element.padding.top + reservedCrossStart + currentCrossPos + lineHeight - childTotalCrossSize + child.margin.top
elseif effectiveAlign == AlignItems.STRETCH then
-- STRETCH: Only apply if height was not explicitly set
if child.autosizing and child.autosizing.height then
-- STRETCH: Set border-box height to lineHeight minus margins, content area shrinks to fit
local availableHeight = lineHeight - child.margin.top - child.margin.bottom
child._borderBoxHeight = availableHeight
child.height = math.max(0, availableHeight - child.padding.top - child.padding.bottom)
end
child.y = self.element.y + self.element.padding.top + reservedCrossStart + currentCrossPos + child.margin.top
end
-- Apply positioning offsets (top, right, bottom, left)
self:applyPositioningOffsets(child)
-- If child has children, re-layout them after position change
if #child.children > 0 then
child:layoutChildren()
end
-- Advance position by child's border-box width plus margins
currentMainPos = currentMainPos + child:getBorderBoxWidth() + child.margin.left + child.margin.right + itemSpacing
else
-- Vertical layout: main axis is Y, cross axis is X
-- Position child at border box (x, y represents top-left including padding)
-- Add reservedMainStart and top margin to account for absolutely positioned siblings and margins
child.y = self.element.y + self.element.padding.top + reservedMainStart + currentMainPos + child.margin.top
-- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations
local childBorderBoxWidth = child:getBorderBoxWidth()
local childTotalCrossSize = childBorderBoxWidth + child.margin.left + child.margin.right
if effectiveAlign == AlignItems.FLEX_START then
child.x = self.element.x + self.element.padding.left + reservedCrossStart + currentCrossPos + child.margin.left
elseif effectiveAlign == AlignItems.CENTER then
child.x = self.element.x + self.element.padding.left + reservedCrossStart + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + child.margin.left
elseif effectiveAlign == AlignItems.FLEX_END then
child.x = self.element.x + self.element.padding.left + reservedCrossStart + currentCrossPos + lineHeight - childTotalCrossSize + child.margin.left
elseif effectiveAlign == AlignItems.STRETCH then
-- STRETCH: Only apply if width was not explicitly set
if child.autosizing and child.autosizing.width then
-- STRETCH: Set border-box width to lineHeight minus margins, content area shrinks to fit
local availableWidth = lineHeight - child.margin.left - child.margin.right
child._borderBoxWidth = availableWidth
child.width = math.max(0, availableWidth - child.padding.left - child.padding.right)
end
child.x = self.element.x + self.element.padding.left + reservedCrossStart + currentCrossPos + child.margin.left
end
-- Apply positioning offsets (top, right, bottom, left)
self:applyPositioningOffsets(child)
-- If child has children, re-layout them after position change
if #child.children > 0 then
child:layoutChildren()
end
-- Advance position by child's border-box height plus margins
currentMainPos = currentMainPos + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom + itemSpacing
end
end
-- Move to next line position
currentCrossPos = currentCrossPos + lineHeight + lineSpacing
end
-- Position explicitly absolute children after flex layout
for _, child in ipairs(self.element.children) do
if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute then
-- Apply positioning offsets (top, right, bottom, left)
self:applyPositioningOffsets(child)
-- If child has children, layout them after position change
if #child.children > 0 then
child:layoutChildren()
end
end
end
-- Detect overflow after children are laid out
self.element:_detectOverflow()
end
--- Get content bounds (position and dimensions of content area)
---@return table -- {x, y, width, height}
function LayoutEngine:getContentBounds()
if not self.element then
return { x = 0, y = 0, width = 0, height = 0 }
end
return {
x = self.element.x + self.element.padding.left,
y = self.element.y + self.element.padding.top,
width = self.element.width,
height = self.element.height,
}
end
return LayoutEngine

View File

@@ -1,478 +0,0 @@
--[[
Renderer.lua - Rendering module for FlexLove Element
Handles all visual rendering including backgrounds, borders, images, themes, and effects
]]
-- Setup module path for relative requires
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Module dependencies
local Color = req("Color")
local RoundedRect = req("RoundedRect")
local NinePatch = req("NinePatch")
local ImageRenderer = req("ImageRenderer")
local Blur = req("Blur")
local Theme = req("Theme")
local utils = req("utils")
-- Extract utilities
local FONT_CACHE = utils.FONT_CACHE
-- ====================
-- Renderer Class
-- ====================
---@class Renderer
---@field element Element -- Reference to parent element
---@field backgroundColor Color -- Background color
---@field borderColor Color -- Border color
---@field opacity number -- Opacity (0-1)
---@field border {top:boolean, right:boolean, bottom:boolean, left:boolean} -- Border sides
---@field cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number} -- Corner radii
---@field theme string? -- Theme name
---@field themeComponent string? -- Theme component name
---@field _themeState string -- Current theme state (normal, hover, pressed, active, disabled)
---@field imagePath string? -- Path to image file
---@field image love.Image? -- Image object
---@field _loadedImage love.Image? -- Cached loaded image
---@field objectFit string -- Image fit mode
---@field objectPosition string -- Image position
---@field imageOpacity number -- Image opacity
---@field contentBlur table? -- Content blur settings
---@field backdropBlur table? -- Backdrop blur settings
---@field _blurInstance table? -- Cached blur instance
---@field scaleCorners number? -- 9-patch corner scale multiplier
---@field scalingAlgorithm string? -- 9-patch scaling algorithm
---@field disableHighlight boolean -- Disable pressed state highlight
local Renderer = {}
Renderer.__index = Renderer
--- Create a new Renderer instance
---@param config table -- Configuration options
---@return Renderer
function Renderer.new(config)
local self = setmetatable({}, Renderer)
-- Initialize rendering state
self.backgroundColor = config.backgroundColor or Color.new(0, 0, 0, 0)
self.borderColor = config.borderColor or Color.new(0, 0, 0, 1)
self.opacity = config.opacity or 1
-- Border configuration
self.border = config.border or {
top = false,
right = false,
bottom = false,
left = false,
}
-- Corner radius configuration
self.cornerRadius = config.cornerRadius or {
topLeft = 0,
topRight = 0,
bottomLeft = 0,
bottomRight = 0,
}
-- Theme configuration
self.theme = config.theme
self.themeComponent = config.themeComponent
self._themeState = config._themeState or "normal"
-- Image configuration
self.imagePath = config.imagePath
self.image = config.image
self._loadedImage = config._loadedImage
self.objectFit = config.objectFit or "fill"
self.objectPosition = config.objectPosition or "center center"
self.imageOpacity = config.imageOpacity or 1
-- Blur configuration
self.contentBlur = config.contentBlur
self.backdropBlur = config.backdropBlur
self._blurInstance = config._blurInstance
-- 9-patch configuration
self.scaleCorners = config.scaleCorners
self.scalingAlgorithm = config.scalingAlgorithm
-- Visual feedback configuration
self.disableHighlight = config.disableHighlight or false
-- Element reference (set via initialize)
self.element = nil
return self
end
--- Initialize renderer with parent element reference
---@param element Element
function Renderer:initialize(element)
self.element = element
end
--- Main draw method - orchestrates all rendering
---@param backdropCanvas love.Canvas? -- Canvas for backdrop blur
function Renderer:draw(backdropCanvas)
-- Early exit if element is invisible (optimization)
if self.opacity <= 0 then
return
end
-- Get element reference for convenience
local element = self.element
if not element then
return
end
-- Handle opacity during animation
local drawBackgroundColor = self.backgroundColor
if element.animation then
local anim = element.animation:interpolate()
if anim.opacity then
drawBackgroundColor = 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 = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local borderBoxHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
-- LAYER 0.5: Draw backdrop blur if configured (before background)
if self.backdropBlur and self.backdropBlur.intensity > 0 and backdropCanvas then
local blurInstance = element:getBlurInstance()
if blurInstance then
Blur.applyBackdrop(blurInstance, self.backdropBlur.intensity, element.x, element.y, borderBoxWidth, borderBoxHeight, backdropCanvas)
end
end
-- LAYER 1: Draw backgroundColor first (behind everything)
self:drawBackground(element.x, element.y, borderBoxWidth, borderBoxHeight, drawBackgroundColor)
-- LAYER 1.5: Draw image on top of backgroundColor (if image exists)
if self._loadedImage then
self:drawImage(element.x, element.y, borderBoxWidth, borderBoxHeight)
end
-- LAYER 2: Draw theme on top of backgroundColor (if theme exists)
if self.themeComponent then
self:drawTheme(element.x, element.y, borderBoxWidth, borderBoxHeight)
end
-- LAYER 3: Draw borders on top of theme (always render if specified)
self:drawBorder(element.x, element.y, borderBoxWidth, borderBoxHeight)
end
--- Draw background with corner radius
---@param x number
---@param y number
---@param width number
---@param height number
---@param drawBackgroundColor Color? -- Optional override for background color
function Renderer:drawBackground(x, y, width, height, drawBackgroundColor)
drawBackgroundColor = drawBackgroundColor or self.backgroundColor
-- Apply opacity to background color
local backgroundWithOpacity = Color.new(
drawBackgroundColor.r,
drawBackgroundColor.g,
drawBackgroundColor.b,
drawBackgroundColor.a * self.opacity
)
love.graphics.setColor(backgroundWithOpacity:toRGBA())
RoundedRect.draw("fill", x, y, width, height, self.cornerRadius)
end
--- Draw image with object-fit modes
---@param x number
---@param y number
---@param borderBoxWidth number
---@param borderBoxHeight number
function Renderer:drawImage(x, y, borderBoxWidth, borderBoxHeight)
if not self._loadedImage or not self.element then
return
end
local element = self.element
-- Calculate image bounds (content area - respects padding)
local imageX = x + element.padding.left
local imageY = y + element.padding.top
local imageWidth = element.width
local imageHeight = element.height
-- Combine element opacity with imageOpacity
local finalOpacity = self.opacity * self.imageOpacity
-- Apply cornerRadius clipping if set
local hasCornerRadius = self.cornerRadius.topLeft > 0
or self.cornerRadius.topRight > 0
or self.cornerRadius.bottomLeft > 0
or self.cornerRadius.bottomRight > 0
if hasCornerRadius then
-- Use stencil to clip image to rounded corners
love.graphics.stencil(function()
RoundedRect.draw("fill", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
end, "replace", 1)
love.graphics.setStencilTest("greater", 0)
end
-- Draw the image
ImageRenderer.draw(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.objectFit, self.objectPosition, finalOpacity)
-- Clear stencil if it was used
if hasCornerRadius then
love.graphics.setStencilTest()
end
end
--- Draw theme component using 9-patch rendering
---@param x number
---@param y number
---@param borderBoxWidth number
---@param borderBoxHeight number
function Renderer:drawTheme(x, y, borderBoxWidth, borderBoxHeight)
if not self.themeComponent or not self.element then
return
end
-- Get the theme to use
local themeToUse = nil
if self.theme then
-- Element specifies a specific theme - load it if needed
if Theme.get(self.theme) then
themeToUse = Theme.get(self.theme)
else
-- Try to load the theme
pcall(function()
Theme.load(self.theme)
end)
themeToUse = Theme.get(self.theme)
end
else
-- Use active theme
themeToUse = Theme.getActive()
end
if not themeToUse then
return
end
-- Get the component from the theme
local component = themeToUse.components[self.themeComponent]
if not component then
return
end
-- Check for state-specific override
local state = self._themeState
if state and component.states and component.states[state] then
component = component.states[state]
end
-- Use component-specific atlas if available, otherwise use theme atlas
local atlasToUse = component._loadedAtlas or themeToUse.atlas
if not atlasToUse or not component.regions then
return
end
-- Validate component has required structure
local hasAllRegions = component.regions.topLeft
and component.regions.topCenter
and component.regions.topRight
and component.regions.middleLeft
and component.regions.middleCenter
and component.regions.middleRight
and component.regions.bottomLeft
and component.regions.bottomCenter
and component.regions.bottomRight
if not hasAllRegions then
return
end
-- Pass element-level overrides for scaleCorners and scalingAlgorithm
NinePatch.draw(component, atlasToUse, x, y, borderBoxWidth, borderBoxHeight, self.opacity, self.scaleCorners, self.scalingAlgorithm)
end
--- Draw borders on specified sides
---@param x number
---@param y number
---@param width number
---@param height number
function Renderer:drawBorder(x, y, width, height)
-- Apply opacity to border color
local borderColorWithOpacity = Color.new(
self.borderColor.r,
self.borderColor.g,
self.borderColor.b,
self.borderColor.a * self.opacity
)
love.graphics.setColor(borderColorWithOpacity:toRGBA())
-- Check if all borders are enabled
local allBorders = self.border.top and self.border.bottom and self.border.left and self.border.right
if allBorders then
-- Draw complete rounded rectangle border
RoundedRect.draw("line", x, y, width, height, self.cornerRadius)
else
-- Draw individual borders (without rounded corners for partial borders)
if self.border.top then
love.graphics.line(x, y, x + width, y)
end
if self.border.bottom then
love.graphics.line(x, y + height, x + width, y + height)
end
if self.border.left then
love.graphics.line(x, y, x, y + height)
end
if self.border.right then
love.graphics.line(x + width, y, x + width, y + height)
end
end
end
--- Draw pressed state highlight overlay
---@param x number
---@param y number
---@param width number
---@param height number
function Renderer:drawPressedHighlight(x, y, width, height)
if self.disableHighlight or not self.element then
return
end
local element = self.element
-- Check if element has onEvent handler
if not element.onEvent then
return
end
-- Check if any button is pressed
local anyPressed = false
for _, pressed in pairs(element._pressed) do
if pressed then
anyPressed = true
break
end
end
if anyPressed then
love.graphics.setColor(0.5, 0.5, 0.5, 0.3 * self.opacity) -- Semi-transparent gray for pressed state with opacity
RoundedRect.draw("fill", x, y, width, height, self.cornerRadius)
end
end
--- Set background color
---@param color Color
function Renderer:setBackgroundColor(color)
self.backgroundColor = color
end
--- Set border color
---@param color Color
function Renderer:setBorderColor(color)
self.borderColor = color
end
--- Set opacity
---@param opacity number
function Renderer:setOpacity(opacity)
self.opacity = opacity
end
--- Set theme state
---@param state string
function Renderer:setThemeState(state)
self._themeState = state
end
--- Set loaded image
---@param image love.Image?
function Renderer:setLoadedImage(image)
self._loadedImage = image
end
--- Get blur instance (delegates to element)
---@return table?
function Renderer:getBlurInstance()
if not self.element then
return nil
end
return self.element:getBlurInstance()
end
--- Update renderer state from element
--- Call this when element properties change
function Renderer:syncFromElement()
if not self.element then
return
end
local element = self.element
-- Sync rendering properties
self.backgroundColor = element.backgroundColor
self.borderColor = element.borderColor
self.opacity = element.opacity
self.border = element.border
self.cornerRadius = element.cornerRadius
self.theme = element.theme
self.themeComponent = element.themeComponent
self._themeState = element._themeState
self.imagePath = element.imagePath
self.image = element.image
self._loadedImage = element._loadedImage
self.objectFit = element.objectFit
self.objectPosition = element.objectPosition
self.imageOpacity = element.imageOpacity
self.contentBlur = element.contentBlur
self.backdropBlur = element.backdropBlur
self._blurInstance = element._blurInstance
self.scaleCorners = element.scaleCorners
self.scalingAlgorithm = element.scalingAlgorithm
self.disableHighlight = element.disableHighlight
end
--- Update element state from renderer
--- Call this when renderer properties change
function Renderer:syncToElement()
if not self.element then
return
end
local element = self.element
-- Sync rendering properties back to element
element.backgroundColor = self.backgroundColor
element.borderColor = self.borderColor
element.opacity = self.opacity
element.border = self.border
element.cornerRadius = self.cornerRadius
element.theme = self.theme
element.themeComponent = self.themeComponent
element._themeState = self._themeState
element.imagePath = self.imagePath
element.image = self.image
element._loadedImage = self._loadedImage
element.objectFit = self.objectFit
element.objectPosition = self.objectPosition
element.imageOpacity = self.imageOpacity
element.contentBlur = self.contentBlur
element.backdropBlur = self.backdropBlur
element._blurInstance = self._blurInstance
element.scaleCorners = self.scaleCorners
element.scalingAlgorithm = self.scalingAlgorithm
element.disableHighlight = self.disableHighlight
end
return Renderer

View File

@@ -1,777 +0,0 @@
--[[
ScrollManager.lua - Scrolling and overflow management for FlexLove
Handles overflow detection, scrollbar rendering, and scrollbar interaction
Extracted from Element.lua for better modularity and testability
]]
-- ====================
-- Module Setup
-- ====================
-- Setup module path for relative requires
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Module dependencies
local Color = req("Color")
-- ====================
-- Error Handling Utilities
-- ====================
--- Standardized error message formatter
---@param module string -- Module name
---@param message string -- Error message
---@return string -- Formatted error message
local function formatError(module, message)
return string.format("[FlexLove.%s] %s", module, message)
end
-- ====================
-- ScrollManager Class
-- ====================
---@class ScrollManager
---@field overflow string -- Overflow mode for both axes ("visible"|"hidden"|"scroll"|"auto")
---@field overflowX string? -- Overflow mode for X axis (overrides overflow)
---@field overflowY string? -- Overflow mode for Y axis (overrides overflow)
---@field scrollbarWidth number -- Width of scrollbar track/thumb
---@field scrollbarColor Color -- Color of scrollbar thumb
---@field scrollbarTrackColor Color -- Color of scrollbar track
---@field scrollbarRadius number -- Corner radius of scrollbar
---@field scrollbarPadding number -- Padding around scrollbar from container edge
---@field scrollSpeed number -- Scroll speed multiplier for wheel events
---@field hideScrollbars {vertical:boolean, horizontal:boolean} -- Hide scrollbars
---@field _element Element? -- Reference to parent element
---@field _overflowX boolean -- Whether content overflows horizontally
---@field _overflowY boolean -- Whether content overflows vertically
---@field _contentWidth number -- Total content width (including overflow)
---@field _contentHeight number -- Total content height (including overflow)
---@field _scrollX number -- Current horizontal scroll position
---@field _scrollY number -- Current vertical scroll position
---@field _maxScrollX number -- Maximum horizontal scroll position
---@field _maxScrollY number -- Maximum vertical scroll position
---@field _scrollbarHoveredVertical boolean -- Whether vertical scrollbar is hovered
---@field _scrollbarHoveredHorizontal boolean -- Whether horizontal scrollbar is hovered
---@field _scrollbarDragging boolean -- Whether a scrollbar is being dragged
---@field _hoveredScrollbar string? -- Which scrollbar is hovered ("vertical"|"horizontal")
---@field _scrollbarDragOffset number -- Offset from thumb top when drag started
---@field _scrollbarPressHandled boolean -- Track if scrollbar press was handled
local ScrollManager = {}
ScrollManager.__index = ScrollManager
--- Create a new ScrollManager instance
---@param config table -- Configuration options
---@return ScrollManager
function ScrollManager.new(config)
if not config then
error(formatError("ScrollManager", "Configuration table is required"))
end
local self = setmetatable({}, ScrollManager)
-- Overflow configuration
self.overflow = config.overflow or "hidden"
self.overflowX = config.overflowX
self.overflowY = config.overflowY
-- Scrollbar appearance
self.scrollbarWidth = config.scrollbarWidth or 12
self.scrollbarColor = config.scrollbarColor or Color.new(0.5, 0.5, 0.5, 0.8)
self.scrollbarTrackColor = config.scrollbarTrackColor or Color.new(0.2, 0.2, 0.2, 0.5)
self.scrollbarRadius = config.scrollbarRadius or 6
self.scrollbarPadding = config.scrollbarPadding or 2
self.scrollSpeed = config.scrollSpeed or 20
-- Validate Color objects
if type(self.scrollbarColor) ~= "table" or not self.scrollbarColor.toRGBA then
error(formatError("ScrollManager", "scrollbarColor must be a Color object"))
end
if type(self.scrollbarTrackColor) ~= "table" or not self.scrollbarTrackColor.toRGBA then
error(formatError("ScrollManager", "scrollbarTrackColor must be a Color object"))
end
-- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean}
if config.hideScrollbars ~= nil then
if type(config.hideScrollbars) == "boolean" then
self.hideScrollbars = { vertical = config.hideScrollbars, horizontal = config.hideScrollbars }
elseif type(config.hideScrollbars) == "table" then
self.hideScrollbars = {
vertical = config.hideScrollbars.vertical ~= nil and config.hideScrollbars.vertical or false,
horizontal = config.hideScrollbars.horizontal ~= nil and config.hideScrollbars.horizontal or false,
}
else
self.hideScrollbars = { vertical = false, horizontal = false }
end
else
self.hideScrollbars = { vertical = false, horizontal = false }
end
-- Internal state
self._element = nil
self._overflowX = false
self._overflowY = false
self._contentWidth = 0
self._contentHeight = 0
self._scrollX = config._scrollX or 0
self._scrollY = config._scrollY or 0
self._maxScrollX = 0
self._maxScrollY = 0
-- Scrollbar interaction state
self._scrollbarHoveredVertical = false
self._scrollbarHoveredHorizontal = false
self._scrollbarDragging = false
self._hoveredScrollbar = nil
self._scrollbarDragOffset = 0
self._scrollbarPressHandled = false
return self
end
--- Initialize ScrollManager with parent element reference
---@param element Element -- Parent element
function ScrollManager:initialize(element)
if not element then
error(formatError("ScrollManager", "Element reference is required"))
end
self._element = element
end
--- Detect if content overflows container bounds
--- Calculates content dimensions and overflow state based on children
function ScrollManager:detectOverflow()
if not self._element then
error(formatError("ScrollManager", "ScrollManager not initialized with element"))
end
-- Reset overflow state
self._overflowX = false
self._overflowY = false
self._contentWidth = self._element.width
self._contentHeight = self._element.height
-- Skip detection if overflow is visible (no clipping needed)
local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY or self.overflow
if overflowX == "visible" and overflowY == "visible" then
return
end
-- Calculate content bounds based on children
if #self._element.children == 0 then
return -- No children, no overflow
end
local minX, minY = 0, 0
local maxX, maxY = 0, 0
-- Content area starts after padding
local contentX = self._element.x + self._element.padding.left
local contentY = self._element.y + self._element.padding.top
for _, child in ipairs(self._element.children) do
-- Skip absolutely positioned children (they don't contribute to overflow)
if not child._explicitlyAbsolute then
-- Calculate child position relative to content area
local childLeft = child.x - contentX
local childTop = child.y - contentY
local childRight = childLeft + child:getBorderBoxWidth() + child.margin.right
local childBottom = childTop + child:getBorderBoxHeight() + child.margin.bottom
maxX = math.max(maxX, childRight)
maxY = math.max(maxY, childBottom)
end
end
-- Calculate content dimensions
self._contentWidth = maxX
self._contentHeight = maxY
-- Detect overflow
local containerWidth = self._element.width
local containerHeight = self._element.height
self._overflowX = self._contentWidth > containerWidth
self._overflowY = self._contentHeight > containerHeight
-- Calculate maximum scroll bounds
self._maxScrollX = math.max(0, self._contentWidth - containerWidth)
self._maxScrollY = math.max(0, self._contentHeight - containerHeight)
-- Clamp current scroll position to new bounds
self._scrollX = math.max(0, math.min(self._scrollX, self._maxScrollX))
self._scrollY = math.max(0, math.min(self._scrollY, self._maxScrollY))
end
--- Set scroll position with bounds clamping
---@param x number? -- X scroll position (nil to keep current)
---@param y number? -- Y scroll position (nil to keep current)
function ScrollManager:setScroll(x, y)
if x ~= nil then
if type(x) ~= "number" then
error(formatError("ScrollManager", "Scroll X position must be a number"))
end
self._scrollX = math.max(0, math.min(x, self._maxScrollX))
end
if y ~= nil then
if type(y) ~= "number" then
error(formatError("ScrollManager", "Scroll Y position must be a number"))
end
self._scrollY = math.max(0, math.min(y, self._maxScrollY))
end
end
--- Get current scroll position
---@return number scrollX, number scrollY
function ScrollManager:getScroll()
return self._scrollX, self._scrollY
end
--- Scroll by delta amount (relative scrolling)
---@param dx number? -- X delta (nil for no change)
---@param dy number? -- Y delta (nil for no change)
function ScrollManager:scroll(dx, dy)
if dx ~= nil then
if type(dx) ~= "number" then
error(formatError("ScrollManager", "Scroll delta X must be a number"))
end
self._scrollX = math.max(0, math.min(self._scrollX + dx, self._maxScrollX))
end
if dy ~= nil then
if type(dy) ~= "number" then
error(formatError("ScrollManager", "Scroll delta Y must be a number"))
end
self._scrollY = math.max(0, math.min(self._scrollY + dy, self._maxScrollY))
end
end
--- Calculate scrollbar dimensions and positions
---@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}}
function ScrollManager:calculateScrollbarDimensions()
if not self._element then
error(formatError("ScrollManager", "ScrollManager not initialized with element"))
end
local result = {
vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 },
horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 },
}
local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY or self.overflow
-- Vertical scrollbar
-- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows
if overflowY == "scroll" then
-- Always show scrollbar for "scroll" mode
result.vertical.visible = true
result.vertical.trackHeight = self._element.height - (self.scrollbarPadding * 2)
if self._overflowY then
-- Content overflows, calculate proper thumb size
local contentRatio = self._element.height / math.max(self._contentHeight, self._element.height)
result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio)
-- Calculate thumb position based on scroll ratio
local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0
local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight
result.vertical.thumbY = maxThumbY * scrollRatio
else
-- No overflow, thumb fills entire track
result.vertical.thumbHeight = result.vertical.trackHeight
result.vertical.thumbY = 0
end
elseif self._overflowY and overflowY == "auto" then
-- Only show scrollbar when content actually overflows
result.vertical.visible = true
result.vertical.trackHeight = self._element.height - (self.scrollbarPadding * 2)
-- Calculate thumb height based on content ratio
local contentRatio = self._element.height / math.max(self._contentHeight, self._element.height)
result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio)
-- Calculate thumb position based on scroll ratio
local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0
local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight
result.vertical.thumbY = maxThumbY * scrollRatio
end
-- Horizontal scrollbar
-- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows
if overflowX == "scroll" then
-- Always show scrollbar for "scroll" mode
result.horizontal.visible = true
result.horizontal.trackWidth = self._element.width - (self.scrollbarPadding * 2)
if self._overflowX then
-- Content overflows, calculate proper thumb size
local contentRatio = self._element.width / math.max(self._contentWidth, self._element.width)
result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio)
-- Calculate thumb position based on scroll ratio
local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0
local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth
result.horizontal.thumbX = maxThumbX * scrollRatio
else
-- No overflow, thumb fills entire track
result.horizontal.thumbWidth = result.horizontal.trackWidth
result.horizontal.thumbX = 0
end
elseif self._overflowX and overflowX == "auto" then
-- Only show scrollbar when content actually overflows
result.horizontal.visible = true
result.horizontal.trackWidth = self._element.width - (self.scrollbarPadding * 2)
-- Calculate thumb width based on content ratio
local contentRatio = self._element.width / math.max(self._contentWidth, self._element.width)
result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio)
-- Calculate thumb position based on scroll ratio
local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0
local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth
result.horizontal.thumbX = maxThumbX * scrollRatio
end
return result
end
--- Draw scrollbars
---@param x number -- Element X position
---@param y number -- Element Y position
---@param width number -- Element width
---@param height number -- Element height
function ScrollManager:drawScrollbars(x, y, width, height)
if not self._element then
error(formatError("ScrollManager", "ScrollManager not initialized with element"))
end
local dims = self:calculateScrollbarDimensions()
-- Vertical scrollbar
if dims.vertical.visible and not self.hideScrollbars.vertical then
-- Position scrollbar within content area (x, y is border-box origin)
local contentX = x + self._element.padding.left
local contentY = y + self._element.padding.top
local trackX = contentX + width - self.scrollbarWidth - self.scrollbarPadding
local trackY = contentY + self.scrollbarPadding
-- Determine thumb color based on state (independent for vertical)
local thumbColor = self.scrollbarColor
if self._scrollbarDragging and self._hoveredScrollbar == "vertical" then
-- Active state: brighter
thumbColor = Color.new(math.min(1, thumbColor.r * 1.4), math.min(1, thumbColor.g * 1.4), math.min(1, thumbColor.b * 1.4), thumbColor.a)
elseif self._scrollbarHoveredVertical then
-- Hover state: slightly brighter
thumbColor = Color.new(math.min(1, thumbColor.r * 1.2), math.min(1, thumbColor.g * 1.2), math.min(1, thumbColor.b * 1.2), thumbColor.a)
end
-- Draw track
love.graphics.setColor(self.scrollbarTrackColor:toRGBA())
love.graphics.rectangle("fill", trackX, trackY, self.scrollbarWidth, dims.vertical.trackHeight, self.scrollbarRadius)
-- Draw thumb with state-based color
love.graphics.setColor(thumbColor:toRGBA())
love.graphics.rectangle("fill", trackX, trackY + dims.vertical.thumbY, self.scrollbarWidth, dims.vertical.thumbHeight, self.scrollbarRadius)
end
-- Horizontal scrollbar
if dims.horizontal.visible and not self.hideScrollbars.horizontal then
-- Position scrollbar within content area (x, y is border-box origin)
local contentX = x + self._element.padding.left
local contentY = y + self._element.padding.top
local trackX = contentX + self.scrollbarPadding
local trackY = contentY + height - self.scrollbarWidth - self.scrollbarPadding
-- Determine thumb color based on state (independent for horizontal)
local thumbColor = self.scrollbarColor
if self._scrollbarDragging and self._hoveredScrollbar == "horizontal" then
-- Active state: brighter
thumbColor = Color.new(math.min(1, thumbColor.r * 1.4), math.min(1, thumbColor.g * 1.4), math.min(1, thumbColor.b * 1.4), thumbColor.a)
elseif self._scrollbarHoveredHorizontal then
-- Hover state: slightly brighter
thumbColor = Color.new(math.min(1, thumbColor.r * 1.2), math.min(1, thumbColor.g * 1.2), math.min(1, thumbColor.b * 1.2), thumbColor.a)
end
-- Draw track
love.graphics.setColor(self.scrollbarTrackColor:toRGBA())
love.graphics.rectangle("fill", trackX, trackY, dims.horizontal.trackWidth, self.scrollbarWidth, self.scrollbarRadius)
-- Draw thumb with state-based color
love.graphics.setColor(thumbColor:toRGBA())
love.graphics.rectangle("fill", trackX + dims.horizontal.thumbX, trackY, dims.horizontal.thumbWidth, self.scrollbarWidth, self.scrollbarRadius)
end
-- Reset color
love.graphics.setColor(1, 1, 1, 1)
end
--- Get scrollbar at mouse position
---@param mouseX number
---@param mouseY number
---@return table|nil -- {component: "vertical"|"horizontal", region: "thumb"|"track"}
function ScrollManager:_getScrollbarAtPosition(mouseX, mouseY)
if not self._element then
return nil
end
local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY or self.overflow
if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then
return nil
end
local dims = self:calculateScrollbarDimensions()
local x, y = self._element.x, self._element.y
local w, h = self._element.width, self._element.height
-- Check vertical scrollbar (only if not hidden)
if dims.vertical.visible and not self.hideScrollbars.vertical then
-- Position scrollbar within content area (x, y is border-box origin)
local contentX = x + self._element.padding.left
local contentY = y + self._element.padding.top
local trackX = contentX + w - self.scrollbarWidth - self.scrollbarPadding
local trackY = contentY + self.scrollbarPadding
local trackW = self.scrollbarWidth
local trackH = dims.vertical.trackHeight
if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then
-- Check if over thumb
local thumbY = trackY + dims.vertical.thumbY
local thumbH = dims.vertical.thumbHeight
if mouseY >= thumbY and mouseY <= thumbY + thumbH then
return { component = "vertical", region = "thumb" }
else
return { component = "vertical", region = "track" }
end
end
end
-- Check horizontal scrollbar (only if not hidden)
if dims.horizontal.visible and not self.hideScrollbars.horizontal then
-- Position scrollbar within content area (x, y is border-box origin)
local contentX = x + self._element.padding.left
local contentY = y + self._element.padding.top
local trackX = contentX + self.scrollbarPadding
local trackY = contentY + h - self.scrollbarWidth - self.scrollbarPadding
local trackW = dims.horizontal.trackWidth
local trackH = self.scrollbarWidth
if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then
-- Check if over thumb
local thumbX = trackX + dims.horizontal.thumbX
local thumbW = dims.horizontal.thumbWidth
if mouseX >= thumbX and mouseX <= thumbX + thumbW then
return { component = "horizontal", region = "thumb" }
else
return { component = "horizontal", region = "track" }
end
end
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 ScrollManager:handleMousePress(mouseX, mouseY, button)
if button ~= 1 then
return false
end -- Only left click
local scrollbar = self:_getScrollbarAtPosition(mouseX, mouseY)
if not scrollbar then
return false
end
if scrollbar.region == "thumb" then
-- Start dragging thumb
self._scrollbarDragging = true
self._hoveredScrollbar = scrollbar.component
local dims = self:calculateScrollbarDimensions()
if scrollbar.component == "vertical" then
local contentY = self._element.y + self._element.padding.top
local trackY = contentY + self.scrollbarPadding
local thumbY = trackY + dims.vertical.thumbY
self._scrollbarDragOffset = mouseY - thumbY
elseif scrollbar.component == "horizontal" then
local contentX = self._element.x + self._element.padding.left
local trackX = contentX + self.scrollbarPadding
local thumbX = trackX + dims.horizontal.thumbX
self._scrollbarDragOffset = mouseX - thumbX
end
return true -- Event consumed
elseif scrollbar.region == "track" then
-- Click on track - jump to position
self:_scrollToTrackPosition(mouseX, mouseY, scrollbar.component)
return true
end
return false
end
--- Handle scrollbar release
---@param mouseX number
---@param mouseY number
---@param button number
---@return boolean -- True if event was consumed
function ScrollManager:handleMouseRelease(mouseX, mouseY, button)
if button ~= 1 then
return false
end
if self._scrollbarDragging then
self._scrollbarDragging = false
return true
end
return false
end
--- Handle scrollbar drag
---@param mouseX number
---@param mouseY number
---@return boolean -- True if event was consumed
function ScrollManager:handleMouseMove(mouseX, mouseY)
if not self._scrollbarDragging then
return false
end
local dims = self:calculateScrollbarDimensions()
if self._hoveredScrollbar == "vertical" then
local contentY = self._element.y + self._element.padding.top
local trackY = contentY + self.scrollbarPadding
local trackH = dims.vertical.trackHeight
local thumbH = dims.vertical.thumbHeight
-- Calculate new thumb position
local newThumbY = mouseY - self._scrollbarDragOffset - trackY
newThumbY = math.max(0, math.min(newThumbY, trackH - thumbH))
-- Convert thumb position to scroll position
local scrollRatio = (trackH - thumbH) > 0 and (newThumbY / (trackH - thumbH)) or 0
local newScrollY = scrollRatio * self._maxScrollY
self:setScroll(nil, newScrollY)
return true
elseif self._hoveredScrollbar == "horizontal" then
local contentX = self._element.x + self._element.padding.left
local trackX = contentX + self.scrollbarPadding
local trackW = dims.horizontal.trackWidth
local thumbW = dims.horizontal.thumbWidth
-- Calculate new thumb position
local newThumbX = mouseX - self._scrollbarDragOffset - trackX
newThumbX = math.max(0, math.min(newThumbX, trackW - thumbW))
-- Convert thumb position to scroll position
local scrollRatio = (trackW - thumbW) > 0 and (newThumbX / (trackW - thumbW)) or 0
local newScrollX = scrollRatio * self._maxScrollX
self:setScroll(newScrollX, nil)
return true
end
return false
end
--- Handle mouse wheel scrolling
---@param x number -- Horizontal scroll amount
---@param y number -- Vertical scroll amount
---@return boolean -- True if scroll was handled
function ScrollManager:handleWheel(x, y)
local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY or self.overflow
if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then
return false
end
local hasVerticalOverflow = self._overflowY and self._maxScrollY > 0
local hasHorizontalOverflow = self._overflowX and self._maxScrollX > 0
local scrolled = false
-- Vertical scrolling
if y ~= 0 and hasVerticalOverflow then
local delta = -y * self.scrollSpeed -- Negative because wheel up = scroll up
local newScrollY = self._scrollY + delta
self:setScroll(nil, newScrollY)
scrolled = true
end
-- Horizontal scrolling
if x ~= 0 and hasHorizontalOverflow then
local delta = -x * self.scrollSpeed
local newScrollX = self._scrollX + delta
self:setScroll(newScrollX, nil)
scrolled = true
end
return scrolled
end
--- Check if scrollbar is hovered at position
---@param mouseX number
---@param mouseY number
---@return boolean vertical, boolean horizontal
function ScrollManager:isScrollbarHovered(mouseX, mouseY)
local scrollbar = self:_getScrollbarAtPosition(mouseX, mouseY)
if not scrollbar then
return false, false
end
return scrollbar.component == "vertical", scrollbar.component == "horizontal"
end
--- Get content bounds and scroll limits
---@return number contentWidth, number contentHeight, number maxScrollX, number maxScrollY
function ScrollManager:getContentBounds()
return self._contentWidth, self._contentHeight, self._maxScrollX, self._maxScrollY
end
--- Update scrollbar state (called each frame)
---@param dt number -- Delta time
---@param mouseX number -- Current mouse X position
---@param mouseY number -- Current mouse Y position
function ScrollManager:update(dt, mouseX, mouseY)
local scrollbar = self:_getScrollbarAtPosition(mouseX, mouseY)
-- Update independent hover states for vertical and horizontal scrollbars
if scrollbar and scrollbar.component == "vertical" then
self._scrollbarHoveredVertical = true
self._hoveredScrollbar = "vertical"
else
if not (self._scrollbarDragging and self._hoveredScrollbar == "vertical") then
self._scrollbarHoveredVertical = false
end
end
if scrollbar and scrollbar.component == "horizontal" then
self._scrollbarHoveredHorizontal = true
self._hoveredScrollbar = "horizontal"
else
if not (self._scrollbarDragging and self._hoveredScrollbar == "horizontal") then
self._scrollbarHoveredHorizontal = false
end
end
-- Clear hoveredScrollbar if neither is hovered
if not scrollbar and not self._scrollbarDragging then
self._hoveredScrollbar = nil
end
end
--- Scroll to track click position (jump to position)
---@param mouseX number
---@param mouseY number
---@param component string -- "vertical" or "horizontal"
function ScrollManager:_scrollToTrackPosition(mouseX, mouseY, component)
local dims = self:calculateScrollbarDimensions()
if component == "vertical" then
local contentY = self._element.y + self._element.padding.top
local trackY = contentY + self.scrollbarPadding
local trackH = dims.vertical.trackHeight
local thumbH = dims.vertical.thumbHeight
-- Calculate target thumb position (centered on click)
local targetThumbY = mouseY - trackY - (thumbH / 2)
targetThumbY = math.max(0, math.min(targetThumbY, trackH - thumbH))
-- Convert to scroll position
local scrollRatio = (trackH - thumbH) > 0 and (targetThumbY / (trackH - thumbH)) or 0
local newScrollY = scrollRatio * self._maxScrollY
self:setScroll(nil, newScrollY)
elseif component == "horizontal" then
local contentX = self._element.x + self._element.padding.left
local trackX = contentX + self.scrollbarPadding
local trackW = dims.horizontal.trackWidth
local thumbW = dims.horizontal.thumbWidth
-- Calculate target thumb position (centered on click)
local targetThumbX = mouseX - trackX - (thumbW / 2)
targetThumbX = math.max(0, math.min(targetThumbX, trackW - thumbW))
-- Convert to scroll position
local scrollRatio = (trackW - thumbW) > 0 and (targetThumbX / (trackW - thumbW)) or 0
local newScrollX = scrollRatio * self._maxScrollX
self:setScroll(newScrollX, nil)
end
end
--- Get current scrollbar dragging state
---@return boolean dragging, string? component
function ScrollManager:getDraggingState()
return self._scrollbarDragging, self._hoveredScrollbar
end
--- Set scrollbar dragging state (for state restoration)
---@param dragging boolean
---@param component string? -- "vertical" or "horizontal"
---@param dragOffset number?
function ScrollManager:setDraggingState(dragging, component, dragOffset)
self._scrollbarDragging = dragging
self._hoveredScrollbar = component
self._scrollbarDragOffset = dragOffset or 0
end
--- Get scrollbar hover state
---@return boolean vertical, boolean horizontal
function ScrollManager:getHoverState()
return self._scrollbarHoveredVertical, self._scrollbarHoveredHorizontal
end
--- Set scrollbar hover state (for state restoration)
---@param vertical boolean
---@param horizontal boolean
function ScrollManager:setHoverState(vertical, horizontal)
self._scrollbarHoveredVertical = vertical
self._scrollbarHoveredHorizontal = horizontal
end
--- Check if element has overflow
---@return boolean hasOverflowX, boolean hasOverflowY
function ScrollManager:hasOverflow()
return self._overflowX, self._overflowY
end
--- Get scroll percentage (0-1)
---@return number percentX, number percentY
function ScrollManager:getScrollPercentage()
local percentX = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0
local percentY = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0
return percentX, percentY
end
--- Scroll to top
function ScrollManager:scrollToTop()
self:setScroll(nil, 0)
end
--- Scroll to bottom
function ScrollManager:scrollToBottom()
self:setScroll(nil, self._maxScrollY)
end
--- Scroll to left
function ScrollManager:scrollToLeft()
self:setScroll(0, nil)
end
--- Scroll to right
function ScrollManager:scrollToRight()
self:setScroll(self._maxScrollX, nil)
end
return ScrollManager

File diff suppressed because it is too large Load Diff

View File

@@ -1,436 +0,0 @@
--[[
ThemeManager - Theme and State Management for FlexLove Elements
Extracts all theme-related functionality from Element.lua into a dedicated module.
Handles theme state management, component loading, 9-patch rendering, and property resolution.
]]
-- Setup module path for relative requires
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Module dependencies
local Theme = req("Theme")
local NinePatch = req("NinePatch")
local StateManager = req("StateManager")
--- Standardized error message formatter
---@param module string -- Module name
---@param message string -- Error message
---@return string -- Formatted error message
local function formatError(module, message)
return string.format("[FlexLove.%s] %s", module, message)
end
---@class ThemeManager
---@field theme string? -- Theme name to use
---@field themeComponent string? -- Component name from theme
---@field _themeState string -- Current theme state (normal, hover, pressed, active, disabled)
---@field disabled boolean -- Whether element is disabled
---@field active boolean -- Whether element is active/focused
---@field disableHighlight boolean -- Whether to disable pressed state highlight
---@field scaleCorners number? -- Scale multiplier for 9-patch corners
---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-patch
---@field contentAutoSizingMultiplier table? -- Multiplier for auto-sized content
---@field _element Element? -- Reference to parent element (set via initialize)
---@field _stateId string? -- State manager ID for immediate mode
local ThemeManager = {}
ThemeManager.__index = ThemeManager
--- Create a new ThemeManager instance
---@param config table -- Configuration options
---@return ThemeManager
function ThemeManager.new(config)
local self = setmetatable({}, ThemeManager)
-- Theme configuration
self.theme = config.theme
self.themeComponent = config.themeComponent
-- State properties
self._themeState = "normal"
self.disabled = config.disabled or false
self.active = config.active or false
self.disableHighlight = config.disableHighlight
-- 9-patch rendering properties
self.scaleCorners = config.scaleCorners
self.scalingAlgorithm = config.scalingAlgorithm
-- Content sizing properties
self.contentAutoSizingMultiplier = config.contentAutoSizingMultiplier
-- Element reference (set via initialize)
self._element = nil
self._stateId = config.stateId
return self
end
--- Initialize ThemeManager with parent element reference
--- This links the ThemeManager to its parent element for accessing dimensions and state
---@param element Element -- Parent element
function ThemeManager:initialize(element)
self._element = element
self._stateId = element._stateId or element.id
end
--- Update theme state based on interaction
--- State priority: disabled > pressed > active > hover > normal
---@param isHovered boolean -- Whether element is hovered
---@param isPressed boolean -- Whether element is pressed (any button)
---@param isFocused boolean -- Whether element is focused
---@param isDisabled boolean -- Whether element is disabled
function ThemeManager:updateState(isHovered, isPressed, isFocused, isDisabled)
if not self.themeComponent then
return
end
local newThemeState = "normal"
-- State priority: disabled > active > pressed > hover > normal
if isDisabled or self.disabled then
newThemeState = "disabled"
elseif self.active or isFocused then
newThemeState = "active"
elseif isPressed then
newThemeState = "pressed"
elseif isHovered then
newThemeState = "hover"
end
-- Update local state
self._themeState = newThemeState
-- Update StateManager if in immediate mode
if self._stateId then
local GuiState = req("GuiState")
if GuiState._immediateMode then
StateManager.updateState(self._stateId, {
hover = (newThemeState == "hover"),
pressed = (newThemeState == "pressed"),
focused = (newThemeState == "active" or isFocused),
disabled = isDisabled or self.disabled,
active = self.active,
})
end
end
end
--- Get current theme state
---@return string -- Current state (normal, hover, pressed, active, disabled)
function ThemeManager:getState()
return self._themeState
end
--- Get theme component for current state
--- Returns the component data with state-specific overrides applied
---@return table|nil -- Component data or nil if not found
function ThemeManager:getThemeComponent()
if not self.themeComponent then
return nil
end
-- Get the theme to use
local themeToUse = self:_getTheme()
if not themeToUse then
return nil
end
-- Get the component from the theme
local component = themeToUse.components[self.themeComponent]
if not component then
return nil
end
-- Check for state-specific override
local state = self._themeState
if state and state ~= "normal" and component.states and component.states[state] then
component = component.states[state]
end
return component
end
--- Check if theme component exists
---@return boolean
function ThemeManager:hasThemeComponent()
return self.themeComponent ~= nil and self:getThemeComponent() ~= nil
end
--- Get the theme to use (element theme or active theme)
---@return table|nil -- Theme data or nil if not found
function ThemeManager:_getTheme()
local themeToUse = nil
if self.theme then
-- Element specifies a specific theme - load it if needed
if Theme.get(self.theme) then
themeToUse = Theme.get(self.theme)
else
-- Try to load the theme
pcall(function()
Theme.load(self.theme)
end)
themeToUse = Theme.get(self.theme)
end
else
-- Use active theme
themeToUse = Theme.getActive()
end
return themeToUse
end
--- Get atlas image for current component
---@return love.Image|nil -- Atlas image or nil
function ThemeManager:_getAtlas()
local component = self:getThemeComponent()
if not component then
return nil
end
local themeToUse = self:_getTheme()
if not themeToUse then
return nil
end
-- Use component-specific atlas if available, otherwise use theme atlas
return component._loadedAtlas or themeToUse.atlas
end
--- Render theme component (9-patch or other)
---@param x number -- X position
---@param y number -- Y position
---@param width number -- Width (border-box)
---@param height number -- Height (border-box)
---@param opacity number? -- Opacity (0-1)
function ThemeManager:render(x, y, width, height, opacity)
if not self.themeComponent then
return
end
opacity = opacity or 1
-- Get the theme to use
local themeToUse = self:_getTheme()
if not themeToUse then
return
end
-- Get the component from the theme
local component = themeToUse.components[self.themeComponent]
if not component then
return
end
-- Check for state-specific override
local state = self._themeState
if state and component.states and component.states[state] then
component = component.states[state]
end
-- Use component-specific atlas if available, otherwise use theme atlas
local atlasToUse = component._loadedAtlas or themeToUse.atlas
if atlasToUse and component.regions then
-- Validate component has required structure for 9-patch
local hasAllRegions = component.regions.topLeft
and component.regions.topCenter
and component.regions.topRight
and component.regions.middleLeft
and component.regions.middleCenter
and component.regions.middleRight
and component.regions.bottomLeft
and component.regions.bottomCenter
and component.regions.bottomRight
if hasAllRegions then
-- Render 9-patch with element-level overrides
NinePatch.draw(
component,
atlasToUse,
x,
y,
width,
height,
opacity,
self.scaleCorners,
self.scalingAlgorithm
)
else
-- Silently skip drawing if component structure is invalid
end
end
end
--- Get styled property value from theme for current state
--- This allows theme components to provide default values for properties
---@param property string -- Property name (e.g., "backgroundColor", "textColor")
---@return any|nil -- Property value or nil if not found
function ThemeManager:getStyle(property)
local component = self:getThemeComponent()
if not component then
return nil
end
-- Check if component has style properties
if component.style and component.style[property] then
return component.style[property]
end
return nil
end
--- Set theme and component
---@param themeName string? -- Theme name
---@param componentName string? -- Component name
function ThemeManager:setTheme(themeName, componentName)
self.theme = themeName
self.themeComponent = componentName
end
--- Get scale corners multiplier
---@return number|nil
function ThemeManager:getScaleCorners()
-- Element-level override takes priority
if self.scaleCorners ~= nil then
return self.scaleCorners
end
-- Fall back to component setting
local component = self:getThemeComponent()
if component and component.scaleCorners then
return component.scaleCorners
end
return nil
end
--- Get scaling algorithm
---@return "nearest"|"bilinear"
function ThemeManager:getScalingAlgorithm()
-- Element-level override takes priority
if self.scalingAlgorithm ~= nil then
return self.scalingAlgorithm
end
-- Fall back to component setting
local component = self:getThemeComponent()
if component and component.scalingAlgorithm then
return component.scalingAlgorithm
end
-- Default to bilinear
return "bilinear"
end
--- Get the current state's scaled content padding
--- Returns the contentPadding for the current theme state, scaled to the element's size
---@param borderBoxWidth number -- Border-box width
---@param borderBoxHeight number -- Border-box height
---@return table|nil -- {left, top, right, bottom} or nil if no contentPadding
function ThemeManager:getScaledContentPadding(borderBoxWidth, borderBoxHeight)
if not self.themeComponent then
return nil
end
local themeToUse = self:_getTheme()
if not themeToUse or not themeToUse.components[self.themeComponent] then
return nil
end
local component = themeToUse.components[self.themeComponent]
-- Check for state-specific override
local state = self._themeState or "normal"
if state and state ~= "normal" and component.states and component.states[state] then
component = component.states[state]
end
if not component._ninePatchData or not component._ninePatchData.contentPadding then
return nil
end
local contentPadding = component._ninePatchData.contentPadding
-- Scale contentPadding to match the actual rendered size
local atlasImage = component._loadedAtlas or themeToUse.atlas
if atlasImage and type(atlasImage) ~= "string" then
local originalWidth, originalHeight = atlasImage:getDimensions()
local scaleX = borderBoxWidth / originalWidth
local scaleY = borderBoxHeight / originalHeight
return {
left = contentPadding.left * scaleX,
top = contentPadding.top * scaleY,
right = contentPadding.right * scaleX,
bottom = contentPadding.bottom * scaleY,
}
else
-- Return unscaled values as fallback
return {
left = contentPadding.left,
top = contentPadding.top,
right = contentPadding.right,
bottom = contentPadding.bottom,
}
end
end
--- Get content auto-sizing multiplier from theme
--- Priority: element config > theme component > theme default
---@return table -- {width, height} multipliers
function ThemeManager:getContentAutoSizingMultiplier()
-- If explicitly set in config, use that
if self.contentAutoSizingMultiplier then
return self.contentAutoSizingMultiplier
end
-- Try to source from theme
local themeToUse = self:_getTheme()
if themeToUse then
-- First check if themeComponent has a multiplier
if self.themeComponent then
local component = themeToUse.components[self.themeComponent]
if component and component.contentAutoSizingMultiplier then
return component.contentAutoSizingMultiplier
elseif themeToUse.contentAutoSizingMultiplier then
-- Fall back to theme default
return themeToUse.contentAutoSizingMultiplier
end
elseif themeToUse.contentAutoSizingMultiplier then
return themeToUse.contentAutoSizingMultiplier
end
end
-- Default multiplier
return { 1, 1 }
end
--- Update disabled state
---@param disabled boolean
function ThemeManager:setDisabled(disabled)
self.disabled = disabled
end
--- Update active state
---@param active boolean
function ThemeManager:setActive(active)
self.active = active
end
--- Get disabled state
---@return boolean
function ThemeManager:isDisabled()
return self.disabled
end
--- Get active state
---@return boolean
function ThemeManager:isActive()
return self.active
end
return ThemeManager