Compare commits
16 Commits
b671f501ec
...
f5608980e3
| Author | SHA1 | Date | |
|---|---|---|---|
| f5608980e3 | |||
| 9a126cb87e | |||
| caf604445f | |||
| ffab292c04 | |||
| 309ebde985 | |||
| 998469141a | |||
|
|
4e14b375e0 | ||
| f1fae85595 | |||
| d948ab2b4c | |||
| 6ae04b5e82 | |||
| 3c3f26b74a | |||
| 2b5957f264 | |||
|
|
7f72623168 | ||
| 2f62810a91 | |||
| 7b34c71623 | |||
| a17e524730 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,7 +1,4 @@
|
|||||||
Cartographer.lua
|
|
||||||
OverlayStats.lua
|
OverlayStats.lua
|
||||||
lume.lua
|
|
||||||
lurker.lua
|
|
||||||
themes/metal/
|
themes/metal/
|
||||||
themes/space/
|
themes/space/
|
||||||
.DS_STORE
|
.DS_STORE
|
||||||
@@ -17,3 +14,4 @@ memory_scan*
|
|||||||
*_report*
|
*_report*
|
||||||
*.key
|
*.key
|
||||||
*.rock
|
*.rock
|
||||||
|
*.rockspec
|
||||||
|
|||||||
324
FlexLove.lua
324
FlexLove.lua
@@ -63,7 +63,7 @@ local enums = utils.enums
|
|||||||
|
|
||||||
---@class FlexLove
|
---@class FlexLove
|
||||||
local flexlove = Context
|
local flexlove = Context
|
||||||
flexlove._VERSION = "0.8.0"
|
flexlove._VERSION = "0.10.0"
|
||||||
flexlove._DESCRIPTION = "UI Library for LÖVE Framework based on flexbox"
|
flexlove._DESCRIPTION = "UI Library for LÖVE Framework based on flexbox"
|
||||||
flexlove._URL = "https://github.com/mikefreno/FlexLove"
|
flexlove._URL = "https://github.com/mikefreno/FlexLove"
|
||||||
flexlove._LICENSE = [[
|
flexlove._LICENSE = [[
|
||||||
@@ -112,6 +112,14 @@ flexlove._deferredCallbacks = {}
|
|||||||
-- Track accumulated delta time for immediate mode updates
|
-- Track accumulated delta time for immediate mode updates
|
||||||
flexlove._accumulatedDt = 0
|
flexlove._accumulatedDt = 0
|
||||||
|
|
||||||
|
-- Touch ownership tracking: maps touch ID (string) to the element that owns it
|
||||||
|
---@type table<string, Element>
|
||||||
|
flexlove._touchOwners = {}
|
||||||
|
|
||||||
|
-- Shared GestureRecognizer instance for touch routing (initialized in init())
|
||||||
|
---@type GestureRecognizer|nil
|
||||||
|
flexlove._gestureRecognizer = nil
|
||||||
|
|
||||||
--- Check if FlexLove initialization is complete and ready to create elements
|
--- Check if FlexLove initialization is complete and ready to create elements
|
||||||
--- Use this before creating elements to avoid automatic queueing
|
--- Use this before creating elements to avoid automatic queueing
|
||||||
---@return boolean ready True if FlexLove is initialized and ready to use
|
---@return boolean ready True if FlexLove is initialized and ready to use
|
||||||
@@ -207,6 +215,11 @@ function flexlove.init(config)
|
|||||||
LayoutEngine.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance, FFI = flexlove._FFI })
|
LayoutEngine.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance, FFI = flexlove._FFI })
|
||||||
EventHandler.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance, InputEvent = InputEvent, utils = utils })
|
EventHandler.init({ ErrorHandler = flexlove._ErrorHandler, Performance = flexlove._Performance, InputEvent = InputEvent, utils = utils })
|
||||||
|
|
||||||
|
-- Initialize shared GestureRecognizer for touch routing
|
||||||
|
if GestureRecognizer then
|
||||||
|
flexlove._gestureRecognizer = GestureRecognizer.new({}, { InputEvent = InputEvent, utils = utils })
|
||||||
|
end
|
||||||
|
|
||||||
flexlove._defaultDependencies = {
|
flexlove._defaultDependencies = {
|
||||||
Context = Context,
|
Context = Context,
|
||||||
Theme = Theme,
|
Theme = Theme,
|
||||||
@@ -232,6 +245,7 @@ function flexlove.init(config)
|
|||||||
ErrorHandler = flexlove._ErrorHandler,
|
ErrorHandler = flexlove._ErrorHandler,
|
||||||
Performance = flexlove._Performance,
|
Performance = flexlove._Performance,
|
||||||
Transform = Transform,
|
Transform = Transform,
|
||||||
|
Animation = Animation,
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Initialize Element module with dependencies
|
-- Initialize Element module with dependencies
|
||||||
@@ -294,6 +308,10 @@ function flexlove.init(config)
|
|||||||
flexlove.initialized = true
|
flexlove.initialized = true
|
||||||
flexlove._initState = "ready"
|
flexlove._initState = "ready"
|
||||||
|
|
||||||
|
-- Configure debug draw overlay
|
||||||
|
flexlove._debugDraw = config.debugDraw or false
|
||||||
|
flexlove._debugDrawKey = config.debugDrawKey or nil
|
||||||
|
|
||||||
-- Process all queued element creations
|
-- Process all queued element creations
|
||||||
local queue = flexlove._initQueue
|
local queue = flexlove._initQueue
|
||||||
flexlove._initQueue = {} -- Clear queue before processing to prevent re-entry issues
|
flexlove._initQueue = {} -- Clear queue before processing to prevent re-entry issues
|
||||||
@@ -557,6 +575,49 @@ flexlove._backdropCanvas = nil
|
|||||||
---@type {width: number, height: number}
|
---@type {width: number, height: number}
|
||||||
flexlove._canvasDimensions = { width = 0, height = 0 }
|
flexlove._canvasDimensions = { width = 0, height = 0 }
|
||||||
|
|
||||||
|
--- Recursively draw debug boundaries for an element and all its children
|
||||||
|
--- Draws regardless of visibility/opacity to reveal hidden or transparent elements
|
||||||
|
---@param element Element
|
||||||
|
local function drawDebugElement(element)
|
||||||
|
local color = element._debugColor
|
||||||
|
if color then
|
||||||
|
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||||
|
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||||
|
|
||||||
|
-- Fill with 0.5 opacity
|
||||||
|
love.graphics.setColor(color[1], color[2], color[3], 0.5)
|
||||||
|
love.graphics.rectangle("fill", element.x, element.y, bw, bh)
|
||||||
|
|
||||||
|
-- Border with full opacity, 1px line
|
||||||
|
love.graphics.setColor(color[1], color[2], color[3], 1)
|
||||||
|
love.graphics.setLineWidth(1)
|
||||||
|
love.graphics.rectangle("line", element.x, element.y, bw, bh)
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, child in ipairs(element.children) do
|
||||||
|
drawDebugElement(child)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Render the debug draw overlay for all elements in the tree
|
||||||
|
--- Traverses every element regardless of visibility or opacity
|
||||||
|
function flexlove._renderDebugOverlay()
|
||||||
|
-- Save current graphics state
|
||||||
|
local prevR, prevG, prevB, prevA = love.graphics.getColor()
|
||||||
|
local prevLineWidth = love.graphics.getLineWidth()
|
||||||
|
|
||||||
|
-- Clear any active scissor so debug draws are always visible
|
||||||
|
love.graphics.setScissor()
|
||||||
|
|
||||||
|
for _, win in ipairs(flexlove.topElements) do
|
||||||
|
drawDebugElement(win)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Restore graphics state
|
||||||
|
love.graphics.setColor(prevR, prevG, prevB, prevA)
|
||||||
|
love.graphics.setLineWidth(prevLineWidth)
|
||||||
|
end
|
||||||
|
|
||||||
--- Render all UI elements with optional backdrop blur support for glassmorphic effects
|
--- Render all UI elements with optional backdrop blur support for glassmorphic effects
|
||||||
--- Place your game scene in gameDrawFunc to enable backdrop blur on UI elements; use postDrawFunc for overlays
|
--- Place your game scene in gameDrawFunc to enable backdrop blur on UI elements; use postDrawFunc for overlays
|
||||||
---@param gameDrawFunc function|nil pass component draws that should be affected by a backdrop blur
|
---@param gameDrawFunc function|nil pass component draws that should be affected by a backdrop blur
|
||||||
@@ -667,6 +728,11 @@ function flexlove.draw(gameDrawFunc, postDrawFunc)
|
|||||||
-- Render performance HUD if enabled
|
-- Render performance HUD if enabled
|
||||||
flexlove._Performance:renderHUD()
|
flexlove._Performance:renderHUD()
|
||||||
|
|
||||||
|
-- Render debug draw overlay if enabled
|
||||||
|
if flexlove._debugDraw then
|
||||||
|
flexlove._renderDebugOverlay()
|
||||||
|
end
|
||||||
|
|
||||||
love.graphics.setCanvas(outerCanvas)
|
love.graphics.setCanvas(outerCanvas)
|
||||||
|
|
||||||
-- NOTE: Deferred callbacks are NOT executed here because the calling code
|
-- NOTE: Deferred callbacks are NOT executed here because the calling code
|
||||||
@@ -918,8 +984,10 @@ end
|
|||||||
---@param scancode string
|
---@param scancode string
|
||||||
---@param isrepeat boolean
|
---@param isrepeat boolean
|
||||||
function flexlove.keypressed(key, scancode, isrepeat)
|
function flexlove.keypressed(key, scancode, isrepeat)
|
||||||
-- Handle performance HUD toggle
|
|
||||||
flexlove._Performance:keypressed(key)
|
flexlove._Performance:keypressed(key)
|
||||||
|
if flexlove._debugDrawKey and key == flexlove._debugDrawKey then
|
||||||
|
flexlove._debugDraw = not flexlove._debugDraw
|
||||||
|
end
|
||||||
local focusedElement = Context.getFocused()
|
local focusedElement = Context.getFocused()
|
||||||
if focusedElement then
|
if focusedElement then
|
||||||
focusedElement:keypressed(key, scancode, isrepeat)
|
focusedElement:keypressed(key, scancode, isrepeat)
|
||||||
@@ -1052,6 +1120,239 @@ function flexlove.wheelmoved(dx, dy)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- Find the touch-interactive element at a given position using z-index ordering
|
||||||
|
--- Similar to getElementAtPosition but checks for touch-enabled elements
|
||||||
|
---@param x number Touch X position
|
||||||
|
---@param y number Touch Y position
|
||||||
|
---@return Element|nil element The topmost touch-enabled element at position
|
||||||
|
function flexlove._getTouchElementAtPosition(x, y)
|
||||||
|
local candidates = {}
|
||||||
|
|
||||||
|
local function collectTouchHits(element, scrollOffsetX, scrollOffsetY)
|
||||||
|
scrollOffsetX = scrollOffsetX or 0
|
||||||
|
scrollOffsetY = scrollOffsetY or 0
|
||||||
|
|
||||||
|
local bx = element.x
|
||||||
|
local by = element.y
|
||||||
|
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||||
|
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||||
|
|
||||||
|
-- Adjust touch position by accumulated scroll offset for hit testing
|
||||||
|
local adjustedX = x + scrollOffsetX
|
||||||
|
local adjustedY = y + scrollOffsetY
|
||||||
|
|
||||||
|
if adjustedX >= bx and adjustedX <= bx + bw and adjustedY >= by and adjustedY <= by + bh then
|
||||||
|
-- Check if element is touch-enabled and interactive
|
||||||
|
if element.touchEnabled and not element.disabled and (element.onEvent or element.onTouchEvent or element.onGesture) then
|
||||||
|
table.insert(candidates, element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Check if this element has scrollable overflow (for touch scrolling)
|
||||||
|
local overflowX = element.overflowX or element.overflow
|
||||||
|
local overflowY = element.overflowY or element.overflow
|
||||||
|
local hasScrollableOverflow = (
|
||||||
|
overflowX == "scroll"
|
||||||
|
or overflowX == "auto"
|
||||||
|
or overflowY == "scroll"
|
||||||
|
or overflowY == "auto"
|
||||||
|
or overflowX == "hidden"
|
||||||
|
or overflowY == "hidden"
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Accumulate scroll offset for children
|
||||||
|
local childScrollOffsetX = scrollOffsetX
|
||||||
|
local childScrollOffsetY = scrollOffsetY
|
||||||
|
if hasScrollableOverflow then
|
||||||
|
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
|
||||||
|
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, child in ipairs(element.children) do
|
||||||
|
collectTouchHits(child, childScrollOffsetX, childScrollOffsetY)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, element in ipairs(flexlove.topElements) do
|
||||||
|
collectTouchHits(element)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Sort by z-index (highest first) — topmost element wins
|
||||||
|
table.sort(candidates, function(a, b)
|
||||||
|
return a.z > b.z
|
||||||
|
end)
|
||||||
|
|
||||||
|
return candidates[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Handle touch press events from LÖVE's touch input system
|
||||||
|
--- Routes touch to the topmost element at the touch position and assigns touch ownership
|
||||||
|
--- Hook this to love.touchpressed() to enable touch interaction
|
||||||
|
---@param id lightuserdata Touch identifier from LÖVE
|
||||||
|
---@param x number Touch X position in screen coordinates
|
||||||
|
---@param y number Touch Y position in screen coordinates
|
||||||
|
---@param dx number X distance moved (usually 0 on press)
|
||||||
|
---@param dy number Y distance moved (usually 0 on press)
|
||||||
|
---@param pressure number Touch pressure (0-1, if supported by device)
|
||||||
|
function flexlove.touchpressed(id, x, y, dx, dy, pressure)
|
||||||
|
local touchId = tostring(id)
|
||||||
|
pressure = pressure or 1.0
|
||||||
|
|
||||||
|
-- Apply base scaling if configured
|
||||||
|
local touchX, touchY = x, y
|
||||||
|
if flexlove.baseScale then
|
||||||
|
touchX = x / flexlove.scaleFactors.x
|
||||||
|
touchY = y / flexlove.scaleFactors.y
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Find the topmost touch-enabled element at this position
|
||||||
|
local element = flexlove._getTouchElementAtPosition(touchX, touchY)
|
||||||
|
|
||||||
|
if element then
|
||||||
|
-- Assign touch ownership: this element receives all subsequent events for this touch
|
||||||
|
flexlove._touchOwners[touchId] = element
|
||||||
|
|
||||||
|
-- Create and route touch event
|
||||||
|
local touchEvent = InputEvent.fromTouch(id, touchX, touchY, "began", pressure)
|
||||||
|
element:handleTouchEvent(touchEvent)
|
||||||
|
|
||||||
|
-- Feed to shared gesture recognizer
|
||||||
|
if flexlove._gestureRecognizer then
|
||||||
|
local gestures = flexlove._gestureRecognizer:processTouchEvent(touchEvent)
|
||||||
|
if gestures then
|
||||||
|
for _, gesture in ipairs(gestures) do
|
||||||
|
element:handleGesture(gesture)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Route to scroll manager for scrollable elements
|
||||||
|
if element._scrollManager then
|
||||||
|
local overflowX = element.overflowX or element.overflow
|
||||||
|
local overflowY = element.overflowY or element.overflow
|
||||||
|
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then
|
||||||
|
element._scrollManager:handleTouchPress(touchX, touchY)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Handle touch move events from LÖVE's touch input system
|
||||||
|
--- Routes touch to the element that owns this touch ID (from the original press), regardless of current position
|
||||||
|
--- Hook this to love.touchmoved() to enable touch drag and gesture tracking
|
||||||
|
---@param id lightuserdata Touch identifier from LÖVE
|
||||||
|
---@param x number Touch X position in screen coordinates
|
||||||
|
---@param y number Touch Y position in screen coordinates
|
||||||
|
---@param dx number X distance moved since last event
|
||||||
|
---@param dy number Y distance moved since last event
|
||||||
|
---@param pressure number Touch pressure (0-1, if supported by device)
|
||||||
|
function flexlove.touchmoved(id, x, y, dx, dy, pressure)
|
||||||
|
local touchId = tostring(id)
|
||||||
|
pressure = pressure or 1.0
|
||||||
|
|
||||||
|
-- Apply base scaling if configured
|
||||||
|
local touchX, touchY = x, y
|
||||||
|
if flexlove.baseScale then
|
||||||
|
touchX = x / flexlove.scaleFactors.x
|
||||||
|
touchY = y / flexlove.scaleFactors.y
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Route to owning element (touch ownership persists from press to release)
|
||||||
|
local element = flexlove._touchOwners[touchId]
|
||||||
|
if element then
|
||||||
|
-- Create and route touch event
|
||||||
|
local touchEvent = InputEvent.fromTouch(id, touchX, touchY, "moved", pressure)
|
||||||
|
element:handleTouchEvent(touchEvent)
|
||||||
|
|
||||||
|
-- Feed to shared gesture recognizer
|
||||||
|
if flexlove._gestureRecognizer then
|
||||||
|
local gestures = flexlove._gestureRecognizer:processTouchEvent(touchEvent)
|
||||||
|
if gestures then
|
||||||
|
for _, gesture in ipairs(gestures) do
|
||||||
|
element:handleGesture(gesture)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Route to scroll manager for scrollable elements
|
||||||
|
if element._scrollManager then
|
||||||
|
local overflowX = element.overflowX or element.overflow
|
||||||
|
local overflowY = element.overflowY or element.overflow
|
||||||
|
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then
|
||||||
|
element._scrollManager:handleTouchMove(touchX, touchY)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Handle touch release events from LÖVE's touch input system
|
||||||
|
--- Routes touch to the owning element and cleans up touch ownership tracking
|
||||||
|
--- Hook this to love.touchreleased() to properly end touch interactions
|
||||||
|
---@param id lightuserdata Touch identifier from LÖVE
|
||||||
|
---@param x number Touch X position in screen coordinates
|
||||||
|
---@param y number Touch Y position in screen coordinates
|
||||||
|
---@param dx number X distance moved since last event
|
||||||
|
---@param dy number Y distance moved since last event
|
||||||
|
---@param pressure number Touch pressure (0-1, if supported by device)
|
||||||
|
function flexlove.touchreleased(id, x, y, dx, dy, pressure)
|
||||||
|
local touchId = tostring(id)
|
||||||
|
pressure = pressure or 1.0
|
||||||
|
|
||||||
|
-- Apply base scaling if configured
|
||||||
|
local touchX, touchY = x, y
|
||||||
|
if flexlove.baseScale then
|
||||||
|
touchX = x / flexlove.scaleFactors.x
|
||||||
|
touchY = y / flexlove.scaleFactors.y
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Route to owning element
|
||||||
|
local element = flexlove._touchOwners[touchId]
|
||||||
|
if element then
|
||||||
|
-- Create and route touch event
|
||||||
|
local touchEvent = InputEvent.fromTouch(id, touchX, touchY, "ended", pressure)
|
||||||
|
element:handleTouchEvent(touchEvent)
|
||||||
|
|
||||||
|
-- Feed to shared gesture recognizer
|
||||||
|
if flexlove._gestureRecognizer then
|
||||||
|
local gestures = flexlove._gestureRecognizer:processTouchEvent(touchEvent)
|
||||||
|
if gestures then
|
||||||
|
for _, gesture in ipairs(gestures) do
|
||||||
|
element:handleGesture(gesture)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Route to scroll manager for scrollable elements
|
||||||
|
if element._scrollManager then
|
||||||
|
local overflowX = element.overflowX or element.overflow
|
||||||
|
local overflowY = element.overflowY or element.overflow
|
||||||
|
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then
|
||||||
|
element._scrollManager:handleTouchRelease()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Clean up touch ownership (touch is complete)
|
||||||
|
flexlove._touchOwners[touchId] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get the number of currently active touches being tracked
|
||||||
|
---@return number count Number of active touch points
|
||||||
|
function flexlove.getActiveTouchCount()
|
||||||
|
local count = 0
|
||||||
|
for _ in pairs(flexlove._touchOwners) do
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
return count
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get the element that currently owns a specific touch
|
||||||
|
---@param touchId string|lightuserdata Touch identifier
|
||||||
|
---@return Element|nil element The element owning this touch, or nil
|
||||||
|
function flexlove.getTouchOwner(touchId)
|
||||||
|
return flexlove._touchOwners[tostring(touchId)]
|
||||||
|
end
|
||||||
|
|
||||||
--- Clean up all UI elements and reset FlexLove to initial state when changing scenes or shutting down
|
--- Clean up all UI elements and reset FlexLove to initial state when changing scenes or shutting down
|
||||||
--- Use this to prevent memory leaks when transitioning between game states or menus
|
--- Use this to prevent memory leaks when transitioning between game states or menus
|
||||||
function flexlove.destroy()
|
function flexlove.destroy()
|
||||||
@@ -1076,6 +1377,12 @@ function flexlove.destroy()
|
|||||||
flexlove._canvasDimensions = { width = 0, height = 0 }
|
flexlove._canvasDimensions = { width = 0, height = 0 }
|
||||||
Context.clearFocus()
|
Context.clearFocus()
|
||||||
StateManager:reset()
|
StateManager:reset()
|
||||||
|
|
||||||
|
-- Clean up touch state
|
||||||
|
flexlove._touchOwners = {}
|
||||||
|
if flexlove._gestureRecognizer then
|
||||||
|
flexlove._gestureRecognizer:reset()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Create a new UI element with flexbox layout, styling, and interaction capabilities
|
--- Create a new UI element with flexbox layout, styling, and interaction capabilities
|
||||||
@@ -1248,6 +1555,19 @@ function flexlove.clearFocus()
|
|||||||
Context.setFocused(nil)
|
Context.setFocused(nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- Enable or disable the debug draw overlay that renders element boundaries with random colors
|
||||||
|
--- Each element gets a unique color: full opacity border and 0.5 opacity fill to identify collisions and overlaps
|
||||||
|
---@param enabled boolean True to enable debug draw overlay, false to disable
|
||||||
|
function flexlove.setDebugDraw(enabled)
|
||||||
|
flexlove._debugDraw = enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Check if the debug draw overlay is currently active
|
||||||
|
---@return boolean enabled True if debug draw overlay is enabled
|
||||||
|
function flexlove.getDebugDraw()
|
||||||
|
return flexlove._debugDraw
|
||||||
|
end
|
||||||
|
|
||||||
flexlove.Animation = Animation
|
flexlove.Animation = Animation
|
||||||
flexlove.Color = Color
|
flexlove.Color = Color
|
||||||
flexlove.Theme = Theme
|
flexlove.Theme = Theme
|
||||||
|
|||||||
79
README.md
79
README.md
@@ -79,6 +79,15 @@ function love.draw()
|
|||||||
SomeMetaComponent:draw()
|
SomeMetaComponent:draw()
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function love.load()
|
||||||
|
FlexLove.init({
|
||||||
|
theme = "space",
|
||||||
|
immediateMode = true,
|
||||||
|
debugDraw = true, -- Enable debug view
|
||||||
|
debugDrawKey = "F3" -- Toggle debug view with F3 key
|
||||||
|
})
|
||||||
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Demos
|
## Quick Demos
|
||||||
@@ -377,6 +386,76 @@ FlexLöve provides comprehensive multi-touch event tracking and gesture recognit
|
|||||||
- Touch scrolling with momentum and bounce effects
|
- Touch scrolling with momentum and bounce effects
|
||||||
- Complete API reference and examples
|
- Complete API reference and examples
|
||||||
|
|
||||||
|
### Custom Rendering
|
||||||
|
|
||||||
|
Each element supports a `customDraw` callback function that executes after the element's standard rendering but before visual feedback. This is useful for:
|
||||||
|
- Adding custom graphics on top of elements
|
||||||
|
- Creating complex visual effects
|
||||||
|
- Utilize flex love positioning to place whatever you need
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local panel = FlexLove.new({
|
||||||
|
width = 300,
|
||||||
|
height = 200,
|
||||||
|
backgroundColor = Color.new(0.1, 0.1, 0.1, 1),
|
||||||
|
customDraw = function(element)
|
||||||
|
-- Draw a custom border around the element
|
||||||
|
love.graphics.setLineWidth(3)
|
||||||
|
love.graphics.setColor(1, 1, 0, 1) -- Yellow
|
||||||
|
love.graphics.rectangle("line",
|
||||||
|
element.x - 5,
|
||||||
|
element.y - 5,
|
||||||
|
element.width + 10,
|
||||||
|
element.height + 10
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Draw a cross in the center
|
||||||
|
love.graphics.setColor(1, 0, 0, 1) -- Red
|
||||||
|
local cx = element.x + element.width / 2
|
||||||
|
local cy = element.y + element.height / 2
|
||||||
|
love.graphics.line(cx - 20, cy, cx + 20, cy)
|
||||||
|
love.graphics.line(cx, cy - 20, cx, cy + 20)
|
||||||
|
end
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The custom draw context is pushed with a fresh graphics state, so it won't affect parent elements or subsequent rendering.
|
||||||
|
|
||||||
|
### Debug View
|
||||||
|
|
||||||
|
Enable the debug draw overlay to visualize element boundaries, hit areas, and layout structure during development. This helps identify:
|
||||||
|
- Element positioning and sizing
|
||||||
|
- Overlapping elements
|
||||||
|
- Hidden or transparent elements
|
||||||
|
- Layout flow issues
|
||||||
|
|
||||||
|
**Enable via initialization:**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
FlexLove.init({
|
||||||
|
debugDraw = true, -- Always enable debug overlay
|
||||||
|
debugDrawKey = "F3" -- Press F3 to toggle (optional)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Programmatic control:**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
-- Toggle debug view at runtime
|
||||||
|
flexlove.setDebugDraw(true) -- Enable
|
||||||
|
flexlove.setDebugDraw(false) -- Disable
|
||||||
|
|
||||||
|
-- Check if debug view is active
|
||||||
|
local isEnabled = flexlove.getDebugDraw()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Each element displays with a unique random color
|
||||||
|
- Full opacity border (1px) and 0.5 opacity fill
|
||||||
|
- Renders regardless of element visibility or opacity
|
||||||
|
- Press `F3` (or your configured key) to toggle on/off
|
||||||
|
- Essential for debugging click targets and layout issues
|
||||||
|
|
||||||
### Deferred Callbacks
|
### Deferred Callbacks
|
||||||
|
|
||||||
Some LÖVE operations (like `love.window.setMode`) cannot be called while a Canvas is active. FlexLöve provides a deferred callback system to handle these operations safely:
|
Some LÖVE operations (like `love.window.setMode`) cannot be called while a Canvas is active. FlexLöve provides a deferred callback system to handle these operations safely:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>FlexLöve v0.8.0 - API Reference</title>
|
<title>FlexLöve v0.9.2 - API Reference</title>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
@@ -321,13 +321,15 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<nav class="sidebar">
|
<nav class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h2>FlexLöve <span style="font-size: 0.6em; color: #8b949e;">v0.8.0</span></h2>
|
<h2>FlexLöve <span style="font-size: 0.6em; color: #8b949e;">v0.9.2</span></h2>
|
||||||
<a href="index.html">← Back to Home</a>
|
<a href="index.html">← Back to Home</a>
|
||||||
|
|
||||||
<div class="version-selector">
|
<div class="version-selector">
|
||||||
<select id="version-dropdown" onchange="window.versionNavigate(this.value)">
|
<select id="version-dropdown" onchange="window.versionNavigate(this.value)">
|
||||||
<option value="">📚 Switch Version</option>
|
<option value="">📚 Switch Version</option>
|
||||||
<option value="current">v0.8.0 (Latest)</option>
|
<option value="current">v0.9.2 (Latest)</option>
|
||||||
|
<option value="v0.9.0">v0.9.0</option>
|
||||||
|
<option value="v0.8.0">v0.8.0</option>
|
||||||
<option value="v0.7.3">v0.7.3</option>
|
<option value="v0.7.3">v0.7.3</option>
|
||||||
<option value="v0.7.2">v0.7.2</option>
|
<option value="v0.7.2">v0.7.2</option>
|
||||||
<option value="v0.7.1">v0.7.1</option>
|
<option value="v0.7.1">v0.7.1</option>
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ cp FlexLove/FlexLove.lua your-project/</code></pre>
|
|||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>
|
<p>
|
||||||
FlexLöve v0.8.0 | MIT License |
|
FlexLöve v0.10.0 | MIT License |
|
||||||
<a href="https://github.com/mikefreno/FlexLove" style="color: #58a6ff"
|
<a href="https://github.com/mikefreno/FlexLove" style="color: #58a6ff"
|
||||||
>GitHub Repository</a
|
>GitHub Repository</a
|
||||||
>
|
>
|
||||||
|
|||||||
3748
docs/versions/v0.8.0/api.html
Normal file
3748
docs/versions/v0.8.0/api.html
Normal file
File diff suppressed because it is too large
Load Diff
3749
docs/versions/v0.9.0/api.html
Normal file
3749
docs/versions/v0.9.0/api.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
end
|
||||||
|
|
||||||
|
--- Link an array of animations into a chain (static helper)
|
||||||
|
--- Each animation's completion triggers the next in sequence
|
||||||
|
---@param animations Animation[] Array of animations to chain
|
||||||
|
---@return Animation first The first animation in the chain
|
||||||
|
function Animation.chainSequence(animations)
|
||||||
|
if type(animations) ~= "table" or #animations == 0 then
|
||||||
|
if Animation._ErrorHandler then
|
||||||
|
Animation._ErrorHandler:warn("Animation", "ANIM_004")
|
||||||
|
end
|
||||||
|
return Animation.new({ duration = 0, start = {}, final = {} })
|
||||||
|
end
|
||||||
|
|
||||||
|
for i = 1, #animations - 1 do
|
||||||
|
animations[i]:chain(animations[i + 1])
|
||||||
|
end
|
||||||
|
|
||||||
|
return animations[1]
|
||||||
|
end
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- ANIMATION GROUP (Utility)
|
-- ANIMATION GROUP (Utility)
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ local Context = {
|
|||||||
_settingFocus = false,
|
_settingFocus = false,
|
||||||
|
|
||||||
initialized = false,
|
initialized = false,
|
||||||
|
|
||||||
|
-- Debug draw overlay
|
||||||
|
_debugDraw = false,
|
||||||
|
_debugDrawKey = nil,
|
||||||
|
|
||||||
-- Initialization state tracking
|
-- Initialization state tracking
|
||||||
---@type "uninitialized"|"initializing"|"ready"
|
---@type "uninitialized"|"initializing"|"ready"
|
||||||
_initState = "uninitialized",
|
_initState = "uninitialized",
|
||||||
|
|||||||
@@ -159,6 +159,13 @@
|
|||||||
---@field _pressed table? -- Internal: button press state tracking
|
---@field _pressed table? -- Internal: button press state tracking
|
||||||
---@field _mouseDownPosition number? -- Internal: mouse down position for drag tracking
|
---@field _mouseDownPosition number? -- Internal: mouse down position for drag tracking
|
||||||
---@field _textDragOccurred boolean? -- Internal: whether text drag occurred
|
---@field _textDragOccurred boolean? -- Internal: whether text drag occurred
|
||||||
|
---@field customDraw fun(element:Element)? -- Custom rendering callback called after standard rendering but before visual feedback (default: nil)
|
||||||
|
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Callback for touch-specific events
|
||||||
|
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent callback should be deferred (default: false)
|
||||||
|
---@field onGesture fun(element:Element, gesture:table)? -- Callback for recognized gestures
|
||||||
|
---@field onGestureDeferred boolean? -- Whether onGesture callback should be deferred (default: false)
|
||||||
|
---@field touchEnabled boolean -- Whether the element responds to touch events (default: true)
|
||||||
|
---@field multiTouchEnabled boolean -- Whether the element supports multiple simultaneous touches (default: false)
|
||||||
---@field animation table? -- Animation instance for this element
|
---@field animation table? -- Animation instance for this element
|
||||||
local Element = {}
|
local Element = {}
|
||||||
Element.__index = Element
|
Element.__index = Element
|
||||||
@@ -190,6 +197,7 @@ function Element.init(deps)
|
|||||||
Element._StateManager = deps.StateManager
|
Element._StateManager = deps.StateManager
|
||||||
Element._GestureRecognizer = deps.GestureRecognizer
|
Element._GestureRecognizer = deps.GestureRecognizer
|
||||||
Element._Performance = deps.Performance
|
Element._Performance = deps.Performance
|
||||||
|
Element._Animation = deps.Animation
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param props ElementProps
|
---@param props ElementProps
|
||||||
@@ -364,6 +372,14 @@ function Element.new(props)
|
|||||||
|
|
||||||
self.customDraw = props.customDraw -- Custom rendering callback
|
self.customDraw = props.customDraw -- Custom rendering callback
|
||||||
|
|
||||||
|
-- Touch event properties
|
||||||
|
self.onTouchEvent = props.onTouchEvent
|
||||||
|
self.onTouchEventDeferred = props.onTouchEventDeferred or false
|
||||||
|
self.onGesture = props.onGesture
|
||||||
|
self.onGestureDeferred = props.onGestureDeferred or false
|
||||||
|
self.touchEnabled = props.touchEnabled ~= false -- Default true
|
||||||
|
self.multiTouchEnabled = props.multiTouchEnabled or false -- Default false
|
||||||
|
|
||||||
-- Initialize state manager ID for immediate mode (use self.id which may be auto-generated)
|
-- Initialize state manager ID for immediate mode (use self.id which may be auto-generated)
|
||||||
self._stateId = self.id
|
self._stateId = self.id
|
||||||
|
|
||||||
@@ -371,6 +387,12 @@ function Element.new(props)
|
|||||||
local eventHandlerConfig = {
|
local eventHandlerConfig = {
|
||||||
onEvent = self.onEvent,
|
onEvent = self.onEvent,
|
||||||
onEventDeferred = props.onEventDeferred,
|
onEventDeferred = props.onEventDeferred,
|
||||||
|
onTouchEvent = self.onTouchEvent,
|
||||||
|
onTouchEventDeferred = self.onTouchEventDeferred,
|
||||||
|
onGesture = self.onGesture,
|
||||||
|
onGestureDeferred = self.onGestureDeferred,
|
||||||
|
touchEnabled = self.touchEnabled,
|
||||||
|
multiTouchEnabled = self.multiTouchEnabled,
|
||||||
}
|
}
|
||||||
if self._elementMode == "immediate" and self._stateId and self._stateId ~= "" then
|
if self._elementMode == "immediate" and self._stateId and self._stateId ~= "" then
|
||||||
local state = Element._StateManager.getState(self._stateId)
|
local state = Element._StateManager.getState(self._stateId)
|
||||||
@@ -2018,6 +2040,26 @@ function Element.new(props)
|
|||||||
self._dirty = false -- Element properties have changed, needs layout
|
self._dirty = false -- Element properties have changed, needs layout
|
||||||
self._childrenDirty = false -- Children have changed, needs layout
|
self._childrenDirty = false -- Children have changed, needs layout
|
||||||
|
|
||||||
|
-- Debug draw: assign a stable random color for element boundary visualization
|
||||||
|
-- Uses a vibrant HSL-based color to ensure good visibility against any background
|
||||||
|
local hue = math.random() * 360
|
||||||
|
local function hslToRgb(h)
|
||||||
|
local s, l = 0.9, 0.55
|
||||||
|
local c = (1 - math.abs(2 * l - 1)) * s
|
||||||
|
local x = c * (1 - math.abs((h / 60) % 2 - 1))
|
||||||
|
local m = l - c / 2
|
||||||
|
local r, g, b
|
||||||
|
if h < 60 then r, g, b = c, x, 0
|
||||||
|
elseif h < 120 then r, g, b = x, c, 0
|
||||||
|
elseif h < 180 then r, g, b = 0, c, x
|
||||||
|
elseif h < 240 then r, g, b = 0, x, c
|
||||||
|
elseif h < 300 then r, g, b = x, 0, c
|
||||||
|
else r, g, b = c, 0, x end
|
||||||
|
return r + m, g + m, b + m
|
||||||
|
end
|
||||||
|
local dr, dg, db = hslToRgb(hue)
|
||||||
|
self._debugColor = { dr, dg, db }
|
||||||
|
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2581,6 +2623,10 @@ function Element:destroy()
|
|||||||
|
|
||||||
-- Clear onEvent to prevent closure leaks
|
-- Clear onEvent to prevent closure leaks
|
||||||
self.onEvent = nil
|
self.onEvent = nil
|
||||||
|
|
||||||
|
-- Clear touch callbacks to prevent closure leaks
|
||||||
|
self.onTouchEvent = nil
|
||||||
|
self.onGesture = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Draw element and its children
|
--- Draw element and its children
|
||||||
@@ -3060,6 +3106,39 @@ function Element:update(dt)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- Handle a touch event directly (for external touch routing)
|
||||||
|
--- Invokes both onEvent and onTouchEvent callbacks if set
|
||||||
|
---@param touchEvent InputEvent The touch event to handle
|
||||||
|
function Element:handleTouchEvent(touchEvent)
|
||||||
|
if not self.touchEnabled or self.disabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if self._eventHandler then
|
||||||
|
self._eventHandler:_invokeCallback(self, touchEvent)
|
||||||
|
self._eventHandler:_invokeTouchCallback(self, touchEvent)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Handle a gesture event (from GestureRecognizer or external routing)
|
||||||
|
---@param gesture table The gesture data (type, position, velocity, etc.)
|
||||||
|
function Element:handleGesture(gesture)
|
||||||
|
if not self.touchEnabled or self.disabled then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if self._eventHandler then
|
||||||
|
self._eventHandler:_invokeGestureCallback(self, gesture)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get active touches currently tracked on this element
|
||||||
|
---@return table<string, table> Active touches keyed by touch ID
|
||||||
|
function Element:getTouches()
|
||||||
|
if self._eventHandler then
|
||||||
|
return self._eventHandler:getActiveTouches()
|
||||||
|
end
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
---@param newViewportWidth number
|
---@param newViewportWidth number
|
||||||
---@param newViewportHeight number
|
---@param newViewportHeight number
|
||||||
function Element:recalculateUnits(newViewportWidth, newViewportHeight)
|
function Element:recalculateUnits(newViewportWidth, newViewportHeight)
|
||||||
@@ -3695,6 +3774,100 @@ function Element:setTransformOrigin(originX, originY)
|
|||||||
self.transform.originY = originY
|
self.transform.originY = originY
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- Animate element to new property values with automatic transition
|
||||||
|
--- Captures current values as start, uses provided values as final, and applies the animation
|
||||||
|
---@param props table Target property values
|
||||||
|
---@param duration number? Animation duration in seconds (default: 0.3)
|
||||||
|
---@param easing string? Easing function name (default: "linear")
|
||||||
|
---@return Element self For method chaining
|
||||||
|
function Element:animateTo(props, duration, easing)
|
||||||
|
if not Element._Animation then
|
||||||
|
Element._ErrorHandler:warn("Element", "ELEM_003")
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
if type(props) ~= "table" then
|
||||||
|
Element._ErrorHandler:warn("Element", "ELEM_003")
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
duration = duration or 0.3
|
||||||
|
easing = easing or "linear"
|
||||||
|
|
||||||
|
-- Collect current values as start
|
||||||
|
local startValues = {}
|
||||||
|
for key, _ in pairs(props) do
|
||||||
|
startValues[key] = self[key]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Create and apply animation
|
||||||
|
local anim = Element._Animation.new({
|
||||||
|
duration = duration,
|
||||||
|
start = startValues,
|
||||||
|
final = props,
|
||||||
|
easing = easing,
|
||||||
|
})
|
||||||
|
|
||||||
|
anim:apply(self)
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Fade element to full opacity
|
||||||
|
---@param duration number? Duration in seconds (default: 0.3)
|
||||||
|
---@param easing string? Easing function name
|
||||||
|
---@return Element self For method chaining
|
||||||
|
function Element:fadeIn(duration, easing)
|
||||||
|
return self:animateTo({ opacity = 1 }, duration or 0.3, easing)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Fade element to zero opacity
|
||||||
|
---@param duration number? Duration in seconds (default: 0.3)
|
||||||
|
---@param easing string? Easing function name
|
||||||
|
---@return Element self For method chaining
|
||||||
|
function Element:fadeOut(duration, easing)
|
||||||
|
return self:animateTo({ opacity = 0 }, duration or 0.3, easing)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Scale element to target scale value using transforms
|
||||||
|
---@param targetScale number Target scale multiplier
|
||||||
|
---@param duration number? Duration in seconds (default: 0.3)
|
||||||
|
---@param easing string? Easing function name
|
||||||
|
---@return Element self For method chaining
|
||||||
|
function Element:scaleTo(targetScale, duration, easing)
|
||||||
|
if not Element._Animation or not Element._Transform then
|
||||||
|
Element._ErrorHandler:warn("Element", "ELEM_003")
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Ensure element has a transform
|
||||||
|
if not self.transform then
|
||||||
|
self.transform = Element._Transform.new({})
|
||||||
|
end
|
||||||
|
|
||||||
|
local currentScaleX = self.transform.scaleX or 1
|
||||||
|
local currentScaleY = self.transform.scaleY or 1
|
||||||
|
|
||||||
|
local anim = Element._Animation.new({
|
||||||
|
duration = duration or 0.3,
|
||||||
|
start = { scaleX = currentScaleX, scaleY = currentScaleY },
|
||||||
|
final = { scaleX = targetScale, scaleY = targetScale },
|
||||||
|
easing = easing or "linear",
|
||||||
|
})
|
||||||
|
|
||||||
|
anim:apply(self)
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Move element to target position
|
||||||
|
---@param x number Target x position
|
||||||
|
---@param y number Target y position
|
||||||
|
---@param duration number? Duration in seconds (default: 0.3)
|
||||||
|
---@param easing string? Easing function name
|
||||||
|
---@return Element self For method chaining
|
||||||
|
function Element:moveTo(x, y, duration, easing)
|
||||||
|
return self:animateTo({ x = x, y = y }, duration or 0.3, easing)
|
||||||
|
end
|
||||||
|
|
||||||
--- Set transition configuration for a property
|
--- Set transition configuration for a property
|
||||||
---@param property string Property name or "all" for all properties
|
---@param property string Property name or "all" for all properties
|
||||||
---@param config table Transition config {duration, easing, delay, onComplete}
|
---@param config table Transition config {duration, easing, delay, onComplete}
|
||||||
@@ -3753,6 +3926,53 @@ function Element:removeTransition(property)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- Resolve a unit-based dimension property (width/height) from a string or CalcObject
|
||||||
|
--- Parses the value, updates self.units, resolves to pixels, and updates border-box dimensions
|
||||||
|
---@param property string "width" or "height"
|
||||||
|
---@param value string|table The unit string (e.g., "50%", "10vw") or CalcObject
|
||||||
|
---@return number resolvedValue The resolved pixel value
|
||||||
|
function Element:_resolveDimensionProperty(property, value)
|
||||||
|
local viewportWidth, viewportHeight = Element._Units.getViewport()
|
||||||
|
local parsedValue, parsedUnit = Element._Units.parse(value)
|
||||||
|
self.units[property] = { value = parsedValue, unit = parsedUnit }
|
||||||
|
|
||||||
|
local parentDimension
|
||||||
|
if property == "width" then
|
||||||
|
parentDimension = self.parent and self.parent.width or viewportWidth
|
||||||
|
else
|
||||||
|
parentDimension = self.parent and self.parent.height or viewportHeight
|
||||||
|
end
|
||||||
|
|
||||||
|
local resolved = Element._Units.resolve(parsedValue, parsedUnit, viewportWidth, viewportHeight, parentDimension)
|
||||||
|
|
||||||
|
if type(resolved) ~= "number" then
|
||||||
|
Element._ErrorHandler:warn("Element", "LAY_003", {
|
||||||
|
issue = string.format("%s resolution returned non-number value", property),
|
||||||
|
type = type(resolved),
|
||||||
|
value = tostring(resolved),
|
||||||
|
})
|
||||||
|
resolved = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
self[property] = resolved
|
||||||
|
|
||||||
|
if property == "width" then
|
||||||
|
if self.autosizing and self.autosizing.width then
|
||||||
|
self._borderBoxWidth = resolved + self.padding.left + self.padding.right
|
||||||
|
else
|
||||||
|
self._borderBoxWidth = resolved
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if self.autosizing and self.autosizing.height then
|
||||||
|
self._borderBoxHeight = resolved + self.padding.top + self.padding.bottom
|
||||||
|
else
|
||||||
|
self._borderBoxHeight = resolved
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
end
|
||||||
|
|
||||||
--- Set property with automatic transition
|
--- Set property with automatic transition
|
||||||
---@param property string Property name
|
---@param property string Property name
|
||||||
---@param value any New value
|
---@param value any New value
|
||||||
@@ -3766,11 +3986,6 @@ function Element:setProperty(property, value)
|
|||||||
shouldTransition = transitionConfig ~= nil
|
shouldTransition = transitionConfig ~= nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Don't transition if value is the same
|
|
||||||
if self[property] == value then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Properties that affect layout and require invalidation
|
-- Properties that affect layout and require invalidation
|
||||||
local layoutProperties = {
|
local layoutProperties = {
|
||||||
width = true,
|
width = true,
|
||||||
@@ -3792,6 +4007,50 @@ function Element:setProperty(property, value)
|
|||||||
left = true,
|
left = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- Dimension properties that accept unit strings and need resolution
|
||||||
|
local dimensionProperties = { width = true, height = true }
|
||||||
|
|
||||||
|
-- For dimension properties with unit strings, resolve to pixels
|
||||||
|
local isUnitValue = type(value) == "string" or (Element._Calc and Element._Calc.isCalc(value))
|
||||||
|
if dimensionProperties[property] and isUnitValue then
|
||||||
|
-- Check if the unit specification is the same (compare against stored units)
|
||||||
|
local currentUnits = self.units[property]
|
||||||
|
local newValue, newUnit = Element._Units.parse(value)
|
||||||
|
if currentUnits and currentUnits.value == newValue and currentUnits.unit == newUnit then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if shouldTransition and transitionConfig then
|
||||||
|
-- For transitions, resolve the target value and transition the pixel value
|
||||||
|
local currentPixelValue = self[property]
|
||||||
|
local resolvedTarget = self:_resolveDimensionProperty(property, value)
|
||||||
|
|
||||||
|
if currentPixelValue ~= nil and currentPixelValue ~= resolvedTarget then
|
||||||
|
-- Reset to current value before animating
|
||||||
|
self[property] = currentPixelValue
|
||||||
|
local Animation = require("modules.Animation")
|
||||||
|
local anim = Animation.new({
|
||||||
|
duration = transitionConfig.duration,
|
||||||
|
start = { [property] = currentPixelValue },
|
||||||
|
final = { [property] = resolvedTarget },
|
||||||
|
easing = transitionConfig.easing,
|
||||||
|
onComplete = transitionConfig.onComplete,
|
||||||
|
})
|
||||||
|
anim:apply(self)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
self:_resolveDimensionProperty(property, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
self:invalidateLayout()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Don't transition if value is the same
|
||||||
|
if self[property] == value then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
if shouldTransition and transitionConfig then
|
if shouldTransition and transitionConfig then
|
||||||
local currentValue = self[property]
|
local currentValue = self[property]
|
||||||
|
|
||||||
@@ -3950,6 +4209,8 @@ function Element:_cleanup()
|
|||||||
self.onEnter = nil
|
self.onEnter = nil
|
||||||
self.onImageLoad = nil
|
self.onImageLoad = nil
|
||||||
self.onImageError = nil
|
self.onImageError = nil
|
||||||
|
self.onTouchEvent = nil
|
||||||
|
self.onGesture = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
return Element
|
return Element
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
---@class EventHandler
|
---@class EventHandler
|
||||||
---@field onEvent fun(element:Element, event:InputEvent)?
|
---@field onEvent fun(element:Element, event:InputEvent)?
|
||||||
---@field onEventDeferred boolean?
|
---@field onEventDeferred boolean?
|
||||||
|
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Touch-specific callback
|
||||||
|
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent is deferred
|
||||||
|
---@field onGesture fun(element:Element, gesture:table)? -- Gesture callback
|
||||||
|
---@field onGestureDeferred boolean? -- Whether onGesture is deferred
|
||||||
|
---@field touchEnabled boolean -- Whether touch events are processed (default: true)
|
||||||
|
---@field multiTouchEnabled boolean -- Whether multi-touch is supported (default: false)
|
||||||
---@field _pressed table<number, boolean>
|
---@field _pressed table<number, boolean>
|
||||||
---@field _lastClickTime number?
|
---@field _lastClickTime number?
|
||||||
---@field _lastClickButton number?
|
---@field _lastClickButton number?
|
||||||
@@ -39,6 +45,12 @@ function EventHandler.new(config)
|
|||||||
|
|
||||||
self.onEvent = config.onEvent
|
self.onEvent = config.onEvent
|
||||||
self.onEventDeferred = config.onEventDeferred
|
self.onEventDeferred = config.onEventDeferred
|
||||||
|
self.onTouchEvent = config.onTouchEvent
|
||||||
|
self.onTouchEventDeferred = config.onTouchEventDeferred or false
|
||||||
|
self.onGesture = config.onGesture
|
||||||
|
self.onGestureDeferred = config.onGestureDeferred or false
|
||||||
|
self.touchEnabled = config.touchEnabled ~= false -- Default true
|
||||||
|
self.multiTouchEnabled = config.multiTouchEnabled or false -- Default false
|
||||||
|
|
||||||
self._pressed = config._pressed or {}
|
self._pressed = config._pressed or {}
|
||||||
|
|
||||||
@@ -462,7 +474,7 @@ function EventHandler:processTouchEvents(element)
|
|||||||
local activeTouchIds = {}
|
local activeTouchIds = {}
|
||||||
|
|
||||||
-- Check if element can process events
|
-- Check if element can process events
|
||||||
local canProcessEvents = (self.onEvent or element.editable) and not element.disabled
|
local canProcessEvents = (self.onEvent or self.onTouchEvent or element.editable) and not element.disabled and self.touchEnabled
|
||||||
|
|
||||||
if not canProcessEvents then
|
if not canProcessEvents then
|
||||||
if EventHandler._Performance and EventHandler._Performance.enabled then
|
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||||
@@ -483,6 +495,12 @@ function EventHandler:processTouchEvents(element)
|
|||||||
activeTouches[tostring(id)] = true
|
activeTouches[tostring(id)] = true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Count active tracked touches for multi-touch filtering
|
||||||
|
local trackedTouchCount = 0
|
||||||
|
for _ in pairs(self._touches) do
|
||||||
|
trackedTouchCount = trackedTouchCount + 1
|
||||||
|
end
|
||||||
|
|
||||||
-- Process active touches
|
-- Process active touches
|
||||||
for _, id in ipairs(touches) do
|
for _, id in ipairs(touches) do
|
||||||
local touchId = tostring(id)
|
local touchId = tostring(id)
|
||||||
@@ -494,8 +512,15 @@ function EventHandler:processTouchEvents(element)
|
|||||||
|
|
||||||
if isInside then
|
if isInside then
|
||||||
if not self._touches[touchId] then
|
if not self._touches[touchId] then
|
||||||
-- New touch began
|
-- Multi-touch filtering: reject new touches when multiTouchEnabled=false
|
||||||
self:_handleTouchBegan(element, touchId, tx, ty, pressure)
|
-- 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
|
else
|
||||||
-- Touch moved
|
-- Touch moved
|
||||||
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
|
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
|
||||||
@@ -561,6 +586,7 @@ function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure)
|
|||||||
touchEvent.dx = 0
|
touchEvent.dx = 0
|
||||||
touchEvent.dy = 0
|
touchEvent.dy = 0
|
||||||
self:_invokeCallback(element, touchEvent)
|
self:_invokeCallback(element, touchEvent)
|
||||||
|
self:_invokeTouchCallback(element, touchEvent)
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Handle touch moved event
|
--- Handle touch moved event
|
||||||
@@ -607,6 +633,7 @@ function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure)
|
|||||||
touchEvent.dx = dx
|
touchEvent.dx = dx
|
||||||
touchEvent.dy = dy
|
touchEvent.dy = dy
|
||||||
self:_invokeCallback(element, touchEvent)
|
self:_invokeCallback(element, touchEvent)
|
||||||
|
self:_invokeTouchCallback(element, touchEvent)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -634,6 +661,7 @@ function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure)
|
|||||||
touchEvent.dx = dx
|
touchEvent.dx = dx
|
||||||
touchEvent.dy = dy
|
touchEvent.dy = dy
|
||||||
self:_invokeCallback(element, touchEvent)
|
self:_invokeCallback(element, touchEvent)
|
||||||
|
self:_invokeTouchCallback(element, touchEvent)
|
||||||
|
|
||||||
-- Cleanup touch state
|
-- Cleanup touch state
|
||||||
self:_cleanupTouch(touchId)
|
self:_cleanupTouch(touchId)
|
||||||
@@ -709,4 +737,52 @@ function EventHandler:_invokeCallback(element, event)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
--- Invoke the onTouchEvent callback, optionally deferring it
|
||||||
|
---@param element Element The element that triggered the event
|
||||||
|
---@param event InputEvent The touch event data
|
||||||
|
function EventHandler:_invokeTouchCallback(element, event)
|
||||||
|
if not self.onTouchEvent then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.onTouchEventDeferred then
|
||||||
|
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
|
||||||
|
if FlexLove and FlexLove.deferCallback then
|
||||||
|
FlexLove.deferCallback(function()
|
||||||
|
self.onTouchEvent(element, event)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
|
||||||
|
eventType = event.type,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
else
|
||||||
|
self.onTouchEvent(element, event)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Invoke the onGesture callback, optionally deferring it
|
||||||
|
---@param element Element The element that triggered the event
|
||||||
|
---@param gesture table The gesture data from GestureRecognizer
|
||||||
|
function EventHandler:_invokeGestureCallback(element, gesture)
|
||||||
|
if not self.onGesture then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.onGestureDeferred then
|
||||||
|
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
|
||||||
|
if FlexLove and FlexLove.deferCallback then
|
||||||
|
FlexLove.deferCallback(function()
|
||||||
|
self.onGesture(element, gesture)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
|
||||||
|
gestureType = gesture.type,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
else
|
||||||
|
self.onGesture(element, gesture)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return EventHandler
|
return EventHandler
|
||||||
|
|||||||
@@ -92,10 +92,11 @@ end
|
|||||||
---@param event InputEvent Touch event
|
---@param event InputEvent Touch event
|
||||||
function GestureRecognizer:processTouchEvent(event)
|
function GestureRecognizer:processTouchEvent(event)
|
||||||
if not event.touchId then
|
if not event.touchId then
|
||||||
return
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local touchId = event.touchId
|
local touchId = event.touchId
|
||||||
|
local gestures = {}
|
||||||
|
|
||||||
-- Update touch state
|
-- Update touch state
|
||||||
if event.type == "touchpress" then
|
if event.type == "touchpress" then
|
||||||
@@ -122,13 +123,17 @@ function GestureRecognizer:processTouchEvent(event)
|
|||||||
touch.phase = "moved"
|
touch.phase = "moved"
|
||||||
|
|
||||||
-- Update gesture detection
|
-- Update gesture detection
|
||||||
self:_detectPan(touchId, event)
|
local panGesture = self:_detectPan(touchId, event)
|
||||||
self:_detectSwipe(touchId, event)
|
if panGesture then table.insert(gestures, panGesture) end
|
||||||
|
local swipeGesture = self:_detectSwipe(touchId, event)
|
||||||
|
if swipeGesture then table.insert(gestures, swipeGesture) end
|
||||||
|
|
||||||
-- Multi-touch gestures
|
-- Multi-touch gestures
|
||||||
if self:_getTouchCount() >= 2 then
|
if self:_getTouchCount() >= 2 then
|
||||||
self:_detectPinch(event)
|
local pinchGesture = self:_detectPinch(event)
|
||||||
self:_detectRotate(event)
|
if pinchGesture then table.insert(gestures, pinchGesture) end
|
||||||
|
local rotateGesture = self:_detectRotate(event)
|
||||||
|
if rotateGesture then table.insert(gestures, rotateGesture) end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -138,9 +143,12 @@ function GestureRecognizer:processTouchEvent(event)
|
|||||||
touch.phase = "ended"
|
touch.phase = "ended"
|
||||||
|
|
||||||
-- Finalize gesture detection
|
-- Finalize gesture detection
|
||||||
self:_detectTapEnded(touchId, event)
|
local tapGesture = self:_detectTapEnded(touchId, event)
|
||||||
self:_detectSwipeEnded(touchId, event)
|
if tapGesture then table.insert(gestures, tapGesture) end
|
||||||
self:_detectPanEnded(touchId, event)
|
local swipeGesture = self:_detectSwipeEnded(touchId, event)
|
||||||
|
if swipeGesture then table.insert(gestures, swipeGesture) end
|
||||||
|
local panGesture = self:_detectPanEnded(touchId, event)
|
||||||
|
if panGesture then table.insert(gestures, panGesture) end
|
||||||
|
|
||||||
-- Cleanup touch
|
-- Cleanup touch
|
||||||
self._touches[touchId] = nil
|
self._touches[touchId] = nil
|
||||||
@@ -151,6 +159,8 @@ function GestureRecognizer:processTouchEvent(event)
|
|||||||
self._touches[touchId] = nil
|
self._touches[touchId] = nil
|
||||||
self:_cancelAllGestures()
|
self:_cancelAllGestures()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
return #gestures > 0 and gestures or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Get number of active touches
|
--- Get number of active touches
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ function LayoutEngine.new(props, deps)
|
|||||||
childrenCount = 0,
|
childrenCount = 0,
|
||||||
containerWidth = 0,
|
containerWidth = 0,
|
||||||
containerHeight = 0,
|
containerHeight = 0,
|
||||||
|
containerX = 0,
|
||||||
|
containerY = 0,
|
||||||
childrenHash = "",
|
childrenHash = "",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1180,51 +1182,56 @@ function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Recalculate position if using viewport or percentage units
|
-- Recalculate position if using viewport or percentage units
|
||||||
if self.element.units.x.unit ~= "px" then
|
-- Skip position recalculation for flex children (non-explicitly-absolute children with a parent)
|
||||||
local parentWidth = self.element.parent and self.element.parent.width or newViewportWidth
|
-- Their x/y is entirely controlled by the parent's layoutChildren() call
|
||||||
local baseX = self.element.parent and self.element.parent.x or 0
|
local isFlexChild = self.element.parent and not self.element._explicitlyAbsolute
|
||||||
local offsetX = Units.resolve(
|
if not isFlexChild then
|
||||||
self.element.units.x.value,
|
if self.element.units.x.unit ~= "px" then
|
||||||
self.element.units.x.unit,
|
local parentWidth = self.element.parent and self.element.parent.width or newViewportWidth
|
||||||
newViewportWidth,
|
local baseX = self.element.parent and self.element.parent.x or 0
|
||||||
newViewportHeight,
|
local offsetX = Units.resolve(
|
||||||
parentWidth
|
self.element.units.x.value,
|
||||||
)
|
self.element.units.x.unit,
|
||||||
self.element.x = baseX + offsetX
|
newViewportWidth,
|
||||||
else
|
newViewportHeight,
|
||||||
-- For pixel units, update position relative to parent's new position (with base scaling)
|
parentWidth
|
||||||
if self.element.parent then
|
)
|
||||||
local baseX = self.element.parent.x
|
self.element.x = baseX + offsetX
|
||||||
local scaledOffset = self._Context.baseScale and (self.element.units.x.value * scaleX)
|
else
|
||||||
or self.element.units.x.value
|
-- For pixel units, update position relative to parent's new position (with base scaling)
|
||||||
self.element.x = baseX + scaledOffset
|
if self.element.parent then
|
||||||
elseif self._Context.baseScale then
|
local baseX = self.element.parent.x
|
||||||
-- Top-level element with pixel position - apply base scaling
|
local scaledOffset = self._Context.baseScale and (self.element.units.x.value * scaleX)
|
||||||
self.element.x = 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
|
||||||
end
|
|
||||||
|
|
||||||
if self.element.units.y.unit ~= "px" then
|
if self.element.units.y.unit ~= "px" then
|
||||||
local parentHeight = self.element.parent and self.element.parent.height or newViewportHeight
|
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 baseY = self.element.parent and self.element.parent.y or 0
|
||||||
local offsetY = Units.resolve(
|
local offsetY = Units.resolve(
|
||||||
self.element.units.y.value,
|
self.element.units.y.value,
|
||||||
self.element.units.y.unit,
|
self.element.units.y.unit,
|
||||||
newViewportWidth,
|
newViewportWidth,
|
||||||
newViewportHeight,
|
newViewportHeight,
|
||||||
parentHeight
|
parentHeight
|
||||||
)
|
)
|
||||||
self.element.y = baseY + offsetY
|
self.element.y = baseY + offsetY
|
||||||
else
|
else
|
||||||
-- For pixel units, update position relative to parent's new position (with base scaling)
|
-- For pixel units, update position relative to parent's new position (with base scaling)
|
||||||
if self.element.parent then
|
if self.element.parent then
|
||||||
local baseY = self.element.parent.y
|
local baseY = self.element.parent.y
|
||||||
local scaledOffset = self._Context.baseScale and (self.element.units.y.value * scaleY)
|
local scaledOffset = self._Context.baseScale and (self.element.units.y.value * scaleY)
|
||||||
or self.element.units.y.value
|
or self.element.units.y.value
|
||||||
self.element.y = baseY + scaledOffset
|
self.element.y = baseY + scaledOffset
|
||||||
elseif self._Context.baseScale then
|
elseif self._Context.baseScale then
|
||||||
-- Top-level element with pixel position - apply base scaling
|
-- Top-level element with pixel position - apply base scaling
|
||||||
self.element.y = self.element.units.y.value * scaleY
|
self.element.y = self.element.units.y.value * scaleY
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1504,6 +1511,8 @@ function LayoutEngine:_canSkipLayout()
|
|||||||
local childrenCount = #self.element.children
|
local childrenCount = #self.element.children
|
||||||
local containerWidth = self.element.width
|
local containerWidth = self.element.width
|
||||||
local containerHeight = self.element.height
|
local containerHeight = self.element.height
|
||||||
|
local containerX = self.element.x
|
||||||
|
local containerY = self.element.y
|
||||||
|
|
||||||
-- Generate simple hash of children dimensions
|
-- Generate simple hash of children dimensions
|
||||||
local childrenHash = ""
|
local childrenHash = ""
|
||||||
@@ -1520,6 +1529,8 @@ function LayoutEngine:_canSkipLayout()
|
|||||||
cache.childrenCount == childrenCount
|
cache.childrenCount == childrenCount
|
||||||
and cache.containerWidth == containerWidth
|
and cache.containerWidth == containerWidth
|
||||||
and cache.containerHeight == containerHeight
|
and cache.containerHeight == containerHeight
|
||||||
|
and cache.containerX == containerX
|
||||||
|
and cache.containerY == containerY
|
||||||
and cache.childrenHash == childrenHash
|
and cache.childrenHash == childrenHash
|
||||||
then
|
then
|
||||||
return true -- Layout hasn't changed, can skip
|
return true -- Layout hasn't changed, can skip
|
||||||
@@ -1529,6 +1540,8 @@ function LayoutEngine:_canSkipLayout()
|
|||||||
cache.childrenCount = childrenCount
|
cache.childrenCount = childrenCount
|
||||||
cache.containerWidth = containerWidth
|
cache.containerWidth = containerWidth
|
||||||
cache.containerHeight = containerHeight
|
cache.containerHeight = containerHeight
|
||||||
|
cache.containerX = containerX
|
||||||
|
cache.containerY = containerY
|
||||||
cache.childrenHash = childrenHash
|
cache.childrenHash = childrenHash
|
||||||
|
|
||||||
return false -- Layout has changed, must recalculate
|
return false -- Layout has changed, must recalculate
|
||||||
|
|||||||
@@ -139,9 +139,9 @@ function Performance:stopTimer(name)
|
|||||||
|
|
||||||
-- Check for warnings
|
-- Check for warnings
|
||||||
if elapsed > self.criticalThresholdMs then
|
if elapsed > self.criticalThresholdMs then
|
||||||
self:addWarning(name, elapsed, "critical")
|
self:_addWarning(name, elapsed, "critical")
|
||||||
elseif elapsed > self.warningThresholdMs then
|
elseif elapsed > self.warningThresholdMs then
|
||||||
self:addWarning(name, elapsed, "warning")
|
self:_addWarning(name, elapsed, "warning")
|
||||||
end
|
end
|
||||||
|
|
||||||
if self.logToConsole then
|
if self.logToConsole then
|
||||||
|
|||||||
@@ -85,8 +85,15 @@ local AnimationProps = {}
|
|||||||
---@field onTextChangeDeferred boolean? -- Whether onTextChange callback should be deferred (default: false)
|
---@field onTextChangeDeferred boolean? -- Whether onTextChange callback should be deferred (default: false)
|
||||||
---@field onEnter fun(element:Element)? -- Callback when Enter key is pressed
|
---@field onEnter fun(element:Element)? -- Callback when Enter key is pressed
|
||||||
---@field onEnterDeferred boolean? -- Whether onEnter callback should be deferred (default: false)
|
---@field onEnterDeferred boolean? -- Whether onEnter callback should be deferred (default: false)
|
||||||
|
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Callback for touch-specific events (touchpress, touchmove, touchrelease)
|
||||||
|
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent callback should be deferred (default: false)
|
||||||
|
---@field onGesture fun(element:Element, gesture:table)? -- Callback for recognized gestures (tap, swipe, pinch, etc.)
|
||||||
|
---@field onGestureDeferred boolean? -- Whether onGesture callback should be deferred (default: false)
|
||||||
|
---@field touchEnabled boolean? -- Whether the element responds to touch events (default: true)
|
||||||
|
---@field multiTouchEnabled boolean? -- Whether the element supports multiple simultaneous touches (default: false)
|
||||||
---@field transform TransformProps? -- Transform properties for animations and styling
|
---@field transform TransformProps? -- Transform properties for animations and styling
|
||||||
---@field transition TransitionProps? -- Transition settings for animations
|
---@field transition TransitionProps? -- Transition settings for animations
|
||||||
|
---@field customDraw fun(element:Element)? -- Custom rendering callback called after standard rendering but before visual feedback (default: nil)
|
||||||
---@field gridRows number? -- Number of rows in the grid (default: 1)
|
---@field gridRows number? -- Number of rows in the grid (default: 1)
|
||||||
---@field gridColumns number? -- Number of columns in the grid (default: 1)
|
---@field gridColumns number? -- Number of columns in the grid (default: 1)
|
||||||
---@field columnGap number|string|CalcObject? -- Gap between grid columns: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0)
|
---@field columnGap number|string|CalcObject? -- Gap between grid columns: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0)
|
||||||
@@ -198,6 +205,8 @@ local TransformProps
|
|||||||
---@field gcInterval number? -- Frames between GC steps in periodic mode (default: 60)
|
---@field gcInterval number? -- Frames between GC steps in periodic mode (default: 60)
|
||||||
---@field gcStepSize number? -- Work units per GC step, higher = more aggressive (default: 200)
|
---@field gcStepSize number? -- Work units per GC step, higher = more aggressive (default: 200)
|
||||||
---@field immediateModeBlurOptimizations boolean? -- Cache blur canvases in immediate mode to avoid re-rendering each frame (default: true)
|
---@field immediateModeBlurOptimizations boolean? -- Cache blur canvases in immediate mode to avoid re-rendering each frame (default: true)
|
||||||
|
---@field debugDraw boolean? -- Enable debug draw overlay showing element boundaries with random colors (default: false)
|
||||||
|
---@field debugDrawKey string? -- Key to toggle debug draw overlay at runtime (default: nil, no toggle key)
|
||||||
local FlexLoveConfig = {}
|
local FlexLoveConfig = {}
|
||||||
|
|
||||||
--=====================================--
|
--=====================================--
|
||||||
|
|||||||
531
testing/__tests__/animation_chaining_test.lua
Normal file
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
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
1902
testing/__tests__/touch_test.lua
Normal file
File diff suppressed because it is too large
Load Diff
442
testing/__tests__/transition_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 = {
|
local testFiles = {
|
||||||
"testing/__tests__/absolute_positioning_test.lua",
|
"testing/__tests__/absolute_positioning_test.lua",
|
||||||
|
"testing/__tests__/animation_chaining_test.lua",
|
||||||
|
"testing/__tests__/animation_group_test.lua",
|
||||||
"testing/__tests__/animation_test.lua",
|
"testing/__tests__/animation_test.lua",
|
||||||
"testing/__tests__/blur_test.lua",
|
"testing/__tests__/blur_test.lua",
|
||||||
"testing/__tests__/calc_test.lua",
|
"testing/__tests__/calc_test.lua",
|
||||||
@@ -66,7 +68,8 @@ local testFiles = {
|
|||||||
"testing/__tests__/scrollbar_placement_test.lua",
|
"testing/__tests__/scrollbar_placement_test.lua",
|
||||||
"testing/__tests__/text_editor_test.lua",
|
"testing/__tests__/text_editor_test.lua",
|
||||||
"testing/__tests__/theme_test.lua",
|
"testing/__tests__/theme_test.lua",
|
||||||
"testing/__tests__/touch_events_test.lua",
|
"testing/__tests__/touch_test.lua",
|
||||||
|
"testing/__tests__/transition_test.lua",
|
||||||
"testing/__tests__/units_test.lua",
|
"testing/__tests__/units_test.lua",
|
||||||
"testing/__tests__/utils_test.lua",
|
"testing/__tests__/utils_test.lua",
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user