Compare commits

...

16 Commits

Author SHA1 Message Date
f5608980e3 v0.10.0 release 2026-02-25 13:21:12 -05:00
9a126cb87e fix: retained mode dimensional property recalc 2026-02-25 12:22:53 -05:00
caf604445f housekeeping: file cleanup 2026-02-25 11:11:52 -05:00
ffab292c04 housekeeping: flatten tests 2026-02-25 11:07:59 -05:00
309ebde985 feat: gesture/multi-touch progress 2026-02-25 10:17:54 -05:00
998469141a feat: animation expansion 2026-02-25 00:46:58 -05:00
github-actions[bot]
4e14b375e0 Archive previous documentation and generate v0.9.2 docs [skip ci] 2026-02-25 05:29:57 +00:00
f1fae85595 v0.9.2 release 2026-02-25 00:29:36 -05:00
d948ab2b4c prop defs 2026-02-25 00:24:58 -05:00
6ae04b5e82 fix: resize bug in retained mode 2026-02-25 00:18:11 -05:00
3c3f26b74a v0.9.1 release 2026-02-24 23:20:29 -05:00
2b5957f264 fix: typo 2026-02-24 23:20:29 -05:00
github-actions[bot]
7f72623168 Archive previous documentation and generate v0.9.0 docs [skip ci] 2026-02-25 03:45:00 +00:00
2f62810a91 v0.9.0 release 2026-02-24 22:43:41 -05:00
7b34c71623 note new capabilities 2026-02-24 22:00:38 -05:00
a17e524730 debug mode 2026-02-24 21:50:12 -05:00
23 changed files with 12091 additions and 544 deletions

4
.gitignore vendored
View File

@@ -1,7 +1,4 @@
Cartographer.lua
OverlayStats.lua OverlayStats.lua
lume.lua
lurker.lua
themes/metal/ themes/metal/
themes/space/ themes/space/
.DS_STORE .DS_STORE
@@ -17,3 +14,4 @@ memory_scan*
*_report* *_report*
*.key *.key
*.rock *.rock
*.rockspec

View File

@@ -63,7 +63,7 @@ local enums = utils.enums
---@class FlexLove ---@class FlexLove
local flexlove = Context local flexlove = Context
flexlove._VERSION = "0.8.0" flexlove._VERSION = "0.10.0"
flexlove._DESCRIPTION = "UI Library for LÖVE Framework based on flexbox" flexlove._DESCRIPTION = "UI Library for LÖVE Framework based on flexbox"
flexlove._URL = "https://github.com/mikefreno/FlexLove" flexlove._URL = "https://github.com/mikefreno/FlexLove"
flexlove._LICENSE = [[ flexlove._LICENSE = [[
@@ -112,6 +112,14 @@ flexlove._deferredCallbacks = {}
-- Track accumulated delta time for immediate mode updates -- Track accumulated delta time for immediate mode updates
flexlove._accumulatedDt = 0 flexlove._accumulatedDt = 0
-- Touch ownership tracking: maps touch ID (string) to the element that owns it
---@type table<string, Element>
flexlove._touchOwners = {}
-- Shared GestureRecognizer instance for touch routing (initialized in init())
---@type GestureRecognizer|nil
flexlove._gestureRecognizer = nil
--- Check if FlexLove initialization is complete and ready to create elements --- Check if FlexLove initialization is complete and ready to create elements
--- Use this before creating elements to avoid automatic queueing --- Use this before creating elements to avoid automatic queueing
---@return boolean ready True if FlexLove is initialized and ready to use ---@return boolean ready True if FlexLove is initialized and ready to use
@@ -207,6 +215,11 @@ function flexlove.init(config)
LayoutEngine.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance, FFI = flexlove._FFI }) LayoutEngine.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance, FFI = flexlove._FFI })
EventHandler.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance, InputEvent = InputEvent, utils = utils }) EventHandler.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance, InputEvent = InputEvent, utils = utils })
-- Initialize shared GestureRecognizer for touch routing
if GestureRecognizer then
flexlove._gestureRecognizer = GestureRecognizer.new({}, { InputEvent = InputEvent, utils = utils })
end
flexlove._defaultDependencies = { flexlove._defaultDependencies = {
Context = Context, Context = Context,
Theme = Theme, Theme = Theme,
@@ -232,6 +245,7 @@ function flexlove.init(config)
ErrorHandler = flexlove._ErrorHandler, ErrorHandler = flexlove._ErrorHandler,
Performance = flexlove._Performance, Performance = flexlove._Performance,
Transform = Transform, Transform = Transform,
Animation = Animation,
} }
-- Initialize Element module with dependencies -- Initialize Element module with dependencies
@@ -294,6 +308,10 @@ function flexlove.init(config)
flexlove.initialized = true flexlove.initialized = true
flexlove._initState = "ready" flexlove._initState = "ready"
-- Configure debug draw overlay
flexlove._debugDraw = config.debugDraw or false
flexlove._debugDrawKey = config.debugDrawKey or nil
-- Process all queued element creations -- Process all queued element creations
local queue = flexlove._initQueue local queue = flexlove._initQueue
flexlove._initQueue = {} -- Clear queue before processing to prevent re-entry issues flexlove._initQueue = {} -- Clear queue before processing to prevent re-entry issues
@@ -557,6 +575,49 @@ flexlove._backdropCanvas = nil
---@type {width: number, height: number} ---@type {width: number, height: number}
flexlove._canvasDimensions = { width = 0, height = 0 } flexlove._canvasDimensions = { width = 0, height = 0 }
--- Recursively draw debug boundaries for an element and all its children
--- Draws regardless of visibility/opacity to reveal hidden or transparent elements
---@param element Element
local function drawDebugElement(element)
local color = element._debugColor
if color then
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)
-- Fill with 0.5 opacity
love.graphics.setColor(color[1], color[2], color[3], 0.5)
love.graphics.rectangle("fill", element.x, element.y, bw, bh)
-- Border with full opacity, 1px line
love.graphics.setColor(color[1], color[2], color[3], 1)
love.graphics.setLineWidth(1)
love.graphics.rectangle("line", element.x, element.y, bw, bh)
end
for _, child in ipairs(element.children) do
drawDebugElement(child)
end
end
--- Render the debug draw overlay for all elements in the tree
--- Traverses every element regardless of visibility or opacity
function flexlove._renderDebugOverlay()
-- Save current graphics state
local prevR, prevG, prevB, prevA = love.graphics.getColor()
local prevLineWidth = love.graphics.getLineWidth()
-- Clear any active scissor so debug draws are always visible
love.graphics.setScissor()
for _, win in ipairs(flexlove.topElements) do
drawDebugElement(win)
end
-- Restore graphics state
love.graphics.setColor(prevR, prevG, prevB, prevA)
love.graphics.setLineWidth(prevLineWidth)
end
--- Render all UI elements with optional backdrop blur support for glassmorphic effects --- Render all UI elements with optional backdrop blur support for glassmorphic effects
--- Place your game scene in gameDrawFunc to enable backdrop blur on UI elements; use postDrawFunc for overlays --- Place your game scene in gameDrawFunc to enable backdrop blur on UI elements; use postDrawFunc for overlays
---@param gameDrawFunc function|nil pass component draws that should be affected by a backdrop blur ---@param gameDrawFunc function|nil pass component draws that should be affected by a backdrop blur
@@ -667,6 +728,11 @@ function flexlove.draw(gameDrawFunc, postDrawFunc)
-- Render performance HUD if enabled -- Render performance HUD if enabled
flexlove._Performance:renderHUD() flexlove._Performance:renderHUD()
-- Render debug draw overlay if enabled
if flexlove._debugDraw then
flexlove._renderDebugOverlay()
end
love.graphics.setCanvas(outerCanvas) love.graphics.setCanvas(outerCanvas)
-- NOTE: Deferred callbacks are NOT executed here because the calling code -- NOTE: Deferred callbacks are NOT executed here because the calling code
@@ -918,8 +984,10 @@ end
---@param scancode string ---@param scancode string
---@param isrepeat boolean ---@param isrepeat boolean
function flexlove.keypressed(key, scancode, isrepeat) function flexlove.keypressed(key, scancode, isrepeat)
-- Handle performance HUD toggle
flexlove._Performance:keypressed(key) flexlove._Performance:keypressed(key)
if flexlove._debugDrawKey and key == flexlove._debugDrawKey then
flexlove._debugDraw = not flexlove._debugDraw
end
local focusedElement = Context.getFocused() local focusedElement = Context.getFocused()
if focusedElement then if focusedElement then
focusedElement:keypressed(key, scancode, isrepeat) focusedElement:keypressed(key, scancode, isrepeat)
@@ -1052,6 +1120,239 @@ function flexlove.wheelmoved(dx, dy)
end end
end end
--- Find the touch-interactive element at a given position using z-index ordering
--- Similar to getElementAtPosition but checks for touch-enabled elements
---@param x number Touch X position
---@param y number Touch Y position
---@return Element|nil element The topmost touch-enabled element at position
function flexlove._getTouchElementAtPosition(x, y)
local candidates = {}
local function collectTouchHits(element, scrollOffsetX, scrollOffsetY)
scrollOffsetX = scrollOffsetX or 0
scrollOffsetY = scrollOffsetY or 0
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)
-- Adjust touch position by accumulated scroll offset for hit testing
local adjustedX = x + scrollOffsetX
local adjustedY = y + scrollOffsetY
if adjustedX >= bx and adjustedX <= bx + bw and adjustedY >= by and adjustedY <= by + bh then
-- Check if element is touch-enabled and interactive
if element.touchEnabled and not element.disabled and (element.onEvent or element.onTouchEvent or element.onGesture) then
table.insert(candidates, element)
end
-- Check if this element has scrollable overflow (for touch scrolling)
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
local hasScrollableOverflow = (
overflowX == "scroll"
or overflowX == "auto"
or overflowY == "scroll"
or overflowY == "auto"
or overflowX == "hidden"
or overflowY == "hidden"
)
-- Accumulate scroll offset for children
local childScrollOffsetX = scrollOffsetX
local childScrollOffsetY = scrollOffsetY
if hasScrollableOverflow then
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
end
for _, child in ipairs(element.children) do
collectTouchHits(child, childScrollOffsetX, childScrollOffsetY)
end
end
end
for _, element in ipairs(flexlove.topElements) do
collectTouchHits(element)
end
-- Sort by z-index (highest first) — topmost element wins
table.sort(candidates, function(a, b)
return a.z > b.z
end)
return candidates[1]
end
--- Handle touch press events from LÖVE's touch input system
--- Routes touch to the topmost element at the touch position and assigns touch ownership
--- Hook this to love.touchpressed() to enable touch interaction
---@param id lightuserdata Touch identifier from LÖVE
---@param x number Touch X position in screen coordinates
---@param y number Touch Y position in screen coordinates
---@param dx number X distance moved (usually 0 on press)
---@param dy number Y distance moved (usually 0 on press)
---@param pressure number Touch pressure (0-1, if supported by device)
function flexlove.touchpressed(id, x, y, dx, dy, pressure)
local touchId = tostring(id)
pressure = pressure or 1.0
-- Apply base scaling if configured
local touchX, touchY = x, y
if flexlove.baseScale then
touchX = x / flexlove.scaleFactors.x
touchY = y / flexlove.scaleFactors.y
end
-- Find the topmost touch-enabled element at this position
local element = flexlove._getTouchElementAtPosition(touchX, touchY)
if element then
-- Assign touch ownership: this element receives all subsequent events for this touch
flexlove._touchOwners[touchId] = element
-- Create and route touch event
local touchEvent = InputEvent.fromTouch(id, touchX, touchY, "began", pressure)
element:handleTouchEvent(touchEvent)
-- Feed to shared gesture recognizer
if flexlove._gestureRecognizer then
local gestures = flexlove._gestureRecognizer:processTouchEvent(touchEvent)
if gestures then
for _, gesture in ipairs(gestures) do
element:handleGesture(gesture)
end
end
end
-- Route to scroll manager for scrollable elements
if element._scrollManager then
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then
element._scrollManager:handleTouchPress(touchX, touchY)
end
end
end
end
--- Handle touch move events from LÖVE's touch input system
--- Routes touch to the element that owns this touch ID (from the original press), regardless of current position
--- Hook this to love.touchmoved() to enable touch drag and gesture tracking
---@param id lightuserdata Touch identifier from LÖVE
---@param x number Touch X position in screen coordinates
---@param y number Touch Y position in screen coordinates
---@param dx number X distance moved since last event
---@param dy number Y distance moved since last event
---@param pressure number Touch pressure (0-1, if supported by device)
function flexlove.touchmoved(id, x, y, dx, dy, pressure)
local touchId = tostring(id)
pressure = pressure or 1.0
-- Apply base scaling if configured
local touchX, touchY = x, y
if flexlove.baseScale then
touchX = x / flexlove.scaleFactors.x
touchY = y / flexlove.scaleFactors.y
end
-- Route to owning element (touch ownership persists from press to release)
local element = flexlove._touchOwners[touchId]
if element then
-- Create and route touch event
local touchEvent = InputEvent.fromTouch(id, touchX, touchY, "moved", pressure)
element:handleTouchEvent(touchEvent)
-- Feed to shared gesture recognizer
if flexlove._gestureRecognizer then
local gestures = flexlove._gestureRecognizer:processTouchEvent(touchEvent)
if gestures then
for _, gesture in ipairs(gestures) do
element:handleGesture(gesture)
end
end
end
-- Route to scroll manager for scrollable elements
if element._scrollManager then
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then
element._scrollManager:handleTouchMove(touchX, touchY)
end
end
end
end
--- Handle touch release events from LÖVE's touch input system
--- Routes touch to the owning element and cleans up touch ownership tracking
--- Hook this to love.touchreleased() to properly end touch interactions
---@param id lightuserdata Touch identifier from LÖVE
---@param x number Touch X position in screen coordinates
---@param y number Touch Y position in screen coordinates
---@param dx number X distance moved since last event
---@param dy number Y distance moved since last event
---@param pressure number Touch pressure (0-1, if supported by device)
function flexlove.touchreleased(id, x, y, dx, dy, pressure)
local touchId = tostring(id)
pressure = pressure or 1.0
-- Apply base scaling if configured
local touchX, touchY = x, y
if flexlove.baseScale then
touchX = x / flexlove.scaleFactors.x
touchY = y / flexlove.scaleFactors.y
end
-- Route to owning element
local element = flexlove._touchOwners[touchId]
if element then
-- Create and route touch event
local touchEvent = InputEvent.fromTouch(id, touchX, touchY, "ended", pressure)
element:handleTouchEvent(touchEvent)
-- Feed to shared gesture recognizer
if flexlove._gestureRecognizer then
local gestures = flexlove._gestureRecognizer:processTouchEvent(touchEvent)
if gestures then
for _, gesture in ipairs(gestures) do
element:handleGesture(gesture)
end
end
end
-- Route to scroll manager for scrollable elements
if element._scrollManager then
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then
element._scrollManager:handleTouchRelease()
end
end
end
-- Clean up touch ownership (touch is complete)
flexlove._touchOwners[touchId] = nil
end
--- Get the number of currently active touches being tracked
---@return number count Number of active touch points
function flexlove.getActiveTouchCount()
local count = 0
for _ in pairs(flexlove._touchOwners) do
count = count + 1
end
return count
end
--- Get the element that currently owns a specific touch
---@param touchId string|lightuserdata Touch identifier
---@return Element|nil element The element owning this touch, or nil
function flexlove.getTouchOwner(touchId)
return flexlove._touchOwners[tostring(touchId)]
end
--- Clean up all UI elements and reset FlexLove to initial state when changing scenes or shutting down --- Clean up all UI elements and reset FlexLove to initial state when changing scenes or shutting down
--- Use this to prevent memory leaks when transitioning between game states or menus --- Use this to prevent memory leaks when transitioning between game states or menus
function flexlove.destroy() function flexlove.destroy()
@@ -1076,6 +1377,12 @@ function flexlove.destroy()
flexlove._canvasDimensions = { width = 0, height = 0 } flexlove._canvasDimensions = { width = 0, height = 0 }
Context.clearFocus() Context.clearFocus()
StateManager:reset() StateManager:reset()
-- Clean up touch state
flexlove._touchOwners = {}
if flexlove._gestureRecognizer then
flexlove._gestureRecognizer:reset()
end
end end
--- Create a new UI element with flexbox layout, styling, and interaction capabilities --- Create a new UI element with flexbox layout, styling, and interaction capabilities
@@ -1248,6 +1555,19 @@ function flexlove.clearFocus()
Context.setFocused(nil) Context.setFocused(nil)
end end
--- Enable or disable the debug draw overlay that renders element boundaries with random colors
--- Each element gets a unique color: full opacity border and 0.5 opacity fill to identify collisions and overlaps
---@param enabled boolean True to enable debug draw overlay, false to disable
function flexlove.setDebugDraw(enabled)
flexlove._debugDraw = enabled
end
--- Check if the debug draw overlay is currently active
---@return boolean enabled True if debug draw overlay is enabled
function flexlove.getDebugDraw()
return flexlove._debugDraw
end
flexlove.Animation = Animation flexlove.Animation = Animation
flexlove.Color = Color flexlove.Color = Color
flexlove.Theme = Theme flexlove.Theme = Theme

View File

@@ -79,6 +79,15 @@ function love.draw()
SomeMetaComponent:draw() SomeMetaComponent:draw()
end) end)
end end
function love.load()
FlexLove.init({
theme = "space",
immediateMode = true,
debugDraw = true, -- Enable debug view
debugDrawKey = "F3" -- Toggle debug view with F3 key
})
end
``` ```
## Quick Demos ## Quick Demos
@@ -377,6 +386,76 @@ FlexLöve provides comprehensive multi-touch event tracking and gesture recognit
- Touch scrolling with momentum and bounce effects - Touch scrolling with momentum and bounce effects
- Complete API reference and examples - Complete API reference and examples
### Custom Rendering
Each element supports a `customDraw` callback function that executes after the element's standard rendering but before visual feedback. This is useful for:
- Adding custom graphics on top of elements
- Creating complex visual effects
- Utilize flex love positioning to place whatever you need
```lua
local panel = FlexLove.new({
width = 300,
height = 200,
backgroundColor = Color.new(0.1, 0.1, 0.1, 1),
customDraw = function(element)
-- Draw a custom border around the element
love.graphics.setLineWidth(3)
love.graphics.setColor(1, 1, 0, 1) -- Yellow
love.graphics.rectangle("line",
element.x - 5,
element.y - 5,
element.width + 10,
element.height + 10
)
-- Draw a cross in the center
love.graphics.setColor(1, 0, 0, 1) -- Red
local cx = element.x + element.width / 2
local cy = element.y + element.height / 2
love.graphics.line(cx - 20, cy, cx + 20, cy)
love.graphics.line(cx, cy - 20, cx, cy + 20)
end
})
```
**Note:** The custom draw context is pushed with a fresh graphics state, so it won't affect parent elements or subsequent rendering.
### Debug View
Enable the debug draw overlay to visualize element boundaries, hit areas, and layout structure during development. This helps identify:
- Element positioning and sizing
- Overlapping elements
- Hidden or transparent elements
- Layout flow issues
**Enable via initialization:**
```lua
FlexLove.init({
debugDraw = true, -- Always enable debug overlay
debugDrawKey = "F3" -- Press F3 to toggle (optional)
})
```
**Programmatic control:**
```lua
-- Toggle debug view at runtime
flexlove.setDebugDraw(true) -- Enable
flexlove.setDebugDraw(false) -- Disable
-- Check if debug view is active
local isEnabled = flexlove.getDebugDraw()
```
**Features:**
- Each element displays with a unique random color
- Full opacity border (1px) and 0.5 opacity fill
- Renders regardless of element visibility or opacity
- Press `F3` (or your configured key) to toggle on/off
- Essential for debugging click targets and layout issues
### Deferred Callbacks ### Deferred Callbacks
Some LÖVE operations (like `love.window.setMode`) cannot be called while a Canvas is active. FlexLöve provides a deferred callback system to handle these operations safely: Some LÖVE operations (like `love.window.setMode`) cannot be called while a Canvas is active. FlexLöve provides a deferred callback system to handle these operations safely:

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlexLöve v0.8.0 - API Reference</title> <title>FlexLöve v0.9.2 - API Reference</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<style> <style>
* { * {
@@ -321,13 +321,15 @@
<div class="container"> <div class="container">
<nav class="sidebar"> <nav class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<h2>FlexLöve <span style="font-size: 0.6em; color: #8b949e;">v0.8.0</span></h2> <h2>FlexLöve <span style="font-size: 0.6em; color: #8b949e;">v0.9.2</span></h2>
<a href="index.html">← Back to Home</a> <a href="index.html">← Back to Home</a>
<div class="version-selector"> <div class="version-selector">
<select id="version-dropdown" onchange="window.versionNavigate(this.value)"> <select id="version-dropdown" onchange="window.versionNavigate(this.value)">
<option value="">📚 Switch Version</option> <option value="">📚 Switch Version</option>
<option value="current">v0.8.0 (Latest)</option> <option value="current">v0.9.2 (Latest)</option>
<option value="v0.9.0">v0.9.0</option>
<option value="v0.8.0">v0.8.0</option>
<option value="v0.7.3">v0.7.3</option> <option value="v0.7.3">v0.7.3</option>
<option value="v0.7.2">v0.7.2</option> <option value="v0.7.2">v0.7.2</option>
<option value="v0.7.1">v0.7.1</option> <option value="v0.7.1">v0.7.1</option>

View File

@@ -285,7 +285,7 @@ cp FlexLove/FlexLove.lua your-project/</code></pre>
<div class="footer"> <div class="footer">
<p> <p>
FlexLöve v0.8.0 | MIT License | FlexLöve v0.10.0 | MIT License |
<a href="https://github.com/mikefreno/FlexLove" style="color: #58a6ff" <a href="https://github.com/mikefreno/FlexLove" style="color: #58a6ff"
>GitHub Repository</a >GitHub Repository</a
> >

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,85 +0,0 @@
package = "flexlove"
version = "0.7.3-1"
source = {
url = "git+https://github.com/mikefreno/FlexLove.git",
tag = "v0.7.3",
}
description = {
summary = "A comprehensive UI library providing flexbox/grid layouts, theming, animations, and event handling for LÖVE2D games",
detailed = [[
FlexLöve is a lightweight, flexible GUI library for LÖVE2D that implements a
flexbox-based layout system. The goals of this project are two-fold: first,
anyone with basic CSS knowledge should be able to use this library with minimal
learning curve. Second, this library should take you from early prototyping to
production.
Features:
- Flexbox and Grid Layout systems
- Modern theming with 9-patch support
- Animations and transitions
- Image rendering with CSS-like object-fit
- Touch events and gesture recognition
- Text input with rich editing features
- Responsive design with viewport units
- Both immediate and retained rendering modes
Going this route, you will need to link the luarocks path to your project:
(for mac/linux)
```lua
package.path = package.path .. ";/Users/<username>/.luarocks/share/lua/<version>/?.lua"
package.path = package.path .. ";/Users/<username>/.luarocks/share/lua/<version>/?/init.lua"
package.cpath = package.cpath .. ";/Users/<username>/.luarocks/lib/lua/<version>/?.so"
```
]],
homepage = "https://mikefreno.github.io/FlexLove/",
license = "MIT",
maintainer = "Mike Freno",
}
dependencies = {
"lua >= 5.1",
"luautf8 >= 0.1.3",
}
build = {
type = "builtin",
modules = {
["FlexLove"] = "FlexLove.lua",
["FlexLove.modules.Animation"] = "modules/Animation.lua",
["FlexLove.modules.Blur"] = "modules/Blur.lua",
["FlexLove.modules.Calc"] = "modules/Calc.lua",
["FlexLove.modules.Color"] = "modules/Color.lua",
["FlexLove.modules.Context"] = "modules/Context.lua",
["FlexLove.modules.Element"] = "modules/Element.lua",
["FlexLove.modules.ErrorHandler"] = "modules/ErrorHandler.lua",
["FlexLove.modules.EventHandler"] = "modules/EventHandler.lua",
["FlexLove.modules.FFI"] = "modules/FFI.lua",
["FlexLove.modules.GestureRecognizer"] = "modules/GestureRecognizer.lua",
["FlexLove.modules.Grid"] = "modules/Grid.lua",
["FlexLove.modules.ImageCache"] = "modules/ImageCache.lua",
["FlexLove.modules.ImageRenderer"] = "modules/ImageRenderer.lua",
["FlexLove.modules.ImageScaler"] = "modules/ImageScaler.lua",
["FlexLove.modules.InputEvent"] = "modules/InputEvent.lua",
["FlexLove.modules.LayoutEngine"] = "modules/LayoutEngine.lua",
["FlexLove.modules.MemoryScanner"] = "modules/MemoryScanner.lua",
["FlexLove.modules.ModuleLoader"] = "modules/ModuleLoader.lua",
["FlexLove.modules.NinePatch"] = "modules/NinePatch.lua",
["FlexLove.modules.Performance"] = "modules/Performance.lua",
["FlexLove.modules.Renderer"] = "modules/Renderer.lua",
["FlexLove.modules.RoundedRect"] = "modules/RoundedRect.lua",
["FlexLove.modules.ScrollManager"] = "modules/ScrollManager.lua",
["FlexLove.modules.StateManager"] = "modules/StateManager.lua",
["FlexLove.modules.TextEditor"] = "modules/TextEditor.lua",
["FlexLove.modules.Theme"] = "modules/Theme.lua",
["FlexLove.modules.types"] = "modules/types.lua",
["FlexLove.modules.Units"] = "modules/Units.lua",
["FlexLove.modules.UTF8"] = "modules/UTF8.lua",
["FlexLove.modules.utils"] = "modules/utils.lua",
},
--copy_directories = {
--"docs",
--"examples",
--},
}

View File

@@ -1,85 +0,0 @@
package = "flexlove"
version = "0.8.0-1"
source = {
url = "git+https://github.com/mikefreno/FlexLove.git",
tag = "v0.8.0",
}
description = {
summary = "A comprehensive UI library providing flexbox/grid layouts, theming, animations, and event handling for LÖVE2D games",
detailed = [[
FlexLöve is a lightweight, flexible GUI library for LÖVE2D that implements a
flexbox-based layout system. The goals of this project are two-fold: first,
anyone with basic CSS knowledge should be able to use this library with minimal
learning curve. Second, this library should take you from early prototyping to
production.
Features:
- Flexbox and Grid Layout systems
- Modern theming with 9-patch support
- Animations and transitions
- Image rendering with CSS-like object-fit
- Touch events and gesture recognition
- Text input with rich editing features
- Responsive design with viewport units
- Both immediate and retained rendering modes
Going this route, you will need to link the luarocks path to your project:
(for mac/linux)
```lua
package.path = package.path .. ";/Users/<username>/.luarocks/share/lua/<version>/?.lua"
package.path = package.path .. ";/Users/<username>/.luarocks/share/lua/<version>/?/init.lua"
package.cpath = package.cpath .. ";/Users/<username>/.luarocks/lib/lua/<version>/?.so"
```
]],
homepage = "https://mikefreno.github.io/FlexLove/",
license = "MIT",
maintainer = "Mike Freno",
}
dependencies = {
"lua >= 5.1",
"luautf8 >= 0.1.3",
}
build = {
type = "builtin",
modules = {
["FlexLove"] = "FlexLove.lua",
["FlexLove.modules.Animation"] = "modules/Animation.lua",
["FlexLove.modules.Blur"] = "modules/Blur.lua",
["FlexLove.modules.Calc"] = "modules/Calc.lua",
["FlexLove.modules.Color"] = "modules/Color.lua",
["FlexLove.modules.Context"] = "modules/Context.lua",
["FlexLove.modules.Element"] = "modules/Element.lua",
["FlexLove.modules.ErrorHandler"] = "modules/ErrorHandler.lua",
["FlexLove.modules.EventHandler"] = "modules/EventHandler.lua",
["FlexLove.modules.FFI"] = "modules/FFI.lua",
["FlexLove.modules.GestureRecognizer"] = "modules/GestureRecognizer.lua",
["FlexLove.modules.Grid"] = "modules/Grid.lua",
["FlexLove.modules.ImageCache"] = "modules/ImageCache.lua",
["FlexLove.modules.ImageRenderer"] = "modules/ImageRenderer.lua",
["FlexLove.modules.ImageScaler"] = "modules/ImageScaler.lua",
["FlexLove.modules.InputEvent"] = "modules/InputEvent.lua",
["FlexLove.modules.LayoutEngine"] = "modules/LayoutEngine.lua",
["FlexLove.modules.MemoryScanner"] = "modules/MemoryScanner.lua",
["FlexLove.modules.ModuleLoader"] = "modules/ModuleLoader.lua",
["FlexLove.modules.NinePatch"] = "modules/NinePatch.lua",
["FlexLove.modules.Performance"] = "modules/Performance.lua",
["FlexLove.modules.Renderer"] = "modules/Renderer.lua",
["FlexLove.modules.RoundedRect"] = "modules/RoundedRect.lua",
["FlexLove.modules.ScrollManager"] = "modules/ScrollManager.lua",
["FlexLove.modules.StateManager"] = "modules/StateManager.lua",
["FlexLove.modules.TextEditor"] = "modules/TextEditor.lua",
["FlexLove.modules.Theme"] = "modules/Theme.lua",
["FlexLove.modules.types"] = "modules/types.lua",
["FlexLove.modules.Units"] = "modules/Units.lua",
["FlexLove.modules.UTF8"] = "modules/UTF8.lua",
["FlexLove.modules.utils"] = "modules/utils.lua",
},
--copy_directories = {
--"docs",
--"examples",
--},
}

View File

@@ -1232,6 +1232,25 @@ function Animation.keyframes(props)
}) })
end end
--- Link an array of animations into a chain (static helper)
--- Each animation's completion triggers the next in sequence
---@param animations Animation[] Array of animations to chain
---@return Animation first The first animation in the chain
function Animation.chainSequence(animations)
if type(animations) ~= "table" or #animations == 0 then
if Animation._ErrorHandler then
Animation._ErrorHandler:warn("Animation", "ANIM_004")
end
return Animation.new({ duration = 0, start = {}, final = {} })
end
for i = 1, #animations - 1 do
animations[i]:chain(animations[i + 1])
end
return animations[1]
end
-- ============================================================================ -- ============================================================================
-- ANIMATION GROUP (Utility) -- ANIMATION GROUP (Utility)
-- ============================================================================ -- ============================================================================

View File

@@ -23,6 +23,10 @@ local Context = {
initialized = false, initialized = false,
-- Debug draw overlay
_debugDraw = false,
_debugDrawKey = nil,
-- Initialization state tracking -- Initialization state tracking
---@type "uninitialized"|"initializing"|"ready" ---@type "uninitialized"|"initializing"|"ready"
_initState = "uninitialized", _initState = "uninitialized",

View File

@@ -159,6 +159,13 @@
---@field _pressed table? -- Internal: button press state tracking ---@field _pressed table? -- Internal: button press state tracking
---@field _mouseDownPosition number? -- Internal: mouse down position for drag tracking ---@field _mouseDownPosition number? -- Internal: mouse down position for drag tracking
---@field _textDragOccurred boolean? -- Internal: whether text drag occurred ---@field _textDragOccurred boolean? -- Internal: whether text drag occurred
---@field customDraw fun(element:Element)? -- Custom rendering callback called after standard rendering but before visual feedback (default: nil)
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Callback for touch-specific events
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent callback should be deferred (default: false)
---@field onGesture fun(element:Element, gesture:table)? -- Callback for recognized gestures
---@field onGestureDeferred boolean? -- Whether onGesture callback should be deferred (default: false)
---@field touchEnabled boolean -- Whether the element responds to touch events (default: true)
---@field multiTouchEnabled boolean -- Whether the element supports multiple simultaneous touches (default: false)
---@field animation table? -- Animation instance for this element ---@field animation table? -- Animation instance for this element
local Element = {} local Element = {}
Element.__index = Element Element.__index = Element
@@ -190,6 +197,7 @@ function Element.init(deps)
Element._StateManager = deps.StateManager Element._StateManager = deps.StateManager
Element._GestureRecognizer = deps.GestureRecognizer Element._GestureRecognizer = deps.GestureRecognizer
Element._Performance = deps.Performance Element._Performance = deps.Performance
Element._Animation = deps.Animation
end end
---@param props ElementProps ---@param props ElementProps
@@ -364,6 +372,14 @@ function Element.new(props)
self.customDraw = props.customDraw -- Custom rendering callback self.customDraw = props.customDraw -- Custom rendering callback
-- Touch event properties
self.onTouchEvent = props.onTouchEvent
self.onTouchEventDeferred = props.onTouchEventDeferred or false
self.onGesture = props.onGesture
self.onGestureDeferred = props.onGestureDeferred or false
self.touchEnabled = props.touchEnabled ~= false -- Default true
self.multiTouchEnabled = props.multiTouchEnabled or false -- Default false
-- Initialize state manager ID for immediate mode (use self.id which may be auto-generated) -- Initialize state manager ID for immediate mode (use self.id which may be auto-generated)
self._stateId = self.id self._stateId = self.id
@@ -371,6 +387,12 @@ function Element.new(props)
local eventHandlerConfig = { local eventHandlerConfig = {
onEvent = self.onEvent, onEvent = self.onEvent,
onEventDeferred = props.onEventDeferred, onEventDeferred = props.onEventDeferred,
onTouchEvent = self.onTouchEvent,
onTouchEventDeferred = self.onTouchEventDeferred,
onGesture = self.onGesture,
onGestureDeferred = self.onGestureDeferred,
touchEnabled = self.touchEnabled,
multiTouchEnabled = self.multiTouchEnabled,
} }
if self._elementMode == "immediate" and self._stateId and self._stateId ~= "" then if self._elementMode == "immediate" and self._stateId and self._stateId ~= "" then
local state = Element._StateManager.getState(self._stateId) local state = Element._StateManager.getState(self._stateId)
@@ -2018,6 +2040,26 @@ function Element.new(props)
self._dirty = false -- Element properties have changed, needs layout self._dirty = false -- Element properties have changed, needs layout
self._childrenDirty = false -- Children have changed, needs layout self._childrenDirty = false -- Children have changed, needs layout
-- Debug draw: assign a stable random color for element boundary visualization
-- Uses a vibrant HSL-based color to ensure good visibility against any background
local hue = math.random() * 360
local function hslToRgb(h)
local s, l = 0.9, 0.55
local c = (1 - math.abs(2 * l - 1)) * s
local x = c * (1 - math.abs((h / 60) % 2 - 1))
local m = l - c / 2
local r, g, b
if h < 60 then r, g, b = c, x, 0
elseif h < 120 then r, g, b = x, c, 0
elseif h < 180 then r, g, b = 0, c, x
elseif h < 240 then r, g, b = 0, x, c
elseif h < 300 then r, g, b = x, 0, c
else r, g, b = c, 0, x end
return r + m, g + m, b + m
end
local dr, dg, db = hslToRgb(hue)
self._debugColor = { dr, dg, db }
return self return self
end end
@@ -2581,6 +2623,10 @@ function Element:destroy()
-- Clear onEvent to prevent closure leaks -- Clear onEvent to prevent closure leaks
self.onEvent = nil self.onEvent = nil
-- Clear touch callbacks to prevent closure leaks
self.onTouchEvent = nil
self.onGesture = nil
end end
--- Draw element and its children --- Draw element and its children
@@ -3060,6 +3106,39 @@ function Element:update(dt)
end end
end end
--- Handle a touch event directly (for external touch routing)
--- Invokes both onEvent and onTouchEvent callbacks if set
---@param touchEvent InputEvent The touch event to handle
function Element:handleTouchEvent(touchEvent)
if not self.touchEnabled or self.disabled then
return
end
if self._eventHandler then
self._eventHandler:_invokeCallback(self, touchEvent)
self._eventHandler:_invokeTouchCallback(self, touchEvent)
end
end
--- Handle a gesture event (from GestureRecognizer or external routing)
---@param gesture table The gesture data (type, position, velocity, etc.)
function Element:handleGesture(gesture)
if not self.touchEnabled or self.disabled then
return
end
if self._eventHandler then
self._eventHandler:_invokeGestureCallback(self, gesture)
end
end
--- Get active touches currently tracked on this element
---@return table<string, table> Active touches keyed by touch ID
function Element:getTouches()
if self._eventHandler then
return self._eventHandler:getActiveTouches()
end
return {}
end
---@param newViewportWidth number ---@param newViewportWidth number
---@param newViewportHeight number ---@param newViewportHeight number
function Element:recalculateUnits(newViewportWidth, newViewportHeight) function Element:recalculateUnits(newViewportWidth, newViewportHeight)
@@ -3695,6 +3774,100 @@ function Element:setTransformOrigin(originX, originY)
self.transform.originY = originY self.transform.originY = originY
end end
--- Animate element to new property values with automatic transition
--- Captures current values as start, uses provided values as final, and applies the animation
---@param props table Target property values
---@param duration number? Animation duration in seconds (default: 0.3)
---@param easing string? Easing function name (default: "linear")
---@return Element self For method chaining
function Element:animateTo(props, duration, easing)
if not Element._Animation then
Element._ErrorHandler:warn("Element", "ELEM_003")
return self
end
if type(props) ~= "table" then
Element._ErrorHandler:warn("Element", "ELEM_003")
return self
end
duration = duration or 0.3
easing = easing or "linear"
-- Collect current values as start
local startValues = {}
for key, _ in pairs(props) do
startValues[key] = self[key]
end
-- Create and apply animation
local anim = Element._Animation.new({
duration = duration,
start = startValues,
final = props,
easing = easing,
})
anim:apply(self)
return self
end
--- Fade element to full opacity
---@param duration number? Duration in seconds (default: 0.3)
---@param easing string? Easing function name
---@return Element self For method chaining
function Element:fadeIn(duration, easing)
return self:animateTo({ opacity = 1 }, duration or 0.3, easing)
end
--- Fade element to zero opacity
---@param duration number? Duration in seconds (default: 0.3)
---@param easing string? Easing function name
---@return Element self For method chaining
function Element:fadeOut(duration, easing)
return self:animateTo({ opacity = 0 }, duration or 0.3, easing)
end
--- Scale element to target scale value using transforms
---@param targetScale number Target scale multiplier
---@param duration number? Duration in seconds (default: 0.3)
---@param easing string? Easing function name
---@return Element self For method chaining
function Element:scaleTo(targetScale, duration, easing)
if not Element._Animation or not Element._Transform then
Element._ErrorHandler:warn("Element", "ELEM_003")
return self
end
-- Ensure element has a transform
if not self.transform then
self.transform = Element._Transform.new({})
end
local currentScaleX = self.transform.scaleX or 1
local currentScaleY = self.transform.scaleY or 1
local anim = Element._Animation.new({
duration = duration or 0.3,
start = { scaleX = currentScaleX, scaleY = currentScaleY },
final = { scaleX = targetScale, scaleY = targetScale },
easing = easing or "linear",
})
anim:apply(self)
return self
end
--- Move element to target position
---@param x number Target x position
---@param y number Target y position
---@param duration number? Duration in seconds (default: 0.3)
---@param easing string? Easing function name
---@return Element self For method chaining
function Element:moveTo(x, y, duration, easing)
return self:animateTo({ x = x, y = y }, duration or 0.3, easing)
end
--- Set transition configuration for a property --- Set transition configuration for a property
---@param property string Property name or "all" for all properties ---@param property string Property name or "all" for all properties
---@param config table Transition config {duration, easing, delay, onComplete} ---@param config table Transition config {duration, easing, delay, onComplete}
@@ -3753,6 +3926,53 @@ function Element:removeTransition(property)
end end
end end
--- Resolve a unit-based dimension property (width/height) from a string or CalcObject
--- Parses the value, updates self.units, resolves to pixels, and updates border-box dimensions
---@param property string "width" or "height"
---@param value string|table The unit string (e.g., "50%", "10vw") or CalcObject
---@return number resolvedValue The resolved pixel value
function Element:_resolveDimensionProperty(property, value)
local viewportWidth, viewportHeight = Element._Units.getViewport()
local parsedValue, parsedUnit = Element._Units.parse(value)
self.units[property] = { value = parsedValue, unit = parsedUnit }
local parentDimension
if property == "width" then
parentDimension = self.parent and self.parent.width or viewportWidth
else
parentDimension = self.parent and self.parent.height or viewportHeight
end
local resolved = Element._Units.resolve(parsedValue, parsedUnit, viewportWidth, viewportHeight, parentDimension)
if type(resolved) ~= "number" then
Element._ErrorHandler:warn("Element", "LAY_003", {
issue = string.format("%s resolution returned non-number value", property),
type = type(resolved),
value = tostring(resolved),
})
resolved = 0
end
self[property] = resolved
if property == "width" then
if self.autosizing and self.autosizing.width then
self._borderBoxWidth = resolved + self.padding.left + self.padding.right
else
self._borderBoxWidth = resolved
end
else
if self.autosizing and self.autosizing.height then
self._borderBoxHeight = resolved + self.padding.top + self.padding.bottom
else
self._borderBoxHeight = resolved
end
end
return resolved
end
--- Set property with automatic transition --- Set property with automatic transition
---@param property string Property name ---@param property string Property name
---@param value any New value ---@param value any New value
@@ -3766,11 +3986,6 @@ function Element:setProperty(property, value)
shouldTransition = transitionConfig ~= nil shouldTransition = transitionConfig ~= nil
end end
-- Don't transition if value is the same
if self[property] == value then
return
end
-- Properties that affect layout and require invalidation -- Properties that affect layout and require invalidation
local layoutProperties = { local layoutProperties = {
width = true, width = true,
@@ -3792,6 +4007,50 @@ function Element:setProperty(property, value)
left = true, left = true,
} }
-- Dimension properties that accept unit strings and need resolution
local dimensionProperties = { width = true, height = true }
-- For dimension properties with unit strings, resolve to pixels
local isUnitValue = type(value) == "string" or (Element._Calc and Element._Calc.isCalc(value))
if dimensionProperties[property] and isUnitValue then
-- Check if the unit specification is the same (compare against stored units)
local currentUnits = self.units[property]
local newValue, newUnit = Element._Units.parse(value)
if currentUnits and currentUnits.value == newValue and currentUnits.unit == newUnit then
return
end
if shouldTransition and transitionConfig then
-- For transitions, resolve the target value and transition the pixel value
local currentPixelValue = self[property]
local resolvedTarget = self:_resolveDimensionProperty(property, value)
if currentPixelValue ~= nil and currentPixelValue ~= resolvedTarget then
-- Reset to current value before animating
self[property] = currentPixelValue
local Animation = require("modules.Animation")
local anim = Animation.new({
duration = transitionConfig.duration,
start = { [property] = currentPixelValue },
final = { [property] = resolvedTarget },
easing = transitionConfig.easing,
onComplete = transitionConfig.onComplete,
})
anim:apply(self)
end
else
self:_resolveDimensionProperty(property, value)
end
self:invalidateLayout()
return
end
-- Don't transition if value is the same
if self[property] == value then
return
end
if shouldTransition and transitionConfig then if shouldTransition and transitionConfig then
local currentValue = self[property] local currentValue = self[property]
@@ -3950,6 +4209,8 @@ function Element:_cleanup()
self.onEnter = nil self.onEnter = nil
self.onImageLoad = nil self.onImageLoad = nil
self.onImageError = nil self.onImageError = nil
self.onTouchEvent = nil
self.onGesture = nil
end end
return Element return Element

View File

@@ -1,6 +1,12 @@
---@class EventHandler ---@class EventHandler
---@field onEvent fun(element:Element, event:InputEvent)? ---@field onEvent fun(element:Element, event:InputEvent)?
---@field onEventDeferred boolean? ---@field onEventDeferred boolean?
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Touch-specific callback
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent is deferred
---@field onGesture fun(element:Element, gesture:table)? -- Gesture callback
---@field onGestureDeferred boolean? -- Whether onGesture is deferred
---@field touchEnabled boolean -- Whether touch events are processed (default: true)
---@field multiTouchEnabled boolean -- Whether multi-touch is supported (default: false)
---@field _pressed table<number, boolean> ---@field _pressed table<number, boolean>
---@field _lastClickTime number? ---@field _lastClickTime number?
---@field _lastClickButton number? ---@field _lastClickButton number?
@@ -39,6 +45,12 @@ function EventHandler.new(config)
self.onEvent = config.onEvent self.onEvent = config.onEvent
self.onEventDeferred = config.onEventDeferred self.onEventDeferred = config.onEventDeferred
self.onTouchEvent = config.onTouchEvent
self.onTouchEventDeferred = config.onTouchEventDeferred or false
self.onGesture = config.onGesture
self.onGestureDeferred = config.onGestureDeferred or false
self.touchEnabled = config.touchEnabled ~= false -- Default true
self.multiTouchEnabled = config.multiTouchEnabled or false -- Default false
self._pressed = config._pressed or {} self._pressed = config._pressed or {}
@@ -462,7 +474,7 @@ function EventHandler:processTouchEvents(element)
local activeTouchIds = {} local activeTouchIds = {}
-- Check if element can process events -- Check if element can process events
local canProcessEvents = (self.onEvent or element.editable) and not element.disabled local canProcessEvents = (self.onEvent or self.onTouchEvent or element.editable) and not element.disabled and self.touchEnabled
if not canProcessEvents then if not canProcessEvents then
if EventHandler._Performance and EventHandler._Performance.enabled then if EventHandler._Performance and EventHandler._Performance.enabled then
@@ -483,6 +495,12 @@ function EventHandler:processTouchEvents(element)
activeTouches[tostring(id)] = true activeTouches[tostring(id)] = true
end end
-- Count active tracked touches for multi-touch filtering
local trackedTouchCount = 0
for _ in pairs(self._touches) do
trackedTouchCount = trackedTouchCount + 1
end
-- Process active touches -- Process active touches
for _, id in ipairs(touches) do for _, id in ipairs(touches) do
local touchId = tostring(id) local touchId = tostring(id)
@@ -494,8 +512,15 @@ function EventHandler:processTouchEvents(element)
if isInside then if isInside then
if not self._touches[touchId] then if not self._touches[touchId] then
-- Multi-touch filtering: reject new touches when multiTouchEnabled=false
-- and we already have an active touch
if not self.multiTouchEnabled and trackedTouchCount > 0 then
-- Skip this new touch (single-touch mode, already tracking one)
else
-- New touch began -- New touch began
self:_handleTouchBegan(element, touchId, tx, ty, pressure) self:_handleTouchBegan(element, touchId, tx, ty, pressure)
trackedTouchCount = trackedTouchCount + 1
end
else else
-- Touch moved -- Touch moved
self:_handleTouchMoved(element, touchId, tx, ty, pressure) self:_handleTouchMoved(element, touchId, tx, ty, pressure)
@@ -561,6 +586,7 @@ function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure)
touchEvent.dx = 0 touchEvent.dx = 0
touchEvent.dy = 0 touchEvent.dy = 0
self:_invokeCallback(element, touchEvent) self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
end end
--- Handle touch moved event --- Handle touch moved event
@@ -607,6 +633,7 @@ function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure)
touchEvent.dx = dx touchEvent.dx = dx
touchEvent.dy = dy touchEvent.dy = dy
self:_invokeCallback(element, touchEvent) self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
end end
end end
@@ -634,6 +661,7 @@ function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure)
touchEvent.dx = dx touchEvent.dx = dx
touchEvent.dy = dy touchEvent.dy = dy
self:_invokeCallback(element, touchEvent) self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
-- Cleanup touch state -- Cleanup touch state
self:_cleanupTouch(touchId) self:_cleanupTouch(touchId)
@@ -709,4 +737,52 @@ function EventHandler:_invokeCallback(element, event)
end end
end end
--- Invoke the onTouchEvent callback, optionally deferring it
---@param element Element The element that triggered the event
---@param event InputEvent The touch event data
function EventHandler:_invokeTouchCallback(element, event)
if not self.onTouchEvent then
return
end
if self.onTouchEventDeferred then
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
self.onTouchEvent(element, event)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
eventType = event.type,
})
end
else
self.onTouchEvent(element, event)
end
end
--- Invoke the onGesture callback, optionally deferring it
---@param element Element The element that triggered the event
---@param gesture table The gesture data from GestureRecognizer
function EventHandler:_invokeGestureCallback(element, gesture)
if not self.onGesture then
return
end
if self.onGestureDeferred then
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
self.onGesture(element, gesture)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
gestureType = gesture.type,
})
end
else
self.onGesture(element, gesture)
end
end
return EventHandler return EventHandler

View File

@@ -92,10 +92,11 @@ end
---@param event InputEvent Touch event ---@param event InputEvent Touch event
function GestureRecognizer:processTouchEvent(event) function GestureRecognizer:processTouchEvent(event)
if not event.touchId then if not event.touchId then
return return nil
end end
local touchId = event.touchId local touchId = event.touchId
local gestures = {}
-- Update touch state -- Update touch state
if event.type == "touchpress" then if event.type == "touchpress" then
@@ -122,13 +123,17 @@ function GestureRecognizer:processTouchEvent(event)
touch.phase = "moved" touch.phase = "moved"
-- Update gesture detection -- Update gesture detection
self:_detectPan(touchId, event) local panGesture = self:_detectPan(touchId, event)
self:_detectSwipe(touchId, event) if panGesture then table.insert(gestures, panGesture) end
local swipeGesture = self:_detectSwipe(touchId, event)
if swipeGesture then table.insert(gestures, swipeGesture) end
-- Multi-touch gestures -- Multi-touch gestures
if self:_getTouchCount() >= 2 then if self:_getTouchCount() >= 2 then
self:_detectPinch(event) local pinchGesture = self:_detectPinch(event)
self:_detectRotate(event) if pinchGesture then table.insert(gestures, pinchGesture) end
local rotateGesture = self:_detectRotate(event)
if rotateGesture then table.insert(gestures, rotateGesture) end
end end
end end
@@ -138,9 +143,12 @@ function GestureRecognizer:processTouchEvent(event)
touch.phase = "ended" touch.phase = "ended"
-- Finalize gesture detection -- Finalize gesture detection
self:_detectTapEnded(touchId, event) local tapGesture = self:_detectTapEnded(touchId, event)
self:_detectSwipeEnded(touchId, event) if tapGesture then table.insert(gestures, tapGesture) end
self:_detectPanEnded(touchId, event) local swipeGesture = self:_detectSwipeEnded(touchId, event)
if swipeGesture then table.insert(gestures, swipeGesture) end
local panGesture = self:_detectPanEnded(touchId, event)
if panGesture then table.insert(gestures, panGesture) end
-- Cleanup touch -- Cleanup touch
self._touches[touchId] = nil self._touches[touchId] = nil
@@ -151,6 +159,8 @@ function GestureRecognizer:processTouchEvent(event)
self._touches[touchId] = nil self._touches[touchId] = nil
self:_cancelAllGestures() self:_cancelAllGestures()
end end
return #gestures > 0 and gestures or nil
end end
--- Get number of active touches --- Get number of active touches

View File

@@ -108,6 +108,8 @@ function LayoutEngine.new(props, deps)
childrenCount = 0, childrenCount = 0,
containerWidth = 0, containerWidth = 0,
containerHeight = 0, containerHeight = 0,
containerX = 0,
containerY = 0,
childrenHash = "", childrenHash = "",
} }
@@ -1180,6 +1182,10 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
end end
-- Recalculate position if using viewport or percentage units -- Recalculate position if using viewport or percentage units
-- Skip position recalculation for flex children (non-explicitly-absolute children with a parent)
-- Their x/y is entirely controlled by the parent's layoutChildren() call
local isFlexChild = self.element.parent and not self.element._explicitlyAbsolute
if not isFlexChild then
if self.element.units.x.unit ~= "px" then if self.element.units.x.unit ~= "px" then
local parentWidth = self.element.parent and self.element.parent.width or newViewportWidth local parentWidth = self.element.parent and self.element.parent.width or newViewportWidth
local baseX = self.element.parent and self.element.parent.x or 0 local baseX = self.element.parent and self.element.parent.x or 0
@@ -1227,6 +1233,7 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
self.element.y = self.element.units.y.value * scaleY self.element.y = self.element.units.y.value * scaleY
end end
end end
end
-- Recalculate textSize if auto-scaling is enabled or using viewport/element-relative units -- Recalculate textSize if auto-scaling is enabled or using viewport/element-relative units
if self.element.autoScaleText and self.element.units.textSize.value then if self.element.autoScaleText and self.element.units.textSize.value then
@@ -1504,6 +1511,8 @@ function LayoutEngine:_canSkipLayout()
local childrenCount = #self.element.children local childrenCount = #self.element.children
local containerWidth = self.element.width local containerWidth = self.element.width
local containerHeight = self.element.height local containerHeight = self.element.height
local containerX = self.element.x
local containerY = self.element.y
-- Generate simple hash of children dimensions -- Generate simple hash of children dimensions
local childrenHash = "" local childrenHash = ""
@@ -1520,6 +1529,8 @@ function LayoutEngine:_canSkipLayout()
cache.childrenCount == childrenCount cache.childrenCount == childrenCount
and cache.containerWidth == containerWidth and cache.containerWidth == containerWidth
and cache.containerHeight == containerHeight and cache.containerHeight == containerHeight
and cache.containerX == containerX
and cache.containerY == containerY
and cache.childrenHash == childrenHash and cache.childrenHash == childrenHash
then then
return true -- Layout hasn't changed, can skip return true -- Layout hasn't changed, can skip
@@ -1529,6 +1540,8 @@ function LayoutEngine:_canSkipLayout()
cache.childrenCount = childrenCount cache.childrenCount = childrenCount
cache.containerWidth = containerWidth cache.containerWidth = containerWidth
cache.containerHeight = containerHeight cache.containerHeight = containerHeight
cache.containerX = containerX
cache.containerY = containerY
cache.childrenHash = childrenHash cache.childrenHash = childrenHash
return false -- Layout has changed, must recalculate return false -- Layout has changed, must recalculate

View File

@@ -139,9 +139,9 @@ function Performance:stopTimer(name)
-- Check for warnings -- Check for warnings
if elapsed > self.criticalThresholdMs then if elapsed > self.criticalThresholdMs then
self:addWarning(name, elapsed, "critical") self:_addWarning(name, elapsed, "critical")
elseif elapsed > self.warningThresholdMs then elseif elapsed > self.warningThresholdMs then
self:addWarning(name, elapsed, "warning") self:_addWarning(name, elapsed, "warning")
end end
if self.logToConsole then if self.logToConsole then

View File

@@ -85,8 +85,15 @@ local AnimationProps = {}
---@field onTextChangeDeferred boolean? -- Whether onTextChange callback should be deferred (default: false) ---@field onTextChangeDeferred boolean? -- Whether onTextChange callback should be deferred (default: false)
---@field onEnter fun(element:Element)? -- Callback when Enter key is pressed ---@field onEnter fun(element:Element)? -- Callback when Enter key is pressed
---@field onEnterDeferred boolean? -- Whether onEnter callback should be deferred (default: false) ---@field onEnterDeferred boolean? -- Whether onEnter callback should be deferred (default: false)
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Callback for touch-specific events (touchpress, touchmove, touchrelease)
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent callback should be deferred (default: false)
---@field onGesture fun(element:Element, gesture:table)? -- Callback for recognized gestures (tap, swipe, pinch, etc.)
---@field onGestureDeferred boolean? -- Whether onGesture callback should be deferred (default: false)
---@field touchEnabled boolean? -- Whether the element responds to touch events (default: true)
---@field multiTouchEnabled boolean? -- Whether the element supports multiple simultaneous touches (default: false)
---@field transform TransformProps? -- Transform properties for animations and styling ---@field transform TransformProps? -- Transform properties for animations and styling
---@field transition TransitionProps? -- Transition settings for animations ---@field transition TransitionProps? -- Transition settings for animations
---@field customDraw fun(element:Element)? -- Custom rendering callback called after standard rendering but before visual feedback (default: nil)
---@field gridRows number? -- Number of rows in the grid (default: 1) ---@field gridRows number? -- Number of rows in the grid (default: 1)
---@field gridColumns number? -- Number of columns in the grid (default: 1) ---@field gridColumns number? -- Number of columns in the grid (default: 1)
---@field columnGap number|string|CalcObject? -- Gap between grid columns: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0) ---@field columnGap number|string|CalcObject? -- Gap between grid columns: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0)
@@ -198,6 +205,8 @@ local TransformProps
---@field gcInterval number? -- Frames between GC steps in periodic mode (default: 60) ---@field gcInterval number? -- Frames between GC steps in periodic mode (default: 60)
---@field gcStepSize number? -- Work units per GC step, higher = more aggressive (default: 200) ---@field gcStepSize number? -- Work units per GC step, higher = more aggressive (default: 200)
---@field immediateModeBlurOptimizations boolean? -- Cache blur canvases in immediate mode to avoid re-rendering each frame (default: true) ---@field immediateModeBlurOptimizations boolean? -- Cache blur canvases in immediate mode to avoid re-rendering each frame (default: true)
---@field debugDraw boolean? -- Enable debug draw overlay showing element boundaries with random colors (default: false)
---@field debugDrawKey string? -- Key to toggle debug draw overlay at runtime (default: nil, no toggle key)
local FlexLoveConfig = {} local FlexLoveConfig = {}
--=====================================-- --=====================================--

View File

@@ -0,0 +1,531 @@
package.path = package.path .. ";./?.lua;./modules/?.lua"
local originalSearchers = package.searchers or package.loaders
table.insert(originalSearchers, 2, function(modname)
if modname:match("^FlexLove%.modules%.") then
local moduleName = modname:gsub("^FlexLove%.modules%.", "")
return function()
return require("modules." .. moduleName)
end
end
end)
require("testing.loveStub")
local luaunit = require("testing.luaunit")
local FlexLove = require("FlexLove")
FlexLove.init()
local Animation = FlexLove.Animation
-- Helper: create a simple animation
local function makeAnim(duration, startX, finalX)
return Animation.new({
duration = duration or 1,
start = { x = startX or 0 },
final = { x = finalX or 100 },
})
end
-- Helper: create a retained-mode test element
local function makeElement(props)
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
local el = FlexLove.new(props or { width = 100, height = 100 })
FlexLove.endFrame()
return el
end
-- ============================================================================
-- Test Suite: Animation Instance Chaining
-- ============================================================================
TestAnimationChaining = {}
function TestAnimationChaining:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestAnimationChaining:tearDown()
FlexLove.endFrame()
end
function TestAnimationChaining:test_chain_links_two_animations()
local anim1 = makeAnim(0.5, 0, 50)
local anim2 = makeAnim(0.5, 50, 100)
local returned = anim1:chain(anim2)
luaunit.assertEquals(anim1._next, anim2)
luaunit.assertEquals(returned, anim2)
end
function TestAnimationChaining:test_chain_with_factory_function()
local factory = function(element)
return makeAnim(0.5, 0, 100)
end
local anim1 = makeAnim(0.5)
local returned = anim1:chain(factory)
luaunit.assertEquals(anim1._nextFactory, factory)
luaunit.assertEquals(returned, anim1) -- returns self when factory
end
function TestAnimationChaining:test_chained_animations_execute_in_order()
local el = makeElement({ width = 100, height = 100, opacity = 1 })
local order = {}
local anim1 = Animation.new({
duration = 0.2,
start = { x = 0 },
final = { x = 50 },
onComplete = function() table.insert(order, 1) end,
})
local anim2 = Animation.new({
duration = 0.2,
start = { x = 50 },
final = { x = 100 },
onComplete = function() table.insert(order, 2) end,
})
anim1:chain(anim2)
anim1:apply(el)
-- Run anim1 to completion
for i = 1, 20 do
el:update(1 / 60)
end
-- anim1 should be done, anim2 should now be the active animation
luaunit.assertEquals(order[1], 1)
luaunit.assertEquals(el.animation, anim2)
-- Run anim2 to completion
for i = 1, 20 do
el:update(1 / 60)
end
luaunit.assertEquals(order[2], 2)
luaunit.assertNil(el.animation)
end
function TestAnimationChaining:test_chain_with_factory_creates_dynamic_animation()
local el = makeElement({ width = 100, height = 100 })
el.x = 0
local anim1 = Animation.new({
duration = 0.1,
start = { x = 0 },
final = { x = 50 },
})
local factoryCalled = false
anim1:chain(function(element)
factoryCalled = true
return Animation.new({
duration = 1.0,
start = { x = 50 },
final = { x = 200 },
})
end)
anim1:apply(el)
-- Run anim1 to completion (0.1s duration, ~7 frames at 1/60)
for i = 1, 10 do
el:update(1 / 60)
end
luaunit.assertTrue(factoryCalled)
luaunit.assertNotNil(el.animation) -- Should have the factory-created animation (1s duration)
end
-- ============================================================================
-- Test Suite: Animation delay()
-- ============================================================================
TestAnimationDelay = {}
function TestAnimationDelay:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestAnimationDelay:tearDown()
FlexLove.endFrame()
end
function TestAnimationDelay:test_delay_delays_animation_start()
local anim = makeAnim(0.5)
anim:delay(0.3)
-- During delay period, animation should not progress
local finished = anim:update(0.2)
luaunit.assertFalse(finished)
luaunit.assertEquals(anim.elapsed, 0)
-- Still in delay (0.2 + 0.15 = 0.35 total delay elapsed, but the second
-- call starts with _delayElapsed=0.2 < 0.3, so it adds 0.15 and returns false)
finished = anim:update(0.15)
luaunit.assertFalse(finished)
luaunit.assertEquals(anim.elapsed, 0)
-- Now delay is past (0.35 >= 0.3), animation should start progressing
anim:update(0.1)
luaunit.assertTrue(anim.elapsed > 0)
end
function TestAnimationDelay:test_delay_returns_self()
local anim = makeAnim(1)
local returned = anim:delay(0.5)
luaunit.assertEquals(returned, anim)
end
function TestAnimationDelay:test_delay_with_invalid_value_defaults_to_zero()
local anim = makeAnim(0.5)
anim:delay(-1)
luaunit.assertEquals(anim._delay, 0)
local anim2 = makeAnim(0.5)
anim2:delay("bad")
luaunit.assertEquals(anim2._delay, 0)
end
-- ============================================================================
-- Test Suite: Animation repeatCount()
-- ============================================================================
TestAnimationRepeat = {}
function TestAnimationRepeat:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestAnimationRepeat:tearDown()
FlexLove.endFrame()
end
function TestAnimationRepeat:test_repeat_n_times()
local anim = Animation.new({
duration = 0.2,
start = { x = 0 },
final = { x = 100 },
})
anim:repeatCount(3)
local completions = 0
-- Run through multiple cycles
for i = 1, 300 do
local finished = anim:update(1 / 60)
if anim.elapsed == 0 or finished then
completions = completions + 1
end
if finished then
break
end
end
luaunit.assertEquals(anim:getState(), "completed")
end
function TestAnimationRepeat:test_repeat_returns_self()
local anim = makeAnim(1)
local returned = anim:repeatCount(3)
luaunit.assertEquals(returned, anim)
end
-- ============================================================================
-- Test Suite: Animation yoyo()
-- ============================================================================
TestAnimationYoyo = {}
function TestAnimationYoyo:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestAnimationYoyo:tearDown()
FlexLove.endFrame()
end
function TestAnimationYoyo:test_yoyo_reverses_on_repeat()
local anim = Animation.new({
duration = 0.2,
start = { x = 0 },
final = { x = 100 },
})
anim:repeatCount(2):yoyo(true)
-- First cycle
for i = 1, 15 do
anim:update(1 / 60)
end
-- After first cycle completes, it should be reversed
luaunit.assertTrue(anim._reversed)
end
function TestAnimationYoyo:test_yoyo_returns_self()
local anim = makeAnim(1)
local returned = anim:yoyo(true)
luaunit.assertEquals(returned, anim)
end
function TestAnimationYoyo:test_yoyo_default_true()
local anim = makeAnim(1)
anim:yoyo()
luaunit.assertTrue(anim._yoyo)
end
function TestAnimationYoyo:test_yoyo_false_disables()
local anim = makeAnim(1)
anim:yoyo(false)
luaunit.assertFalse(anim._yoyo)
end
-- ============================================================================
-- Test Suite: Animation.chainSequence() static helper
-- ============================================================================
TestAnimationChainSequence = {}
function TestAnimationChainSequence:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestAnimationChainSequence:tearDown()
FlexLove.endFrame()
end
function TestAnimationChainSequence:test_chainSequence_links_all_animations()
local a1 = makeAnim(0.2, 0, 50)
local a2 = makeAnim(0.2, 50, 100)
local a3 = makeAnim(0.2, 100, 150)
local first = Animation.chainSequence({ a1, a2, a3 })
luaunit.assertEquals(first, a1)
luaunit.assertEquals(a1._next, a2)
luaunit.assertEquals(a2._next, a3)
end
function TestAnimationChainSequence:test_chainSequence_single_animation()
local a1 = makeAnim(0.2)
local first = Animation.chainSequence({ a1 })
luaunit.assertEquals(first, a1)
luaunit.assertNil(a1._next)
end
function TestAnimationChainSequence:test_chainSequence_empty_array()
local first = Animation.chainSequence({})
luaunit.assertNotNil(first) -- should return a fallback animation
end
-- ============================================================================
-- Test Suite: Element Fluent API
-- ============================================================================
TestElementFluentAPI = {}
function TestElementFluentAPI:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestElementFluentAPI:tearDown()
FlexLove.endFrame()
end
function TestElementFluentAPI:test_animateTo_creates_animation()
local el = FlexLove.new({ width = 100, height = 100 })
el.opacity = 0.5
local returned = el:animateTo({ opacity = 1 }, 0.5, "easeOutQuad")
luaunit.assertEquals(returned, el) -- returns self
luaunit.assertNotNil(el.animation)
luaunit.assertEquals(el.animation.duration, 0.5)
luaunit.assertEquals(el.animation.start.opacity, 0.5)
luaunit.assertEquals(el.animation.final.opacity, 1)
end
function TestElementFluentAPI:test_animateTo_with_defaults()
local el = FlexLove.new({ width = 100, height = 100 })
el.x = 10
el:animateTo({ x = 200 })
luaunit.assertNotNil(el.animation)
luaunit.assertEquals(el.animation.duration, 0.3) -- default
end
function TestElementFluentAPI:test_fadeIn_sets_opacity_target_to_1()
local el = FlexLove.new({ width = 100, height = 100 })
el.opacity = 0
local returned = el:fadeIn(0.5)
luaunit.assertEquals(returned, el)
luaunit.assertNotNil(el.animation)
luaunit.assertEquals(el.animation.start.opacity, 0)
luaunit.assertEquals(el.animation.final.opacity, 1)
end
function TestElementFluentAPI:test_fadeIn_default_duration()
local el = FlexLove.new({ width = 100, height = 100 })
el.opacity = 0
el:fadeIn()
luaunit.assertEquals(el.animation.duration, 0.3)
end
function TestElementFluentAPI:test_fadeOut_sets_opacity_target_to_0()
local el = FlexLove.new({ width = 100, height = 100 })
el.opacity = 1
local returned = el:fadeOut(0.5)
luaunit.assertEquals(returned, el)
luaunit.assertNotNil(el.animation)
luaunit.assertEquals(el.animation.start.opacity, 1)
luaunit.assertEquals(el.animation.final.opacity, 0)
end
function TestElementFluentAPI:test_fadeOut_default_duration()
local el = FlexLove.new({ width = 100, height = 100 })
el:fadeOut()
luaunit.assertEquals(el.animation.duration, 0.3)
end
function TestElementFluentAPI:test_scaleTo_creates_scale_animation()
local el = FlexLove.new({ width = 100, height = 100 })
local returned = el:scaleTo(2.0, 0.5)
luaunit.assertEquals(returned, el)
luaunit.assertNotNil(el.animation)
luaunit.assertEquals(el.animation.final.scaleX, 2.0)
luaunit.assertEquals(el.animation.final.scaleY, 2.0)
end
function TestElementFluentAPI:test_scaleTo_default_duration()
local el = FlexLove.new({ width = 100, height = 100 })
el:scaleTo(1.5)
luaunit.assertEquals(el.animation.duration, 0.3)
end
function TestElementFluentAPI:test_scaleTo_initializes_transform()
local el = FlexLove.new({ width = 100, height = 100 })
-- Should not have a transform yet (or it has one from constructor)
el:scaleTo(2.0)
luaunit.assertNotNil(el.transform)
end
function TestElementFluentAPI:test_moveTo_creates_position_animation()
local el = FlexLove.new({ width = 100, height = 100 })
el.x = 0
el.y = 0
local returned = el:moveTo(200, 300, 0.5, "easeInOutCubic")
luaunit.assertEquals(returned, el)
luaunit.assertNotNil(el.animation)
luaunit.assertEquals(el.animation.start.x, 0)
luaunit.assertEquals(el.animation.start.y, 0)
luaunit.assertEquals(el.animation.final.x, 200)
luaunit.assertEquals(el.animation.final.y, 300)
end
function TestElementFluentAPI:test_moveTo_default_duration()
local el = FlexLove.new({ width = 100, height = 100 })
el:moveTo(100, 100)
luaunit.assertEquals(el.animation.duration, 0.3)
end
function TestElementFluentAPI:test_animateTo_with_invalid_props_returns_self()
local el = FlexLove.new({ width = 100, height = 100 })
local returned = el:animateTo("invalid")
luaunit.assertEquals(returned, el)
luaunit.assertNil(el.animation)
end
-- ============================================================================
-- Test Suite: Integration - Chaining with Fluent API
-- ============================================================================
TestAnimationChainingIntegration = {}
function TestAnimationChainingIntegration:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestAnimationChainingIntegration:tearDown()
FlexLove.endFrame()
end
function TestAnimationChainingIntegration:test_chained_delay_and_repeat()
local anim = Animation.new({
duration = 0.2,
start = { x = 0 },
final = { x = 100 },
})
local chained = anim:delay(0.1):repeatCount(2):yoyo(true)
luaunit.assertEquals(chained, anim)
luaunit.assertEquals(anim._delay, 0.1)
luaunit.assertEquals(anim._repeatCount, 2)
luaunit.assertTrue(anim._yoyo)
end
function TestAnimationChainingIntegration:test_complex_chain_executes_fully()
local el = makeElement({ width = 100, height = 100, opacity = 1 })
local a1 = Animation.new({
duration = 0.1,
start = { opacity = 1 },
final = { opacity = 0 },
})
local a2 = Animation.new({
duration = 0.1,
start = { opacity = 0 },
final = { opacity = 1 },
})
local a3 = Animation.new({
duration = 0.1,
start = { opacity = 1 },
final = { opacity = 0.5 },
})
Animation.chainSequence({ a1, a2, a3 })
a1:apply(el)
-- Run all three animations
for i = 1, 100 do
el:update(1 / 60)
if not el.animation then
break
end
end
-- All should have completed, no animation left
luaunit.assertNil(el.animation)
end
-- Run all tests
if not _G.RUNNING_ALL_TESTS then
os.exit(luaunit.LuaUnit.run())
end

View File

@@ -0,0 +1,853 @@
package.path = package.path .. ";./?.lua;./modules/?.lua"
local originalSearchers = package.searchers or package.loaders
table.insert(originalSearchers, 2, function(modname)
if modname:match("^FlexLove%.modules%.") then
local moduleName = modname:gsub("^FlexLove%.modules%.", "")
return function()
return require("modules." .. moduleName)
end
end
end)
require("testing.loveStub")
local luaunit = require("testing.luaunit")
local FlexLove = require("FlexLove")
FlexLove.init()
local Animation = FlexLove.Animation
local AnimationGroup = Animation.Group
-- Helper: create a simple animation with given duration
local function makeAnim(duration, startVal, finalVal)
return Animation.new({
duration = duration or 1,
start = { x = startVal or 0 },
final = { x = finalVal or 100 },
})
end
-- Helper: advance an animation group to completion
local function runToCompletion(group, dt)
dt = dt or 1 / 60
local maxFrames = 10000
for i = 1, maxFrames do
if group:update(dt) then
return i
end
end
return maxFrames
end
-- ============================================================================
-- Test Suite: AnimationGroup Construction
-- ============================================================================
TestAnimationGroupConstruction = {}
function TestAnimationGroupConstruction:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestAnimationGroupConstruction:tearDown()
FlexLove.endFrame()
end
function TestAnimationGroupConstruction:test_new_creates_group_with_defaults()
local anim1 = makeAnim(1)
local anim2 = makeAnim(1)
local group = AnimationGroup.new({ animations = { anim1, anim2 } })
luaunit.assertNotNil(group)
luaunit.assertEquals(group.mode, "parallel")
luaunit.assertEquals(group.stagger, 0.1)
luaunit.assertEquals(#group.animations, 2)
luaunit.assertEquals(group:getState(), "ready")
end
function TestAnimationGroupConstruction:test_new_with_sequence_mode()
local group = AnimationGroup.new({
animations = { makeAnim(1) },
mode = "sequence",
})
luaunit.assertEquals(group.mode, "sequence")
end
function TestAnimationGroupConstruction:test_new_with_stagger_mode()
local group = AnimationGroup.new({
animations = { makeAnim(1) },
mode = "stagger",
stagger = 0.2,
})
luaunit.assertEquals(group.mode, "stagger")
luaunit.assertEquals(group.stagger, 0.2)
end
function TestAnimationGroupConstruction:test_new_with_invalid_mode_defaults_to_parallel()
local group = AnimationGroup.new({
animations = { makeAnim(1) },
mode = "invalid",
})
luaunit.assertEquals(group.mode, "parallel")
end
function TestAnimationGroupConstruction:test_new_with_nil_props_does_not_error()
local group = AnimationGroup.new(nil)
luaunit.assertNotNil(group)
luaunit.assertEquals(#group.animations, 0)
end
function TestAnimationGroupConstruction:test_new_with_empty_animations()
local group = AnimationGroup.new({ animations = {} })
luaunit.assertNotNil(group)
luaunit.assertEquals(#group.animations, 0)
end
function TestAnimationGroupConstruction:test_new_with_callbacks()
local onStart = function() end
local onComplete = function() end
local group = AnimationGroup.new({
animations = { makeAnim(1) },
onStart = onStart,
onComplete = onComplete,
})
luaunit.assertEquals(group.onStart, onStart)
luaunit.assertEquals(group.onComplete, onComplete)
end
-- ============================================================================
-- Test Suite: Parallel Mode
-- ============================================================================
TestAnimationGroupParallel = {}
function TestAnimationGroupParallel:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestAnimationGroupParallel:tearDown()
FlexLove.endFrame()
end
function TestAnimationGroupParallel:test_parallel_runs_all_animations_simultaneously()
local anim1 = makeAnim(1)
local anim2 = makeAnim(1)
local group = AnimationGroup.new({
mode = "parallel",
animations = { anim1, anim2 },
})
group:update(0.5)
-- Both animations should have progressed
luaunit.assertTrue(anim1.elapsed > 0)
luaunit.assertTrue(anim2.elapsed > 0)
end
function TestAnimationGroupParallel:test_parallel_completes_when_all_finish()
local anim1 = makeAnim(0.5)
local anim2 = makeAnim(1.0)
local group = AnimationGroup.new({
mode = "parallel",
animations = { anim1, anim2 },
})
-- After 0.6s: anim1 done, anim2 not done
local finished = group:update(0.6)
luaunit.assertFalse(finished)
luaunit.assertEquals(group:getState(), "playing")
-- After another 0.5s: both done
finished = group:update(0.5)
luaunit.assertTrue(finished)
luaunit.assertEquals(group:getState(), "completed")
end
function TestAnimationGroupParallel:test_parallel_uses_max_duration()
local anim1 = makeAnim(0.3)
local anim2 = makeAnim(0.5)
local anim3 = makeAnim(0.8)
local group = AnimationGroup.new({
mode = "parallel",
animations = { anim1, anim2, anim3 },
})
-- At 0.5s, anim3 is not yet done
local finished = group:update(0.5)
luaunit.assertFalse(finished)
-- At 0.9s total, all should be done
finished = group:update(0.4)
luaunit.assertTrue(finished)
end
function TestAnimationGroupParallel:test_parallel_does_not_update_completed_animations()
local anim1 = makeAnim(0.2)
local anim2 = makeAnim(1.0)
local group = AnimationGroup.new({
mode = "parallel",
animations = { anim1, anim2 },
})
-- Run past anim1's completion
group:update(0.3)
local anim1Elapsed = anim1.elapsed
-- Update again - anim1 should not be updated further
group:update(0.1)
-- anim1 is completed, so its elapsed might stay clamped
luaunit.assertEquals(anim1:getState(), "completed")
end
-- ============================================================================
-- Test Suite: Sequence Mode
-- ============================================================================
TestAnimationGroupSequence = {}
function TestAnimationGroupSequence:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestAnimationGroupSequence:tearDown()
FlexLove.endFrame()
end
function TestAnimationGroupSequence:test_sequence_runs_one_at_a_time()
local anim1 = makeAnim(0.5)
local anim2 = makeAnim(0.5)
local group = AnimationGroup.new({
mode = "sequence",
animations = { anim1, anim2 },
})
-- After 0.3s, only anim1 should have progressed
group:update(0.3)
luaunit.assertTrue(anim1.elapsed > 0)
luaunit.assertEquals(anim2.elapsed, 0) -- anim2 hasn't started
end
function TestAnimationGroupSequence:test_sequence_advances_to_next_on_completion()
local anim1 = makeAnim(0.5)
local anim2 = makeAnim(0.5)
local group = AnimationGroup.new({
mode = "sequence",
animations = { anim1, anim2 },
})
-- Complete anim1
group:update(0.6)
luaunit.assertEquals(anim1:getState(), "completed")
-- Now anim2 should receive updates
group:update(0.3)
luaunit.assertTrue(anim2.elapsed > 0)
end
function TestAnimationGroupSequence:test_sequence_completes_when_last_finishes()
local anim1 = makeAnim(0.3)
local anim2 = makeAnim(0.3)
local group = AnimationGroup.new({
mode = "sequence",
animations = { anim1, anim2 },
})
-- Complete anim1
group:update(0.4)
luaunit.assertFalse(group:getState() == "completed")
-- Complete anim2
group:update(0.4)
luaunit.assertEquals(group:getState(), "completed")
end
function TestAnimationGroupSequence:test_sequence_maintains_order()
local order = {}
local anim1 = Animation.new({
duration = 0.2,
start = { x = 0 },
final = { x = 100 },
onStart = function() table.insert(order, 1) end,
})
local anim2 = Animation.new({
duration = 0.2,
start = { x = 0 },
final = { x = 100 },
onStart = function() table.insert(order, 2) end,
})
local anim3 = Animation.new({
duration = 0.2,
start = { x = 0 },
final = { x = 100 },
onStart = function() table.insert(order, 3) end,
})
local group = AnimationGroup.new({
mode = "sequence",
animations = { anim1, anim2, anim3 },
})
runToCompletion(group, 0.05)
luaunit.assertEquals(order, { 1, 2, 3 })
end
-- ============================================================================
-- Test Suite: Stagger Mode
-- ============================================================================
TestAnimationGroupStagger = {}
function TestAnimationGroupStagger:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestAnimationGroupStagger:tearDown()
FlexLove.endFrame()
end
function TestAnimationGroupStagger:test_stagger_delays_animation_starts()
local anim1 = makeAnim(1)
local anim2 = makeAnim(1)
local anim3 = makeAnim(1)
local group = AnimationGroup.new({
mode = "stagger",
stagger = 0.5,
animations = { anim1, anim2, anim3 },
})
-- At t=0.3: only anim1 should have started (stagger=0.5 means anim2 starts at t=0.5)
group:update(0.3)
luaunit.assertTrue(anim1.elapsed > 0)
luaunit.assertEquals(anim2.elapsed, 0)
luaunit.assertEquals(anim3.elapsed, 0)
end
function TestAnimationGroupStagger:test_stagger_timing_is_correct()
local anim1 = makeAnim(1)
local anim2 = makeAnim(1)
local anim3 = makeAnim(1)
local group = AnimationGroup.new({
mode = "stagger",
stagger = 0.2,
animations = { anim1, anim2, anim3 },
})
-- At t=0.15: only anim1 started (anim2 starts at t=0.2, anim3 at t=0.4)
group:update(0.15)
luaunit.assertTrue(anim1.elapsed > 0)
luaunit.assertEquals(anim2.elapsed, 0)
luaunit.assertEquals(anim3.elapsed, 0)
-- At t=0.3: anim1 and anim2 started, anim3 not yet
group:update(0.15)
luaunit.assertTrue(anim1.elapsed > 0)
luaunit.assertTrue(anim2.elapsed > 0)
luaunit.assertEquals(anim3.elapsed, 0)
-- At t=0.5: all started
group:update(0.2)
luaunit.assertTrue(anim3.elapsed > 0)
end
function TestAnimationGroupStagger:test_stagger_completes_when_all_finish()
-- With stagger, animations get the full dt once their stagger offset is reached.
-- Use a longer stagger so anim2 hasn't started yet at the first check.
local anim1 = makeAnim(0.5)
local anim2 = makeAnim(0.5)
local group = AnimationGroup.new({
mode = "stagger",
stagger = 0.5,
animations = { anim1, anim2 },
})
-- At t=0.3: anim1 started, anim2 not yet (starts at t=0.5)
local finished = group:update(0.3)
luaunit.assertFalse(finished)
luaunit.assertTrue(anim1.elapsed > 0)
luaunit.assertEquals(anim2.elapsed, 0)
-- At t=0.6: anim1 completed, anim2 just started and got 0.3s dt
finished = group:update(0.3)
luaunit.assertFalse(finished)
luaunit.assertEquals(anim1:getState(), "completed")
luaunit.assertTrue(anim2.elapsed > 0)
-- At t=0.9: anim2 should be completed (got 0.3 + 0.3 = 0.6s of updates)
finished = group:update(0.3)
luaunit.assertTrue(finished)
luaunit.assertEquals(group:getState(), "completed")
end
-- ============================================================================
-- Test Suite: Callbacks
-- ============================================================================
TestAnimationGroupCallbacks = {}
function TestAnimationGroupCallbacks:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestAnimationGroupCallbacks:tearDown()
FlexLove.endFrame()
end
function TestAnimationGroupCallbacks:test_onStart_called_once()
local startCount = 0
local group = AnimationGroup.new({
animations = { makeAnim(0.5) },
onStart = function() startCount = startCount + 1 end,
})
group:update(0.1)
group:update(0.1)
group:update(0.1)
luaunit.assertEquals(startCount, 1)
end
function TestAnimationGroupCallbacks:test_onStart_receives_group_reference()
local receivedGroup = nil
local group = AnimationGroup.new({
animations = { makeAnim(0.5) },
onStart = function(g) receivedGroup = g end,
})
group:update(0.1)
luaunit.assertEquals(receivedGroup, group)
end
function TestAnimationGroupCallbacks:test_onComplete_called_when_all_finish()
local completeCount = 0
local group = AnimationGroup.new({
animations = { makeAnim(0.3) },
onComplete = function() completeCount = completeCount + 1 end,
})
runToCompletion(group)
luaunit.assertEquals(completeCount, 1)
end
function TestAnimationGroupCallbacks:test_onComplete_not_called_before_completion()
local completed = false
local group = AnimationGroup.new({
animations = { makeAnim(1) },
onComplete = function() completed = true end,
})
group:update(0.5)
luaunit.assertFalse(completed)
end
function TestAnimationGroupCallbacks:test_callback_error_does_not_crash()
local group = AnimationGroup.new({
animations = { makeAnim(0.1) },
onStart = function() error("onStart error") end,
onComplete = function() error("onComplete error") end,
})
-- Should not throw
runToCompletion(group)
luaunit.assertEquals(group:getState(), "completed")
end
-- ============================================================================
-- Test Suite: Control Methods
-- ============================================================================
TestAnimationGroupControl = {}
function TestAnimationGroupControl:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestAnimationGroupControl:tearDown()
FlexLove.endFrame()
end
function TestAnimationGroupControl:test_pause_stops_updates()
local anim1 = makeAnim(1)
local group = AnimationGroup.new({
animations = { anim1 },
})
group:update(0.2)
local elapsedBefore = anim1.elapsed
group:pause()
group:update(0.3)
-- Elapsed should not have increased
luaunit.assertEquals(anim1.elapsed, elapsedBefore)
luaunit.assertTrue(group:isPaused())
end
function TestAnimationGroupControl:test_resume_continues_updates()
local anim1 = makeAnim(1)
local group = AnimationGroup.new({
animations = { anim1 },
})
group:update(0.2)
group:pause()
group:update(0.3) -- Should be ignored
group:resume()
group:update(0.2)
-- Should have progressed past the paused value
luaunit.assertTrue(anim1.elapsed > 0.2)
luaunit.assertFalse(group:isPaused())
end
function TestAnimationGroupControl:test_reverse_reverses_all_animations()
local anim1 = makeAnim(1)
local anim2 = makeAnim(1)
local group = AnimationGroup.new({
animations = { anim1, anim2 },
})
group:update(0.5)
group:reverse()
luaunit.assertTrue(anim1._reversed)
luaunit.assertTrue(anim2._reversed)
end
function TestAnimationGroupControl:test_setSpeed_affects_all_animations()
local anim1 = makeAnim(1)
local anim2 = makeAnim(1)
local group = AnimationGroup.new({
animations = { anim1, anim2 },
})
group:setSpeed(2.0)
luaunit.assertEquals(anim1._speed, 2.0)
luaunit.assertEquals(anim2._speed, 2.0)
end
function TestAnimationGroupControl:test_cancel_cancels_all_animations()
local anim1 = makeAnim(1)
local anim2 = makeAnim(1)
local group = AnimationGroup.new({
animations = { anim1, anim2 },
})
group:update(0.3)
group:cancel()
luaunit.assertEquals(group:getState(), "cancelled")
luaunit.assertEquals(anim1:getState(), "cancelled")
luaunit.assertEquals(anim2:getState(), "cancelled")
end
function TestAnimationGroupControl:test_cancel_prevents_further_updates()
local anim1 = makeAnim(1)
local group = AnimationGroup.new({
animations = { anim1 },
})
group:update(0.2)
group:cancel()
local elapsedAfterCancel = anim1.elapsed
group:update(0.3)
luaunit.assertEquals(anim1.elapsed, elapsedAfterCancel)
end
function TestAnimationGroupControl:test_reset_restores_initial_state()
local anim1 = makeAnim(0.5)
local group = AnimationGroup.new({
mode = "sequence",
animations = { anim1 },
})
runToCompletion(group)
luaunit.assertEquals(group:getState(), "completed")
group:reset()
luaunit.assertEquals(group:getState(), "ready")
luaunit.assertFalse(group._hasStarted)
luaunit.assertEquals(group._currentIndex, 1)
luaunit.assertEquals(group._staggerElapsed, 0)
end
function TestAnimationGroupControl:test_reset_allows_replaying()
local completeCount = 0
local group = AnimationGroup.new({
animations = { makeAnim(0.2) },
onComplete = function() completeCount = completeCount + 1 end,
})
runToCompletion(group)
luaunit.assertEquals(completeCount, 1)
group:reset()
runToCompletion(group)
luaunit.assertEquals(completeCount, 2)
end
-- ============================================================================
-- Test Suite: State and Progress
-- ============================================================================
TestAnimationGroupStateProgress = {}
function TestAnimationGroupStateProgress:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestAnimationGroupStateProgress:tearDown()
FlexLove.endFrame()
end
function TestAnimationGroupStateProgress:test_state_transitions()
local group = AnimationGroup.new({
animations = { makeAnim(0.5) },
})
luaunit.assertEquals(group:getState(), "ready")
group:update(0.1)
luaunit.assertEquals(group:getState(), "playing")
runToCompletion(group)
luaunit.assertEquals(group:getState(), "completed")
end
function TestAnimationGroupStateProgress:test_progress_parallel()
local anim1 = makeAnim(1)
local anim2 = makeAnim(1)
local group = AnimationGroup.new({
mode = "parallel",
animations = { anim1, anim2 },
})
luaunit.assertAlmostEquals(group:getProgress(), 0, 0.01)
group:update(0.5)
local progress = group:getProgress()
luaunit.assertTrue(progress > 0)
luaunit.assertTrue(progress < 1)
runToCompletion(group)
luaunit.assertAlmostEquals(group:getProgress(), 1, 0.01)
end
function TestAnimationGroupStateProgress:test_progress_sequence()
local anim1 = makeAnim(1)
local anim2 = makeAnim(1)
local group = AnimationGroup.new({
mode = "sequence",
animations = { anim1, anim2 },
})
-- Before any update
luaunit.assertAlmostEquals(group:getProgress(), 0, 0.01)
-- Halfway through first animation (25% total)
group:update(0.5)
local progress = group:getProgress()
luaunit.assertTrue(progress > 0)
luaunit.assertTrue(progress <= 0.5)
-- Complete first animation (50% total)
group:update(0.6)
progress = group:getProgress()
luaunit.assertTrue(progress >= 0.5)
end
function TestAnimationGroupStateProgress:test_empty_group_progress_is_1()
local group = AnimationGroup.new({ animations = {} })
luaunit.assertAlmostEquals(group:getProgress(), 1, 0.01)
end
-- ============================================================================
-- Test Suite: Empty and Edge Cases
-- ============================================================================
TestAnimationGroupEdgeCases = {}
function TestAnimationGroupEdgeCases:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestAnimationGroupEdgeCases:tearDown()
FlexLove.endFrame()
end
function TestAnimationGroupEdgeCases:test_empty_group_completes_immediately()
local completed = false
local group = AnimationGroup.new({
animations = {},
onComplete = function() completed = true end,
})
local finished = group:update(0.1)
luaunit.assertTrue(finished)
luaunit.assertEquals(group:getState(), "completed")
end
function TestAnimationGroupEdgeCases:test_single_animation_group()
local anim = makeAnim(0.5)
local group = AnimationGroup.new({
animations = { anim },
})
runToCompletion(group)
luaunit.assertEquals(group:getState(), "completed")
luaunit.assertEquals(anim:getState(), "completed")
end
function TestAnimationGroupEdgeCases:test_update_after_completion_returns_true()
local group = AnimationGroup.new({
animations = { makeAnim(0.1) },
})
runToCompletion(group)
local finished = group:update(0.1)
luaunit.assertTrue(finished)
end
function TestAnimationGroupEdgeCases:test_invalid_dt_is_handled()
local group = AnimationGroup.new({
animations = { makeAnim(1) },
})
-- Should not throw for invalid dt values
group:update(-1)
group:update(0 / 0) -- NaN
group:update(math.huge)
luaunit.assertNotNil(group)
end
function TestAnimationGroupEdgeCases:test_apply_assigns_group_to_element()
local group = AnimationGroup.new({
animations = { makeAnim(1) },
})
local mockElement = {}
group:apply(mockElement)
luaunit.assertEquals(mockElement.animationGroup, group)
end
function TestAnimationGroupEdgeCases:test_apply_with_nil_element_does_not_crash()
local group = AnimationGroup.new({
animations = { makeAnim(1) },
})
-- Should not throw
group:apply(nil)
end
-- ============================================================================
-- Test Suite: Nested Groups
-- ============================================================================
TestAnimationGroupNested = {}
function TestAnimationGroupNested:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestAnimationGroupNested:tearDown()
FlexLove.endFrame()
end
function TestAnimationGroupNested:test_nested_parallel_in_sequence()
local anim1 = makeAnim(0.3)
local anim2 = makeAnim(0.3)
local innerGroup = AnimationGroup.new({
mode = "parallel",
animations = { anim1, anim2 },
})
local anim3 = makeAnim(0.3)
local outerGroup = AnimationGroup.new({
mode = "sequence",
animations = { innerGroup, anim3 },
})
-- Inner group should run first
outerGroup:update(0.2)
luaunit.assertTrue(anim1.elapsed > 0)
luaunit.assertTrue(anim2.elapsed > 0)
luaunit.assertEquals(anim3.elapsed, 0)
-- Complete inner group, anim3 should start
outerGroup:update(0.2)
outerGroup:update(0.2)
luaunit.assertTrue(anim3.elapsed > 0)
end
function TestAnimationGroupNested:test_nested_sequence_in_parallel()
local anim1 = makeAnim(0.2)
local anim2 = makeAnim(0.2)
local innerSeq = AnimationGroup.new({
mode = "sequence",
animations = { anim1, anim2 },
})
local anim3 = makeAnim(0.3)
local outerGroup = AnimationGroup.new({
mode = "parallel",
animations = { innerSeq, anim3 },
})
-- Both innerSeq and anim3 should run in parallel
outerGroup:update(0.1)
luaunit.assertTrue(anim1.elapsed > 0)
luaunit.assertTrue(anim3.elapsed > 0)
end
function TestAnimationGroupNested:test_nested_group_completes()
local innerGroup = AnimationGroup.new({
mode = "parallel",
animations = { makeAnim(0.2), makeAnim(0.2) },
})
local outerGroup = AnimationGroup.new({
mode = "sequence",
animations = { innerGroup, makeAnim(0.2) },
})
runToCompletion(outerGroup)
luaunit.assertEquals(outerGroup:getState(), "completed")
luaunit.assertEquals(innerGroup:getState(), "completed")
end
function TestAnimationGroupNested:test_deeply_nested_groups()
local leaf1 = makeAnim(0.1)
local leaf2 = makeAnim(0.1)
local inner = AnimationGroup.new({
mode = "parallel",
animations = { leaf1, leaf2 },
})
local middle = AnimationGroup.new({
mode = "sequence",
animations = { inner, makeAnim(0.1) },
})
local outer = AnimationGroup.new({
mode = "parallel",
animations = { middle, makeAnim(0.2) },
})
runToCompletion(outer)
luaunit.assertEquals(outer:getState(), "completed")
luaunit.assertEquals(middle:getState(), "completed")
luaunit.assertEquals(inner:getState(), "completed")
end
-- Run all tests
if not _G.RUNNING_ALL_TESTS then
os.exit(luaunit.LuaUnit.run())
end

View File

@@ -1,302 +0,0 @@
package.path = package.path .. ";./?.lua;./modules/?.lua"
local originalSearchers = package.searchers or package.loaders
table.insert(originalSearchers, 2, function(modname)
if modname:match("^FlexLove%.modules%.") then
local moduleName = modname:gsub("^FlexLove%.modules%.", "")
return function()
return require("modules." .. moduleName)
end
end
end)
require("testing.loveStub")
local luaunit = require("testing.luaunit")
local FlexLove = require("FlexLove")
-- Initialize FlexLove to ensure all modules are properly set up
FlexLove.init()
TestTouchEvents = {}
-- Test: InputEvent.fromTouch creates valid touch event
function TestTouchEvents:testInputEvent_FromTouch()
local InputEvent = package.loaded["modules.InputEvent"]
local touchId = "touch1"
local event = InputEvent.fromTouch(touchId, 100, 200, "began", 0.8)
luaunit.assertEquals(event.type, "touchpress")
luaunit.assertEquals(event.x, 100)
luaunit.assertEquals(event.y, 200)
luaunit.assertEquals(event.touchId, "touch1")
luaunit.assertEquals(event.pressure, 0.8)
luaunit.assertEquals(event.phase, "began")
luaunit.assertEquals(event.button, 1) -- Treat as left button
end
-- Test: Touch event with moved phase
function TestTouchEvents:testInputEvent_FromTouch_Moved()
local InputEvent = package.loaded["modules.InputEvent"]
local event = InputEvent.fromTouch("touch1", 150, 250, "moved", 1.0)
luaunit.assertEquals(event.type, "touchmove")
luaunit.assertEquals(event.phase, "moved")
end
-- Test: Touch event with ended phase
function TestTouchEvents:testInputEvent_FromTouch_Ended()
local InputEvent = package.loaded["modules.InputEvent"]
local event = InputEvent.fromTouch("touch1", 150, 250, "ended", 1.0)
luaunit.assertEquals(event.type, "touchrelease")
luaunit.assertEquals(event.phase, "ended")
end
-- Test: Touch event with cancelled phase
function TestTouchEvents:testInputEvent_FromTouch_Cancelled()
local InputEvent = package.loaded["modules.InputEvent"]
local event = InputEvent.fromTouch("touch1", 150, 250, "cancelled", 1.0)
luaunit.assertEquals(event.type, "touchcancel")
luaunit.assertEquals(event.phase, "cancelled")
end
-- Test: EventHandler tracks touch began
function TestTouchEvents:testEventHandler_TouchBegan()
FlexLove.beginFrame()
local touchEvents = {}
local element = FlexLove.new({
width = 200,
height = 200,
onEvent = function(el, event)
table.insert(touchEvents, event)
end,
})
FlexLove.endFrame()
-- Simulate touch began
love.touch.getTouches = function()
return { "touch1" }
end
love.touch.getPosition = function(id)
if id == "touch1" then
return 100, 100
end
return 0, 0
end
-- Trigger touch event processing
FlexLove.beginFrame()
element._eventHandler:processTouchEvents(element)
FlexLove.endFrame()
-- Filter out hover/unhover events (from mouse processing)
local filteredEvents = {}
for _, event in ipairs(touchEvents) do
if event.type ~= "hover" and event.type ~= "unhover" then
table.insert(filteredEvents, event)
end
end
-- Should have received at least one touchpress event
-- Note: May receive multiple events due to test state/frame processing
luaunit.assertTrue(#filteredEvents >= 1, "Should receive at least 1 touch event, got " .. #filteredEvents)
luaunit.assertEquals(filteredEvents[1].type, "touchpress")
luaunit.assertEquals(filteredEvents[1].touchId, "touch1")
end
-- Test: EventHandler tracks touch moved
function TestTouchEvents:testEventHandler_TouchMoved()
FlexLove.beginFrame()
local touchEvents = {}
local element = FlexLove.new({
width = 200,
height = 200,
onEvent = function(el, event)
table.insert(touchEvents, event)
end,
})
FlexLove.endFrame()
-- Simulate touch began
love.touch.getTouches = function()
return { "touch1" }
end
love.touch.getPosition = function(id)
if id == "touch1" then
return 100, 100
end
return 0, 0
end
-- First touch
FlexLove.beginFrame()
element._eventHandler:processTouchEvents(element)
FlexLove.endFrame()
-- Move touch
love.touch.getPosition = function(id)
if id == "touch1" then
return 150, 150
end
return 0, 0
end
FlexLove.beginFrame()
element._eventHandler:processTouchEvents(element)
FlexLove.endFrame()
-- Filter out hover/unhover events (from mouse processing)
local filteredEvents = {}
for _, event in ipairs(touchEvents) do
if event.type ~= "hover" and event.type ~= "unhover" then
table.insert(filteredEvents, event)
end
end
-- Should have received touchpress and touchmove events
luaunit.assertEquals(#filteredEvents, 2)
luaunit.assertEquals(filteredEvents[1].type, "touchpress")
luaunit.assertEquals(filteredEvents[2].type, "touchmove")
luaunit.assertEquals(filteredEvents[2].dx, 50)
luaunit.assertEquals(filteredEvents[2].dy, 50)
end
-- Test: EventHandler tracks touch ended
function TestTouchEvents:testEventHandler_TouchEnded()
FlexLove.beginFrame()
local touchEvents = {}
local element = FlexLove.new({
width = 200,
height = 200,
onEvent = function(el, event)
table.insert(touchEvents, event)
end,
})
FlexLove.endFrame()
-- Simulate touch began
love.touch.getTouches = function()
return { "touch1" }
end
love.touch.getPosition = function(id)
if id == "touch1" then
return 100, 100
end
return 0, 0
end
-- First touch
FlexLove.beginFrame()
element._eventHandler:processTouchEvents(element)
FlexLove.endFrame()
-- End touch
love.touch.getTouches = function()
return {}
end
FlexLove.beginFrame()
element._eventHandler:processTouchEvents(element)
FlexLove.endFrame()
-- Filter out hover/unhover events (from mouse processing)
local filteredEvents = {}
for _, event in ipairs(touchEvents) do
if event.type ~= "hover" and event.type ~= "unhover" then
table.insert(filteredEvents, event)
end
end
-- Should have received touchpress and touchrelease events
luaunit.assertEquals(#filteredEvents, 2)
luaunit.assertEquals(filteredEvents[1].type, "touchpress")
luaunit.assertEquals(filteredEvents[2].type, "touchrelease")
end
-- Test: EventHandler tracks multiple simultaneous touches
function TestTouchEvents:testEventHandler_MultiTouch()
FlexLove.beginFrame()
local touchEvents = {}
local element = FlexLove.new({
width = 200,
height = 200,
onEvent = function(el, event)
table.insert(touchEvents, event)
end,
})
FlexLove.endFrame()
-- Simulate two touches
love.touch.getTouches = function()
return { "touch1", "touch2" }
end
love.touch.getPosition = function(id)
if id == "touch1" then
return 50, 50
end
if id == "touch2" then
return 150, 150
end
return 0, 0
end
FlexLove.beginFrame()
element._eventHandler:processTouchEvents(element)
FlexLove.endFrame()
-- Filter out hover/unhover events (from mouse processing)
local filteredEvents = {}
for _, event in ipairs(touchEvents) do
if event.type ~= "hover" and event.type ~= "unhover" then
table.insert(filteredEvents, event)
end
end
-- Should have received two touchpress events (one for each touch)
luaunit.assertEquals(#filteredEvents, 2)
luaunit.assertEquals(filteredEvents[1].type, "touchpress")
luaunit.assertEquals(filteredEvents[2].type, "touchpress")
-- Different touch IDs
luaunit.assertNotEquals(touchEvents[1].touchId, touchEvents[2].touchId)
end
-- Test: GestureRecognizer detects tap
function TestTouchEvents:testGestureRecognizer_Tap()
local GestureRecognizer = package.loaded["modules.GestureRecognizer"]
local InputEvent = package.loaded["modules.InputEvent"]
local utils = package.loaded["modules.utils"]
local recognizer = GestureRecognizer.new({}, {
InputEvent = InputEvent,
utils = utils,
})
-- Simulate tap (press and quick release)
local touchId = "touch1"
local pressEvent = InputEvent.fromTouch(touchId, 100, 100, "began", 1.0)
local releaseEvent = InputEvent.fromTouch(touchId, 102, 102, "ended", 1.0)
recognizer:processTouchEvent(pressEvent)
local gesture = recognizer:processTouchEvent(releaseEvent)
-- Note: The gesture detection returns from internal methods,
-- needs to be captured from the event processing
-- This is a basic structural test
luaunit.assertNotNil(recognizer)
end
if not _G.RUNNING_ALL_TESTS then
os.exit(luaunit.LuaUnit.run())
end

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,442 @@
package.path = package.path .. ";./?.lua;./modules/?.lua"
local originalSearchers = package.searchers or package.loaders
table.insert(originalSearchers, 2, function(modname)
if modname:match("^FlexLove%.modules%.") then
local moduleName = modname:gsub("^FlexLove%.modules%.", "")
return function()
return require("modules." .. moduleName)
end
end
end)
require("testing.loveStub")
local luaunit = require("testing.luaunit")
local FlexLove = require("FlexLove")
FlexLove.init()
local Animation = FlexLove.Animation
-- Helper: create a retained-mode element
local function makeElement(props)
props = props or {}
props.width = props.width or 100
props.height = props.height or 100
return FlexLove.new(props)
end
-- ============================================================================
-- Test Suite: setTransition()
-- ============================================================================
TestSetTransition = {}
function TestSetTransition:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestSetTransition:tearDown()
FlexLove.endFrame()
end
function TestSetTransition:test_setTransition_creates_transitions_table()
local el = makeElement()
el:setTransition("opacity", { duration = 0.5 })
luaunit.assertNotNil(el.transitions)
luaunit.assertNotNil(el.transitions.opacity)
end
function TestSetTransition:test_setTransition_stores_config()
local el = makeElement()
el:setTransition("opacity", {
duration = 0.5,
easing = "easeInQuad",
delay = 0.1,
})
luaunit.assertEquals(el.transitions.opacity.duration, 0.5)
luaunit.assertEquals(el.transitions.opacity.easing, "easeInQuad")
luaunit.assertEquals(el.transitions.opacity.delay, 0.1)
end
function TestSetTransition:test_setTransition_uses_defaults()
local el = makeElement()
el:setTransition("opacity", {})
luaunit.assertEquals(el.transitions.opacity.duration, 0.3)
luaunit.assertEquals(el.transitions.opacity.easing, "easeOutQuad")
luaunit.assertEquals(el.transitions.opacity.delay, 0)
end
function TestSetTransition:test_setTransition_invalid_duration_uses_default()
local el = makeElement()
el:setTransition("opacity", { duration = -1 })
luaunit.assertEquals(el.transitions.opacity.duration, 0.3)
end
function TestSetTransition:test_setTransition_with_invalid_config_handles_gracefully()
local el = makeElement()
-- Should not throw
el:setTransition("opacity", "invalid")
luaunit.assertNotNil(el.transitions.opacity)
end
function TestSetTransition:test_setTransition_for_all_properties()
local el = makeElement()
el:setTransition("all", { duration = 0.2, easing = "linear" })
luaunit.assertNotNil(el.transitions["all"])
luaunit.assertEquals(el.transitions["all"].duration, 0.2)
end
function TestSetTransition:test_setTransition_with_onComplete_callback()
local el = makeElement()
local cb = function() end
el:setTransition("opacity", {
duration = 0.3,
onComplete = cb,
})
luaunit.assertEquals(el.transitions.opacity.onComplete, cb)
end
function TestSetTransition:test_setTransition_overwrites_previous()
local el = makeElement()
el:setTransition("opacity", { duration = 0.5 })
el:setTransition("opacity", { duration = 1.0 })
luaunit.assertEquals(el.transitions.opacity.duration, 1.0)
end
-- ============================================================================
-- Test Suite: setTransitionGroup()
-- ============================================================================
TestSetTransitionGroup = {}
function TestSetTransitionGroup:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestSetTransitionGroup:tearDown()
FlexLove.endFrame()
end
function TestSetTransitionGroup:test_setTransitionGroup_applies_to_all_properties()
local el = makeElement()
el:setTransitionGroup("colors", { duration = 0.3 }, {
"backgroundColor",
"borderColor",
"textColor",
})
luaunit.assertNotNil(el.transitions.backgroundColor)
luaunit.assertNotNil(el.transitions.borderColor)
luaunit.assertNotNil(el.transitions.textColor)
luaunit.assertEquals(el.transitions.backgroundColor.duration, 0.3)
end
function TestSetTransitionGroup:test_setTransitionGroup_with_invalid_properties()
local el = makeElement()
-- Should not throw
el:setTransitionGroup("invalid", { duration = 0.3 }, "not a table")
-- No transitions should be set
luaunit.assertNil(el.transitions)
end
function TestSetTransitionGroup:test_setTransitionGroup_shared_config()
local el = makeElement()
el:setTransitionGroup("position", { duration = 0.5, easing = "easeInOutCubic" }, {
"x",
"y",
})
luaunit.assertEquals(el.transitions.x.duration, 0.5)
luaunit.assertEquals(el.transitions.y.duration, 0.5)
luaunit.assertEquals(el.transitions.x.easing, "easeInOutCubic")
end
-- ============================================================================
-- Test Suite: removeTransition()
-- ============================================================================
TestRemoveTransition = {}
function TestRemoveTransition:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestRemoveTransition:tearDown()
FlexLove.endFrame()
end
function TestRemoveTransition:test_removeTransition_removes_single()
local el = makeElement()
el:setTransition("opacity", { duration = 0.3 })
el:setTransition("x", { duration = 0.5 })
el:removeTransition("opacity")
luaunit.assertNil(el.transitions.opacity)
luaunit.assertNotNil(el.transitions.x)
end
function TestRemoveTransition:test_removeTransition_all_clears_all()
local el = makeElement()
el:setTransition("opacity", { duration = 0.3 })
el:setTransition("x", { duration = 0.5 })
el:removeTransition("all")
luaunit.assertEquals(next(el.transitions), nil) -- empty table
end
function TestRemoveTransition:test_removeTransition_no_transitions_does_not_error()
local el = makeElement()
-- Should not throw even with no transitions set
el:removeTransition("opacity")
end
function TestRemoveTransition:test_removeTransition_nonexistent_property()
local el = makeElement()
el:setTransition("opacity", { duration = 0.3 })
-- Should not throw
el:removeTransition("nonexistent")
luaunit.assertNotNil(el.transitions.opacity)
end
-- ============================================================================
-- Test Suite: setProperty() with Transitions
-- ============================================================================
TestSetPropertyTransitions = {}
function TestSetPropertyTransitions:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestSetPropertyTransitions:tearDown()
FlexLove.endFrame()
end
function TestSetPropertyTransitions:test_setProperty_without_transition_sets_immediately()
local el = makeElement()
el.opacity = 1
el:setProperty("opacity", 0.5)
luaunit.assertEquals(el.opacity, 0.5)
luaunit.assertNil(el.animation)
end
function TestSetPropertyTransitions:test_setProperty_with_transition_creates_animation()
local el = makeElement()
el.opacity = 1
el:setTransition("opacity", { duration = 0.5 })
el:setProperty("opacity", 0)
luaunit.assertNotNil(el.animation)
luaunit.assertEquals(el.animation.duration, 0.5)
luaunit.assertEquals(el.animation.start.opacity, 1)
luaunit.assertEquals(el.animation.final.opacity, 0)
end
function TestSetPropertyTransitions:test_setProperty_same_value_does_not_animate()
local el = makeElement()
el.opacity = 1
el:setTransition("opacity", { duration = 0.5 })
el:setProperty("opacity", 1)
luaunit.assertNil(el.animation)
end
function TestSetPropertyTransitions:test_setProperty_with_all_transition()
local el = makeElement()
el.opacity = 1
el:setTransition("all", { duration = 0.3 })
el:setProperty("opacity", 0)
luaunit.assertNotNil(el.animation)
luaunit.assertEquals(el.animation.duration, 0.3)
end
function TestSetPropertyTransitions:test_setProperty_specific_overrides_all()
local el = makeElement()
el.opacity = 1
el:setTransition("all", { duration = 0.3 })
el:setTransition("opacity", { duration = 0.8 })
el:setProperty("opacity", 0)
-- Should use the specific "opacity" transition, not "all"
luaunit.assertNotNil(el.animation)
luaunit.assertEquals(el.animation.duration, 0.8)
end
function TestSetPropertyTransitions:test_setProperty_transition_with_delay()
local el = makeElement()
el.opacity = 1
el:setTransition("opacity", { duration = 0.3, delay = 0.2 })
el:setProperty("opacity", 0)
-- Animation should have the delay set
-- The delay is part of the transition config, which is used to create the animation
-- Note: delay may not be passed to Animation.new automatically by current implementation
luaunit.assertNotNil(el.animation)
end
function TestSetPropertyTransitions:test_setProperty_transition_onComplete_callback()
local el = makeElement()
el.opacity = 1
local callbackCalled = false
el:setTransition("opacity", {
duration = 0.3,
onComplete = function() callbackCalled = true end,
})
el:setProperty("opacity", 0)
luaunit.assertNotNil(el.animation)
luaunit.assertNotNil(el.animation.onComplete)
end
function TestSetPropertyTransitions:test_setProperty_nil_current_value_sets_directly()
local el = makeElement()
el:setTransition("customProp", { duration = 0.3 })
-- customProp is nil, should set directly
el:setProperty("customProp", 42)
luaunit.assertEquals(el.customProp, 42)
luaunit.assertNil(el.animation)
end
-- ============================================================================
-- Test Suite: Per-Property Transition Configuration
-- ============================================================================
TestPerPropertyTransitionConfig = {}
function TestPerPropertyTransitionConfig:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestPerPropertyTransitionConfig:tearDown()
FlexLove.endFrame()
end
function TestPerPropertyTransitionConfig:test_different_durations_per_property()
local el = makeElement()
el:setTransition("opacity", { duration = 0.3 })
el:setTransition("x", { duration = 1.0 })
luaunit.assertEquals(el.transitions.opacity.duration, 0.3)
luaunit.assertEquals(el.transitions.x.duration, 1.0)
end
function TestPerPropertyTransitionConfig:test_different_easing_per_property()
local el = makeElement()
el:setTransition("opacity", { easing = "easeInQuad" })
el:setTransition("x", { easing = "easeOutCubic" })
luaunit.assertEquals(el.transitions.opacity.easing, "easeInQuad")
luaunit.assertEquals(el.transitions.x.easing, "easeOutCubic")
end
function TestPerPropertyTransitionConfig:test_transition_disabled_after_removal()
local el = makeElement()
el.opacity = 1
el:setTransition("opacity", { duration = 0.3 })
-- Verify transition is active
el:setProperty("opacity", 0.5)
luaunit.assertNotNil(el.animation)
-- Remove transition and reset
el.animation = nil
el.opacity = 1
el:removeTransition("opacity")
-- Should set immediately now
el:setProperty("opacity", 0.5)
luaunit.assertEquals(el.opacity, 0.5)
luaunit.assertNil(el.animation)
end
function TestPerPropertyTransitionConfig:test_multiple_properties_configured()
local el = makeElement()
el:setTransition("opacity", { duration = 0.3 })
el:setTransition("x", { duration = 0.5 })
el:setTransition("width", { duration = 1.0 })
luaunit.assertEquals(el.transitions.opacity.duration, 0.3)
luaunit.assertEquals(el.transitions.x.duration, 0.5)
luaunit.assertEquals(el.transitions.width.duration, 1.0)
end
-- ============================================================================
-- Test Suite: Transition Integration
-- ============================================================================
TestTransitionIntegration = {}
function TestTransitionIntegration:setUp()
love.window.setMode(1920, 1080)
FlexLove.beginFrame()
end
function TestTransitionIntegration:tearDown()
FlexLove.endFrame()
end
function TestTransitionIntegration:test_transition_animation_runs_to_completion()
local el = makeElement()
el.opacity = 1
el:setTransition("opacity", { duration = 0.2 })
el:setProperty("opacity", 0)
luaunit.assertNotNil(el.animation)
-- Run animation to completion
for i = 1, 30 do
el:update(1 / 60)
if not el.animation then
break
end
end
luaunit.assertNil(el.animation)
end
function TestTransitionIntegration:test_manual_animation_overrides_transition()
local el = makeElement()
el.opacity = 1
el:setTransition("opacity", { duration = 0.3 })
-- Apply manual animation
local manualAnim = Animation.new({
duration = 1.0,
start = { opacity = 1 },
final = { opacity = 0 },
})
manualAnim:apply(el)
luaunit.assertEquals(el.animation.duration, 1.0) -- Manual anim
end
-- Run all tests
if not _G.RUNNING_ALL_TESTS then
os.exit(luaunit.LuaUnit.run())
end

View File

@@ -37,6 +37,8 @@ local luaunit = require("testing.luaunit")
local testFiles = { local testFiles = {
"testing/__tests__/absolute_positioning_test.lua", "testing/__tests__/absolute_positioning_test.lua",
"testing/__tests__/animation_chaining_test.lua",
"testing/__tests__/animation_group_test.lua",
"testing/__tests__/animation_test.lua", "testing/__tests__/animation_test.lua",
"testing/__tests__/blur_test.lua", "testing/__tests__/blur_test.lua",
"testing/__tests__/calc_test.lua", "testing/__tests__/calc_test.lua",
@@ -66,7 +68,8 @@ local testFiles = {
"testing/__tests__/scrollbar_placement_test.lua", "testing/__tests__/scrollbar_placement_test.lua",
"testing/__tests__/text_editor_test.lua", "testing/__tests__/text_editor_test.lua",
"testing/__tests__/theme_test.lua", "testing/__tests__/theme_test.lua",
"testing/__tests__/touch_events_test.lua", "testing/__tests__/touch_test.lua",
"testing/__tests__/transition_test.lua",
"testing/__tests__/units_test.lua", "testing/__tests__/units_test.lua",
"testing/__tests__/utils_test.lua", "testing/__tests__/utils_test.lua",
} }