This commit is contained in:
Michael Freno
2025-11-13 00:06:09 -05:00
parent de4167a7b6
commit 712b3c40e9
24 changed files with 423 additions and 557 deletions

View File

@@ -1,10 +1,3 @@
---@class Animation
---@field duration number
---@field start {width?:number, height?:number, opacity?:number}
---@field final {width?:number, height?:number, opacity?:number}
---@field elapsed number
---@field transform table?
---@field transition table?
--- Easing functions for animations --- Easing functions for animations
local Easing = { local Easing = {
linear = function(t) linear = function(t)
@@ -47,27 +40,15 @@ local Easing = {
return t == 1 and 1 or 1 - math.pow(2, -10 * t) return t == 1 and 1 or 1 - math.pow(2, -10 * t)
end, end,
} }
---@class Animation
local Animation = {}
Animation.__index = Animation
---@class AnimationProps
---@field duration number ---@field duration number
---@field start {width?:number, height?:number, opacity?:number} ---@field start {width?:number, height?:number, opacity?:number}
---@field final {width?:number, height?:number, opacity?:number} ---@field final {width?:number, height?:number, opacity?:number}
---@field elapsed number
---@field transform table? ---@field transform table?
---@field transition table? ---@field transition table?
local AnimationProps = {} local Animation = {}
Animation.__index = Animation
---@class TransformProps
---@field scale {x?:number, y?:number}?
---@field rotate number?
---@field translate {x?:number, y?:number}?
---@field skew {x?:number, y?:number}?
---@class TransitionProps
---@field duration number?
---@field easing string?
---@param props AnimationProps ---@param props AnimationProps
---@return Animation ---@return Animation

View File

@@ -1,8 +1,3 @@
--[[
FlexLove Blur Module
Fast Gaussian blur implementation with canvas caching
]]
local Blur = {} local Blur = {}
-- Canvas cache to avoid recreating canvases every frame -- Canvas cache to avoid recreating canvases every frame

View File

@@ -1,11 +1,3 @@
--[[
Provides color handling with RGB/RGBA support and hex string conversion
]]
-- ====================
-- Error Handling Utilities
-- ====================
--- Standardized error message formatter --- Standardized error message formatter
---@param module string -- Module name (e.g., "Color", "Theme", "Units") ---@param module string -- Module name (e.g., "Color", "Theme", "Units")
---@param message string -- Error message ---@param message string -- Error message

View File

@@ -1,19 +1,3 @@
-- ====================
-- Event Handler Module
-- ====================
-- Handles all user input events (mouse, keyboard, touch) for UI elements
-- Manages event state, click detection, drag tracking, hover, and focus
---
--- Dependencies (must be injected via deps parameter):
--- - InputEvent: Input event class for creating event objects
--- - GuiState: GUI state manager (unused currently, reserved for future use)
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Get keyboard modifiers helper
local function getModifiers() local function getModifiers()
return { return {
shift = love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift"), shift = love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift"),
@@ -33,42 +17,42 @@ EventHandler.__index = EventHandler
---@return EventHandler ---@return EventHandler
function EventHandler.new(config, deps) function EventHandler.new(config, deps)
config = config or {} config = config or {}
local self = setmetatable({}, EventHandler) local self = setmetatable({}, EventHandler)
-- Store dependencies -- Store dependencies
self._InputEvent = deps.InputEvent self._InputEvent = deps.InputEvent
self._GuiState = deps.GuiState self._GuiState = deps.GuiState
-- Event callback -- Event callback
self.onEvent = config.onEvent self.onEvent = config.onEvent
-- Mouse button state tracking {button -> boolean} -- Mouse button state tracking {button -> boolean}
self._pressed = config._pressed or {} self._pressed = config._pressed or {}
-- Click detection state -- Click detection state
self._lastClickTime = config._lastClickTime self._lastClickTime = config._lastClickTime
self._lastClickButton = config._lastClickButton self._lastClickButton = config._lastClickButton
self._clickCount = config._clickCount or 0 self._clickCount = config._clickCount or 0
-- Drag tracking per button {button -> position} -- Drag tracking per button {button -> position}
self._dragStartX = config._dragStartX or {} self._dragStartX = config._dragStartX or {}
self._dragStartY = config._dragStartY or {} self._dragStartY = config._dragStartY or {}
self._lastMouseX = config._lastMouseX or {} self._lastMouseX = config._lastMouseX or {}
self._lastMouseY = config._lastMouseY or {} self._lastMouseY = config._lastMouseY or {}
-- Touch state tracking {touchId -> boolean} -- Touch state tracking {touchId -> boolean}
self._touchPressed = config._touchPressed or {} self._touchPressed = config._touchPressed or {}
-- Hover state -- Hover state
self._hovered = config._hovered or false self._hovered = config._hovered or false
-- Reference to parent element (set via initialize) -- Reference to parent element (set via initialize)
self._element = nil self._element = nil
-- Scrollbar press tracking flag -- Scrollbar press tracking flag
self._scrollbarPressHandled = false self._scrollbarPressHandled = false
return self return self
end end
@@ -97,8 +81,10 @@ end
--- Restore state from persistence (for immediate mode) --- Restore state from persistence (for immediate mode)
---@param state table State data ---@param state table State data
function EventHandler:setState(state) function EventHandler:setState(state)
if not state then return end if not state then
return
end
self._pressed = state._pressed or {} self._pressed = state._pressed or {}
self._lastClickTime = state._lastClickTime self._lastClickTime = state._lastClickTime
self._lastClickButton = state._lastClickButton self._lastClickButton = state._lastClickButton
@@ -119,9 +105,9 @@ function EventHandler:processMouseEvents(mx, my, isHovering, isActiveElement)
if not self._element then if not self._element then
return return
end end
local element = self._element local element = self._element
-- Check if currently dragging (allows drag continuation even if occluded) -- Check if currently dragging (allows drag continuation even if occluded)
local isDragging = false local isDragging = false
for _, button in ipairs({ 1, 2, 3 }) do for _, button in ipairs({ 1, 2, 3 }) do
@@ -130,17 +116,17 @@ function EventHandler:processMouseEvents(mx, my, isHovering, isActiveElement)
break break
end end
end end
-- Can only process events if we have handler, element is enabled, and is active or dragging -- Can only process events if we have handler, element is enabled, and is active or dragging
local canProcessEvents = (self.onEvent or element.editable) and not element.disabled and (isActiveElement or isDragging) local canProcessEvents = (self.onEvent or element.editable) and not element.disabled and (isActiveElement or isDragging)
if not canProcessEvents then if not canProcessEvents then
return return
end end
-- Process all three mouse buttons -- Process all three mouse buttons
local buttons = { 1, 2, 3 } -- left, right, middle local buttons = { 1, 2, 3 } -- left, right, middle
for _, button in ipairs(buttons) do for _, button in ipairs(buttons) do
if isHovering or isDragging then if isHovering or isDragging then
if love.mouse.isDown(button) then if love.mouse.isDown(button) then
@@ -172,10 +158,12 @@ end
---@param my number Mouse Y position ---@param my number Mouse Y position
---@param button number Mouse button (1=left, 2=right, 3=middle) ---@param button number Mouse button (1=left, 2=right, 3=middle)
function EventHandler:_handleMousePress(mx, my, button) function EventHandler:_handleMousePress(mx, my, button)
if not self._element then return end if not self._element then
return
end
local element = self._element local element = self._element
-- Check if press is on scrollbar first (skip if already handled) -- Check if press is on scrollbar first (skip if already handled)
if button == 1 and not self._scrollbarPressHandled and element._handleScrollbarPress then if button == 1 and not self._scrollbarPressHandled and element._handleScrollbarPress then
if element:_handleScrollbarPress(mx, my, button) then if element:_handleScrollbarPress(mx, my, button) then
@@ -185,7 +173,7 @@ function EventHandler:_handleMousePress(mx, my, button)
return return
end end
end end
-- Fire press event -- Fire press event
if self.onEvent then if self.onEvent then
local modifiers = getModifiers() local modifiers = getModifiers()
@@ -199,15 +187,15 @@ function EventHandler:_handleMousePress(mx, my, button)
}) })
self.onEvent(element, pressEvent) self.onEvent(element, pressEvent)
end end
self._pressed[button] = true self._pressed[button] = true
-- Set mouse down position for text selection on left click -- Set mouse down position for text selection on left click
if button == 1 and element._textEditor then if button == 1 and element._textEditor then
element._mouseDownPosition = element._textEditor:mouseToTextPosition(mx, my) element._mouseDownPosition = element._textEditor:mouseToTextPosition(mx, my)
element._textDragOccurred = false -- Reset drag flag on press element._textDragOccurred = false -- Reset drag flag on press
end end
-- Record drag start position per button -- Record drag start position per button
self._dragStartX[button] = mx self._dragStartX[button] = mx
self._dragStartY[button] = my self._dragStartY[button] = my
@@ -221,20 +209,22 @@ end
---@param button number Mouse button ---@param button number Mouse button
---@param isHovering boolean Whether mouse is over element ---@param isHovering boolean Whether mouse is over element
function EventHandler:_handleMouseDrag(mx, my, button, isHovering) function EventHandler:_handleMouseDrag(mx, my, button, isHovering)
if not self._element then return end if not self._element then
return
end
local element = self._element local element = self._element
local lastX = self._lastMouseX[button] or mx local lastX = self._lastMouseX[button] or mx
local lastY = self._lastMouseY[button] or my local lastY = self._lastMouseY[button] or my
if lastX ~= mx or lastY ~= my then if lastX ~= mx or lastY ~= my then
-- Mouse has moved - fire drag event only if still hovering -- Mouse has moved - fire drag event only if still hovering
if self.onEvent and isHovering then if self.onEvent and isHovering then
local modifiers = getModifiers() local modifiers = getModifiers()
local dx = mx - self._dragStartX[button] local dx = mx - self._dragStartX[button]
local dy = my - self._dragStartY[button] local dy = my - self._dragStartY[button]
local dragEvent = self._InputEvent.new({ local dragEvent = self._InputEvent.new({
type = "drag", type = "drag",
button = button, button = button,
@@ -247,12 +237,12 @@ function EventHandler:_handleMouseDrag(mx, my, button, isHovering)
}) })
self.onEvent(element, dragEvent) self.onEvent(element, dragEvent)
end end
-- Handle text selection drag for editable elements -- Handle text selection drag for editable elements
if button == 1 and element.editable and element._focused and element._handleTextDrag then if button == 1 and element.editable and element._focused and element._handleTextDrag then
element:_handleTextDrag(mx, my) element:_handleTextDrag(mx, my)
end end
-- Update last known position for this button -- Update last known position for this button
self._lastMouseX[button] = mx self._lastMouseX[button] = mx
self._lastMouseY[button] = my self._lastMouseY[button] = my
@@ -264,27 +254,29 @@ end
---@param my number Mouse Y position ---@param my number Mouse Y position
---@param button number Mouse button ---@param button number Mouse button
function EventHandler:_handleMouseRelease(mx, my, button) function EventHandler:_handleMouseRelease(mx, my, button)
if not self._element then return end if not self._element then
return
end
local element = self._element local element = self._element
local currentTime = love.timer.getTime() local currentTime = love.timer.getTime()
local modifiers = getModifiers() local modifiers = getModifiers()
-- Determine click count (double-click detection) -- Determine click count (double-click detection)
local clickCount = 1 local clickCount = 1
local doubleClickThreshold = 0.3 -- 300ms for double-click local doubleClickThreshold = 0.3 -- 300ms for double-click
if self._lastClickTime and self._lastClickButton == button and (currentTime - self._lastClickTime) < doubleClickThreshold then if self._lastClickTime and self._lastClickButton == button and (currentTime - self._lastClickTime) < doubleClickThreshold then
clickCount = self._clickCount + 1 clickCount = self._clickCount + 1
else else
clickCount = 1 clickCount = 1
end end
self._clickCount = clickCount self._clickCount = clickCount
self._lastClickTime = currentTime self._lastClickTime = currentTime
self._lastClickButton = button self._lastClickButton = button
-- Determine event type based on button -- Determine event type based on button
local eventType = "click" local eventType = "click"
if button == 2 then if button == 2 then
@@ -292,7 +284,7 @@ function EventHandler:_handleMouseRelease(mx, my, button)
elseif button == 3 then elseif button == 3 then
eventType = "middleclick" eventType = "middleclick"
end end
-- Fire click event -- Fire click event
if self.onEvent then if self.onEvent then
local clickEvent = self._InputEvent.new({ local clickEvent = self._InputEvent.new({
@@ -305,18 +297,18 @@ function EventHandler:_handleMouseRelease(mx, my, button)
}) })
self.onEvent(element, clickEvent) self.onEvent(element, clickEvent)
end end
self._pressed[button] = false self._pressed[button] = false
-- Clean up drag tracking -- Clean up drag tracking
self._dragStartX[button] = nil self._dragStartX[button] = nil
self._dragStartY[button] = nil self._dragStartY[button] = nil
-- Clean up text selection drag tracking -- Clean up text selection drag tracking
if button == 1 then if button == 1 then
element._mouseDownPosition = nil element._mouseDownPosition = nil
end end
-- Focus editable elements on left click -- Focus editable elements on left click
if button == 1 and element.editable then if button == 1 and element.editable then
-- Only focus if not already focused (to avoid moving cursor to end) -- Only focus if not already focused (to avoid moving cursor to end)
@@ -324,17 +316,17 @@ function EventHandler:_handleMouseRelease(mx, my, button)
if not wasFocused then if not wasFocused then
element:focus() element:focus()
end end
-- Handle text click for cursor positioning and word selection -- Handle text click for cursor positioning and word selection
-- Only process click if no text drag occurred (to preserve drag selection) -- Only process click if no text drag occurred (to preserve drag selection)
if element._handleTextClick and not element._textDragOccurred then if element._handleTextClick and not element._textDragOccurred then
element:_handleTextClick(mx, my, clickCount) element:_handleTextClick(mx, my, clickCount)
end end
-- Reset drag flag after release -- Reset drag flag after release
element._textDragOccurred = false element._textDragOccurred = false
end end
-- Fire release event -- Fire release event
if self.onEvent then if self.onEvent then
local releaseEvent = self._InputEvent.new({ local releaseEvent = self._InputEvent.new({
@@ -354,14 +346,14 @@ function EventHandler:processTouchEvents()
if not self._element or not self.onEvent then if not self._element or not self.onEvent then
return return
end end
local element = self._element local element = self._element
local bx = element.x local bx = element.x
local by = element.y local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) 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 bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
local touches = love.touch.getTouches() local touches = love.touch.getTouches()
for _, id in ipairs(touches) do for _, id in ipairs(touches) do
local tx, ty = love.touch.getPosition(id) local tx, ty = love.touch.getPosition(id)

View File

@@ -1,7 +1,3 @@
-- ====================
-- Grid Layout System
-- ====================
local modulePath = (...):match("(.-)[^%.]+$") local modulePath = (...):match("(.-)[^%.]+$")
local utils = require(modulePath .. "utils") local utils = require(modulePath .. "utils")
local enums = utils.enums local enums = utils.enums

View File

@@ -1,8 +1,3 @@
-- ====================
-- GUI State Module
-- ====================
-- Shared state between Gui and Element to avoid circular dependencies
---@class GuiState ---@class GuiState
local GuiState = { local GuiState = {
-- Top-level elements -- Top-level elements

View File

@@ -1,12 +1,3 @@
--[[
ImageCache.lua - Image caching system for FlexLove
Provides efficient image loading and caching with memory management
]]
-- ====================
-- ImageCache
-- ====================
---@class ImageCache ---@class ImageCache
---@field _cache table<string, {image: love.Image, imageData: love.ImageData?}> ---@field _cache table<string, {image: love.Image, imageData: love.ImageData?}>
local ImageCache = {} local ImageCache = {}

View File

@@ -1,12 +1,3 @@
--[[
ImageDataReader.lua - Image data reading utilities for FlexLove
Provides functions to load and read pixel data from images
]]
-- ====================
-- Error Handling Utilities
-- ====================
--- Standardized error message formatter --- Standardized error message formatter
---@param module string -- Module name (e.g., "Color", "Theme", "Units") ---@param module string -- Module name (e.g., "Color", "Theme", "Units")
---@param message string -- Error message ---@param message string -- Error message

View File

@@ -1,12 +1,3 @@
--[[
ImageRenderer.lua - Image rendering utilities for FlexLove
Provides object-fit modes and object-position support for image rendering
]]
-- ====================
-- Error Handling Utilities
-- ====================
--- Standardized error message formatter --- Standardized error message formatter
---@param module string -- Module name (e.g., "Color", "Theme", "Units") ---@param module string -- Module name (e.g., "Color", "Theme", "Units")
---@param message string -- Error message ---@param message string -- Error message

View File

@@ -1,12 +1,3 @@
--[[
ImageScaler.lua - Image scaling utilities for FlexLove
Provides nearest-neighbor and bilinear interpolation scaling algorithms
]]
-- ====================
-- Error Handling Utilities
-- ====================
--- Standardized error message formatter --- Standardized error message formatter
---@param module string -- Module name (e.g., "Color", "Theme", "Units") ---@param module string -- Module name (e.g., "Color", "Theme", "Units")
---@param message string -- Error message ---@param message string -- Error message

View File

@@ -1,7 +1,3 @@
-- ====================
-- Input Event System
-- ====================
---@class InputEvent ---@class InputEvent
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag" ---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"
---@field button number -- Mouse button: 1 (left), 2 (right), 3 (middle) ---@field button number -- Mouse button: 1 (left), 2 (right), 3 (middle)

View File

@@ -1,16 +1,3 @@
-- ====================
-- LayoutEngine Module
-- ====================
-- Handles all layout calculations for Element including:
-- - Flexbox layout algorithm
-- - Grid layout delegation
-- - Auto-sizing calculations
-- - CSS positioning offsets
---
--- Dependencies (must be injected via deps parameter):
--- - utils: Utility functions and enums
--- - Grid: Grid layout module
---@class LayoutEngine ---@class LayoutEngine
---@field element Element Reference to the parent element ---@field element Element Reference to the parent element
---@field positioning Positioning Layout positioning mode ---@field positioning Positioning Layout positioning mode

View File

@@ -1,9 +1,3 @@
--[[
NinePatch - 9-Patch Renderer for FlexLove
Handles rendering of 9-patch components with Android-style scaling.
Corners can be scaled independently while edges stretch in one dimension.
]]
local modulePath = (...):match("(.-)[^%.]+$") local modulePath = (...):match("(.-)[^%.]+$")
local ImageScaler = require(modulePath .. "ImageScaler") local ImageScaler = require(modulePath .. "ImageScaler")

View File

@@ -1,12 +1,3 @@
--[[
NinePatchParser.lua - 9-patch PNG parser for FlexLove
Parses Android-style 9-patch images to extract stretch regions and content padding
]]
-- ====================
-- Error Handling Utilities
-- ====================
--- Standardized error message formatter --- Standardized error message formatter
---@param module string -- Module name (e.g., "Color", "Theme", "Units") ---@param module string -- Module name (e.g., "Color", "Theme", "Units")
---@param message string -- Error message ---@param message string -- Error message

View File

@@ -1,20 +1,3 @@
--- Renderer Module
-- Handles all visual rendering for Elements including backgrounds, borders,
-- images, themes, blur effects, and text rendering.
--
-- This module is responsible for the visual presentation layer of Elements,
-- delegating from Element's draw() method to keep rendering concerns separated.
---
--- Dependencies (must be injected via deps parameter):
--- - Color: Color module for color manipulation
--- - RoundedRect: Rounded rectangle drawing module
--- - NinePatch: 9-patch rendering module
--- - ImageRenderer: Image rendering module
--- - ImageCache: Image caching module
--- - Theme: Theme management module
--- - Blur: Blur effects module
--- - utils: Utility functions (FONT_CACHE, enums)
local Renderer = {} local Renderer = {}
Renderer.__index = Renderer Renderer.__index = Renderer
@@ -25,9 +8,9 @@ Renderer.__index = Renderer
function Renderer.new(config, deps) function Renderer.new(config, deps)
local Color = deps.Color local Color = deps.Color
local ImageCache = deps.ImageCache local ImageCache = deps.ImageCache
local self = setmetatable({}, Renderer) local self = setmetatable({}, Renderer)
-- Store dependencies for instance methods -- Store dependencies for instance methods
self._Color = Color self._Color = Color
self._RoundedRect = deps.RoundedRect self._RoundedRect = deps.RoundedRect
@@ -39,36 +22,36 @@ function Renderer.new(config, deps)
self._utils = deps.utils self._utils = deps.utils
self._FONT_CACHE = deps.utils.FONT_CACHE self._FONT_CACHE = deps.utils.FONT_CACHE
self._TextAlign = deps.utils.enums.TextAlign self._TextAlign = deps.utils.enums.TextAlign
-- Store reference to parent element (will be set via initialize) -- Store reference to parent element (will be set via initialize)
self._element = nil self._element = nil
-- Visual properties -- Visual properties
self.backgroundColor = config.backgroundColor or Color.new(0, 0, 0, 0) self.backgroundColor = config.backgroundColor or Color.new(0, 0, 0, 0)
self.borderColor = config.borderColor or Color.new(0, 0, 0, 1) self.borderColor = config.borderColor or Color.new(0, 0, 0, 1)
self.opacity = config.opacity or 1 self.opacity = config.opacity or 1
-- Border configuration -- Border configuration
self.border = config.border or { self.border = config.border or {
top = false, top = false,
right = false, right = false,
bottom = false, bottom = false,
left = false left = false,
} }
-- Corner radius -- Corner radius
self.cornerRadius = config.cornerRadius or { self.cornerRadius = config.cornerRadius or {
topLeft = 0, topLeft = 0,
topRight = 0, topRight = 0,
bottomLeft = 0, bottomLeft = 0,
bottomRight = 0 bottomRight = 0,
} }
-- Theme properties -- Theme properties
self.theme = config.theme self.theme = config.theme
self.themeComponent = config.themeComponent self.themeComponent = config.themeComponent
self._themeState = "normal" self._themeState = "normal"
-- Image properties -- Image properties
self.imagePath = config.imagePath self.imagePath = config.imagePath
self.image = config.image self.image = config.image
@@ -76,12 +59,12 @@ function Renderer.new(config, deps)
self.objectFit = config.objectFit or "fill" self.objectFit = config.objectFit or "fill"
self.objectPosition = config.objectPosition or "center center" self.objectPosition = config.objectPosition or "center center"
self.imageOpacity = config.imageOpacity or 1 self.imageOpacity = config.imageOpacity or 1
-- Blur effects -- Blur effects
self.contentBlur = config.contentBlur self.contentBlur = config.contentBlur
self.backdropBlur = config.backdropBlur self.backdropBlur = config.backdropBlur
self._blurInstance = nil self._blurInstance = nil
-- Load image if path provided -- Load image if path provided
if self.imagePath and not self.image then if self.imagePath and not self.image then
local loadedImage, err = ImageCache.load(self.imagePath) local loadedImage, err = ImageCache.load(self.imagePath)
@@ -95,7 +78,7 @@ function Renderer.new(config, deps)
else else
self._loadedImage = nil self._loadedImage = nil
end end
return self return self
end end
@@ -115,12 +98,12 @@ function Renderer:getBlurInstance()
elseif self.backdropBlur and self.backdropBlur.quality then elseif self.backdropBlur and self.backdropBlur.quality then
quality = self.backdropBlur.quality quality = self.backdropBlur.quality
end end
-- Create or reuse blur instance -- Create or reuse blur instance
if not self._blurInstance or self._blurInstance.quality ~= quality then if not self._blurInstance or self._blurInstance.quality ~= quality then
self._blurInstance = self._Blur.new(quality) self._blurInstance = self._Blur.new(quality)
end end
return self._blurInstance return self._blurInstance
end end
@@ -137,12 +120,7 @@ end
---@param height number Height ---@param height number Height
---@param drawBackgroundColor table Background color (may have animation applied) ---@param drawBackgroundColor table Background color (may have animation applied)
function Renderer:_drawBackground(x, y, width, height, drawBackgroundColor) function Renderer:_drawBackground(x, y, width, height, drawBackgroundColor)
local backgroundWithOpacity = self._Color.new( local backgroundWithOpacity = self._Color.new(drawBackgroundColor.r, drawBackgroundColor.g, drawBackgroundColor.b, drawBackgroundColor.a * self.opacity)
drawBackgroundColor.r,
drawBackgroundColor.g,
drawBackgroundColor.b,
drawBackgroundColor.a * self.opacity
)
love.graphics.setColor(backgroundWithOpacity:toRGBA()) love.graphics.setColor(backgroundWithOpacity:toRGBA())
self._RoundedRect.draw("fill", x, y, width, height, self.cornerRadius) self._RoundedRect.draw("fill", x, y, width, height, self.cornerRadius)
end end
@@ -160,22 +138,22 @@ function Renderer:_drawImage(x, y, paddingLeft, paddingTop, contentWidth, conten
if not self._loadedImage then if not self._loadedImage then
return return
end end
-- Calculate image bounds (content area - respects padding) -- Calculate image bounds (content area - respects padding)
local imageX = x + paddingLeft local imageX = x + paddingLeft
local imageY = y + paddingTop local imageY = y + paddingTop
local imageWidth = contentWidth local imageWidth = contentWidth
local imageHeight = contentHeight local imageHeight = contentHeight
-- Combine element opacity with imageOpacity -- Combine element opacity with imageOpacity
local finalOpacity = self.opacity * self.imageOpacity local finalOpacity = self.opacity * self.imageOpacity
-- Apply cornerRadius clipping if set -- Apply cornerRadius clipping if set
local hasCornerRadius = self.cornerRadius.topLeft > 0 local hasCornerRadius = self.cornerRadius.topLeft > 0
or self.cornerRadius.topRight > 0 or self.cornerRadius.topRight > 0
or self.cornerRadius.bottomLeft > 0 or self.cornerRadius.bottomLeft > 0
or self.cornerRadius.bottomRight > 0 or self.cornerRadius.bottomRight > 0
if hasCornerRadius then if hasCornerRadius then
-- Use stencil to clip image to rounded corners -- Use stencil to clip image to rounded corners
love.graphics.stencil(function() love.graphics.stencil(function()
@@ -183,10 +161,10 @@ function Renderer:_drawImage(x, y, paddingLeft, paddingTop, contentWidth, conten
end, "replace", 1) end, "replace", 1)
love.graphics.setStencilTest("greater", 0) love.graphics.setStencilTest("greater", 0)
end end
-- Draw the image -- Draw the image
self._ImageRenderer.draw(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.objectFit, self.objectPosition, finalOpacity) self._ImageRenderer.draw(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.objectFit, self.objectPosition, finalOpacity)
-- Clear stencil if it was used -- Clear stencil if it was used
if hasCornerRadius then if hasCornerRadius then
love.graphics.setStencilTest() love.graphics.setStencilTest()
@@ -204,7 +182,7 @@ function Renderer:_drawTheme(x, y, borderBoxWidth, borderBoxHeight, scaleCorners
if not self.themeComponent then if not self.themeComponent then
return return
end end
-- Get the theme to use -- Get the theme to use
local themeToUse = nil local themeToUse = nil
if self.theme then if self.theme then
@@ -222,26 +200,26 @@ function Renderer:_drawTheme(x, y, borderBoxWidth, borderBoxHeight, scaleCorners
-- Use active theme -- Use active theme
themeToUse = self._Theme.getActive() themeToUse = self._Theme.getActive()
end end
if not themeToUse then if not themeToUse then
return return
end end
-- Get the component from the theme -- Get the component from the theme
local component = themeToUse.components[self.themeComponent] local component = themeToUse.components[self.themeComponent]
if not component then if not component then
return return
end end
-- Check for state-specific override -- Check for state-specific override
local state = self._themeState local state = self._themeState
if state and component.states and component.states[state] then if state and component.states and component.states[state] then
component = component.states[state] component = component.states[state]
end end
-- Use component-specific atlas if available, otherwise use theme atlas -- Use component-specific atlas if available, otherwise use theme atlas
local atlasToUse = component._loadedAtlas or themeToUse.atlas local atlasToUse = component._loadedAtlas or themeToUse.atlas
if atlasToUse and component.regions then if atlasToUse and component.regions then
-- Validate component has required structure -- Validate component has required structure
local hasAllRegions = component.regions.topLeft local hasAllRegions = component.regions.topLeft
@@ -253,7 +231,7 @@ function Renderer:_drawTheme(x, y, borderBoxWidth, borderBoxHeight, scaleCorners
and component.regions.bottomLeft and component.regions.bottomLeft
and component.regions.bottomCenter and component.regions.bottomCenter
and component.regions.bottomRight and component.regions.bottomRight
if hasAllRegions then if hasAllRegions then
-- Pass element-level overrides for scaleCorners and scalingAlgorithm -- Pass element-level overrides for scaleCorners and scalingAlgorithm
self._NinePatch.draw(component, atlasToUse, x, y, borderBoxWidth, borderBoxHeight, self.opacity, scaleCorners, scalingAlgorithm) self._NinePatch.draw(component, atlasToUse, x, y, borderBoxWidth, borderBoxHeight, self.opacity, scaleCorners, scalingAlgorithm)
@@ -267,17 +245,12 @@ end
---@param borderBoxWidth number Border box width ---@param borderBoxWidth number Border box width
---@param borderBoxHeight number Border box height ---@param borderBoxHeight number Border box height
function Renderer:_drawBorders(x, y, borderBoxWidth, borderBoxHeight) function Renderer:_drawBorders(x, y, borderBoxWidth, borderBoxHeight)
local borderColorWithOpacity = self._Color.new( local borderColorWithOpacity = self._Color.new(self.borderColor.r, self.borderColor.g, self.borderColor.b, self.borderColor.a * self.opacity)
self.borderColor.r,
self.borderColor.g,
self.borderColor.b,
self.borderColor.a * self.opacity
)
love.graphics.setColor(borderColorWithOpacity:toRGBA()) love.graphics.setColor(borderColorWithOpacity:toRGBA())
-- Check if all borders are enabled -- Check if all borders are enabled
local allBorders = self.border.top and self.border.bottom and self.border.left and self.border.right local allBorders = self.border.top and self.border.bottom and self.border.left and self.border.right
if allBorders then if allBorders then
-- Draw complete rounded rectangle border -- Draw complete rounded rectangle border
self._RoundedRect.draw("line", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius) self._RoundedRect.draw("line", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
@@ -305,14 +278,14 @@ function Renderer:draw(backdropCanvas)
if self.opacity <= 0 then if self.opacity <= 0 then
return return
end end
-- Element must be initialized before drawing -- Element must be initialized before drawing
if not self._element then if not self._element then
error("Renderer:draw() called before initialize(). Call renderer:initialize(element) first.") error("Renderer:draw() called before initialize(). Call renderer:initialize(element) first.")
end end
local element = self._element local element = self._element
-- Handle opacity during animation -- Handle opacity during animation
local drawBackgroundColor = self.backgroundColor local drawBackgroundColor = self.backgroundColor
if element.animation then if element.animation then
@@ -321,11 +294,11 @@ function Renderer:draw(backdropCanvas)
drawBackgroundColor = self._Color.new(self.backgroundColor.r, self.backgroundColor.g, self.backgroundColor.b, anim.opacity) drawBackgroundColor = self._Color.new(self.backgroundColor.r, self.backgroundColor.g, self.backgroundColor.b, anim.opacity)
end end
end end
-- Cache border box dimensions for this draw call (optimization) -- Cache border box dimensions for this draw call (optimization)
local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) 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) local borderBoxHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
-- LAYER 0.5: Draw backdrop blur if configured (before background) -- LAYER 0.5: Draw backdrop blur if configured (before background)
if self.backdropBlur and self.backdropBlur.intensity > 0 and backdropCanvas then if self.backdropBlur and self.backdropBlur.intensity > 0 and backdropCanvas then
local blurInstance = self:getBlurInstance() local blurInstance = self:getBlurInstance()
@@ -333,25 +306,16 @@ function Renderer:draw(backdropCanvas)
self._Blur.applyBackdrop(blurInstance, self.backdropBlur.intensity, element.x, element.y, borderBoxWidth, borderBoxHeight, backdropCanvas) self._Blur.applyBackdrop(blurInstance, self.backdropBlur.intensity, element.x, element.y, borderBoxWidth, borderBoxHeight, backdropCanvas)
end end
end end
-- LAYER 1: Draw backgroundColor first (behind everything) -- LAYER 1: Draw backgroundColor first (behind everything)
self:_drawBackground(element.x, element.y, borderBoxWidth, borderBoxHeight, drawBackgroundColor) self:_drawBackground(element.x, element.y, borderBoxWidth, borderBoxHeight, drawBackgroundColor)
-- LAYER 1.5: Draw image on top of backgroundColor (if image exists) -- LAYER 1.5: Draw image on top of backgroundColor (if image exists)
self:_drawImage( self:_drawImage(element.x, element.y, element.padding.left, element.padding.top, element.width, element.height, borderBoxWidth, borderBoxHeight)
element.x,
element.y,
element.padding.left,
element.padding.top,
element.width,
element.height,
borderBoxWidth,
borderBoxHeight
)
-- LAYER 2: Draw theme on top of backgroundColor (if theme exists) -- LAYER 2: Draw theme on top of backgroundColor (if theme exists)
self:_drawTheme(element.x, element.y, borderBoxWidth, borderBoxHeight, element.scaleCorners, element.scalingAlgorithm) self:_drawTheme(element.x, element.y, borderBoxWidth, borderBoxHeight, element.scaleCorners, element.scalingAlgorithm)
-- LAYER 3: Draw borders on top of theme -- LAYER 3: Draw borders on top of theme
self:_drawBorders(element.x, element.y, borderBoxWidth, borderBoxHeight) self:_drawBorders(element.x, element.y, borderBoxWidth, borderBoxHeight)
end end
@@ -382,7 +346,7 @@ end
function Renderer:wrapLine(element, line, maxWidth) function Renderer:wrapLine(element, line, maxWidth)
-- UTF-8 support -- UTF-8 support
local utf8 = utf8 or require("utf8") local utf8 = utf8 or require("utf8")
if not element.editable then if not element.editable then
return { { text = line, startIdx = 0, endIdx = utf8.len(line) } } return { { text = line, startIdx = 0, endIdx = utf8.len(line) } }
end end
@@ -596,7 +560,8 @@ function Renderer:drawText(element)
end end
if displayText and displayText ~= "" then if displayText and displayText ~= "" then
local textColor = isPlaceholder and self._Color.new(element.textColor.r * 0.5, element.textColor.g * 0.5, element.textColor.b * 0.5, element.textColor.a * 0.5) local textColor = isPlaceholder
and self._Color.new(element.textColor.r * 0.5, element.textColor.g * 0.5, element.textColor.b * 0.5, element.textColor.a * 0.5)
or element.textColor or element.textColor
local textColorWithOpacity = self._Color.new(textColor.r, textColor.g, textColor.b, textColor.a * self.opacity) local textColorWithOpacity = self._Color.new(textColor.r, textColor.g, textColor.b, textColor.a * self.opacity)
love.graphics.setColor(textColorWithOpacity:toRGBA()) love.graphics.setColor(textColorWithOpacity:toRGBA())

View File

@@ -1,8 +1,3 @@
--[[
RoundedRect - Rounded Rectangle Helper for FlexLove
Provides functions for generating and drawing rounded rectangles with per-corner radius control.
]]
local RoundedRect = {} local RoundedRect = {}
--- Generate points for a rounded rectangle --- Generate points for a rounded rectangle

View File

@@ -1,10 +1,3 @@
--- ScrollManager.lua
--- Handles scrolling, overflow detection, and scrollbar rendering/interaction for Elements
--- Extracted from Element.lua as part of element-refactor-modularization task 05
---
--- Dependencies (must be injected via deps parameter):
--- - Color: Color module for creating color instances
---@class ScrollManager ---@class ScrollManager
---@field overflow string -- "visible"|"hidden"|"auto"|"scroll" ---@field overflow string -- "visible"|"hidden"|"auto"|"scroll"
---@field overflowX string? -- X-axis specific overflow (overrides overflow) ---@field overflowX string? -- X-axis specific overflow (overrides overflow)
@@ -41,15 +34,15 @@ ScrollManager.__index = ScrollManager
function ScrollManager.new(config, deps) function ScrollManager.new(config, deps)
local Color = deps.Color local Color = deps.Color
local self = setmetatable({}, ScrollManager) local self = setmetatable({}, ScrollManager)
-- Store dependency for instance methods -- Store dependency for instance methods
self._Color = Color self._Color = Color
-- Configuration -- Configuration
self.overflow = config.overflow or "hidden" self.overflow = config.overflow or "hidden"
self.overflowX = config.overflowX self.overflowX = config.overflowX
self.overflowY = config.overflowY self.overflowY = config.overflowY
-- Scrollbar appearance -- Scrollbar appearance
self.scrollbarWidth = config.scrollbarWidth or 12 self.scrollbarWidth = config.scrollbarWidth or 12
self.scrollbarColor = config.scrollbarColor or Color.new(0.5, 0.5, 0.5, 0.8) self.scrollbarColor = config.scrollbarColor or Color.new(0.5, 0.5, 0.5, 0.8)
@@ -57,7 +50,7 @@ function ScrollManager.new(config, deps)
self.scrollbarRadius = config.scrollbarRadius or 6 self.scrollbarRadius = config.scrollbarRadius or 6
self.scrollbarPadding = config.scrollbarPadding or 2 self.scrollbarPadding = config.scrollbarPadding or 2
self.scrollSpeed = config.scrollSpeed or 20 self.scrollSpeed = config.scrollSpeed or 20
-- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean} -- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean}
if config.hideScrollbars ~= nil then if config.hideScrollbars ~= nil then
if type(config.hideScrollbars) == "boolean" then if type(config.hideScrollbars) == "boolean" then
@@ -73,19 +66,19 @@ function ScrollManager.new(config, deps)
else else
self.hideScrollbars = { vertical = false, horizontal = false } self.hideScrollbars = { vertical = false, horizontal = false }
end end
-- Internal overflow state -- Internal overflow state
self._overflowX = false self._overflowX = false
self._overflowY = false self._overflowY = false
self._contentWidth = 0 self._contentWidth = 0
self._contentHeight = 0 self._contentHeight = 0
-- Scroll state (can be restored from config in immediate mode) -- Scroll state (can be restored from config in immediate mode)
self._scrollX = config._scrollX or 0 self._scrollX = config._scrollX or 0
self._scrollY = config._scrollY or 0 self._scrollY = config._scrollY or 0
self._maxScrollX = 0 self._maxScrollX = 0
self._maxScrollY = 0 self._maxScrollY = 0
-- Scrollbar interaction state -- Scrollbar interaction state
self._scrollbarHoveredVertical = false self._scrollbarHoveredVertical = false
self._scrollbarHoveredHorizontal = false self._scrollbarHoveredHorizontal = false
@@ -93,10 +86,10 @@ function ScrollManager.new(config, deps)
self._hoveredScrollbar = nil -- "vertical" or "horizontal" self._hoveredScrollbar = nil -- "vertical" or "horizontal"
self._scrollbarDragOffset = 0 self._scrollbarDragOffset = 0
self._scrollbarPressHandled = false self._scrollbarPressHandled = false
-- Element reference (set via initialize) -- Element reference (set via initialize)
self._element = nil self._element = nil
return self return self
end end
@@ -111,34 +104,34 @@ function ScrollManager:detectOverflow()
if not self._element then if not self._element then
error("ScrollManager:detectOverflow() called before initialize()") error("ScrollManager:detectOverflow() called before initialize()")
end end
local element = self._element local element = self._element
-- Reset overflow state -- Reset overflow state
self._overflowX = false self._overflowX = false
self._overflowY = false self._overflowY = false
self._contentWidth = element.width self._contentWidth = element.width
self._contentHeight = element.height self._contentHeight = element.height
-- Skip detection if overflow is visible (no clipping needed) -- Skip detection if overflow is visible (no clipping needed)
local overflowX = self.overflowX or self.overflow local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY or self.overflow local overflowY = self.overflowY or self.overflow
if overflowX == "visible" and overflowY == "visible" then if overflowX == "visible" and overflowY == "visible" then
return return
end end
-- Calculate content bounds based on children -- Calculate content bounds based on children
if #element.children == 0 then if #element.children == 0 then
return -- No children, no overflow return -- No children, no overflow
end end
local minX, minY = 0, 0 local minX, minY = 0, 0
local maxX, maxY = 0, 0 local maxX, maxY = 0, 0
-- Content area starts after padding -- Content area starts after padding
local contentX = element.x + element.padding.left local contentX = element.x + element.padding.left
local contentY = element.y + element.padding.top local contentY = element.y + element.padding.top
for _, child in ipairs(element.children) do for _, child in ipairs(element.children) do
-- Skip absolutely positioned children (they don't contribute to overflow) -- Skip absolutely positioned children (they don't contribute to overflow)
if not child._explicitlyAbsolute then if not child._explicitlyAbsolute then
@@ -147,27 +140,27 @@ function ScrollManager:detectOverflow()
local childTop = child.y - contentY local childTop = child.y - contentY
local childRight = childLeft + child:getBorderBoxWidth() + child.margin.right local childRight = childLeft + child:getBorderBoxWidth() + child.margin.right
local childBottom = childTop + child:getBorderBoxHeight() + child.margin.bottom local childBottom = childTop + child:getBorderBoxHeight() + child.margin.bottom
maxX = math.max(maxX, childRight) maxX = math.max(maxX, childRight)
maxY = math.max(maxY, childBottom) maxY = math.max(maxY, childBottom)
end end
end end
-- Calculate content dimensions -- Calculate content dimensions
self._contentWidth = maxX self._contentWidth = maxX
self._contentHeight = maxY self._contentHeight = maxY
-- Detect overflow -- Detect overflow
local containerWidth = element.width local containerWidth = element.width
local containerHeight = element.height local containerHeight = element.height
self._overflowX = self._contentWidth > containerWidth self._overflowX = self._contentWidth > containerWidth
self._overflowY = self._contentHeight > containerHeight self._overflowY = self._contentHeight > containerHeight
-- Calculate maximum scroll bounds -- Calculate maximum scroll bounds
self._maxScrollX = math.max(0, self._contentWidth - containerWidth) self._maxScrollX = math.max(0, self._contentWidth - containerWidth)
self._maxScrollY = math.max(0, self._contentHeight - containerHeight) self._maxScrollY = math.max(0, self._contentHeight - containerHeight)
-- Clamp current scroll position to new bounds -- Clamp current scroll position to new bounds
self._scrollX = math.max(0, math.min(self._scrollX, self._maxScrollX)) self._scrollX = math.max(0, math.min(self._scrollX, self._maxScrollX))
self._scrollY = math.max(0, math.min(self._scrollY, self._maxScrollY)) self._scrollY = math.max(0, math.min(self._scrollY, self._maxScrollY))
@@ -235,28 +228,28 @@ function ScrollManager:calculateScrollbarDimensions()
if not self._element then if not self._element then
error("ScrollManager:calculateScrollbarDimensions() called before initialize()") error("ScrollManager:calculateScrollbarDimensions() called before initialize()")
end end
local element = self._element local element = self._element
local result = { local result = {
vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 }, vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 },
horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 }, horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 },
} }
local overflowX = self.overflowX or self.overflow local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY or self.overflow local overflowY = self.overflowY or self.overflow
-- Vertical scrollbar -- Vertical scrollbar
-- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows -- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows
if overflowY == "scroll" then if overflowY == "scroll" then
-- Always show scrollbar for "scroll" mode -- Always show scrollbar for "scroll" mode
result.vertical.visible = true result.vertical.visible = true
result.vertical.trackHeight = element.height - (self.scrollbarPadding * 2) result.vertical.trackHeight = element.height - (self.scrollbarPadding * 2)
if self._overflowY then if self._overflowY then
-- Content overflows, calculate proper thumb size -- Content overflows, calculate proper thumb size
local contentRatio = element.height / math.max(self._contentHeight, element.height) local contentRatio = element.height / math.max(self._contentHeight, element.height)
result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio) result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio)
-- Calculate thumb position based on scroll ratio -- Calculate thumb position based on scroll ratio
local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0
local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight
@@ -270,29 +263,29 @@ function ScrollManager:calculateScrollbarDimensions()
-- Only show scrollbar when content actually overflows -- Only show scrollbar when content actually overflows
result.vertical.visible = true result.vertical.visible = true
result.vertical.trackHeight = element.height - (self.scrollbarPadding * 2) result.vertical.trackHeight = element.height - (self.scrollbarPadding * 2)
-- Calculate thumb height based on content ratio -- Calculate thumb height based on content ratio
local contentRatio = element.height / math.max(self._contentHeight, element.height) local contentRatio = element.height / math.max(self._contentHeight, element.height)
result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio) result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio)
-- Calculate thumb position based on scroll ratio -- Calculate thumb position based on scroll ratio
local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0
local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight
result.vertical.thumbY = maxThumbY * scrollRatio result.vertical.thumbY = maxThumbY * scrollRatio
end end
-- Horizontal scrollbar -- Horizontal scrollbar
-- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows -- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows
if overflowX == "scroll" then if overflowX == "scroll" then
-- Always show scrollbar for "scroll" mode -- Always show scrollbar for "scroll" mode
result.horizontal.visible = true result.horizontal.visible = true
result.horizontal.trackWidth = element.width - (self.scrollbarPadding * 2) result.horizontal.trackWidth = element.width - (self.scrollbarPadding * 2)
if self._overflowX then if self._overflowX then
-- Content overflows, calculate proper thumb size -- Content overflows, calculate proper thumb size
local contentRatio = element.width / math.max(self._contentWidth, element.width) local contentRatio = element.width / math.max(self._contentWidth, element.width)
result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio) result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio)
-- Calculate thumb position based on scroll ratio -- Calculate thumb position based on scroll ratio
local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0
local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth
@@ -306,17 +299,17 @@ function ScrollManager:calculateScrollbarDimensions()
-- Only show scrollbar when content actually overflows -- Only show scrollbar when content actually overflows
result.horizontal.visible = true result.horizontal.visible = true
result.horizontal.trackWidth = element.width - (self.scrollbarPadding * 2) result.horizontal.trackWidth = element.width - (self.scrollbarPadding * 2)
-- Calculate thumb width based on content ratio -- Calculate thumb width based on content ratio
local contentRatio = element.width / math.max(self._contentWidth, element.width) local contentRatio = element.width / math.max(self._contentWidth, element.width)
result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio) result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio)
-- Calculate thumb position based on scroll ratio -- Calculate thumb position based on scroll ratio
local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0
local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth
result.horizontal.thumbX = maxThumbX * scrollRatio result.horizontal.thumbX = maxThumbX * scrollRatio
end end
return result return result
end end
@@ -328,19 +321,19 @@ function ScrollManager:getScrollbarAtPosition(mouseX, mouseY)
if not self._element then if not self._element then
error("ScrollManager:getScrollbarAtPosition() called before initialize()") error("ScrollManager:getScrollbarAtPosition() called before initialize()")
end end
local element = self._element local element = self._element
local overflowX = self.overflowX or self.overflow local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY or self.overflow local overflowY = self.overflowY or self.overflow
if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then
return nil return nil
end end
local dims = self:calculateScrollbarDimensions() local dims = self:calculateScrollbarDimensions()
local x, y = element.x, element.y local x, y = element.x, element.y
local w, h = element.width, element.height local w, h = element.width, element.height
-- Check vertical scrollbar (only if not hidden) -- Check vertical scrollbar (only if not hidden)
if dims.vertical.visible and not self.hideScrollbars.vertical then if dims.vertical.visible and not self.hideScrollbars.vertical then
-- Position scrollbar within content area (x, y is border-box origin) -- Position scrollbar within content area (x, y is border-box origin)
@@ -350,7 +343,7 @@ function ScrollManager:getScrollbarAtPosition(mouseX, mouseY)
local trackY = contentY + self.scrollbarPadding local trackY = contentY + self.scrollbarPadding
local trackW = self.scrollbarWidth local trackW = self.scrollbarWidth
local trackH = dims.vertical.trackHeight local trackH = dims.vertical.trackHeight
if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then
-- Check if over thumb -- Check if over thumb
local thumbY = trackY + dims.vertical.thumbY local thumbY = trackY + dims.vertical.thumbY
@@ -362,7 +355,7 @@ function ScrollManager:getScrollbarAtPosition(mouseX, mouseY)
end end
end end
end end
-- Check horizontal scrollbar (only if not hidden) -- Check horizontal scrollbar (only if not hidden)
if dims.horizontal.visible and not self.hideScrollbars.horizontal then if dims.horizontal.visible and not self.hideScrollbars.horizontal then
-- Position scrollbar within content area (x, y is border-box origin) -- Position scrollbar within content area (x, y is border-box origin)
@@ -372,7 +365,7 @@ function ScrollManager:getScrollbarAtPosition(mouseX, mouseY)
local trackY = contentY + h - self.scrollbarWidth - self.scrollbarPadding local trackY = contentY + h - self.scrollbarWidth - self.scrollbarPadding
local trackW = dims.horizontal.trackWidth local trackW = dims.horizontal.trackWidth
local trackH = self.scrollbarWidth local trackH = self.scrollbarWidth
if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then
-- Check if over thumb -- Check if over thumb
local thumbX = trackX + dims.horizontal.thumbX local thumbX = trackX + dims.horizontal.thumbX
@@ -384,7 +377,7 @@ function ScrollManager:getScrollbarAtPosition(mouseX, mouseY)
end end
end end
end end
return nil return nil
end end
@@ -397,23 +390,23 @@ function ScrollManager:handleMousePress(mouseX, mouseY, button)
if not self._element then if not self._element then
error("ScrollManager:handleMousePress() called before initialize()") error("ScrollManager:handleMousePress() called before initialize()")
end end
if button ~= 1 then if button ~= 1 then
return false return false
end -- Only left click end -- Only left click
local scrollbar = self:getScrollbarAtPosition(mouseX, mouseY) local scrollbar = self:getScrollbarAtPosition(mouseX, mouseY)
if not scrollbar then if not scrollbar then
return false return false
end end
if scrollbar.region == "thumb" then if scrollbar.region == "thumb" then
-- Start dragging thumb -- Start dragging thumb
self._scrollbarDragging = true self._scrollbarDragging = true
self._hoveredScrollbar = scrollbar.component self._hoveredScrollbar = scrollbar.component
local dims = self:calculateScrollbarDimensions() local dims = self:calculateScrollbarDimensions()
local element = self._element local element = self._element
if scrollbar.component == "vertical" then if scrollbar.component == "vertical" then
local contentY = element.y + element.padding.top local contentY = element.y + element.padding.top
local trackY = contentY + self.scrollbarPadding local trackY = contentY + self.scrollbarPadding
@@ -425,14 +418,14 @@ function ScrollManager:handleMousePress(mouseX, mouseY, button)
local thumbX = trackX + dims.horizontal.thumbX local thumbX = trackX + dims.horizontal.thumbX
self._scrollbarDragOffset = mouseX - thumbX self._scrollbarDragOffset = mouseX - thumbX
end end
return true -- Event consumed return true -- Event consumed
elseif scrollbar.region == "track" then elseif scrollbar.region == "track" then
-- Click on track - jump to position -- Click on track - jump to position
self:_scrollToTrackPosition(mouseX, mouseY, scrollbar.component) self:_scrollToTrackPosition(mouseX, mouseY, scrollbar.component)
return true return true
end end
return false return false
end end
@@ -444,28 +437,28 @@ function ScrollManager:handleMouseMove(mouseX, mouseY)
if not self._element then if not self._element then
return false return false
end end
if not self._scrollbarDragging then if not self._scrollbarDragging then
return false return false
end end
local dims = self:calculateScrollbarDimensions() local dims = self:calculateScrollbarDimensions()
local element = self._element local element = self._element
if self._hoveredScrollbar == "vertical" then if self._hoveredScrollbar == "vertical" then
local contentY = element.y + element.padding.top local contentY = element.y + element.padding.top
local trackY = contentY + self.scrollbarPadding local trackY = contentY + self.scrollbarPadding
local trackH = dims.vertical.trackHeight local trackH = dims.vertical.trackHeight
local thumbH = dims.vertical.thumbHeight local thumbH = dims.vertical.thumbHeight
-- Calculate new thumb position -- Calculate new thumb position
local newThumbY = mouseY - self._scrollbarDragOffset - trackY local newThumbY = mouseY - self._scrollbarDragOffset - trackY
newThumbY = math.max(0, math.min(newThumbY, trackH - thumbH)) newThumbY = math.max(0, math.min(newThumbY, trackH - thumbH))
-- Convert thumb position to scroll position -- Convert thumb position to scroll position
local scrollRatio = (trackH - thumbH) > 0 and (newThumbY / (trackH - thumbH)) or 0 local scrollRatio = (trackH - thumbH) > 0 and (newThumbY / (trackH - thumbH)) or 0
local newScrollY = scrollRatio * self._maxScrollY local newScrollY = scrollRatio * self._maxScrollY
self:setScroll(nil, newScrollY) self:setScroll(nil, newScrollY)
return true return true
elseif self._hoveredScrollbar == "horizontal" then elseif self._hoveredScrollbar == "horizontal" then
@@ -473,19 +466,19 @@ function ScrollManager:handleMouseMove(mouseX, mouseY)
local trackX = contentX + self.scrollbarPadding local trackX = contentX + self.scrollbarPadding
local trackW = dims.horizontal.trackWidth local trackW = dims.horizontal.trackWidth
local thumbW = dims.horizontal.thumbWidth local thumbW = dims.horizontal.thumbWidth
-- Calculate new thumb position -- Calculate new thumb position
local newThumbX = mouseX - self._scrollbarDragOffset - trackX local newThumbX = mouseX - self._scrollbarDragOffset - trackX
newThumbX = math.max(0, math.min(newThumbX, trackW - thumbW)) newThumbX = math.max(0, math.min(newThumbX, trackW - thumbW))
-- Convert thumb position to scroll position -- Convert thumb position to scroll position
local scrollRatio = (trackW - thumbW) > 0 and (newThumbX / (trackW - thumbW)) or 0 local scrollRatio = (trackW - thumbW) > 0 and (newThumbX / (trackW - thumbW)) or 0
local newScrollX = scrollRatio * self._maxScrollX local newScrollX = scrollRatio * self._maxScrollX
self:setScroll(newScrollX, nil) self:setScroll(newScrollX, nil)
return true return true
end end
return false return false
end end
@@ -496,12 +489,12 @@ function ScrollManager:handleMouseRelease(button)
if button ~= 1 then if button ~= 1 then
return false return false
end end
if self._scrollbarDragging then if self._scrollbarDragging then
self._scrollbarDragging = false self._scrollbarDragging = false
return true return true
end end
return false return false
end end
@@ -513,39 +506,39 @@ function ScrollManager:_scrollToTrackPosition(mouseX, mouseY, component)
if not self._element then if not self._element then
return return
end end
local dims = self:calculateScrollbarDimensions() local dims = self:calculateScrollbarDimensions()
local element = self._element local element = self._element
if component == "vertical" then if component == "vertical" then
local contentY = element.y + element.padding.top local contentY = element.y + element.padding.top
local trackY = contentY + self.scrollbarPadding local trackY = contentY + self.scrollbarPadding
local trackH = dims.vertical.trackHeight local trackH = dims.vertical.trackHeight
local thumbH = dims.vertical.thumbHeight local thumbH = dims.vertical.thumbHeight
-- Calculate target thumb position (centered on click) -- Calculate target thumb position (centered on click)
local targetThumbY = mouseY - trackY - (thumbH / 2) local targetThumbY = mouseY - trackY - (thumbH / 2)
targetThumbY = math.max(0, math.min(targetThumbY, trackH - thumbH)) targetThumbY = math.max(0, math.min(targetThumbY, trackH - thumbH))
-- Convert to scroll position -- Convert to scroll position
local scrollRatio = (trackH - thumbH) > 0 and (targetThumbY / (trackH - thumbH)) or 0 local scrollRatio = (trackH - thumbH) > 0 and (targetThumbY / (trackH - thumbH)) or 0
local newScrollY = scrollRatio * self._maxScrollY local newScrollY = scrollRatio * self._maxScrollY
self:setScroll(nil, newScrollY) self:setScroll(nil, newScrollY)
elseif component == "horizontal" then elseif component == "horizontal" then
local contentX = element.x + element.padding.left local contentX = element.x + element.padding.left
local trackX = contentX + self.scrollbarPadding local trackX = contentX + self.scrollbarPadding
local trackW = dims.horizontal.trackWidth local trackW = dims.horizontal.trackWidth
local thumbW = dims.horizontal.thumbWidth local thumbW = dims.horizontal.thumbWidth
-- Calculate target thumb position (centered on click) -- Calculate target thumb position (centered on click)
local targetThumbX = mouseX - trackX - (thumbW / 2) local targetThumbX = mouseX - trackX - (thumbW / 2)
targetThumbX = math.max(0, math.min(targetThumbX, trackW - thumbW)) targetThumbX = math.max(0, math.min(targetThumbX, trackW - thumbW))
-- Convert to scroll position -- Convert to scroll position
local scrollRatio = (trackW - thumbW) > 0 and (targetThumbX / (trackW - thumbW)) or 0 local scrollRatio = (trackW - thumbW) > 0 and (targetThumbX / (trackW - thumbW)) or 0
local newScrollX = scrollRatio * self._maxScrollX local newScrollX = scrollRatio * self._maxScrollX
self:setScroll(newScrollX, nil) self:setScroll(newScrollX, nil)
end end
end end
@@ -557,16 +550,16 @@ end
function ScrollManager:handleWheel(x, y) function ScrollManager:handleWheel(x, y)
local overflowX = self.overflowX or self.overflow local overflowX = self.overflowX or self.overflow
local overflowY = self.overflowY or self.overflow local overflowY = self.overflowY or self.overflow
if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then
return false return false
end end
local hasVerticalOverflow = self._overflowY and self._maxScrollY > 0 local hasVerticalOverflow = self._overflowY and self._maxScrollY > 0
local hasHorizontalOverflow = self._overflowX and self._maxScrollX > 0 local hasHorizontalOverflow = self._overflowX and self._maxScrollX > 0
local scrolled = false local scrolled = false
-- Vertical scrolling -- Vertical scrolling
if y ~= 0 and hasVerticalOverflow then if y ~= 0 and hasVerticalOverflow then
local delta = -y * self.scrollSpeed -- Negative because wheel up = scroll up local delta = -y * self.scrollSpeed -- Negative because wheel up = scroll up
@@ -574,7 +567,7 @@ function ScrollManager:handleWheel(x, y)
self:setScroll(nil, newScrollY) self:setScroll(nil, newScrollY)
scrolled = true scrolled = true
end end
-- Horizontal scrolling -- Horizontal scrolling
if x ~= 0 and hasHorizontalOverflow then if x ~= 0 and hasHorizontalOverflow then
local delta = -x * self.scrollSpeed local delta = -x * self.scrollSpeed
@@ -582,7 +575,7 @@ function ScrollManager:handleWheel(x, y)
self:setScroll(newScrollX, nil) self:setScroll(newScrollX, nil)
scrolled = true scrolled = true
end end
return scrolled return scrolled
end end
@@ -591,7 +584,7 @@ end
---@param mouseY number ---@param mouseY number
function ScrollManager:updateHoverState(mouseX, mouseY) function ScrollManager:updateHoverState(mouseX, mouseY)
local scrollbar = self:getScrollbarAtPosition(mouseX, mouseY) local scrollbar = self:getScrollbarAtPosition(mouseX, mouseY)
if scrollbar then if scrollbar then
if scrollbar.component == "vertical" then if scrollbar.component == "vertical" then
self._scrollbarHoveredVertical = true self._scrollbarHoveredVertical = true
@@ -640,7 +633,7 @@ function ScrollManager:setState(state)
if not state then if not state then
return return
end end
if state.scrollX then if state.scrollX then
self._scrollX = state.scrollX self._scrollX = state.scrollX
end end

View File

@@ -1,10 +1,3 @@
-- ====================
-- State Manager Module
-- ====================
-- Unified state management system for immediate mode GUI elements
-- Combines ID-based state persistence with interactive state tracking
-- Handles all element state: interaction, scroll, input, click tracking, etc.
---@class StateManager ---@class StateManager
local StateManager = {} local StateManager = {}
@@ -23,7 +16,7 @@ local callSiteCounters = {}
-- Configuration -- Configuration
local config = { local config = {
stateRetentionFrames = 60, -- Keep unused state for 60 frames (~1 second at 60fps) stateRetentionFrames = 60, -- Keep unused state for 60 frames (~1 second at 60fps)
maxStateEntries = 1000, -- Maximum state entries before forced GC maxStateEntries = 1000, -- Maximum state entries before forced GC
} }
-- ==================== -- ====================
@@ -36,28 +29,30 @@ local config = {
---@param depth number|nil Current recursion depth ---@param depth number|nil Current recursion depth
---@return string ---@return string
local function hashProps(props, visited, depth) local function hashProps(props, visited, depth)
if not props then return "" end if not props then
return ""
end
-- Initialize visited table on first call -- Initialize visited table on first call
visited = visited or {} visited = visited or {}
depth = depth or 0 depth = depth or 0
-- Limit recursion depth to prevent deep nesting issues -- Limit recursion depth to prevent deep nesting issues
if depth > 3 then if depth > 3 then
return "[deep]" return "[deep]"
end end
-- Check if we've already visited this table (circular reference) -- Check if we've already visited this table (circular reference)
if visited[props] then if visited[props] then
return "[circular]" return "[circular]"
end end
-- Mark this table as visited -- Mark this table as visited
visited[props] = true visited[props] = true
local parts = {} local parts = {}
local keys = {} local keys = {}
-- Properties to skip (they cause issues or aren't relevant for ID generation) -- Properties to skip (they cause issues or aren't relevant for ID generation)
local skipKeys = { local skipKeys = {
onEvent = true, onEvent = true,
@@ -70,12 +65,12 @@ local function hashProps(props, visited, depth)
onEnter = true, onEnter = true,
userdata = true, userdata = true,
-- Dynamic input/state properties that should not affect ID stability -- Dynamic input/state properties that should not affect ID stability
text = true, -- Text content changes as user types text = true, -- Text content changes as user types
placeholder = true, -- Placeholder text is presentational placeholder = true, -- Placeholder text is presentational
editable = true, -- Editable state can be toggled dynamically editable = true, -- Editable state can be toggled dynamically
selectOnFocus = true, -- Input behavior flag selectOnFocus = true, -- Input behavior flag
autoGrow = true, -- Auto-grow behavior flag autoGrow = true, -- Auto-grow behavior flag
passwordMode = true, -- Password mode can be toggled passwordMode = true, -- Password mode can be toggled
} }
-- Collect and sort keys for consistent ordering -- Collect and sort keys for consistent ordering
@@ -121,17 +116,17 @@ function StateManager.generateID(props, parent)
local filename = source:match("([^/\\]+)$") or source -- Get filename local filename = source:match("([^/\\]+)$") or source -- Get filename
filename = filename:gsub("%.lua$", "") -- Remove .lua extension filename = filename:gsub("%.lua$", "") -- Remove .lua extension
local locationKey = filename .. "_L" .. line local locationKey = filename .. "_L" .. line
-- If we have a parent, use tree-based ID generation for stability -- If we have a parent, use tree-based ID generation for stability
if parent and parent.id and parent.id ~= "" then if parent and parent.id and parent.id ~= "" then
-- Count how many children the parent currently has -- Count how many children the parent currently has
-- This gives us a stable sibling index -- This gives us a stable sibling index
local siblingIndex = #(parent.children or {}) local siblingIndex = #(parent.children or {})
-- Generate ID based on parent ID + sibling position (NO line number for stability) -- Generate ID based on parent ID + sibling position (NO line number for stability)
-- This ensures the same position in the tree always gets the same ID -- This ensures the same position in the tree always gets the same ID
local baseID = parent.id .. "_child" .. siblingIndex local baseID = parent.id .. "_child" .. siblingIndex
-- Add property hash if provided (for additional differentiation at same position) -- Add property hash if provided (for additional differentiation at same position)
if props then if props then
local propHash = hashProps(props) local propHash = hashProps(props)
@@ -144,17 +139,17 @@ function StateManager.generateID(props, parent)
baseID = baseID .. "_" .. hash baseID = baseID .. "_" .. hash
end end
end end
return baseID return baseID
end end
-- No parent (top-level element): use call-site counter approach -- No parent (top-level element): use call-site counter approach
-- Track how many elements have been created at this location -- Track how many elements have been created at this location
callSiteCounters[locationKey] = (callSiteCounters[locationKey] or 0) + 1 callSiteCounters[locationKey] = (callSiteCounters[locationKey] or 0) + 1
local instanceNum = callSiteCounters[locationKey] local instanceNum = callSiteCounters[locationKey]
local baseID = locationKey local baseID = locationKey
-- Add instance number if multiple elements created at same location (e.g., in loops) -- Add instance number if multiple elements created at same location (e.g., in loops)
if instanceNum > 1 then if instanceNum > 1 then
baseID = baseID .. "_" .. instanceNum baseID = baseID .. "_" .. instanceNum
@@ -193,48 +188,100 @@ function StateManager.getState(id, defaultState)
if not stateStore[id] then if not stateStore[id] then
-- Merge default state with standard structure -- Merge default state with standard structure
stateStore[id] = defaultState or {} stateStore[id] = defaultState or {}
-- Ensure all standard properties exist with defaults -- Ensure all standard properties exist with defaults
local state = stateStore[id] local state = stateStore[id]
-- Interaction states -- Interaction states
if state.hover == nil then state.hover = false end if state.hover == nil then
if state.pressed == nil then state.pressed = false end state.hover = false
if state.focused == nil then state.focused = false end end
if state.disabled == nil then state.disabled = false end if state.pressed == nil then
if state.active == nil then state.active = false end state.pressed = false
end
if state.focused == nil then
state.focused = false
end
if state.disabled == nil then
state.disabled = false
end
if state.active == nil then
state.active = false
end
-- Scrollbar states -- Scrollbar states
if state.scrollbarHoveredVertical == nil then state.scrollbarHoveredVertical = false end if state.scrollbarHoveredVertical == nil then
if state.scrollbarHoveredHorizontal == nil then state.scrollbarHoveredHorizontal = false end state.scrollbarHoveredVertical = false
if state.scrollbarDragging == nil then state.scrollbarDragging = false end end
if state.hoveredScrollbar == nil then state.hoveredScrollbar = nil end if state.scrollbarHoveredHorizontal == nil then
if state.scrollbarDragOffset == nil then state.scrollbarDragOffset = 0 end state.scrollbarHoveredHorizontal = false
end
if state.scrollbarDragging == nil then
state.scrollbarDragging = false
end
if state.hoveredScrollbar == nil then
state.hoveredScrollbar = nil
end
if state.scrollbarDragOffset == nil then
state.scrollbarDragOffset = 0
end
-- Scroll position -- Scroll position
if state.scrollX == nil then state.scrollX = 0 end if state.scrollX == nil then
if state.scrollY == nil then state.scrollY = 0 end state.scrollX = 0
end
if state.scrollY == nil then
state.scrollY = 0
end
-- Click tracking -- Click tracking
if state._pressed == nil then state._pressed = {} end if state._pressed == nil then
if state._lastClickTime == nil then state._lastClickTime = nil end state._pressed = {}
if state._lastClickButton == nil then state._lastClickButton = nil end end
if state._clickCount == nil then state._clickCount = 0 end if state._lastClickTime == nil then
state._lastClickTime = nil
end
if state._lastClickButton == nil then
state._lastClickButton = nil
end
if state._clickCount == nil then
state._clickCount = 0
end
-- Drag tracking -- Drag tracking
if state._dragStartX == nil then state._dragStartX = {} end if state._dragStartX == nil then
if state._dragStartY == nil then state._dragStartY = {} end state._dragStartX = {}
if state._lastMouseX == nil then state._lastMouseX = {} end end
if state._lastMouseY == nil then state._lastMouseY = {} end if state._dragStartY == nil then
state._dragStartY = {}
end
if state._lastMouseX == nil then
state._lastMouseX = {}
end
if state._lastMouseY == nil then
state._lastMouseY = {}
end
-- Input/focus state -- Input/focus state
if state._hovered == nil then state._hovered = nil end if state._hovered == nil then
if state._focused == nil then state._focused = nil end state._hovered = nil
if state._cursorPosition == nil then state._cursorPosition = nil end end
if state._selectionStart == nil then state._selectionStart = nil end if state._focused == nil then
if state._selectionEnd == nil then state._selectionEnd = nil end state._focused = nil
if state._textBuffer == nil then state._textBuffer = "" end end
if state._cursorPosition == nil then
state._cursorPosition = nil
end
if state._selectionStart == nil then
state._selectionStart = nil
end
if state._selectionEnd == nil then
state._selectionEnd = nil
end
if state._textBuffer == nil then
state._textBuffer = ""
end
-- Create metadata -- Create metadata
stateMetadata[id] = { stateMetadata[id] = {
lastFrame = frameNumber, lastFrame = frameNumber,
@@ -278,12 +325,12 @@ end
---@param newState table New state values to merge ---@param newState table New state values to merge
function StateManager.updateState(id, newState) function StateManager.updateState(id, newState)
local state = StateManager.getState(id) local state = StateManager.getState(id)
-- Merge new state into existing state -- Merge new state into existing state
for key, value in pairs(newState) do for key, value in pairs(newState) do
state[key] = value state[key] = value
end end
-- Update metadata -- Update metadata
stateMetadata[id].lastFrame = frameNumber stateMetadata[id].lastFrame = frameNumber
end end
@@ -468,7 +515,7 @@ end
---@return table state Active state values ---@return table state Active state values
function StateManager.getActiveState(id) function StateManager.getActiveState(id)
local state = StateManager.getState(id) local state = StateManager.getState(id)
-- Return only the active state properties (not tracking frames or internal state) -- Return only the active state properties (not tracking frames or internal state)
return { return {
hover = state.hover, hover = state.hover,

View File

@@ -1,28 +1,3 @@
-- ====================
-- TextEditor Module
-- ====================
-- Handles all text editing functionality including:
-- - Text buffer management
-- - Cursor positioning and navigation
-- - Text selection
-- - Multi-line text with wrapping
-- - Focus management
-- - Keyboard input handling
-- - Text rendering (cursor, selection highlights)
---
--- Dependencies (must be injected via deps parameter):
--- - GuiState: GUI state manager
--- - StateManager: State persistence for immediate mode
--- - Color: Color utility class (reserved for future use)
--- - utils: Utility functions (FONT_CACHE, getModifiers)
-- Setup module path for relative requires
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- UTF-8 support
local utf8 = utf8 or require("utf8") local utf8 = utf8 or require("utf8")
local TextEditor = {} local TextEditor = {}

View File

@@ -1,16 +1,9 @@
--[[
Theme - Theme System for FlexLove
Manages theme loading, registration, and component/color/font access.
Supports 9-patch images, component states, and dynamic theme switching.
]]
local modulePath = (...):match("(.-)[^%.]+$") local modulePath = (...):match("(.-)[^%.]+$")
local function req(name) local function req(name)
return require(modulePath .. name) return require(modulePath .. name)
end end
local NinePatchParser = req("NinePatchParser") local NinePatchParser = req("NinePatchParser")
local ImageScaler = req("ImageScaler")
--- Standardized error message formatter --- Standardized error message formatter
---@param module string -- Module name (e.g., "Color", "Theme", "Units") ---@param module string -- Module name (e.g., "Color", "Theme", "Units")

View File

@@ -1,10 +1,3 @@
--- ThemeManager.lua
--- Manages theme application, state transitions, and property resolution for Elements
--- Extracted from Element.lua as part of element-refactor-modularization task 06
---
--- Dependencies (must be injected via deps parameter):
--- - Theme: Theme module for loading and accessing themes
---@class ThemeManager ---@class ThemeManager
---@field theme string? -- Theme name to use ---@field theme string? -- Theme name to use
---@field themeComponent string? -- Component name from theme (e.g., "button", "panel") ---@field themeComponent string? -- Component name from theme (e.g., "button", "panel")

View File

@@ -1,8 +1,3 @@
-- ====================
-- Units System
-- ====================
--- Unit parsing and viewport calculations
local Units = {} local Units = {}
--- Parse a unit value (string or number) into value and unit type --- Parse a unit value (string or number) into value and unit type

127
modules/types.lua Normal file
View File

@@ -0,0 +1,127 @@
--=====================================--
-- only used for types with lua_ls, if you're using a different LSP (or none), you can remove this file --
--=====================================--
--=====================================--
-- For Animation.lua
--=====================================--
---@class AnimationProps
---@field duration number
---@field start {width?:number, height?:number, opacity?:number}
---@field final {width?:number, height?:number, opacity?:number}
---@field transform table?
---@field transition table?
local AnimationProps = {}
---@class TransformProps
---@field scale {x?:number, y?:number}?
---@field rotate number?
---@field translate {x?:number, y?:number}?
---@field skew {x?:number, y?:number}?
---@class TransitionProps
---@field duration number?
---@field easing string?
--=====================================--
-- For Element.lua
--=====================================--
---@class ElementProps
---@field id string? -- Unique identifier for the element (auto-generated in immediate mode if not provided)
---@field parent Element? -- Parent element for hierarchical structure
---@field x number|string? -- X coordinate of the element (default: 0)
---@field y number|string? -- Y coordinate of the element (default: 0)
---@field z number? -- Z-index for layering (default: 0)
---@field width number|string? -- Width of the element (default: calculated automatically)
---@field height number|string? -- Height of the element (default: calculated automatically)
---@field top number|string? -- Offset from top edge (CSS-style positioning)
---@field right number|string? -- Offset from right edge (CSS-style positioning)
---@field bottom number|string? -- Offset from bottom edge (CSS-style positioning)
---@field left number|string? -- Offset from left edge (CSS-style positioning)
---@field border Border? -- Border configuration for the element
---@field borderColor Color? -- Color of the border (default: black)
---@field opacity number? -- Element opacity 0-1 (default: 1)
---@field backgroundColor Color? -- Background color (default: transparent)
---@field cornerRadius number|{topLeft:number?, topRight:number?, bottomLeft:number?, bottomRight:number?}? -- Corner radius: number (all corners) or table for individual corners (default: 0)
---@field gap number|string? -- Space between children elements (default: 0)
---@field padding {top:number|string?, right:number|string?, bottom:number|string?, left:number|string?, horizontal:number|string?, vertical:number|string?}? -- Padding around children (default: {top=0, right=0, bottom=0, left=0})
---@field margin {top:number|string?, right:number|string?, bottom:number|string?, left:number|string?, horizontal:number|string?, vertical:number|string?}? -- Margin around element (default: {top=0, right=0, bottom=0, left=0})
---@field text string? -- Text content to display (default: nil)
---@field textAlign TextAlign? -- Alignment of the text content (default: START)
---@field textColor Color? -- Color of the text content (default: black or theme text color)
---@field textSize number|string? -- Font size: number (px), string with units ("2vh", "10%"), or preset ("xxs"|"xs"|"sm"|"md"|"lg"|"xl"|"xxl"|"3xl"|"4xl") (default: "md" or 12px)
---@field minTextSize number? -- Minimum text size in pixels for auto-scaling
---@field maxTextSize number? -- Maximum text size in pixels for auto-scaling
---@field fontFamily string? -- Font family name from theme or path to font file (default: theme default or system default, inherits from parent)
---@field autoScaleText boolean? -- Whether text should auto-scale with window size (default: true)
---@field positioning Positioning? -- Layout positioning mode: "absolute"|"relative"|"flex"|"grid" (default: RELATIVE)
---@field flexDirection FlexDirection? -- Direction of flex layout: "horizontal"|"vertical" (default: HORIZONTAL)
---@field justifyContent JustifyContent? -- Alignment of items along main axis (default: FLEX_START)
---@field alignItems AlignItems? -- Alignment of items along cross axis (default: STRETCH)
---@field alignContent AlignContent? -- Alignment of lines in multi-line flex containers (default: STRETCH)
---@field flexWrap FlexWrap? -- Whether children wrap to multiple lines: "nowrap"|"wrap"|"wrap-reverse" (default: NOWRAP)
---@field justifySelf JustifySelf? -- Alignment of the item itself along main axis (default: AUTO)
---@field alignSelf AlignSelf? -- Alignment of the item itself along cross axis (default: AUTO)
---@field onEvent fun(element:Element, event:InputEvent)? -- Callback function for interaction events
---@field onFocus fun(element:Element, event:InputEvent)? -- Callback when element receives focus
---@field onBlur fun(element:Element, event:InputEvent)? -- Callback when element loses focus
---@field onTextInput fun(element:Element, text:string)? -- Callback when text is input
---@field onTextChange fun(element:Element, text:string)? -- Callback when text content changes
---@field onEnter fun(element:Element)? -- Callback when Enter key is pressed
---@field transform TransformProps? -- Transform properties for animations and styling
---@field transition TransitionProps? -- Transition settings for animations
---@field gridRows number? -- Number of rows in the grid (default: 1)
---@field gridColumns number? -- Number of columns in the grid (default: 1)
---@field columnGap number|string? -- Gap between grid columns (default: 0)
---@field rowGap number|string? -- Gap between grid rows (default: 0)
---@field theme string? -- Theme name to use (e.g., "space", "metal"). Defaults to theme from Gui.init()
---@field themeComponent string? -- Theme component to use (e.g., "panel", "button", "input"). If nil, no theme is applied
---@field disabled boolean? -- Whether the element is disabled (default: false)
---@field active boolean? -- Whether the element is active/focused (for inputs, default: false)
---@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false, or true when using themeComponent)
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions (default: sourced from theme or {1, 1})
---@field scaleCorners number? -- Scale multiplier for 9-patch corners/edges. E.g., 2 = 2x size (overrides theme setting)
---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-patch corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting)
---@field contentBlur {intensity:number, quality:number}? -- Blur the element's content including children (intensity: 0-100, quality: 1-10, default: nil)
---@field backdropBlur {intensity:number, quality:number}? -- Blur content behind the element (intensity: 0-100, quality: 1-10, default: nil)
---@field editable boolean? -- Whether the element is editable (default: false)
---@field multiline boolean? -- Whether the element supports multiple lines (default: false)
---@field textWrap boolean|"word"|"char"? -- Text wrapping mode (default: false for single-line, "word" for multi-line)
---@field maxLines number? -- Maximum number of lines (default: nil)
---@field maxLength number? -- Maximum text length in characters (default: nil)
---@field placeholder string? -- Placeholder text when empty (default: nil)
---@field passwordMode boolean? -- Whether to display text as password (default: false, disables multiline)
---@field inputType "text"|"number"|"email"|"url"? -- Input type for validation (default: "text")
---@field textOverflow "clip"|"ellipsis"|"scroll"? -- Text overflow behavior (default: "clip")
---@field scrollable boolean? -- Whether text is scrollable (default: false for single-line, true for multi-line)
---@field autoGrow boolean? -- Whether element auto-grows with text (default: false for single-line, true for multi-line)
---@field selectOnFocus boolean? -- Whether to select all text on focus (default: false)
---@field cursorColor Color? -- Cursor color (default: nil, uses textColor)
---@field selectionColor Color? -- Selection background color (default: nil, uses theme or default)
---@field cursorBlinkRate number? -- Cursor blink rate in seconds (default: 0.5)
---@field overflow "visible"|"hidden"|"scroll"|"auto"? -- Overflow behavior (default: "hidden")
---@field overflowX "visible"|"hidden"|"scroll"|"auto"? -- X-axis overflow (overrides overflow)
---@field overflowY "visible"|"hidden"|"scroll"|"auto"? -- Y-axis overflow (overrides overflow)
---@field scrollbarWidth number? -- Width of scrollbar track in pixels (default: 12)
---@field scrollbarColor Color? -- Scrollbar thumb color (default: Color.new(0.5, 0.5, 0.5, 0.8))
---@field scrollbarTrackColor Color? -- Scrollbar track color (default: Color.new(0.2, 0.2, 0.2, 0.5))
---@field scrollbarRadius number? -- Corner radius for scrollbar (default: 6)
---@field scrollbarPadding number? -- Padding between scrollbar and edge (default: 2)
---@field scrollSpeed number? -- Pixels per wheel notch (default: 20)
---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control, default: false)
---@field imagePath string? -- Path to image file (auto-loads via ImageCache)
---@field image love.Image? -- Image object to display
---@field objectFit "fill"|"contain"|"cover"|"scale-down"|"none"? -- Image fit mode (default: "fill")
---@field objectPosition string? -- Image position like "center center", "top left", "50% 50%" (default: "center center")
---@field imageOpacity number? -- Image opacity 0-1 (default: 1, combines with element opacity)
---@field _scrollX number? -- Internal: scroll X position (restored in immediate mode)
---@field _scrollY number? -- Internal: scroll Y position (restored in immediate mode)
---@field userdata table? -- User-defined data storage for custom properties
local ElementProps = {}
---@class Border
---@field top boolean
---@field right boolean
---@field bottom boolean
---@field left boolean
local Border = {}

View File

@@ -1,103 +1,3 @@
---@class ElementProps
---@field id string? -- Unique identifier for the element (auto-generated in immediate mode if not provided)
---@field parent Element? -- Parent element for hierarchical structure
---@field x number|string? -- X coordinate of the element (default: 0)
---@field y number|string? -- Y coordinate of the element (default: 0)
---@field z number? -- Z-index for layering (default: 0)
---@field width number|string? -- Width of the element (default: calculated automatically)
---@field height number|string? -- Height of the element (default: calculated automatically)
---@field top number|string? -- Offset from top edge (CSS-style positioning)
---@field right number|string? -- Offset from right edge (CSS-style positioning)
---@field bottom number|string? -- Offset from bottom edge (CSS-style positioning)
---@field left number|string? -- Offset from left edge (CSS-style positioning)
---@field border Border? -- Border configuration for the element
---@field borderColor Color? -- Color of the border (default: black)
---@field opacity number? -- Element opacity 0-1 (default: 1)
---@field backgroundColor Color? -- Background color (default: transparent)
---@field cornerRadius number|{topLeft:number?, topRight:number?, bottomLeft:number?, bottomRight:number?}? -- Corner radius: number (all corners) or table for individual corners (default: 0)
---@field gap number|string? -- Space between children elements (default: 0)
---@field padding {top:number|string?, right:number|string?, bottom:number|string?, left:number|string?, horizontal:number|string?, vertical:number|string?}? -- Padding around children (default: {top=0, right=0, bottom=0, left=0})
---@field margin {top:number|string?, right:number|string?, bottom:number|string?, left:number|string?, horizontal:number|string?, vertical:number|string?}? -- Margin around element (default: {top=0, right=0, bottom=0, left=0})
---@field text string? -- Text content to display (default: nil)
---@field textAlign TextAlign? -- Alignment of the text content (default: START)
---@field textColor Color? -- Color of the text content (default: black or theme text color)
---@field textSize number|string? -- Font size: number (px), string with units ("2vh", "10%"), or preset ("xxs"|"xs"|"sm"|"md"|"lg"|"xl"|"xxl"|"3xl"|"4xl") (default: "md" or 12px)
---@field minTextSize number? -- Minimum text size in pixels for auto-scaling
---@field maxTextSize number? -- Maximum text size in pixels for auto-scaling
---@field fontFamily string? -- Font family name from theme or path to font file (default: theme default or system default, inherits from parent)
---@field autoScaleText boolean? -- Whether text should auto-scale with window size (default: true)
---@field positioning Positioning? -- Layout positioning mode: "absolute"|"relative"|"flex"|"grid" (default: RELATIVE)
---@field flexDirection FlexDirection? -- Direction of flex layout: "horizontal"|"vertical" (default: HORIZONTAL)
---@field justifyContent JustifyContent? -- Alignment of items along main axis (default: FLEX_START)
---@field alignItems AlignItems? -- Alignment of items along cross axis (default: STRETCH)
---@field alignContent AlignContent? -- Alignment of lines in multi-line flex containers (default: STRETCH)
---@field flexWrap FlexWrap? -- Whether children wrap to multiple lines: "nowrap"|"wrap"|"wrap-reverse" (default: NOWRAP)
---@field justifySelf JustifySelf? -- Alignment of the item itself along main axis (default: AUTO)
---@field alignSelf AlignSelf? -- Alignment of the item itself along cross axis (default: AUTO)
---@field onEvent fun(element:Element, event:InputEvent)? -- Callback function for interaction events
---@field onFocus fun(element:Element, event:InputEvent)? -- Callback when element receives focus
---@field onBlur fun(element:Element, event:InputEvent)? -- Callback when element loses focus
---@field onTextInput fun(element:Element, text:string)? -- Callback when text is input
---@field onTextChange fun(element:Element, text:string)? -- Callback when text content changes
---@field onEnter fun(element:Element)? -- Callback when Enter key is pressed
---@field transform TransformProps? -- Transform properties for animations and styling
---@field transition TransitionProps? -- Transition settings for animations
---@field gridRows number? -- Number of rows in the grid (default: 1)
---@field gridColumns number? -- Number of columns in the grid (default: 1)
---@field columnGap number|string? -- Gap between grid columns (default: 0)
---@field rowGap number|string? -- Gap between grid rows (default: 0)
---@field theme string? -- Theme name to use (e.g., "space", "metal"). Defaults to theme from Gui.init()
---@field themeComponent string? -- Theme component to use (e.g., "panel", "button", "input"). If nil, no theme is applied
---@field disabled boolean? -- Whether the element is disabled (default: false)
---@field active boolean? -- Whether the element is active/focused (for inputs, default: false)
---@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false, or true when using themeComponent)
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions (default: sourced from theme or {1, 1})
---@field scaleCorners number? -- Scale multiplier for 9-patch corners/edges. E.g., 2 = 2x size (overrides theme setting)
---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-patch corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting)
---@field contentBlur {intensity:number, quality:number}? -- Blur the element's content including children (intensity: 0-100, quality: 1-10, default: nil)
---@field backdropBlur {intensity:number, quality:number}? -- Blur content behind the element (intensity: 0-100, quality: 1-10, default: nil)
---@field editable boolean? -- Whether the element is editable (default: false)
---@field multiline boolean? -- Whether the element supports multiple lines (default: false)
---@field textWrap boolean|"word"|"char"? -- Text wrapping mode (default: false for single-line, "word" for multi-line)
---@field maxLines number? -- Maximum number of lines (default: nil)
---@field maxLength number? -- Maximum text length in characters (default: nil)
---@field placeholder string? -- Placeholder text when empty (default: nil)
---@field passwordMode boolean? -- Whether to display text as password (default: false, disables multiline)
---@field inputType "text"|"number"|"email"|"url"? -- Input type for validation (default: "text")
---@field textOverflow "clip"|"ellipsis"|"scroll"? -- Text overflow behavior (default: "clip")
---@field scrollable boolean? -- Whether text is scrollable (default: false for single-line, true for multi-line)
---@field autoGrow boolean? -- Whether element auto-grows with text (default: false for single-line, true for multi-line)
---@field selectOnFocus boolean? -- Whether to select all text on focus (default: false)
---@field cursorColor Color? -- Cursor color (default: nil, uses textColor)
---@field selectionColor Color? -- Selection background color (default: nil, uses theme or default)
---@field cursorBlinkRate number? -- Cursor blink rate in seconds (default: 0.5)
---@field overflow "visible"|"hidden"|"scroll"|"auto"? -- Overflow behavior (default: "hidden")
---@field overflowX "visible"|"hidden"|"scroll"|"auto"? -- X-axis overflow (overrides overflow)
---@field overflowY "visible"|"hidden"|"scroll"|"auto"? -- Y-axis overflow (overrides overflow)
---@field scrollbarWidth number? -- Width of scrollbar track in pixels (default: 12)
---@field scrollbarColor Color? -- Scrollbar thumb color (default: Color.new(0.5, 0.5, 0.5, 0.8))
---@field scrollbarTrackColor Color? -- Scrollbar track color (default: Color.new(0.2, 0.2, 0.2, 0.5))
---@field scrollbarRadius number? -- Corner radius for scrollbar (default: 6)
---@field scrollbarPadding number? -- Padding between scrollbar and edge (default: 2)
---@field scrollSpeed number? -- Pixels per wheel notch (default: 20)
---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control, default: false)
---@field imagePath string? -- Path to image file (auto-loads via ImageCache)
---@field image love.Image? -- Image object to display
---@field objectFit "fill"|"contain"|"cover"|"scale-down"|"none"? -- Image fit mode (default: "fill")
---@field objectPosition string? -- Image position like "center center", "top left", "50% 50%" (default: "center center")
---@field imageOpacity number? -- Image opacity 0-1 (default: 1, combines with element opacity)
---@field _scrollX number? -- Internal: scroll X position (restored in immediate mode)
---@field _scrollY number? -- Internal: scroll Y position (restored in immediate mode)
---@field userdata table? -- User-defined data storage for custom properties
local ElementProps = {}
---@class Border
---@field top boolean
---@field right boolean
---@field bottom boolean
---@field left boolean
local Border = {}
local enums = { local enums = {
---@enum TextAlign ---@enum TextAlign
TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" }, TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" },