Compare commits

..

24 Commits

Author SHA1 Message Date
d8f67ebd27 note 2026-02-26 10:23:39 -05:00
github-actions[bot]
99f467fa69 Archive previous documentation and generate v0.10.2 docs [skip ci] 2026-02-26 15:17:17 +00:00
141a22cd9c v0.10.2 release 2026-02-26 10:16:46 -05:00
eb3afc11ac address: Themes not included (#1) 2026-02-26 10:01:22 -05:00
d72f442de7 v0.10.1 release 2026-02-26 01:58:07 -05:00
3713f45a76 fix: stops debug draw flashing 2026-02-26 01:58:07 -05:00
c4fc62af20 fix: onEvent not correctly triggering in immediate mode (#2) 2026-02-26 01:57:58 -05:00
github-actions[bot]
71f7776f78 Archive previous documentation and generate v0.10.0 docs [skip ci] 2026-02-25 18:21:34 +00:00
f5608980e3 v0.10.0 release 2026-02-25 13:21:12 -05:00
9a126cb87e fix: retained mode dimensional property recalc 2026-02-25 12:22:53 -05:00
caf604445f housekeeping: file cleanup 2026-02-25 11:11:52 -05:00
ffab292c04 housekeeping: flatten tests 2026-02-25 11:07:59 -05:00
309ebde985 feat: gesture/multi-touch progress 2026-02-25 10:17:54 -05:00
998469141a feat: animation expansion 2026-02-25 00:46:58 -05:00
github-actions[bot]
4e14b375e0 Archive previous documentation and generate v0.9.2 docs [skip ci] 2026-02-25 05:29:57 +00:00
f1fae85595 v0.9.2 release 2026-02-25 00:29:36 -05:00
d948ab2b4c prop defs 2026-02-25 00:24:58 -05:00
6ae04b5e82 fix: resize bug in retained mode 2026-02-25 00:18:11 -05:00
3c3f26b74a v0.9.1 release 2026-02-24 23:20:29 -05:00
2b5957f264 fix: typo 2026-02-24 23:20:29 -05:00
github-actions[bot]
7f72623168 Archive previous documentation and generate v0.9.0 docs [skip ci] 2026-02-25 03:45:00 +00:00
2f62810a91 v0.9.0 release 2026-02-24 22:43:41 -05:00
7b34c71623 note new capabilities 2026-02-24 22:00:38 -05:00
a17e524730 debug mode 2026-02-24 21:50:12 -05:00
121 changed files with 19667 additions and 548 deletions

5
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlexLöve v0.8.0 - API Reference</title> <title>FlexLöve v0.10.2 - API Reference</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"> <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,17 @@
<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.10.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.10.2 (Latest)</option>
<option value="v0.10.0">v0.10.0</option>
<option value="v0.9.2">v0.9.2</option>
<option value="v0.9.0">v0.9.0</option>
<option value="v0.8.0">v0.8.0</option>
<option value="v0.7.3">v0.7.3</option> <option value="v0.7.3">v0.7.3</option>
<option value="v0.7.2">v0.7.2</option> <option value="v0.7.2">v0.7.2</option>
<option value="v0.7.1">v0.7.1</option> <option value="v0.7.1">v0.7.1</option>

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,10 @@ local Context = {
initialized = false, initialized = false,
-- Debug draw overlay
_debugDraw = false,
_debugDrawKey = nil,
-- Initialization state tracking -- Initialization state tracking
---@type "uninitialized"|"initializing"|"ready" ---@type "uninitialized"|"initializing"|"ready"
_initState = "uninitialized", _initState = "uninitialized",
@@ -60,6 +64,19 @@ function Context.clearFrameElements()
end end
end end
--- Calculate the depth (nesting level) of an element
---@param elem Element
---@return number
local function getElementDepth(elem)
local depth = 0
local current = elem.parent
while current do
depth = depth + 1
current = current.parent
end
return depth
end
--- Sort elements by z-index (called after all elements are registered) --- Sort elements by z-index (called after all elements are registered)
function Context.sortElementsByZIndex() function Context.sortElementsByZIndex()
-- Sort elements by z-index (lowest to highest) -- Sort elements by z-index (lowest to highest)
@@ -76,7 +93,13 @@ function Context.sortElementsByZIndex()
return z return z
end end
return getEffectiveZIndex(a) < getEffectiveZIndex(b) local za = getEffectiveZIndex(a)
local zb = getEffectiveZIndex(b)
if za ~= zb then
return za < zb
end
-- Tiebreaker: deeper elements (children) sort higher
return getElementDepth(a) < getElementDepth(b)
end) end)
end end
@@ -149,6 +172,7 @@ function Context.getTopElementAt(x, y)
return nil return nil
end end
local fallback = nil
for i = #Context._zIndexOrderedElements, 1, -1 do for i = #Context._zIndexOrderedElements, 1, -1 do
local element = Context._zIndexOrderedElements[i] local element = Context._zIndexOrderedElements[i]
@@ -157,11 +181,15 @@ function Context.getTopElementAt(x, y)
if interactive then if interactive then
return interactive return interactive
end end
return element -- Non-interactive element hit: remember as fallback but keep looking
-- for interactive children/siblings at same or lower z-index
if not fallback then
fallback = element
end
end end
end end
return nil return fallback
end end
--- Set the focused element (centralizes focus management) --- Set the focused element (centralizes focus management)

View File

@@ -159,6 +159,13 @@
---@field _pressed table? -- Internal: button press state tracking ---@field _pressed table? -- Internal: button press state tracking
---@field _mouseDownPosition number? -- Internal: mouse down position for drag tracking ---@field _mouseDownPosition number? -- Internal: mouse down position for drag tracking
---@field _textDragOccurred boolean? -- Internal: whether text drag occurred ---@field _textDragOccurred boolean? -- Internal: whether text drag occurred
---@field customDraw fun(element:Element)? -- Custom rendering callback called after standard rendering but before visual feedback (default: nil)
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Callback for touch-specific events
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent callback should be deferred (default: false)
---@field onGesture fun(element:Element, gesture:table)? -- Callback for recognized gestures
---@field onGestureDeferred boolean? -- Whether onGesture callback should be deferred (default: false)
---@field touchEnabled boolean -- Whether the element responds to touch events (default: true)
---@field multiTouchEnabled boolean -- Whether the element supports multiple simultaneous touches (default: false)
---@field animation table? -- Animation instance for this element ---@field animation table? -- Animation instance for this element
local Element = {} local Element = {}
Element.__index = Element Element.__index = Element
@@ -190,6 +197,7 @@ function Element.init(deps)
Element._StateManager = deps.StateManager Element._StateManager = deps.StateManager
Element._GestureRecognizer = deps.GestureRecognizer Element._GestureRecognizer = deps.GestureRecognizer
Element._Performance = deps.Performance Element._Performance = deps.Performance
Element._Animation = deps.Animation
end end
---@param props ElementProps ---@param props ElementProps
@@ -364,6 +372,14 @@ function Element.new(props)
self.customDraw = props.customDraw -- Custom rendering callback self.customDraw = props.customDraw -- Custom rendering callback
-- Touch event properties
self.onTouchEvent = props.onTouchEvent
self.onTouchEventDeferred = props.onTouchEventDeferred or false
self.onGesture = props.onGesture
self.onGestureDeferred = props.onGestureDeferred or false
self.touchEnabled = props.touchEnabled ~= false -- Default true
self.multiTouchEnabled = props.multiTouchEnabled or false -- Default false
-- Initialize state manager ID for immediate mode (use self.id which may be auto-generated) -- Initialize state manager ID for immediate mode (use self.id which may be auto-generated)
self._stateId = self.id self._stateId = self.id
@@ -371,6 +387,12 @@ function Element.new(props)
local eventHandlerConfig = { local eventHandlerConfig = {
onEvent = self.onEvent, onEvent = self.onEvent,
onEventDeferred = props.onEventDeferred, onEventDeferred = props.onEventDeferred,
onTouchEvent = self.onTouchEvent,
onTouchEventDeferred = self.onTouchEventDeferred,
onGesture = self.onGesture,
onGestureDeferred = self.onGestureDeferred,
touchEnabled = self.touchEnabled,
multiTouchEnabled = self.multiTouchEnabled,
} }
if self._elementMode == "immediate" and self._stateId and self._stateId ~= "" then if self._elementMode == "immediate" and self._stateId and self._stateId ~= "" then
local state = Element._StateManager.getState(self._stateId) local state = Element._StateManager.getState(self._stateId)
@@ -2018,6 +2040,33 @@ 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 deterministic color for element boundary visualization
-- Uses a hash of the element ID to produce a stable hue, so colors don't flash each frame
local function hashStringToHue(str)
local hash = 5381
for i = 1, #str do
hash = ((hash * 33) + string.byte(str, i)) % 360
end
return hash
end
local hue = hashStringToHue(self.id or tostring(self))
local function hslToRgb(h)
local s, l = 0.9, 0.55
local c = (1 - math.abs(2 * l - 1)) * s
local x = c * (1 - math.abs((h / 60) % 2 - 1))
local m = l - c / 2
local r, g, b
if h < 60 then r, g, b = c, x, 0
elseif h < 120 then r, g, b = x, c, 0
elseif h < 180 then r, g, b = 0, c, x
elseif h < 240 then r, g, b = 0, x, c
elseif h < 300 then r, g, b = x, 0, c
else r, g, b = c, 0, x end
return r + m, g + m, b + m
end
local dr, dg, db = hslToRgb(hue)
self._debugColor = { dr, dg, db }
return self return self
end end
@@ -2581,6 +2630,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 +3113,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 +3781,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 +3933,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 +3993,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 +4014,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 +4216,8 @@ function Element:_cleanup()
self.onEnter = nil self.onEnter = nil
self.onImageLoad = nil self.onImageLoad = nil
self.onImageError = nil self.onImageError = nil
self.onTouchEvent = nil
self.onGesture = nil
end end
return Element return Element

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

BIN
themes/metal/Guide.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B

38
themes/metal/README.md Normal file
View File

@@ -0,0 +1,38 @@
This image pack is not fully converted to 9 patch, it is being done as I have a need for.
- [x] Bar
- [x] BarDropdown
- [x] BarKnob
- [x] BarKnobDropdown
- [x] BarToggle
- [x] Button
- [ ] Cursor
- [ ] Filler
- [x] Frame
- [ ] IconConfirm
- [x] Select
- [ ] Slot
- [x] TextField
- [ ] Toggle
You can compare the BarKnob's for a before and after for the 9 patch additions
This theme is a part of the [Complete UI Essential Pack](https://crusenho.itch.io/complete-ui-essential-pack) by [Crusenho](https://crusenho.itch.io/)
This theme is governed by the following license:
Attribution 4.0 International (CC BY 4.0)
You are free to:
Share — copy and redistribute the material in any medium or format for any purpose, even commercially.
Adapt — remix, transform, and build upon the material for any purpose, even commercially.
The licensor cannot revoke these freedoms as long as you follow the license terms.
Under the following terms:
Attribution - You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
No additional restrictions - You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.
License Link: <https://creativecommons.org/licenses/by/4.0/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Some files were not shown because too many files have changed in this diff Show More