This commit is contained in:
Michael Freno
2025-10-13 21:07:39 -04:00
parent c13c2c41ea
commit 8709dd26f9
6 changed files with 656 additions and 95 deletions

View File

@@ -1,4 +1,256 @@
-- Utility class for color handling --[[
================================================================================
FlexLove - Flexible UI Library for LÖVE Framework
================================================================================
A comprehensive UI library providing flexbox/grid layouts, theming, animations,
and event handling for LÖVE2D games.
ARCHITECTURE OVERVIEW:
---------------------
1. Color System - RGBA color utilities with hex conversion
2. Theme System - 9-slice theming with state support (normal/hover/pressed/disabled)
3. Units System - Responsive units (px, %, vw, vh, ew, eh) with viewport scaling
4. Layout System - Flexbox, Grid, Absolute, and Relative positioning
5. Event System - Mouse/touch events with z-index ordering
6. Animation System - Interpolation with easing functions
7. GUI Manager - Top-level manager for elements and global state
API CONVENTIONS:
---------------
- Constructors: ClassName.new(props) -> instance
- Static Methods: ClassName.methodName(args) -> result
- Instance Methods: instance:methodName(args) -> result
- Getters: instance:getPropertyName() -> value
- Internal Fields: _fieldName (private, do not access directly)
- Error Handling: Constructors throw errors, utility functions return nil + error string
NAMING PATTERNS:
---------------
- Classes: PascalCase (Element, Theme, Color)
- Functions: camelCase (resolveImagePath, getViewport)
- Properties: camelCase (backgroundColor, textColor, cornerRadius)
- Constants: UPPER_SNAKE_CASE (TEXT_SIZE_PRESETS, FONT_CACHE_MAX_SIZE)
- Private: _prefixedCamelCase (_pressed, _themeState, _borderBoxWidth)
PARAMETER ORDERING:
------------------
- Position: (x, y, width, height) - standard order
- Units: (value, unit, viewportW, viewportH, parentSize) - value first
- Drawing: (element, position, dimensions, styling, opacity) - element first
RETURN VALUE PATTERNS:
---------------------
- Single Success: return value
- Success/Failure: return result, errorMessage (nil on success for error)
- Multiple Values: return value1, value2 (documented in @return)
- Constructors: Always return instance (never nil)
USAGE EXAMPLE:
-------------
```lua
local FlexLove = require("libs.FlexLove")
-- Initialize with base scaling and theme
FlexLove.Gui.init({
baseScale = { width = 1920, height = 1080 },
theme = "space"
})
-- Create a button with flexbox layout
local button = FlexLove.Element.new({
width = "20vw",
height = "10vh",
backgroundColor = FlexLove.Color.new(0.2, 0.2, 0.8, 1),
text = "Click Me",
textSize = "md",
themeComponent = "button",
callback = function(element, event)
print("Button clicked!")
end
})
-- In your love.update and love.draw:
function love.update(dt)
FlexLove.Gui.update(dt)
end
function love.draw()
FlexLove.Gui.draw()
end
```
ADDITIONAL EXAMPLES:
-------------------
1. Creating Colors:
```lua
-- From RGB values (0-1 range)
local red = FlexLove.Color.new(1, 0, 0, 1)
-- From hex string
local blue = FlexLove.Color.fromHex("#0000FF")
local semiTransparent = FlexLove.Color.fromHex("#FF000080")
```
2. Responsive Units:
```lua
-- Viewport-relative units
local container = FlexLove.Element.new({
width = "50vw", -- 50% of viewport width
height = "30vh", -- 30% of viewport height
padding = { horizontal = "2vw", vertical = "1vh" }
})
-- Percentage units (relative to parent)
local child = FlexLove.Element.new({
parent = container,
width = "80%", -- 80% of parent width
height = "50%" -- 50% of parent height
})
```
3. Flexbox Layout:
```lua
-- Horizontal flex container
local row = FlexLove.Element.new({
positioning = FlexLove.Positioning.FLEX,
flexDirection = FlexLove.FlexDirection.HORIZONTAL,
justifyContent = FlexLove.JustifyContent.SPACE_BETWEEN,
alignItems = FlexLove.AlignItems.CENTER,
gap = 10,
width = "80vw",
height = "10vh"
})
-- Add children
for i = 1, 3 do
FlexLove.Element.new({
parent = row,
width = "20vw",
height = "8vh",
text = "Item " .. i
})
end
```
4. Grid Layout:
```lua
-- 3x3 grid
local grid = FlexLove.Element.new({
positioning = FlexLove.Positioning.GRID,
gridRows = 3,
gridColumns = 3,
columnGap = 10,
rowGap = 10,
width = "60vw",
height = "60vh"
})
-- Add 9 children (auto-placed in grid)
for i = 1, 9 do
FlexLove.Element.new({
parent = grid,
text = "Cell " .. i
})
end
```
5. Theming:
```lua
-- Load and activate a theme
FlexLove.Theme.load("space")
FlexLove.Theme.setActive("space")
-- Use theme component
local button = FlexLove.Element.new({
themeComponent = "button",
text = "Themed Button",
callback = function(element, event)
print("Clicked!")
end
})
-- Access theme resources
local primaryColor = FlexLove.Theme.getColor("primary")
local headingFont = FlexLove.Theme.getFont("heading")
```
6. Animations:
```lua
-- Fade animation
local fadeIn = FlexLove.Animation.fade(1.0, 0, 1)
fadeIn:apply(element)
-- Scale animation
local scaleUp = FlexLove.Animation.scale(0.5,
{ width = 100, height = 50 },
{ width = 200, height = 100 }
)
scaleUp:apply(element)
-- Custom animation with easing
local customAnim = FlexLove.Animation.new({
duration = 1.0,
start = { opacity = 0, width = 100 },
final = { opacity = 1, width = 200 },
easing = "easeInOutCubic"
})
customAnim:apply(element)
```
7. Event Handling:
```lua
local button = FlexLove.Element.new({
text = "Interactive",
callback = function(element, event)
if event.type == "click" then
print("Clicked with button:", event.button)
print("Position:", event.x, event.y)
print("Modifiers:", event.modifiers.shift, event.modifiers.ctrl)
elseif event.type == "press" then
print("Button pressed")
elseif event.type == "release" then
print("Button released")
end
end
})
```
VERSION: 1.0.0
LICENSE: MIT
================================================================================
]]
-- ====================
-- Error Handling Utilities
-- ====================
--- Standardized error message formatter
---@param module string -- Module name (e.g., "Color", "Theme", "Units")
---@param message string -- Error message
---@return string -- Formatted error message
local function formatError(module, message)
return string.format("[FlexLove.%s] %s", module, message)
end
--- Safe function call wrapper with error handling
---@param fn function -- Function to call
---@param errorContext string? -- Optional context for error message
---@return boolean success, any result -- Returns success status and result or error message
local function safecall(fn, errorContext)
local success, result = pcall(fn)
if not success and errorContext then
print(formatError("Core", errorContext .. ": " .. tostring(result)))
end
return success, result
end
-- ====================
-- Color System
-- ====================
--- Utility class for color handling
---@class Color ---@class Color
---@field r number -- Red component (0-1) ---@field r number -- Red component (0-1)
---@field g number -- Green component (0-1) ---@field g number -- Green component (0-1)
@@ -28,8 +280,10 @@ function Color:toRGBA()
end end
--- Convert hex string to color --- Convert hex string to color
--- Supports both 6-digit (#RRGGBB) and 8-digit (#RRGGBBAA) hex formats
---@param hexWithTag string -- e.g. "#RRGGBB" or "#RRGGBBAA" ---@param hexWithTag string -- e.g. "#RRGGBB" or "#RRGGBBAA"
---@return Color ---@return Color
---@throws Error if hex string format is invalid
function Color.fromHex(hexWithTag) function Color.fromHex(hexWithTag)
local hex = hexWithTag:gsub("#", "") local hex = hexWithTag:gsub("#", "")
if #hex == 6 then if #hex == 6 then
@@ -44,7 +298,7 @@ function Color.fromHex(hexWithTag)
local a = tonumber("0x" .. hex:sub(7, 8)) / 255 local a = tonumber("0x" .. hex:sub(7, 8)) / 255
return Color.new(r, g, b, a) return Color.new(r, g, b, a)
else else
error("Invalid hex string") error(formatError("Color", string.format("Invalid hex string format: '%s'. Expected #RRGGBB or #RRGGBBAA", hexWithTag)))
end end
end end
@@ -138,10 +392,64 @@ local function resolveImagePath(imagePath)
return FLEXLOVE_FILESYSTEM_PATH .. "/" .. imagePath return FLEXLOVE_FILESYSTEM_PATH .. "/" .. imagePath
end end
--- Safely load an image with error handling
---@param imagePath string
---@return love.Image?, string? -- Returns image or nil, error message
local function safeLoadImage(imagePath)
local success, result = pcall(function()
return love.graphics.newImage(imagePath)
end)
if success then
return result, nil
else
local errorMsg = string.format("[FlexLove] Failed to load image: %s - %s", imagePath, tostring(result))
print(errorMsg)
return nil, errorMsg
end
end
--- Create a new theme instance --- Create a new theme instance
---@param definition ThemeDefinition ---@param definition ThemeDefinition
---@return Theme ---@return Theme
--- Validate theme definition structure
---@param definition ThemeDefinition
---@return boolean, string? -- Returns true if valid, or false with error message
local function validateThemeDefinition(definition)
if not definition then
return false, "Theme definition is nil"
end
if type(definition) ~= "table" then
return false, "Theme definition must be a table"
end
if not definition.name or type(definition.name) ~= "string" then
return false, "Theme must have a 'name' field (string)"
end
if definition.components and type(definition.components) ~= "table" then
return false, "Theme 'components' must be a table"
end
if definition.colors and type(definition.colors) ~= "table" then
return false, "Theme 'colors' must be a table"
end
if definition.fonts and type(definition.fonts) ~= "table" then
return false, "Theme 'fonts' must be a table"
end
return true, nil
end
function Theme.new(definition) function Theme.new(definition)
-- Validate theme definition
local valid, err = validateThemeDefinition(definition)
if not valid then
error("[FlexLove] Invalid theme definition: " .. tostring(err))
end
local self = setmetatable({}, Theme) local self = setmetatable({}, Theme)
self.name = definition.name self.name = definition.name
@@ -149,7 +457,12 @@ function Theme.new(definition)
if definition.atlas then if definition.atlas then
if type(definition.atlas) == "string" then if type(definition.atlas) == "string" then
local resolvedPath = resolveImagePath(definition.atlas) local resolvedPath = resolveImagePath(definition.atlas)
self.atlas = love.graphics.newImage(resolvedPath) local image, err = safeLoadImage(resolvedPath)
if image then
self.atlas = image
else
print("[FlexLove] Warning: Failed to load global atlas for theme '" .. definition.name .. "'")
end
else else
self.atlas = definition.atlas self.atlas = definition.atlas
end end
@@ -164,7 +477,12 @@ function Theme.new(definition)
if component.atlas then if component.atlas then
if type(component.atlas) == "string" then if type(component.atlas) == "string" then
local resolvedPath = resolveImagePath(component.atlas) local resolvedPath = resolveImagePath(component.atlas)
component._loadedAtlas = love.graphics.newImage(resolvedPath) local image, err = safeLoadImage(resolvedPath)
if image then
component._loadedAtlas = image
else
print("[FlexLove] Warning: Failed to load atlas for component '" .. componentName .. "'")
end
else else
component._loadedAtlas = component.atlas component._loadedAtlas = component.atlas
end end
@@ -249,9 +567,9 @@ function Theme.getActive()
end end
--- Get a component from the active theme --- Get a component from the active theme
---@param componentName string ---@param componentName string -- Name of the component (e.g., "button", "panel")
---@param state string? ---@param state string? -- Optional state (e.g., "hover", "pressed", "disabled")
---@return ThemeComponent? ---@return ThemeComponent? -- Returns component or nil if not found
function Theme.getComponent(componentName, state) function Theme.getComponent(componentName, state)
if not activeTheme then if not activeTheme then
return nil return nil
@@ -270,6 +588,44 @@ function Theme.getComponent(componentName, state)
return component return component
end end
--- Get a font from the active theme
---@param fontName string -- Name of the font family (e.g., "default", "heading")
---@return string? -- Returns font path or nil if not found
function Theme.getFont(fontName)
if not activeTheme then
return nil
end
return activeTheme.fonts and activeTheme.fonts[fontName]
end
--- Get a color from the active theme
---@param colorName string -- Name of the color (e.g., "primary", "secondary")
---@return Color? -- Returns Color instance or nil if not found
function Theme.getColor(colorName)
if not activeTheme then
return nil
end
return activeTheme.colors and activeTheme.colors[colorName]
end
--- Check if a theme is currently active
---@return boolean -- Returns true if a theme is active
function Theme.hasActive()
return activeTheme ~= nil
end
--- Get all registered theme names
---@return table<string> -- Array of theme names
function Theme.getRegisteredThemes()
local themeNames = {}
for name, _ in pairs(themes) do
table.insert(themeNames, name)
end
return themeNames
end
-- ==================== -- ====================
-- Rounded Rectangle Helper -- Rounded Rectangle Helper
-- ==================== -- ====================
@@ -635,18 +991,19 @@ function Units.parse(value)
end end
--- Convert relative units to pixels based on viewport and parent dimensions --- Convert relative units to pixels based on viewport and parent dimensions
---@param value number ---@param value number -- Numeric value to convert
---@param unit string ---@param unit string -- Unit type ("px", "%", "vw", "vh", "ew", "eh")
---@param viewportWidth number ---@param viewportWidth number -- Current viewport width in pixels
---@param viewportHeight number ---@param viewportHeight number -- Current viewport height in pixels
---@param parentSize number? -- Required for percentage units ---@param parentSize number? -- Required for percentage units (parent dimension)
---@return number -- Pixel value ---@return number -- Resolved pixel value
---@throws Error if unit type is unknown or percentage used without parent size
function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize) function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize)
if unit == "px" then if unit == "px" then
return value return value
elseif unit == "%" then elseif unit == "%" then
if not parentSize then if not parentSize then
error("Percentage units require parent dimension") error(formatError("Units", "Percentage units require parent dimension"))
end end
return (value / 100) * parentSize return (value / 100) * parentSize
elseif unit == "vw" then elseif unit == "vw" then
@@ -654,18 +1011,22 @@ function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize)
elseif unit == "vh" then elseif unit == "vh" then
return (value / 100) * viewportHeight return (value / 100) * viewportHeight
else else
error("Unknown unit type: " .. unit) error(formatError("Units", string.format("Unknown unit type: '%s'. Valid units: px, %%, vw, vh, ew, eh", unit)))
end end
end end
---@return number, number -- width, height ---@return number, number -- width, height
function Units.getViewport() function Units.getViewport()
-- Try both functions to be compatible with different love versions and test environments -- Return cached viewport if available (only during resize operations)
if Gui and Gui._cachedViewport and Gui._cachedViewport.width > 0 then
return Gui._cachedViewport.width, Gui._cachedViewport.height
end
-- Query viewport dimensions normally
if love.graphics and love.graphics.getDimensions then if love.graphics and love.graphics.getDimensions then
return love.graphics.getDimensions() return love.graphics.getDimensions()
else else
local w, h = love.window.getMode() return love.window.getMode()
return w, h
end end
end end
@@ -737,6 +1098,30 @@ function Units.resolveSpacing(spacingProps, parentWidth, parentHeight)
return result return result
end end
--- Check if a unit string is valid
---@param unitStr string -- Unit string to validate (e.g., "10px", "50%", "20vw")
---@return boolean -- Returns true if unit string is valid
function Units.isValid(unitStr)
if type(unitStr) ~= "string" then
return false
end
local value, unit = Units.parse(unitStr)
local validUnits = { px = true, ["%"] = true, vw = true, vh = true, ew = true, eh = true }
return validUnits[unit] == true
end
--- Parse and resolve a unit value in one call
---@param value string|number -- Value to parse and resolve
---@param viewportWidth number -- Current viewport width
---@param viewportHeight number -- Current viewport height
---@param parentSize number? -- Parent dimension for percentage units
---@return number -- Resolved pixel value
function Units.parseAndResolve(value, viewportWidth, viewportHeight, parentSize)
local numValue, unit = Units.parse(value)
return Units.resolve(numValue, unit, viewportWidth, viewportHeight, parentSize)
end
-- ==================== -- ====================
-- Grid System -- Grid System
-- ==================== -- ====================
@@ -884,6 +1269,7 @@ local Gui = {
baseScale = nil, baseScale = nil,
scaleFactors = { x = 1.0, y = 1.0 }, scaleFactors = { x = 1.0, y = 1.0 },
defaultTheme = nil, defaultTheme = nil,
_cachedViewport = { width = 0, height = 0 }, -- Cached viewport dimensions
} }
--- Initialize FlexLove with configuration --- Initialize FlexLove with configuration
@@ -956,10 +1342,63 @@ function Gui.draw()
end end
end end
--- Find the topmost element at given coordinates (considering z-index)
---@param x number
---@param y number
---@return Element? -- Returns the topmost element or nil
function Gui.getElementAtPosition(x, y)
local candidates = {}
-- Recursively collect all elements that contain the point
local function collectHits(element)
-- Check if point is within element bounds
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
if x >= bx and x <= bx + bw and y >= by and y <= by + bh then
-- Only consider elements with callbacks (interactive elements)
if element.callback and not element.disabled then
table.insert(candidates, element)
end
-- Check children
for _, child in ipairs(element.children) do
collectHits(child)
end
end
end
-- Collect hits from all top-level elements
for _, element in ipairs(Gui.topElements) do
collectHits(element)
end
-- Sort by z-index (highest first)
table.sort(candidates, function(a, b)
return a.z > b.z
end)
-- Return the topmost element (highest z-index)
return candidates[1]
end
function Gui.update(dt) function Gui.update(dt)
-- Reset event handling flags for new frame
local mx, my = love.mouse.getPosition()
local topElement = Gui.getElementAtPosition(mx, my)
-- Mark which element should handle events this frame
Gui._activeEventElement = topElement
-- Update all elements
for _, win in ipairs(Gui.topElements) do for _, win in ipairs(Gui.topElements) do
win:update(dt) win:update(dt)
end end
-- Clear active element for next frame
Gui._activeEventElement = nil
end end
--- Destroy all elements and their children --- Destroy all elements and their children
@@ -1033,6 +1472,39 @@ end
---@field elapsed number ---@field elapsed number
---@field transform table? ---@field transform table?
---@field transition table? ---@field transition table?
--- Easing functions for animations
local Easing = {
linear = function(t) return t end,
easeInQuad = function(t) return t * t end,
easeOutQuad = function(t) return t * (2 - t) end,
easeInOutQuad = function(t)
return t < 0.5 and 2 * t * t or -1 + (4 - 2 * t) * t
end,
easeInCubic = function(t) return t * t * t end,
easeOutCubic = function(t)
local t1 = t - 1
return t1 * t1 * t1 + 1
end,
easeInOutCubic = function(t)
return t < 0.5 and 4 * t * t * t or (t - 1) * (2 * t - 2) * (2 * t - 2) + 1
end,
easeInQuart = function(t) return t * t * t * t end,
easeOutQuart = function(t)
local t1 = t - 1
return 1 - t1 * t1 * t1 * t1
end,
easeInExpo = function(t)
return t == 0 and 0 or math.pow(2, 10 * (t - 1))
end,
easeOutExpo = function(t)
return t == 1 and 1 or 1 - math.pow(2, -10 * t)
end,
}
local Animation = {} local Animation = {}
Animation.__index = Animation Animation.__index = Animation
@@ -1064,6 +1536,15 @@ function Animation.new(props)
self.transform = props.transform self.transform = props.transform
self.transition = props.transition self.transition = props.transition
self.elapsed = 0 self.elapsed = 0
-- Set easing function (default to linear)
local easingName = props.easing or "linear"
self.easing = Easing[easingName] or Easing.linear
-- Pre-allocate result table to avoid GC pressure
self._cachedResult = {}
self._resultDirty = true
return self return self
end end
@@ -1071,6 +1552,7 @@ end
---@return boolean ---@return boolean
function Animation:update(dt) function Animation:update(dt)
self.elapsed = self.elapsed + dt self.elapsed = self.elapsed + dt
self._resultDirty = true -- Mark cached result as dirty
if self.elapsed >= self.duration then if self.elapsed >= self.duration then
return true -- finished return true -- finished
else else
@@ -1080,8 +1562,19 @@ end
---@return table ---@return table
function Animation:interpolate() function Animation:interpolate()
-- Return cached result if not dirty (avoids recalculation)
if not self._resultDirty then
return self._cachedResult
end
local t = math.min(self.elapsed / self.duration, 1) local t = math.min(self.elapsed / self.duration, 1)
local result = {} t = self.easing(t) -- Apply easing function
local result = self._cachedResult -- Reuse existing table
-- Clear previous values
result.width = nil
result.height = nil
result.opacity = nil
-- Handle width and height if present -- Handle width and height if present
if self.start.width and self.final.width then if self.start.width and self.final.width then
@@ -1104,6 +1597,7 @@ function Animation:interpolate()
end end
end end
self._resultDirty = false -- Mark as clean
return result return result
end end
@@ -1149,6 +1643,8 @@ function Animation.scale(duration, fromScale, toScale)
end end
local FONT_CACHE = {} local FONT_CACHE = {}
local FONT_CACHE_MAX_SIZE = 50 -- Limit cache size to prevent unbounded growth
local FONT_CACHE_ORDER = {} -- Track access order for LRU eviction
--- Create or get a font from cache --- Create or get a font from cache
---@param size number ---@param size number
@@ -1175,6 +1671,15 @@ function FONT_CACHE.get(size, fontPath)
-- Load default font -- Load default font
FONT_CACHE[cacheKey] = love.graphics.newFont(size) FONT_CACHE[cacheKey] = love.graphics.newFont(size)
end end
-- Add to access order for LRU tracking
table.insert(FONT_CACHE_ORDER, cacheKey)
-- Evict oldest entry if cache is full (LRU eviction)
if #FONT_CACHE_ORDER > FONT_CACHE_MAX_SIZE then
local oldestKey = table.remove(FONT_CACHE_ORDER, 1)
FONT_CACHE[oldestKey] = nil
end
end end
return FONT_CACHE[cacheKey] return FONT_CACHE[cacheKey]
end end
@@ -1219,6 +1724,33 @@ end
-- ==================== -- ====================
-- Element Object -- Element Object
-- ==================== -- ====================
--[[
INTERNAL FIELD NAMING CONVENTIONS:
---------------------------------
Fields prefixed with underscore (_) are internal/private and should not be accessed directly:
- _pressed: Internal state tracking for mouse button presses
- _lastClickTime: Internal timestamp for double-click detection
- _lastClickButton: Internal button tracking for click events
- _clickCount: Internal counter for multi-click detection
- _touchPressed: Internal touch state tracking
- _themeState: Internal current theme state (managed automatically)
- _borderBoxWidth: Internal cached border-box width (optimization)
- _borderBoxHeight: Internal cached border-box height (optimization)
- _explicitlyAbsolute: Internal flag for positioning logic
- _originalPositioning: Internal original positioning value
- _cachedResult: Internal animation cache (Animation class)
- _resultDirty: Internal animation dirty flag (Animation class)
- _loadedAtlas: Internal cached atlas image (ThemeComponent)
- _cachedViewport: Internal viewport cache (Gui class)
Public API methods to access internal state:
- Element:getBorderBoxWidth() - Get border-box width
- Element:getBorderBoxHeight() - Get border-box height
- Element:getBounds() - Get element bounds
]]
---@class Element ---@class Element
---@field id string ---@field id string
---@field autosizing {width:boolean, height:boolean} -- Whether the element should automatically size to fit its children ---@field autosizing {width:boolean, height:boolean} -- Whether the element should automatically size to fit its children
@@ -2488,10 +3020,18 @@ function Element:destroy()
-- Clear animation reference -- Clear animation reference
self.animation = nil self.animation = nil
-- Clear callback to prevent closure leaks
self.callback = nil
end end
--- Draw element and its children --- Draw element and its children
function Element:draw() function Element:draw()
-- Early exit if element is invisible (optimization)
if self.opacity <= 0 then
return
end
-- Handle opacity during animation -- Handle opacity during animation
local drawBackgroundColor = self.backgroundColor local drawBackgroundColor = self.backgroundColor
if self.animation then if self.animation then
@@ -2502,6 +3042,10 @@ function Element:draw()
end end
end end
-- Cache border box dimensions for this draw call (optimization)
local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
-- LAYER 1: Draw backgroundColor first (behind everything) -- LAYER 1: Draw backgroundColor first (behind everything)
-- Apply opacity to all drawing operations -- Apply opacity to all drawing operations
-- (x, y) represents border box, so draw background from (x, y) -- (x, y) represents border box, so draw background from (x, y)
@@ -2513,8 +3057,8 @@ function Element:draw()
"fill", "fill",
self.x, self.x,
self.y, self.y,
self._borderBoxWidth or (self.width + self.padding.left + self.padding.right), borderBoxWidth,
self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom), borderBoxHeight,
self.cornerRadius self.cornerRadius
) )
@@ -2551,10 +3095,18 @@ function Element:draw()
-- 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 then if atlasToUse and component.regions then
-- Validate component has required structure
local hasAllRegions = component.regions.topLeft and component.regions.topCenter and
component.regions.topRight and component.regions.middleLeft and
component.regions.middleCenter and component.regions.middleRight and
component.regions.bottomLeft and component.regions.bottomCenter and
component.regions.bottomRight
if hasAllRegions then
NineSlice.draw(component, atlasToUse, self.x, self.y, self.width, self.height, self.padding, self.opacity) NineSlice.draw(component, atlasToUse, self.x, self.y, self.width, self.height, self.padding, self.opacity)
else else
print("[FlexLove] No atlas for component: " .. self.themeComponent) -- Silently skip drawing if component structure is invalid
end
end end
else else
print("[FlexLove] Component not found: " .. self.themeComponent .. " in theme: " .. themeToUse.name) print("[FlexLove] Component not found: " .. self.themeComponent .. " in theme: " .. themeToUse.name)
@@ -2572,10 +3124,6 @@ function Element:draw()
-- 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
-- BORDER-BOX MODEL: Use stored border-box dimensions for drawing
local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
if allBorders then if allBorders then
-- Draw complete rounded rectangle border -- Draw complete rounded rectangle border
RoundedRect.draw("line", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius) RoundedRect.draw("line", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
@@ -2774,8 +3322,10 @@ function Element:update(dt)
end end
end end
-- Only process button events if callback exists and element is not disabled -- Only process button events if callback exists, element is not disabled,
if self.callback and not self.disabled then -- and this is the topmost element at the mouse position (z-index ordering)
local isActiveElement = (Gui._activeEventElement == nil or Gui._activeEventElement == self)
if self.callback and not self.disabled and isActiveElement then
-- Check all three mouse buttons -- Check all three mouse buttons
local buttons = { 1, 2, 3 } -- left, right, middle local buttons = { 1, 2, 3 } -- left, right, middle

View File

@@ -342,4 +342,4 @@ function TestGridLayout:test_single_cell_grid()
end end
print("Running Simplified Grid Layout Tests...") print("Running Simplified Grid Layout Tests...")
os.exit(lu.LuaUnit.run()) lu.LuaUnit.run()

View File

@@ -3,8 +3,8 @@
package.path = package.path .. ";?.lua" package.path = package.path .. ";?.lua"
local lu = require("testing/luaunit") local lu = require("testing.luaunit")
require("testing/loveStub") -- Required to mock LOVE functions require("testing.loveStub") -- Required to mock LOVE functions
local FlexLove = require("FlexLove") local FlexLove = require("FlexLove")
local Gui = FlexLove.GUI local Gui = FlexLove.GUI
@@ -250,8 +250,12 @@ function TestEventSystem:test_press_and_release_events_are_fired()
local hasPress = false local hasPress = false
local hasRelease = false local hasRelease = false
for _, eventType in ipairs(eventsReceived) do for _, eventType in ipairs(eventsReceived) do
if eventType == "press" then hasPress = true end if eventType == "press" then
if eventType == "release" then hasRelease = true end hasPress = true
end
if eventType == "release" then
hasRelease = true
end
end end
lu.assertTrue(hasPress, "Should receive press event") lu.assertTrue(hasPress, "Should receive press event")
@@ -344,4 +348,5 @@ function TestEventSystem:test_multiple_modifiers_detected()
lu.assertTrue(eventReceived.modifiers.ctrl, "Ctrl modifier should be detected") lu.assertTrue(eventReceived.modifiers.ctrl, "Ctrl modifier should be detected")
end end
return TestEventSystem print("Running Event System Tests...")
lu.LuaUnit.run()

View File

@@ -1,15 +1,14 @@
-- Test: Sibling Space Reservation in Flex and Grid Layouts -- Test: Sibling Space Reservation in Flex and Grid Layouts
-- Purpose: Verify that absolutely positioned siblings with explicit positioning -- Purpose: Verify that absolutely positioned siblings with explicit positioning
-- properly reserve space in flex and grid containers -- properly reserve space in flex and grid containers
package.path = package.path .. ";?.lua"
local lu = require("testing.luaunit") local lu = require("testing.luaunit")
local FlexLove = require("libs.FlexLove") require("testing.loveStub") -- Required to mock LOVE functions
local FlexLove = require("FlexLove")
local Gui = FlexLove.GUI local Gui = FlexLove.GUI
local Color = FlexLove.Color local Color = FlexLove.Color
-- Mock love.graphics and love.window
_G.love = require("testing.loveStub")
TestSiblingSpaceReservation = {} TestSiblingSpaceReservation = {}
function TestSiblingSpaceReservation:setUp() function TestSiblingSpaceReservation:setUp()
@@ -434,4 +433,5 @@ function TestSiblingSpaceReservation:test_absolute_without_positioning_offsets_d
lu.assertEquals(flexChild.x, 0, "Absolute children without positioning offsets should not reserve space") lu.assertEquals(flexChild.x, 0, "Absolute children without positioning offsets should not reserve space")
end end
return TestSiblingSpaceReservation print("Running Sibling Space Reservation Tests...")
lu.LuaUnit.run()

View File

@@ -1,5 +1,7 @@
package.path = package.path .. ";?.lua"
local lu = require("testing.luaunit") local lu = require("testing.luaunit")
require("testing.loveStub") require("testing.loveStub") -- Required to mock LOVE functions
local FlexLove = require("FlexLove") local FlexLove = require("FlexLove")
TestFontFamilyInheritance = {} TestFontFamilyInheritance = {}
@@ -219,4 +221,5 @@ function TestFontFamilyInheritance:testInheritanceDoesNotAffectSiblings()
lu.assertNotEquals(child2.fontFamily, child1.fontFamily, "Siblings should have independent fontFamily values") lu.assertNotEquals(child2.fontFamily, child1.fontFamily, "Siblings should have independent fontFamily values")
end end
return TestFontFamilyInheritance print("Running Font Family Inheritance Tests...")
lu.LuaUnit.run()

View File

@@ -1,5 +1,7 @@
package.path = package.path .. ";?.lua"
local lu = require("testing.luaunit") local lu = require("testing.luaunit")
require("testing.loveStub") require("testing.loveStub") -- Required to mock LOVE functions
local FlexLove = require("FlexLove") local FlexLove = require("FlexLove")
TestNegativeMargin = {} TestNegativeMargin = {}
@@ -331,4 +333,5 @@ function TestNegativeMargin:testNegativeMarginInNestedElements()
lu.assertEquals(child.margin.left, -10) lu.assertEquals(child.margin.left, -10)
end end
return TestNegativeMargin print("Running Negative Margin Tests...")
lu.LuaUnit.run()