restart
This commit is contained in:
3515
modules/Element.lua
3515
modules/Element.lua
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
|
|
||||||
Reference in New Issue
Block a user