Compare commits
24 Commits
b671f501ec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d8f67ebd27 | |||
|
|
99f467fa69 | ||
| 141a22cd9c | |||
| eb3afc11ac | |||
| d72f442de7 | |||
| 3713f45a76 | |||
| c4fc62af20 | |||
|
|
71f7776f78 | ||
| f5608980e3 | |||
| 9a126cb87e | |||
| caf604445f | |||
| ffab292c04 | |||
| 309ebde985 | |||
| 998469141a | |||
|
|
4e14b375e0 | ||
| f1fae85595 | |||
| d948ab2b4c | |||
| 6ae04b5e82 | |||
| 3c3f26b74a | |||
| 2b5957f264 | |||
|
|
7f72623168 | ||
| 2f62810a91 | |||
| 7b34c71623 | |||
| a17e524730 |
5
.gitignore
vendored
@@ -1,8 +1,4 @@
|
||||
Cartographer.lua
|
||||
OverlayStats.lua
|
||||
lume.lua
|
||||
lurker.lua
|
||||
themes/metal/
|
||||
themes/space/
|
||||
.DS_STORE
|
||||
tasks
|
||||
@@ -17,3 +13,4 @@ memory_scan*
|
||||
*_report*
|
||||
*.key
|
||||
*.rock
|
||||
*.rockspec
|
||||
|
||||
324
FlexLove.lua
@@ -63,7 +63,7 @@ local enums = utils.enums
|
||||
|
||||
---@class FlexLove
|
||||
local flexlove = Context
|
||||
flexlove._VERSION = "0.8.0"
|
||||
flexlove._VERSION = "0.10.2"
|
||||
flexlove._DESCRIPTION = "UI Library for LÖVE Framework based on flexbox"
|
||||
flexlove._URL = "https://github.com/mikefreno/FlexLove"
|
||||
flexlove._LICENSE = [[
|
||||
@@ -112,6 +112,14 @@ flexlove._deferredCallbacks = {}
|
||||
-- Track accumulated delta time for immediate mode updates
|
||||
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
|
||||
--- Use this before creating elements to avoid automatic queueing
|
||||
---@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 })
|
||||
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 = {
|
||||
Context = Context,
|
||||
Theme = Theme,
|
||||
@@ -232,6 +245,7 @@ function flexlove.init(config)
|
||||
ErrorHandler = flexlove._ErrorHandler,
|
||||
Performance = flexlove._Performance,
|
||||
Transform = Transform,
|
||||
Animation = Animation,
|
||||
}
|
||||
|
||||
-- Initialize Element module with dependencies
|
||||
@@ -294,6 +308,10 @@ function flexlove.init(config)
|
||||
flexlove.initialized = true
|
||||
flexlove._initState = "ready"
|
||||
|
||||
-- Configure debug draw overlay
|
||||
flexlove._debugDraw = config.debugDraw or false
|
||||
flexlove._debugDrawKey = config.debugDrawKey or nil
|
||||
|
||||
-- Process all queued element creations
|
||||
local queue = flexlove._initQueue
|
||||
flexlove._initQueue = {} -- Clear queue before processing to prevent re-entry issues
|
||||
@@ -557,6 +575,49 @@ flexlove._backdropCanvas = nil
|
||||
---@type {width: number, height: number}
|
||||
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
|
||||
--- 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
|
||||
@@ -667,6 +728,11 @@ function flexlove.draw(gameDrawFunc, postDrawFunc)
|
||||
-- Render performance HUD if enabled
|
||||
flexlove._Performance:renderHUD()
|
||||
|
||||
-- Render debug draw overlay if enabled
|
||||
if flexlove._debugDraw then
|
||||
flexlove._renderDebugOverlay()
|
||||
end
|
||||
|
||||
love.graphics.setCanvas(outerCanvas)
|
||||
|
||||
-- NOTE: Deferred callbacks are NOT executed here because the calling code
|
||||
@@ -918,8 +984,10 @@ end
|
||||
---@param scancode string
|
||||
---@param isrepeat boolean
|
||||
function flexlove.keypressed(key, scancode, isrepeat)
|
||||
-- Handle performance HUD toggle
|
||||
flexlove._Performance:keypressed(key)
|
||||
if flexlove._debugDrawKey and key == flexlove._debugDrawKey then
|
||||
flexlove._debugDraw = not flexlove._debugDraw
|
||||
end
|
||||
local focusedElement = Context.getFocused()
|
||||
if focusedElement then
|
||||
focusedElement:keypressed(key, scancode, isrepeat)
|
||||
@@ -1052,6 +1120,239 @@ function flexlove.wheelmoved(dx, dy)
|
||||
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
|
||||
--- Use this to prevent memory leaks when transitioning between game states or menus
|
||||
function flexlove.destroy()
|
||||
@@ -1076,6 +1377,12 @@ function flexlove.destroy()
|
||||
flexlove._canvasDimensions = { width = 0, height = 0 }
|
||||
Context.clearFocus()
|
||||
StateManager:reset()
|
||||
|
||||
-- Clean up touch state
|
||||
flexlove._touchOwners = {}
|
||||
if flexlove._gestureRecognizer then
|
||||
flexlove._gestureRecognizer:reset()
|
||||
end
|
||||
end
|
||||
|
||||
--- Create a new UI element with flexbox layout, styling, and interaction capabilities
|
||||
@@ -1248,6 +1555,19 @@ function flexlove.clearFocus()
|
||||
Context.setFocused(nil)
|
||||
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.Color = Color
|
||||
flexlove.Theme = Theme
|
||||
|
||||
79
README.md
@@ -79,6 +79,15 @@ function love.draw()
|
||||
SomeMetaComponent:draw()
|
||||
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
|
||||
@@ -377,6 +386,76 @@ FlexLöve provides comprehensive multi-touch event tracking and gesture recognit
|
||||
- Touch scrolling with momentum and bounce effects
|
||||
- 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
|
||||
|
||||
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:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlexLöve v0.8.0 - API Reference</title>
|
||||
<title>FlexLöve v0.10.2 - API Reference</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||
<style>
|
||||
* {
|
||||
@@ -321,13 +321,17 @@
|
||||
<div class="container">
|
||||
<nav class="sidebar">
|
||||
<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.10.2</span></h2>
|
||||
<a href="index.html">← Back to Home</a>
|
||||
|
||||
<div class="version-selector">
|
||||
<select id="version-dropdown" onchange="window.versionNavigate(this.value)">
|
||||
<option value="">📚 Switch Version</option>
|
||||
<option value="current">v0.8.0 (Latest)</option>
|
||||
<option value="current">v0.10.2 (Latest)</option>
|
||||
<option value="v0.10.0">v0.10.0</option>
|
||||
<option value="v0.9.2">v0.9.2</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.2">v0.7.2</option>
|
||||
<option value="v0.7.1">v0.7.1</option>
|
||||
|
||||
@@ -285,7 +285,7 @@ cp FlexLove/FlexLove.lua your-project/</code></pre>
|
||||
|
||||
<div class="footer">
|
||||
<p>
|
||||
FlexLöve v0.8.0 | MIT License |
|
||||
FlexLöve v0.10.2 | MIT License |
|
||||
<a href="https://github.com/mikefreno/FlexLove" style="color: #58a6ff"
|
||||
>GitHub Repository</a
|
||||
>
|
||||
|
||||
3751
docs/versions/v0.10.0/api.html
Normal file
3748
docs/versions/v0.8.0/api.html
Normal file
3749
docs/versions/v0.9.0/api.html
Normal file
3750
docs/versions/v0.9.2/api.html
Normal 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",
|
||||
--},
|
||||
}
|
||||
@@ -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",
|
||||
--},
|
||||
}
|
||||
@@ -1232,6 +1232,25 @@ function Animation.keyframes(props)
|
||||
})
|
||||
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)
|
||||
-- ============================================================================
|
||||
|
||||
@@ -23,6 +23,10 @@ local Context = {
|
||||
|
||||
initialized = false,
|
||||
|
||||
-- Debug draw overlay
|
||||
_debugDraw = false,
|
||||
_debugDrawKey = nil,
|
||||
|
||||
-- Initialization state tracking
|
||||
---@type "uninitialized"|"initializing"|"ready"
|
||||
_initState = "uninitialized",
|
||||
@@ -60,6 +64,19 @@ function Context.clearFrameElements()
|
||||
end
|
||||
end
|
||||
|
||||
--- Calculate the depth (nesting level) of an element
|
||||
---@param elem Element
|
||||
---@return number
|
||||
local function getElementDepth(elem)
|
||||
local depth = 0
|
||||
local current = elem.parent
|
||||
while current do
|
||||
depth = depth + 1
|
||||
current = current.parent
|
||||
end
|
||||
return depth
|
||||
end
|
||||
|
||||
--- Sort elements by z-index (called after all elements are registered)
|
||||
function Context.sortElementsByZIndex()
|
||||
-- Sort elements by z-index (lowest to highest)
|
||||
@@ -76,7 +93,13 @@ function Context.sortElementsByZIndex()
|
||||
return z
|
||||
end
|
||||
|
||||
return getEffectiveZIndex(a) < getEffectiveZIndex(b)
|
||||
local za = getEffectiveZIndex(a)
|
||||
local zb = getEffectiveZIndex(b)
|
||||
if za ~= zb then
|
||||
return za < zb
|
||||
end
|
||||
-- Tiebreaker: deeper elements (children) sort higher
|
||||
return getElementDepth(a) < getElementDepth(b)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -149,6 +172,7 @@ function Context.getTopElementAt(x, y)
|
||||
return nil
|
||||
end
|
||||
|
||||
local fallback = nil
|
||||
for i = #Context._zIndexOrderedElements, 1, -1 do
|
||||
local element = Context._zIndexOrderedElements[i]
|
||||
|
||||
@@ -157,11 +181,15 @@ function Context.getTopElementAt(x, y)
|
||||
if interactive then
|
||||
return interactive
|
||||
end
|
||||
return element
|
||||
-- Non-interactive element hit: remember as fallback but keep looking
|
||||
-- for interactive children/siblings at same or lower z-index
|
||||
if not fallback then
|
||||
fallback = element
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
return fallback
|
||||
end
|
||||
|
||||
--- Set the focused element (centralizes focus management)
|
||||
|
||||
@@ -159,6 +159,13 @@
|
||||
---@field _pressed table? -- Internal: button press state tracking
|
||||
---@field _mouseDownPosition number? -- Internal: mouse down position for drag tracking
|
||||
---@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
|
||||
local Element = {}
|
||||
Element.__index = Element
|
||||
@@ -190,6 +197,7 @@ function Element.init(deps)
|
||||
Element._StateManager = deps.StateManager
|
||||
Element._GestureRecognizer = deps.GestureRecognizer
|
||||
Element._Performance = deps.Performance
|
||||
Element._Animation = deps.Animation
|
||||
end
|
||||
|
||||
---@param props ElementProps
|
||||
@@ -364,6 +372,14 @@ function Element.new(props)
|
||||
|
||||
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)
|
||||
self._stateId = self.id
|
||||
|
||||
@@ -371,6 +387,12 @@ function Element.new(props)
|
||||
local eventHandlerConfig = {
|
||||
onEvent = self.onEvent,
|
||||
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
|
||||
local state = Element._StateManager.getState(self._stateId)
|
||||
@@ -2018,6 +2040,33 @@ function Element.new(props)
|
||||
self._dirty = false -- Element properties have changed, needs layout
|
||||
self._childrenDirty = false -- Children have changed, needs layout
|
||||
|
||||
-- Debug draw: assign a deterministic color for element boundary visualization
|
||||
-- Uses a hash of the element ID to produce a stable hue, so colors don't flash each frame
|
||||
local function hashStringToHue(str)
|
||||
local hash = 5381
|
||||
for i = 1, #str do
|
||||
hash = ((hash * 33) + string.byte(str, i)) % 360
|
||||
end
|
||||
return hash
|
||||
end
|
||||
local hue = hashStringToHue(self.id or tostring(self))
|
||||
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
|
||||
end
|
||||
|
||||
@@ -2581,6 +2630,10 @@ function Element:destroy()
|
||||
|
||||
-- Clear onEvent to prevent closure leaks
|
||||
self.onEvent = nil
|
||||
|
||||
-- Clear touch callbacks to prevent closure leaks
|
||||
self.onTouchEvent = nil
|
||||
self.onGesture = nil
|
||||
end
|
||||
|
||||
--- Draw element and its children
|
||||
@@ -3060,6 +3113,39 @@ function Element:update(dt)
|
||||
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 newViewportHeight number
|
||||
function Element:recalculateUnits(newViewportWidth, newViewportHeight)
|
||||
@@ -3695,6 +3781,100 @@ function Element:setTransformOrigin(originX, originY)
|
||||
self.transform.originY = originY
|
||||
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
|
||||
---@param property string Property name or "all" for all properties
|
||||
---@param config table Transition config {duration, easing, delay, onComplete}
|
||||
@@ -3753,6 +3933,53 @@ function Element:removeTransition(property)
|
||||
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
|
||||
---@param property string Property name
|
||||
---@param value any New value
|
||||
@@ -3766,11 +3993,6 @@ function Element:setProperty(property, value)
|
||||
shouldTransition = transitionConfig ~= nil
|
||||
end
|
||||
|
||||
-- Don't transition if value is the same
|
||||
if self[property] == value then
|
||||
return
|
||||
end
|
||||
|
||||
-- Properties that affect layout and require invalidation
|
||||
local layoutProperties = {
|
||||
width = true,
|
||||
@@ -3792,6 +4014,50 @@ function Element:setProperty(property, value)
|
||||
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
|
||||
local currentValue = self[property]
|
||||
|
||||
@@ -3950,6 +4216,8 @@ function Element:_cleanup()
|
||||
self.onEnter = nil
|
||||
self.onImageLoad = nil
|
||||
self.onImageError = nil
|
||||
self.onTouchEvent = nil
|
||||
self.onGesture = nil
|
||||
end
|
||||
|
||||
return Element
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
---@class EventHandler
|
||||
---@field onEvent fun(element:Element, event:InputEvent)?
|
||||
---@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 _lastClickTime number?
|
||||
---@field _lastClickButton number?
|
||||
@@ -39,6 +45,12 @@ function EventHandler.new(config)
|
||||
|
||||
self.onEvent = config.onEvent
|
||||
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 {}
|
||||
|
||||
@@ -462,7 +474,7 @@ function EventHandler:processTouchEvents(element)
|
||||
local activeTouchIds = {}
|
||||
|
||||
-- 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 EventHandler._Performance and EventHandler._Performance.enabled then
|
||||
@@ -483,6 +495,12 @@ function EventHandler:processTouchEvents(element)
|
||||
activeTouches[tostring(id)] = true
|
||||
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
|
||||
for _, id in ipairs(touches) do
|
||||
local touchId = tostring(id)
|
||||
@@ -494,8 +512,15 @@ function EventHandler:processTouchEvents(element)
|
||||
|
||||
if isInside then
|
||||
if not self._touches[touchId] then
|
||||
-- New touch began
|
||||
self:_handleTouchBegan(element, touchId, tx, ty, pressure)
|
||||
-- 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
|
||||
self:_handleTouchBegan(element, touchId, tx, ty, pressure)
|
||||
trackedTouchCount = trackedTouchCount + 1
|
||||
end
|
||||
else
|
||||
-- Touch moved
|
||||
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
|
||||
@@ -561,6 +586,7 @@ function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure)
|
||||
touchEvent.dx = 0
|
||||
touchEvent.dy = 0
|
||||
self:_invokeCallback(element, touchEvent)
|
||||
self:_invokeTouchCallback(element, touchEvent)
|
||||
end
|
||||
|
||||
--- Handle touch moved event
|
||||
@@ -607,6 +633,7 @@ function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure)
|
||||
touchEvent.dx = dx
|
||||
touchEvent.dy = dy
|
||||
self:_invokeCallback(element, touchEvent)
|
||||
self:_invokeTouchCallback(element, touchEvent)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -634,6 +661,7 @@ function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure)
|
||||
touchEvent.dx = dx
|
||||
touchEvent.dy = dy
|
||||
self:_invokeCallback(element, touchEvent)
|
||||
self:_invokeTouchCallback(element, touchEvent)
|
||||
|
||||
-- Cleanup touch state
|
||||
self:_cleanupTouch(touchId)
|
||||
@@ -709,4 +737,52 @@ function EventHandler:_invokeCallback(element, event)
|
||||
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
|
||||
|
||||
@@ -92,10 +92,11 @@ end
|
||||
---@param event InputEvent Touch event
|
||||
function GestureRecognizer:processTouchEvent(event)
|
||||
if not event.touchId then
|
||||
return
|
||||
return nil
|
||||
end
|
||||
|
||||
local touchId = event.touchId
|
||||
local gestures = {}
|
||||
|
||||
-- Update touch state
|
||||
if event.type == "touchpress" then
|
||||
@@ -122,13 +123,17 @@ function GestureRecognizer:processTouchEvent(event)
|
||||
touch.phase = "moved"
|
||||
|
||||
-- Update gesture detection
|
||||
self:_detectPan(touchId, event)
|
||||
self:_detectSwipe(touchId, event)
|
||||
local panGesture = self:_detectPan(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
|
||||
if self:_getTouchCount() >= 2 then
|
||||
self:_detectPinch(event)
|
||||
self:_detectRotate(event)
|
||||
local pinchGesture = self:_detectPinch(event)
|
||||
if pinchGesture then table.insert(gestures, pinchGesture) end
|
||||
local rotateGesture = self:_detectRotate(event)
|
||||
if rotateGesture then table.insert(gestures, rotateGesture) end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -138,9 +143,12 @@ function GestureRecognizer:processTouchEvent(event)
|
||||
touch.phase = "ended"
|
||||
|
||||
-- Finalize gesture detection
|
||||
self:_detectTapEnded(touchId, event)
|
||||
self:_detectSwipeEnded(touchId, event)
|
||||
self:_detectPanEnded(touchId, event)
|
||||
local tapGesture = self:_detectTapEnded(touchId, event)
|
||||
if tapGesture then table.insert(gestures, tapGesture) end
|
||||
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
|
||||
self._touches[touchId] = nil
|
||||
@@ -151,6 +159,8 @@ function GestureRecognizer:processTouchEvent(event)
|
||||
self._touches[touchId] = nil
|
||||
self:_cancelAllGestures()
|
||||
end
|
||||
|
||||
return #gestures > 0 and gestures or nil
|
||||
end
|
||||
|
||||
--- Get number of active touches
|
||||
|
||||
@@ -108,6 +108,8 @@ function LayoutEngine.new(props, deps)
|
||||
childrenCount = 0,
|
||||
containerWidth = 0,
|
||||
containerHeight = 0,
|
||||
containerX = 0,
|
||||
containerY = 0,
|
||||
childrenHash = "",
|
||||
}
|
||||
|
||||
@@ -1180,51 +1182,56 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
|
||||
end
|
||||
|
||||
-- Recalculate position if using viewport or percentage units
|
||||
if self.element.units.x.unit ~= "px" then
|
||||
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 offsetX = Units.resolve(
|
||||
self.element.units.x.value,
|
||||
self.element.units.x.unit,
|
||||
newViewportWidth,
|
||||
newViewportHeight,
|
||||
parentWidth
|
||||
)
|
||||
self.element.x = baseX + offsetX
|
||||
else
|
||||
-- For pixel units, update position relative to parent's new position (with base scaling)
|
||||
if self.element.parent then
|
||||
local baseX = self.element.parent.x
|
||||
local scaledOffset = self._Context.baseScale and (self.element.units.x.value * scaleX)
|
||||
or self.element.units.x.value
|
||||
self.element.x = baseX + scaledOffset
|
||||
elseif self._Context.baseScale then
|
||||
-- Top-level element with pixel position - apply base scaling
|
||||
self.element.x = self.element.units.x.value * scaleX
|
||||
-- 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
|
||||
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 offsetX = Units.resolve(
|
||||
self.element.units.x.value,
|
||||
self.element.units.x.unit,
|
||||
newViewportWidth,
|
||||
newViewportHeight,
|
||||
parentWidth
|
||||
)
|
||||
self.element.x = baseX + offsetX
|
||||
else
|
||||
-- For pixel units, update position relative to parent's new position (with base scaling)
|
||||
if self.element.parent then
|
||||
local baseX = self.element.parent.x
|
||||
local scaledOffset = self._Context.baseScale and (self.element.units.x.value * scaleX)
|
||||
or self.element.units.x.value
|
||||
self.element.x = baseX + scaledOffset
|
||||
elseif self._Context.baseScale then
|
||||
-- Top-level element with pixel position - apply base scaling
|
||||
self.element.x = self.element.units.x.value * scaleX
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if self.element.units.y.unit ~= "px" then
|
||||
local parentHeight = self.element.parent and self.element.parent.height or newViewportHeight
|
||||
local baseY = self.element.parent and self.element.parent.y or 0
|
||||
local offsetY = Units.resolve(
|
||||
self.element.units.y.value,
|
||||
self.element.units.y.unit,
|
||||
newViewportWidth,
|
||||
newViewportHeight,
|
||||
parentHeight
|
||||
)
|
||||
self.element.y = baseY + offsetY
|
||||
else
|
||||
-- For pixel units, update position relative to parent's new position (with base scaling)
|
||||
if self.element.parent then
|
||||
local baseY = self.element.parent.y
|
||||
local scaledOffset = self._Context.baseScale and (self.element.units.y.value * scaleY)
|
||||
or self.element.units.y.value
|
||||
self.element.y = baseY + scaledOffset
|
||||
elseif self._Context.baseScale then
|
||||
-- Top-level element with pixel position - apply base scaling
|
||||
self.element.y = self.element.units.y.value * scaleY
|
||||
if self.element.units.y.unit ~= "px" then
|
||||
local parentHeight = self.element.parent and self.element.parent.height or newViewportHeight
|
||||
local baseY = self.element.parent and self.element.parent.y or 0
|
||||
local offsetY = Units.resolve(
|
||||
self.element.units.y.value,
|
||||
self.element.units.y.unit,
|
||||
newViewportWidth,
|
||||
newViewportHeight,
|
||||
parentHeight
|
||||
)
|
||||
self.element.y = baseY + offsetY
|
||||
else
|
||||
-- For pixel units, update position relative to parent's new position (with base scaling)
|
||||
if self.element.parent then
|
||||
local baseY = self.element.parent.y
|
||||
local scaledOffset = self._Context.baseScale and (self.element.units.y.value * scaleY)
|
||||
or self.element.units.y.value
|
||||
self.element.y = baseY + scaledOffset
|
||||
elseif self._Context.baseScale then
|
||||
-- Top-level element with pixel position - apply base scaling
|
||||
self.element.y = self.element.units.y.value * scaleY
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1504,6 +1511,8 @@ function LayoutEngine:_canSkipLayout()
|
||||
local childrenCount = #self.element.children
|
||||
local containerWidth = self.element.width
|
||||
local containerHeight = self.element.height
|
||||
local containerX = self.element.x
|
||||
local containerY = self.element.y
|
||||
|
||||
-- Generate simple hash of children dimensions
|
||||
local childrenHash = ""
|
||||
@@ -1520,6 +1529,8 @@ function LayoutEngine:_canSkipLayout()
|
||||
cache.childrenCount == childrenCount
|
||||
and cache.containerWidth == containerWidth
|
||||
and cache.containerHeight == containerHeight
|
||||
and cache.containerX == containerX
|
||||
and cache.containerY == containerY
|
||||
and cache.childrenHash == childrenHash
|
||||
then
|
||||
return true -- Layout hasn't changed, can skip
|
||||
@@ -1529,6 +1540,8 @@ function LayoutEngine:_canSkipLayout()
|
||||
cache.childrenCount = childrenCount
|
||||
cache.containerWidth = containerWidth
|
||||
cache.containerHeight = containerHeight
|
||||
cache.containerX = containerX
|
||||
cache.containerY = containerY
|
||||
cache.childrenHash = childrenHash
|
||||
|
||||
return false -- Layout has changed, must recalculate
|
||||
|
||||
@@ -139,9 +139,9 @@ function Performance:stopTimer(name)
|
||||
|
||||
-- Check for warnings
|
||||
if elapsed > self.criticalThresholdMs then
|
||||
self:addWarning(name, elapsed, "critical")
|
||||
self:_addWarning(name, elapsed, "critical")
|
||||
elseif elapsed > self.warningThresholdMs then
|
||||
self:addWarning(name, elapsed, "warning")
|
||||
self:_addWarning(name, elapsed, "warning")
|
||||
end
|
||||
|
||||
if self.logToConsole then
|
||||
|
||||
@@ -85,8 +85,15 @@ local AnimationProps = {}
|
||||
---@field onTextChangeDeferred boolean? -- Whether onTextChange callback should be deferred (default: false)
|
||||
---@field onEnter fun(element:Element)? -- Callback when Enter key is pressed
|
||||
---@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 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 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)
|
||||
@@ -198,6 +205,8 @@ local TransformProps
|
||||
---@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 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 = {}
|
||||
|
||||
--=====================================--
|
||||
|
||||
531
testing/__tests__/animation_chaining_test.lua
Normal 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
|
||||
853
testing/__tests__/animation_group_test.lua
Normal 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
|
||||
@@ -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
|
||||
1902
testing/__tests__/touch_test.lua
Normal file
442
testing/__tests__/transition_test.lua
Normal 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
|
||||
@@ -37,6 +37,8 @@ local luaunit = require("testing.luaunit")
|
||||
|
||||
local testFiles = {
|
||||
"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__/blur_test.lua",
|
||||
"testing/__tests__/calc_test.lua",
|
||||
@@ -66,7 +68,8 @@ local testFiles = {
|
||||
"testing/__tests__/scrollbar_placement_test.lua",
|
||||
"testing/__tests__/text_editor_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__/utils_test.lua",
|
||||
}
|
||||
|
||||
BIN
themes/metal/Bar/Bar01a.9.png
Normal file
|
After Width: | Height: | Size: 161 B |
BIN
themes/metal/Bar/Bar02a.9.png
Normal file
|
After Width: | Height: | Size: 166 B |
BIN
themes/metal/Bar/Bar03a.9.png
Normal file
|
After Width: | Height: | Size: 161 B |
BIN
themes/metal/Bar/Bar04a.9.png
Normal file
|
After Width: | Height: | Size: 173 B |
BIN
themes/metal/BarDropdown/BarDropdown01a.9.png
Normal file
|
After Width: | Height: | Size: 185 B |
BIN
themes/metal/BarKnob/BarKnob01a.9.png
Normal file
|
After Width: | Height: | Size: 103 B |
BIN
themes/metal/BarKnob/BarKnob01a.png
Normal file
|
After Width: | Height: | Size: 129 B |
BIN
themes/metal/BarKnob/BarKnob02a.9.png
Normal file
|
After Width: | Height: | Size: 117 B |
BIN
themes/metal/BarKnob/BarKnob02a.png
Normal file
|
After Width: | Height: | Size: 121 B |
BIN
themes/metal/BarKnob/BarKnob03a.9.png
Normal file
|
After Width: | Height: | Size: 99 B |
BIN
themes/metal/BarKnob/BarKnob03a.png
Normal file
|
After Width: | Height: | Size: 110 B |
BIN
themes/metal/BarKnob/BarKnob04a.9.png
Normal file
|
After Width: | Height: | Size: 120 B |
BIN
themes/metal/BarKnob/BarKnob04a.png
Normal file
|
After Width: | Height: | Size: 138 B |
BIN
themes/metal/BarKnobDropdown/BarKnobDropdown01a.9.png
Normal file
|
After Width: | Height: | Size: 111 B |
BIN
themes/metal/BarKnobToggle/BarKnobToggle01a.9.png
Normal file
|
After Width: | Height: | Size: 165 B |
BIN
themes/metal/BarToggle/BarToggle01a.9.png
Normal file
|
After Width: | Height: | Size: 246 B |
BIN
themes/metal/Button/Button01a_1.9.png
Normal file
|
After Width: | Height: | Size: 258 B |
BIN
themes/metal/Button/Button01a_2.9.png
Normal file
|
After Width: | Height: | Size: 249 B |
BIN
themes/metal/Button/Button01a_3.9.png
Normal file
|
After Width: | Height: | Size: 258 B |
BIN
themes/metal/Button/Button01a_4.9.png
Normal file
|
After Width: | Height: | Size: 251 B |
BIN
themes/metal/Button/Button02a_1.9.png
Normal file
|
After Width: | Height: | Size: 266 B |
BIN
themes/metal/Button/Button02a_2.9.png
Normal file
|
After Width: | Height: | Size: 262 B |
BIN
themes/metal/Button/Button02a_3.9.png
Normal file
|
After Width: | Height: | Size: 264 B |
BIN
themes/metal/Button/Button02a_4.9.png
Normal file
|
After Width: | Height: | Size: 265 B |
BIN
themes/metal/Cursor/Cursor01a.png
Normal file
|
After Width: | Height: | Size: 180 B |
BIN
themes/metal/Cursor/Cursor01b.png
Normal file
|
After Width: | Height: | Size: 151 B |
BIN
themes/metal/Cursor/Cursor02a.png
Normal file
|
After Width: | Height: | Size: 206 B |
BIN
themes/metal/Cursor/Cursor02b.png
Normal file
|
After Width: | Height: | Size: 151 B |
BIN
themes/metal/Cursor/Cursor03a.png
Normal file
|
After Width: | Height: | Size: 180 B |
BIN
themes/metal/Cursor/Cursor03b.png
Normal file
|
After Width: | Height: | Size: 143 B |
BIN
themes/metal/Filler/FillerBlue01a.png
Normal file
|
After Width: | Height: | Size: 94 B |
BIN
themes/metal/Filler/FillerCyan01a.png
Normal file
|
After Width: | Height: | Size: 94 B |
BIN
themes/metal/Filler/FillerGreen01a.png
Normal file
|
After Width: | Height: | Size: 94 B |
BIN
themes/metal/Filler/FillerGrey01a.png
Normal file
|
After Width: | Height: | Size: 94 B |
BIN
themes/metal/Filler/FillerOrange01a.png
Normal file
|
After Width: | Height: | Size: 94 B |
BIN
themes/metal/Filler/FillerRed01a.png
Normal file
|
After Width: | Height: | Size: 94 B |
BIN
themes/metal/Filler/FillerWhite01a.png
Normal file
|
After Width: | Height: | Size: 94 B |
BIN
themes/metal/Filler/FillerYellow01a.png
Normal file
|
After Width: | Height: | Size: 94 B |
BIN
themes/metal/Frame/Frame01a.9.png
Normal file
|
After Width: | Height: | Size: 525 B |
BIN
themes/metal/Frame/Frame01b.9.png
Normal file
|
After Width: | Height: | Size: 368 B |
BIN
themes/metal/Frame/Frame02a.9.png
Normal file
|
After Width: | Height: | Size: 331 B |
BIN
themes/metal/Frame/Frame02b.9.png
Normal file
|
After Width: | Height: | Size: 256 B |
BIN
themes/metal/Frame/Frame03a.9.png
Normal file
|
After Width: | Height: | Size: 378 B |
BIN
themes/metal/Frame/Frame03b.9.png
Normal file
|
After Width: | Height: | Size: 271 B |
BIN
themes/metal/Guide.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
themes/metal/IconConfirm/IconConfirm01a.png
Normal file
|
After Width: | Height: | Size: 96 B |
BIN
themes/metal/IconConfirm/IconConfirm02a.png
Normal file
|
After Width: | Height: | Size: 127 B |
BIN
themes/metal/IconConfirm/IconConfirm03a.png
Normal file
|
After Width: | Height: | Size: 119 B |
38
themes/metal/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
This image pack is not fully converted to 9 patch, it is being done as I have a need for.
|
||||
- [x] Bar
|
||||
- [x] BarDropdown
|
||||
- [x] BarKnob
|
||||
- [x] BarKnobDropdown
|
||||
- [x] BarToggle
|
||||
- [x] Button
|
||||
- [ ] Cursor
|
||||
- [ ] Filler
|
||||
- [x] Frame
|
||||
- [ ] IconConfirm
|
||||
- [x] Select
|
||||
- [ ] Slot
|
||||
- [x] TextField
|
||||
- [ ] Toggle
|
||||
|
||||
You can compare the BarKnob's for a before and after for the 9 patch additions
|
||||
|
||||
This theme is a part of the [Complete UI Essential Pack](https://crusenho.itch.io/complete-ui-essential-pack) by [Crusenho](https://crusenho.itch.io/)
|
||||
This theme is governed by the following license:
|
||||
|
||||
Attribution 4.0 International (CC BY 4.0)
|
||||
|
||||
You are free to:
|
||||
|
||||
Share — copy and redistribute the material in any medium or format for any purpose, even commercially.
|
||||
|
||||
Adapt — remix, transform, and build upon the material for any purpose, even commercially.
|
||||
|
||||
The licensor cannot revoke these freedoms as long as you follow the license terms.
|
||||
|
||||
Under the following terms:
|
||||
|
||||
Attribution - You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
|
||||
|
||||
No additional restrictions - You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.
|
||||
|
||||
License Link: <https://creativecommons.org/licenses/by/4.0/>
|
||||
BIN
themes/metal/Select/Select01a.9.png
Normal file
|
After Width: | Height: | Size: 281 B |
BIN
themes/metal/Slot/Slot01a.png
Normal file
|
After Width: | Height: | Size: 295 B |
BIN
themes/metal/Slot/Slot01b.png
Normal file
|
After Width: | Height: | Size: 281 B |
BIN
themes/metal/Slot/Slot01c.png
Normal file
|
After Width: | Height: | Size: 297 B |
BIN
themes/metal/Slot/Slot02a.png
Normal file
|
After Width: | Height: | Size: 315 B |
BIN
themes/metal/Slot/Slot02b.png
Normal file
|
After Width: | Height: | Size: 309 B |
BIN
themes/metal/Slot/Slot02c.png
Normal file
|
After Width: | Height: | Size: 315 B |
BIN
themes/metal/TextField/Textfield01a.9.png
Normal file
|
After Width: | Height: | Size: 299 B |
BIN
themes/metal/TextField/Textfield01b.9.png
Normal file
|
After Width: | Height: | Size: 274 B |
BIN
themes/metal/Toggle/Toggle01a.9.png
Normal file
|
After Width: | Height: | Size: 199 B |
BIN
themes/metal/Toggle/Toggle01a.png
Normal file
|
After Width: | Height: | Size: 281 B |
BIN
themes/metal/Toggle/Toggle01b.png
Normal file
|
After Width: | Height: | Size: 238 B |
BIN
themes/metal/Toggle/Toggle02a.png
Normal file
|
After Width: | Height: | Size: 304 B |
BIN
themes/metal/Toggle/Toggle02b.png
Normal file
|
After Width: | Height: | Size: 255 B |
BIN
themes/metal/Toggle/Toggle03a_1.png
Normal file
|
After Width: | Height: | Size: 219 B |
BIN
themes/metal/Toggle/Toggle03a_2.png
Normal file
|
After Width: | Height: | Size: 216 B |
BIN
themes/metal/Toggle/Toggle03a_3.png
Normal file
|
After Width: | Height: | Size: 216 B |
BIN
themes/metal/Toggle/Toggle03a_4.png
Normal file
|
After Width: | Height: | Size: 244 B |
BIN
themes/metal/Toggle/Toggle03b_1.png
Normal file
|
After Width: | Height: | Size: 204 B |
BIN
themes/metal/Toggle/Toggle03b_2.png
Normal file
|
After Width: | Height: | Size: 200 B |
BIN
themes/metal/Toggle/Toggle03b_3.png
Normal file
|
After Width: | Height: | Size: 200 B |
BIN
themes/metal/Toggle/Toggle03b_4.png
Normal file
|
After Width: | Height: | Size: 227 B |
BIN
themes/metal/Toggle/Toggle04a_1.png
Normal file
|
After Width: | Height: | Size: 255 B |
BIN
themes/metal/Toggle/Toggle04a_2.png
Normal file
|
After Width: | Height: | Size: 261 B |
BIN
themes/metal/Toggle/Toggle04a_3.png
Normal file
|
After Width: | Height: | Size: 261 B |
BIN
themes/metal/Toggle/Toggle04a_4.png
Normal file
|
After Width: | Height: | Size: 279 B |