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
---@field r number -- Red component (0-1)
---@field g number -- Green component (0-1)
@@ -28,8 +280,10 @@ function Color:toRGBA()
end
--- Convert hex string to color
--- Supports both 6-digit (#RRGGBB) and 8-digit (#RRGGBBAA) hex formats
---@param hexWithTag string -- e.g. "#RRGGBB" or "#RRGGBBAA"
---@return Color
---@throws Error if hex string format is invalid
function Color.fromHex(hexWithTag)
local hex = hexWithTag:gsub("#", "")
if #hex == 6 then
@@ -44,7 +298,7 @@ function Color.fromHex(hexWithTag)
local a = tonumber("0x" .. hex:sub(7, 8)) / 255
return Color.new(r, g, b, a)
else
error("Invalid hex string")
error(formatError("Color", string.format("Invalid hex string format: '%s'. Expected #RRGGBB or #RRGGBBAA", hexWithTag)))
end
end
@@ -138,10 +392,64 @@ local function resolveImagePath(imagePath)
return FLEXLOVE_FILESYSTEM_PATH .. "/" .. imagePath
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
---@param definition ThemeDefinition
---@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)
-- Validate theme definition
local valid, err = validateThemeDefinition(definition)
if not valid then
error("[FlexLove] Invalid theme definition: " .. tostring(err))
end
local self = setmetatable({}, Theme)
self.name = definition.name
@@ -149,7 +457,12 @@ function Theme.new(definition)
if definition.atlas then
if type(definition.atlas) == "string" then
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
self.atlas = definition.atlas
end
@@ -164,7 +477,12 @@ function Theme.new(definition)
if component.atlas then
if type(component.atlas) == "string" then
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
component._loadedAtlas = component.atlas
end
@@ -249,9 +567,9 @@ function Theme.getActive()
end
--- Get a component from the active theme
---@param componentName string
---@param state string?
---@return ThemeComponent?
---@param componentName string -- Name of the component (e.g., "button", "panel")
---@param state string? -- Optional state (e.g., "hover", "pressed", "disabled")
---@return ThemeComponent? -- Returns component or nil if not found
function Theme.getComponent(componentName, state)
if not activeTheme then
return nil
@@ -270,6 +588,44 @@ function Theme.getComponent(componentName, state)
return component
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
-- ====================
@@ -635,18 +991,19 @@ function Units.parse(value)
end
--- Convert relative units to pixels based on viewport and parent dimensions
---@param value number
---@param unit string
---@param viewportWidth number
---@param viewportHeight number
---@param parentSize number? -- Required for percentage units
---@return number -- Pixel value
---@param value number -- Numeric value to convert
---@param unit string -- Unit type ("px", "%", "vw", "vh", "ew", "eh")
---@param viewportWidth number -- Current viewport width in pixels
---@param viewportHeight number -- Current viewport height in pixels
---@param parentSize number? -- Required for percentage units (parent dimension)
---@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)
if unit == "px" then
return value
elseif unit == "%" then
if not parentSize then
error("Percentage units require parent dimension")
error(formatError("Units", "Percentage units require parent dimension"))
end
return (value / 100) * parentSize
elseif unit == "vw" then
@@ -654,18 +1011,22 @@ function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize)
elseif unit == "vh" then
return (value / 100) * viewportHeight
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
---@return number, number -- width, height
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
return love.graphics.getDimensions()
else
local w, h = love.window.getMode()
return w, h
return love.window.getMode()
end
end
@@ -737,6 +1098,30 @@ function Units.resolveSpacing(spacingProps, parentWidth, parentHeight)
return result
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
-- ====================
@@ -884,6 +1269,7 @@ local Gui = {
baseScale = nil,
scaleFactors = { x = 1.0, y = 1.0 },
defaultTheme = nil,
_cachedViewport = { width = 0, height = 0 }, -- Cached viewport dimensions
}
--- Initialize FlexLove with configuration
@@ -956,10 +1342,63 @@ function Gui.draw()
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)
-- 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
win:update(dt)
end
-- Clear active element for next frame
Gui._activeEventElement = nil
end
--- Destroy all elements and their children
@@ -1033,6 +1472,39 @@ end
---@field elapsed number
---@field transform 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 = {}
Animation.__index = Animation
@@ -1064,6 +1536,15 @@ function Animation.new(props)
self.transform = props.transform
self.transition = props.transition
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
end
@@ -1071,6 +1552,7 @@ end
---@return boolean
function Animation:update(dt)
self.elapsed = self.elapsed + dt
self._resultDirty = true -- Mark cached result as dirty
if self.elapsed >= self.duration then
return true -- finished
else
@@ -1080,8 +1562,19 @@ end
---@return table
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 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
if self.start.width and self.final.width then
@@ -1104,6 +1597,7 @@ function Animation:interpolate()
end
end
self._resultDirty = false -- Mark as clean
return result
end
@@ -1149,6 +1643,8 @@ function Animation.scale(duration, fromScale, toScale)
end
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
---@param size number
@@ -1175,6 +1671,15 @@ function FONT_CACHE.get(size, fontPath)
-- Load default font
FONT_CACHE[cacheKey] = love.graphics.newFont(size)
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
return FONT_CACHE[cacheKey]
end
@@ -1219,6 +1724,33 @@ end
-- ====================
-- 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
---@field id string
---@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
self.animation = nil
-- Clear callback to prevent closure leaks
self.callback = nil
end
--- Draw element and its children
function Element:draw()
-- Early exit if element is invisible (optimization)
if self.opacity <= 0 then
return
end
-- Handle opacity during animation
local drawBackgroundColor = self.backgroundColor
if self.animation then
@@ -2502,6 +3042,10 @@ function Element:draw()
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)
-- Apply opacity to all drawing operations
-- (x, y) represents border box, so draw background from (x, y)
@@ -2513,8 +3057,8 @@ function Element:draw()
"fill",
self.x,
self.y,
self._borderBoxWidth or (self.width + self.padding.left + self.padding.right),
self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom),
borderBoxWidth,
borderBoxHeight,
self.cornerRadius
)
@@ -2551,10 +3095,18 @@ function Element:draw()
-- Use component-specific atlas if available, otherwise use theme atlas
local atlasToUse = component._loadedAtlas or themeToUse.atlas
if atlasToUse then
NineSlice.draw(component, atlasToUse, self.x, self.y, self.width, self.height, self.padding, self.opacity)
else
print("[FlexLove] No atlas for component: " .. self.themeComponent)
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)
else
-- Silently skip drawing if component structure is invalid
end
end
else
print("[FlexLove] Component not found: " .. self.themeComponent .. " in theme: " .. themeToUse.name)
@@ -2572,10 +3124,6 @@ function Element:draw()
-- Check if all borders are enabled
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
-- Draw complete rounded rectangle border
RoundedRect.draw("line", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
@@ -2774,8 +3322,10 @@ function Element:update(dt)
end
end
-- Only process button events if callback exists and element is not disabled
if self.callback and not self.disabled then
-- Only process button events if callback exists, element is not disabled,
-- 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
local buttons = { 1, 2, 3 } -- left, right, middle

View File

@@ -269,7 +269,7 @@ function TestGridLayout:test_nested_grids()
-- Inner grid should be stretched to fill outer grid cell (200x200)
lu.assertAlmostEquals(innerGrid.width, 200, 1)
lu.assertAlmostEquals(innerGrid.height, 200, 1)
-- Items in inner grid should be positioned correctly
-- Each cell in inner grid is 100x100
lu.assertAlmostEquals(item1.x, 0, 1)
@@ -342,4 +342,4 @@ function TestGridLayout:test_single_cell_grid()
end
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"
local lu = require("testing/luaunit")
require("testing/loveStub") -- Required to mock LOVE functions
local lu = require("testing.luaunit")
require("testing.loveStub") -- Required to mock LOVE functions
local FlexLove = require("FlexLove")
local Gui = FlexLove.GUI
@@ -26,7 +26,7 @@ end
-- Test 1: Event object structure
function TestEventSystem:test_event_object_has_required_fields()
local eventReceived = nil
local button = Gui.new({
x = 100,
y = 100,
@@ -36,15 +36,15 @@ function TestEventSystem:test_event_object_has_required_fields()
eventReceived = event
end,
})
-- Simulate mouse press and release
love.mouse.setPosition(150, 150)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
-- Verify event object structure
lu.assertNotNil(eventReceived, "Event should be received")
lu.assertNotNil(eventReceived.type, "Event should have type field")
@@ -59,28 +59,28 @@ end
-- Test 2: Left click event
function TestEventSystem:test_left_click_generates_click_event()
local eventsReceived = {}
local button = Gui.new({
x = 100,
y = 100,
width = 200,
height = 100,
callback = function(element, event)
table.insert(eventsReceived, {type = event.type, button = event.button})
table.insert(eventsReceived, { type = event.type, button = event.button })
end,
})
-- Simulate left click
love.mouse.setPosition(150, 150)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
-- Should receive press, click, and release events
lu.assertTrue(#eventsReceived >= 2, "Should receive at least 2 events")
-- Check for click event
local hasClickEvent = false
for _, evt in ipairs(eventsReceived) do
@@ -95,25 +95,25 @@ end
-- Test 3: Right click event
function TestEventSystem:test_right_click_generates_rightclick_event()
local eventsReceived = {}
local button = Gui.new({
x = 100,
y = 100,
width = 200,
height = 100,
callback = function(element, event)
table.insert(eventsReceived, {type = event.type, button = event.button})
table.insert(eventsReceived, { type = event.type, button = event.button })
end,
})
-- Simulate right click
love.mouse.setPosition(150, 150)
love.mouse.setDown(2, true)
button:update(0.016)
love.mouse.setDown(2, false)
button:update(0.016)
-- Check for rightclick event
local hasRightClickEvent = false
for _, evt in ipairs(eventsReceived) do
@@ -128,25 +128,25 @@ end
-- Test 4: Middle click event
function TestEventSystem:test_middle_click_generates_middleclick_event()
local eventsReceived = {}
local button = Gui.new({
x = 100,
y = 100,
width = 200,
height = 100,
callback = function(element, event)
table.insert(eventsReceived, {type = event.type, button = event.button})
table.insert(eventsReceived, { type = event.type, button = event.button })
end,
})
-- Simulate middle click
love.mouse.setPosition(150, 150)
love.mouse.setDown(3, true)
button:update(0.016)
love.mouse.setDown(3, false)
button:update(0.016)
-- Check for middleclick event
local hasMiddleClickEvent = false
for _, evt in ipairs(eventsReceived) do
@@ -161,7 +161,7 @@ end
-- Test 5: Modifier keys detection
function TestEventSystem:test_modifier_keys_are_detected()
local eventReceived = nil
local button = Gui.new({
x = 100,
y = 100,
@@ -173,16 +173,16 @@ function TestEventSystem:test_modifier_keys_are_detected()
end
end,
})
-- Simulate shift + click
love.keyboard.setDown("lshift", true)
love.mouse.setPosition(150, 150)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
lu.assertNotNil(eventReceived, "Should receive click event")
lu.assertTrue(eventReceived.modifiers.shift, "Shift modifier should be detected")
end
@@ -190,7 +190,7 @@ end
-- Test 6: Double click detection
function TestEventSystem:test_double_click_increments_click_count()
local clickEvents = {}
local button = Gui.new({
x = 100,
y = 100,
@@ -202,21 +202,21 @@ function TestEventSystem:test_double_click_increments_click_count()
end
end,
})
-- Simulate first click
love.mouse.setPosition(150, 150)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
-- Simulate second click quickly (double-click)
love.timer.setTime(love.timer.getTime() + 0.1) -- 100ms later
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
lu.assertEquals(#clickEvents, 2, "Should receive 2 click events")
lu.assertEquals(clickEvents[1], 1, "First click should have clickCount = 1")
lu.assertEquals(clickEvents[2], 2, "Second click should have clickCount = 2")
@@ -225,7 +225,7 @@ end
-- Test 7: Press and release events
function TestEventSystem:test_press_and_release_events_are_fired()
local eventsReceived = {}
local button = Gui.new({
x = 100,
y = 100,
@@ -235,25 +235,29 @@ function TestEventSystem:test_press_and_release_events_are_fired()
table.insert(eventsReceived, event.type)
end,
})
-- Simulate click
love.mouse.setPosition(150, 150)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
-- Should receive press, click, and release
lu.assertTrue(#eventsReceived >= 2, "Should receive multiple events")
local hasPress = false
local hasRelease = false
for _, eventType in ipairs(eventsReceived) do
if eventType == "press" then hasPress = true end
if eventType == "release" then hasRelease = true end
if eventType == "press" then
hasPress = true
end
if eventType == "release" then
hasRelease = true
end
end
lu.assertTrue(hasPress, "Should receive press event")
lu.assertTrue(hasRelease, "Should receive release event")
end
@@ -261,7 +265,7 @@ end
-- Test 8: Mouse position in event
function TestEventSystem:test_event_contains_mouse_position()
local eventReceived = nil
local button = Gui.new({
x = 100,
y = 100,
@@ -273,16 +277,16 @@ function TestEventSystem:test_event_contains_mouse_position()
end
end,
})
-- Simulate click at specific position
local mouseX, mouseY = 175, 125
love.mouse.setPosition(mouseX, mouseY)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
lu.assertNotNil(eventReceived, "Should receive click event")
lu.assertEquals(eventReceived.x, mouseX, "Event should contain correct mouse X position")
lu.assertEquals(eventReceived.y, mouseY, "Event should contain correct mouse Y position")
@@ -291,7 +295,7 @@ end
-- Test 9: No callback when mouse outside element
function TestEventSystem:test_no_callback_when_clicking_outside_element()
local callbackCalled = false
local button = Gui.new({
x = 100,
y = 100,
@@ -301,22 +305,22 @@ function TestEventSystem:test_no_callback_when_clicking_outside_element()
callbackCalled = true
end,
})
-- Click outside element
love.mouse.setPosition(50, 50)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
lu.assertFalse(callbackCalled, "Callback should not be called when clicking outside element")
end
-- Test 10: Multiple modifiers
function TestEventSystem:test_multiple_modifiers_detected()
local eventReceived = nil
local button = Gui.new({
x = 100,
y = 100,
@@ -328,20 +332,21 @@ function TestEventSystem:test_multiple_modifiers_detected()
end
end,
})
-- Simulate shift + ctrl + click
love.keyboard.setDown("lshift", true)
love.keyboard.setDown("lctrl", true)
love.mouse.setPosition(150, 150)
love.mouse.setDown(1, true)
button:update(0.016)
love.mouse.setDown(1, false)
button:update(0.016)
lu.assertNotNil(eventReceived, "Should receive click event")
lu.assertTrue(eventReceived.modifiers.shift, "Shift modifier should be detected")
lu.assertTrue(eventReceived.modifiers.ctrl, "Ctrl modifier should be detected")
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
-- Purpose: Verify that absolutely positioned siblings with explicit positioning
-- properly reserve space in flex and grid containers
package.path = package.path .. ";?.lua"
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 Color = FlexLove.Color
-- Mock love.graphics and love.window
_G.love = require("testing.loveStub")
TestSiblingSpaceReservation = {}
function TestSiblingSpaceReservation:setUp()
@@ -106,7 +105,7 @@ function TestSiblingSpaceReservation:test_flex_horizontal_right_positioned_sibli
-- The flex child (width 100) should fit within this space
-- Child should start at x = 0
lu.assertEquals(flexChild.x, 0, "Flex child should start at container left edge")
-- The absolutely positioned sibling should be at the right edge
-- x = container.x + container.width + padding.left - right - (width + padding)
-- = 0 + 1000 + 0 - 10 - 50 = 940
@@ -208,7 +207,7 @@ function TestSiblingSpaceReservation:test_flex_horizontal_multiple_positioned_si
-- Available space: 1000 - 45 - 45 = 910px
-- First flex child should start at x = 0 + 0 + 45 = 45
lu.assertEquals(flexChild1.x, 45, "First flex child should start after left sibling")
-- Second flex child should start at x = 45 + 100 + gap = 145 (assuming gap=10)
lu.assertIsTrue(flexChild2.x >= 145, "Second flex child should be positioned after first")
end
@@ -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")
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")
require("testing.loveStub")
require("testing.loveStub") -- Required to mock LOVE functions
local FlexLove = require("FlexLove")
TestFontFamilyInheritance = {}
@@ -219,4 +221,5 @@ function TestFontFamilyInheritance:testInheritanceDoesNotAffectSiblings()
lu.assertNotEquals(child2.fontFamily, child1.fontFamily, "Siblings should have independent fontFamily values")
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")
require("testing.loveStub")
require("testing.loveStub") -- Required to mock LOVE functions
local FlexLove = require("FlexLove")
TestNegativeMargin = {}
@@ -331,4 +333,5 @@ function TestNegativeMargin:testNegativeMarginInNestedElements()
lu.assertEquals(child.margin.left, -10)
end
return TestNegativeMargin
print("Running Negative Margin Tests...")
lu.LuaUnit.run()