diff --git a/README.md b/README.md index feb9a4d..7f256e9 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ This library is under active development. While many features are functional, so ### Coming Soon The following features are currently being actively developed: - **Animations**(in progress): Simple to use animations for UI transitions and effects -- **Generic Image Support**(in progress): Enhanced image rendering capabilities and utilities - **Multi-touch Support**(on hold): Support for multi-touch events with additional parameters @@ -24,6 +23,7 @@ The following features are currently being actively developed: - **Theme System**: 9-patch (NinePatch) theming with state support (normal, hover, pressed, disabled) - **Android 9-Patch Auto-Parsing**: Automatic parsing of *.9.png files with multi-region support - **Animations**: Built-in animation support for transitions and effects +- **Image Support**: CSS-like object-fit, object-position, tiling/repeat modes, tinting, and opacity control - **Responsive Design**: Automatic resizing with viewport units (vw, vh, %) - **Color Handling**: Utility classes for managing colors in various formats - **Text Rendering**: Flexible text display with alignment and auto-scaling @@ -232,6 +232,15 @@ Common properties for all elements: textAlign = "start", -- "start", "center", "end" textSize = "md", -- Number or preset ("xs", "sm", "md", "lg", "xl", etc.) + -- Images + imagePath = "path/to/image.png", -- Path to image file (auto-loads) + image = imageObject, -- Or provide love.Image directly + objectFit = "fill", -- "fill", "contain", "cover", "scale-down", "none" + objectPosition = "center center", -- Image positioning (keywords or percentages) + imageRepeat = "no-repeat", -- "no-repeat", "repeat", "repeat-x", "repeat-y", "space", "round" + imageTint = Color.new(1, 1, 1, 1), -- Color tint overlay (default: white/no tint) + imageOpacity = 1, -- Image opacity 0-1 (combines with element opacity) + -- Theming theme = "space", -- Theme name themeComponent = "button", -- Component type from theme @@ -557,6 +566,56 @@ local customAnim = Animation.new({ customAnim:apply(element) ``` +### Images + +Display images with CSS-like object-fit and positioning: + +```lua +local Gui = FlexLove.Gui + +-- Basic image display +local imageBox = Gui.new({ + width = 200, + height = 200, + imagePath = "assets/photo.jpg", + objectFit = "cover", -- fill, contain, cover, scale-down, none + objectPosition = "center center", -- positioning within bounds + imageOpacity = 1.0, + imageTint = Color.new(1, 1, 1, 1), -- optional color tint +}) +``` + +**Object-fit modes:** +- `fill` - Stretch to fill (may distort) +- `contain` - Fit within bounds (preserves aspect ratio) +- `cover` - Cover bounds (preserves aspect ratio, may crop) +- `scale-down` - Use smaller of none or contain +- `none` - Natural size (no scaling) + +**Object-position examples:** +- `"center center"` - Center both axes +- `"top left"` - Top-left corner +- `"bottom right"` - Bottom-right corner +- `"50% 20%"` - Custom percentage positioning + +**Image tiling:** +```lua +{ + imagePath = "pattern.png", + imageRepeat = "repeat", -- repeat, repeat-x, repeat-y, no-repeat, space, round +} +``` + +**Image effects:** +```lua +{ + imageTint = Color.new(1, 0, 0, 1), -- Red tint overlay + imageOpacity = 0.8, -- 80% opacity +} +``` + +See `examples/image_showcase.lua` for a comprehensive demonstration of all image features. + ### Creating Colors ```lua diff --git a/RELEASE.md b/RELEASE.md index 28a63ae..d5ea8f0 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -148,7 +148,7 @@ The release package includes **only** the files needed to use FlexLöve: - `FlexLove.lua` - Main library - `modules/` - All module files - `LICENSE` - License terms -- `README.txt` - Installation instructions +- `README.md` - Installation instructions ❌ **Not included:** - `docs/` - Documentation (hosted on GitHub Pages) diff --git a/examples/image_showcase.lua b/examples/image_showcase.lua new file mode 100644 index 0000000..dbb7d37 --- /dev/null +++ b/examples/image_showcase.lua @@ -0,0 +1,279 @@ +-- Image Showcase Example +-- Demonstrates all image features in FlexLove + +local FlexLove = require("libs.FlexLove") +local Color = FlexLove.Color +-- I use this to avoid lsp warnings +local lv = love + +-- Set to immediate mode for this example +FlexLove.setMode("immediate") + +function lv.load() + -- Set window size + lv.window.setMode(1200, 800, { resizable = true }) + lv.window.setTitle("FlexLove Image Showcase") +end + +function lv.draw() + local container = FlexLove.new({ + width = "100vw", + height = "100vh", + flexDirection = "vertical", + padding = { top = 20, right = 20, bottom = 20, left = 20 }, + gap = 20, + backgroundColor = Color.new(0.95, 0.95, 0.95, 1), + }) + + -- Title + local title = FlexLove.new({ + parent = container, + text = "FlexLove Image Showcase", + textSize = "xxl", + textColor = Color.new(0.2, 0.2, 0.2, 1), + textAlign = "center", + padding = { top = 0, right = 0, bottom = 20, left = 0 }, + }) + + -- Section 1: Object-Fit Modes + local fitSection = FlexLove.new({ + parent = container, + flexDirection = "vertical", + gap = 10, + }) + + local fitTitle = FlexLove.new({ + parent = fitSection, + text = "Object-Fit Modes", + textSize = "lg", + textColor = Color.new(0.3, 0.3, 0.3, 1), + }) + + local fitRow = FlexLove.new({ + parent = fitSection, + flexDirection = "horizontal", + gap = 10, + justifyContent = "space-around", + }) + + local fitModes = { "fill", "contain", "cover", "scale-down", "none" } + for _, mode in ipairs(fitModes) do + local fitBox = FlexLove.new({ + parent = fitRow, + width = 180, + height = 120, + flexDirection = "vertical", + gap = 5, + backgroundColor = Color.new(1, 1, 1, 1), + cornerRadius = 8, + padding = { top = 10, right = 10, bottom = 10, left = 10 }, + }) + + local fitImage = FlexLove.new({ + parent = fitBox, + width = 160, + height = 80, + backgroundColor = Color.new(0.9, 0.9, 0.9, 1), + cornerRadius = 4, + imagePath = "sample.jpg", + objectFit = mode, + }) + + local fitLabel = FlexLove.new({ + parent = fitBox, + text = mode, + textSize = "sm", + textColor = Color.new(0.4, 0.4, 0.4, 1), + textAlign = "center", + }) + end + + local posSection = FlexLove.new({ + parent = container, + flexDirection = "vertical", + gap = 10, + }) + + local posTitle = FlexLove.new({ + parent = posSection, + text = "Object-Position", + textSize = "lg", + textColor = Color.new(0.3, 0.3, 0.3, 1), + }) + + local posRow = FlexLove.new({ + parent = posSection, + flexDirection = "horizontal", + gap = 10, + justifyContent = "space-around", + }) + + local positions = { "top left", "center center", "bottom right", "50% 20%", "left center" } + for _, pos in ipairs(positions) do + local posBox = FlexLove.new({ + parent = posRow, + width = 180, + height = 120, + flexDirection = "vertical", + gap = 5, + backgroundColor = Color.new(1, 1, 1, 1), + cornerRadius = 8, + padding = { top = 10, right = 10, bottom = 10, left = 10 }, + }) + + local posImage = FlexLove.new({ + parent = posBox, + width = 160, + height = 80, + backgroundColor = Color.new(0.9, 0.9, 0.9, 1), + cornerRadius = 4, + imagePath = "sample.jpg", + objectFit = "none", + objectPosition = pos, + }) + + local posLabel = FlexLove.new({ + parent = posBox, + text = pos, + textSize = "xs", + textColor = Color.new(0.4, 0.4, 0.4, 1), + textAlign = "center", + }) + end + + -- Section 3: Image Tiling/Repeat + local tileSection = FlexLove.new({ + parent = container, + flexDirection = "vertical", + gap = 10, + }) + + local tileTitle = FlexLove.new({ + parent = tileSection, + text = "Image Tiling (Repeat Modes)", + textSize = "lg", + textColor = Color.new(0.3, 0.3, 0.3, 1), + }) + + local tileRow = FlexLove.new({ + parent = tileSection, + flexDirection = "horizontal", + gap = 10, + justifyContent = "space-around", + }) + + local repeatModes = { "no-repeat", "repeat", "repeat-x", "repeat-y" } + for _, mode in ipairs(repeatModes) do + local tileBox = FlexLove.new({ + parent = tileRow, + width = 240, + height = 120, + flexDirection = "vertical", + gap = 5, + backgroundColor = Color.new(1, 1, 1, 1), + cornerRadius = 8, + padding = { top = 10, right = 10, bottom = 10, left = 10 }, + }) + + local tileImage = FlexLove.new({ + parent = tileBox, + width = 220, + height = 80, + backgroundColor = Color.new(0.9, 0.9, 0.9, 1), + cornerRadius = 4, + -- imagePath = "assets/pattern.png", -- Uncomment if you have a pattern image + imageRepeat = mode, + }) + + local tileLabel = FlexLove.new({ + parent = tileBox, + text = mode, + textSize = "sm", + textColor = Color.new(0.4, 0.4, 0.4, 1), + textAlign = "center", + }) + end + + -- Section 4: Image Tinting and Opacity + local tintSection = FlexLove.new({ + parent = container, + flexDirection = "vertical", + gap = 10, + }) + + local tintTitle = FlexLove.new({ + parent = tintSection, + text = "Image Tinting & Opacity", + textSize = "lg", + textColor = Color.new(0.3, 0.3, 0.3, 1), + }) + + local tintRow = FlexLove.new({ + parent = tintSection, + flexDirection = "horizontal", + gap = 10, + justifyContent = "space-around", + }) + + local tints = { + { name = "No Tint", color = nil, opacity = 1 }, + { name = "Red Tint", color = Color.new(1, 0.5, 0.5, 1), opacity = 1 }, + { name = "Blue Tint", color = Color.new(0.5, 0.5, 1, 1), opacity = 1 }, + { name = "50% Opacity", color = nil, opacity = 0.5 }, + { name = "Green + 70%", color = Color.new(0.5, 1, 0.5, 1), opacity = 0.7 }, + } + + for _, tint in ipairs(tints) do + local tintBox = FlexLove.new({ + parent = tintRow, + width = 180, + height = 120, + flexDirection = "vertical", + gap = 5, + backgroundColor = Color.new(1, 1, 1, 1), + cornerRadius = 8, + padding = { top = 10, right = 10, bottom = 10, left = 10 }, + }) + + local tintImage = FlexLove.new({ + parent = tintBox, + width = 160, + height = 80, + backgroundColor = Color.new(0.9, 0.9, 0.9, 1), + cornerRadius = 4, + imagePath = "sample.jpg", + imageTint = tint.color, + imageOpacity = tint.opacity, + }) + + local tintLabel = FlexLove.new({ + parent = tintBox, + text = tint.name, + textSize = "xs", + textColor = Color.new(0.4, 0.4, 0.4, 1), + textAlign = "center", + }) + end + + -- Footer note + local note = FlexLove.new({ + parent = container, + text = "Note: Uncomment imagePath properties in code to see actual images", + textSize = "xs", + textColor = Color.new(0.5, 0.5, 0.5, 1), + textAlign = "center", + padding = { top = 10, right = 0, bottom = 0, left = 0 }, + }) +end + +function lv.mousepressed(x, y, button) + FlexLove.mousepressed(x, y, button) +end + +function lv.mousereleased(x, y, button) + FlexLove.mousereleased(x, y, button) +end + +function lv.mousemoved(x, y, dx, dy) + FlexLove.mousemoved(x, y, dx, dy) +end diff --git a/examples/input_handling.lua b/examples/input_handling.lua index 57f3077..0ab5421 100644 --- a/examples/input_handling.lua +++ b/examples/input_handling.lua @@ -6,227 +6,227 @@ local FlexLove = require("libs.FlexLove") local InputExample = {} function InputExample:new() - local obj = { - -- State variables for input handling example - mousePosition = { x = 0, y = 0 }, - keyPressed = "", - touchPosition = { x = 0, y = 0 }, - isMouseOver = false, - hoverCount = 0 - } - setmetatable(obj, {__index = self}) - return obj + local obj = { + -- State variables for input handling example + mousePosition = { x = 0, y = 0 }, + keyPressed = "", + touchPosition = { x = 0, y = 0 }, + isMouseOver = false, + hoverCount = 0, + } + setmetatable(obj, { __index = self }) + return obj end function InputExample:render() - local flex = FlexLove.new({ - x = "10%", - y = "10%", - width = "80%", - height = "80%", - positioning = "flex", - flexDirection = "vertical", - gap = 10, - padding = { horizontal = 10, vertical = 10 }, - }) - - -- Title - FlexLove.new({ - parent = flex, - text = "Input Handling System Example", - textAlign = "center", - textSize = "2xl", - width = "100%", - height = "10%", - }) - - -- Mouse interaction section - local mouseSection = FlexLove.new({ - parent = flex, - positioning = "flex", - flexDirection = "horizontal", - justifyContent = "space-between", - alignItems = "center", - width = "100%", - height = "20%", - backgroundColor = "#2d3748", - borderRadius = 8, - padding = { horizontal = 15 }, - }) - - FlexLove.new({ - parent = mouseSection, - text = "Mouse Position: (" .. self.mousePosition.x .. ", " .. self.mousePosition.y .. ")", - textAlign = "left", - textSize = "md", - width = "60%", - }) - - -- Hoverable area - local hoverArea = FlexLove.new({ - parent = mouseSection, - positioning = "flex", - justifyContent = "center", - alignItems = "center", - width = "30%", - height = "100%", - backgroundColor = "#4a5568", - borderRadius = 8, - padding = { horizontal = 10 }, - onEvent = function(_, event) - if event.type == "mousemoved" then - self.mousePosition.x = event.x - self.mousePosition.y = event.y - elseif event.type == "mouseenter" then - self.isMouseOver = true - self.hoverCount = self.hoverCount + 1 - elseif event.type == "mouseleave" then - self.isMouseOver = false - end - end, - }) - - FlexLove.new({ - parent = hoverArea, - text = "Hover over me!", - textAlign = "center", - textSize = "md", - width = "100%", - height = "100%", - color = self.isMouseOver and "#48bb78" or "#a0aec0", -- Green when hovered - }) - - -- Keyboard input section - local keyboardSection = FlexLove.new({ - parent = flex, - positioning = "flex", - flexDirection = "horizontal", - justifyContent = "space-between", - alignItems = "center", - width = "100%", - height = "20%", - backgroundColor = "#4a5568", - borderRadius = 8, - padding = { horizontal = 15 }, - }) - - FlexLove.new({ - parent = keyboardSection, - text = "Last Key Pressed: " .. (self.keyPressed or "None"), - textAlign = "left", - textSize = "md", - width = "60%", - }) - - -- Input field for typing - local inputField = FlexLove.new({ - parent = keyboardSection, - themeComponent = "inputv2", - text = "", - textAlign = "left", - textSize = "md", - width = "30%", - onEvent = function(_, event) - if event.type == "textinput" then - self.keyPressed = event.text - elseif event.type == "keypressed" then - self.keyPressed = event.key - end - end, - }) - - -- Touch input section - local touchSection = FlexLove.new({ - parent = flex, - positioning = "flex", - flexDirection = "horizontal", - justifyContent = "space-between", - alignItems = "center", - width = "100%", - height = "20%", - backgroundColor = "#2d3748", - borderRadius = 8, - padding = { horizontal = 15 }, - }) - - FlexLove.new({ - parent = touchSection, - text = "Touch Position: (" .. self.touchPosition.x .. ", " .. self.touchPosition.y .. ")", - textAlign = "left", - textSize = "md", - width = "60%", - }) - - -- Touchable area - local touchArea = FlexLove.new({ - parent = touchSection, - positioning = "flex", - justifyContent = "center", - alignItems = "center", - width = "30%", - height = "100%", - backgroundColor = "#4a5568", - borderRadius = 8, - padding = { horizontal = 10 }, - onEvent = function(_, event) - if event.type == "touch" then - self.touchPosition.x = event.x - self.touchPosition.y = event.y - end - end, - }) - - FlexLove.new({ - parent = touchArea, - text = "Touch me!", - textAlign = "center", - textSize = "md", - width = "100%", - height = "100%", - }) - - -- Status section showing interaction counts - local statusSection = FlexLove.new({ - parent = flex, - positioning = "flex", - flexDirection = "horizontal", - justifyContent = "space-between", - alignItems = "center", - width = "100%", - height = "20%", - backgroundColor = "#4a5568", - borderRadius = 8, - padding = { horizontal = 15 }, - }) - - FlexLove.new({ - parent = statusSection, - text = "Hover Count: " .. self.hoverCount, - textAlign = "left", - textSize = "md", - width = "30%", - }) - - -- Reset button - FlexLove.new({ - parent = statusSection, - themeComponent = "buttonv2", - text = "Reset All", - textAlign = "center", - width = "30%", - onEvent = function(_, event) - if event.type == "release" then - self.mousePosition = { x = 0, y = 0 } - self.keyPressed = "" - self.touchPosition = { x = 0, y = 0 } - self.hoverCount = 0 - self.isMouseOver = false - print("All input states reset") - end - end, - }) - - return flex + local flex = FlexLove.new({ + x = "10%", + y = "10%", + width = "80%", + height = "80%", + positioning = "flex", + flexDirection = "vertical", + gap = 10, + padding = { horizontal = 10, vertical = 10 }, + }) + + -- Title + FlexLove.new({ + parent = flex, + text = "Input Handling System Example", + textAlign = "center", + textSize = "2xl", + width = "100%", + height = "10%", + }) + + -- Mouse interaction section + local mouseSection = FlexLove.new({ + parent = flex, + positioning = "flex", + flexDirection = "horizontal", + justifyContent = "space-between", + alignItems = "center", + width = "100%", + height = "20%", + backgroundColor = "#2d3748", + borderRadius = 8, + padding = { horizontal = 15 }, + }) + + FlexLove.new({ + parent = mouseSection, + text = "Mouse Position: (" .. self.mousePosition.x .. ", " .. self.mousePosition.y .. ")", + textAlign = "left", + textSize = "md", + width = "60%", + }) + + -- Hoverable area + local hoverArea = FlexLove.new({ + parent = mouseSection, + positioning = "flex", + justifyContent = "center", + alignItems = "center", + width = "30%", + height = "100%", + backgroundColor = "#4a5568", + borderRadius = 8, + padding = { horizontal = 10 }, + onEvent = function(_, event) + if event.type == "mousemoved" then + self.mousePosition.x = event.x + self.mousePosition.y = event.y + elseif event.type == "mouseenter" then + self.isMouseOver = true + self.hoverCount = self.hoverCount + 1 + elseif event.type == "mouseleave" then + self.isMouseOver = false + end + end, + }) + + FlexLove.new({ + parent = hoverArea, + text = "Hover over me!", + textAlign = "center", + textSize = "md", + width = "100%", + height = "100%", + color = self.isMouseOver and "#48bb78" or "#a0aec0", -- Green when hovered + }) + + -- Keyboard input section + local keyboardSection = FlexLove.new({ + parent = flex, + positioning = "flex", + flexDirection = "horizontal", + justifyContent = "space-between", + alignItems = "center", + width = "100%", + height = "20%", + backgroundColor = "#4a5568", + borderRadius = 8, + padding = { horizontal = 15 }, + }) + + FlexLove.new({ + parent = keyboardSection, + text = "Last Key Pressed: " .. (self.keyPressed or "None"), + textAlign = "left", + textSize = "md", + width = "60%", + }) + + -- Input field for typing + local inputField = FlexLove.new({ + parent = keyboardSection, + themeComponent = "inputv2", + text = "", + textAlign = "left", + textSize = "md", + width = "30%", + onEvent = function(_, event) + if event.type == "textinput" then + self.keyPressed = event.text + elseif event.type == "keypressed" then + self.keyPressed = event.key + end + end, + }) + + -- Touch input section + local touchSection = FlexLove.new({ + parent = flex, + positioning = "flex", + flexDirection = "horizontal", + justifyContent = "space-between", + alignItems = "center", + width = "100%", + height = "20%", + backgroundColor = "#2d3748", + borderRadius = 8, + padding = { horizontal = 15 }, + }) + + FlexLove.new({ + parent = touchSection, + text = "Touch Position: (" .. self.touchPosition.x .. ", " .. self.touchPosition.y .. ")", + textAlign = "left", + textSize = "md", + width = "60%", + }) + + -- Touchable area + local touchArea = FlexLove.new({ + parent = touchSection, + positioning = "flex", + justifyContent = "center", + alignItems = "center", + width = "30%", + height = "100%", + backgroundColor = "#4a5568", + borderRadius = 8, + padding = { horizontal = 10 }, + onEvent = function(_, event) + if event.type == "touch" then + self.touchPosition.x = event.x + self.touchPosition.y = event.y + end + end, + }) + + FlexLove.new({ + parent = touchArea, + text = "Touch me!", + textAlign = "center", + textSize = "md", + width = "100%", + height = "100%", + }) + + -- Status section showing interaction counts + local statusSection = FlexLove.new({ + parent = flex, + positioning = "flex", + flexDirection = "horizontal", + justifyContent = "space-between", + alignItems = "center", + width = "100%", + height = "20%", + backgroundColor = "#4a5568", + borderRadius = 8, + padding = { horizontal = 15 }, + }) + + FlexLove.new({ + parent = statusSection, + text = "Hover Count: " .. self.hoverCount, + textAlign = "left", + textSize = "md", + width = "30%", + }) + + -- Reset button + FlexLove.new({ + parent = statusSection, + themeComponent = "buttonv2", + text = "Reset All", + textAlign = "center", + width = "30%", + onEvent = function(_, event) + if event.type == "release" then + self.mousePosition = { x = 0, y = 0 } + self.keyPressed = "" + self.touchPosition = { x = 0, y = 0 } + self.hoverCount = 0 + self.isMouseOver = false + print("All input states reset") + end + end, + }) + + return flex end -return InputExample \ No newline at end of file +return InputExample diff --git a/examples/performance_example.lua b/examples/performance_example.lua deleted file mode 100644 index 5c08a5d..0000000 --- a/examples/performance_example.lua +++ /dev/null @@ -1,124 +0,0 @@ ---- Performance Monitoring Example ---- Demonstrates how to use the Performance module - -package.path = package.path .. ";./?.lua;./modules/?.lua" - --- Load love stub and Performance module -require("testing.loveStub") -local Performance = require("modules.Performance") - -print("=== Performance Module Example ===\n") - --- 1. Initialize and enable performance monitoring -print("1. Initializing Performance monitoring...") -Performance.init({ - enabled = true, - logToConsole = true, - logWarnings = true, -}) -print(" Enabled: " .. tostring(Performance.isEnabled())) -print() - --- 2. Test basic timer functionality -print("2. Testing timers...") -Performance.startTimer("test_operation") --- Simulate some work -local sum = 0 -for i = 1, 1000000 do - sum = sum + i -end -local elapsed = Performance.stopTimer("test_operation") -print(string.format(" Test operation completed in %.3fms", elapsed)) -print() - --- 3. Test measure wrapper -print("3. Testing measure wrapper...") -local expensiveFunction = function(n) - local result = 0 - for i = 1, n do - result = result + math.sqrt(i) - end - return result -end - -local measuredFunction = Performance.measure("expensive_calculation", expensiveFunction) -local result = measuredFunction(100000) -print(string.format(" Expensive calculation result: %.2f", result)) -print() - --- 4. Simulate frame timing -print("4. Simulating frame timing...") -for _ = 1, 10 do - Performance.startFrame() - - -- Simulate frame work - Performance.startTimer("frame_layout") - local layoutSum = 0 - for i = 1, 50000 do - layoutSum = layoutSum + i - end - Performance.stopTimer("frame_layout") - - Performance.startTimer("frame_render") - local renderSum = 0 - for i = 1, 30000 do - renderSum = renderSum + i - end - Performance.stopTimer("frame_render") - - Performance.endFrame() -end -print(string.format(" Simulated %d frames", 10)) -print() - --- 5. Get and display metrics -print("5. Performance Metrics:") -local metrics = Performance.getMetrics() -print(string.format(" FPS: %d", metrics.frame.fps)) -print(string.format(" Average Frame Time: %.3fms", metrics.frame.averageFrameTime)) -print(string.format(" Min/Max Frame Time: %.3f/%.3fms", metrics.frame.minFrameTime, metrics.frame.maxFrameTime)) -print(string.format(" Memory: %.2f MB (peak: %.2f MB)", metrics.memory.currentMb, metrics.memory.peakMb)) -print() - -print("6. Top Timings:") -for name, data in pairs(metrics.timings) do - print(string.format(" %s:", name)) - print(string.format(" Average: %.3fms", data.average)) - print(string.format(" Min/Max: %.3f/%.3fms", data.min, data.max)) - print(string.format(" Count: %d", data.count)) -end -print() - --- 7. Export metrics -print("7. Exporting metrics...") -local json = Performance.exportJSON() -print(" JSON Export:") -print(json) -print() - -local csv = Performance.exportCSV() -print(" CSV Export:") -print(csv) -print() - --- 8. Test warnings -print("8. Recent Warnings:") -local warnings = Performance.getWarnings(5) -if #warnings > 0 then - for _, warning in ipairs(warnings) do - print(string.format(" [%s] %s: %.3fms", warning.level, warning.name, warning.value)) - end -else - print(" No warnings") -end -print() - --- 9. Reset and verify -print("9. Testing reset...") -Performance.reset() -local newMetrics = Performance.getMetrics() -print(string.format(" Frame count after reset: %d", newMetrics.frame.frameCount)) -print(string.format(" Timings count after reset: %d", #newMetrics.timings)) -print() - -print("=== Performance Module Example Complete ===") diff --git a/examples/sample.jpg b/examples/sample.jpg new file mode 100644 index 0000000..1fe8e17 Binary files /dev/null and b/examples/sample.jpg differ diff --git a/examples/slider_example.lua b/examples/slider_example.lua index 52ece53..64f4511 100644 --- a/examples/slider_example.lua +++ b/examples/slider_example.lua @@ -1,8 +1,6 @@ --- Example demonstrating how to create sliders using FlexLove -- This example shows the implementation pattern used in SettingsMenu.lua local FlexLove = require("libs.FlexLove") -local Theme = FlexLove.Theme -local Color = FlexLove.Color local helpers = require("utils.helperFunctions") local round = helpers.round @@ -14,11 +12,11 @@ local instance ---@return SliderExample function SliderExample.init() - if instance == nil then - local self = setmetatable({}, SliderExample) - instance = self - end - return instance + if instance == nil then + local self = setmetatable({}, SliderExample) + instance = self + end + return instance end --- Create a slider control like in SettingsMenu @@ -29,131 +27,132 @@ end ---@param initial_value number? Initial value (defaults to min) ---@param display_multiplier number? Multiplier for display (e.g., 100 for percentage) function SliderExample:create_slider(parent, label, min, max, initial_value, display_multiplier) - display_multiplier = display_multiplier or 1 - initial_value = initial_value or min + display_multiplier = display_multiplier or 1 + initial_value = initial_value or min - local row = FlexLove.new({ - parent = parent, - width = "100%", - height = "5vh", - positioning = "flex", - flexDirection = "horizontal", - justifyContent = "space-between", - alignItems = "center", - gap = 10, - }) + local row = FlexLove.new({ + parent = parent, + width = "100%", + height = "5vh", + positioning = "flex", + flexDirection = "horizontal", + justifyContent = "space-between", + alignItems = "center", + gap = 10, + }) - -- Label - FlexLove.new({ - parent = row, - text = label, - textAlign = "start", - textSize = "md", - width = "30%", - }) + -- Label + FlexLove.new({ + parent = row, + text = label, + textAlign = "start", + textSize = "md", + width = "30%", + }) - local slider_container = FlexLove.new({ - parent = row, - width = "50%", - height = "100%", - positioning = "flex", - flexDirection = "horizontal", - alignItems = "center", - gap = 5, - }) + local slider_container = FlexLove.new({ + parent = row, + width = "50%", + height = "100%", + positioning = "flex", + flexDirection = "horizontal", + alignItems = "center", + gap = 5, + }) - local value = initial_value - local normalized = (value - min) / (max - min) + local value = initial_value + local normalized = (value - min) / (max - min) - local function convert_x_to_percentage(mx, parentX, parentWidth) - local val = (mx - parentX) / parentWidth - if val < 0.01 then - val = 0 - elseif val > 0.99 then - val = 1 - else - val = round(val, 2) - end - -- In a real app, you'd update the actual setting here - value = min + (val * (max - min)) - -- Update the display value - value_display.text = string.format("%d", value * display_multiplier) + local function convert_x_to_percentage(mx, parentX, parentWidth) + local val = (mx - parentX) / parentWidth + if val < 0.01 then + val = 0 + elseif val > 0.99 then + val = 1 + else + val = round(val, 2) end + -- In a real app, you'd update the actual setting here + value = min + (val * (max - min)) + -- Update the display value + value_display.text = string.format("%d", value * display_multiplier) + end - local slider_track = FlexLove.new({ - parent = slider_container, - width = "80%", - height = "75%", - positioning = "flex", - flexDirection = "horizontal", - themeComponent = "framev3", - onEvent = function(elem, event) - convert_x_to_percentage(event.x, elem.x, elem.width) - end, - }) + local slider_track = FlexLove.new({ + parent = slider_container, + width = "80%", + height = "75%", + positioning = "flex", + flexDirection = "horizontal", + themeComponent = "framev3", + onEvent = function(elem, event) + convert_x_to_percentage(event.x, elem.x, elem.width) + end, + }) - local fill_bar = FlexLove.new({ - parent = slider_track, - width = (normalized * 100) .. "%", - height = "100%", - themeComponent = "buttonv1", - onEvent = function(_, event) - convert_x_to_percentage(event.x, slider_track.x, slider_track.width) - end, - }) + local fill_bar = FlexLove.new({ + parent = slider_track, + width = (normalized * 100) .. "%", + height = "100%", + themeComponent = "buttonv1", + onEvent = function(_, event) + convert_x_to_percentage(event.x, slider_track.x, slider_track.width) + end, + }) - local value_display = FlexLove.new({ - parent = slider_container, - text = string.format("%d", value * display_multiplier), - textAlign = "center", - textSize = "md", - width = "15%", - }) + local value_display = FlexLove.new({ + parent = slider_container, + text = string.format("%d", value * display_multiplier), + textAlign = "center", + textSize = "md", + width = "15%", + }) end --- Create an example UI with multiple sliders function SliderExample:render_example() - -- Create a window for our example - local window = FlexLove.new({ - x = "10%", - y = "10%", - width = "80%", - height = "80%", - themeComponent = "framev3", - positioning = "flex", - flexDirection = "vertical", - justifySelf = "center", - justifyContent = "flex-start", - alignItems = "center", - scaleCorners = 3, - padding = { horizontal = "5%", vertical = "3%" }, - gap = 20, - }) + -- Create a window for our example + local window = FlexLove.new({ + x = "10%", + y = "10%", + width = "80%", + height = "80%", + themeComponent = "framev3", + positioning = "flex", + flexDirection = "vertical", + justifySelf = "center", + justifyContent = "flex-start", + alignItems = "center", + scaleCorners = 3, + padding = { horizontal = "5%", vertical = "3%" }, + gap = 20, + }) - FlexLove.new({ - parent = window, - text = "Slider Example", - textAlign = "center", - textSize = "3xl", - width = "100%", - margin = { top = "-4%", bottom = "4%" }, - }) + FlexLove.new({ + parent = window, + text = "Slider Example", + textAlign = "center", + textSize = "3xl", + width = "100%", + margin = { top = "-4%", bottom = "4%" }, + }) - -- Content container - local content = FlexLove.new({ - parent = window, - width = "100%", - height = "100%", - positioning = "flex", - flexDirection = "vertical", - padding = { top = "4%" }, - gap = 20, - }) + -- Content container + local content = FlexLove.new({ + parent = window, + width = "100%", + height = "100%", + positioning = "flex", + flexDirection = "vertical", + padding = { top = "4%" }, + gap = 20, + }) - -- Create a few example sliders - self:create_slider(content, "Volume", 0, 100, 75, 1) - self:create_slider(content, "Brightness", 0, 100, 50, 1) - self:create_slider(content, "Sensitivity", 0.1, 2.0, 1.0, 100) + -- Create a few example sliders + self:create_slider(content, "Volume", 0, 100, 75, 1) + self:create_slider(content, "Brightness", 0, 100, 50, 1) + self:create_slider(content, "Sensitivity", 0.1, 2.0, 1.0, 100) end -return SliderExample.init() \ No newline at end of file +return SliderExample.init() + diff --git a/examples/stateful_ui.lua b/examples/stateful_ui.lua index 6db4778..1e4baf6 100644 --- a/examples/stateful_ui.lua +++ b/examples/stateful_ui.lua @@ -244,4 +244,3 @@ function StatefulUIExample:render() end return StatefulUIExample - diff --git a/examples/theme_custom_components.lua b/examples/theme_custom_components.lua index da27ed2..7dc2644 100644 --- a/examples/theme_custom_components.lua +++ b/examples/theme_custom_components.lua @@ -141,4 +141,3 @@ function ThemeExample:render() end return ThemeExample - diff --git a/modules/Animation.lua b/modules/Animation.lua index b96cd01..3dc2952 100644 --- a/modules/Animation.lua +++ b/modules/Animation.lua @@ -46,22 +46,27 @@ local Easing = { } ---@class AnimationProps ---@field duration number Duration in seconds ----@field start {width?:number, height?:number, opacity?:number} Starting values ----@field final {width?:number, height?:number, opacity?:number} Final values +---@field start table Starting values (can contain: width, height, opacity, x, y, gap, imageOpacity, backgroundColor, borderColor, textColor, padding, margin, cornerRadius, etc.) +---@field final table Final values (same properties as start) ---@field easing string? Easing function name (default: "linear") ---@field transform table? Additional transform properties ---@field transition table? Transition properties +---@field onStart function? Called when animation starts: (animation, element) +---@field onUpdate function? Called each frame: (animation, element, progress) +---@field onComplete function? Called when animation completes: (animation, element) +---@field onCancel function? Called when animation is cancelled: (animation, element) ---@class Animation ---@field duration number Duration in seconds ----@field start {width?:number, height?:number, opacity?:number} Starting values ----@field final {width?:number, height?:number, opacity?:number} Final values +---@field start table Starting values +---@field final table Final values ---@field elapsed number Elapsed time in seconds ---@field easing EasingFunction Easing function ---@field transform table? Additional transform properties ---@field transition table? Transition properties ---@field _cachedResult table Cached interpolation result ---@field _resultDirty boolean Whether cached result needs recalculation +---@field _Color table? Reference to Color module (for lerp) local Animation = {} Animation.__index = Animation @@ -94,6 +99,19 @@ function Animation.new(props) self.transition = props.transition self.elapsed = 0 + -- Lifecycle callbacks + self.onStart = props.onStart + self.onUpdate = props.onUpdate + self.onComplete = props.onComplete + self.onCancel = props.onCancel + self._hasStarted = false + + -- Control state + self._paused = false + self._reversed = false + self._speed = 1.0 + self._state = "pending" -- "pending", "playing", "paused", "completed", "cancelled" + -- Validate and set easing function local easingName = props.easing or "linear" if type(easingName) == "string" then @@ -113,24 +131,140 @@ end ---Update the animation with delta time ---@param dt number Delta time in seconds +---@param element table? Optional element reference for callbacks ---@return boolean completed True if animation is complete -function Animation:update(dt) +function Animation:update(dt, element) -- Sanitize dt if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then dt = 0 end - self.elapsed = self.elapsed + dt - self._resultDirty = true - if self.elapsed >= self.duration then - return true - else + -- Don't update if paused + if self._paused then return false end + + -- Call onStart on first update + if not self._hasStarted then + self._hasStarted = true + self._state = "playing" + if self.onStart and type(self.onStart) == "function" then + local success, err = pcall(self.onStart, self, element) + if not success then + -- Log error but don't crash + print(string.format("[Animation] onStart error: %s", tostring(err))) + end + end + end + + -- Apply speed multiplier + dt = dt * self._speed + + -- Update elapsed time (reversed if needed) + if self._reversed then + self.elapsed = self.elapsed - dt + if self.elapsed <= 0 then + self.elapsed = 0 + self._state = "completed" + self._resultDirty = true + -- Call onComplete callback + if self.onComplete and type(self.onComplete) == "function" then + local success, err = pcall(self.onComplete, self, element) + if not success then + print(string.format("[Animation] onComplete error: %s", tostring(err))) + end + end + return true + end + else + self.elapsed = self.elapsed + dt + if self.elapsed >= self.duration then + self.elapsed = self.duration + self._state = "completed" + self._resultDirty = true + -- Call onComplete callback + if self.onComplete and type(self.onComplete) == "function" then + local success, err = pcall(self.onComplete, self, element) + if not success then + print(string.format("[Animation] onComplete error: %s", tostring(err))) + end + end + return true + end + end + + self._resultDirty = true + + -- Call onUpdate callback + if self.onUpdate and type(self.onUpdate) == "function" then + local progress = self.elapsed / self.duration + local success, err = pcall(self.onUpdate, self, element, progress) + if not success then + print(string.format("[Animation] onUpdate error: %s", tostring(err))) + end + end + + return false +end + +--- Helper function to interpolate numeric values +---@param startValue number Starting value +---@param finalValue number Final value +---@param easedT number Eased time (0-1) +---@return number interpolated Interpolated value +local function lerpNumber(startValue, finalValue, easedT) + return startValue * (1 - easedT) + finalValue * easedT +end + +--- Helper function to interpolate Color values +---@param startColor any Starting color (Color instance or parseable color) +---@param finalColor any Final color (Color instance or parseable color) +---@param easedT number Eased time (0-1) +---@param ColorModule table Color module reference +---@return any interpolated Interpolated Color instance +local function lerpColor(startColor, finalColor, easedT, ColorModule) + if not ColorModule then + return nil + end + + -- Parse colors if needed + local colorA = ColorModule.parse(startColor) + local colorB = ColorModule.parse(finalColor) + + return ColorModule.lerp(colorA, colorB, easedT) +end + +--- Helper function to interpolate table values (padding, margin, cornerRadius) +---@param startTable table Starting table +---@param finalTable table Final table +---@param easedT number Eased time (0-1) +---@return table interpolated Interpolated table +local function lerpTable(startTable, finalTable, easedT) + local result = {} + + -- Iterate through all keys in both tables + local keys = {} + for k in pairs(startTable) do keys[k] = true end + for k in pairs(finalTable) do keys[k] = true end + + for key in pairs(keys) do + local startVal = startTable[key] + local finalVal = finalTable[key] + + if type(startVal) == "number" and type(finalVal) == "number" then + result[key] = lerpNumber(startVal, finalVal, easedT) + elseif startVal ~= nil then + result[key] = startVal + else + result[key] = finalVal + end + end + + return result end ---Interpolate animation values at current time ----@return table result Interpolated values {width?, height?, opacity?, ...} +---@return table result Interpolated values {width?, height?, opacity?, x?, y?, backgroundColor?, ...} function Animation:interpolate() -- Return cached result if not dirty (avoids recalculation) if not self._resultDirty then @@ -146,27 +280,68 @@ function Animation:interpolate() end local result = self._cachedResult -- Reuse existing table - - result.width = nil - result.height = nil - result.opacity = nil - - -- Interpolate width if both start and final are valid numbers - if type(self.start.width) == "number" and type(self.final.width) == "number" then - result.width = self.start.width * (1 - easedT) + self.final.width * easedT + + -- Clear previous results + for k in pairs(result) do + result[k] = nil + end + + -- Define properties that should be animated as numbers + local numericProperties = { + "width", "height", "opacity", "x", "y", + "gap", "imageOpacity", "scrollbarWidth", + "borderWidth", "fontSize", "lineHeight" + } + + -- Define properties that should be animated as Colors + local colorProperties = { + "backgroundColor", "borderColor", "textColor", + "scrollbarColor", "scrollbarBackgroundColor", "imageTint" + } + + -- Define properties that should be animated as tables + local tableProperties = { + "padding", "margin", "cornerRadius" + } + + -- Interpolate numeric properties + for _, prop in ipairs(numericProperties) do + local startVal = self.start[prop] + local finalVal = self.final[prop] + + if type(startVal) == "number" and type(finalVal) == "number" then + result[prop] = lerpNumber(startVal, finalVal, easedT) + end + end + + -- Interpolate color properties (if Color module is available) + if self._Color then + for _, prop in ipairs(colorProperties) do + local startVal = self.start[prop] + local finalVal = self.final[prop] + + if startVal ~= nil and finalVal ~= nil then + result[prop] = lerpColor(startVal, finalVal, easedT, self._Color) + end + end + end + + -- Interpolate table properties + for _, prop in ipairs(tableProperties) do + local startVal = self.start[prop] + local finalVal = self.final[prop] + + if type(startVal) == "table" and type(finalVal) == "table" then + result[prop] = lerpTable(startVal, finalVal, easedT) + end end - -- Interpolate height if both start and final are valid numbers - if type(self.start.height) == "number" and type(self.final.height) == "number" then - result.height = self.start.height * (1 - easedT) + self.final.height * easedT + -- Interpolate transform property (if Transform module is available) + if self._Transform and self.start.transform and self.final.transform then + result.transform = self._Transform.lerp(self.start.transform, self.final.transform, easedT) end - -- Interpolate opacity if both start and final are valid numbers - if type(self.start.opacity) == "number" and type(self.final.opacity) == "number" then - result.opacity = self.start.opacity * (1 - easedT) + self.final.opacity * easedT - end - - -- Copy transform properties + -- Copy transform properties (legacy support) if self.transform and type(self.transform) == "table" then for key, value in pairs(self.transform) do result[key] = value @@ -186,6 +361,109 @@ function Animation:apply(element) element.animation = self end +--- Set Color module reference for color interpolation +---@param ColorModule table Color module +function Animation:setColorModule(ColorModule) + self._Color = ColorModule +end + +--- Set Transform module reference for transform interpolation +---@param TransformModule table Transform module +function Animation:setTransformModule(TransformModule) + self._Transform = TransformModule +end + +---Pause the animation +function Animation:pause() + if self._state == "playing" or self._state == "pending" then + self._paused = true + self._state = "paused" + end +end + +---Resume the animation +function Animation:resume() + if self._state == "paused" then + self._paused = false + self._state = "playing" + end +end + +---Check if animation is paused +---@return boolean paused +function Animation:isPaused() + return self._paused +end + +---Reverse the animation direction +function Animation:reverse() + self._reversed = not self._reversed +end + +---Check if animation is reversed +---@return boolean reversed +function Animation:isReversed() + return self._reversed +end + +---Set animation playback speed +---@param speed number Speed multiplier (1.0 = normal, 2.0 = double speed, 0.5 = half speed) +function Animation:setSpeed(speed) + if type(speed) == "number" and speed > 0 then + self._speed = speed + end +end + +---Get animation playback speed +---@return number speed Current speed multiplier +function Animation:getSpeed() + return self._speed +end + +---Seek to a specific time in the animation +---@param time number Time in seconds (clamped to 0-duration) +function Animation:seek(time) + if type(time) == "number" then + self.elapsed = math.max(0, math.min(time, self.duration)) + self._resultDirty = true + end +end + +---Get current animation state +---@return string state Current state: "pending", "playing", "paused", "completed", "cancelled" +function Animation:getState() + return self._state +end + +---Cancel the animation +---@param element table? Optional element reference for callback +function Animation:cancel(element) + if self._state ~= "cancelled" and self._state ~= "completed" then + self._state = "cancelled" + if self.onCancel and type(self.onCancel) == "function" then + local success, err = pcall(self.onCancel, self, element) + if not success then + print(string.format("[Animation] onCancel error: %s", tostring(err))) + end + end + end +end + +---Reset the animation to its initial state +function Animation:reset() + self.elapsed = 0 + self._hasStarted = false + self._paused = false + self._state = "pending" + self._resultDirty = true +end + +---Get the current progress of the animation +---@return number progress Progress from 0 to 1 +function Animation:getProgress() + return math.min(self.elapsed / self.duration, 1) +end + --- Create a simple fade animation ---@param duration number Duration in seconds ---@param fromOpacity number Starting opacity (0-1) diff --git a/modules/Color.lua b/modules/Color.lua index 7348cb1..e7ee7f8 100644 --- a/modules/Color.lua +++ b/modules/Color.lua @@ -425,6 +425,35 @@ function Color.parse(value) return Color.sanitizeColor(value, Color.new(0, 0, 0, 1)) end +--- Linear interpolation between two colors +---@param colorA Color Starting color +---@param colorB Color Ending color +---@param t number Interpolation factor (0-1) +---@return Color color Interpolated color +function Color.lerp(colorA, colorB, t) + -- Sanitize inputs + if type(colorA) ~= "table" or getmetatable(colorA) ~= Color then + colorA = Color.new(0, 0, 0, 1) + end + if type(colorB) ~= "table" or getmetatable(colorB) ~= Color then + colorB = Color.new(0, 0, 0, 1) + end + if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then + t = 0 + end + + -- Clamp t to 0-1 range + t = math.max(0, math.min(1, t)) + + -- Linear interpolation for each channel + local r = colorA.r * (1 - t) + colorB.r * t + local g = colorA.g * (1 - t) + colorB.g * t + local b = colorA.b * (1 - t) + colorB.b * t + local a = colorA.a * (1 - t) + colorB.a * t + + return Color.new(r, g, b, a) +end + -- Export ErrorHandler initializer Color.initializeErrorHandler = initializeErrorHandler diff --git a/modules/Element.lua b/modules/Element.lua index 2c08242..4759865 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -109,6 +109,8 @@ ---@field objectFit "fill"|"contain"|"cover"|"scale-down"|"none"? -- Image fit mode (default: "fill") ---@field objectPosition string? -- Image position like "center center", "top left", "50% 50%" (default: "center center") ---@field imageOpacity number? -- Image opacity 0-1 (default: 1, combines with element opacity) +---@field imageRepeat "no-repeat"|"repeat"|"repeat-x"|"repeat-y"|"space"|"round"? -- Image repeat/tiling mode (default: "no-repeat") +---@field imageTint Color? -- Color to tint the image (default: nil/white, no tint) ---@field _loadedImage love.Image? -- Internal: cached loaded image ---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control) ---@field userdata table? @@ -176,6 +178,7 @@ function Element.new(props, deps) ImageCache = deps.ImageCache, Theme = deps.Theme, Blur = deps.Blur, + Transform = deps.Transform, utils = deps.utils, } @@ -389,6 +392,9 @@ function Element.new(props, deps) -- Set visibility property (default: "visible") self.visibility = props.visibility or "visible" + -- Set transform property (optional) + self.transform = props.transform or nil + -- Handle cornerRadius (can be number or table) if props.cornerRadius then if type(props.cornerRadius) == "number" then @@ -451,6 +457,23 @@ function Element.new(props, deps) end self.imageOpacity = props.imageOpacity or 1 + -- Validate and set imageRepeat + if props.imageRepeat then + local validImageRepeat = { + ["no-repeat"] = "no-repeat", + ["repeat"] = "repeat", + ["repeat-x"] = "repeat-x", + ["repeat-y"] = "repeat-y", + space = "space", + round = "round" + } + self._deps.utils.validateEnum(props.imageRepeat, validImageRepeat, "imageRepeat") + end + self.imageRepeat = props.imageRepeat or "no-repeat" + + -- Set imageTint + self.imageTint = props.imageTint + -- Auto-load image if imagePath is provided if self.imagePath and not self.image then local loadedImage, err = self._deps.ImageCache.load(self.imagePath) @@ -483,6 +506,8 @@ function Element.new(props, deps) objectFit = self.objectFit, objectPosition = self.objectPosition, imageOpacity = self.imageOpacity, + imageRepeat = self.imageRepeat, + imageTint = self.imageTint, contentBlur = self.contentBlur, backdropBlur = self.backdropBlur, }, rendererDeps) @@ -2026,17 +2051,75 @@ function Element:update(dt) -- Update animation if exists if self.animation then - local finished = self.animation:update(dt) + -- Ensure animation has Color module reference for color interpolation + if not self.animation._Color and self._deps.Color then + self.animation:setColorModule(self._deps.Color) + end + + -- Ensure animation has Transform module reference for transform interpolation + if not self.animation._Transform and self._deps.Transform then + self.animation:setTransformModule(self._deps.Transform) + end + + local finished = self.animation:update(dt, self) if finished then + -- Animation:update() already called onComplete callback self.animation = nil -- remove finished animation else -- Apply animation interpolation during update local anim = self.animation:interpolate() + + -- Apply numeric properties self.width = anim.width or self.width self.height = anim.height or self.height self.opacity = anim.opacity or self.opacity - -- Update background color with interpolated opacity - if anim.opacity then + self.x = anim.x or self.x + self.y = anim.y or self.y + self.gap = anim.gap or self.gap + self.imageOpacity = anim.imageOpacity or self.imageOpacity + self.scrollbarWidth = anim.scrollbarWidth or self.scrollbarWidth + self.borderWidth = anim.borderWidth or self.borderWidth + self.fontSize = anim.fontSize or self.fontSize + self.lineHeight = anim.lineHeight or self.lineHeight + + -- Apply color properties + if anim.backgroundColor then + self.backgroundColor = anim.backgroundColor + end + if anim.borderColor then + self.borderColor = anim.borderColor + end + if anim.textColor then + self.textColor = anim.textColor + end + if anim.scrollbarColor then + self.scrollbarColor = anim.scrollbarColor + end + if anim.scrollbarBackgroundColor then + self.scrollbarBackgroundColor = anim.scrollbarBackgroundColor + end + if anim.imageTint then + self.imageTint = anim.imageTint + end + + -- Apply table properties + if anim.padding then + self.padding = anim.padding + end + if anim.margin then + self.margin = anim.margin + end + if anim.cornerRadius then + self.cornerRadius = anim.cornerRadius + end + + -- Apply transform property + if anim.transform then + self.transform = anim.transform + end + + -- Backward compatibility: Update background color with interpolated opacity + if anim.opacity and not anim.backgroundColor then self.backgroundColor.a = anim.opacity end end @@ -2741,4 +2824,85 @@ function Element:_trackActiveAnimations() end end +--- Set image tint color +---@param color Color Color to tint the image +function Element:setImageTint(color) + self.imageTint = color + if self._renderer then + self._renderer.imageTint = color + end +end + +--- Set image opacity +---@param opacity number Opacity 0-1 +function Element:setImageOpacity(opacity) + if opacity ~= nil then + self._deps.utils.validateRange(opacity, 0, 1, "imageOpacity") + end + self.imageOpacity = opacity + if self._renderer then + self._renderer.imageOpacity = opacity + end +end + +--- Set image repeat mode +---@param repeatMode string Repeat mode: "no-repeat", "repeat", "repeat-x", "repeat-y", "space", "round" +function Element:setImageRepeat(repeatMode) + local validImageRepeat = { + ["no-repeat"] = "no-repeat", + ["repeat"] = "repeat", + ["repeat-x"] = "repeat-x", + ["repeat-y"] = "repeat-y", + space = "space", + round = "round" + } + self._deps.utils.validateEnum(repeatMode, validImageRepeat, "imageRepeat") + self.imageRepeat = repeatMode + if self._renderer then + self._renderer.imageRepeat = repeatMode + end +end + +--- Rotate element by angle +---@param angle number Angle in radians +function Element:rotate(angle) + if not self.transform then + self.transform = self._deps.Transform.new({}) + end + self.transform.rotate = angle +end + +--- Scale element +---@param scaleX number X-axis scale +---@param scaleY number? Y-axis scale (defaults to scaleX) +function Element:scale(scaleX, scaleY) + if not self.transform then + self.transform = self._deps.Transform.new({}) + end + self.transform.scaleX = scaleX + self.transform.scaleY = scaleY or scaleX +end + +--- Translate element +---@param x number X translation +---@param y number Y translation +function Element:translate(x, y) + if not self.transform then + self.transform = self._deps.Transform.new({}) + end + self.transform.translateX = x + self.transform.translateY = y +end + +--- Set transform origin +---@param originX number X origin (0-1, where 0.5 is center) +---@param originY number Y origin (0-1, where 0.5 is center) +function Element:setTransformOrigin(originX, originY) + if not self.transform then + self.transform = self._deps.Transform.new({}) + end + self.transform.originX = originX + self.transform.originY = originY +end + return Element diff --git a/modules/ImageRenderer.lua b/modules/ImageRenderer.lua index f8d23df..74c9341 100644 --- a/modules/ImageRenderer.lua +++ b/modules/ImageRenderer.lua @@ -197,7 +197,8 @@ end ---@param fitMode string? -- Object-fit mode (default: "fill") ---@param objectPosition string? -- Object-position (default: "center center") ---@param opacity number? -- Opacity 0-1 (default: 1) -function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, opacity) +---@param tintColor Color? -- Color to tint the image (default: white/no tint) +function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, opacity, tintColor) if not image then return -- Nothing to draw end @@ -212,8 +213,12 @@ function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, -- Save current color local r, g, b, a = love.graphics.getColor() - -- Apply opacity - love.graphics.setColor(1, 1, 1, opacity) + -- Apply opacity and tint + if tintColor then + love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity) + else + love.graphics.setColor(1, 1, 1, opacity) + end -- Draw image if params.sx ~= 0 or params.sy ~= 0 or params.sw ~= imgWidth or params.sh ~= imgHeight then @@ -229,4 +234,139 @@ function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, love.graphics.setColor(r, g, b, a) end +--- Draw an image with tiling/repeat mode +---@param image love.Image -- Image to draw +---@param x number -- X position of bounds +---@param y number -- Y position of bounds +---@param width number -- Width of bounds +---@param height number -- Height of bounds +---@param repeatMode string? -- Repeat mode: "repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round" (default: "no-repeat") +---@param opacity number? -- Opacity 0-1 (default: 1) +---@param tintColor Color? -- Color to tint the image (default: white/no tint) +function ImageRenderer.drawTiled(image, x, y, width, height, repeatMode, opacity, tintColor) + if not image then + return -- Nothing to draw + end + + opacity = opacity or 1 + repeatMode = repeatMode or "no-repeat" + + local imgWidth, imgHeight = image:getDimensions() + + -- Save current color + local r, g, b, a = love.graphics.getColor() + + -- Apply opacity and tint + if tintColor then + love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity) + else + love.graphics.setColor(1, 1, 1, opacity) + end + + if repeatMode == "no-repeat" then + -- Just draw once, no tiling + love.graphics.draw(image, x, y) + elseif repeatMode == "repeat" then + -- Tile in both directions + local tilesX = math.ceil(width / imgWidth) + local tilesY = math.ceil(height / imgHeight) + + for tileY = 0, tilesY - 1 do + for tileX = 0, tilesX - 1 do + local drawX = x + (tileX * imgWidth) + local drawY = y + (tileY * imgHeight) + + -- Calculate how much of the tile to draw (for partial tiles at edges) + local drawWidth = math.min(imgWidth, width - (tileX * imgWidth)) + local drawHeight = math.min(imgHeight, height - (tileY * imgHeight)) + + if drawWidth < imgWidth or drawHeight < imgHeight then + -- Use quad for partial tile + local quad = love.graphics.newQuad(0, 0, drawWidth, drawHeight, imgWidth, imgHeight) + love.graphics.draw(image, quad, drawX, drawY) + else + -- Draw full tile + love.graphics.draw(image, drawX, drawY) + end + end + end + elseif repeatMode == "repeat-x" then + -- Tile horizontally only + local tilesX = math.ceil(width / imgWidth) + + for tileX = 0, tilesX - 1 do + local drawX = x + (tileX * imgWidth) + local drawWidth = math.min(imgWidth, width - (tileX * imgWidth)) + + if drawWidth < imgWidth then + -- Use quad for partial tile + local quad = love.graphics.newQuad(0, 0, drawWidth, imgHeight, imgWidth, imgHeight) + love.graphics.draw(image, quad, drawX, y) + else + -- Draw full tile + love.graphics.draw(image, drawX, y) + end + end + elseif repeatMode == "repeat-y" then + -- Tile vertically only + local tilesY = math.ceil(height / imgHeight) + + for tileY = 0, tilesY - 1 do + local drawY = y + (tileY * imgHeight) + local drawHeight = math.min(imgHeight, height - (tileY * imgHeight)) + + if drawHeight < imgHeight then + -- Use quad for partial tile + local quad = love.graphics.newQuad(0, 0, imgWidth, drawHeight, imgWidth, imgHeight) + love.graphics.draw(image, quad, x, drawY) + else + -- Draw full tile + love.graphics.draw(image, x, drawY) + end + end + elseif repeatMode == "space" then + -- Distribute tiles with even spacing + local tilesX = math.floor(width / imgWidth) + local tilesY = math.floor(height / imgHeight) + + if tilesX < 1 then tilesX = 1 end + if tilesY < 1 then tilesY = 1 end + + local spaceX = tilesX > 1 and (width - (tilesX * imgWidth)) / (tilesX - 1) or 0 + local spaceY = tilesY > 1 and (height - (tilesY * imgHeight)) / (tilesY - 1) or 0 + + for tileY = 0, tilesY - 1 do + for tileX = 0, tilesX - 1 do + local drawX = x + (tileX * (imgWidth + spaceX)) + local drawY = y + (tileY * (imgHeight + spaceY)) + love.graphics.draw(image, drawX, drawY) + end + end + elseif repeatMode == "round" then + -- Scale tiles to fit bounds exactly + local tilesX = math.max(1, math.round(width / imgWidth)) + local tilesY = math.max(1, math.round(height / imgHeight)) + + local scaleX = width / (tilesX * imgWidth) + local scaleY = height / (tilesY * imgHeight) + + for tileY = 0, tilesY - 1 do + for tileX = 0, tilesX - 1 do + local drawX = x + (tileX * imgWidth * scaleX) + local drawY = y + (tileY * imgHeight * scaleY) + love.graphics.draw(image, drawX, drawY, 0, scaleX, scaleY) + end + end + else + ErrorHandler.warn("ImageRenderer", "VAL_007", string.format("Invalid repeat mode: '%s'. Using 'no-repeat'", tostring(repeatMode)), { + repeatMode = repeatMode, + fallback = "no-repeat" + }) + love.graphics.draw(image, x, y) + end + + -- Restore color + love.graphics.setColor(r, g, b, a) +end + return ImageRenderer diff --git a/modules/Renderer.lua b/modules/Renderer.lua index dfa3d5e..dc2d5ac 100644 --- a/modules/Renderer.lua +++ b/modules/Renderer.lua @@ -35,7 +35,7 @@ local ErrorHandler --- Create a new Renderer instance ---@param config table Configuration table with rendering properties ----@param deps table Dependencies {Color, RoundedRect, NinePatch, ImageRenderer, ImageCache, Theme, Blur, utils} +---@param deps table Dependencies {Color, RoundedRect, NinePatch, ImageRenderer, ImageCache, Theme, Blur, Transform, utils} function Renderer.new(config, deps) local Color = deps.Color local ImageCache = deps.ImageCache @@ -50,6 +50,7 @@ function Renderer.new(config, deps) self._ImageCache = ImageCache self._Theme = deps.Theme self._Blur = deps.Blur + self._Transform = deps.Transform self._utils = deps.utils self._FONT_CACHE = deps.utils.FONT_CACHE self._TextAlign = deps.utils.enums.TextAlign @@ -90,6 +91,8 @@ function Renderer.new(config, deps) self.objectFit = config.objectFit or "fill" self.objectPosition = config.objectPosition or "center center" self.imageOpacity = config.imageOpacity or 1 + self.imageRepeat = config.imageRepeat or "no-repeat" + self.imageTint = config.imageTint -- Blur effects self.contentBlur = config.contentBlur @@ -193,8 +196,14 @@ function Renderer:_drawImage(x, y, paddingLeft, paddingTop, contentWidth, conten love.graphics.setStencilTest("greater", 0) end - -- Draw the image - self._ImageRenderer.draw(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.objectFit, self.objectPosition, finalOpacity) + -- Draw the image based on repeat mode + if self.imageRepeat and self.imageRepeat ~= "no-repeat" then + -- Use tiled rendering + self._ImageRenderer.drawTiled(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.imageRepeat, finalOpacity, self.imageTint) + else + -- Use standard fit-based rendering + self._ImageRenderer.draw(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.objectFit, self.objectPosition, finalOpacity, self.imageTint) + end -- Clear stencil if it was used if hasCornerRadius then @@ -347,6 +356,12 @@ function Renderer:draw(backdropCanvas) local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) local borderBoxHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom) + -- Apply transform if exists + local hasTransform = element.transform and self._Transform and not self._Transform.isIdentity(element.transform) + if hasTransform then + self._Transform.apply(element.transform, element.x, element.y, element.width, element.height) + end + -- LAYER 0.5: Draw backdrop blur if configured (before background) if self.backdropBlur and self.backdropBlur.intensity > 0 and backdropCanvas then local blurInstance = self:getBlurInstance() @@ -366,6 +381,11 @@ function Renderer:draw(backdropCanvas) -- LAYER 3: Draw borders on top of theme self:_drawBorders(element.x, element.y, borderBoxWidth, borderBoxHeight) + + -- Unapply transform if it was applied + if hasTransform then + self._Transform.unapply() + end -- Stop performance timing if Performance and Performance.isEnabled() and elementId then diff --git a/modules/Transform.lua b/modules/Transform.lua new file mode 100644 index 0000000..1f7da3a --- /dev/null +++ b/modules/Transform.lua @@ -0,0 +1,147 @@ +--- Transform module for 2D transformations (rotate, scale, translate, skew) +---@class Transform +---@field rotate number? Rotation in radians (default: 0) +---@field scaleX number? X-axis scale (default: 1) +---@field scaleY number? Y-axis scale (default: 1) +---@field translateX number? X translation in pixels (default: 0) +---@field translateY number? Y translation in pixels (default: 0) +---@field skewX number? X-axis skew in radians (default: 0) +---@field skewY number? Y-axis skew in radians (default: 0) +---@field originX number? Transform origin X (0-1, default: 0.5) +---@field originY number? Transform origin Y (0-1, default: 0.5) +local Transform = {} +Transform.__index = Transform + +--- Create a new transform instance +---@param props TransformProps? +---@return Transform transform +function Transform.new(props) + props = props or {} + + local self = setmetatable({}, Transform) + + self.rotate = props.rotate or 0 + self.scaleX = props.scaleX or 1 + self.scaleY = props.scaleY or 1 + self.translateX = props.translateX or 0 + self.translateY = props.translateY or 0 + self.skewX = props.skewX or 0 + self.skewY = props.skewY or 0 + self.originX = props.originX or 0.5 + self.originY = props.originY or 0.5 + + return self +end + +--- Apply transform to LÖVE graphics context +---@param transform Transform Transform instance +---@param x number Element x position +---@param y number Element y position +---@param width number Element width +---@param height number Element height +function Transform.apply(transform, x, y, width, height) + if not transform then + return + end + + -- Calculate transform origin + local ox = x + width * transform.originX + local oy = y + height * transform.originY + + -- Apply transform in correct order: translate → rotate → scale → skew + love.graphics.push() + love.graphics.translate(ox, oy) + + if transform.rotate ~= 0 then + love.graphics.rotate(transform.rotate) + end + + if transform.scaleX ~= 1 or transform.scaleY ~= 1 then + love.graphics.scale(transform.scaleX, transform.scaleY) + end + + if transform.skewX ~= 0 or transform.skewY ~= 0 then + love.graphics.shear(transform.skewX, transform.skewY) + end + + love.graphics.translate(-ox, -oy) + love.graphics.translate(transform.translateX, transform.translateY) +end + +--- Remove transform from LÖVE graphics context +function Transform.unapply() + love.graphics.pop() +end + +--- Interpolate between two transforms +---@param from Transform Starting transform +---@param to Transform Ending transform +---@param t number Interpolation factor (0-1) +---@return Transform interpolated +function Transform.lerp(from, to, t) + -- Sanitize inputs + if type(from) ~= "table" then + from = Transform.new() + end + if type(to) ~= "table" then + to = Transform.new() + end + if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then + t = 0 + end + + -- Clamp t to 0-1 range + t = math.max(0, math.min(1, t)) + + return Transform.new({ + rotate = (from.rotate or 0) * (1 - t) + (to.rotate or 0) * t, + scaleX = (from.scaleX or 1) * (1 - t) + (to.scaleX or 1) * t, + scaleY = (from.scaleY or 1) * (1 - t) + (to.scaleY or 1) * t, + translateX = (from.translateX or 0) * (1 - t) + (to.translateX or 0) * t, + translateY = (from.translateY or 0) * (1 - t) + (to.translateY or 0) * t, + skewX = (from.skewX or 0) * (1 - t) + (to.skewX or 0) * t, + skewY = (from.skewY or 0) * (1 - t) + (to.skewY or 0) * t, + originX = (from.originX or 0.5) * (1 - t) + (to.originX or 0.5) * t, + originY = (from.originY or 0.5) * (1 - t) + (to.originY or 0.5) * t, + }) +end + +--- Check if transform is identity (no transformation) +---@param transform Transform +---@return boolean isIdentity +function Transform.isIdentity(transform) + if not transform then + return true + end + + return transform.rotate == 0 + and transform.scaleX == 1 + and transform.scaleY == 1 + and transform.translateX == 0 + and transform.translateY == 0 + and transform.skewX == 0 + and transform.skewY == 0 +end + +--- Clone a transform +---@param transform Transform +---@return Transform clone +function Transform.clone(transform) + if not transform then + return Transform.new() + end + + return Transform.new({ + rotate = transform.rotate, + scaleX = transform.scaleX, + scaleY = transform.scaleY, + translateX = transform.translateX, + translateY = transform.translateY, + skewX = transform.skewX, + skewY = transform.skewY, + originX = transform.originX, + originY = transform.originY, + }) +end + +return Transform diff --git a/modules/types.lua b/modules/types.lua index 765f56b..477e090 100644 --- a/modules/types.lua +++ b/modules/types.lua @@ -125,3 +125,15 @@ local ElementProps = {} ---@field bottom boolean ---@field left boolean local Border = {} + +---@class TransformProps +---@field rotate number? Rotation in radians (default: 0) +---@field scaleX number? X-axis scale (default: 1) +---@field scaleY number? Y-axis scale (default: 1) +---@field translateX number? X translation in pixels (default: 0) +---@field translateY number? Y translation in pixels (default: 0) +---@field skewX number? X-axis skew in radians (default: 0) +---@field skewY number? Y-axis skew in radians (default: 0) +---@field originX number? Transform origin X (0-1, default: 0.5) +---@field originY number? Transform origin Y (0-1, default: 0.5) +local TransformProps diff --git a/modules/utils.lua b/modules/utils.lua index 8e4183b..fb124bd 100644 --- a/modules/utils.lua +++ b/modules/utils.lua @@ -64,6 +64,15 @@ local enums = { XL3 = "3xl", XL4 = "4xl", }, + ---@enum ImageRepeat + ImageRepeat = { + NO_REPEAT = "no-repeat", + REPEAT = "repeat", + REPEAT_X = "repeat-x", + REPEAT_Y = "repeat-y", + SPACE = "space", + ROUND = "round", + }, } --- Get current keyboard modifiers state diff --git a/scripts/create-release.sh b/scripts/create-release.sh index 51d6c2f..c5aa3b1 100755 --- a/scripts/create-release.sh +++ b/scripts/create-release.sh @@ -89,7 +89,7 @@ echo -e "Contents:" echo " - FlexLove.lua" echo " - modules/ (27 files)" echo " - LICENSE" -echo " - README.txt" +echo " - README.md" echo "" echo -e "Files created:" echo " - $OUTPUT_FILE" diff --git a/testing/__tests__/animation_properties_test.lua b/testing/__tests__/animation_properties_test.lua new file mode 100644 index 0000000..b9869e6 --- /dev/null +++ b/testing/__tests__/animation_properties_test.lua @@ -0,0 +1,536 @@ +local luaunit = require("testing.luaunit") +require("testing.loveStub") + +local Animation = require("modules.Animation") +local Color = require("modules.Color") + +TestAnimationProperties = {} + +function TestAnimationProperties:setUp() + -- Reset state before each test +end + +-- Test Color.lerp() method + +function TestAnimationProperties:testColorLerp_MidPoint() + local colorA = Color.new(0, 0, 0, 1) -- Black + local colorB = Color.new(1, 1, 1, 1) -- White + local result = Color.lerp(colorA, colorB, 0.5) + + luaunit.assertAlmostEquals(result.r, 0.5, 0.01) + luaunit.assertAlmostEquals(result.g, 0.5, 0.01) + luaunit.assertAlmostEquals(result.b, 0.5, 0.01) + luaunit.assertAlmostEquals(result.a, 1, 0.01) +end + +function TestAnimationProperties:testColorLerp_StartPoint() + local colorA = Color.new(1, 0, 0, 1) -- Red + local colorB = Color.new(0, 0, 1, 1) -- Blue + local result = Color.lerp(colorA, colorB, 0) + + luaunit.assertAlmostEquals(result.r, 1, 0.01) + luaunit.assertAlmostEquals(result.g, 0, 0.01) + luaunit.assertAlmostEquals(result.b, 0, 0.01) +end + +function TestAnimationProperties:testColorLerp_EndPoint() + local colorA = Color.new(1, 0, 0, 1) -- Red + local colorB = Color.new(0, 0, 1, 1) -- Blue + local result = Color.lerp(colorA, colorB, 1) + + luaunit.assertAlmostEquals(result.r, 0, 0.01) + luaunit.assertAlmostEquals(result.g, 0, 0.01) + luaunit.assertAlmostEquals(result.b, 1, 0.01) +end + +function TestAnimationProperties:testColorLerp_Alpha() + local colorA = Color.new(1, 1, 1, 0) -- Transparent white + local colorB = Color.new(1, 1, 1, 1) -- Opaque white + local result = Color.lerp(colorA, colorB, 0.5) + + luaunit.assertAlmostEquals(result.a, 0.5, 0.01) +end + +function TestAnimationProperties:testColorLerp_InvalidInputs() + -- Should handle invalid inputs gracefully + local result = Color.lerp("invalid", "invalid", 0.5) + luaunit.assertNotNil(result) + luaunit.assertEquals(getmetatable(result), Color) +end + +function TestAnimationProperties:testColorLerp_ClampT() + local colorA = Color.new(0, 0, 0, 1) + local colorB = Color.new(1, 1, 1, 1) + + -- Test t > 1 + local result1 = Color.lerp(colorA, colorB, 1.5) + luaunit.assertAlmostEquals(result1.r, 1, 0.01) + + -- Test t < 0 + local result2 = Color.lerp(colorA, colorB, -0.5) + luaunit.assertAlmostEquals(result2.r, 0, 0.01) +end + +-- Test Position Animation (x, y) + +function TestAnimationProperties:testPositionAnimation_XProperty() + local anim = Animation.new({ + duration = 1, + start = { x = 0 }, + final = { x = 100 }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertAlmostEquals(result.x, 50, 0.01) +end + +function TestAnimationProperties:testPositionAnimation_YProperty() + local anim = Animation.new({ + duration = 1, + start = { y = 0 }, + final = { y = 200 }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertAlmostEquals(result.y, 100, 0.01) +end + +function TestAnimationProperties:testPositionAnimation_XY() + local anim = Animation.new({ + duration = 1, + start = { x = 10, y = 20 }, + final = { x = 110, y = 220 }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertAlmostEquals(result.x, 60, 0.01) + luaunit.assertAlmostEquals(result.y, 120, 0.01) +end + +-- Test Color Property Animation + +function TestAnimationProperties:testColorAnimation_BackgroundColor() + local anim = Animation.new({ + duration = 1, + start = { backgroundColor = Color.new(1, 0, 0, 1) }, -- Red + final = { backgroundColor = Color.new(0, 0, 1, 1) }, -- Blue + }) + anim:setColorModule(Color) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertNotNil(result.backgroundColor) + luaunit.assertAlmostEquals(result.backgroundColor.r, 0.5, 0.01) + luaunit.assertAlmostEquals(result.backgroundColor.b, 0.5, 0.01) +end + +function TestAnimationProperties:testColorAnimation_MultipleColors() + local anim = Animation.new({ + duration = 1, + start = { + backgroundColor = Color.new(1, 0, 0, 1), + borderColor = Color.new(0, 1, 0, 1), + textColor = Color.new(0, 0, 1, 1), + }, + final = { + backgroundColor = Color.new(0, 1, 0, 1), + borderColor = Color.new(0, 0, 1, 1), + textColor = Color.new(1, 0, 0, 1), + }, + }) + anim:setColorModule(Color) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertNotNil(result.backgroundColor) + luaunit.assertNotNil(result.borderColor) + luaunit.assertNotNil(result.textColor) + + -- Mid-point should be (0.5, 0.5, 0.5) for backgroundColor + luaunit.assertAlmostEquals(result.backgroundColor.r, 0.5, 0.01) + luaunit.assertAlmostEquals(result.backgroundColor.g, 0.5, 0.01) +end + +function TestAnimationProperties:testColorAnimation_WithoutColorModule() + -- Should not interpolate colors without Color module set + local anim = Animation.new({ + duration = 1, + start = { backgroundColor = Color.new(1, 0, 0, 1) }, + final = { backgroundColor = Color.new(0, 0, 1, 1) }, + }) + -- Don't set Color module + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertNil(result.backgroundColor) +end + +function TestAnimationProperties:testColorAnimation_HexColors() + local anim = Animation.new({ + duration = 1, + start = { backgroundColor = "#FF0000" }, -- Red + final = { backgroundColor = "#0000FF" }, -- Blue + }) + anim:setColorModule(Color) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertNotNil(result.backgroundColor) + luaunit.assertAlmostEquals(result.backgroundColor.r, 0.5, 0.01) +end + +function TestAnimationProperties:testColorAnimation_NamedColors() + local anim = Animation.new({ + duration = 1, + start = { backgroundColor = "red" }, + final = { backgroundColor = "blue" }, + }) + anim:setColorModule(Color) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertNotNil(result.backgroundColor) + luaunit.assertAlmostEquals(result.backgroundColor.r, 0.5, 0.01) +end + +-- Test Numeric Property Animation + +function TestAnimationProperties:testNumericAnimation_Gap() + local anim = Animation.new({ + duration = 1, + start = { gap = 0 }, + final = { gap = 20 }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertAlmostEquals(result.gap, 10, 0.01) +end + +function TestAnimationProperties:testNumericAnimation_ImageOpacity() + local anim = Animation.new({ + duration = 1, + start = { imageOpacity = 0 }, + final = { imageOpacity = 1 }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertAlmostEquals(result.imageOpacity, 0.5, 0.01) +end + +function TestAnimationProperties:testNumericAnimation_BorderWidth() + local anim = Animation.new({ + duration = 1, + start = { borderWidth = 1 }, + final = { borderWidth = 10 }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertAlmostEquals(result.borderWidth, 5.5, 0.01) +end + +function TestAnimationProperties:testNumericAnimation_FontSize() + local anim = Animation.new({ + duration = 1, + start = { fontSize = 12 }, + final = { fontSize = 24 }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertAlmostEquals(result.fontSize, 18, 0.01) +end + +function TestAnimationProperties:testNumericAnimation_MultipleProperties() + local anim = Animation.new({ + duration = 1, + start = { gap = 0, imageOpacity = 0, borderWidth = 1 }, + final = { gap = 20, imageOpacity = 1, borderWidth = 5 }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertAlmostEquals(result.gap, 10, 0.01) + luaunit.assertAlmostEquals(result.imageOpacity, 0.5, 0.01) + luaunit.assertAlmostEquals(result.borderWidth, 3, 0.01) +end + +-- Test Table Property Animation (padding, margin, cornerRadius) + +function TestAnimationProperties:testTableAnimation_Padding() + local anim = Animation.new({ + duration = 1, + start = { padding = { top = 0, right = 0, bottom = 0, left = 0 } }, + final = { padding = { top = 10, right = 20, bottom = 10, left = 20 } }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertNotNil(result.padding) + luaunit.assertAlmostEquals(result.padding.top, 5, 0.01) + luaunit.assertAlmostEquals(result.padding.right, 10, 0.01) + luaunit.assertAlmostEquals(result.padding.bottom, 5, 0.01) + luaunit.assertAlmostEquals(result.padding.left, 10, 0.01) +end + +function TestAnimationProperties:testTableAnimation_Margin() + local anim = Animation.new({ + duration = 1, + start = { margin = { top = 0, right = 0, bottom = 0, left = 0 } }, + final = { margin = { top = 20, right = 20, bottom = 20, left = 20 } }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertNotNil(result.margin) + luaunit.assertAlmostEquals(result.margin.top, 10, 0.01) + luaunit.assertAlmostEquals(result.margin.right, 10, 0.01) +end + +function TestAnimationProperties:testTableAnimation_CornerRadius() + local anim = Animation.new({ + duration = 1, + start = { cornerRadius = { topLeft = 0, topRight = 0, bottomLeft = 0, bottomRight = 0 } }, + final = { cornerRadius = { topLeft = 10, topRight = 10, bottomLeft = 10, bottomRight = 10 } }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertNotNil(result.cornerRadius) + luaunit.assertAlmostEquals(result.cornerRadius.topLeft, 5, 0.01) + luaunit.assertAlmostEquals(result.cornerRadius.topRight, 5, 0.01) +end + +function TestAnimationProperties:testTableAnimation_PartialKeys() + -- Test when start and final have different keys + local anim = Animation.new({ + duration = 1, + start = { padding = { top = 0, left = 0 } }, + final = { padding = { top = 10, right = 20, left = 10 } }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertNotNil(result.padding) + luaunit.assertAlmostEquals(result.padding.top, 5, 0.01) + luaunit.assertAlmostEquals(result.padding.left, 5, 0.01) + luaunit.assertNotNil(result.padding.right) +end + +function TestAnimationProperties:testTableAnimation_NonNumericValues() + -- Should skip non-numeric values in tables + local anim = Animation.new({ + duration = 1, + start = { padding = { top = 0, special = "value" } }, + final = { padding = { top = 10, special = "value" } }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertNotNil(result.padding) + luaunit.assertAlmostEquals(result.padding.top, 5, 0.01) +end + +-- Test Combined Animations + +function TestAnimationProperties:testCombinedAnimation_AllTypes() + local anim = Animation.new({ + duration = 1, + start = { + width = 100, + height = 100, + x = 0, + y = 0, + opacity = 0, + backgroundColor = Color.new(1, 0, 0, 1), + gap = 0, + padding = { top = 0, left = 0 }, + }, + final = { + width = 200, + height = 200, + x = 100, + y = 100, + opacity = 1, + backgroundColor = Color.new(0, 0, 1, 1), + gap = 20, + padding = { top = 10, left = 10 }, + }, + }) + anim:setColorModule(Color) + + anim:update(0.5) + local result = anim:interpolate() + + -- Check all properties interpolated correctly + luaunit.assertAlmostEquals(result.width, 150, 0.01) + luaunit.assertAlmostEquals(result.height, 150, 0.01) + luaunit.assertAlmostEquals(result.x, 50, 0.01) + luaunit.assertAlmostEquals(result.y, 50, 0.01) + luaunit.assertAlmostEquals(result.opacity, 0.5, 0.01) + luaunit.assertAlmostEquals(result.gap, 10, 0.01) + luaunit.assertNotNil(result.backgroundColor) + luaunit.assertNotNil(result.padding) +end + +function TestAnimationProperties:testCombinedAnimation_WithEasing() + local anim = Animation.new({ + duration = 1, + start = { x = 0, backgroundColor = Color.new(0, 0, 0, 1) }, + final = { x = 100, backgroundColor = Color.new(1, 1, 1, 1) }, + easing = "easeInQuad", + }) + anim:setColorModule(Color) + + anim:update(0.5) + local result = anim:interpolate() + + -- With easeInQuad, at t=0.5, eased value should be 0.25 + luaunit.assertAlmostEquals(result.x, 25, 0.01) + luaunit.assertAlmostEquals(result.backgroundColor.r, 0.25, 0.01) +end + +-- Test Backward Compatibility + +function TestAnimationProperties:testBackwardCompatibility_WidthHeightOpacity() + -- Ensure old animations still work + local anim = Animation.new({ + duration = 1, + start = { width = 100, height = 100, opacity = 0 }, + final = { width = 200, height = 200, opacity = 1 }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertAlmostEquals(result.width, 150, 0.01) + luaunit.assertAlmostEquals(result.height, 150, 0.01) + luaunit.assertAlmostEquals(result.opacity, 0.5, 0.01) +end + +function TestAnimationProperties:testBackwardCompatibility_FadeHelper() + local anim = Animation.fade(1, 0, 1) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertAlmostEquals(result.opacity, 0.5, 0.01) +end + +function TestAnimationProperties:testBackwardCompatibility_ScaleHelper() + local anim = Animation.scale(1, { width = 100, height = 100 }, { width = 200, height = 200 }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertAlmostEquals(result.width, 150, 0.01) + luaunit.assertAlmostEquals(result.height, 150, 0.01) +end + +-- Test Edge Cases + +function TestAnimationProperties:testEdgeCase_MissingStartValue() + local anim = Animation.new({ + duration = 1, + start = { x = 0 }, + final = { x = 100, y = 100 }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertAlmostEquals(result.x, 50, 0.01) + luaunit.assertNil(result.y) -- Should be nil since start.y is missing +end + +function TestAnimationProperties:testEdgeCase_MissingFinalValue() + local anim = Animation.new({ + duration = 1, + start = { x = 0, y = 0 }, + final = { x = 100 }, + }) + + anim:update(0.5) + local result = anim:interpolate() + + luaunit.assertAlmostEquals(result.x, 50, 0.01) + luaunit.assertNil(result.y) -- Should be nil since final.y is missing +end + +function TestAnimationProperties:testEdgeCase_EmptyTables() + local anim = Animation.new({ + duration = 1, + start = {}, + final = {}, + }) + + anim:update(0.5) + local result = anim:interpolate() + + -- Should not error, just return empty result + luaunit.assertNotNil(result) +end + +function TestAnimationProperties:testEdgeCase_CachedResult() + -- Test that cached results work correctly + local anim = Animation.new({ + duration = 1, + start = { x = 0 }, + final = { x = 100 }, + }) + + anim:update(0.5) + local result1 = anim:interpolate() + local result2 = anim:interpolate() -- Should use cached result + + luaunit.assertEquals(result1, result2) -- Same table reference + luaunit.assertAlmostEquals(result1.x, 50, 0.01) +end + +function TestAnimationProperties:testEdgeCase_ResultInvalidatedOnUpdate() + local anim = Animation.new({ + duration = 1, + start = { x = 0 }, + final = { x = 100 }, + }) + + anim:update(0.5) + local result1 = anim:interpolate() + local x1 = result1.x -- Store value, not reference + + anim:update(0.25) -- Update again + local result2 = anim:interpolate() + local x2 = result2.x + + -- Should recalculate + -- Note: result1 and result2 are the same cached table, but values should be updated + luaunit.assertAlmostEquals(x1, 50, 0.01) + luaunit.assertAlmostEquals(x2, 75, 0.01) + -- result1.x will actually be 75 now since it's the same table reference + luaunit.assertAlmostEquals(result1.x, 75, 0.01) +end + +os.exit(luaunit.LuaUnit.run()) diff --git a/testing/__tests__/image_tiling_test.lua b/testing/__tests__/image_tiling_test.lua new file mode 100644 index 0000000..7f1b8c3 --- /dev/null +++ b/testing/__tests__/image_tiling_test.lua @@ -0,0 +1,405 @@ +-- Image Tiling Tests +-- Tests for ImageRenderer tiling functionality + +local luaunit = require("testing.luaunit") +require("testing.loveStub") + +local ImageRenderer = require("modules.ImageRenderer") +local ErrorHandler = require("modules.ErrorHandler") +local Color = require("modules.Color") + +-- Initialize ImageRenderer with ErrorHandler +ImageRenderer.init({ ErrorHandler = ErrorHandler }) + +TestImageTiling = {} + +function TestImageTiling:setUp() + -- Create a mock image + self.mockImage = { + getDimensions = function() return 64, 64 end, + type = function() return "Image" end, + } +end + +function TestImageTiling:tearDown() + self.mockImage = nil +end + +function TestImageTiling:testDrawTiledNoRepeat() + -- Test no-repeat mode (single image) + local drawCalls = {} + local originalDraw = love.graphics.draw + love.graphics.draw = function(...) + table.insert(drawCalls, {...}) + end + + ImageRenderer.drawTiled(self.mockImage, 100, 100, 200, 200, "no-repeat", 1, nil) + + -- Should draw once + luaunit.assertEquals(#drawCalls, 1) + luaunit.assertEquals(drawCalls[1][1], self.mockImage) + luaunit.assertEquals(drawCalls[1][2], 100) + luaunit.assertEquals(drawCalls[1][3], 100) + + love.graphics.draw = originalDraw +end + +function TestImageTiling:testDrawTiledRepeat() + -- Test repeat mode (tiles in both directions) + local drawCalls = {} + local originalDraw = love.graphics.draw + local originalNewQuad = love.graphics.newQuad + + love.graphics.draw = function(...) + table.insert(drawCalls, {...}) + end + + love.graphics.newQuad = function(...) + return { type = "quad", ... } + end + + -- Image is 64x64, bounds are 200x200 + -- Should tile 4 times (4 tiles total: 2x2 with partials) + ImageRenderer.drawTiled(self.mockImage, 100, 100, 200, 200, "repeat", 1, nil) + + -- 4 tiles: (0,0), (64,0), (0,64), (64,64) + -- 2 full tiles + 2 partial tiles = 4 draws + luaunit.assertTrue(#drawCalls >= 4) + + love.graphics.draw = originalDraw + love.graphics.newQuad = originalNewQuad +end + +function TestImageTiling:testDrawTiledRepeatX() + -- Test repeat-x mode (tiles horizontally only) + local drawCalls = {} + local originalDraw = love.graphics.draw + local originalNewQuad = love.graphics.newQuad + + love.graphics.draw = function(...) + table.insert(drawCalls, {...}) + end + + love.graphics.newQuad = function(...) + return { type = "quad", ... } + end + + -- Image is 64x64, bounds are 200x64 + -- Should tile 4 times horizontally: (0), (64), (128), (192) + ImageRenderer.drawTiled(self.mockImage, 100, 100, 200, 64, "repeat-x", 1, nil) + + -- 3 full tiles + 1 partial tile = 4 draws + luaunit.assertTrue(#drawCalls >= 3) + + love.graphics.draw = originalDraw + love.graphics.newQuad = originalNewQuad +end + +function TestImageTiling:testDrawTiledRepeatY() + -- Test repeat-y mode (tiles vertically only) + local drawCalls = {} + local originalDraw = love.graphics.draw + local originalNewQuad = love.graphics.newQuad + + love.graphics.draw = function(...) + table.insert(drawCalls, {...}) + end + + love.graphics.newQuad = function(...) + return { type = "quad", ... } + end + + -- Image is 64x64, bounds are 64x200 + -- Should tile 4 times vertically + ImageRenderer.drawTiled(self.mockImage, 100, 100, 64, 200, "repeat-y", 1, nil) + + -- 3 full tiles + 1 partial tile = 4 draws + luaunit.assertTrue(#drawCalls >= 3) + + love.graphics.draw = originalDraw + love.graphics.newQuad = originalNewQuad +end + +function TestImageTiling:testDrawTiledSpace() + -- Test space mode (distributes tiles with even spacing) + local drawCalls = {} + local originalDraw = love.graphics.draw + + love.graphics.draw = function(...) + table.insert(drawCalls, {...}) + end + + -- Image is 64x64, bounds are 200x200 + ImageRenderer.drawTiled(self.mockImage, 100, 100, 200, 200, "space", 1, nil) + + -- Should draw multiple tiles with spacing + luaunit.assertTrue(#drawCalls > 1) + + love.graphics.draw = originalDraw +end + +function TestImageTiling:testDrawTiledRound() + -- Test round mode (scales tiles to fit exactly) + local drawCalls = {} + local originalDraw = love.graphics.draw + + love.graphics.draw = function(...) + table.insert(drawCalls, {...}) + end + + -- Image is 64x64, bounds are 200x200 + ImageRenderer.drawTiled(self.mockImage, 100, 100, 200, 200, "round", 1, nil) + + -- Should draw tiles with scaling + luaunit.assertTrue(#drawCalls > 1) + + love.graphics.draw = originalDraw +end + +function TestImageTiling:testDrawTiledWithOpacity() + -- Test tiling with opacity + local setColorCalls = {} + local originalSetColor = love.graphics.setColor + + love.graphics.setColor = function(...) + table.insert(setColorCalls, {...}) + end + + ImageRenderer.drawTiled(self.mockImage, 100, 100, 200, 200, "no-repeat", 0.5, nil) + + -- Should set color with opacity + luaunit.assertTrue(#setColorCalls > 0) + -- Check that opacity 0.5 was used + local found = false + for _, call in ipairs(setColorCalls) do + if call[4] == 0.5 then + found = true + break + end + end + luaunit.assertTrue(found) + + love.graphics.setColor = originalSetColor +end + +function TestImageTiling:testDrawTiledWithTint() + -- Test tiling with tint color + local setColorCalls = {} + local originalSetColor = love.graphics.setColor + + love.graphics.setColor = function(...) + table.insert(setColorCalls, {...}) + end + + local redTint = Color.new(1, 0, 0, 1) + ImageRenderer.drawTiled(self.mockImage, 100, 100, 200, 200, "no-repeat", 1, redTint) + + -- Should set color with tint + luaunit.assertTrue(#setColorCalls > 0) + -- Check that red tint was used (r=1, g=0, b=0) + local found = false + for _, call in ipairs(setColorCalls) do + if call[1] == 1 and call[2] == 0 and call[3] == 0 then + found = true + break + end + end + luaunit.assertTrue(found) + + love.graphics.setColor = originalSetColor +end + +function TestImageTiling:testElementImageRepeatProperty() + -- Test that Element accepts imageRepeat property + local Element = require("modules.Element") + local utils = require("modules.utils") + local Color = require("modules.Color") + local Units = require("modules.Units") + local LayoutEngine = require("modules.LayoutEngine") + local Renderer = require("modules.Renderer") + local EventHandler = require("modules.EventHandler") + local ImageCache = require("modules.ImageCache") + + local deps = { + utils = utils, + Color = Color, + Units = Units, + LayoutEngine = LayoutEngine, + Renderer = Renderer, + EventHandler = EventHandler, + ImageCache = ImageCache, + ImageRenderer = ImageRenderer, + ErrorHandler = ErrorHandler, + } + + local element = Element.new({ + width = 200, + height = 200, + imageRepeat = "repeat", + }, deps) + + luaunit.assertEquals(element.imageRepeat, "repeat") +end + +function TestImageTiling:testElementImageRepeatDefault() + -- Test that imageRepeat defaults to "no-repeat" + local Element = require("modules.Element") + local utils = require("modules.utils") + local Color = require("modules.Color") + local Units = require("modules.Units") + local LayoutEngine = require("modules.LayoutEngine") + local Renderer = require("modules.Renderer") + local EventHandler = require("modules.EventHandler") + local ImageCache = require("modules.ImageCache") + + local deps = { + utils = utils, + Color = Color, + Units = Units, + LayoutEngine = LayoutEngine, + Renderer = Renderer, + EventHandler = EventHandler, + ImageCache = ImageCache, + ImageRenderer = ImageRenderer, + ErrorHandler = ErrorHandler, + } + + local element = Element.new({ + width = 200, + height = 200, + }, deps) + + luaunit.assertEquals(element.imageRepeat, "no-repeat") +end + +function TestImageTiling:testElementSetImageRepeat() + -- Test setImageRepeat method + local Element = require("modules.Element") + local utils = require("modules.utils") + local Color = require("modules.Color") + local Units = require("modules.Units") + local LayoutEngine = require("modules.LayoutEngine") + local Renderer = require("modules.Renderer") + local EventHandler = require("modules.EventHandler") + local ImageCache = require("modules.ImageCache") + + local deps = { + utils = utils, + Color = Color, + Units = Units, + LayoutEngine = LayoutEngine, + Renderer = Renderer, + EventHandler = EventHandler, + ImageCache = ImageCache, + ImageRenderer = ImageRenderer, + ErrorHandler = ErrorHandler, + } + + local element = Element.new({ + width = 200, + height = 200, + }, deps) + + element:setImageRepeat("repeat-x") + luaunit.assertEquals(element.imageRepeat, "repeat-x") +end + +function TestImageTiling:testElementImageTintProperty() + -- Test that Element accepts imageTint property + local Element = require("modules.Element") + local utils = require("modules.utils") + local Units = require("modules.Units") + local LayoutEngine = require("modules.LayoutEngine") + local Renderer = require("modules.Renderer") + local EventHandler = require("modules.EventHandler") + local ImageCache = require("modules.ImageCache") + + local redTint = Color.new(1, 0, 0, 1) + + local deps = { + utils = utils, + Color = Color, + Units = Units, + LayoutEngine = LayoutEngine, + Renderer = Renderer, + EventHandler = EventHandler, + ImageCache = ImageCache, + ImageRenderer = ImageRenderer, + ErrorHandler = ErrorHandler, + } + + local element = Element.new({ + width = 200, + height = 200, + imageTint = redTint, + }, deps) + + luaunit.assertEquals(element.imageTint, redTint) +end + +function TestImageTiling:testElementSetImageTint() + -- Test setImageTint method + local Element = require("modules.Element") + local utils = require("modules.utils") + local Units = require("modules.Units") + local LayoutEngine = require("modules.LayoutEngine") + local Renderer = require("modules.Renderer") + local EventHandler = require("modules.EventHandler") + local ImageCache = require("modules.ImageCache") + + local deps = { + utils = utils, + Color = Color, + Units = Units, + LayoutEngine = LayoutEngine, + Renderer = Renderer, + EventHandler = EventHandler, + ImageCache = ImageCache, + ImageRenderer = ImageRenderer, + ErrorHandler = ErrorHandler, + } + + local element = Element.new({ + width = 200, + height = 200, + }, deps) + + local blueTint = Color.new(0, 0, 1, 1) + element:setImageTint(blueTint) + luaunit.assertEquals(element.imageTint, blueTint) +end + +function TestImageTiling:testElementSetImageOpacity() + -- Test setImageOpacity method + local Element = require("modules.Element") + local utils = require("modules.utils") + local Color = require("modules.Color") + local Units = require("modules.Units") + local LayoutEngine = require("modules.LayoutEngine") + local Renderer = require("modules.Renderer") + local EventHandler = require("modules.EventHandler") + local ImageCache = require("modules.ImageCache") + + local deps = { + utils = utils, + Color = Color, + Units = Units, + LayoutEngine = LayoutEngine, + Renderer = Renderer, + EventHandler = EventHandler, + ImageCache = ImageCache, + ImageRenderer = ImageRenderer, + ErrorHandler = ErrorHandler, + } + + local element = Element.new({ + width = 200, + height = 200, + }, deps) + + element:setImageOpacity(0.7) + luaunit.assertEquals(element.imageOpacity, 0.7) +end + +-- Run the tests +os.exit(luaunit.LuaUnit.run()) diff --git a/testing/__tests__/transform_test.lua b/testing/__tests__/transform_test.lua new file mode 100644 index 0000000..d58c9ab --- /dev/null +++ b/testing/__tests__/transform_test.lua @@ -0,0 +1,292 @@ +local luaunit = require("testing.luaunit") +require("testing.loveStub") + +local Transform = require("modules.Transform") + +TestTransform = {} + +function TestTransform:setUp() + -- Reset state before each test +end + +-- Test Transform.new() + +function TestTransform:testNew_DefaultValues() + local transform = Transform.new() + + luaunit.assertNotNil(transform) + luaunit.assertEquals(transform.rotate, 0) + luaunit.assertEquals(transform.scaleX, 1) + luaunit.assertEquals(transform.scaleY, 1) + luaunit.assertEquals(transform.translateX, 0) + luaunit.assertEquals(transform.translateY, 0) + luaunit.assertEquals(transform.skewX, 0) + luaunit.assertEquals(transform.skewY, 0) + luaunit.assertEquals(transform.originX, 0.5) + luaunit.assertEquals(transform.originY, 0.5) +end + +function TestTransform:testNew_CustomValues() + local transform = Transform.new({ + rotate = math.pi / 4, + scaleX = 2, + scaleY = 3, + translateX = 100, + translateY = 200, + skewX = 0.1, + skewY = 0.2, + originX = 0, + originY = 1, + }) + + luaunit.assertAlmostEquals(transform.rotate, math.pi / 4, 0.01) + luaunit.assertEquals(transform.scaleX, 2) + luaunit.assertEquals(transform.scaleY, 3) + luaunit.assertEquals(transform.translateX, 100) + luaunit.assertEquals(transform.translateY, 200) + luaunit.assertAlmostEquals(transform.skewX, 0.1, 0.01) + luaunit.assertAlmostEquals(transform.skewY, 0.2, 0.01) + luaunit.assertEquals(transform.originX, 0) + luaunit.assertEquals(transform.originY, 1) +end + +function TestTransform:testNew_PartialValues() + local transform = Transform.new({ + rotate = math.pi, + scaleX = 2, + }) + + luaunit.assertAlmostEquals(transform.rotate, math.pi, 0.01) + luaunit.assertEquals(transform.scaleX, 2) + luaunit.assertEquals(transform.scaleY, 1) -- default + luaunit.assertEquals(transform.translateX, 0) -- default +end + +function TestTransform:testNew_EmptyProps() + local transform = Transform.new({}) + + -- Should use all defaults + luaunit.assertEquals(transform.rotate, 0) + luaunit.assertEquals(transform.scaleX, 1) + luaunit.assertEquals(transform.originX, 0.5) +end + +function TestTransform:testNew_NilProps() + local transform = Transform.new(nil) + + -- Should use all defaults + luaunit.assertEquals(transform.rotate, 0) + luaunit.assertEquals(transform.scaleX, 1) +end + +-- Test Transform.lerp() + +function TestTransform:testLerp_MidPoint() + local from = Transform.new({ rotate = 0, scaleX = 1, scaleY = 1 }) + local to = Transform.new({ rotate = math.pi, scaleX = 2, scaleY = 3 }) + + local result = Transform.lerp(from, to, 0.5) + + luaunit.assertAlmostEquals(result.rotate, math.pi / 2, 0.01) + luaunit.assertAlmostEquals(result.scaleX, 1.5, 0.01) + luaunit.assertAlmostEquals(result.scaleY, 2, 0.01) +end + +function TestTransform:testLerp_StartPoint() + local from = Transform.new({ rotate = 0, scaleX = 1 }) + local to = Transform.new({ rotate = math.pi, scaleX = 2 }) + + local result = Transform.lerp(from, to, 0) + + luaunit.assertAlmostEquals(result.rotate, 0, 0.01) + luaunit.assertAlmostEquals(result.scaleX, 1, 0.01) +end + +function TestTransform:testLerp_EndPoint() + local from = Transform.new({ rotate = 0, scaleX = 1 }) + local to = Transform.new({ rotate = math.pi, scaleX = 2 }) + + local result = Transform.lerp(from, to, 1) + + luaunit.assertAlmostEquals(result.rotate, math.pi, 0.01) + luaunit.assertAlmostEquals(result.scaleX, 2, 0.01) +end + +function TestTransform:testLerp_AllProperties() + local from = Transform.new({ + rotate = 0, + scaleX = 1, + scaleY = 1, + translateX = 0, + translateY = 0, + skewX = 0, + skewY = 0, + originX = 0, + originY = 0, + }) + + local to = Transform.new({ + rotate = math.pi, + scaleX = 2, + scaleY = 3, + translateX = 100, + translateY = 200, + skewX = 0.2, + skewY = 0.4, + originX = 1, + originY = 1, + }) + + local result = Transform.lerp(from, to, 0.5) + + luaunit.assertAlmostEquals(result.rotate, math.pi / 2, 0.01) + luaunit.assertAlmostEquals(result.scaleX, 1.5, 0.01) + luaunit.assertAlmostEquals(result.scaleY, 2, 0.01) + luaunit.assertAlmostEquals(result.translateX, 50, 0.01) + luaunit.assertAlmostEquals(result.translateY, 100, 0.01) + luaunit.assertAlmostEquals(result.skewX, 0.1, 0.01) + luaunit.assertAlmostEquals(result.skewY, 0.2, 0.01) + luaunit.assertAlmostEquals(result.originX, 0.5, 0.01) + luaunit.assertAlmostEquals(result.originY, 0.5, 0.01) +end + +function TestTransform:testLerp_InvalidInputs() + -- Should handle nil gracefully + local result = Transform.lerp(nil, nil, 0.5) + + luaunit.assertNotNil(result) + luaunit.assertEquals(result.rotate, 0) + luaunit.assertEquals(result.scaleX, 1) +end + +function TestTransform:testLerp_ClampT() + local from = Transform.new({ scaleX = 1 }) + local to = Transform.new({ scaleX = 2 }) + + -- Test t > 1 + local result1 = Transform.lerp(from, to, 1.5) + luaunit.assertAlmostEquals(result1.scaleX, 2, 0.01) + + -- Test t < 0 + local result2 = Transform.lerp(from, to, -0.5) + luaunit.assertAlmostEquals(result2.scaleX, 1, 0.01) +end + +function TestTransform:testLerp_InvalidT() + local from = Transform.new({ scaleX = 1 }) + local to = Transform.new({ scaleX = 2 }) + + -- Test NaN + local result1 = Transform.lerp(from, to, 0 / 0) + luaunit.assertAlmostEquals(result1.scaleX, 1, 0.01) -- Should default to 0 + + -- Test Infinity + local result2 = Transform.lerp(from, to, math.huge) + luaunit.assertAlmostEquals(result2.scaleX, 2, 0.01) -- Should clamp to 1 +end + +-- Test Transform.isIdentity() + +function TestTransform:testIsIdentity_True() + local transform = Transform.new() + luaunit.assertTrue(Transform.isIdentity(transform)) +end + +function TestTransform:testIsIdentity_Nil() + luaunit.assertTrue(Transform.isIdentity(nil)) +end + +function TestTransform:testIsIdentity_FalseRotate() + local transform = Transform.new({ rotate = 0.1 }) + luaunit.assertFalse(Transform.isIdentity(transform)) +end + +function TestTransform:testIsIdentity_FalseScale() + local transform = Transform.new({ scaleX = 2 }) + luaunit.assertFalse(Transform.isIdentity(transform)) +end + +function TestTransform:testIsIdentity_FalseTranslate() + local transform = Transform.new({ translateX = 10 }) + luaunit.assertFalse(Transform.isIdentity(transform)) +end + +function TestTransform:testIsIdentity_FalseSkew() + local transform = Transform.new({ skewX = 0.1 }) + luaunit.assertFalse(Transform.isIdentity(transform)) +end + +-- Test Transform.clone() + +function TestTransform:testClone_AllProperties() + local original = Transform.new({ + rotate = math.pi / 4, + scaleX = 2, + scaleY = 3, + translateX = 100, + translateY = 200, + skewX = 0.1, + skewY = 0.2, + originX = 0.25, + originY = 0.75, + }) + + local clone = Transform.clone(original) + + luaunit.assertAlmostEquals(clone.rotate, math.pi / 4, 0.01) + luaunit.assertEquals(clone.scaleX, 2) + luaunit.assertEquals(clone.scaleY, 3) + luaunit.assertEquals(clone.translateX, 100) + luaunit.assertEquals(clone.translateY, 200) + luaunit.assertAlmostEquals(clone.skewX, 0.1, 0.01) + luaunit.assertAlmostEquals(clone.skewY, 0.2, 0.01) + luaunit.assertAlmostEquals(clone.originX, 0.25, 0.01) + luaunit.assertAlmostEquals(clone.originY, 0.75, 0.01) + + -- Ensure it's a different object + luaunit.assertNotEquals(clone, original) +end + +function TestTransform:testClone_Nil() + local clone = Transform.clone(nil) + + luaunit.assertNotNil(clone) + luaunit.assertEquals(clone.rotate, 0) + luaunit.assertEquals(clone.scaleX, 1) +end + +function TestTransform:testClone_Mutation() + local original = Transform.new({ rotate = 0 }) + local clone = Transform.clone(original) + + -- Mutate clone + clone.rotate = math.pi + + -- Original should be unchanged + luaunit.assertEquals(original.rotate, 0) + luaunit.assertAlmostEquals(clone.rotate, math.pi, 0.01) +end + +-- Integration Tests + +function TestTransform:testTransformAnimation() + local Animation = require("modules.Animation") + local Transform = require("modules.Transform") + + local anim = Animation.new({ + duration = 1, + start = { transform = Transform.new({ rotate = 0, scaleX = 1 }) }, + final = { transform = Transform.new({ rotate = math.pi, scaleX = 2 }) }, + }) + + anim:setTransformModule(Transform) + anim:update(0.5) + + local result = anim:interpolate() + + luaunit.assertNotNil(result.transform) + luaunit.assertAlmostEquals(result.transform.rotate, math.pi / 2, 0.01) + luaunit.assertAlmostEquals(result.transform.scaleX, 1.5, 0.01) +end + +os.exit(luaunit.LuaUnit.run()) diff --git a/testing/runAll.lua b/testing/runAll.lua index 6465501..40845fd 100644 --- a/testing/runAll.lua +++ b/testing/runAll.lua @@ -18,6 +18,8 @@ local luaunit = require("testing.luaunit") -- Run all tests in the __tests__ directory local testFiles = { "testing/__tests__/animation_test.lua", + "testing/__tests__/animation_properties_test.lua", + "testing/__tests__/transform_test.lua", "testing/__tests__/blur_test.lua", "testing/__tests__/color_validation_test.lua", "testing/__tests__/element_test.lua",